diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aac27b7d2..2f4f9827b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,5 +50,8 @@ jobs: - name: Build (every package, topological) run: pnpm -r run build + - name: API docs (every package that defines them; warnings fail) + run: pnpm -r run docs + - name: Test (each package runs its own complete suite) run: pnpm -r run test diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index 8585882d7..aab571bb6 100644 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -31,28 +31,12 @@ jobs: - run: pnpm install --frozen-lockfile - - run: pnpm run build - working-directory: packages/data + - run: pnpm -r run build - - run: cp -r packages/data/dist packages/data/docs/dist + - run: pnpm -r run docs - - run: pnpm --filter @adobe/data-sync build - - - run: pnpm --filter @adobe/data-lit build - - - run: pnpm --filter @adobe/data-gpu build - - - run: pnpm --filter data-p2p-tictactoe build - env: - CI: true - - - run: cp -r packages/data-p2p-tictactoe/dist packages/data/docs/p2p-tictactoe - - - run: pnpm --filter data-gpu-samples build - env: - CI: true - - - run: cp -r packages/data-gpu-samples/dist packages/data/docs/gpu-samples + # Each package's package.json `pages` field says what it publishes where. + - run: node scripts/collect-pages.mjs site - uses: actions/configure-pages@v5 with: @@ -60,7 +44,7 @@ jobs: - uses: actions/upload-pages-artifact@v3 with: - path: packages/data/docs + path: site deploy: needs: build diff --git a/.gitignore b/.gitignore index 9e20317d4..039c012cb 100644 --- a/.gitignore +++ b/.gitignore @@ -47,6 +47,7 @@ source.txt # Generated Docs (typedoc output) docs/api packages/data/docs/api +packages/ecs/docs packages/data/references # Playwright MCP capture artifacts @@ -54,3 +55,4 @@ packages/data/references # Vitest browser attachments and failure screenshots .vitest/ +/site/ diff --git a/CLAUDE.md b/CLAUDE.md index 6f1c37349..15ee227d6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,6 +27,12 @@ Standard script names a package may define: that the uniform pipeline does not call. - **`lint`** — lint the package. - **`dev`** — local dev/watch. +- **`docs`** — generate the package's documentation (e.g. typedoc). What a + package publishes to the GitHub Pages site is its `pages` field: a list of + `{ "from", "to" }`, a folder of the package and its path on the site (`"."` + for the root). The docs workflow runs `pnpm -r run build` and + `pnpm -r run docs`, then `scripts/collect-pages.mjs` copies every package's + `pages`, so publishing never names a package. Do **not** add a `publish-public` (or similar) script. Publishing is governed *only* by the `private` field: `pnpm -r publish` publishes every package with diff --git a/packages/data-gpu-samples/package.json b/packages/data-gpu-samples/package.json index 0eb8c95d1..b6811ba77 100644 --- a/packages/data-gpu-samples/package.json +++ b/packages/data-gpu-samples/package.json @@ -4,6 +4,9 @@ "description": "WebGPU samples built on @adobe/data-gpu", "type": "module", "private": true, + "pages": [ + { "from": "dist", "to": "gpu-samples" } + ], "scripts": { "predev": "pnpm --filter @adobe/data-gpu build", "dev": "vite", diff --git a/packages/data-gpu-samples/vite.config.ts b/packages/data-gpu-samples/vite.config.ts index 11e224a87..e84a5469a 100644 --- a/packages/data-gpu-samples/vite.config.ts +++ b/packages/data-gpu-samples/vite.config.ts @@ -14,9 +14,9 @@ export default defineConfig({ }, }, root: ".", - // In production (GitHub Pages) the app lives at /data/graphics-samples/. + // In production (GitHub Pages) the app lives at /data/gpu-samples/, its `pages` target. // The dev server ignores this — it always serves from /. - base: process.env.CI ? "/data/graphics-samples/" : "/", + base: process.env.CI ? "/data/gpu-samples/" : "/", build: { outDir: "dist" }, server: { port: 3008, open: false }, }); diff --git a/packages/data-p2p-tictactoe/package.json b/packages/data-p2p-tictactoe/package.json index 161963532..0c06e3911 100644 --- a/packages/data-p2p-tictactoe/package.json +++ b/packages/data-p2p-tictactoe/package.json @@ -4,6 +4,9 @@ "description": "Serverless P2P tic-tac-toe — WebRTC DataChannel + @adobe/data-sync", "type": "module", "private": true, + "pages": [ + { "from": "dist", "to": "p2p-tictactoe" } + ], "scripts": { "dev": "vite", "build": "vite build", diff --git a/packages/data/docs/index.html b/packages/data/docs/index.html index c9bc838ed..f27563489 100644 --- a/packages/data/docs/index.html +++ b/packages/data/docs/index.html @@ -16,6 +16,7 @@

@adobe/data

diff --git a/packages/data/package.json b/packages/data/package.json index 3e640844d..bae33f235 100644 --- a/packages/data/package.json +++ b/packages/data/package.json @@ -5,6 +5,10 @@ "type": "module", "sideEffects": false, "private": false, + "pages": [ + { "from": "docs", "to": "." }, + { "from": "dist", "to": "dist" } + ], "publishConfig": { "registry": "https://registry.npmjs.org/", "access": "public" @@ -19,8 +23,8 @@ "scripts": { "build": "pnpm build-assembly && run-p build:*", "build:code": "tsc -b", - "build:apidocs": "typedoc", "typecheck": "pnpm build-assembly && tsc -b", + "docs": "typedoc", "build-assembly": "run-p asbuild:release", "clean": "rm -rf dist build node_modules", "clean-cache": "rm -rf node_modules/.cache", diff --git a/packages/ecs/CLAUDE.md b/packages/ecs/CLAUDE.md index 91009ef66..4e2f63b05 100644 --- a/packages/ecs/CLAUDE.md +++ b/packages/ecs/CLAUDE.md @@ -25,7 +25,8 @@ Humans own the interface and agents own the implementation: agents don't add to It's a project of its own: nothing in it imports from outside it. Each type exists once, there. - **`src/implementation`** holds `createWorld` and everything behind it, the kernel compiler included. It imports types and the registry's read side from `src/interface`. -- **`src/samples`** holds one folder per sample, its page included; `pnpm sample` serves them. +- **`src/samples`** holds one folder per sample, its page included. `pnpm dev` (or `pnpm sample`, without + the type-check watch) serves them, with a sample browser at the root that has a tab for each. - `src/index.ts` exports `src/interface`, and `World` as a value too, whose `create` and `join` are the implementation's, so `import { World } from "@adobe/ecs"` gives consumers the type, `World.create` and `World.join`, the only entry points. Samples and the guide use them; tests and benchmarks may take `createWorld` from @@ -44,6 +45,12 @@ when a measured win conflicts with a style or design preference, take the win an row as low as it can be, and allocate nothing on those paths: reuse arrays, buffers and objects, share read-only empties, and avoid closures, spreads and awaits there. Measure changes with `pnpm perf` against the recorded baseline. +## Pull requests + +Each PR costs a review, a merge and a publish, so keep one open PR at a time. Add new work and fixes to it as more +commits; never open stacked or parallel PRs. Keep it rebased on main, with this package's version one above main's. +Bump it by hand in `package.json`; never run the workspace `bump` script, which bumps packages this PR didn't change. + ## Memory Decisions about working on this package live in `memory.md`; add new ones there. diff --git a/packages/ecs/README.md b/packages/ecs/README.md index 619a1ed35..ec7e5e7b5 100644 --- a/packages/ecs/README.md +++ b/packages/ecs/README.md @@ -1,5 +1,54 @@ # @adobe/ecs -A data-oriented entity component system. Systems run on the main thread, on worker threads, as WASM kernels or on the GPU. +A high-performance, multithreaded Entity Component System for web-based applications, simulations and games. -See the [guide](src/guide/guide.md) for how to use it. +## Features + +- **Linear, gapless structure-of-arrays component storage** + - Fixed-length values (numbers, tuples, structs) in a `SharedArrayBuffer` + - Shared across the main thread and all workers + - Read by WASM kernels without copying + - WebGPU-compatible struct layout for fast uploads + - WebGPU storage for GPU system kernels + - Synced back to shared memory only when the CPU reads it + - Strings in shared memory, readable on every thread + - Objects and other variable-length values on the thread that owns them +- **Declarations** + - Components and resources declared once, as TypeScript types; the build registers them + - Named types such as `F32`, `U32` and your own `Schema.Type`s give storage precision, width and bounds +- **Systems** + - Plain TypeScript bodies with declared reads and writes + - Kernels compiled and checked at build time to optimized JavaScript, WASM and WGSL + - Balanced scheduling across the main thread, workers and the GPU + - Typed input, with its schema generated from the TypeScript type + - Render systems that draw straight from component storage + - Observer systems for reading state into a UI +- **Changes** + - Per-component added and changed ticks: an O(1) check per entity, with each change seen exactly once + - Filters that skip unchanged chunks and 64-row blocks + - Structural changes batched at sync points, so memory never moves while systems run + - Rules, finalizers and traits for derived components, cleanup and references +- **Queries** + - Hash, unique, sorted and keyless indexes, all updated incrementally + - Hierarchy traversal that reruns only below what changed +- **Threads** + - Worlds shared with other threads through a `MessagePort` or worker + - Joined threads run their own systems and observers, and lend their workers to the pool +- **Runtime** + - Kernels allocate nothing per row; commands, lookups and reports reuse their buffers + - Errors reported as data, in each run's report + +## Setup + +```ts +// vite.config.ts +import { Compiler } from "@adobe/ecs/compiler"; +export default defineConfig({ plugins: [Compiler.plugin()] }); +``` + +A library builds with `Compiler.plugin({ library: true })`, so its build ships the registrations its declarations give. + +## Guide + +See the [guide](src/guide/guide.md) for how to use it, and the [API reference](https://adobe.github.io/data/ecs/) for +every type and function. diff --git a/packages/ecs/design.md b/packages/ecs/design.md index da7d33e89..aa0ee28a5 100644 --- a/packages/ecs/design.md +++ b/packages/ecs/design.md @@ -27,8 +27,9 @@ Rule: anything that doesn't affect the API or correctness belongs in phase 2+. - Structure of Arrays component storage. - Numeric types, tuples and structs live in linear memory backed by `SharedArrayBuffer`, so workers can read and write them. +- **A union of number constants** (`0 | 1 | 2`, or an `as const` object's values) is a number: an integer column of the narrowest of u32 and i32 holding every constant, else f64, as with `-0`, which integers can't hold. A resource of one without a default starts at its first constant. A union with `null`, as `number | null` is, isn't a struct: it's stored as an object. - **Structs** (numbers, booleans, entities, fixed-length tuples and structs of those) have a fixed size and live in shared memory. **Strings** (any string, a union of string literals, either with `null`) live in shared memory too, so every thread reads them, and have no thread: a declaration giving one a `thread` is rejected, in types and at registration, and a string places no system. **Objects** (everything else: variable-length arrays, plain objects, DOM nodes, GPU objects, functions) live on one logical thread, `world` unless declared otherwise ([`threads.md`](threads.md)). - - **A string's storage:** the world's thread holds each value as it is, in a column of values, and reads and writes it there, as it does an object. While a joined thread may read strings, from the first `share` until the last thread sharing is gone, the world also keeps a record of each value in its memory, which a column of 4-byte handles names, 0 for `null` (`strings.ts`): its length in UTF-16 code units, a generation and its code units, immutable. A write makes a new record and lets the old one go; a record let go is freed at the end of the second sync point after, which no joined reader outlives: a task ends with its run, and a tail or an observer post, with its snapshot, before the second sync point after the one it reads. Two readers outlive that, so they hold records of their own: a joined thread's finalizer reads the strings its stops held from copies in the stop list, let go once the thread acknowledged them, and an observer system reading a string resource posts as row systems do, since a post holding copies of its resources is waited for by nothing. Records up to 512 bytes are carved from slabs by power-of-two size class, larger ones are the arena's blocks. A joined thread decodes a record once and keeps its text by handle, while the record's generation is the same. Without shared memory, which no other thread reads, strings are held only as values. + - **A string's storage:** the world's thread holds each value as it is, in a column of values, and reads and writes it there, as it does an object. While a joined thread may read strings, from the first `share` until the last thread sharing is gone, the world also keeps a record of each value in its memory, which a column of 4-byte handles names, 0 for `null` (`strings.ts`): its length in UTF-16 code units, a generation and its code units, immutable. A write makes a new record and lets the old one go; a record let go is freed at the end of the second sync point after, which no joined reader outlives: a task ends with its run, and a tail or an observer post, with its snapshot, before the second sync point after the one it reads. Two readers outlive that, so they hold records of their own: a joined thread's finalizer reads the strings its stops held, and the string resources it reads, from copies in the stop list, as it reads its linear resources, so every resource as of before its stops' sync point applied, let go once the thread acknowledged them, and an observer system reading a string resource posts as row systems do, since a post holding copies of its resources is waited for by nothing. Records up to 512 bytes are carved from slabs by power-of-two size class, larger ones are the arena's blocks. A joined thread decodes a record once and keeps its text by handle, while the record's generation is the same. Without shared memory, which no other thread reads, strings are held only as values. - **What reads and writes them:** any system reads strings, on any thread. Only the world's thread writes them: a system placed on a joined thread that writes one is rejected there, and a command on a joined thread gives one only its start. Kernels don't read them yet, by type and check. An index keyed by strings is kept on the world's thread, by their values as objects' are, so it's looked up only there. - Memory only reallocates at sync points. Structure is frozen between sync points, so workers never see memory move mid-phase. - Removed rows stay in place until Apply. At Apply the last row is swap-moved into the hole and the entity lookup table is updated. @@ -36,16 +37,16 @@ Rule: anything that doesn't affect the API or correctness belongs in phase 2+. ## Execution - **Systems are designed to run on workers.** That is a primary constraint, not an add-on. -- **A system's declared read/write sets drive both ordering and parallelism.** +- **A system's read/write sets, which the build infers from its body, drive both ordering and parallelism.** - **Systems that touch main-thread-only components are pinned to the main thread.** - **Structural changes become visible only at sync points,** whether they come from the main thread or from workers. -- **Component values are immutable values,** deeply, by type convention rather than freezing. A system reads a value and writes a whole new one (`row[position] = [x + vx, y + vy, z + vz]`); values are never edited in place. This is value semantics, as in shading languages: no value aliases another, so the compiler is free to lower a whole-value write to per-leaf stores, as a shader lowers a struct assignment. System source is ordinary TypeScript, type-checked normally. -- **Kernel systems (`kernel: "cpu"` or `"gpu"`) are compiled at build time.** A Vite/Rolldown plugin compiles them in its `transform` hook, after TS types are stripped and before minification, so names are never mangled and nothing is parsed at runtime. +- **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. - - From one parse it emits a compiled kernel (efficient JS; later WASM and WGSL) and the inferred read/write sets, which are checked against the declared signature both ways: a body can't use a key its declaration doesn't list, and a declaration can't list a key its body never uses, since that key would still narrow which entities match and hold back systems that could run alongside it. The same goes for a reduced resource the body never contributes to. Main-thread systems get the second check where their bodies index or destructure `row` and `context` only by module-scope keys, and only index or test lookup results; otherwise TypeScript's row and context types are the only check. - - **The plugin is required for kernels.** Registering a kernel system that wasn't compiled throws. - - **The plugin also gives keys ids** (`compiler/identify.ts`), in the same `transform`, after the kernel transform: each top-level `const`, `let` or `var` declarator, exported or not, initialized by a call of the global `Symbol`, and `export default Symbol(...)`, becomes `identify$(Symbol(...), "#")`, with a source map (`compiler/source-map.ts`). `` is the name of the nearest `package.json` above the module that has one and the module's path in that package, without a query, so a workspace-linked package names no directory outside it; without a named package, a dependency's package name and path inside `node_modules`, or the path from the root; never an absolute path; `#default` names a default export. A function appended to the module records the id in a `Map` under the global `Symbol.for("@adobe/ecs/ids@1")`, which the registry reads at registration, so the code imports nothing and nothing new is exported. Symbols made in functions, blocks, TypeScript namespaces, class fields or CommonJS, and modules where `Symbol` or `globalThis` is bound, aren't identified. In the same way, a top-level declarator initialized by a call of `System.create`, `System.render`, `Rule.create`, `Finalizer.create` or `Finalizer.changes`, with `System`, `Rule` or `Finalizer` imported from the package under any local name, and its `export default` form, gives the call's first argument, its declaration, a composite's list of members or a system's body alone, the id `"#"`: `identify$(declaration, id)` records it in the same map, and the constructor takes it out when it registers the item, so a declaration object reused elsewhere has no id. A kernel's call wrapped by the kernel transform is identified the same. The plugin adds itself to `worker.plugins`; a second instance in one bundle does nothing. - - **The application compiles every kernel,** its dependencies' too, against the one runtime it bundles. A library ships its kernels as source in its `dist`, uncompiled and unminified, so their code stays in the kernel subset and its errors stay readable. Non-kernel systems run as written on the main thread, through accessor rows over the same storage. + - **Access is inferred** (`compiler/body-access.ts`, `compiler/inferred.ts`): for every `System.create`, `System.render`, `Finalizer.create` and `Finalizer.changes` call with its declaration written in place (or passed through a wrapping call) and its body a function in place or bound by name at module scope, the build reads what the body reads and writes of its row, reads, writes and reduces into of resources, reads of each lookup's rows and of its parent through `up`, by module-scope keys and namespaces' members (`Report.run`). It wraps the declaration as `access$(declaration, { components, resources, lookup, up, at })`, recording that in a global `WeakMap` under `Symbol.for("@adobe/ecs/access@1")`, which the constructor takes out and joins with the declaration into the lists the world takes (`interface/system/resolve.ts`): a row's reads and writes, the rest of `with` as `has`, a lookup's reads, `up`'s reads. A kernel compiles from the same lists. A key a body uses that its declaration doesn't require fails the build, so matching never changes unseen; TypeScript rejects it too, typing rows over `with`. Where the build can't follow a row, every member is taken as written, with a build warning naming the module by its path in its package, line and column; an observer's is taken as read, and where it can't follow a lookup's rows, all it requires is taken as read, without a warning, since that is what their declarations say; a body whose resources or `up` it can't follow fails at registration, since every resource would place it on every thread. An internal factory making declarations at runtime records its own access first, which the build's doesn't replace; the implementation's own systems use `createResolved` with lists already resolved. + - **The plugin is required for kernels and input.** Registering a kernel system that wasn't compiled throws, as does a system whose body takes input without the schema the plugin gives. `Compiler.plugin()` returns its two parts: input schemas, before TypeScript is stripped, and kernels and ids, after. + - **The plugin also gives keys ids** (`compiler/identify.ts`), in the same `transform`, after the kernel transform: each top-level `const`, `let` or `var` declarator, exported or not, initialized by a call of the global `Symbol`, and `export default Symbol(...)`, becomes `identify$(Symbol(...), "#")`, with a source map (`compiler/source-map.ts`). `` is the name and version of the nearest `package.json` above the module that has a name, `name@version`, or `name` alone without a version, and the module's path in that package, without a query, so a workspace-linked package names no directory outside it and copies of a package at different versions never share ids; for a dependency without one, its package name and path inside `node_modules`; never an absolute path; `#default` names a default export. A module outside both that has anything to identify fails the build, so an id never depends on the build's root. A function appended to the module records the id in a `Map` under the global `Symbol.for("@adobe/ecs/ids@1")`, which the registry reads at registration, so the code imports nothing and nothing new is exported. Symbols made in functions, blocks, TypeScript namespaces, class fields or CommonJS, and modules where `Symbol` or `globalThis` is bound, aren't identified. In the same way, a top-level declarator initialized by a call of `System.create`, `System.render`, `Rule.create`, `Finalizer.create` or `Finalizer.changes`, with `System`, `Rule` or `Finalizer` imported from the package under any local name, and its `export default` form, gives the call's first argument, its declaration, a composite's list of members or a system's body alone, the id `"#"`: `identify$(declaration, id)` records it in the same map, and the constructor takes it out when it registers the item, so a declaration object reused elsewhere has no id. A kernel's call wrapped by the kernel transform is identified the same. The plugin adds itself to `worker.plugins`, and sets `worker.format` to `"es"` where the config doesn't, since kernels in worker bundles are separate chunks: that applies to every worker the app bundles, its own included, which load as module workers. A second instance in one bundle does nothing. Ids identify keys across threads and reloads; nothing persists them, and persisted data will need names that survive refactors and versions. + - **The application compiles every kernel,** its dependencies' too, against the one runtime it bundles. A library ships its kernels as source in its `dist`, uncompiled and unminified, so their code stays in the kernel subset and its errors stay readable. It builds with Vite and `Compiler.plugin({ library: true })`, which runs only the pass before TypeScript is stripped, so its `dist` ships the registrations and input schemas its types give, which an application's build can't read from JavaScript, while its kernels stay source and its keys get their ids from the application's plugin. A library built by `tsc` alone, and declarations in `.d.ts` files, register nothing. Non-kernel systems run as written on the main thread, through accessor rows over the same storage. - **Workers:** - The plugin emits each kernel as its own chunk and attaches its `new URL(..., import.meta.url)` reference to the system. Served, a kernel's url is the dev server's `/@ecs-kernel/…` module, which a page and its workers import from it; served to node, as tests run, it's the kernel's module as a `data:` url, which a pool's `worker_threads` import without the server. - On registration, workers `import()` each kernel and cache it by the world's index for its module's url, so new values of one kernel, as a module evaluated again makes, share one entry and workers' caches are bounded by code. @@ -55,8 +56,8 @@ Rule: anything that doesn't affect the API or correctness belongs in phase 2+. - **`SharedArrayBuffer` requires cross-origin isolation** (COOP/COEP headers). Without it, every system runs on the main thread. - **Subset errors** are reported at build time. An ESLint rule shares the same validator, so errors also show in the editor. - **GPU systems.** The built-in `gpuDevice` resource, `GPUDevice | null`, is the one device the world's GPU storage lives on, for compute and render alike: GPU buffers belong to one device, so a second device means a second world. It's an ordinary resource, given as `World.create({ gpuDevice: device })` or set later by a main-thread system; the world never destroys it. After each run the world follows it: a new device gets the GPU storage and `kernel: "gpu"` systems, after what's ahead on the old device is read back; with null, GPU kernels run on the CPU again. Render systems run only while there's a device, as if their `is` listed `gpuDevice`. A lost device makes the world set `gpuDevice` to null, as its own sync point, so nothing fails: GPU kernels run on the CPU from the CPU's copies, render systems are skipped, and systems watching `changed: [gpuDevice]` see it; GPU writes not yet read back are lost with the device. - - **`kernel: "gpu"` declares a GPU kernel.** It runs as a WGSL compute shader compiled from the same Program IR as WASM in a world with a device, and as a `"cpu"` kernel in one without, so it stays portable. Each rule is checked as early as it can be known: types rule out lookups, change filters and commands; the build rejects loops and anything that keeps the body from lowering to a Program; registration rejects f64 or byte values, in every world, since such a declaration can never hold. - - **Render systems** (`System.render`) run once per run on the main thread in schedule order, read-only. They record into the run's encoder over the GPU copies of the components their accessors name, which are what they read, one table per matched archetype. `Gpu.wgsl(system)` returns their shaders' declarations: the world's bind group at the group they name and a function per component (`fn position(row: u32) -> vec3`), from schemas alone, so shaders can be written before a world exists. Pipelines take `Gpu.layout(device)`, one object per device, so they can be built before a world exists; bind groups and buffers stay the world's, so they can be replaced as storage grows. + - **`kernel: "gpu"` declares a GPU kernel.** It runs as a WGSL compute shader compiled from the same Program IR as WASM in a world with a device, and as a kernel on the pool in one without, so it stays portable. Each rule is checked as early as it can be known: types rule out lookups, change filters and commands; the build rejects loops and anything that keeps the body from lowering to a Program; registration rejects f64 or byte values, in every world, since such a declaration can never hold. + - **Render systems** (`System.render`) run once per run on the main thread in schedule order, read-only. They record into the run's encoder over the GPU copies of the components their accessors name, which are what they read, one table per matched archetype; with `order`, a sorted index, one table of every matching row in its order, whose accessors resolve an instance's position through an order block of slot and row to its archetype's columns. `Gpu.wgsl(system)` returns their shaders' declarations: the world's bind group at the group they name and a function per component (`fn position(row: u32) -> vec3`), from schemas alone, so shaders can be written before a world exists. Pipelines take `Gpu.layout(device)`, one object per device, so they can be built before a world exists; bind groups and buffers stay the world's, so they can be replaced as storage grows. - **Residency:** a separate GPU heap holds copies of only the storages GPU systems use. Each copy tracks rows the CPU changed since its upload and rows the GPU wrote since its readback. GPU systems upload what the CPU changed first, in runs of the chunks it stamped since the last upload: the components GPU systems use keep ticks, and kernels writing them keep their chunk stamps. CPU systems read back what the GPU wrote first. A sync point reads back only archetypes whose existing rows it moves or writes (despawns, adds, removes); spawns only append rows, which are uploaded alone, and a copy that outgrows its block moves on the GPU. GPU-to-GPU chains and rendering never copy back. - **Change filters:** a GPU kernel's writes stamp each written column once with the run's tick, and `changed` filters pass a whole column when its stamp is newer than their `lastRun`, so writes on the GPU cost nothing per row on the CPU. - **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. @@ -70,25 +71,31 @@ Rule: anything that doesn't affect the API or correctness belongs in phase 2+. - **`Changed` is conservative:** it never misses a change but may report a component whose value is unchanged. Consumers must treat it as "may have changed", which keeps early cutoff a pure optimization. - **System bodies can't carry state from one row to the next.** Cross-row results (sums, counts) need an explicit reduction construct, so work can later be split without changing semantics. - **Systems, rules and finalizers are identified by the values their constructors return,** never by name; across threads a kernel is its module and index. Closures can't cross threads, so configuration is passed as data. -- **Public types are only those a consumer writes:** the interfaces to augment (`Components`, `Resources`, `Traits`, `Threads`), `Entity`, `Schema`, the math types, `Component.Key`, `Value` and `With`, `Resource.Key` and `Value`, `System`, `System.Commands`, `Finalizer`, `World`, and `Report` with `Report.Failure` and `Report.Warning`. `Archetype` and `Rule`, as `Component`, `Resource`, `Trait` and `Gpu`, name only namespaces. The rest are inferred from the values the API returns: a thread's name is `keyof Threads`, the cadence's value `Resource.Value`, a rule `ReturnType`, and an archetype any readonly list of components. Every type those values carry is named from the package root, so declarations a library emits for them name no internal module. +- **Public types are only those a consumer writes:** the interfaces to augment (`Components`, `Resources`, `Traits`, `Threads`), `Entity`, `Schema`, the math types, `Component.Key`, `Value` and `With`, `Resource.Key` and `Value`, `System`, `System.Commands`, `Finalizer`, `World`, and `Report` with `Report.Failure` and `Report.Warning`. `Rule`, as `Component`, `Resource` and `Gpu`, names only a namespace. The rest are inferred from the values the API returns: a thread's name is `keyof Threads`, the cadence's value `Resource.Value`, a rule `ReturnType`, and an archetype is a list of components declared `as const`. Every type those values carry is named from the package root, so declarations a library emits for them name no internal module. - **Components and resources:** - - Each is identified by an exported key, a plain `Symbol(...)`, which TypeScript types as a `unique symbol`. Its traits are declared by hand, the source of truth: the module augments `Components` or `Resources` in `@adobe/ecs` with a record per key, `[parent]: { type: Entity; reference: "owner" }`, `type` the value's TypeScript type, and `Component.Value` is that type. Registering it, a side effect of loading its module, gives the same traits, `type` as a schema: `Component.register(parent, { type: Entity.schema, reference: "owner" })`. Its types check registration against the declaration both ways: the schema describes exactly the declared type (`Schema.ToType`, under which `Entity.schema` is `Entity`, a fixed-length array a tuple and `{ const: true }` `true`) and a default in it is of that type, every declared trait is given with its declared value, and nothing undeclared is given. A failed check names the problem where it can (`"the schema describes another type than the declared one"`). `type` is inferred from the schema alone and the checks are separate members of the parameter's type, so a registration's contextual typing never resolves them. - - **Checks of a generic key read lookups:** while a registration's key is still being inferred, contextual typing resolves its checks for a generic key, and a recursive check of a generic value, such as `IsStruct`, recurses without end. Traits read whether a key's value is a struct from a lookup computed once for every key (`Structs` in `trait/types.ts`), its keys remapped (`as K`) so a generic key's lookup stays a lookup; the checks read `Components[K]["type"]` rather than `Component.Value`, whose deferred conditional resolves the same way. - - **Traits** are strongly typed and externally extensible. A trait is defined by augmenting `Traits`, whose member gives the value the trait allows on component or resource `N` (`never` where it doesn't apply), and its key, a symbol too, is registered with `Trait.register`, in the registry components and resources share. The built-in traits (`type`, `derived`, `within`, `reference`, `reduce`, `thread`, `transfer`) are named by strings, which are reserved for the core. An allowed value may look at `N`'s own traits and at the one component a declared value names, never map over every component, which would make type checking quadratic in the component count. Types filter by trait: `Component.With`. Traits are a registry beside components rather than components of component entities: a trait's allowed value depends on the component it's on, which a component value can't express, and nothing reads traits on a hot path. Component entities can later project them read-only. + - Each is identified by an exported key, a plain `Symbol()`, which TypeScript types as a `unique symbol`. Its traits are declared once, the source of truth: the module augments `Components` or `Resources` in `@adobe/ecs` with a record per key, `[parent]: { type: Entity; reference: "owner" }`, `type` the value's TypeScript type, and `Component.Value` is that type. The plugin's pass before TypeScript is stripped (`compiler/augmentations.ts`) records each record right after its key, and after any binding the record names, a side effect of loading the key's module: the schema of `type`, its `default`, and its other traits as declared. Nothing restates a declaration, so nothing can disagree with it. A key is a top-level `Symbol()` constant of the module declaring it, so importing a key registers it and every declared key has an id; a module declaring a key it imports, or anything else, fails the build, naming both. The block itself does nothing at runtime, so code between a key and a block below it sees the key registered. The build also checks the built-in traits' values as far as their literals and types show (`derived` is `true`, `reference` an entity's `"owner"` or `"weak"`, `within` `typeof` a key, `transfer` a Transferable class, `reduce` an operator on a resource, `thread` a name on a value that isn't a struct or string), and `World.create` checks every declaration's traits against `Traits`, a default against its type among them: while one doesn't hold, each `World.create` call fails to compile, once, on the call: its `this` must then be `{ "declarations don't hold": { [key]: { [member]: problem } } }`, which tsc prints whole, naming each such key, its members by their bindings and what's wrong with each (`"isn't allowed on this key"`, `"isn't of the declared type"`, `"isn't a trait"`), and the world it returns type-checks as any other (`exports.test.ts`). That check is one mapped type over the declarations, each key's members checked against its own `Traits`. The package's own built-in keys register themselves, so the pass leaves its library modules alone. Emitted code imports nothing and consumers have no way to register a key themselves: a function appended to the module sets each key's `{ kind, declaration }` in a global `Map` under `Symbol.for("@adobe/ecs/declarations@1")`, which every copy of the package in the program reads, in the order modules recorded them, at the first read of a key after more were recorded, and none changes. A declaration that fails is recorded for its own key alone: a read of another key never throws for it, and every use of its key fails, each time, giving it as the reason, so a system using it fails in each run's report. A library's shipped build records its keys the same way. The check of declarations at `World.create` holds only where it's called as the package's `World.create`: assigned first to a variable typed `() => World`, it's no longer checked, a limit of checking through `this`; registration still checks what values show. + - **A type's schema** is read from its syntax, through aliases, interfaces, namespaces and re-exports of the module and those it imports, into the package's sources, which it ships, or a JavaScript package's declarations (`compiler/type-schemas.ts`); typed input reads its types the same way. `number`, `string`, `boolean`, `null`, literals, unions, arrays, one-type tuples, objects, `ReadonlySet` and `ReadonlyMap` have their schemas, and `Entity` its own. A type named by `Schema.Type`, which TypeScript resolves to `Schema.ToType`, a plain type, has the schema `S`, written as a literal type or `typeof` an `as const` constant: `type Level = Schema.Type<{ type: "integer"; minimum: 0; maximum: 9 }>`. The package's `F32`, `I32`, `U32` and vectors are declared so, so their precision and width come from their declarations, and a consumer's types the same. A class, a function, `unknown`, or a `Set` or `Map`, which changes in place, isn't data: the value gets no schema. Any other type fails the build at its declaration. + - **Traits** are strongly typed and externally extensible. A trait is defined by augmenting `Traits`, whose member gives the value the trait allows on component or resource `N` (`never` where it doesn't apply), keyed by a symbol too, which the build registers, in the registry components and resources share, before the components of its block. A trait's value is declared as a literal type or `typeof` a value, which the registration passes. The built-in traits (`type`, `derived`, `within`, `reference`, `reduce`, `thread`, `transfer`) are named by strings, which are reserved for the core. An allowed value may look at `N`'s own traits and at the one component a declared value names, never map over every component, which would make type checking quadratic in the component count. Types filter by trait: `Component.With`. The build checks the built-in traits' literals, registration what values tell, and `World.create` every trait's value against `Traits`, a trait of your own's included. Traits are a registry beside components rather than components of component entities: a trait's allowed value depends on the component it's on, which a component value can't express, and nothing reads traits on a hot path. Component entities can later project them read-only. - **Entities are branded numbers** (`Entity`), and every entity-typed value, nested fields included, uses `Entity.schema`, so persistence and replication find references by schema. Index 0 is never an entity: `Entity.none`, zeroed memory and entity defaults name none, and a lookup of it returns null. - - **A resource always has a value:** its schema's `default` or the declaration's, beside `type` (`Resource.register(names, { type: { type: "map", keys, values }, default: new Map() })`), either of the declared type and never both, left out only when the type allows `null`, which it then starts at. - - **Values that aren't data** (functions, objects with methods, such as a `Blob`, an `HTMLElement` or a `GPUDevice`, a `Set` or `Map`, which changes in place, and objects holding any of them, looked at five levels deep) are declared by their type alone: registration has no `type`, the types require one exactly when the declared type is data, and a resource gives its `default` on the declaration unless its type allows `null`. The world stores such values as they are, so only main-thread systems use them, and persistence and replication skip them. + - **A resource always has a value,** and so does a derived component. A schema never holds a default, which isn't part of what values are: the start is the declaration's. The first of these applies: + 1. its `default`, declared beside `type` as a literal type (`default: -9.8`, `default: [1, 2, 3]`, `default: null`), never `typeof` a value; + 2. its type's namespace's `default`: the value beside the type alias with the same name (`type Height = F32; const Height = { default: -Infinity } as const`), read as `Height.default`, followed through imports, re-exports and aliases, and each field and tuple item of a type written in place takes its own type's start the same way. The package's types have one each: `F32.default`, `I32.default`, `U32.default` and the vectors' are zeros, `Quat.default` and `Mat4x4.default` the identities, `Entity.default` is `Entity.none`. A default is shared by every row and world that starts from it, so the build takes one only when it's evidently deeply readonly: `as const` as the outermost assertion on it or the whole namespace, with no cast inside to a mutable type (`[] as number[]` fails), or a `const` typed readonly (`ReadonlyMap`, `ReadonlySet`, `Readonly<…>`, readonly arrays and fields, an alias of one), whose annotation it trusts, or a call of a function whose declared return type is readonly (`packColor(255, 214, 0)` returning `U32`); a value it can't see as readonly fails the build at the part that isn't, saying why. One the build can read as data is checked against its type there. Imported as a type alone, a namespace's default the build can read as data is written into the registration, except an object a joined thread knows by identity, whose namespace is imported as a value; + 3. for a reduced resource, its operator's identity, leaf by leaf (`+` 0, `*` 1, `min` its largest value or +∞, `max` its smallest or −∞, `&` every bit of its column, all ones for a `u32` and −1 otherwise, or `true`, `|` and `^` 0 or `false`), as a fold of no contributions leaves it, taken only where it's one of the leaf's values, else the rules below; it outranks the package's own types' defaults, never a namespace of yours, at any depth; + 4. `null`, when its type allows it; + 5. its zero value: `0`, `false`, `""`, item by item for a tuple and field by field for a struct, and a union's literal that is one of those; + 6. a fresh empty `Map` or `Set` of each world's own, for a `ReadonlyMap` or `ReadonlySet`; a `Map` or `Set` type gets none. + Otherwise the build fails, saying how to give one: for a union of literals none of which is a zero value, a range without 0, a class, or a `Map` or `Set` that isn't null. A component's literal or namespace's default is its start the same way, which commands that give it no value add; a struct component without a literal needs none. The registration gives the start beside the schema, and checks it's one of the schema's values. A literal default is written into the registration, so it's a copy; a start that must be one object, as an object a joined thread fills is matched by identity, is a namespace's, read where it's declared, never copied. + - **Values that aren't data** (functions, objects with methods, such as a `Blob`, an `HTMLElement` or a `GPUDevice`, a `Set` or `Map`, which changes in place, and objects holding any of them) are declared by their type alone, and registered without a schema; a DOM `Node`, by its class or one extending it, needs `thread: "main"`, which the build requires. The world stores such values as they are, so only main-thread systems use them, and persistence and replication skip them. - **Schemas** are JSON Schema, of the forms the ECS reads, and two JSON lacks: `{ type: "set", items }`, a `ReadonlySet`, and `{ type: "map", keys, values }`, a `ReadonlyMap`. Neither `"set"` nor `"map"` is a JSON Schema type, so every other schema keeps its meaning. A set or map is data, never a struct: it's stored as an object, on its thread, kernels can't read it, it crosses to other threads by structured clone, and registration compares defaults holding one by their entries. - - **Import and use:** code that references a component or resource always imports its key. That guarantees the module is loaded, so the component is registered. - - **Keys are plain symbols, compared by identity,** so no two separately declared components, resources or traits can collide, and a symbol needs no description. A key is one kind across components, resources and traits, because a resource may be stored as a component on a single entity. Registration rejects a string, a `Symbol.for` symbol, which any module could claim, and a well-known symbol such as `Symbol.iterator`; types reject strings too: `register` takes only symbol keys, and while `Components`, `Resources` or `Traits` has a string key that isn't a built-in trait, every component and resource declaration requires a property named for the problem (`"component keys must be symbols"`), so nothing registers until it's fixed. A key registered again as the same kind with the same declaration is accepted; as another kind, or with another declaration, it throws. A module evaluated again makes new symbols, which share its keys' ids (see the plugin). + - **Import and use:** code that references a component or resource always imports its key, and the key's module declares it and registers it right after the key, so importing a key registers it, on every thread. A module may not declare a key it imports: the build rejects it, naming both. + - **Keys are plain symbols, compared by identity,** so no two separately declared components, resources or traits can collide, and a symbol needs no description. A key is one kind across components, resources and traits, because a resource may be stored as a component on a single entity. Registration rejects a string, a `Symbol.for` symbol, which any module could claim, and a well-known symbol such as `Symbol.iterator`; the build rejects a declaration keyed by anything but a `Symbol()` constant. A key registered again as the same kind with the same declaration is accepted; as another kind, or with another declaration, it throws. A module evaluated again makes new symbols, which share its keys' ids (see the plugin). - **Ids:** a symbol the plugin gave an id, registered as the kind and with the declaration of the key already registered under that id, is that key: the registry shares one entry between them, declarations compare them as one key, so systems, rules and finalizers declared again with them are accepted, and schedules, strata and finalizer order compare keys by the first symbol registered. A world stores them as one component, by the first symbol registered, resolving another on its first use, and gives values and changes keyed by the newest symbol registered. A symbol under a registered id with another kind or declaration throws at registration, as a module evaluated again after its declaration changed makes it. Keys aren't matched by description, and a symbol without an id is only itself. - - **Labels:** errors and reports name a key by its label: its description while no other registered key has it, else its id, and without one its description numbered (`#2`) in registration order, so a label is short unless it would be ambiguous. Labels are for display; nothing looks a key up by its label. -- **Archetypes** (`src/interface/archetype/`): an archetype is a readonly list of components, `readonly Component.Key[]`. `Archetype.create(components)` returns its argument, typed as the tuple given, throws for a component listed twice, and gives the list a program-wide index in a hidden property, by which worlds find what they resolved for it without comparing components. Any readonly list of components is an archetype too and goes wherever component lists do (systems, indexes, rules, finalizers); a plain list is compared by its components each time, since it may change, while an archetype must not change. A resource or string in one is a type error. The kernel compiler reads an array literal, or a constant holding one or `Archetype.create` of one, as its list: of the kernel's module, or imported, which it follows through the linker as it does inlined functions, re-exports and namespaces included, so editing the list's module compiles the kernel again. It reads an imported list's keys in the scope of the module that declares it, and names each in the kernel's module by the kernel's own binding of the same definition, or else through the list, as `List[k]`, so the kernel refers to the same symbols. A list it can't read, such as one computed when its module runs, fails the build at the call or declaration that names it. `add` and `remove` read their component the same way: one key when its definition is `Symbol(...)`, a list when it's one, and a build failure naming the call and the reason otherwise; a name from a package external to the build is taken as one key, since the build can't read it, and registration rejects a list there. + - **Labels:** errors and reports name a key by its label: its description, or without one its binding from its id, whatever other keys have, so a label never changes as modules load; an error about an id names the id too. Labels are for display; nothing looks a key up by its label. +- **Archetypes:** an archetype is a list of components declared `as const`, a readonly tuple, which goes wherever component lists do (spawns, adds and removes, systems, indexes, rules, finalizers). There's no constructor: the build gives every list it can see an index. Before TypeScript is stripped it wraps each list declared `as const` that the module evaluates as it loads, outside functions, whose members are its `Symbol()` keys, imports, a namespace's members (`Report.run`) or spreads of such lists, as `archetype$([...] as const)` (`compiler/archetypes.ts`). After, it hoists each array literal of keys a main-thread body passes its commands into a module variable made once, `(list$0 ??= archetype$([a, b]))`, so the call allocates nothing (`compiler/spawns.ts`), only when no name declared around the body, nor its parameters, shadows a key it names, since the hoisted list is made once. The appended function, which imports nothing, gives a list a program-wide index in the property `Symbol.for("@adobe/ecs/archetype@1")`, counted by a global every copy of the package shares, when every member is a symbol, each once, and the list is extensible: it runs where the list is made, perhaps before its keys' declarations register, so whether they're components is checked where the list is used; worlds find what they resolved for it by that index without comparing components. A list naming one of its module's keys twice fails the build. Without `as const`, a list's keys widen to `symbol`, which no list of components takes, so the types require it. A list made at runtime is resolved by its keys among the eight recent ones; else its keys, in order, find the one index every list of them shares (`internArchetype`), given the first time, so lists made on every call take no index of their own and allocate nothing once seen. The list keeps that record of its keys and index in the same property, which holds while its keys stay the same, since such a list may change. The package's own lists, built without the plugin, take their indexes from `indexArchetype`. A resource or string in a list is a type error. The kernel compiler reads an array literal, or a constant holding one, indexed or not, as its list: of the kernel's module, or imported, which it follows through the linker as it does inlined functions, re-exports and namespaces included, so editing the list's module compiles the kernel again. It reads an imported list's keys in the scope of the module that declares it, and names each in the kernel's module by the kernel's own binding of the same definition, or else through the list, as `List[k]`, so the kernel refers to the same symbols. A list it can't read, such as one computed when its module runs, fails the build at the call or declaration that names it. `add` and `remove` read their component the same way: one key when its definition is `Symbol(...)`, a list when it's one, and a build failure naming the call and the reason otherwise; a name from a package external to the build is taken as one key, since the build can't read it, and registration rejects a list there. - **Systems** (`src/interface/system/`; its type tests pin the behavior): ```ts export const follow = System.create({ - name: "ai/follow", - components: { read: [position, target], write: [velocity] }, + components: { with: [position, target, velocity] }, lookup: { targets: { read: [position] } }, after: [integrate], // imported from the physics module }, (row, { lookup }) => { @@ -97,39 +104,39 @@ Rule: anything that doesn't affect the API or correctness belongs in phase 2+. }); export const move = System.create({ - name: "player/move", - input: { type: "object", properties: { distance: { type: "number" } } }, components: { has: [player], write: [position] }, - }, (row, { input }) => { /* … */ }); + }, (row, context, input: { readonly distance: number }) => { /* … */ }); - export const main = System.create({ name: "main", systems: [input, physics, follow, render] }); + export const main = System.create({ systems: [input, physics, follow, render] }); ``` - - **A system is the value its constructor returns.** `System.create` and `System.render` return a system typed `System`: its `name`, and the input `run` takes. Whatever else the value holds is the implementation's and isn't typed. `System.create`'s arguments tell its forms apart: one function is a body alone, two arguments a declaration and body, one array a composite's members and one object a composite's declaration, `{ systems, before, after, during, name }`. Its one-argument forms are a single overload, so a mistake in a body alone or a composite is reported at the mistake, not as no overload matching; the plugin compiles only the two-argument form as a kernel. They exist for body type inference, a binding the plugin can find, and a value the world can run. `Finalizer.create` and `Finalizer.changes` return a finalizer typed `Finalizer`, for other finalizers' `before` and `after`, and `Rule.create` a rule typed `Rule`, both their `name` alone. Nothing runs when they're called. - - **Declaration keys mirror the body's context:** `components` and `resources` say what a system accesses, each with access modes (`read`, `write`) and filters; `lookup`, `reduce` and `input` each appear in the context under the same name. - - **Components:** a system matches entities that have every component in `read`, `write`, `has`, `added` and `changed`, and none in `not`. + - **A system is the value its constructor returns.** `System.create` and `System.render` return a system typed `System`: its `name`, and the input `run` takes. Whatever else the value holds is the implementation's and isn't typed. `System.create`'s arguments tell its forms apart: one function is a body alone, two arguments a declaration and body, one array a composite's members and one object a composite's declaration, `{ systems, before, after, during }`. Its one-argument forms are a single overload, so a mistake in a body alone or a composite is reported at the mistake, not as no overload matching; the plugin compiles only the two-argument form as a kernel. They exist for body type inference, a binding the plugin can find, and a value the world can run. `Finalizer.create` and `Finalizer.changes` return a finalizer typed `Finalizer`, for other finalizers' `before` and `after`, and `Rule.create` a rule typed `Rule`, both their label, `name`, alone. Nothing runs when they're called. + - **A declaration says only what its body can't show:** which entities it runs over, its filters and conditions, its lookups' membership, its order. `lookup` appears in the context under the same name; every resource is in `resources`, writable but reduced and world-written ones, and every reduced resource in `reduce`. + - **Components:** a system matches entities that have every component in `with`, `added` and `changed`, and none in `not`. - **Conditions:** `resources: { is, not }` say whether a system runs at all: only while each resource in `is` is neither `null` nor `false`, and each in `not` is. Otherwise it's skipped, which is a success, so systems ordered after it still run, and it keeps its `lastRun`. A condition's type must allow `null` or `false`, so `0` and `""` never turn a system off; `is` makes it readable, narrowed, so it isn't also read or written. Expected absence is a condition, not a failure, which is why any real failure is still reported. Finalizers have no conditions: they release what entities hold whatever the world's state. - - A component in `write` is readable too, so it's never also in `read`; listing it in both is rejected in types and at registration. The same holds for resources. - `added`/`changed` pass when **any** of the listed components was added or changed. - - **Lookups** are named and read-only, and take an entity ID only: `(entity) => values | null`. They can't be iterated. A lookup's `changed` names components it reads or has; its row then has `changed`, whether one of them may have changed in the entity since the system last ran, as conservative as `changed` filters, true on a first run and in finalizers. It reads the row's ticks and its column's GPU stamp, and a move keeps them, so a move alone isn't a change. That row is one per lookup, valid until it's called again; a lookup without `changed` gives a new object per call, and one reading nothing a shared empty one. Each lookup's form is chosen once, at registration, so a lookup without `changed` pays nothing for it. The components it watches keep ticks and kernels' stamps while its system lives, as a filter's do. + - **Optional components:** `components: { with, optional, not, added, changed }`. An `optional` component never narrows what a system matches; its row holds the value where the entity has it and `undefined` where it doesn't, and assigning one an entity lacks fails the system, since adding it takes `commands.add`, as does assigning `undefined` to one it has, which removes nothing (`commands.remove` does). Which optional components an archetype has is fixed, so rows are made per pattern of them (`rowBinderOf`), each binding its present columns once per archetype and its absent ones as getters giving `undefined`: no check per row (`optional 100K` 0.42 ms against `with 100K` 0.42). Reads and writes of them are inferred and count for conflicts, change stamping and placement as members' do. A key in `added` or `changed` is a member, so naming one there makes it required. Lookups by entity take `optional` too, read where the entity has them and never required, and so do finalizers, whose stops still depend on `with` and `not` alone. CPU kernels read them through a presence column (`{ kind: "present" }`), any column with rows where the archetype has the component and empty where it doesn't, tested once per call: an absent component's columns are empty, so its reads give `undefined` as a body run as written does, and a write fails the system through `absent`, which names it (`labels`). A test of one for `undefined` lowers to that presence, and a kernel runs loops specialized for every optional component present and every one absent, and the general one only for archetypes with some: `optional 100K kernel` costs what `with 100K kernel` does. WASM runs archetypes with every optional component and JS the rest; a kernel whose rows run as a list (`up`, `order`) or whose lookup has optional components runs as JS. GPU kernels reject them, in types and at the build: WGSL has no `undefined` to read where an archetype lacks one, and a GPU kernel can't fail its system for one row. Tails and observer systems on joined threads read rows from snapshots across archetypes, which record each row's archetype, so they bind rows a run of one archetype at a time, every archetype with the same optional components sharing one copy of the columns; finalizers there bind their stops the same way. + - **Lookups** are named and read-only, and take an entity ID only: `(entity) => values | null`. They can't be iterated. A lookup by entity requires its `with` and excludes its `not`; its `changed` names components of its `with`, and its row then has `changed`, whether one of them may have changed in the entity since the system last ran, as conservative as `changed` filters, true on a first run and in finalizers. It reads the row's ticks and its column's GPU stamp, and a move keeps them, so a move alone isn't a change. That row is one per lookup, valid until it's called again; a lookup without `changed` gives a new object per call, and one reading nothing a shared empty one. Each lookup's form is chosen once, at registration, so a lookup without `changed` pays nothing for it. The components it watches keep ticks and kernels' stamps while its system lives, as a filter's do. - **Resources:** listing a resource in `changed` makes the filter pass for every row. Kernels can only read resources. - - **`kernel: "cpu"`** opts a system into being compiled, so it can run in chunks across workers; **`kernel: "gpu"`** narrows the subset so it can also run on the GPU. The term comes from CUDA, WebGPU and Taichi. - - It's explicit rather than inferred, so a body that leaves the subset fails the build instead of silently running on the main thread. - - Type-checked: values in `components` and `lookup` must be linear (numbers, booleans, entities, fixed-length tuples, and structs of those), and resources are read-only. Presence-only lists (`has`, `not`, `added`, `changed`) can name any component. A tuple or struct a command or spawn gives must go to a linear component, which registration checks. - - The build validator checks `input`, since `Schema.ToType` doesn't produce tuples. - - Without `kernel`, a system is plain JS on the main thread. + - **`kernel`** says whether a body compiles to a kernel, a function run on every matched row, in parallel, in chunks across workers. The term comes from CUDA, WebGPU and Taichi. + - Without it, the build tries a system with components, unless it observes or runs on one entity: it compiles when its body is in the subset, reads no module value but primitive constants, functions and the interface's own values (an object or array holding data, however it's made, may change after the build, which would inline it), and its schemas match, and otherwise runs as written. A system without components runs once, so it never compiles. + - `true` requires it, so a body that leaves the subset fails the build, saying why; `false` runs it as written, for debugging or a main-thread baseline; `"gpu"` narrows the subset so it can also run on the GPU. + - Checked by the build: what a kernel's body reads and writes of its rows and lookups must be linear (numbers, booleans, entities, fixed-length tuples, and structs of those), and it writes no resources. What it only requires (`with`, `not`, `added`, `changed`) can name any component. + - A kernel's input fields are structs. A TypeScript `number` is a float, so a field the kernel uses as an integer is typed `I32` or `U32`. + - **The pool starts once rows warrant it:** while no archetype a kernel matches holds half a piece of rows for a worker, kernels run on the world's thread and no workers start. A kernel registering looks at the archetypes it matches; one running here over that many rows starts the pool after its run's sync point, so nothing scans archetypes per run. Kernels failing to load on the pool a run starts are a `"thread"` failure in that run's report, labeled `workers`; the pool ends, and kernels run on the world's thread until the `workers` resource changes. - **`components` is optional.** A system without it runs once per run, on the main thread, and its body takes only the context. - - **`input`** is an object schema of named fields for a per-run, read-only value, and `run` requires it exactly when it's declared. - - A system with input can't use `added` or `changed`, because its `lastRun` is shared across runs that receive different input. - - **`target`** names an input field holding an entity: the system runs on that entity's row alone, once, when it's alive and matches `components`, and on nothing otherwise, so a stale entity from an event needs no check. Its writes are in place, stamped as any row's. It needs `components`, isn't a kernel, has no `up`, `order` or `reduce`, and runs only on the world's thread. + - **Input** is the body's parameter after its context, an object of named fields for a per-run, read-only value, typed as TypeScript: `run` requires it exactly when the body takes it. It's not part of the declaration, so its type is written once, where it's used. + - The plugin reads the schema the runtime needs from the parameter's type, by its syntax, before types are stripped (`compiler/input-schemas.ts`), and passes it to `create` after the body: type literals, aliases and interfaces of the module and those it imports, `number`, `string`, `boolean`, `null`, literals, unions, arrays, tuples of one type, `ReadonlySet`, `ReadonlyMap`, `(typeof X)[keyof typeof X]` for an `as const` object of literals, the package's `Entity` and math types, and `unknown`. A name neither declared nor imported is a global class, such as `OffscreenCanvas` or `HTMLCanvasElement`, which takes any value. Any other type fails the build at the parameter. A module whose input types come from a changed file is transformed again. Without the plugin, a body taking input throws at creation, as does one not written in place. + - Its checks are on the body, after the body is inferred: a system with input can't use `added` or `changed`, because its `lastRun` is shared across runs that receive different input; a `target` names an entity field of it; an observer system takes none. A check in the declaration's position can't see the input, which TypeScript infers only from the body, after the declaration. + - **`target`** names a field of the input holding an entity: the system runs on that entity's row alone, once, when it's alive and matches `components`, and on nothing otherwise, so a stale entity from an event needs no check. Its writes are in place, stamped as any row's. It needs `components`, isn't a kernel, has no `up`, `order` or `reduce`, and runs only on the world's thread. - **A composite's input** has every field of its members' inputs, and each member receives the fields it declares, so workers get only those. A field several members declare is one value for all of them, with the same schema in each: a different type is a compile-time error at each member that differs, and creating the composite throws naming both. A field optional in one and required in another is required. - `(system name, input)` is a replicable transaction. On a GPU, input maps to a uniform buffer. - A `Blob` input can only be stored or forwarded, because its contents can't be read synchronously. - - **Bodies are `(subject, context)`:** `(row, context)` for a system with `components` and for a finalizer, `(context)` for a system without `components`, `(changes, context)` for a changes finalizer, and `(gpu, context)` for a render system. - - `row`: `{ readonly entity }`, plus `read` values (read-only) and `write` values (readable and writable). No component may be named `entity`. - - `context` holds each kind under the declaration's own key, present only when the declaration declares something of that kind: `resources` (values, assignable when in `write`), `lookup` (functions by name), `reduce` (contribution functions by resource) and `input`. It also has `commands`, except in GPU kernels and render systems. Keys of different kinds never share a namespace, so nothing can collide. - - `commands`: `spawn(components)` returns the world's spawner of exactly those components, the same function for a world and set of components each time, which takes their values and returns a real `Entity`; `despawn`; `add(entity, component, value)`, the value left out for a tag, or `add(entity, components, values)`, adding or replacing and marking the components changed; and `remove(entity, component)` or `remove(entity, components)`. A spawn's or list add's values are exact against its components: a tag needs no value, a component whose type allows `null` may be left out to start at `null`, every other one is required, and leaving one out or giving `undefined` never leaves the component out; a list that isn't a tuple takes any of its components' values. The two forms of `add` and `remove` are told apart by whether the second argument is a symbol. They apply in the order they were issued. They take only components that aren't derived, which is checked in types and at runtime, where a key that isn't a component commands may name, or a list naming one twice, throws at first use before anything is queued. Values are read only by the components' keys; a string key in the values of a spawn, or of an add whose components are a list as written, an array literal or a module constant of one or of `Archetype.create`, fails the build; an add of one component takes an object literal as its value. + - **Bodies are `(subject, context, input)`:** `(row, context, input)` for a system with `components`, `(context, input)` for a system without, `input` left out by a body that takes none; `(row, context)` for a finalizer, `(changes, context)` for a changes finalizer, and `(gpu, context)` for a render system. + - `row`: `{ readonly entity }`, plus the values of its `with`, `added` and `changed`, writable but references, and read-only in an observer or render system. No component may be named `entity`. + - `context` holds each kind under its own key: `resources` (every resource, assignable but reduced and world-written ones), `lookup` (functions by name, when declared), `reduce` (contribution functions by reduced resource) and `up` (with `up` declared). It also has `commands`, except in GPU kernels and render systems. Keys of different kinds never share a namespace, so nothing can collide. + - `commands`: `spawn(components)` returns the world's spawner of exactly those components, the same function for a world and set of components each time, which takes their values and returns a real `Entity`; `despawn`; `add(entity, component, value)`, the value left out for a tag, or `add(entity, components, values)`, adding or replacing and marking the components changed; and `remove(entity, component)` or `remove(entity, components)`. A spawn's or list add's values are exact against its components: a tag needs no value, a component whose type allows `null` may be left out to start at `null`, every other one is required, and leaving one out or giving `undefined` never leaves the component out; a list that isn't a tuple takes any of its components' values. The two forms of `add` and `remove` are told apart by whether the second argument is a symbol. They apply in the order they were issued. They take only components that aren't derived, which is checked in types and at runtime, where a key that isn't a component commands may name, or a list naming one twice, throws at first use before anything is queued. Values are read only by the components' keys; a string key in the values of a spawn, or of an add whose components are a list as written, an array literal or a module constant of one, fails the build; an add of one component takes an object literal as its value. - **Writes to the current row are direct and immediate.** Commands are only for structural changes and for writes to other entities. - - **Names are labels,** optional and not unique, so two libraries can both name a system `"linked"`; namespacing them (`"physics/integrate"`) keeps labels short. A system that declares nothing is created from its body alone, `System.create(({ commands }) => …)`, which runs once per run. Ordering constraints list systems; a system can't list itself, since it doesn't exist while it's declared. + - **Declarations take no name.** A system is labeled by its binding, which its id names, so two libraries can both declare a system `linked`. A system that declares nothing is created from its body alone, `System.create(({ commands }) => …)`, which runs once per run. Ordering constraints list systems; a system can't list itself, since it doesn't exist while it's declared. - **Composites** flatten their members. - A composite's constraints apply to every member, and members' own constraints resolve against the flattened set. - If the same system appears twice, as one value or as values of one id (see below), it's flattened to one entry, the first reached. Systems that only share a name are different systems. @@ -142,15 +149,15 @@ Rule: anything that doesn't affect the API or correctness belongs in phase 2+. - **`during` places a system alongside another:** after what that system follows and before what follows it, both by its own constraints and by others', outside a composite's own members. No-op systems ordered one after another are phases, and systems during different phases are ordered with each other. Where no ordering path separates them, the two share a tier. - **Unordered conflicts throw when a composite is created:** within a composite, two systems that both write a component or resource, or where one reads what the other writes, must have an ordering path between them. Contributors to one reduced resource don't conflict with each other, and every system that reads it is ordered after all of them. - **A lookup can't read a component its own system writes;** that throws at registration. `up` can, since it reads a parent only after the parent ran. - - **`up: { through, read }` runs rows in depth order** through `through`, a reference component, parent first, and the context's `up` holds what `read` lists of the row's parent, or null without one. A parent's value is the one written earlier in the same run, so one run carries a change down a whole tree, and results don't depend on chunking. It's Flecs's depth-ordered (`cascade`) iteration, with the parent's values in the context. + - **`up: { through }` runs rows in depth order** through `through`, a reference component, parent first, and the context's `up` holds what the body reads of the row's parent, or null without one or for a parent lacking one of them. A parent's value is the one written earlier in the same run, so one run carries a change down a whole tree, and results don't depend on chunking. It's Flecs's depth-ordered (`cascade`) iteration, with the parent's values in the context. - `through` doesn't narrow the match: rows without it are roots. References change only through commands, so the tree changes only at sync points. - A row's depth counts its ancestors among the matched rows: a row whose parent doesn't match is a root of this system's order. - With change filters, a row also passes when its parent reference, or what it reads of its parent, changed since the system last ran, decided on ticks, so a parent another system wrote counts too. Those components keep ticks and kernels keep their stamps. A row that moved to another archetype since then passes too, since removing its parent reference moves it, as do the children of a parent that moved to another archetype or started or stopped matching. - Rows on or under a parent cycle don't run; reports don't list them yet. - **Order:** rows run depth by depth, and within a depth in groups of one archetype whose parents share one archetype, each in storage order. Groups of a depth never read each other's writes, so values don't depend on the grouping; commands and reductions follow it, deterministically. The order, each row's parent row and the children of each parent change only at sync points, since only those move rows or change references. - **Kernels read `up` straight from the parent's columns:** a call covers one group, so it binds the parent archetype's columns once, and each row reads its parent's leaf at the parent's row. A leaf's stride and offset depend only on its component's layout, so they're the same in every archetype. `up === null` holds for the whole call. - - **Filtered runs push changes down:** rows are marked from chunk ticks of their own filters and their parent reference, from the sync points' journal of entities whose parent, matching or archetype changed, and as children of parents whose read values changed; each row that runs marks its children, so a run costs a check of each chunk plus the subtrees that changed, and nothing when nothing did. - - **`kernel: "gpu"` systems may declare `up`;** what `read` lists must be made of 4-byte scalars, as the rest of a GPU kernel's values are. + - **Filtered runs push changes down:** rows are marked from chunk and block ticks of their own filters and their parent reference, from the sync points' journal of entities whose parent, matching or archetype changed, and as children of parents whose read values changed; each row that runs marks its children, so a run costs a check of each chunk plus the subtrees that changed, and nothing when nothing did. + - **`kernel: "gpu"` systems may declare `up`;** what the body reads of the parent must be made of 4-byte scalars, as the rest of a GPU kernel's values are. - **In a main-thread body, `context.up` is one reused object,** valid only while its row runs, as `row` is. - **World** (`src/interface/world/`): - `World.create(resources?)` returns a world synchronously, on the creating thread, with resources starting at the given values in place of their defaults; a key that isn't a resource, or one only the world writes, throws; string keys are ignored, as in commands. Built-in resources are symbols too, so initial values import them: `World.create({ [workers]: 0 })`. @@ -163,16 +170,16 @@ Rule: anything that doesn't affect the API or correctness belongs in phase 2+. - **Threads:** `world.share(port)` lets another thread join through a port (a `MessagePort`, a `Worker` or a worker's scope), returning nothing; the joined thread's progress and claims are the implementation's own storage. The other thread calls the implementation's `join(port, { threads })`, whose port and options are inferred from `share`, claiming the logical threads it fills, and gets a `World` whose `run` works as here, and whose `add` and `remove` take observer systems, which run there after the world's sync points, by the ids the plugin gives top-level systems and keys (an observer system it adds needs none, only its keys do): a run of a system without one, or one the world never imported, rejects, and a thread claimed twice, or one with a system or finalizer it can't run, without an id, naming a key without one, declaring `up` or `order`, looking up through an index with an object key part or with `changed: true`, or not imported there, can't join ([`threads.md`](threads.md)). The objects and object resources of the threads it claims are its own: commands elsewhere give them only their start or `null`, and systems elsewhere can't read them; the systems that read or write them run there, each tier waiting for it listed in the report's `warnings`, and are skipped with a `"thread"` failure once it's gone, later tiers still running. A terminal one, whose body the plugin proves never reaches `commands` and which writes only those objects, reduces into nothing and looks nothing up, runs as a tail when no later system conflicts with it: after its tick's sync point, unwarned, while the next tick goes on, a writer of what it still reads first copying the rest of its reads and waiting for at most the chunk it claimed; a run waits for its tails, and a tick's report is written once after them, with their failures. The finalizers that read them run there too, after the sync point: the world copies each stop's declared struct reads into that thread's stop list without waiting, the thread runs them as it next replays that sync, before its next task or observer post, and the index of an entity they stopped is held until the thread acknowledges; their failures are in a later run's report. When it's gone its objects come back to the world's thread, its held indexes return, stops it never finalized are `"thread"` failures, and its systems and finalizers run here again. Once the world is disposed, a joined thread finalizes every row its finalizers match. Without cross-origin isolation both throw. - **Observer systems are the way to read a world.** A consumer reads once by running an observer system that copies what it reads into variables of its own, and follows changes by adding one whose `changed` filters pass only after the runs and ticks that changed what it reads; the world has no observers. Derived values are computed by systems, inside the ECS. - **Observer factories** (`src/observe/`, internal, never exported: consumers write plain observer systems; its own TypeScript project importing only the package root, for tests, debugging and benchmarks) give observers as `Observe.resource(world, key)`, `Observe.entity(world, entity, { required, optional })` (no whole-entity form), `Observe.select(world, query)`, `Observe.count(world, query)` and `Observe.toPromise`, built on observer systems alone. Per world, in a `WeakMap`: one observer system per resource key and per shape of entity observer (its required and optional lists) or query (components, `where` keys, `order`, whether paged), shared by every subscriber, each `where` and page of a query an entry. A value they hold goes to a new subscriber during the subscribing call, as of the last sync point; any other waits for the flush at the end of the task, which runs what reads it (a resource's reader, a query's scan) and one delivering run, after every run called before, so cold first values are read-your-writes and come in subscribe order; a first value whose notify throws is thrown out of band and stops that subscriber, a later one's is a `"system"` failure of its run or tick. A maintenance observer system, added first, counts passes and removes shapes the passes found idle in three, so loops of first values stay warm; a resource's reader runs before it in its own run, and its notifier, filtering on the resource's `changed`, skips subscribers that had their first value in that pass. The failures of the factories' own runs, which nothing else reads, are rethrown out of band: each of their runs runs a system reading reports, `Observe/rethrow`, never added so no tick waits for it, which rethrows their systems' failures in the reports of their runs, a pass's failures once a later run of theirs sees them; on a joined thread, they're in a later report of the world's. Entity shapes look each entity up by entity with `changed`, reading values only once changed, and with no optional components skip every entity while an index without a key on the required ones reports no change. A query's first values come from one scan, an observer row system run for them, ties and unordered results put in entity order; a scan of one entry with at most one test, every entity or a count, gathers in the scanner's body into typed scratch, each value made at its length; only once a subscriber outlives its first value is the shape's tracker added: a sorted index on `order` when every term goes one direction, read from its end for descending, each run of equal keys in entity order; for a count, or a selection without a page, a sorted index on its first `where` key whose values have an order, each entry looking up its range with every bound made inclusive, so it holds every row the condition passes at the precision values are kept at, then testing each row, or for a count of one condition, taking the range's length less the rows at a bound the test rejects (rows at one key share their value, so one tells for all); otherwise an index without a key, in entity order. Each notifies only when its result differs; unordered results in entity order compare as sequences. Disposed worlds run nothing, so a first value not held never comes. `observe-cases.test.ts`, `observe-ticks-cases.test.ts`, `observe-cases.browser.test.ts` and `observe-factories.test.ts` cover them, and tests read through them (`reads.test-helper.ts`); tests of storage order, or of a joined thread's objects as of the world's last sync point, read through observer runs. Benchmarks count ticks with an observer system filtering on the clock, and read before what they time through observer runs, which leave nothing added (`perftest/observer.ts`); the factories only check results once nothing more is timed on a world, since they keep their maintenance system added there. On a joined thread's world, which the factories know by its `remove` refusing a system that isn't an observer, they add no maintenance or delivering system, since observer systems run there in posts in no order with one another: a shape's cold first values come once a run of its own system resolved, a post for it having run after every run the thread called before subscribing (a resource's notifier, whose first run once added passes its filter; an entity shape's system; a query's tracker, added on first use, with no scan, since a scan's rows can't be told from a later post's there, and for an ordered selection without a page an index without a key sorted there rather than a sorted one the world's thread keeps); a pass is a turn of the thread's event loop that ran a shape's system, idle shapes swept after it; indexes they make reach the world by declaration; a system the thread can't place, as for objects of the world's thread, throws when subscribed. On the world's thread, one naming objects a joined thread fills fails to register: an error in a run's report, or out of band for the factories' own runs, whose rejections on a live world are rethrown. `observe-joined-cases.test.ts` covers them on a joined thread, and the worker cases of `remote-objects` and `joining` on joined workers. - - **Identity is the value, and names are labels:** no name is ever looked up, so systems, rules and finalizers can't collide across separately written code, as keys can't. Errors and reports name one by its label: its name while no other registered system, rule or finalizer has it, else its name with its id (`"linked (lib/src/link.ts#linked)"`), and without one its name numbered (`#2`) in registration order. One declared without a name is labeled by its binding and id (`"spawnOnce (app/src/setup.ts#spawnOnce)"`), and without an id by its kind numbered by creation (`"system #3"`, `"rule #1"`, `"finalizer #2"`). Labels are for display. + - **Identity is the value, and labels are bindings:** no label is ever looked up, so systems, rules and finalizers can't collide across separately written code, as keys can't. Errors and reports name one by its label: the binding its id names (`linked` for `lib@1.0.0/src/link.ts#linked`); for one made where no top-level binding is, the binding, function, property or method the plugin found it in (`mover` for one made in `const mover = (speed) => System.create(...)`), with no id; and else its kind numbered by creation (`"system #3"`, `"rule #1"`, `"finalizer #2"`). A label never changes as other items load, and reports give the id beside it to tell items of one label apart. Internal factories label theirs through the registry's map of labels, which the plugin's labels never override. - **Ids:** an item the plugin gave an id, declared again under that id with the same kind and the same declaration, its keys and the systems and finalizers it lists matching by id, is that item, as a module evaluated again declares it: a system is a new value, with the new body, that schedules compare as the first one, so constraints from modules evaluated again still order it; a rule is the one already recorded and `Rule.create` returns it; a finalizer keeps its place in every world with the new body and `Finalizer.create` returns it. The same id with another kind or declaration throws, telling to reload the page: replacing a rule or finalizer would have to undo what worlds derived or finalized under the old one. An item without an id is only itself: declared again, it's a new item. Constraints list the systems themselves, imported from their modules; when two modules import each other, one declares the order. - `run(system, ...input): Promise` runs a system or composite, ends in a sync point, and resolves to its run number. Runs are sequential in call order; physically, runs that don't conflict may overlap. - It's async because workers can't be joined synchronously on the main thread. - **Registration is the first run:** a system's declaration is checked when it's created, with the components and resources it names, which must be registered by then: every check that needs no world, its reductions, conditions, `up`, placement and render accessors included. A compiled kernel is checked against its declaration and schemas where its module registers it, and a `within` cycle when the component closing it registers. The first time a world runs a system, it gives its keys ids, adds its resources and loads its kernels, serialized with runs, and the run rejects without recording anything if that fails: a kernel the build didn't compile, a kernel module or WASM that fails to load, and a GPU kernel whose shader can't be generated from its bound columns are found there. A kernel's first run pays for its load; a run before timing work avoids it. - **Observer systems** run after its sync point, before it resolves (see the observer pass). - - **What went wrong is its report** (see Reports are entities): `failures` (each a `kind` of `"system"`, `"finalizer"`, `"gpu"`, `"thread"` or `"index"`, the `name`, a system's, finalizer's or index's label, the `message`, and the engine's `stack` or null), the labels of the `skipped` systems of later tiers and of joined threads that are gone, the count of `dropped` commands against entities that weren't alive, and `warnings` of what slowed it, a `"blocking"` tier that waited for a joined thread by its system's label. A throw's value is normalized to text, so a report is plain data; domain errors are data a system writes, not throws. It rejects only when the run can't happen or finish: registration fails, the world is disposed, its GPU device is lost, or its sync point can't apply a command's value; a rejected run takes no number. + - **What went wrong is its report** (see Reports are entities): `failures` (each a `kind` of `"system"`, `"finalizer"`, `"gpu"`, `"thread"` or `"index"`, the `name`, a system's, finalizer's or index's label, its `id`, the plugin's or null, the `message`, and the engine's `stack` or null), the labels of the `skipped` systems of later tiers and of joined threads that are gone, the count of `dropped` commands against entities that weren't alive, and `warnings` of what slowed it, a `"blocking"` tier that waited for a joined thread by its system's label and id. A throw's value is normalized to text, so a report is plain data; domain errors are data a system writes, not throws. It rejects only when the run can't happen or finish: registration fails, the world is disposed, its GPU device is lost, or its sync point can't apply a command's value; a rejected run takes no number. - Each system keeps its own `lastRun`, so it sees every change since it last ran. - `dispose()` and `[Symbol.asyncDispose]` wait for pending runs, then stop ticking, terminate workers and release memory, so `await using world = ...` works. - - **Initial state** comes from the resources' schema defaults. A system with `resources: { changed: [level] }` runs on its first run and spawns the entities the level calls for, so no run-once flag is needed. + - **Initial state** comes from the resources' starts. A system with `resources: { changed: [level] }` runs on its first run and spawns the entities the level calls for, so no run-once flag is needed. - **A sync point runs in three phases:** 1. **Plan.** Merge command buffers in the run's sorted system order, then chunk index, never worker ID, so conflicting commands resolve the same way every run. Expand the declared cascades and the rule closure. The result is a net plan of `fromArch → toArch` for each entity, covering the whole flush, with nothing applied yet. Only the net first→last archetype matters, so an entity that passes A→B→C produces no events for B. 2. **Finalize, once,** against the final plan, while every row is still live (see Finalizers). @@ -187,7 +194,7 @@ Rule: anything that doesn't affect the API or correctness belongs in phase 2+. - **Matching, not component removal, is the unit of add/remove symmetry.** An entity stops matching a query when a required component is removed, when it's despawned, or when an excluded component is added. Per-component removal is a monitored `With`. - Monitored queries are **structural only**. Their `Changed`/`Added` filters are stripped or rejected at the type level, and optional components don't affect matching. Value conditions become tag components set by systems. - Replacing a required component within one flush (remove + re-add) counts as a stop plus a start. -- **Rules (structural derivation):** `Rule.create({ name, components: { has, not }, derive: [component] })`: entities matching its components have the derived components. +- **Rules (structural derivation):** `Rule.create({ components: { has, not }, derive: [component] })`: entities matching its components have the derived components. - **Datalog with stratified negation** over which components each entity has. Rules and `within` declarations are evaluated as one fixed point per stratum; `Rule.create` rejects a cycle through a negation, which could oscillate, and any cycle through `within`. That makes the closure a pure function of the archetype, cached per archetype, so A→B becomes a single move A→closure(B), and entities never match and then unmatch within a flush. Reference cascades run first, on the values the plan leaves; they only despawn and remove, so the result is the same fixed point. - **Union ownership:** a derived component is present while any rule deriving it matches. - **Derived components** (`derived: true`) are structurally read-only to user code: commands reject them in types and at runtime; systems write their values. Each starts at its schema's `default`, which the types require, stamped `added`, and a normal `added` system fills it in. No initializer phase is needed, because creation can wait for the next system run and removal can't. @@ -195,8 +202,8 @@ Rule: anything that doesn't affect the API or correctness belongs in phase 2+. - **Cascades** are component traits, expanded in Plan with the rules. - **`within: host`:** the component exists only while its host does, whatever removes the host; transitively; in command order, so removing a host and adding it back ends its dependents. A derived host a rule keeps never leaves. A dependent added without its host is dropped. - **`reference: "owner" | "weak"`** on an entity-valued component: when the target despawns, or a reference is written to an entity that won't be alive, `owner` despawns the holder and `weak` removes the component, transitively. Only the value the plan leaves counts, so a reference pointed elsewhere in the same sync point stays. References change only through commands (systems can't list them in `components.write`), which keeps the index of holders by target exact. Without `reference`, a reference dangles and a lookup of it returns null. -- **Finalizers are the only removal mechanism.** `Finalizer.create({ name, components: { read, has, not }, lookup, resources, before, after }, (row, { resources, lookup, commands }) => …)`. They run inside the sync point, on the world's thread, against the final plan and live values from before Apply, with no copying. They are for releasing resources (GPU/DOM handles), updating indexes and adjusting aggregates, and they never miss a removal. - - **On another thread:** one whose objects live on another thread than the world's, which a joined thread may fill, runs there after the sync point, with its declared struct reads copied and its thread's objects as of before Apply ([`threads.md`](threads.md)). So it issues no commands, writes no resources and looks up nothing, in types and at creation; a changes finalizer watches and reads only the world's thread's values; and finalizers on different threads can't be ordered with `before` or `after`. +- **Finalizers are the only removal mechanism.** `Finalizer.create({ components: { with, not }, lookup, before, after }, (row, { resources, lookup, commands }) => …)`, what its body reads and writes inferred as a system's is. They run inside the sync point, on the world's thread, against the final plan and live values from before Apply, with no copying. They are for releasing resources (GPU/DOM handles), updating indexes and adjusting aggregates, and they never miss a removal. + - **On another thread:** one whose objects live on another thread than the world's, which a joined thread may fill, runs there after the sync point, with its declared struct and string reads and its resources copied and its thread's objects as of before Apply ([`threads.md`](threads.md)). So it issues no commands, writes no resources and looks up nothing, in types and at creation; a changes finalizer watches and reads only the world's thread's values; and finalizers on different threads can't be ordered with `before` or `after`. - **A stop:** the entity matched before the sync point and is despawned, loses a required component by any cause (a command, a rule, `within` or a reference), has one removed and added again, or gains an excluded one. An entity spawned and despawned in the same sync point never matched. - **Deferred handling:** a consumer that handles removals later appends what it needs (IDs, values) to a queue it owns during Finalize, and works through it whenever it runs. A consumer that tracks stops keeps state about the entities it saw start, and ignores stops for entities it doesn't know. - **Run once, against the final plan.** Their structural commands go to the **next** sync point. Re-planning would let a later round bring back a component an earlier round already finalized, for example a freed handle on an entity that survives. @@ -207,11 +214,11 @@ Rule: anything that doesn't affect the API or correctness belongs in phase 2+. - **Order:** `before` and `after` list other finalizers, then creation order. Two that both write a resource, or where one reads what the other writes, must be ordered. Every active finalizer runs at every sync point; none is in a composite, and the phase costs nothing when none is active. - **Throwing:** the plan still applies, and the run's report lists each failure. - **`dispose()`** runs per-query finalizers for every live match, so handles are released, and no whole-plan finalizers. -- **Whole-plan finalizers** see the sync point's structural changes rather than one query's stops, for persistence, replication and structural undo: `Finalizer.changes({ name, watch }, (changes, context) => …)`. Changes are grouped by archetype pair (`from`, `to`, null for spawned or despawned, `added`, `removed`, `replaced`, `entities`), `replaced` listing the components an add gave a value while the entity had them before and after, so an edit committed as one add is one change, filtered to `watch`, by default every component that isn't derived or an object a joined thread fills; a derived one is rejected. `previous(entity, component)` and `next(entity, component)` give values before and after. They are ordered with per-query finalizers. +- **Whole-plan finalizers** see the sync point's structural changes rather than one query's stops, for persistence, replication and structural undo: `Finalizer.changes({ watch }, (changes, context) => …)`. Changes are grouped by archetype pair (`from`, `to`, null for spawned or despawned, `added`, `removed`, `replaced`, `entities`), `replaced` listing the components an add gave a value while the entity had them before and after, so an edit committed as one add is one change, filtered to `watch`, by default every component that isn't derived or an object a joined thread fills; a derived one is rejected. `previous(entity, component)` and `next(entity, component)` give values before and after. They are ordered with per-query finalizers. - **Async resources** need no other API: a rule derives a slot; a system with `added: [slot]` writes a fresh token into it and starts the load; the completion calls `world.run(apply, { entity, token, value })`, which adds the ready component (`within: slot`) if the slot's token still matches and releases the value otherwise; a finalizer on the slot aborts the load, and one on the ready component releases it. Shared assets are reference-counted by the loader, since the ECS gives each user exactly one acquire and one release. - **Reductions** compute one value from many rows, in kernels across workers as well as on the main thread. - - **The operator is a resource trait,** `Resource.register(total, { type: { type: "number", default: 0 }, reduce: "+" })`: `"+" "*" "min" "max" "&" "|" "^"`, applied leaf by leaf to numbers, tuples and structs, and on booleans only bitwise. A contributing system repeats it, `reduce: { [total]: "+" }`; the types require the two to match and registration checks it again, so changing a resource's operator breaks every contributor. + - **The operator is a resource trait,** `[total]: { type: F32; default: 0; reduce: "+" }`: `"+" "*" "min" "max" "&" "|" "^"`, applied leaf by leaf to numbers, tuples and structs, and on booleans only bitwise. A contributing system lists the resource, `resources: { reduce: [total] }`, and folds with its trait's operator, which a kernel reads at build time from the module declaring the resource: its augmentation in TypeScript, or in JavaScript, as a library's `dist` ships it, the registration its own build gave it; changing a resource's operator changes every contributor. - **The body contributes** with `reduce[total](value)`; it can't read the fold, and a contributor can't read or filter on what it reduces into. A reduced resource is never written, by systems or finalizers. - **Each run starts again:** a run that includes a contributor sets the resource to the operator's identity, and it ends up holding the fold of every contribution in that run, stamped changed only if it differs. Several systems may contribute in one run; they don't conflict with each other, and every system that reads the resource is ordered after all of them. A run without a contributor leaves it as it is, and before the first, it holds its `default`. - **A failed or skipped run:** when a contributor fails, or no contributor runs its rows, the resource keeps its previous value. @@ -220,8 +227,8 @@ Rule: anything that doesn't affect the API or correctness belongs in phase 2+. - **Contributions have a known shape:** a tuple or struct component contributed whole needs its leaves known, as whole-value writes do. - **Indexes** (`src/interface/indexes/`; its type tests pin the behavior) find rows by the values of stored components, for systems and finalizers. - - **Declared once, program-wide:** `Index.create({ name, key: [cell], components: { has, not }, sorted, unique })` returns an `Index`, typed by its declaration as written, so a library's emitted declarations name it from the package root and `Index<{ readonly sorted: true }>` accepts any sorted index. The plugin gives top-level bindings ids, as it gives systems. It holds the entities with every key part and `has` component and no `not` one. With an empty key, `key: []`, it holds them by membership alone, every entity but the resource entity when `has` and `not` are empty too, and isn't unique. Key parts are components of data; a sorted index orders numbers numerically, NaN last and -0 as +0, booleans false first, strings by code point, never locale, and tuples element by element, and ties by entity index. Object parts must all live on one thread, the world's beside a string part, since the world keeps keys of strings. A computed key is a component a system computes and writes, as any value. - - **Lookups:** `lookup: { near: { index: grid, read: [position], changed: [position] } }` gives the context a function taking the key's parts as arguments. A hash index takes the whole key and gives its bucket's rows in entity order; a sorted index takes a prefix of the key, possibly empty, whose last part may be a comparison (`{ ">=": t0, "<": t1 }`, `==`, `!=`, `<`, `<=`, `>`, `>=`, together narrowing it), and gives its rows in key order, then entity order; an index without a key takes none and gives every row it holds, in entity order; a unique index given a whole key gives the row or null. A list has `length`, `changed` and `at(i)`; a row has `entity` and what `read` names, which every entity the index holds must have. `changed` is whether an entity entered, left or moved in the list, or a component in `changed` changed in a row of it, since the system last ran; only those components keep ticks for it. The plugin gives each call site in a main-thread body its own list, reused, so a list is valid until its site runs again and a row until its list is reused; a call it didn't rewrite makes a new list. + - **Declared once, program-wide:** `Index.create({ key: [cell], components: { with, not }, sorted, unique })` returns an `Index`, typed by its declaration as written, so a library's emitted declarations name it from the package root and `Index<{ readonly sorted: true }>` accepts any sorted index. The plugin gives top-level bindings ids, as it gives systems. It holds the entities with every key part and `has` component and no `not` one. With an empty key, `key: []`, it holds them by membership alone, every entity but the resource entity when `has` and `not` are empty too, and isn't unique. Key parts are components of data; a sorted index orders numbers numerically, NaN last and -0 as +0, booleans false first, strings by code point, never locale, and tuples element by element, and ties by entity index. Object parts must all live on one thread, the world's beside a string part, since the world keeps keys of strings. A computed key is a component a system computes and writes, as any value. + - **Lookups:** `lookup: { near: { index: grid, changed: [position] } }` gives the context a function taking the key's parts as arguments. A hash index takes the whole key and gives its bucket's rows in entity order; a sorted index takes a prefix of the key, possibly empty, whose last part may be a comparison (`{ ">=": t0, "<": t1 }`, `==`, `!=`, `<`, `<=`, `>`, `>=`, together narrowing it), and gives its rows in key order, then entity order; an index without a key takes none and gives every row it holds, in entity order; a unique index given a whole key gives the row or null. A list has `length`, `changed` and `at(i)`; a row has `entity` and what `read` names, which every entity the index holds must have. `changed` is whether an entity entered, left or moved in the list, or a component in `changed` changed in a row of it, since the system last ran; only those components keep ticks for it. The plugin gives each call site in a main-thread body its own list, reused, so a list is valid until its site runs again and a row until its list is reused; a call it didn't rewrite makes a new list. - **`changed: true`** on a system's index lookup makes the index a change filter, as a resource in `changed` is: every row passes when, since the system last ran, an entity entered or left the index or moved to another key or position in it, as its refreshes count them; a rebuild counts as a change, and a key written the same, a move to another archetype, or a change to what the index doesn't key on doesn't. With several, any one changing passes. A system whose only change filters these are, with no `up`, is skipped before its body when none changed, as when a condition fails, keeping its `lastRun`, at one comparison per such lookup, against a tick each index keeps of its last refresh that changed an entry; with other change filters its rows pass by them otherwise. Conditions still decide first. Its lists' `changed` then reports entries alone. Observer systems and CPU kernels take it, the world deciding before it calls them; finalizers, which run per entity, don't, and a system on a joined thread, or an observer placed on or added on one, is rejected, the decision needing the world's index. Systems without it pay one boolean test per run. - **`order: index`** runs a system's rows that a sorted index holds in its order, with change filters the rows that pass; commands and reductions follow it. Not with `up`, and not on GPU kernels. - **Scheduling and currency:** a system using an index, through a lookup or `order`, reads its key parts, for ordering, conflicts and placement, and can't write them or what it reads through the index. It sees key values written by systems ordered before it in the run, and entities as of the last sync point; a finalizer sees the index as of before its sync point applies, as its lookups see rows. @@ -234,7 +241,7 @@ Rule: anything that doesn't affect the API or correctness belongs in phase 2+. 1. **Kernel subset.** Kernels compile to efficient JS or WASM. Main-thread systems run as normal JS with every feature available. - **Parser:** the bundler's own (`this.parse`, ESTree, in both Vite and Rolldown); the compiler validates the subset on that tree, and an ESLint rule can share the validator since ESLint also produces ESTree. A second JS parser would only add size and upkeep. - **Closed world:** every input comes from the ECS (query rows, resources). - - **Precision:** arithmetic has at least the storage precision. Results are not bit-exact across backends (JS, WASM, GPU). + - **Precision:** a CPU kernel compiled without `kernel: true` computes as its body as written does, in f64, rounding only where it stores, in JS and WASM alike, so compiling it never changes a result. A kernel the declaration asks for may compute at storage precision, as WASM's f32 SIMD lanes do, rounding each step: about 4.4 times faster for `move` and `chain` than one row at a time in f64, and not bit-exact with the body as written. GPU kernels compute in f32. - **Imported functions:** kernels call pure functions and use constants from any module the build resolves to a file, the kernel's own module included: user TypeScript or JavaScript, other packages' ESM, and the package's math types. The plugin reads each module a kernel reaches, with types stripped, follows its imports and re-exports, and inlines each call compiled in its own module's scope, so tuples are scalarized and never allocate, on JS, WASM and the GPU alike. A function outside the subset fails the build at the kernel's call, with a frame naming the file and line of each inlined function down to the failure. Recursion fails, also across modules. A module's constants must be known at build time: a `let`, or a mutation at runtime, isn't seen. Editing an inlined file compiles its kernels again, in dev too. - **Math types:** the package exports math types that are also namespaces of functions: `Vec2`, `Vec3`, `Vec4` (each `F32`, with `I32` and `U32` nested beside it), `Quat`, `Mat4x4`, and the scalars `F32`, `I32`, `U32`, each with a `schema`. They follow WGSL's semantics, and where WGSL leaves a result indeterminate they do what is fastest: integer vectors wrap, `round` rounds halfway values to even, `mod` is `x - m * floor(x / m)`, and `normalize` of a zero vector is NaN. Integer functions are defined for integers in range, and `pow` for a positive base; other inputs give indeterminate results, which may differ between the CPU and the GPU. The few that throw (`Mat4x4.inverse`, `Mat4x4.perspective`, `Mat4x4.lookAt`) can't be used in kernels. - **WGSL builtins:** a math function whose WGSL builtin matches it declares that explicitly, in its own file (`intrinsic({ wgsl: "normalize", parameters: ["vec3f"], result: "vec3f" }, implementation)`). In a GPU kernel a call to it becomes an intrinsic region holding its operands and its inlined body: WGSL emits the builtin on vector and matrix types, nesting calls as builtins do, and JS and WASM run the body, so CPU output is unchanged. Locals keep their JS names in WGSL, and a region's result is named after its function. @@ -266,11 +273,11 @@ Measured with `pnpm perf` (benchmarks bundled as in production, on plain node) a - **Commands:** typed parallel arrays with explicit counts, reused per system across runs; ids resolved when issued, and each list command records its shape. Kernels issue commands through `begin`/`value`/`component` by key index, allocating nothing; the compiler gives every component of a command's list a value, `undefined` for one it leaves out, which the runtime writes as the component's start. Workers write theirs into a command ring in shared memory, structs as bytes in their storages' layout and other values as tags, which the world drains at the sync point into buffers whose struct values stay bytes in the ring, copied by word as they apply (`threads.md`). - **Kernel spawns:** staged, not commands. The compiler lists each kernel's spawn lists, and each spawn calls its list's spawner with one value per component, generated per list as main-thread spawners are. The main thread stages into batches per runner, and each chunk's buffer names the rows staged while it was current; a worker stages per task and copies its batches into its command ring after the chunk's commands. The sync point commits staged rows in merge order, right after main-thread batches and before any command, so row order doesn't depend on which thread ran a chunk. Kernel spawners reserve entities in blocks, from 8 positions doubling to 4096, so workers don't contend for the cursor; each run or task ends its block, and the sync point returns the positions it left unused to the free list with their generations. - **Sync point:** plans straight over the queued buffers into reusable typed arrays, through cached archetype edges (arrays by component id), per-pair move lists, and for each list add or remove a table from source archetype to target; archetypes grow once per sync point; common layouts are written inline rather than through layout closures. Applying groups entries by source and target archetype: each pair copies the columns its archetypes share column by column for its block of rows and writes derived defaults once per column, then each entry's values are written. Rows left are removed last entry first, so an archetype moving out whole drops its rows without copying them; a row moved into a hole that another entry still leaves takes that entry's place. -- **Chunk ticks:** every storage keeps the newest `added`/`changed` tick per 1024-row chunk, stamped with every row tick, so change filters skip unchanged chunks. Stamps every row reaches are made once per kernel call, per chunk. +- **Chunk and block ticks:** every storage keeps the newest `added`/`changed` tick per 1024-row chunk and per 64-row block, both stamped with every row tick and raised by a row copied in, in shared memory beside the row ticks, so change filters skip unchanged chunks, then the unchanged blocks of a chunk that changed: finding a chunk's passing rows costs its sixteen block ticks and the rows of the blocks that changed, not its 1024 row ticks. Writers store a row's tick, then its block's, then its chunk's, the reverse of the order filters read them. Stamps every row reaches are made once per kernel call, per block and chunk. - **Ticks only where observed:** a component has tick columns only once some registered system filters on its `added` or `changed`, and resources always do; until then nothing allocates or stamps them, and once started they stay, bounded by the components. Columns start at the current tick, so a new system, starting with `lastRun = 0`, sees every row once. Kernels also drop `changed` stamps for components nothing watches: a count per component of the registered systems filtering on its `changed` and of the finalizers' indexes following it, an index's for the world's life. A count turning positive reloads the kernels that dropped its stamps; one falling to zero reloads nothing, and kernels drop its stamps when they next load for another reason, so a filter coming and going, as a system run once does, reloads nothing. - **What a system holds lives as long as its value:** a world keeps its schedules and runners by system value, weakly, so a system nothing else references, such as one made for a single run, is collected with its runner, command buffers and `lastRun`, while a module's systems keep theirs between runs. What a runner holds beyond its JS values (reduction partials and `up` orders in the arena, lookup directories, GPU records, its hook after each sync point, its count of watched components) is released once its system is collected, through a finalization registry whose records hold nothing of the system. Runs read a schedule's runners again only after one was replaced or removed, as placements, device changes and `remove` do; a removed one reads as a runner that runs nothing, and a registered composite holding a system forgotten since registers again on its next run, keeping its other members' runners. Ids of keys, added resources and tick columns stay, bounded by the components; the registry forgets a collected item without an id, its label with it. `lifetime.test.ts` runs 10,000 new systems, each once, and finds every table and the heap as they were. - **Kernels where WASM can't compile** (a content security policy without `'wasm-unsafe-eval'`) run their compiled JS. -- **Change filters inside WASM kernels:** one call per archetype or worker chunk; chunks and rows are skipped in WASM. With SIMD, outside `up`, each aligned block of eight rows checks its ticks at once and is skipped when none passes. +- **Change filters inside WASM kernels:** one call per archetype or worker chunk; chunks, blocks and rows are skipped in WASM. An outer loop skips chunks whose ticks aren't newer, reads a passing chunk's block ticks into a mask, and finds each run of passing blocks with `ctz`; an inner loop runs the run's rows, at one comparison a row. A chunk with more than half its blocks passing is one run. With SIMD, outside `up`, each aligned group of eight rows checks its ticks at once and is skipped when none passes. - **Reductions in WASM kernels:** sums, products, minimums and maximums fold consecutive rows into four lanes in turn, so a fold doesn't wait on the one before. - **Holders:** each reference component keeps the holders of each target as linked lists by entity index, updated from the plan's reference changes at Apply, so cascades find a despawned target's holders without a scan. - **Lookups in WASM kernels:** a directory per lookup in shared memory (per archetype: a match flag, column addresses, and for `changed` each watched component's tick column, 0 without ticks), rebuilt at sync points and once a registration starts ticks; finds check index and generation against the shared entity table. A lookup with `changed` reads its system's `lastRun` after the lookup parameters, -1 once a GPU kernel stamped a whole watched column since; JS kernels on workers read it there too, and a JS kernel's result holds `changed` after its values. @@ -284,10 +291,10 @@ Measured with `pnpm perf` (benchmarks bundled as in production, on plain node) a - **The observer pass:** each list of observer systems keeps its runners, read again once runners change, and whether any looks up through an index, so an idle pass reads no index and no GPU state: `observer idle 100` about 80 ns an observer system a tick, and a run of observer systems alone skips its empty sync point: `run observer` 0.21 µs against 0.38–0.42 for `run idle`. One on a joined thread is posted only when its conditions hold and a filter may pass, by its resources' and matched chunks' ticks, into a block of its own, waking each thread once a pass; that thread reads the shared memory directly, the next writer copying what it hasn't read yet, or, for one reading only struct resources, reads copies the post holds, written into one of two blocks so the world never waits for it: `joined observer resource` 4.3–5.0 µs a tick and `joined observer 10K` 0.41–0.51 ms in node; 7.2–7.5 µs and 0.34–0.50 ms in chromium. - **Indexes:** - Keys are encoded as order-preserving words (f32 and f64 bits flipped, NaN last and -0 as +0, i32 with its sign bit flipped, booleans 0 or 1, object parts as ids interned per index), hashed by a multiply-xorshift mix, the same in JS and WASM. - - Hash indexes: two-word slots of hash and bucket beside bucket records of hash, segment and key, so a probe reads one line until a hash matches; each bucket a segment of one entries buffer with spare slots, its entries (entity index, archetype, row) in entity order; ticks by bucket for `changed`, membership's and each watched component's. Sorted indexes: blocks of at most 128 entries in key order, then entity order, listed in key order, with a fence of each block's last key and entity beside them, a Fenwick tree of block lengths for positions, and segment trees of block ticks, so a range's `changed` is O(log blocks), and the whole index's the tree's root; entries are padded to an even number of words, so a shift within a block moves 8-byte words, and a block split or dropped refreshes positions, fences and trees from its position on. An index without a key is a sorted table with no key words, its entries in entity order: an entity entering or leaving moves at most a block's entries, found by binary search by entity, and writes to what it holds by cost it nothing, since it reads no key ticks; a watched component's change stamps one block, found by chunk ticks. `keyless refresh 1M 1%` 187 ns an entity entering or leaving, `keyless spawn 1M 1%` 105 ns an entity spawned or despawned at the end of entity order, `keyless index idle` 7 ns an index, `keyless length` 5 ns a lookup reading `length` and `changed`. All in the arena. - - Kept current before each tier holding a user, from the journal and the ticks of key parts, which systems' writes and sync points' values mark per component, so an idle refresh reads no ticks: `index idle` 9 ns an index. Changed rows move their entries, short buckets one by one and long ones in one merge, sorted blocks split and dropped: `index refresh 1M 1%` 1.4 ms. Once the rows it lists pass an eighth of the index, it's built again. + - Hash indexes: two-word slots of hash and bucket beside bucket records of hash, segment and key, so a probe reads one line until a hash matches; each bucket a segment of one entries buffer with spare slots, its entries (entity index, archetype, row) in entity order; ticks by bucket for `changed`, membership's and each watched component's. A change to a bucket of at least 64 entries, in an index that isn't unique, may leave it unsorted, so moving an entity between two long buckets costs O(log bucket) to find it and O(1) amortized to move it, not a shift of the bucket: an entry leaving the part in entity order leaves a hole (archetype -1, its entity index kept for binary search) and one arriving out of order joins a tail, its position kept by entity index, leaving it by swap-remove; the record's length counts entries, not holes, so `length` and `changed` read no order. An unsorted bucket is sorted, its tail sorted aside, its holes closed and the tail merged in from the end, when a list on the world's thread first reads it by position, when the index's arrays are handed to a kernel or another thread (`writeIndexParameters`), when its segment is full and has holes, closing them in place rather than growing it, and once its holes and tail pass four times its entries; a segment an unsorted bucket outgrows gets half its length more spare slots, so sorts to make room are O(1) amortized; long moves copy 8-byte words, since shared memory copies an element at a time. `bucket move 100K length` 5.5 µs a move between two buckets of 50K and a run reading both lengths, against 423 µs kept in order; `bucket move 100K in order` 69 µs, both buckets sorted again for a read of their first rows. Sorted indexes: blocks of at most 128 entries in key order, then entity order, listed in key order, with a fence of each block's last key and entity beside them, a Fenwick tree of block lengths for positions, and segment trees of block ticks, so a range's `changed` is O(log blocks), and the whole index's the tree's root; entries are padded to an even number of words, so a shift within a block moves 8-byte words, and a block split or dropped refreshes positions, fences and trees from its position on. An index without a key is a sorted table with no key words, its entries in entity order: an entity entering or leaving moves at most a block's entries, found by binary search by entity, and writes to what it holds by cost it nothing, since it reads no key ticks; a watched component's change stamps one block, found by chunk ticks. `keyless refresh 1M 1%` 187 ns an entity entering or leaving, `keyless spawn 1M 1%` 105 ns an entity spawned or despawned at the end of entity order, `keyless index idle` 7 ns an index, `keyless length` 5 ns a lookup reading `length` and `changed`. All in the arena. + - Kept current before each tier holding a user, from the journal and the ticks of key parts, which systems' writes and sync points' values mark per component, so an idle refresh reads no ticks: `index idle` 9 ns an index. Changed rows move their entries, short buckets kept in entity order and long ones left unsorted, sorted blocks split and dropped: `index refresh 1M 1%` 1.4 ms. Once the rows it lists pass an eighth of the index, it's built again. - Built again in four phases over fixed pieces of entity indexes and fixed partitions of the slots, so the index is the same whatever the number of workers: counting each piece's members by partition, listing them into partition order through code generated for the key's leaves, grouping each partition's keys into buckets, and filling its range of the table. Workers claim phases through their task rings; with no pool the world's thread runs them: `index rebuild 1M` 27 ms on the world's thread, 6.7 ms across nine workers. Sorted indexes are built on the world's thread, by LSD radix sort over 16-bit digits. - - Main-thread lookups: the plugin rewrites each call into a site of its own, literal tuples and comparisons flattened into scalar arguments; a hash site's encoding, hash and probe are generated code, a loop where a content security policy forbids it. Lists, and the rows they give, share one hidden class each, their accessors the same functions reading their state through `this`; a site's lookup makes its rows ahead of `at`, for a list's first 256 positions and a sorted list's last 256, and on the world's thread rows check one count of replaced arrays rather than their archetype's version: `unique lookup 1M` 44 ns a lookup, `children lookup` 13 ns a child, `sorted range 1M` 0.56 µs a range of ten, `sorted page 100K` 5 µs a change against 1.1 ms for `select page 100K`. + - Main-thread lookups: the plugin rewrites each call into a site of its own, literal tuples and comparisons flattened into scalar arguments; a hash site's encoding, hash and probe are generated code, a loop where a content security policy forbids it. Lists, and the rows they give, share one hidden class each, their accessors the same functions reading their state through `this`; a site's lookup makes its rows ahead of `at`, for a list's first 256 positions and a sorted list's last 256, and rows check one count of replaced arrays rather than their archetype's version, on a joined thread settled once a run: `unique lookup 1M` 44 ns a lookup, `children lookup` 13 ns a child, `sorted range 1M` 0.56 µs a range of ten, `sorted page 100K` 5 µs a change against 1.1 ms for `select page 100K`. - Kernels compile each lookup call into a probe the world binds to its index's layout; WASM probes in straight-line code, reading the index's arrays at addresses each run's parameters give, on the world's thread and pool workers; joined threads read a record of them the world writes before each task: `grid neighbors 100K` 3.7 µs a row on the world's thread, 179 ns a row in a kernel across the pool. - `order`: main-thread bodies walk the index's entries; filtered runs sort passing rows' positions while few pass and scan past an eighth; kernels run lists of rows as `up`'s do: `order run 100K` 31 ns a row against 28 in storage order. - **`up`:** @@ -295,7 +302,7 @@ Measured with `pnpm perf` (benchmarks bundled as in production, on plain node) a - Each system keeps a tree by entity index (parent, level, children as linked lists), read from the journal; levels settle by walking up to a root or a settled entity, which also finds cycles. - The order: groups of one depth, archetype and parent archetype, as segments of one arena buffer with spare slots, patched in two phases by binary search and block moves within a group, so rows keep storage order. - Rebuilt in full, in O(rows), when the journal dropped entries a system hadn't read, or when more than a quarter of the order's rows would move. - - Filtered runs mark entities into lists by depth, from chunk ticks and the journal; a depth with few marks is sorted, one with many is scanned in order. + - Filtered runs mark entities into lists by depth, from chunk and block ticks and the journal; a depth with few marks is sorted, one with many is scanned in order. - WASM kernels run a group's row list, with the parent archetype's column addresses as parameters. - Workers take one depth at a time as claimed pieces, with a barrier per depth; depths too small to split run on the main thread without a wait. - The GPU runs a dispatch per group, in order, within one pass; row lists live in the heap and upload only when the order changes. @@ -326,7 +333,7 @@ Measured with `pnpm perf` (benchmarks bundled as in production, on plain node) a - One dispatch over several archetypes, and indirect draws; heap segments past `maxBufferSize`; automatic placement. - Finalizers on components ahead on the GPU: until then, the sync point reads them back first. - Read back only the rows a sync point moves, rather than whole disturbed archetypes, or move them on the GPU. -- **GPU-only components, not planned until a workload needs one.** A const meta tag on the component entity (components are entities), so its values have a column in the GPU heap and none in the CPU arena; entities, archetypes and `has`/`not` stay on the CPU. Values given at spawn are uploaded once; otherwise rows start at the schema default. +- **GPU-only components, not planned until a workload needs one.** A const meta tag on the component entity (components are entities), so its values have a column in the GPU heap and none in the CPU arena; entities, archetypes and `has`/`not` stay on the CPU. Values given at spawn are uploaded once; otherwise rows start at their start. - **The use case:** GPU state with history that nothing on the CPU reads, such as a simulation integrated only on the GPU, previous-frame transforms for motion vectors, or accumulators. Data recomputed every frame (culled lists, indirect draw arguments, sort keys) isn't state: render systems own those as scratch buffers. Cached derived data (world matrices, skinning palettes) is an optimization, not a need. - **What it adds over an ordinary component written only on the GPU,** which already stays GPU-resident and is never read back unless observed: the CPU column's memory, and a type-level guarantee that CPU systems and observers can't name it. - **What it requires:** structural changes on the GPU (the move kernel above), since there's no CPU copy to move. diff --git a/packages/ecs/followups.md b/packages/ecs/followups.md index 566e8c1e6..e6b893440 100644 --- a/packages/ecs/followups.md +++ b/packages/ecs/followups.md @@ -1,11 +1,93 @@ # ecs follow-ups -Open items awaiting a decision or work. Remove an item once it's decided or done. +Open items awaiting a decision or work, by priority: the API is the focus while it's being finalized; then +correctness, then optimization. Remove an item once it's decided or done. + +## API changes + +- **Run results: a run resolves to its body's value; reports leave the public API** (agreed in design, not started). + `run(system, input): Promise`, R the per-run body's return type (`void` for row systems, composites and bodies + returning nothing), so one-off reads are observer systems returning a value. A run whose systems failed rejects + after its sync point applies, with a useful error naming each failure; its exact shape is no contract. Ticks' + failures are rethrown out of band. `Report`, its archetype and types, and run numbers leave the public API; the + implementation keeps or drops reports as performance dictates. A later `World.create` options parameter may take + instrumentation (timing, failure logs, debugging); nothing until it's designed. +- **Nothing tells the world's thread a join finished.** After `World.join`, a run from the world's own thread can run + before the joined thread has claimed its threads, so that thread's observers miss it; the guide's threads section + runs from the joined world instead. Options: `World.join` resolving once claimed (already true on the joined side?), + or `world.share` returning a promise of the claim. Smallest form to be decided. A refused claim, such as one of a + thread an id-less finalizer runs on, shows the world's thread only `"gone"` and the joined thread only a rejected + first run, so a helper waiting for `"joined"` waits forever. +- **Optional components in GPU kernels:** each dispatch's record could carry a presence word, but WGSL has no + `undefined` to read where an archetype lacks one, and a GPU kernel can't fail its system for one row; supporting + them means a presence expression through the WASM and WGSL backends and a GPU semantics for absent reads and writes. +- **Optional groups:** components always present together (boards' text's four, path's three) must each be tested + for `undefined` before building one object. Possible: an optional group, present or absent as a whole, narrowed by + one test. Boards isn't asking for it; record usage before deciding. +- **Iterable lists:** `for (const row of list)` on index lookups' lists, binding each row as the loop reaches it with + no allocation (the list is its own iterator, reset per loop), lowered by kernels to the indexed loop. `list[i]` and + array methods stay out: indexing can't bind lazily without a Proxy, and the methods would widen the surface. +- **`watch` by trait on changes finalizers:** `Finalizer.changes({ watch: "persist" }, …)`, `watch` taking a trait name as + well as a list of keys, typed as `Component.With<…>`, computed once per component id as `watches` already is. The + hook external persistence would build on. +- **Observer systems can't declare `up`**, and `order` can't be combined with `up`, so views as separate entities + don't work as bindings. Proposal: allow `up` in observer systems, since it only reads; let `order` take an index over + the entity a reference names. (from the todo sample) +- **Per-row CPU values in render systems:** a render body's tables are `{ count, bind }`, so it can't draw with a + per-row object such as a texture's bind group, and a row body can't learn its GPU row. A consumer drawing an image + per row keeps its own instance buffer, 4,000 draws in 2.1 ms a tick. Possible: tables giving their entities in GPU + row order. Not needed until a consumer's profile says so. +- **Descending sorted indexes:** an ordered render reads its index front to back, so a consumer painting back to front + maps `instance_index` to `ecs_count() - 1u - instance_index`, which costs nothing. Possible: a `descending` option on + sorted indexes. Not needed until a consumer's profile or readability says so. +- **Report retention:** the last 256 runs keep their reports (`retainedRuns` in `reports.ts`), a fixed internal + constant; a later run takes the oldest one's entity. Recommended: keep it internal until a consumer needs another; then + a world option or resource. +- **Report components are writable:** systems may write or despawn reports, which are ordinary entities; a despawned + one is spawned again for a later run. Recommended: accept, as with any entity; or make them world-written as `clock` + is. +- **Computed index keys:** an index keyed on a value computed from a component, such as a struct field + (`position.x`, `Report.run.number`), instead of only whole components. Two possible forms: + `key: [[Report.run, "number"]]` (a field path) or `keyOf: run => run.number` (a function). Open questions: + - how key words are laid out in shared memory; + - lookups on workers, on joined threads and in kernels (JS and WASM); + - change tracking that follows the field, not the whole component; + - whether a function form can run anywhere but the world's thread. +- **Discriminated struct unions:** unions of structs with a number, boolean or string-literal discriminant, stored in + fixed size (a tag column plus the variants' fields) so they live in shared memory. Today `isStruct` + (`interface/registry/schemas.ts`) treats every `anyOf` as an object. The work covers row readers and writers, + narrowing on the tag in the kernel compiler, WGSL layouts, and snapshots. Check first whether the type-level + `IsStruct` already calls numeric-tagged unions structs while the runtime doesn't. +- **The `Finalizer` namespace** (from earlier open work; to be restated before it's discussed). + +## Correctness + +- **Transactions, future:** never by default, since they would cost every run. An explicit declarative option on a + one-off system, making its run all or nothing. + +## Optimization + +- **A run allocates about 3.6 KB even for an empty system,** in node: the run path's async functions (`executeTiers`, + `perform`, `execute`, `runWith`), their promises and a few small objects. Make the path allocate nothing when nothing + waits, and measure with a sampling heap profile. +- **`transfer`, chromium:** three runs on a quiet machine gave 19.9, 22.5 and 16.1 ms, so its spread of about 20% is + its own, not the machine's. Find what varies (likely the time a worker takes to wake) before treating its changes + as signal. +- Index step 5 items. +- Starting the pool for large index builds. +- `order` on workers. +- Chromium timer clamps. +- The `transfer` cost. +- The perf.md "Next" items. -## Queued (decided, not started) +## External utilities +- **Persistence,** never part of the core: provided externally through custom traits (e.g. `persist: string`, the stable + name saved data maps through, independent of thread ids) and changes finalizers watching by trait. +- **A test helper awaiting the next tick.** Tests watch `clock` with an observer system today; a helper in + `src/observe` would do it (from the todo sample). -## Decisions for the maintainers +## Decisions awaiting confirmation - **An index lookup's `changed: true` is a change filter,** as a resource in `changed` is: when the index changed every row passes, and otherwise rows pass by the system's other change filters, so with `components.changed` it runs the @@ -29,31 +111,13 @@ Open items awaiting a decision or work. Remove an item once it's decided or done - **Filtering in the todo sample:** it writes `hidden` on each row (about 2 ms of script at 10K) rather than setting one attribute on the list and hiding rows with CSS. The attribute version is slower once laid out (25 ms against 17 ms at 10K) and puts the filter's meaning in the list element. -- **One-off reads:** `world.run` returns no value, so a one-off read needs an observer system that writes into an outer - variable. -- **Per-row CPU values in render systems:** a render body's tables are `{ count, bind }`, so it can't draw with a - per-row object such as a texture's bind group, and a row body can't learn its GPU row. A consumer drawing an image - per row keeps its own instance buffer, 4,000 draws in 2.1 ms a tick. Possible: tables giving their entities in GPU - row order. Not needed until a consumer's profile says so. -- **Paint order across render tables:** a render system draws each matching archetype as its own table in row order, - so 2D content of several kinds (shapes, images, paths) can't be blended back to front without the consumer keeping - its own sorted instance list. Proposal: `order: index` on `System.render`, giving one table spanning its archetypes - whose `bind` sets a buffer of the index's rows in sorted order with each archetype's column base addresses; every - column already lives in one heap, so accessors resolve a sorted instance to its row. Not needed while consumers draw - every kind with one shader from their own list. -- **Report retention:** the last 256 runs keep their reports (`retainedRuns` in `reports.ts`), a fixed internal - constant; a later run takes the oldest one's entity. Recommended: keep it internal until a consumer needs another; then - a world option or resource. - **The cost of a report for every run:** a clean run costs about 125 ns more in node (`run idle` 333 to 458 ns, `run observer` 166 to 291), half of it the two clock reads `elapsed` takes. Decided: timing stays on. Check again if runs get cheap enough for it to matter: a `Report.timing` resource, off by default with `elapsed` NaN, measured `run idle` 416 ns and `run observer` 208. - **Reports are hidden from queries that require no component,** as the resource entity is: an index or system with no - `has` or `read`, and changes finalizers watching everything, don't see them. Recommended: accept, since they're the + `with`, and changes finalizers watching everything, don't see them. Recommended: accept, since they're the world's own. -- **Report components are writable:** systems may write or despawn reports, which are ordinary entities; a despawned - one is spawned again for a later run. Recommended: accept, as with any entity; or make them world-written as `clock` - is. - **Errors while tails run:** an add or removal of `Report.errors` found after a run's sync point waits for the next sync point while tails run on a joined thread, an earlier run's errors emptied meanwhile. Recommended: accept; errors known before the sync point, system failures, skipped systems and warnings, are in place when the run resolves. @@ -82,65 +146,3 @@ Open items awaiting a decision or work. Remove an item once it's decided or done - **An observer system on a joined thread that reads a string resource doesn't post copies of its resources:** nothing waits for such posts, and a handle there could outlive its record, so it posts as row systems do. Recommended: accept. -## Findings from the todo sample (`src/samples/todo/README.md`) - -2. **Observer systems can't declare `up`**, and `order` can't be combined with `up`, so views as separate entities - don't work as bindings. Proposal: allow `up` in observer systems, since it only reads; let `order` take an index over - the entity a reference names. -4. **Low-cardinality hash indexes cost O(bucket) per change.** Buckets are kept in entity order and shifted on removal: - one toggle costs 26, 62 and 400 µs at 1K, 10K and 100K. Proposal: unordered buckets with swap-remove and a position - map, or a count-only index. -5. **Change filters scan the whole dirty chunk** (1,024 row ticks), once per system that filters on `changed`. - Proposal: a second level of change ticks, one per 32–64 rows. -6. **Nothing awaits the next tick.** Tests watch `clock` with an observer system. Proposal: a test helper in - `src/observe`. - -## Findings from strings in shared memory - -- **A joined finalizer read a resource of its thread as undefined:** in `remote-finalizers.test.ts`, "that throw are in - a later run's report", the finalizer's `resources[tone]`, an object of its thread, is undefined for the stops of a - sync point soon after the thread joined, where the same finalizer reads it in the other cases; the fixture reads it - optionally now. The test checks no tone, so whether it was undefined before, as a string, wasn't seen. Worth finding - out whether a remote finalizer can run before its thread's resources reach its mirror. - -## Features to discuss - -- **Computed index keys:** an index keyed on a value computed from a component, such as a struct field - (`position.x`, `Report.run.number`), instead of only whole components. Two possible forms: - `key: [[Report.run, "number"]]` (a field path) or `keyOf: run => run.number` (a function). Open questions: - - how key words are laid out in shared memory; - - lookups on workers, on joined threads and in kernels (JS and WASM); - - change tracking that follows the field, not the whole component; - - whether a function form can run anywhere but the world's thread. -- **Discriminated struct unions:** unions of structs with a number, boolean or string-literal discriminant, stored in - fixed size (a tag column plus the variants' fields) so they live in shared memory. Today `isStruct` - (`interface/registry/schemas.ts`) treats every `anyOf` as an object. The work covers row readers and writers, - narrowing on the tag in the kernel compiler, WGSL layouts, and snapshots. Check first whether the type-level - `IsStruct` already calls numeric-tagged unions structs while the runtime doesn't. - -## Performance - -- **The filtered `up` benchmarks are slower than this machine's record at `e3c30aa1`:** `up one root changed` +49%, - `up filtered after a reparent` +45%, `up small tree in a big world` +79%. They're the same at `c6bb3c83`, before - strings, so the drift is older. Worth bisecting from `e3c30aa1`. - -- **A joined thread's first tail task once never finished,** under heavy machine load (two test suites at once): the - world waited on `channel.idle()` with task 1 posted and not done (`threads.browser.test.ts`, tails "see one tick's - values"). It didn't recur in 25 runs without the extra load, and the base failed once on another threads test in the - same comparison. Worth a stress run of the joined-thread suites under load. - -- **`transfer`, chromium:** its median varied with machine load. Check it alone on a quiet machine before the next - record. -- **`joined observer 10K`:** checking archetype versions once per post instead of per row would save some of the - thread's run (see perf.md "Next"). - -## Earlier open work - -- Index step 5 items. -- Starting the pool for large index builds. -- `order` on workers. -- Typed transfers (Phase 7). -- The `Finalizer` namespace. -- Chromium timer clamps. -- The `transfer` cost. -- The perf.md "Next" items. diff --git a/packages/ecs/memory.md b/packages/ecs/memory.md index be03816b5..78e6a12a8 100644 --- a/packages/ecs/memory.md +++ b/packages/ecs/memory.md @@ -5,6 +5,12 @@ decisions about ecs here rather than in a personal memory, so every machine and ## Interface +- **A minimal, abstract surface, as WebGPU's is.** Expose the smallest common surface that says what consumers mean, + and nothing about how it's done, so the implementation stays free to change and optimize. Prefer what the build can + derive over what consumers write; never expose a mechanism consumers could call instead of declaring intent, such + as registration. +- **Agents write most consumer code; type errors guide them, editor affordances don't.** Spend effort on precise types + and build errors, not on completions, editor plugins or other tooling only an IDE shows. - **Humans own the interface, agents own the implementation** (`src/interface/CLAUDE.md`). Agents add or widen nothing public: no exported types, built-in components or resources, fields, options, union members or observable behaviour. Any change that can't be avoided is proposed for review before it lands. diff --git a/packages/ecs/package.json b/packages/ecs/package.json index 8c605e03d..279400ed2 100644 --- a/packages/ecs/package.json +++ b/packages/ecs/package.json @@ -1,11 +1,14 @@ { "name": "@adobe/ecs", - "version": "0.1.0", + "version": "0.1.1", "description": "Adobe ECS", "license": "MIT", "type": "module", "sideEffects": false, "private": false, + "pages": [ + { "from": "docs", "to": "ecs" } + ], "independentVersion": true, "publishConfig": { "registry": "https://registry.npmjs.org/", @@ -30,14 +33,15 @@ ], "scripts": { "build": "cp ../../LICENSE . && tsc -b", - "dev": "tsc -b -w --preserveWatchOutput", + "dev": "tsc -b -w --preserveWatchOutput & vite --config vite.sample.config.ts", "typecheck": "tsc -b", "test": "pnpm exec playwright install chromium && vitest --run", "test:perf": "pnpm exec playwright install chromium && vitest --run --config vitest.perf.config.ts", "perf": "node scripts/perf.mjs", "perf:browser": "node scripts/perf.mjs --browser", "perf:report": "node scripts/perf.mjs --report", - "sample": "vite --config vite.sample.config.ts" + "sample": "vite --config vite.sample.config.ts", + "docs": "typedoc" }, "devDependencies": { "@types/estree": "^1.0.5", @@ -45,6 +49,7 @@ "@vitest/browser-playwright": "^5.0.5", "@webgpu/types": "^0.1.61", "playwright": "^1.64.1", + "typedoc": "^0.28.9", "typescript": "^5.8.3", "vite": "^8.3.4", "vitest": "^5.0.5" diff --git a/packages/ecs/scripts/typedoc-guide-links.mjs b/packages/ecs/scripts/typedoc-guide-links.mjs new file mode 100644 index 000000000..6b3121086 --- /dev/null +++ b/packages/ecs/scripts/typedoc-guide-links.mjs @@ -0,0 +1,23 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +// Points the guide's links to the tests its excerpts come from at those files on GitHub, where they render with line +// highlights, instead of at copies typedoc would serve as raw files. +import { rmSync } from "node:fs"; +import { join } from "node:path"; +import { MarkdownEvent, RendererEvent } from "typedoc"; + +const source = "https://github.com/adobe/data/blob/main/packages/ecs/src/guide/"; +const copied = /href="(?:\.\.\/)*media\/([\w.-]+\.ts)(#L\d+(?:-L\d+)?)?"/g; + +/** @param {import("typedoc").Application} app */ +export const load = app => { + const files = new Set(); + app.renderer.on(MarkdownEvent.PARSE, event => { + event.parsedText = event.parsedText.replace(copied, (_, file, lines = "") => { + files.add(file); + return `href="${source}${file}${lines}"`; + }); + }); + app.renderer.on(RendererEvent.END, event => { + for (const file of files) rmSync(join(event.outputDirectory, "media", file), { force: true }); + }); +}; diff --git a/packages/ecs/src/exports.test.ts b/packages/ecs/src/exports.test.ts index 161675a29..6624b0c6b 100644 --- a/packages/ecs/src/exports.test.ts +++ b/packages/ecs/src/exports.test.ts @@ -29,31 +29,28 @@ const install = (): void => { }; const consumer = ` -import { Archetype, Component, Index, Rule, System, World, gpuDevice, workers } from "@adobe/ecs"; +import { Index, Rule, System, World, gpuDevice, workers } from "@adobe/ecs"; export const position = Symbol("exportsTest/position"); export const element = Symbol("exportsTest/element"); export const shown = Symbol("exportsTest/shown"); declare module "@adobe/ecs" { interface Components { [position]: { type: number }; [element]: { type: HTMLElement; thread: "main" }; [shown]: { type: true; derived: true } } } -Component.register(position, { type: { type: "number" } }); -Component.register(element, { thread: "main" }); -Component.register(shown, { type: { const: true }, derived: true }); -export const Placed = Archetype.create([position, element]); -export const showing = Rule.create({ components: { has: [element] }, derive: [shown] }); -export const move = System.create({ name: "exportsTest/move", components: { write: [position] } }, row => { row[position] += 1; }); -export const all = System.create({ name: "exportsTest/all", systems: [move] }); -export const draw = System.render({ name: "exportsTest/draw", shader: { accessors: {} } }, () => {}); -export const placed = Index.create({ name: "exportsTest/placed", key: [position], components: { has: [element] } }); +export const Placed = [position, element] as const; +export const showing = Rule.create({ components: { with: [element] }, derive: [shown] }); +export const move = System.create({ components: { with: [position] } }, row => { row[position] += 1; }); +export const all = System.create({ systems: [move] }); +export const draw = System.render({ shader: { accessors: {} } }, () => {}); +export const placed = Index.create({ key: [position], components: { with: [element] } }); export const ranked = Index.create({ key: [position], sorted: true }); -export const after = System.create({ components: { read: [position] }, lookup: { next: { index: ranked, read: [position] } } }, (row, { lookup }) => { void lookup.next({ ">": row[position] }).length; }); +export const after = System.create({ components: { with: [position] }, lookup: { next: { index: ranked } } }, (row, { lookup }) => { void lookup.next({ ">": row[position] }).length; }); export const sortedName = (index: Index<{ readonly sorted: true }>): string => index.name; // A world comes from the one import every other value does. export const world: World = World.create({ [workers]: 0 }); // Another thread joins it through the other end of a channel the world shares. declare const port: MessagePort; export const joined: World = World.join(port, { threads: ["main"] }); -export const read = System.create({ observer: true, components: { read: [position] }, resources: { read: [gpuDevice] }, lookup: { at: { read: [position] } } }, () => {}); +export const read = System.create({ observer: true, components: { with: [position] }, lookup: { at: { with: [position] } } }, () => {}); export const added = world.add(read); `; @@ -78,7 +75,7 @@ const internal = (path: string): boolean => || path.startsWith("interface/gpu/record"); /** Compiles a consumer's `index.ts` against the installed package, emitting its declarations. */ -const compile = (source: string): { readonly messages: string[] } => { +const compile = (source: string): { readonly messages: string[]; readonly lines: number[] } => { writeFileSync(join(temporary, "index.ts"), source); const program = ts.createProgram([join(temporary, "index.ts")], { target: ts.ScriptTarget.ESNext, @@ -92,36 +89,82 @@ const compile = (source: string): { readonly messages: string[] } => { outDir: join(temporary, "out"), }); const result = program.emit(); - return { messages: messages([...ts.getPreEmitDiagnostics(program), ...result.diagnostics]) }; + const diagnostics = [...ts.getPreEmitDiagnostics(program), ...result.diagnostics]; + // Each diagnostic's line in the consumer, 1-based; 0 for one outside it. + const lines = diagnostics.map(diagnostic => diagnostic.file === undefined || diagnostic.start === undefined || !diagnostic.file.fileName.endsWith("index.ts") + ? 0 + : diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start).line + 1); + return { messages: messages(diagnostics), lines }; }; -/** A consumer that registers a symbol-keyed component beside the augmentation given. */ -const stringKeyed = (augmentation: string, registration = ""): string => ` -import { Component, Trait } from "@adobe/ecs"; -export const keyed = Symbol("exportsTest/keyed"); -declare module "@adobe/ecs" { - interface Components { [keyed]: { type: number } } - ${augmentation} -} -Component.register(keyed, { type: { type: "number" } }); -${registration} -`; +/** The 1-based lines of `source` holding `text`. */ +const linesWith = (source: string, text: string): number[] => + source.split("\n").flatMap((line, index) => line.includes(text) ? [index + 1] : []); /** A consumer with a system whose declaration lists a key in both read and write, and composites of conflicting inputs. */ const misdeclared = ` -import { Component, System } from "@adobe/ecs"; +import { System } from "@adobe/ecs"; export const health = Symbol("exportsTest/health"); declare module "@adobe/ecs" { interface Components { [health]: { type: number } } } -Component.register(health, { type: { type: "number" } }); -export const both = System.create({ components: { read: [health], write: [health] } }, () => {}); -const near = System.create({ input: { type: "object", properties: { distance: { type: "number" } } } }, () => {}); -const far = System.create({ input: { type: "object", properties: { distance: { type: "string" } } } }, () => {}); +// What a body reads is inferred, never declared. +export const both = System.create({ components: { read: [health] } }, () => {}); +const near = System.create({}, (_context, input: { readonly distance: number }) => { void input; }); +const far = System.create({}, (_context, input: { readonly distance: string }) => { void input; }); export const listed = System.create([near, far]); export const declared = System.create({ systems: [near, far], after: [both] }); `; +/** A consumer creating two worlds and using them, with `declarations` augmenting the package. */ +const creating = (declarations: string): string => ` +import { World, workers, type Component } from "@adobe/ecs"; +${declarations} +export const world: World = World.create(); +export const other: World = World.create({ [workers]: 0 }); +export const typed: World = world; +export const disposed = other.dispose(); +`; + +/** A trait of your own on a key its type doesn't allow. */ +const customTrait = creating(` +export const instanced = Symbol(); +export const foo = Symbol(); +declare module "@adobe/ecs" { + interface Traits { readonly [instanced]: N extends Component.Key ? ([Component.Value] extends [readonly number[]] ? true : never) : never } + interface Components { [foo]: { type: string; [instanced]: true } } +}`); + +/** A built-in trait on a key its type doesn't allow: a thread on a struct. */ +const builtInTrait = creating(` +export const spot = Symbol(); +declare module "@adobe/ecs" { + interface Components { [spot]: { type: number; thread: "main" } } +}`); + +/** A default that isn't of the declared type. */ +const wrongDefault = creating(` +export const label = Symbol(); +declare module "@adobe/ecs" { + interface Resources { [label]: { type: string; default: 1 } } +}`); + +/** One key with several members that don't hold: a custom trait and a thread on a string. */ +const severalTraits = creating(` +export const instanced = Symbol(); +export const name = Symbol(); +declare module "@adobe/ecs" { + interface Traits { readonly [instanced]: N extends Component.Key ? ([Component.Value] extends [readonly number[]] ? true : never) : never } + interface Components { [name]: { type: string; [instanced]: true; thread: "main" } } +}`); + +/** One key whose default is of another type and which can't be transferred. */ +const defaultAndTransfer = creating(` +export const canvas = Symbol(); +declare module "@adobe/ecs" { + interface Resources { [canvas]: { type: string; default: 1; transfer: true } } +}`); + describe("package exports", () => { beforeAll(() => { install(); @@ -133,10 +176,9 @@ describe("package exports", () => { // An index's type reads as it was declared, naming only the package root. const emitted = readFileSync(join(temporary, "out", "index.d.ts"), "utf8"); expect(emitted).toContain(`export declare const placed: Index<{ - readonly name: "exportsTest/placed"; readonly key: readonly [typeof position]; readonly components: { - readonly has: readonly [typeof element]; + readonly with: readonly [typeof element]; }; }>;`); expect(emitted).toContain(`export declare const ranked: Index<{ @@ -145,21 +187,39 @@ describe("package exports", () => { }>;`); expect([...emitted.matchAll(/import\("([^"]+)"\)/g)].map(match => match[1]).filter(path => path !== "@adobe/ecs")).toEqual([]); }, 60_000); + it("report a declaration that doesn't hold once at each World.create call, naming the key, the member and the problem", () => { + const cases = [ + { source: customTrait, named: `readonly [foo]: { readonly [instanced]: "isn't allowed on this key"; }` }, + { source: builtInTrait, named: `readonly [spot]: { readonly thread: "isn't allowed on this key"; }` }, + { source: wrongDefault, named: `readonly [label]: { readonly default: "isn't of the declared type"; }` }, + ]; + for (const { source, named } of cases) { + const { messages: reported, lines } = compile(source); + // One error per call, on the call; uses of the worlds type-check. + expect(lines).toEqual(linesWith(source, "World.create(")); + for (const message of reported) { + expect(message).toContain("declarations don't hold"); + expect(message).toContain(named); + } + } + expect(compile(creating("")).messages).toEqual([]); + }, 60_000); + it("name every member of a key that doesn't hold", () => { + const cases = [ + { source: severalTraits, named: [`readonly [instanced]: "isn't allowed on this key"`, `readonly thread: "isn't allowed on this key"`] }, + { source: defaultAndTransfer, named: [`readonly default: "isn't of the declared type"`, `readonly transfer: "isn't allowed on this key"`] }, + ]; + for (const { source, named } of cases) { + const { messages: reported, lines } = compile(source); + expect(lines).toEqual(linesWith(source, "World.create(")); + for (const message of reported) for (const member of named) expect(message).toContain(member); + } + }, 60_000); it("report a misdeclared system, and a composite of conflicting members, at the mistake rather than as no overload matching", () => { const reported = compile(misdeclared).messages; expect(reported.some(message => message.includes("No overload matches"))).toBe(false); expect(reported.filter(message => message.includes(`has a different type in another member of this composite.`))).toHaveLength(4); - expect(reported.some(message => message.startsWith("Type '[unique symbol]' is not assignable"))).toBe(true); - }, 60_000); - it("fail every registration to compile while a component, resource or trait is keyed by a string, naming the keys", () => { - expect(compile(stringKeyed(`interface Components { "exportsTest/named": { type: number } }`)).messages.join("\n")) - .toContain(`'{ readonly "component keys must be symbols": "exportsTest/named"; }'`); - expect(compile(stringKeyed(`interface Resources { "exportsTest/time": { type: number } }`)).messages.join("\n")) - .toContain(`'{ readonly "resource keys must be symbols": "exportsTest/time"; }'`); - const trait = compile(stringKeyed(`interface Traits { readonly persisted: true }`, `Trait.register("persisted");`)).messages.join("\n"); - expect(trait).toContain(`'{ readonly "trait keys must be symbols": "persisted"; }'`); - // Registration takes only symbol keys, so a string-keyed trait can't be registered either. - expect(trait).toContain(`Argument of type '"persisted"' is not assignable`); + expect(reported.some(message => message.startsWith("Type 'symbol[]' is not assignable to type 'undefined'"))).toBe(true); }, 60_000); }); diff --git a/packages/ecs/src/guide/01-resources.test.ts b/packages/ecs/src/guide/01-resources.test.ts index 8e19e05ba..19544a5c3 100644 --- a/packages/ecs/src/guide/01-resources.test.ts +++ b/packages/ecs/src/guide/01-resources.test.ts @@ -1,36 +1,44 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { expect, it } from "vitest"; -import { Entity, Resource, System, World } from "../index.js"; +import { Entity, System, World, type F32 } from "../index.js"; +import { Resource } from "@adobe/ecs"; // A resource is a key whose value a world holds once. -export const gravity = Symbol("gravity"); -export const names = Symbol("names"); -export const audio = Symbol("audio"); -export const focused = Symbol("focused"); +export const gravity = Symbol(); +export const floor = Symbol(); +export const names = Symbol(); +export const audio = Symbol(); +export const focused = Symbol(); -// Its traits are declared; `type` is the value's type. +// A type's namespace may give the value it starts at, which every row and world starting from it shares, so readonly. +export type Height = F32; +export const Height = { default: -Infinity } as const; + +// It's declared once, with its traits: `type` is the value's type, and `default` a literal it starts at. Without one, +// it starts at its type's namespace's default, a reduction's identity, null, its zero value (0, false, "", item by +// item), or an empty ReadonlyMap or ReadonlySet of each world's own. The build registers what's declared. declare module "@adobe/ecs" { interface Resources { - [gravity]: { type: number }; + [gravity]: { type: number; default: -9.8 }; + [floor]: { type: Height }; [names]: { type: ReadonlyMap }; + // A value that isn't data, such as a class, is stored as it is; a change inside it is seen once it's written back. [audio]: { type: AudioContext | null }; + // Entity.none, 0, rather than null, so an entity stays data every thread reads. [focused]: { type: Entity }; } } -// Registration gives the same traits, with a schema that describes exactly the declared type. A resource always has a -// value: its schema's default, or one beside it. -Resource.register(gravity, { type: { type: "number", default: -9.8 } }); -Resource.register(names, { type: { type: "map", keys: { type: "string" }, values: { type: "number" } }, default: new Map() }); -// A value that isn't data has no schema and is stored as it is. A type that allows null starts there. -Resource.register(audio, {}); -// No entity is Entity.none rather than null, so an entity stays data every thread reads. -Resource.register(focused, { type: { ...Entity.schema, default: Entity.none } }); - // Systems read a world: an observer only reads, after the world's changes apply. -let values: readonly unknown[] = []; -const read = System.create({ observer: true, resources: { read: [gravity, names, audio, focused] } }, ({ resources }) => { - values = [resources[gravity], resources[names], resources[audio], resources[focused]]; +let values: readonly [ + Resource.Value, + Resource.Value, + Resource.Value, + Resource.Value, + Resource.Value +] | null = null; +const read = System.create({ observer: true }, ({ resources }) => { + values = [resources[gravity], resources[floor], resources[names], resources[audio], resources[focused]]; }); it("holds one value per world", async () => { @@ -39,7 +47,7 @@ it("holds one value per world", async () => { await using moon = World.create({ [gravity]: -1.6 }); // run: runs a system once. await earth.run(read); - expect(values).toEqual([-9.8, new Map(), null, Entity.none]); + expect(values).toEqual([-9.8, -Infinity, new Map(), null, Entity.none]); await moon.run(read); - expect(values[0]).toBe(-1.6); + expect(values?.[0]).toBe(-1.6); }); diff --git a/packages/ecs/src/guide/02-entities.test.ts b/packages/ecs/src/guide/02-entities.test.ts index 55148ebd3..6ec401ced 100644 --- a/packages/ecs/src/guide/02-entities.test.ts +++ b/packages/ecs/src/guide/02-entities.test.ts @@ -1,11 +1,11 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { expect, it } from "vitest"; -import { Archetype, Component, System, Vec3, World } from "../index.js"; +import { System, Vec3, World } from "../index.js"; -// A component is a key whose values entities hold, declared and registered as a resource is. -export const position = Symbol("position"); -export const velocity = Symbol("velocity"); -export const enemy = Symbol("enemy"); +// A component is a key whose values entities hold, declared as a resource is. +export const position = Symbol(); +export const velocity = Symbol(); +export const enemy = Symbol(); declare module "@adobe/ecs" { interface Components { @@ -16,12 +16,9 @@ declare module "@adobe/ecs" { } } -Component.register(position, { type: Vec3.schema }); -Component.register(velocity, { type: Vec3.schema }); -Component.register(enemy, { type: { const: true } }); - -// An archetype names a list of components, once at module scope; it goes wherever a list of components does. -export const Particle = Archetype.create([position, velocity]); +// An archetype is a list of components declared `as const` at module scope, which the build indexes; it goes wherever +// a list of components does. +export const Particle = [position, velocity] as const; // Systems spawn entities: name the components, then give their values. export const spawn = System.create(({ commands }) => { @@ -30,17 +27,17 @@ export const spawn = System.create(({ commands }) => { }); // Commands also add and remove components, and despawn entities. -export const defeat = System.create({ components: { has: [enemy] } }, (row, { commands }) => { +export const defeat = System.create({ components: { with: [enemy] } }, (row, { commands }) => { commands.remove(row.entity, enemy); }); // An observer system reads them back, once per entity with its components. const foes: Vec3[] = []; -const readFoes = System.create({ observer: true, components: { read: [position], has: [enemy] } }, row => { +const readFoes = System.create({ observer: true, components: { with: [position, enemy] } }, row => { foes.push(row[position]); }); let particles = 0; -const countParticles = System.create({ observer: true, components: { has: Particle } }, () => { +const countParticles = System.create({ observer: true, components: { with: [...Particle] } }, () => { particles += 1; }); diff --git a/packages/ecs/src/guide/03-systems.test.ts b/packages/ecs/src/guide/03-systems.test.ts index dbc7e81b0..7cc760020 100644 --- a/packages/ecs/src/guide/03-systems.test.ts +++ b/packages/ecs/src/guide/03-systems.test.ts @@ -1,12 +1,12 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { expect, it } from "vitest"; -import { Component, Report, Resource, System, Vec3, World } from "../index.js"; +import { Report, System, Vec3, World } from "../index.js"; -const position = Symbol("position"); -const velocity = Symbol("velocity"); -const frozen = Symbol("frozen"); -const delta = Symbol("delta"); -const frames = Symbol("frames"); +const position = Symbol(); +const velocity = Symbol(); +const frozen = Symbol(); +const delta = Symbol(); +const frames = Symbol(); declare module "@adobe/ecs" { interface Components { @@ -15,35 +15,27 @@ declare module "@adobe/ecs" { [frozen]: { type: true }; } interface Resources { - [delta]: { type: number }; + [delta]: { type: number; default: 0.5 }; [frames]: { type: number }; } } -Component.register(position, { type: Vec3.schema }); -Component.register(velocity, { type: Vec3.schema }); -Component.register(frozen, { type: { const: true } }); -Resource.register(delta, { type: { type: "number", default: 0.5 } }); -Resource.register(frames, { type: { type: "number", default: 0 } }); - const spawn = System.create(({ commands }) => { commands.spawn([position, velocity])({ [position]: [0, 0, 0], [velocity]: [2, 0, 0] }); commands.spawn([position, velocity, frozen])({ [position]: [0, 0, 0], [velocity]: [2, 0, 0] }); }); -// Runs once per entity with every component in read and write, and none in not. +// Runs once per entity with every component in with, and none in not. What its body reads and writes the build +// infers. const move = System.create({ - // Optional: labels the system in reports. - name: "physics/move", - components: { read: [velocity], write: [position], not: [frozen] }, - resources: { read: [delta] }, + components: { with: [velocity, position], not: [frozen] }, }, (row, { resources }) => { // Values are replaced whole, never edited in place. row[position] = Vec3.add(row[position], Vec3.scale(row[velocity], resources[delta])); }); // Without components, a system runs once per run. -const count = System.create({ resources: { write: [frames] } }, ({ resources }) => { +const count = System.create({}, ({ resources }) => { resources[frames] += 1; }); @@ -51,7 +43,7 @@ const count = System.create({ resources: { write: [frames] } }, ({ resources }) // react to it. const moved: Vec3[] = []; let counted = 0; -const read = System.create({ observer: true, components: { read: [position], not: [frozen] }, resources: { read: [frames] } }, (row, { resources }) => { +const read = System.create({ observer: true, components: { with: [position], not: [frozen] } }, (row, { resources }) => { moved.push(row[position]); counted = resources[frames]; }); @@ -67,7 +59,26 @@ it("runs a system once per entity, or once", async () => { expect(counted).toBe(1); }); -const fail = System.create({ name: "fail" }, () => { +const spawnStill = System.create(({ commands }) => { + commands.spawn([position])({ [position]: [5, 0, 0] }); + commands.spawn([position, velocity])({ [position]: [0, 0, 0], [velocity]: [2, 0, 0] }); +}); + +// An optional component is in a row where its entity has it, and undefined where it doesn't: it never narrows which +// entities a system runs on. Commands add and remove it; assigning undefined removes nothing. +const speeds: (Vec3 | undefined)[] = []; +const readSpeeds = System.create({ observer: true, components: { with: [position], optional: [velocity] } }, row => { + speeds.push(row[velocity]); +}); + +it("reads optional components where entities have them", async () => { + await using world = World.create(); + await world.run(spawnStill); + await world.run(readSpeeds); + expect(speeds).toEqual([undefined, [2, 0, 0]]); +}); + +const fail = System.create(() => { throw new Error("broken"); }); @@ -75,7 +86,7 @@ const fail = System.create({ name: "fail" }, () => { // it took, and `Report.errors` is there only when something went wrong. An observer system added to follow errors as // they change logs them; what observer systems throw is in the next run's report. const logged: string[] = []; -const logFailures = System.create({ observer: true, components: { read: [Report.errors], changed: [Report.errors] } }, row => { +const logFailures = System.create({ observer: true, components: { with: [Report.errors], changed: [Report.errors] } }, row => { for (const failure of row[Report.errors].failures) logged.push(`${failure.name}: ${failure.message}`); }); diff --git a/packages/ecs/src/guide/04-ticks.test.ts b/packages/ecs/src/guide/04-ticks.test.ts index 21e84f4fe..286b2832a 100644 --- a/packages/ecs/src/guide/04-ticks.test.ts +++ b/packages/ecs/src/guide/04-ticks.test.ts @@ -1,9 +1,9 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { expect, it } from "vitest"; -import { always, cadence, clock, Resource, System, wake, World } from "../index.js"; +import { always, cadence, clock, System, wake, World } from "../index.js"; -const elapsed = Symbol("elapsed"); -const steps = Symbol("steps"); +const elapsed = Symbol(); +const steps = Symbol(); declare module "@adobe/ecs" { interface Resources { @@ -12,22 +12,19 @@ declare module "@adobe/ecs" { } } -Resource.register(elapsed, { type: { type: "number", default: 0 } }); -Resource.register(steps, { type: { type: "number", default: 0 } }); - // clock is the current tick's: delta is the milliseconds since the last one. -const advance = System.create({ resources: { read: [clock], write: [elapsed] } }, ({ resources }) => { +const advance = System.create({}, ({ resources }) => { resources[elapsed] += resources[clock].delta; }); // Added, an observer system reacts to changes: with elapsed in changed, it runs after each tick that changed it. let reached: (value: number) => void = () => {}; -const watch = System.create({ observer: true, resources: { read: [elapsed], changed: [elapsed] } }, ({ resources }) => { +const watch = System.create({ observer: true, resources: { changed: [elapsed] } }, ({ resources }) => { if (resources[elapsed] >= 30) reached(resources[elapsed]); }); let read = { elapsed: 0, delta: 0 }; -const readTime = System.create({ observer: true, resources: { read: [elapsed, clock] } }, ({ resources }) => { +const readTime = System.create({ observer: true }, ({ resources }) => { read = { elapsed: resources[elapsed], delta: resources[clock].delta }; }); @@ -53,13 +50,13 @@ it("runs added systems on every tick", async () => { // A system asks for another tick by contributing a time to wake: the first tick at or after it runs. Work that a tick's // commands start and a later tick finishes asks for the next one this way. -const step = System.create({ resources: { read: [clock], write: [steps] }, reduce: { [wake]: "min" } }, ({ resources, reduce }) => { +const step = System.create({}, ({ resources, reduce }) => { resources[steps] += 1; if (resources[steps] < 3) reduce[wake](resources[clock].now); }); let finished: (value: number) => void = () => {}; -const watchSteps = System.create({ observer: true, resources: { read: [steps], changed: [steps] } }, ({ resources }) => { +const watchSteps = System.create({ observer: true, resources: { changed: [steps] } }, ({ resources }) => { if (resources[steps] === 3) finished(resources[steps]); }); diff --git a/packages/ecs/src/guide/05-scheduling.test.ts b/packages/ecs/src/guide/05-scheduling.test.ts index e91f21034..c562880da 100644 --- a/packages/ecs/src/guide/05-scheduling.test.ts +++ b/packages/ecs/src/guide/05-scheduling.test.ts @@ -1,9 +1,9 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { expect, it } from "vitest"; -import { Component, System, World } from "../index.js"; +import { System, World } from "../index.js"; -const health = Symbol("health"); -const damage = Symbol("damage"); +const health = Symbol(); +const damage = Symbol(); declare module "@adobe/ecs" { interface Components { @@ -12,23 +12,20 @@ declare module "@adobe/ecs" { } } -Component.register(health, { type: { type: "number" } }); -Component.register(damage, { type: { type: "number" } }); - const spawn = System.create(({ commands }) => { commands.spawn([health, damage])({ [health]: 10, [damage]: 4 }); }); -const hit = System.create({ components: { read: [damage], write: [health] } }, row => { +const hit = System.create({ components: { with: [damage, health] } }, row => { row[health] -= row[damage]; }); // before, after and during list the systems themselves. -const heal = System.create({ components: { write: [health] }, after: [hit] }, row => { +const heal = System.create({ components: { with: [health] }, after: [hit] }, row => { row[health] = Math.min(row[health] * 2, 10); }); // A list of systems is a composite: it runs its members as one system, in their order; members without an order -// between them may run in parallel. { systems, after, ... } declares one with a name or constraints. +// between them may run in parallel. { systems, after, ... } declares one with constraints. const frame = System.create([heal, hit]); const healths: number[] = []; -const read = System.create({ observer: true, components: { read: [health] } }, row => { +const read = System.create({ observer: true, components: { with: [health] } }, row => { healths.push(row[health]); }); @@ -44,11 +41,11 @@ it("orders a composite's members by their constraints", async () => { // Phases are systems that do nothing; a system during one runs after what the phase follows and before what follows it. const update = System.create(() => {}); const render = System.create({ after: [update] }, () => {}); -const poison = System.create({ components: { write: [health] }, during: [update] }, row => { +const poison = System.create({ components: { with: [health] }, during: [update] }, row => { row[health] -= 1; }); const seen: number[] = []; -const report = System.create({ components: { read: [health] }, during: [render] }, row => { +const report = System.create({ components: { with: [health] }, during: [render] }, row => { seen.push(row[health]); }); const phased = System.create([report, poison, update, render]); diff --git a/packages/ecs/src/guide/06-sync-points.test.ts b/packages/ecs/src/guide/06-sync-points.test.ts index e27a8156b..11df7cfd6 100644 --- a/packages/ecs/src/guide/06-sync-points.test.ts +++ b/packages/ecs/src/guide/06-sync-points.test.ts @@ -1,9 +1,9 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { expect, it } from "vitest"; -import { Component, Resource, System, World } from "../index.js"; +import { System, World } from "../index.js"; -const health = Symbol("health"); -const seen = Symbol("seen"); +const health = Symbol(); +const seen = Symbol(); declare module "@adobe/ecs" { interface Components { @@ -14,9 +14,6 @@ declare module "@adobe/ecs" { } } -Component.register(health, { type: { type: "number" } }); -Resource.register(seen, { type: { type: "number", default: 0 } }); - // Value writes apply at once; commands wait for the sync point that ends each run, then apply in the order issued. const spawn = System.create(({ commands }) => { // spawn gives the entity at once, so later commands can name it. @@ -26,18 +23,18 @@ const spawn = System.create(({ commands }) => { commands.despawn(commands.spawn([health])({ [health]: 1 })); }); // A composite is one run with one sync point: count runs after spawn, before its entities exist. -const count = System.create({ components: { has: [health] }, resources: { write: [seen] }, after: [spawn] }, (row, { resources }) => { +const count = System.create({ components: { with: [health] }, after: [spawn] }, (row, { resources }) => { resources[seen] += 1; }); const frame = System.create([spawn, count]); const healths: number[] = []; // An observer runs after the sync point of every run and tick it's added to, reading what it left; it writes nothing. -const watch = System.create({ observer: true, components: { read: [health] } }, row => { +const watch = System.create({ observer: true, components: { with: [health] } }, row => { healths.push(row[health]); }); let counted = 0; -const readSeen = System.create({ observer: true, resources: { read: [seen] } }, ({ resources }) => { +const readSeen = System.create({ observer: true }, ({ resources }) => { counted = resources[seen]; }); diff --git a/packages/ecs/src/guide/07-change-filters.test.ts b/packages/ecs/src/guide/07-change-filters.test.ts index 457ba502b..1af9c016b 100644 --- a/packages/ecs/src/guide/07-change-filters.test.ts +++ b/packages/ecs/src/guide/07-change-filters.test.ts @@ -1,14 +1,14 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { expect, it } from "vitest"; -import { Component, Resource, System, World } from "../index.js"; +import { System, World } from "../index.js"; -const score = Symbol("score"); -const shown = Symbol("shown"); -const boss = Symbol("boss"); -const level = Symbol("level"); -const paused = Symbol("paused"); -const map = Symbol("map"); -const steps = Symbol("steps"); +const score = Symbol(); +const shown = Symbol(); +const boss = Symbol(); +const level = Symbol(); +const paused = Symbol(); +const map = Symbol(); +const steps = Symbol(); declare module "@adobe/ecs" { interface Components { @@ -17,36 +17,28 @@ declare module "@adobe/ecs" { [boss]: { type: true }; } interface Resources { - [level]: { type: number }; + [level]: { type: number; default: 1 }; [paused]: { type: boolean }; [map]: { type: { readonly width: number } | null }; [steps]: { type: number }; } } -Component.register(score, { type: { type: "number" } }); -Component.register(shown, { type: { type: "number" } }); -Component.register(boss, { type: { const: true } }); -Resource.register(level, { type: { type: "number", default: 1 } }); -Resource.register(paused, { type: { type: "boolean", default: false } }); -Resource.register(map, { type: { anyOf: [{ type: "object", properties: { width: { type: "number" } } }, { type: "null" }] } }); -Resource.register(steps, { type: { type: "number", default: 0 } }); - // A resource in changed passes the first run and each run after it changes: no run-once flag needed. -const load = System.create({ resources: { read: [level], changed: [level] } }, ({ resources, commands }) => { +const load = System.create({ resources: { changed: [level] } }, ({ resources, commands }) => { commands.spawn([score, shown, boss])({ [score]: 0, [shown]: -1 }); for (let i = 0; i < resources[level]; i++) commands.spawn([score, shown])({ [score]: 0, [shown]: -1 }); }); // A component in changed passes only rows written since this system last ran. -const show = System.create({ components: { read: [score], write: [shown], changed: [score] } }, row => { +const show = System.create({ components: { with: [score, shown], changed: [score] } }, row => { row[shown] = row[score]; }); -const bump = System.create({ components: { write: [score], has: [boss] } }, row => { +const bump = System.create({ components: { with: [score, boss] } }, row => { row[score] += 10; }); const shownValues: number[] = []; -const readShown = System.create({ observer: true, components: { read: [shown] } }, row => { +const readShown = System.create({ observer: true, components: { with: [shown] } }, row => { shownValues.push(row[shown]); }); @@ -65,14 +57,14 @@ it("runs systems only for what changed since they last ran", async () => { }); // Runs only while each resource in is is neither null nor false, and each in not is; is reads it narrowed. -const step = System.create({ resources: { is: [map], not: [paused], write: [steps] } }, ({ resources }) => { +const step = System.create({ resources: { is: [map], not: [paused] } }, ({ resources }) => { resources[steps] += resources[map].width; }); -const pause = System.create({ resources: { write: [paused] } }, ({ resources }) => { resources[paused] = true; }); -const open = System.create({ resources: { write: [map] } }, ({ resources }) => { resources[map] = { width: 4 }; }); +const pause = System.create({}, ({ resources }) => { resources[paused] = true; }); +const open = System.create({}, ({ resources }) => { resources[map] = { width: 4 }; }); let stepped = 0; -const readSteps = System.create({ observer: true, resources: { read: [steps] } }, ({ resources }) => { +const readSteps = System.create({ observer: true }, ({ resources }) => { stepped = resources[steps]; }); diff --git a/packages/ecs/src/guide/08-input.test.ts b/packages/ecs/src/guide/08-input.test.ts index 7b78cf131..511b4624d 100644 --- a/packages/ecs/src/guide/08-input.test.ts +++ b/packages/ecs/src/guide/08-input.test.ts @@ -1,8 +1,8 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { expect, it } from "vitest"; -import { Component, Entity, System, World } from "../index.js"; +import { Entity, System, World } from "../index.js"; -const health = Symbol("health"); +const health = Symbol(); declare module "@adobe/ecs" { interface Components { @@ -10,30 +10,26 @@ declare module "@adobe/ecs" { } } -Component.register(health, { type: { type: "number" } }); - let player = Entity.none; const spawn = System.create(({ commands }) => { player = commands.spawn([health])({ [health]: 10 }); }); -// input is an object schema of named fields; each run passes a value of it. +// A body's input is its parameter after its context, an object of named fields; each run passes a value of it. const hit = System.create({ - components: { write: [health] }, - input: { type: "object", properties: { amount: { type: "number" } } }, -}, (row, { input }) => { + components: { with: [health] }, +}, (row, _context, input: { readonly amount: number }) => { row[health] -= input.amount; }); // target names an input field holding an entity: the system runs on that entity's row alone, or on none once it's gone. const heal = System.create({ - components: { write: [health] }, - input: { type: "object", properties: { entity: Entity.schema, amount: { type: "number" } } }, + components: { with: [health] }, target: "entity", -}, (row, { input }) => { +}, (row, _context, input: { readonly entity: Entity; readonly amount: number }) => { row[health] += input.amount; }); const healths: number[] = []; -const read = System.create({ observer: true, components: { read: [health] } }, row => { +const read = System.create({ observer: true, components: { with: [health] } }, row => { healths.push(row[health]); }); diff --git a/packages/ecs/src/guide/09-rules.test.ts b/packages/ecs/src/guide/09-rules.test.ts index 1f4d0ed28..8ac1914ca 100644 --- a/packages/ecs/src/guide/09-rules.test.ts +++ b/packages/ecs/src/guide/09-rules.test.ts @@ -1,48 +1,42 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { expect, it } from "vitest"; -import { Component, Rule, System, World } from "../index.js"; +import { Rule, System, World } from "../index.js"; -const mesh = Symbol("mesh"); -const sprite = Symbol("sprite"); -const hidden = Symbol("hidden"); -const drawn = Symbol("drawn"); -const layer = Symbol("layer"); +const mesh = Symbol(); +const sprite = Symbol(); +const hidden = Symbol(); +const drawn = Symbol(); +const layer = Symbol(); declare module "@adobe/ecs" { interface Components { [mesh]: { type: number }; [sprite]: { type: number }; [hidden]: { type: true }; - // Only rules add derived components, starting at their schema's default. + // Only rules add derived components, starting at their default. [drawn]: { type: true; derived: true }; - [layer]: { type: number; derived: true }; + [layer]: { type: number; derived: true; default: 1 }; } } -Component.register(mesh, { type: { type: "number" } }); -Component.register(sprite, { type: { type: "number" } }); -Component.register(hidden, { type: { const: true } }); -Component.register(drawn, { type: { const: true }, derived: true }); -Component.register(layer, { type: { type: "number", default: 1 }, derived: true }); - // Entities matching a rule have what it derives, only while they match. -Rule.create({ components: { has: [mesh], not: [hidden] }, derive: [drawn] }); +Rule.create({ components: { with: [mesh], not: [hidden] }, derive: [drawn] }); // Several rules may derive one component, present while any matches. -Rule.create({ components: { has: [sprite], not: [hidden] }, derive: [drawn] }); +Rule.create({ components: { with: [sprite], not: [hidden] }, derive: [drawn] }); // Rules build on what others derive. -Rule.create({ components: { has: [drawn] }, derive: [layer] }); +Rule.create({ components: { with: [drawn] }, derive: [layer] }); const spawn = System.create(({ commands }) => { commands.spawn([mesh])({ [mesh]: 1 }); commands.spawn([sprite])({ [sprite]: 2 }); commands.spawn([mesh, hidden])({ [mesh]: 3 }); }); -const hide = System.create({ components: { has: [sprite] } }, (row, { commands }) => { +const hide = System.create({ components: { with: [sprite] } }, (row, { commands }) => { commands.add(row.entity, hidden); }); const layers: number[] = []; -const read = System.create({ observer: true, components: { read: [layer], has: [drawn] } }, row => { +const read = System.create({ observer: true, components: { with: [layer, drawn] } }, row => { layers.push(row[layer]); }); diff --git a/packages/ecs/src/guide/10-finalizers.test.ts b/packages/ecs/src/guide/10-finalizers.test.ts index 7aae82c35..a4ff4af0c 100644 --- a/packages/ecs/src/guide/10-finalizers.test.ts +++ b/packages/ecs/src/guide/10-finalizers.test.ts @@ -1,8 +1,8 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { expect, it } from "vitest"; -import { Component, Finalizer, System, World } from "../index.js"; +import { Finalizer, System, World } from "../index.js"; -const handle = Symbol("handle"); +const handle = Symbol(); declare module "@adobe/ecs" { interface Components { @@ -10,13 +10,11 @@ declare module "@adobe/ecs" { } } -Component.register(handle, { type: { type: "number" } }); - /** What handles name outside the world, as GPU buffers and DOM nodes are. */ const open = new Set(); // Runs for each entity that stops matching, whatever the cause, reading its values from before. -Finalizer.create({ components: { read: [handle] } }, row => { +Finalizer.create({ components: { with: [handle] } }, row => { open.delete(row[handle]); }); @@ -26,7 +24,7 @@ const spawn = System.create(({ commands }) => { commands.spawn([handle])({ [handle]: id }); } }); -const destroy = System.create({ components: { read: [handle] } }, (row, { commands }) => { +const destroy = System.create({ components: { with: [handle] } }, (row, { commands }) => { if (row[handle] === 7) commands.despawn(row.entity); else commands.remove(row.entity, handle); }); diff --git a/packages/ecs/src/guide/11-traits.test.ts b/packages/ecs/src/guide/11-traits.test.ts index ac41a53f7..0bb7f919a 100644 --- a/packages/ecs/src/guide/11-traits.test.ts +++ b/packages/ecs/src/guide/11-traits.test.ts @@ -1,14 +1,14 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { expect, it } from "vitest"; -import { Component, Entity, System, World } from "../index.js"; +import { Entity, System, World } from "../index.js"; -const ship = Symbol("ship"); -const parent = Symbol("parent"); -const target = Symbol("target"); -const selected = Symbol("selected"); -const outline = Symbol("outline"); +const ship = Symbol(); +const parent = Symbol(); +const target = Symbol(); +const selected = Symbol(); +const outline = Symbol(); -// Traits are the fields of a key's declaration; type is one. Registration repeats them. +// Traits are the fields of a key's declaration; type is one. declare module "@adobe/ecs" { interface Components { [ship]: { type: true }; @@ -22,27 +22,21 @@ declare module "@adobe/ecs" { } } -Component.register(ship, { type: { const: true } }); -Component.register(parent, { type: Entity.schema, reference: "owner" }); -Component.register(target, { type: Entity.schema, reference: "weak" }); -Component.register(selected, { type: { const: true } }); -Component.register(outline, { type: { type: "number" }, within: selected }); - const spawn = System.create(({ commands }) => { const mothership = commands.spawn([ship, selected, outline])({ [outline]: 2 }); commands.spawn([parent])({ [parent]: mothership }); commands.spawn([target])({ [target]: mothership }); }); -const deselect = System.create({ components: { has: [selected] } }, (row, { commands }) => { +const deselect = System.create({ components: { with: [selected] } }, (row, { commands }) => { commands.remove(row.entity, selected); }); -const destroy = System.create({ components: { has: [ship] } }, (row, { commands }) => { +const destroy = System.create({ components: { with: [ship] } }, (row, { commands }) => { commands.despawn(row.entity); }); const counts = { outlined: 0, targeting: 0, all: 0 }; -const countOutlined = System.create({ observer: true, components: { has: [outline] } }, () => { counts.outlined += 1; }); -const countTargeting = System.create({ observer: true, components: { has: [target] } }, () => { counts.targeting += 1; }); +const countOutlined = System.create({ observer: true, components: { with: [outline] } }, () => { counts.outlined += 1; }); +const countTargeting = System.create({ observer: true, components: { with: [target] } }, () => { counts.targeting += 1; }); const countAll = System.create({ observer: true, components: {} }, () => { counts.all += 1; }); it("removes components with their host, and follows references", async () => { diff --git a/packages/ecs/src/guide/11-traits.type-test.ts b/packages/ecs/src/guide/11-traits.type-test.ts index 609ef53dc..9a70b23bb 100644 --- a/packages/ecs/src/guide/11-traits.type-test.ts +++ b/packages/ecs/src/guide/11-traits.type-test.ts @@ -1,28 +1,25 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Component, Trait } from "../index.js"; +import { Component } from "../index.js"; type Equal = (() => T extends X ? 1 : 2) extends (() => T extends Y ? 1 : 2) ? true : false; const check = <_T extends true>() => { }; // A trait of your own is a member of Traits: the value it allows on key N, or never where it doesn't apply. -export const instanced = Symbol("instanced"); +export const instanced = Symbol(); declare module "@adobe/ecs" { interface Traits { readonly [instanced]: N extends Component.Key ? ([Component.Value] extends [readonly number[]] ? true : never) : never; } } -Trait.register(instanced); -const transform = Symbol("transform"); -const name = Symbol("name"); +const transform = Symbol(); +const name = Symbol(); declare module "@adobe/ecs" { interface Components { [transform]: { type: readonly [number, number, number]; [instanced]: true }; [name]: { type: string }; } } -Component.register(transform, { type: { type: "array", items: { type: "number" }, minItems: 3, maxItems: 3 }, [instanced]: true }); -Component.register(name, { type: { type: "string" } }); // Types select components by trait. check, typeof transform | typeof name>, typeof transform>>(); diff --git a/packages/ecs/src/guide/12-lookups.test.ts b/packages/ecs/src/guide/12-lookups.test.ts new file mode 100644 index 000000000..79b146f98 --- /dev/null +++ b/packages/ecs/src/guide/12-lookups.test.ts @@ -0,0 +1,62 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { expect, it } from "vitest"; +import { Entity, System, World } from "../index.js"; + +const health = Symbol(); +const target = Symbol(); +const frozen = Symbol(); +const aim = Symbol(); + +declare module "@adobe/ecs" { + interface Components { + [health]: { type: number }; + [target]: { type: Entity; reference: "weak" }; + [frozen]: { type: true }; + [aim]: { type: number }; + } +} + +// The entity's row, or null when it doesn't have every component in with, or has one in not. The row holds only what +// with lists. +const track = System.create({ + components: { with: [target, aim] }, + lookup: { enemy: { with: [health], not: [frozen] } }, +}, (row, { lookup }) => { + const enemy = lookup.enemy(row[target]); + row[aim] = enemy === null ? 0 : enemy[health]; +}); + +// With changed, its row says whether those components may have changed since the system last ran. +const refreshed: boolean[] = []; +const watch = System.create({ + observer: true, + components: { with: [target] }, + lookup: { enemy: { with: [health], changed: [health] } }, +}, (row, { lookup }) => { + const enemy = lookup.enemy(row[target]); + if (enemy !== null) refreshed.push(enemy.changed); +}); + +const aims: number[] = []; +const readAims = System.create({ observer: true, components: { with: [aim] } }, row => { + aims.push(row[aim]); +}); + +const spawn = System.create(({ commands }) => { + const ogre = commands.spawn([health])({ [health]: 40 }); + const statue = commands.spawn([health, frozen])({ [health]: 90 }); + commands.spawn([target, aim])({ [target]: ogre, [aim]: -1 }); + commands.spawn([target, aim])({ [target]: statue, [aim]: -1 }); +}); + +it("reads other entities by id", async () => { + await using world = World.create(); + await world.run(spawn); + await world.run(track); + await world.run(readAims); + // The frozen statue doesn't match, so its tracker reads null. + expect(aims.sort()).toEqual([0, 40]); + await world.run(watch); + await world.run(watch); + expect(refreshed).toEqual([true, true, false, false]); +}); diff --git a/packages/ecs/src/guide/13-indexes.test.ts b/packages/ecs/src/guide/13-indexes.test.ts index dbc539f14..adaac90d2 100644 --- a/packages/ecs/src/guide/13-indexes.test.ts +++ b/packages/ecs/src/guide/13-indexes.test.ts @@ -1,70 +1,64 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { expect, it } from "vitest"; -import { Component, Index, System, World } from "../index.js"; +import { type I32, Index, System, type Vec2, World } from "../index.js"; -const cell = Symbol("cell"); -const crowded = Symbol("crowded"); -const score = Symbol("score"); -const player = Symbol("player"); -const serverId = Symbol("serverId"); +const cell = Symbol(); +const crowded = Symbol(); +const score = Symbol(); +const player = Symbol(); +const serverId = Symbol(); declare module "@adobe/ecs" { interface Components { - [cell]: { type: readonly [number, number] }; + [cell]: { type: Vec2.I32 }; [crowded]: { type: number }; [score]: { type: number }; [player]: { type: string }; - [serverId]: { type: number }; + [serverId]: { type: I32 }; } } -Component.register(cell, { type: { type: "array", items: { type: "integer" }, minItems: 2, maxItems: 2 } }); -Component.register(crowded, { type: { type: "number" } }); -Component.register(score, { type: { type: "number" } }); -Component.register(player, { type: { type: "string" } }); -Component.register(serverId, { type: { type: "integer" } }); - -// An index finds entities by their components' values: here, those with a crowded count, by their cell. -const grid = Index.create({ key: [cell], components: { has: [crowded] } }); +// Here, entities with a crowded count, by their cell. +const grid = Index.create({ key: [cell], components: { with: [crowded] } }); // A lookup through it gives the rows with a key, in entity order. -const count = System.create({ components: { read: [cell], write: [crowded] }, lookup: { at: { index: grid } } }, (row, { lookup }) => { +const count = System.create({ components: { with: [cell, crowded] }, lookup: { at: { index: grid } } }, (row, { lookup }) => { const [x, y] = row[cell]; row[crowded] = lookup.at([x, y]).length; }); // A sorted index keeps its key's order: lookups take a range, and `order` runs a system's rows in it. -const byScore = Index.create({ key: [score], sorted: true, components: { has: [player] } }); +const byScore = Index.create({ key: [score], sorted: true, components: { with: [player] } }); const winners: string[] = []; -const leaders = System.create({ observer: true, lookup: { scores: { index: byScore, read: [player] } } }, ({ lookup }) => { +const leaders = System.create({ observer: true, lookup: { scores: { index: byScore } } }, ({ lookup }) => { winners.length = 0; const high = lookup.scores({ ">=": 90 }); for (let i = 0; i < high.length; i++) winners.push(high.at(i)[player]); }); const board: string[] = []; -const list = System.create({ observer: true, components: { read: [player] }, order: byScore }, row => { +const list = System.create({ observer: true, components: { with: [player] }, order: byScore }, row => { board.push(row[player]); }); // A unique index gives one row, or null. const byServerId = Index.create({ key: [serverId], unique: true }); let found: number | null = null; -const find = System.create({ input: { type: "object", properties: { id: { type: "integer" } } }, lookup: { byId: { index: byServerId } } }, ({ input, lookup }) => { +const find = System.create({ lookup: { byId: { index: byServerId } } }, ({ lookup }, input: { readonly id: number }) => { found = lookup.byId(input.id)?.entity ?? null; }); // An index without a key holds every entity with its components, in entity order. With changed: true, this observer // system runs only when an entity entered or left the index, so added, it keeps a count as players come and go. -const players = Index.create({ key: [], components: { has: [player] } }); +const players = Index.create({ key: [], components: { with: [player] } }); let playerCount = 0; const countPlayers = System.create({ observer: true, lookup: { players: { index: players, changed: true } } }, ({ lookup }) => { playerCount = lookup.players().length; }); const crowds: number[] = []; -const readCrowds = System.create({ observer: true, components: { read: [crowded] } }, row => { +const readCrowds = System.create({ observer: true, components: { with: [crowded] } }, row => { crowds.push(row[crowded]); }); diff --git a/packages/ecs/src/guide/14-kernels.test.ts b/packages/ecs/src/guide/14-kernels.test.ts new file mode 100644 index 000000000..03e6bcf11 --- /dev/null +++ b/packages/ecs/src/guide/14-kernels.test.ts @@ -0,0 +1,54 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { expect, it } from "vitest"; +import { type F32, System, Vec3, World } from "../index.js"; + +const position = Symbol(); +const velocity = Symbol(); +const delta = Symbol(); + +declare module "@adobe/ecs" { + interface Components { + [position]: { type: Vec3 }; + [velocity]: { type: Vec3 }; + } + interface Resources { + [delta]: { type: F32; default: 0.5 }; + } +} + +// A kernel is a body run once per row, each row on its own, so the world can split rows across workers and run them as +// WASM. A body that reads and writes only structs, calls math and touches no module state is one: the build compiles +// it, with results the same as the body as written. +const move = System.create({ components: { with: [velocity, position] } }, (row, { resources }) => { + row[position] = Vec3.add(row[position], Vec3.scale(row[velocity], resources[delta])); +}); + +// kernel: true asserts it: the build fails, saying why, if the body can't be one. It also computes f32 values at f32, +// which is faster on dense rows. +const drift = System.create({ kernel: true, components: { with: [velocity, position] } }, row => { + row[position] = Vec3.add(row[position], row[velocity]); +}); + +// kernel: false runs the body as written, on its thread: to step through it in a debugger, say. +const slow = System.create({ kernel: false, components: { with: [velocity] } }, row => { + row[velocity] = Vec3.scale(row[velocity], 0.5); +}); + +const positions: Vec3[] = []; +const read = System.create({ observer: true, components: { with: [position] } }, row => { + positions.push(row[position]); +}); + +const spawn = System.create(({ commands }) => { + for (let i = 0; i < 3; i++) commands.spawn([position, velocity])({ [position]: [i, 0, 0], [velocity]: [2, 0, 0] }); +}); + +it("runs bodies as kernels", async () => { + await using world = World.create(); + await world.run(spawn); + await world.run(move); + await world.run(drift); + await world.run(slow); + await world.run(read); + expect(positions.map(([x]) => x).sort()).toEqual([3, 4, 5]); +}); diff --git a/packages/ecs/src/guide/15-reductions.test.ts b/packages/ecs/src/guide/15-reductions.test.ts new file mode 100644 index 000000000..68b0e2145 --- /dev/null +++ b/packages/ecs/src/guide/15-reductions.test.ts @@ -0,0 +1,46 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { expect, it } from "vitest"; +import { type F32, System, World } from "../index.js"; + +const mass = Symbol(); +const total = Symbol(); +const heaviest = Symbol(); +const count = Symbol(); + +declare module "@adobe/ecs" { + interface Components { + [mass]: { type: F32 }; + } + // A reduced resource names how contributions combine: "+", "*", "min", "max", or bitwise "&", "|", "^". + interface Resources { + [total]: { type: number; reduce: "+" }; + [heaviest]: { type: F32; reduce: "max" }; + [count]: { type: number; reduce: "+" }; + } +} + +// Each row contributes; every run starts each resource it reduces at its operator's identity and leaves it holding the +// fold of what every row gave, however the rows were split across workers. +const weigh = System.create({ components: { with: [mass] } }, (row, { reduce }) => { + reduce[total](row[mass]); + reduce[heaviest](row[mass]); + reduce[count](1); +}); + +// Read the result after the run, as any resource. +let summary = ""; +const report = System.create({ observer: true }, ({ resources }) => { + summary = `${resources[count]} weigh ${resources[total]}, at most ${resources[heaviest]}`; +}); + +const spawn = System.create(({ commands }) => { + for (const value of [2, 5, 3]) commands.spawn([mass])({ [mass]: value }); +}); + +it("folds rows into a resource", async () => { + await using world = World.create(); + await world.run(spawn); + await world.run(weigh); + await world.run(report); + expect(summary).toBe("3 weigh 10, at most 5"); +}); diff --git a/packages/ecs/src/guide/16-gpu.test.ts b/packages/ecs/src/guide/16-gpu.test.ts new file mode 100644 index 000000000..b9128a341 --- /dev/null +++ b/packages/ecs/src/guide/16-gpu.test.ts @@ -0,0 +1,58 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { expect, it } from "vitest"; +import { type F32, Gpu, System, Vec2, World } from "../index.js"; + +const position = Symbol(); +const velocity = Symbol(); +const size = Symbol(); + +declare module "@adobe/ecs" { + interface Components { + [position]: { type: Vec2 }; + [velocity]: { type: Vec2 }; + [size]: { type: F32 }; + } +} + +// kernel: "gpu" runs the body as a compute shader over the GPU's copy of its components, in a world given a device +// (World.create({ [gpuDevice]: device })), and as a kernel on the CPU in one without. Its rows hold 32-bit numbers +// and vectors of them; it has no lookups, change filters or commands. +const move = System.create({ kernel: "gpu", components: { with: [velocity, position] } }, row => { + row[position] = Vec2.add(row[position], row[velocity]); +}); + +// A render system records draws over the GPU copy of the components its accessors name, in a world with a device. +// Gpu.wgsl(draw) gives its shaders a function per accessor, `fn dotPosition(row: u32) -> vec2`, and +// Gpu.layout(device) the bind group layout its pipeline takes. Each table is a group of matching rows to draw. +let pipeline: GPURenderPipeline | null = null; +let target: GPUTextureView | null = null; +const draw = System.render({ shader: { accessors: { dotPosition: position, dotSize: size } } }, ({ encoder, tables }) => { + if (pipeline === null || target === null) return; + const pass = encoder.beginRenderPass({ colorAttachments: [{ view: target, loadOp: "clear", storeOp: "store" }] }); + pass.setPipeline(pipeline); + for (const table of tables) { + table.bind(pass); + pass.draw(6, table.count); + } + pass.end(); +}); +const shaderCode = Gpu.wgsl(draw); + +const positions: Vec2[] = []; +const read = System.create({ observer: true, components: { with: [position] } }, row => { + positions.push(row[position]); +}); + +const spawn = System.create(({ commands }) => { + commands.spawn([position, velocity, size])({ [position]: [0, 0], [velocity]: [1, 2], [size]: 4 }); +}); + +it("runs GPU kernels on the CPU without a device, and skips render systems", async () => { + await using world = World.create(); + await world.run(spawn); + await world.run(move); + await world.run(draw); + await world.run(read); + expect(positions).toEqual([[1, 2]]); + expect(shaderCode).toContain("fn dotPosition(row: u32) -> vec2"); +}); diff --git a/packages/ecs/src/guide/17-hierarchies.test.ts b/packages/ecs/src/guide/17-hierarchies.test.ts new file mode 100644 index 000000000..dfb134986 --- /dev/null +++ b/packages/ecs/src/guide/17-hierarchies.test.ts @@ -0,0 +1,43 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { expect, it } from "vitest"; +import { Entity, System, World } from "../index.js"; + +const parent = Symbol(); +const offset = Symbol(); +const placed = Symbol(); +const label = Symbol(); + +declare module "@adobe/ecs" { + interface Components { + // Despawned with its parent. + [parent]: { type: Entity; reference: "owner" }; + [offset]: { type: number }; + [placed]: { type: number }; + [label]: { type: string }; + } +} + +// up runs rows parent first, through a reference, so each row reads its parent's values from earlier in the same run: +// context.up holds what the body reads of the parent, or null for a row without one. +const place = System.create({ components: { with: [offset, placed] }, up: { through: parent } }, (row, { up }) => { + row[placed] = (up === null ? 0 : up[placed]) + row[offset]; +}); + +const where: string[] = []; +const read = System.create({ observer: true, components: { with: [label, placed] } }, row => { + where.push(`${row[label]} at ${row[placed]}`); +}); + +const spawn = System.create(({ commands }) => { + const table = commands.spawn([label, offset, placed])({ [label]: "table", [offset]: 10, [placed]: 0 }); + const tray = commands.spawn([label, offset, placed, parent])({ [label]: "tray", [offset]: 2, [placed]: 0, [parent]: table }); + commands.spawn([label, offset, placed, parent])({ [label]: "cup", [offset]: 1, [placed]: 0, [parent]: tray }); +}); + +it("runs rows parent first", async () => { + await using world = World.create(); + await world.run(spawn); + await world.run(place); + await world.run(read); + expect(where.sort()).toEqual(["cup at 13", "table at 10", "tray at 12"]); +}); diff --git a/packages/ecs/src/guide/18-identity.test.ts b/packages/ecs/src/guide/18-identity.test.ts new file mode 100644 index 000000000..39b083a5a --- /dev/null +++ b/packages/ecs/src/guide/18-identity.test.ts @@ -0,0 +1,24 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { expect, it } from "vitest"; +import { Report, System, World } from "../index.js"; + +// A key, system, index, rule or finalizer is the value its constructor returns: two written alike are two, and code +// written apart can't collide. The build gives each one bound at a module's top level an id from where it's declared, +// its package's name and version, its module's path and its binding, so other threads and a module evaluated again +// by hot reload name the same one. +const explode = System.create(() => { + throw new Error("boom"); +}); + +// Reports label it by its binding, and give the id beside it to tell two of one label apart. +const failures: string[] = []; +const logFailures = System.create({ observer: true, components: { with: [Report.errors], changed: [Report.errors] } }, row => { + for (const failure of row[Report.errors].failures) failures.push(`${failure.name} ${failure.id}`); +}); + +it("labels systems by their binding and id", async () => { + await using world = World.create(); + world.add(logFailures); + await world.run(explode); + expect(failures).toEqual([expect.stringMatching(/^explode @adobe\/ecs@[^/]+\/src\/guide\/18-identity\.test\.ts#explode$/)]); +}); diff --git a/packages/ecs/src/guide/19-threads.test.ts b/packages/ecs/src/guide/19-threads.test.ts new file mode 100644 index 000000000..015fed36c --- /dev/null +++ b/packages/ecs/src/guide/19-threads.test.ts @@ -0,0 +1,55 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { expect, it } from "vitest"; +import { System, World } from "../index.js"; + +const volume = Symbol(); +const levels = Symbol(); + +// A logical thread is where objects live and the code touching them runs. Declare one, and place objects on it. +declare module "@adobe/ecs" { + interface Threads { + audio: true; + } + interface Components { + [volume]: { type: number }; + } + interface Resources { + [levels]: { type: ReadonlyMap; thread: "audio" }; + } +} + +// A system runs on the thread its objects live on: this one, writing levels, runs on audio. +const mix = System.create({ components: { with: [volume] } }, (row, { resources }) => { + resources[levels] = new Map([...resources[levels], [`voice ${row.entity}`, row[volume]]]); +}); + +// Another thread joins a shared world, claiming the threads it fills, and adds observer systems that run there after +// the world's sync points. +const heard: number[] = []; +const listen = System.create({ observer: true }, ({ resources }) => { + heard.push(resources[levels].size); +}); + +const spawn = System.create(({ commands }) => { + commands.spawn([volume])({ [volume]: 0.8 }); + commands.spawn([volume])({ [volume]: 0.3 }); +}); + +/** Waits a turn of the event loop at a time until `done`. */ +const until = async (done: () => boolean): Promise => { + while (!done()) await new Promise(resolve => setTimeout(resolve, 1)); +}; + +it("shares a world with another thread", async () => { + // Usually one end of the channel goes to a worker, `new Worker(...)`, which joins with the other. + const channel = new MessageChannel(); + await using world = World.create(); + world.share(channel.port1); + await using audio = World.join(channel.port2, { threads: ["audio"] }); + audio.add(listen); + await world.run(spawn); + // A joined world runs systems as the world does. + await audio.run(mix); + await until(() => heard.at(-1) === 2); + expect(heard.at(-1)).toBe(2); +}); diff --git a/packages/ecs/src/guide/CLAUDE.md b/packages/ecs/src/guide/CLAUDE.md index 66eb1607f..06342577c 100644 --- a/packages/ecs/src/guide/CLAUDE.md +++ b/packages/ecs/src/guide/CLAUDE.md @@ -13,22 +13,21 @@ code it shows: every excerpt comes from a file here that runs as a test, so the compile-time checks prevent most mistakes. - Never mention `@adobe/data` or any other package that isn't this one. It means nothing to a consumer. - Describe only the current API, with no history. -- Keep it small. Write sections for the basics and intermediate topics; advanced topics are placeholders until the - API settles. +- Keep it small. Advanced sections are as short as the rest: each shows the common case of its topic and stops. ## Structure -- **Setup:** its own short section before the basics: the build plugin and how keys are registered and declared. +- **Setup:** its own short section before the basics: the build plugin and how keys are declared. - **Basics:** resources, then entities, components and archetypes, which together are the whole data model, then systems, and ticks and cadence: everything needed to build and run a world and its loop. Topics that belong together share a section; entities are taught with components, and archetypes, named lists of components, with them. - **Intermediate:** scheduling and composites, sync points, change filters and conditions, input, rules, finalizers and change sets, and traits with within and references. -- **Advanced:** placeholders only. +- **Advanced:** lookups, indexes, kernels, reductions, GPU kernels and render systems, hierarchies, identity and ids, + and threads. ## Code conventions -- Keys are short: `Symbol("position")`, never a path like `"guide/components/position"`. A resource's description says - nothing about components. -- Archetypes are types, so their names are PascalCase: `const Particle = Archetype.create([position, velocity])`. +- 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. diff --git a/packages/ecs/src/guide/guide.md b/packages/ecs/src/guide/guide.md index 4554c0e38..7adf4b7de 100644 --- a/packages/ecs/src/guide/guide.md +++ b/packages/ecs/src/guide/guide.md @@ -35,16 +35,25 @@ How to use the ECS. Each excerpt comes from a test in this folder; the link unde ## Setup ```ts -// vite.config.ts: compiles kernels, checks each system's declaration against its body, and gives keys and systems ids. -import { kernelPlugin } from "@adobe/ecs/compiler"; -export default defineConfig({ plugins: [kernelPlugin()] }); +// vite.config.ts: infers what each system's body reads and writes, compiles kernels, reads the schema of each body's +// input from its type, and gives keys and systems ids. +import { Compiler } from "@adobe/ecs/compiler"; +export default defineConfig({ plugins: [Compiler.plugin()] }); ``` -Each component and resource is declared once, by augmenting `Components` or `Resources` in `@adobe/ecs` with its -traits, `type` among them, and registered once with the same traits. Registration's types check the two match. -`type` is registered as a JSON Schema for the value, in which `{ type: "set", items }` and -`{ type: "map", keys, values }` describe a `ReadonlySet` and a `ReadonlyMap`. Traits of your own augment `Traits`. -The examples create worlds with `World.create()`. +Each component and resource is declared once, in the module that makes its key, by augmenting `Components` or +`Resources` in `@adobe/ecs` with its traits, `type` among them. The plugin registers each declaration: it reads the +schema the value is stored by from `type`, so a type named by `Schema.Type`, such as `F32` or `U32`, gives its +precision or width. Traits of your own augment `Traits`. While a declaration's trait or default doesn't hold, each +`World.create` call fails to compile, naming the key, the member and what's wrong with it. A library builds with Vite and +`Compiler.plugin({ library: true })`, so its build ships its registrations. Kernels run on workers as separate +chunks, so the plugin sets Vite's `worker.format` to `"es"` when the config doesn't set it, for every worker the app +bundles, its own included, which then load as module workers (`{ type: "module" }`). The examples create worlds with +`World.create()`. + +A system declares only what its body can't show: the entities it runs over (`with` and `not`), its change filters +and conditions, and its order. What its body reads, writes and reduces into the plugin infers from the body, so +write the declaration and body where the system is made, and index rows and resources by key. ## Basics @@ -54,50 +63,62 @@ Systems change that data, once per run or on every tick. ### 1. Resources ```ts -export const gravity = Symbol("gravity"); -export const names = Symbol("names"); -export const audio = Symbol("audio"); -export const focused = Symbol("focused"); - -// Its traits are declared; `type` is the value's type. +export const gravity = Symbol(); +export const floor = Symbol(); +export const names = Symbol(); +export const audio = Symbol(); +export const focused = Symbol(); + +// A type's namespace may give the value it starts at, which every row and world starting from it shares, so readonly. +export type Height = F32; +export const Height = { default: -Infinity } as const; + +// It's declared once, with its traits: `type` is the value's type, and `default` a literal it starts at. Without one, +// it starts at its type's namespace's default, a reduction's identity, null, its zero value (0, false, "", item by +// item), or an empty ReadonlyMap or ReadonlySet of each world's own. The build registers what's declared. declare module "@adobe/ecs" { interface Resources { - [gravity]: { type: number }; + [gravity]: { type: number; default: -9.8 }; + [floor]: { type: Height }; [names]: { type: ReadonlyMap }; + // A value that isn't data, such as a class, is stored as it is; a change inside it is seen once it's written back. [audio]: { type: AudioContext | null }; + // Entity.none, 0, rather than null, so an entity stays data every thread reads. [focused]: { type: Entity }; } } -// Registration gives the same traits, with a schema that describes exactly the declared type. A resource always has a -// value: its schema's default, or one beside it. -Resource.register(gravity, { type: { type: "number", default: -9.8 } }); -Resource.register(names, { type: { type: "map", keys: { type: "string" }, values: { type: "number" } }, default: new Map() }); -// A value that isn't data has no schema and is stored as it is. A type that allows null starts there. -Resource.register(audio, {}); -// No entity is Entity.none rather than null, so an entity stays data every thread reads. -Resource.register(focused, { type: { ...Entity.schema, default: Entity.none } }); - // Systems read a world: an observer only reads, after the world's changes apply. -let values: readonly unknown[] = []; -const read = System.create({ observer: true, resources: { read: [gravity, names, audio, focused] } }, ({ resources }) => { - values = [resources[gravity], resources[names], resources[audio], resources[focused]]; -}); - -await using moon = World.create({ [gravity]: -1.6 }); -// run: runs a system once. -await moon.run(read); -expect(values[0]).toBe(-1.6); +let values: readonly [ + Resource.Value, + Resource.Value, + Resource.Value, + Resource.Value, + Resource.Value +] | null = null; +const read = System.create({ observer: true }, ({ resources }) => { + values = [resources[gravity], resources[floor], resources[names], resources[audio], resources[focused]]; +}); + +it("holds one value per world", async () => { + // A world starts each resource at its default, or at the value given when it's created. + await using earth = World.create(); + await using moon = World.create({ [gravity]: -1.6 }); + // run: runs a system once. + await earth.run(read); + expect(values).toEqual([-9.8, -Infinity, new Map(), null, Entity.none]); + await moon.run(read); + expect(values?.[0]).toBe(-1.6); ``` -[`01-resources.test.ts`](01-resources.test.ts#L6-L46) +[`01-resources.test.ts`](01-resources.test.ts#L7-L52) ### 2. Entities, components and archetypes ```ts -export const position = Symbol("position"); -export const velocity = Symbol("velocity"); -export const enemy = Symbol("enemy"); +export const position = Symbol(); +export const velocity = Symbol(); +export const enemy = Symbol(); declare module "@adobe/ecs" { interface Components { @@ -108,12 +129,9 @@ declare module "@adobe/ecs" { } } -Component.register(position, { type: Vec3.schema }); -Component.register(velocity, { type: Vec3.schema }); -Component.register(enemy, { type: { const: true } }); - -// An archetype names a list of components, once at module scope; it goes wherever a list of components does. -export const Particle = Archetype.create([position, velocity]); +// An archetype is a list of components declared `as const` at module scope, which the build indexes; it goes wherever +// a list of components does. +export const Particle = [position, velocity] as const; // Systems spawn entities: name the components, then give their values. export const spawn = System.create(({ commands }) => { @@ -122,45 +140,45 @@ export const spawn = System.create(({ commands }) => { }); // Commands also add and remove components, and despawn entities. -export const defeat = System.create({ components: { has: [enemy] } }, (row, { commands }) => { +export const defeat = System.create({ components: { with: [enemy] } }, (row, { commands }) => { commands.remove(row.entity, enemy); }); // An observer system reads them back, once per entity with its components. const foes: Vec3[] = []; -const readFoes = System.create({ observer: true, components: { read: [position], has: [enemy] } }, row => { +const readFoes = System.create({ observer: true, components: { with: [position, enemy] } }, row => { foes.push(row[position]); }); let particles = 0; -const countParticles = System.create({ observer: true, components: { has: Particle } }, () => { +const countParticles = System.create({ observer: true, components: { with: [...Particle] } }, () => { particles += 1; }); -await world.run(spawn); -await world.run(readFoes); -await world.run(countParticles); -expect(foes).toEqual([[0, 10, 0]]); -expect(particles).toBe(1); +it("spawns entities with components", async () => { + await using world = World.create(); + await world.run(spawn); + await world.run(readFoes); + await world.run(countParticles); + expect(foes).toEqual([[0, 10, 0]]); + expect(particles).toBe(1); ``` -[`02-entities.test.ts`](02-entities.test.ts#L6-L59) +[`02-entities.test.ts`](02-entities.test.ts#L6-L50) ### 3. Systems ```ts -// Runs once per entity with every component in read and write, and none in not. +// Runs once per entity with every component in with, and none in not. What its body reads and writes the build +// infers. const move = System.create({ - // Optional: labels the system in reports. - name: "physics/move", - components: { read: [velocity], write: [position], not: [frozen] }, - resources: { read: [delta] }, + components: { with: [velocity, position], not: [frozen] }, }, (row, { resources }) => { // Values are replaced whole, never edited in place. row[position] = Vec3.add(row[position], Vec3.scale(row[velocity], resources[delta])); }); // Without components, a system runs once per run. -const count = System.create({ resources: { write: [frames] } }, ({ resources }) => { +const count = System.create({}, ({ resources }) => { resources[frames] += 1; }); @@ -168,21 +186,43 @@ const count = System.create({ resources: { write: [frames] } }, ({ resources }) // react to it. const moved: Vec3[] = []; let counted = 0; -const read = System.create({ observer: true, components: { read: [position], not: [frozen] }, resources: { read: [frames] } }, (row, { resources }) => { +const read = System.create({ observer: true, components: { with: [position], not: [frozen] } }, (row, { resources }) => { moved.push(row[position]); counted = resources[frames]; }); -await world.run(move); -await world.run(count); -await world.run(read); -expect(moved).toEqual([[1, 0, 0]]); +it("runs a system once per entity, or once", async () => { + await using world = World.create(); + // run: runs a system once. + await world.run(spawn); + await world.run(move); + await world.run(count); + await world.run(read); + expect(moved).toEqual([[1, 0, 0]]); ``` -[`03-systems.test.ts`](03-systems.test.ts#L35-L69) +[`03-systems.test.ts`](03-systems.test.ts#L28-L58) ```ts -const fail = System.create({ name: "fail" }, () => { +// An optional component is in a row where its entity has it, and undefined where it doesn't: it never narrows which +// entities a system runs on. Commands add and remove it; assigning undefined removes nothing. +const speeds: (Vec3 | undefined)[] = []; +const readSpeeds = System.create({ observer: true, components: { with: [position], optional: [velocity] } }, row => { + speeds.push(row[velocity]); +}); + +it("reads optional components where entities have them", async () => { + await using world = World.create(); + await world.run(spawnStill); + await world.run(readSpeeds); + expect(speeds).toEqual([undefined, [2, 0, 0]]); +}); +``` + +[`03-systems.test.ts`](03-systems.test.ts#L67-L79) + +```ts +const fail = System.create(() => { throw new Error("broken"); }); @@ -190,95 +230,114 @@ const fail = System.create({ name: "fail" }, () => { // it took, and `Report.errors` is there only when something went wrong. An observer system added to follow errors as // they change logs them; what observer systems throw is in the next run's report. const logged: string[] = []; -const logFailures = System.create({ observer: true, components: { read: [Report.errors], changed: [Report.errors] } }, row => { +const logFailures = System.create({ observer: true, components: { with: [Report.errors], changed: [Report.errors] } }, row => { for (const failure of row[Report.errors].failures) logged.push(`${failure.name}: ${failure.message}`); }); -world.add(logFailures); -const run: number = await world.run(fail); -expect(run).toBe(1); -expect(logged).toEqual(["fail: broken"]); +it("reports what failed as entities", async () => { + await using world = World.create(); + world.add(logFailures); + const run: number = await world.run(fail); + expect(run).toBe(1); + expect(logged).toEqual(["fail: broken"]); ``` -[`03-systems.test.ts`](03-systems.test.ts#L71-L87) +[`03-systems.test.ts`](03-systems.test.ts#L81-L98) ### 4. Ticks and cadence ```ts // clock is the current tick's: delta is the milliseconds since the last one. -const advance = System.create({ resources: { read: [clock], write: [elapsed] } }, ({ resources }) => { +const advance = System.create({}, ({ resources }) => { resources[elapsed] += resources[clock].delta; }); // Added, an observer system reacts to changes: with elapsed in changed, it runs after each tick that changed it. let reached: (value: number) => void = () => {}; -const watch = System.create({ observer: true, resources: { read: [elapsed], changed: [elapsed] } }, ({ resources }) => { +const watch = System.create({ observer: true, resources: { changed: [elapsed] } }, ({ resources }) => { if (resources[elapsed] >= 30) reached(resources[elapsed]); }); -// cadence ticks once per animation frame by default; fixed ticks every step milliseconds, each with that delta. -// A tick runs only after a run arrives, unless always is set. -await using world = World.create({ [cadence]: { kind: "fixed", step: 10 }, [always]: true }); -const ticked = new Promise(resolve => { reached = resolve; }); -// add: runs a system on every tick. -world.add(advance); -world.add(watch); -expect(await ticked).toBeGreaterThanOrEqual(30); -// remove: takes a system added off every tick again. -world.remove(advance); +let read = { elapsed: 0, delta: 0 }; +const readTime = System.create({ observer: true }, ({ resources }) => { + read = { elapsed: resources[elapsed], delta: resources[clock].delta }; +}); + +it("runs added systems on every tick", async () => { + // cadence ticks once per animation frame by default; fixed ticks every step milliseconds, each with that delta. + // A tick runs only after a run arrives, unless always is set. + await using world = World.create({ [cadence]: { kind: "fixed", step: 10 }, [always]: true }); + const ticked = new Promise(resolve => { reached = resolve; }); + // add: runs a system on every tick. + world.add(advance); + world.add(watch); + expect(await ticked).toBeGreaterThanOrEqual(30); + await world.run(readTime); + expect(read.delta).toBe(10); + // remove: takes a system added off every tick again. + world.remove(advance); ``` -[`04-ticks.test.ts`](04-ticks.test.ts#L18-L52) +[`04-ticks.test.ts`](04-ticks.test.ts#L15-L43) ```ts // A system asks for another tick by contributing a time to wake: the first tick at or after it runs. Work that a tick's // commands start and a later tick finishes asks for the next one this way. -const step = System.create({ resources: { read: [clock], write: [steps] }, reduce: { [wake]: "min" } }, ({ resources, reduce }) => { +const step = System.create({}, ({ resources, reduce }) => { resources[steps] += 1; if (resources[steps] < 3) reduce[wake](resources[clock].now); }); -await using world = World.create({ [cadence]: { kind: "fixed", step: 10 } }); -world.add(step); -// One run arriving ticks once; step's wakes tick twice more. -await world.run(System.create(() => {})); +let finished: (value: number) => void = () => {}; +const watchSteps = System.create({ observer: true, resources: { changed: [steps] } }, ({ resources }) => { + if (resources[steps] === 3) finished(resources[steps]); +}); + +it("ticks again when a system asks", async () => { + await using world = World.create({ [cadence]: { kind: "fixed", step: 10 } }); + const done = new Promise(resolve => { finished = resolve; }); + world.add(step); + world.add(watchSteps); + // One run arriving ticks once; step's wakes tick twice more. + await world.run(System.create(() => {})); ``` -[`04-ticks.test.ts`](04-ticks.test.ts#L54-L75) +[`04-ticks.test.ts`](04-ticks.test.ts#L51-L69) ## Intermediate ### 5. Scheduling and composites ```ts -const hit = System.create({ components: { read: [damage], write: [health] } }, row => { +const hit = System.create({ components: { with: [damage, health] } }, row => { row[health] -= row[damage]; }); // before, after and during list the systems themselves. -const heal = System.create({ components: { write: [health] }, after: [hit] }, row => { +const heal = System.create({ components: { with: [health] }, after: [hit] }, row => { row[health] = Math.min(row[health] * 2, 10); }); // A list of systems is a composite: it runs its members as one system, in their order; members without an order -// between them may run in parallel. { systems, after, ... } declares one with a name or constraints. +// between them may run in parallel. { systems, after, ... } declares one with constraints. const frame = System.create([heal, hit]); ``` -[`05-scheduling.test.ts`](05-scheduling.test.ts#L19-L43) +[`05-scheduling.test.ts`](05-scheduling.test.ts#L16-L25) ```ts // Phases are systems that do nothing; a system during one runs after what the phase follows and before what follows it. const update = System.create(() => {}); const render = System.create({ after: [update] }, () => {}); -const poison = System.create({ components: { write: [health] }, during: [update] }, row => { +const poison = System.create({ components: { with: [health] }, during: [update] }, row => { row[health] -= 1; }); -const report = System.create({ components: { read: [health] }, during: [render] }, row => { +const seen: number[] = []; +const report = System.create({ components: { with: [health] }, during: [render] }, row => { seen.push(row[health]); }); const phased = System.create([report, poison, update, render]); ``` -[`05-scheduling.test.ts`](05-scheduling.test.ts#L45-L62) +[`05-scheduling.test.ts`](05-scheduling.test.ts#L41-L51) ### 6. Sync points @@ -292,112 +351,139 @@ const spawn = System.create(({ commands }) => { commands.despawn(commands.spawn([health])({ [health]: 1 })); }); // A composite is one run with one sync point: count runs after spawn, before its entities exist. -const count = System.create({ components: { has: [health] }, resources: { write: [seen] }, after: [spawn] }, (row, { resources }) => { +const count = System.create({ components: { with: [health] }, after: [spawn] }, (row, { resources }) => { resources[seen] += 1; }); const frame = System.create([spawn, count]); ``` -[`06-sync-points.test.ts`](06-sync-points.test.ts#L21-L53) +[`06-sync-points.test.ts`](06-sync-points.test.ts#L17-L29) ```ts // An observer runs after the sync point of every run and tick it's added to, reading what it left; it writes nothing. -const watch = System.create({ observer: true, components: { read: [health] } }, row => { +const watch = System.create({ observer: true, components: { with: [health] } }, row => { healths.push(row[health]); }); ``` -[`06-sync-points.test.ts`](06-sync-points.test.ts#L36-L39) +[`06-sync-points.test.ts`](06-sync-points.test.ts#L32-L35) ### 7. Change filters and conditions ```ts // A resource in changed passes the first run and each run after it changes: no run-once flag needed. -const load = System.create({ resources: { read: [level], changed: [level] } }, ({ resources, commands }) => { +const load = System.create({ resources: { changed: [level] } }, ({ resources, commands }) => { commands.spawn([score, shown, boss])({ [score]: 0, [shown]: -1 }); for (let i = 0; i < resources[level]; i++) commands.spawn([score, shown])({ [score]: 0, [shown]: -1 }); }); // A component in changed passes only rows written since this system last ran. -const show = System.create({ components: { read: [score], write: [shown], changed: [score] } }, row => { +const show = System.create({ components: { with: [score, shown], changed: [score] } }, row => { row[shown] = row[score]; }); ``` -[`07-change-filters.test.ts`](07-change-filters.test.ts#L36-L66) +[`07-change-filters.test.ts`](07-change-filters.test.ts#L27-L35) ```ts // Runs only while each resource in is is neither null nor false, and each in not is; is reads it narrowed. -const step = System.create({ resources: { is: [map], not: [paused], write: [steps] } }, ({ resources }) => { +const step = System.create({ resources: { is: [map], not: [paused] } }, ({ resources }) => { resources[steps] += resources[map].width; }); ``` -[`07-change-filters.test.ts`](07-change-filters.test.ts#L68-L89) +[`07-change-filters.test.ts`](07-change-filters.test.ts#L59-L62) ### 8. Input ```ts -// input is an object schema of named fields; each run passes a value of it. +// A body's input is its parameter after its context, an object of named fields; each run passes a value of it. const hit = System.create({ - components: { write: [health] }, - input: { type: "object", properties: { amount: { type: "number" } } }, -}, (row, { input }) => { + components: { with: [health] }, +}, (row, _context, input: { readonly amount: number }) => { row[health] -= input.amount; }); // target names an input field holding an entity: the system runs on that entity's row alone, or on none once it's gone. const heal = System.create({ - components: { write: [health] }, - input: { type: "object", properties: { entity: Entity.schema, amount: { type: "number" } } }, + components: { with: [health] }, target: "entity", -}, (row, { input }) => { +}, (row, _context, input: { readonly entity: Entity; readonly amount: number }) => { row[health] += input.amount; }); -// Systems with input are run, not added; a composite's input has every field its members declare. -await world.run(hit, { amount: 3 }); -await world.run(heal, { entity: player, amount: 1 }); +const healths: number[] = []; +const read = System.create({ observer: true, components: { with: [health] } }, row => { + healths.push(row[health]); +}); + +it("runs a system with a value", async () => { + await using world = World.create(); + await world.run(spawn); + // Systems with input are run, not added; a composite's input has every field its members declare. + await world.run(hit, { amount: 3 }); + await world.run(heal, { entity: player, amount: 1 }); ``` -[`08-input.test.ts`](08-input.test.ts#L19-L49) +[`08-input.test.ts`](08-input.test.ts#L16-L41) ### 9. Rules ```ts declare module "@adobe/ecs" { interface Components { - // Only rules add derived components, starting at their schema's default. + [mesh]: { type: number }; + [sprite]: { type: number }; + [hidden]: { type: true }; + // Only rules add derived components, starting at their default. [drawn]: { type: true; derived: true }; - [layer]: { type: number; derived: true }; + [layer]: { type: number; derived: true; default: 1 }; } } -Component.register(drawn, { type: { const: true }, derived: true }); -Component.register(layer, { type: { type: "number", default: 1 }, derived: true }); - // Entities matching a rule have what it derives, only while they match. -Rule.create({ components: { has: [mesh], not: [hidden] }, derive: [drawn] }); +Rule.create({ components: { with: [mesh], not: [hidden] }, derive: [drawn] }); // Several rules may derive one component, present while any matches. -Rule.create({ components: { has: [sprite], not: [hidden] }, derive: [drawn] }); +Rule.create({ components: { with: [sprite], not: [hidden] }, derive: [drawn] }); // Rules build on what others derive. -Rule.create({ components: { has: [drawn] }, derive: [layer] }); +Rule.create({ components: { with: [drawn] }, derive: [layer] }); ``` -[`09-rules.test.ts`](09-rules.test.ts#L11-L59) +[`09-rules.test.ts`](09-rules.test.ts#L11-L27) ### 10. Finalizers and change sets ```ts // Runs for each entity that stops matching, whatever the cause, reading its values from before. -Finalizer.create({ components: { read: [handle] } }, row => { +Finalizer.create({ components: { with: [handle] } }, row => { open.delete(row[handle]); }); -// dispose runs every finalizer for the entities it still matches. -await world.dispose(); +const spawn = System.create(({ commands }) => { + for (const id of [7, 8]) { + open.add(id); + commands.spawn([handle])({ [handle]: id }); + } +}); +const destroy = System.create({ components: { with: [handle] } }, (row, { commands }) => { + if (row[handle] === 7) commands.despawn(row.entity); + else commands.remove(row.entity, handle); +}); + +it("releases what entities held when they stop matching", async () => { + await using world = World.create(); + await world.run(spawn); + await world.run(destroy); + expect([...open]).toEqual([]); +}); + +it("releases what every entity holds when the world is disposed", async () => { + const world = World.create(); + await world.run(spawn); + // dispose runs every finalizer for the entities it still matches. + await world.dispose(); ``` -[`10-finalizers.test.ts`](10-finalizers.test.ts#L16-L48) +[`10-finalizers.test.ts`](10-finalizers.test.ts#L16-L43) ```ts // Sees each sync point's structural changes to the components it watches, grouped by how entities changed, with @@ -409,12 +495,12 @@ Finalizer.changes({ watch: [handle] }, changes => { }); ``` -[`10-finalizers.test.ts`](10-finalizers.test.ts#L50-L66) +[`10-finalizers.test.ts`](10-finalizers.test.ts#L49-L55) ### 11. Traits, within and references ```ts -// Traits are the fields of a key's declaration; type is one. Registration repeats them. +// Traits are the fields of a key's declaration; type is one. declare module "@adobe/ecs" { interface Components { [ship]: { type: true }; @@ -428,12 +514,6 @@ declare module "@adobe/ecs" { } } -Component.register(ship, { type: { const: true } }); -Component.register(parent, { type: Entity.schema, reference: "owner" }); -Component.register(target, { type: Entity.schema, reference: "weak" }); -Component.register(selected, { type: { const: true } }); -Component.register(outline, { type: { type: "number" }, within: selected }); - const spawn = System.create(({ commands }) => { const mothership = commands.spawn([ship, selected, outline])({ [outline]: 2 }); commands.spawn([parent])({ [parent]: mothership }); @@ -441,111 +521,331 @@ const spawn = System.create(({ commands }) => { }); ``` -[`11-traits.test.ts`](11-traits.test.ts#L12-L60) +[`11-traits.test.ts`](11-traits.test.ts#L11-L29) ```ts // A trait of your own is a member of Traits: the value it allows on key N, or never where it doesn't apply. -export const instanced = Symbol("instanced"); +export const instanced = Symbol(); declare module "@adobe/ecs" { interface Traits { readonly [instanced]: N extends Component.Key ? ([Component.Value] extends [readonly number[]] ? true : never) : never; } } -Trait.register(instanced); + +const transform = Symbol(); +const name = Symbol(); +declare module "@adobe/ecs" { + interface Components { + [transform]: { type: readonly [number, number, number]; [instanced]: true }; + [name]: { type: string }; + } +} // Types select components by trait. check, typeof transform | typeof name>, typeof transform>>(); ``` -[`11-traits.type-test.ts`](11-traits.type-test.ts#L7-L28) +[`11-traits.type-test.ts`](11-traits.type-test.ts#L7-L25) ## Advanced ### 12. Lookups -Reading another entity's components by id, from a system. To be written. +A lookup reads other entities from a system: by id, as here, or by value through an index (next section). It's named in +the declaration and called from the body, it's read-only, and it doesn't change which entities the system runs on. + +```ts +// The entity's row, or null when it doesn't have every component in with, or has one in not. The row holds only what +// with lists. +const track = System.create({ + components: { with: [target, aim] }, + lookup: { enemy: { with: [health], not: [frozen] } }, +}, (row, { lookup }) => { + const enemy = lookup.enemy(row[target]); + row[aim] = enemy === null ? 0 : enemy[health]; +}); +``` + +[`12-lookups.test.ts`](12-lookups.test.ts#L19-L27) + +```ts +// With changed, its row says whether those components may have changed since the system last ran. +const refreshed: boolean[] = []; +const watch = System.create({ + observer: true, + components: { with: [target] }, + lookup: { enemy: { with: [health], changed: [health] } }, +}, (row, { lookup }) => { + const enemy = lookup.enemy(row[target]); + if (enemy !== null) refreshed.push(enemy.changed); +}); +``` + +[`12-lookups.test.ts`](12-lookups.test.ts#L29-L38) + +To find entities by their values rather than their ids, look up through an index (next section). ### 13. Indexes +An index is a declared map from component values to the entities holding them, which every world keeps current. A +lookup through it finds entities by value, as a lookup by entity finds one by id. + +- A hash index, the default, gives the rows with a key. +- `sorted: true` also takes ranges, and orders a system's rows with `order`. +- `unique: true` gives one row, or null. +- `key: []` holds every entity it matches. + ```ts -// An index finds entities by their components' values: here, those with a crowded count, by their cell. -const grid = Index.create({ key: [cell], components: { has: [crowded] } }); +// Here, entities with a crowded count, by their cell. +const grid = Index.create({ key: [cell], components: { with: [crowded] } }); // A lookup through it gives the rows with a key, in entity order. -const count = System.create({ components: { read: [cell], write: [crowded] }, lookup: { at: { index: grid } } }, (row, { lookup }) => { +const count = System.create({ components: { with: [cell, crowded] }, lookup: { at: { index: grid } } }, (row, { lookup }) => { const [x, y] = row[cell]; row[crowded] = lookup.at([x, y]).length; }); ``` -[`13-indexes.test.ts`](13-indexes.test.ts#L28-L35) +[`13-indexes.test.ts`](13-indexes.test.ts#L21-L28) ```ts // A sorted index keeps its key's order: lookups take a range, and `order` runs a system's rows in it. -const byScore = Index.create({ key: [score], sorted: true, components: { has: [player] } }); +const byScore = Index.create({ key: [score], sorted: true, components: { with: [player] } }); const winners: string[] = []; -const leaders = System.create({ observer: true, lookup: { scores: { index: byScore, read: [player] } } }, ({ lookup }) => { +const leaders = System.create({ observer: true, lookup: { scores: { index: byScore } } }, ({ lookup }) => { winners.length = 0; const high = lookup.scores({ ">=": 90 }); for (let i = 0; i < high.length; i++) winners.push(high.at(i)[player]); }); const board: string[] = []; -const list = System.create({ observer: true, components: { read: [player] }, order: byScore }, row => { +const list = System.create({ observer: true, components: { with: [player] }, order: byScore }, row => { board.push(row[player]); }); ``` -[`13-indexes.test.ts`](13-indexes.test.ts#L37-L50) +[`13-indexes.test.ts`](13-indexes.test.ts#L30-L43) ```ts // A unique index gives one row, or null. const byServerId = Index.create({ key: [serverId], unique: true }); let found: number | null = null; -const find = System.create({ input: { type: "object", properties: { id: { type: "integer" } } }, lookup: { byId: { index: byServerId } } }, ({ input, lookup }) => { +const find = System.create({ lookup: { byId: { index: byServerId } } }, ({ lookup }, input: { readonly id: number }) => { found = lookup.byId(input.id)?.entity ?? null; }); ``` -[`13-indexes.test.ts`](13-indexes.test.ts#L52-L57) +[`13-indexes.test.ts`](13-indexes.test.ts#L45-L50) ```ts // An index without a key holds every entity with its components, in entity order. With changed: true, this observer // system runs only when an entity entered or left the index, so added, it keeps a count as players come and go. -const players = Index.create({ key: [], components: { has: [player] } }); +const players = Index.create({ key: [], components: { with: [player] } }); let playerCount = 0; const countPlayers = System.create({ observer: true, lookup: { players: { index: players, changed: true } } }, ({ lookup }) => { playerCount = lookup.players().length; }); ``` -[`13-indexes.test.ts`](13-indexes.test.ts#L59-L65) +[`13-indexes.test.ts`](13-indexes.test.ts#L52-L58) -A list stays valid until its call site runs again. Lists and rows read only what the index holds and its lookup's -`read` names, and `list.changed` tells whether an entity entered, left or moved in it since the system last ran. A -lookup's `changed: true` runs its system only when that happened anywhere in the index. +A list has `length`, `changed` and `at(i)`. It's reused, valid until its call site runs again, each row as long as its +list, and costs only what the body reads. Rows hold what every entity in the index has: its key and its `with`. +`list.changed` says whether an entity entered, left or moved in the list since the system last ran; a lookup's +`changed: true` runs its system only when that happened anywhere in the index. ### 14. Kernels -Systems compiled to run across workers. To be written. +```ts +// A kernel is a body run once per row, each row on its own, so the world can split rows across workers and run them as +// WASM. A body that reads and writes only structs, calls math and touches no module state is one: the build compiles +// it, with results the same as the body as written. +const move = System.create({ components: { with: [velocity, position] } }, (row, { resources }) => { + row[position] = Vec3.add(row[position], Vec3.scale(row[velocity], resources[delta])); +}); + +// kernel: true asserts it: the build fails, saying why, if the body can't be one. It also computes f32 values at f32, +// which is faster on dense rows. +const drift = System.create({ kernel: true, components: { with: [velocity, position] } }, row => { + row[position] = Vec3.add(row[position], row[velocity]); +}); + +// kernel: false runs the body as written, on its thread: to step through it in a debugger, say. +const slow = System.create({ kernel: false, components: { with: [velocity] } }, row => { + row[velocity] = Vec3.scale(row[velocity], 0.5); +}); +``` + +[`14-kernels.test.ts`](14-kernels.test.ts#L19-L35) ### 15. Reductions -One value computed from many rows. To be written. +```ts +declare module "@adobe/ecs" { + interface Components { + [mass]: { type: F32 }; + } + // A reduced resource names how contributions combine: "+", "*", "min", "max", or bitwise "&", "|", "^". + interface Resources { + [total]: { type: number; reduce: "+" }; + [heaviest]: { type: F32; reduce: "max" }; + [count]: { type: number; reduce: "+" }; + } +} +``` + +[`15-reductions.test.ts`](15-reductions.test.ts#L10-L20) + +```ts +// Each row contributes; every run starts each resource it reduces at its operator's identity and leaves it holding the +// fold of what every row gave, however the rows were split across workers. +const weigh = System.create({ components: { with: [mass] } }, (row, { reduce }) => { + reduce[total](row[mass]); + reduce[heaviest](row[mass]); + reduce[count](1); +}); + +// Read the result after the run, as any resource. +let summary = ""; +const report = System.create({ observer: true }, ({ resources }) => { + summary = `${resources[count]} weigh ${resources[total]}, at most ${resources[heaviest]}`; +}); +``` + +[`15-reductions.test.ts`](15-reductions.test.ts#L22-L34) ### 16. GPU kernels and render systems -Systems that run or draw on the GPU. To be written. +```ts +// kernel: "gpu" runs the body as a compute shader over the GPU's copy of its components, in a world given a device +// (World.create({ [gpuDevice]: device })), and as a kernel on the CPU in one without. Its rows hold 32-bit numbers +// and vectors of them; it has no lookups, change filters or commands. +const move = System.create({ kernel: "gpu", components: { with: [velocity, position] } }, row => { + row[position] = Vec2.add(row[position], row[velocity]); +}); +``` + +[`16-gpu.test.ts`](16-gpu.test.ts#L17-L22) + +```ts +// A render system records draws over the GPU copy of the components its accessors name, in a world with a device. +// Gpu.wgsl(draw) gives its shaders a function per accessor, `fn dotPosition(row: u32) -> vec2`, and +// Gpu.layout(device) the bind group layout its pipeline takes. Each table is a group of matching rows to draw. +let pipeline: GPURenderPipeline | null = null; +let target: GPUTextureView | null = null; +const draw = System.render({ shader: { accessors: { dotPosition: position, dotSize: size } } }, ({ encoder, tables }) => { + if (pipeline === null || target === null) return; + const pass = encoder.beginRenderPass({ colorAttachments: [{ view: target, loadOp: "clear", storeOp: "store" }] }); + pass.setPipeline(pipeline); + for (const table of tables) { + table.bind(pass); + pass.draw(6, table.count); + } + pass.end(); +}); +const shaderCode = Gpu.wgsl(draw); +``` + +[`16-gpu.test.ts`](16-gpu.test.ts#L24-L39) ### 17. Hierarchies -Running rows parent first, through a reference. To be written. +```ts +declare module "@adobe/ecs" { + interface Components { + // Despawned with its parent. + [parent]: { type: Entity; reference: "owner" }; + [offset]: { type: number }; + [placed]: { type: number }; + [label]: { type: string }; + } +} + +// up runs rows parent first, through a reference, so each row reads its parent's values from earlier in the same run: +// context.up holds what the body reads of the parent, or null for a row without one. +const place = System.create({ components: { with: [offset, placed] }, up: { through: parent } }, (row, { up }) => { + row[placed] = (up === null ? 0 : up[placed]) + row[offset]; +}); +``` + +[`17-hierarchies.test.ts`](17-hierarchies.test.ts#L10-L24) ### 18. Identity and ids -How systems and keys stay the same across reloads. To be written. +```ts +// A key, system, index, rule or finalizer is the value its constructor returns: two written alike are two, and code +// written apart can't collide. The build gives each one bound at a module's top level an id from where it's declared, +// its package's name and version, its module's path and its binding, so other threads and a module evaluated again +// by hot reload name the same one. +const explode = System.create(() => { + throw new Error("boom"); +}); + +// Reports label it by its binding, and give the id beside it to tell two of one label apart. +const failures: string[] = []; +const logFailures = System.create({ observer: true, components: { with: [Report.errors], changed: [Report.errors] } }, row => { + for (const failure of row[Report.errors].failures) failures.push(`${failure.name} ${failure.id}`); +}); +``` + +[`18-identity.test.ts`](18-identity.test.ts#L5-L17) ### 19. Threads -Sharing a world with another thread, which joins it to run systems and add observers. To be written. +```ts +// A logical thread is where objects live and the code touching them runs. Declare one, and place objects on it. +declare module "@adobe/ecs" { + interface Threads { + audio: true; + } + interface Components { + [volume]: { type: number }; + } + interface Resources { + [levels]: { type: ReadonlyMap; thread: "audio" }; + } +} + +// A system runs on the thread its objects live on: this one, writing levels, runs on audio. +const mix = System.create({ components: { with: [volume] } }, (row, { resources }) => { + resources[levels] = new Map([...resources[levels], [`voice ${row.entity}`, row[volume]]]); +}); + +// Another thread joins a shared world, claiming the threads it fills, and adds observer systems that run there after +// the world's sync points. +const heard: number[] = []; +const listen = System.create({ observer: true }, ({ resources }) => { + heard.push(resources[levels].size); +}); +``` + +[`19-threads.test.ts`](19-threads.test.ts#L8-L31) + +```ts +const spawn = System.create(({ commands }) => { + commands.spawn([volume])({ [volume]: 0.8 }); + commands.spawn([volume])({ [volume]: 0.3 }); +}); + +/** Waits a turn of the event loop at a time until `done`. */ +const until = async (done: () => boolean): Promise => { + while (!done()) await new Promise(resolve => setTimeout(resolve, 1)); +}; + +it("shares a world with another thread", async () => { + // Usually one end of the channel goes to a worker, `new Worker(...)`, which joins with the other. + const channel = new MessageChannel(); + await using world = World.create(); + world.share(channel.port1); + await using audio = World.join(channel.port2, { threads: ["audio"] }); + audio.add(listen); + await world.run(spawn); + // A joined world runs systems as the world does. + await audio.run(mix); + await until(() => heard.at(-1) === 2); + expect(heard.at(-1)).toBe(2); +}); +``` + +[`19-threads.test.ts`](19-threads.test.ts#L33-L55) diff --git a/packages/ecs/src/implementation/compiler/access.ts b/packages/ecs/src/implementation/compiler/access.ts index 6ae9f6b81..c4169494a 100644 --- a/packages/ecs/src/implementation/compiler/access.ts +++ b/packages/ecs/src/implementation/compiler/access.ts @@ -50,9 +50,8 @@ export const access = (value: Value, key: Key, node: Node, scope: Scope, optiona if (name === "lookup") return { kind: "lookups" }; if (name === "reduce") return { kind: "reducers" }; if (name === "commands") return { kind: "commands" }; - if (name === "input" && signature.input) return { kind: "dynamic", expression: { kind: "args", path: [] } }; if (name === "up" && scope.unit.up !== null) return { kind: "found", lookup: signature.lookups.length, expression: local(scope.unit.up) }; - return fail(node, "the context holds the declared resources, lookup, reduce, input and up, and commands"); + return fail(node, "the context holds the declared resources, lookup, reduce and up, and commands"); case "resources": { const index = key.kind === "binding" ? signature.resources.indexOf(key.name) : -1; if (key.kind !== "binding" || index < 0) return fail(node, `${key.kind === "binding" ? key.name : "resources"} is not in resources read`); @@ -61,7 +60,7 @@ export const access = (value: Value, key: Key, node: Node, scope: Scope, optiona } case "reducers": { const reduced = key.kind === "binding" ? signature.reduced.findIndex(entry => entry.resource === key.name) : -1; - return reduced >= 0 ? { kind: "reducer", reduced } : fail(node, "reduce is indexed by a resource in the declaration's reduce"); + return reduced >= 0 ? { kind: "reducer", reduced } : fail(node, "reduce is indexed by a resource in the declaration's resources.reduce"); } case "lookups": { const lookup = signature.lookups.findIndex(entry => entry.name === name); diff --git a/packages/ecs/src/implementation/compiler/archetypes.test.ts b/packages/ecs/src/implementation/compiler/archetypes.test.ts new file mode 100644 index 000000000..016e9b034 --- /dev/null +++ b/packages/ecs/src/implementation/compiler/archetypes.test.ts @@ -0,0 +1,64 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { describe, expect, test } from 'vitest'; +import { parseAst } from 'vite'; +import { archetypeFunction, archetypeInsertions } from './archetypes.js'; +import { archetypeIndexKey } from '../../interface/registry/archetypes.js'; +import { insertInto } from './source-map.js'; +import { moduleOf } from './type-schemas.js'; + +/** The module as the pass leaves it without the function it appends, its failure's message, or null. */ +const passed = (code: string): string | null => { + const result = archetypeInsertions(moduleOf("/src/lists.ts", code, parseAst(code, { lang: "ts" }, "/src/lists.ts"))); + if (result === null) return null; + if ("failure" in result) return `failure: ${result.failure.message}`; + return insertInto(code, result.insertions, "/src/lists.ts").code.replace(/\nfunction archetype\$\d*\(list\) \{.*\}\n$/, ""); +}; + +const keys = `import { label, type Other } from "./keys.js";\nconst todo = Symbol();\nconst done = Symbol();\n`; + +describe("archetypeInsertions", () => { + test("gives a top-level list of keys declared as const an index, its own keys or imported", () => { + expect(passed(`${keys}export const Todo = [todo, label, done] as const;`)).toBe(`${keys}export const Todo = archetype$([todo, label, done] as const);`); + }); + test("gives an index to lists spreading lists of keys, and to lists in what the module makes as it loads", () => { + expect(passed(`${keys}const Node = [todo, done] as const;\nconst Kinds = [[...Node, label] as const, { list: [label] as const }];`)) + .toBe(`${keys}const Node = archetype$([todo, done] as const);\nconst Kinds = [archetype$([...Node, label] as const), { list: archetype$([label] as const) }];`); + }); + test("leaves a list spreading what isn't a list of keys", () => { + expect(passed(`${keys}const named = [todo];\nconst Node = [...named, done] as const;`)).toBe(null); + }); + test("leaves lists it can't tell are only keys, or that aren't declared as const", () => { + expect(passed(`${keys}const loose = [todo, done];\nconst mixed = [todo, 1] as const;\nconst named = [todo, other] as const;\nconst empty = [] as const;`)).toBe(null); + }); + test("leaves a list in a function, which isn't made once", () => { + expect(passed(`${keys}const make = () => [todo, done] as const;`)).toBe(null); + }); + test("fails for a list naming one of its module's keys twice, and leaves one naming an import twice", () => { + expect(passed(`${keys}const Todo = [todo, done, todo] as const;`)).toBe("failure: this list names todo twice; a list of components names each once"); + expect(passed(`${keys}const Labels = [label, label] as const;`)).toBe(null); + }); + test("leaves a module binding a global the appended function uses", () => { + expect(passed(`${keys}const Object = 1;\nconst Todo = [todo, done] as const;`)).toBe(null); + }); + test("gives an index to a list of a namespace's keys", () => { + expect(passed(`import { Report } from "@adobe/ecs";\nconst Reported = [Report.run, Report.errors] as const;`)) + .toBe(`import { Report } from "@adobe/ecs";\nconst Reported = archetype$([Report.run, Report.errors] as const);`); + }); + test("appends a function indexing a list of symbols each listed once, whether or not their declarations registered yet", () => { + const made: unknown = new Function(`${archetypeFunction("index")}\nreturn index;`)(); + const index = (list: readonly unknown[]): unknown => typeof made === "function" ? Reflect.apply(made, undefined, [list]) : null; + const [first, second] = [Symbol(), Symbol()]; + const list = [first, second]; + expect(index(list)).toBe(list); + expect(typeof Reflect.get(list, archetypeIndexKey)).toBe("number"); + const twice = [first, first]; + index(twice); + expect(Reflect.get(twice, archetypeIndexKey)).toBe(undefined); + }); + test("appends a function giving each list its index once", () => { + const code = `${keys}const Todo = [todo, done] as const;`; + const result = archetypeInsertions(moduleOf("/src/lists.ts", code, parseAst(code, { lang: "ts" }, "/src/lists.ts"))); + const whole = result !== null && "insertions" in result ? insertInto(code, result.insertions, "/src/lists.ts").code : ""; + expect(whole.match(/function archetype\$\(list\)/g)?.length).toBe(1); + }); +}); diff --git a/packages/ecs/src/implementation/compiler/archetypes.ts b/packages/ecs/src/implementation/compiler/archetypes.ts new file mode 100644 index 000000000..00602378a --- /dev/null +++ b/packages/ecs/src/implementation/compiler/archetypes.ts @@ -0,0 +1,134 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { archetypeCountName, archetypeIndexName } from '../../interface/registry/archetypes.js'; +import type { PassResult } from './input-schemas.js'; +import type { Insertion } from './source-map.js'; +import type { Module, Node, Statement } from './type-schemas.js'; + +/** + * The function the plugin appends, as `name`, to a module with lists of keys it can read: it gives a list a + * program-wide index, by which worlds find what they resolved for it without comparing components, when every member is + * a symbol, each once, and returns the list. It runs where the list is made, before any key's declaration may be + * registered, so whether they're components is checked where the list is used. Generated code imports nothing, so it + * shares the count through a global, as every copy of the package does (`registry/archetypes.ts`). + */ +export const archetypeFunction = (name: string): string => [ + `function ${name}(list) {`, + ` const index = Symbol.for(${JSON.stringify(archetypeIndexName)}), count = Symbol.for(${JSON.stringify(archetypeCountName)});`, + ` if (Object.prototype.hasOwnProperty.call(list, index) || !Object.isExtensible(list)) return list;`, + ` for (let k = 0; k < list.length; k++) if (typeof list[k] !== "symbol" || list.indexOf(list[k]) < k) return list;`, + ` const next = globalThis[count] || 0; globalThis[count] = next + 1; Object.defineProperty(list, index, { value: next });`, + ` return list;`, + `}`, +].join(""); + +const uniqueName = (code: string, base: string): string => { + let name = base; + for (let n = 1; code.includes(name); n++) name = `${base}${n}`; + return name; +}; + +type Declarations = Extract; +type Expression = NonNullable; +/** `[...] as const`. */ +type ConstList = Extract & { readonly expression: Extract }; + +const isNode = (value: object): value is Node => typeof Reflect.get(value, "start") === "number" && typeof Reflect.get(value, "type") === "string"; + +const isConstList = (node: Node): node is ConstList => { + if (node.type !== "TSAsExpression") return false; + const expression: unknown = Reflect.get(node, "expression"); + const annotation: unknown = Reflect.get(node, "typeAnnotation"); + const typeName: unknown = typeof annotation === "object" && annotation !== null ? Reflect.get(annotation, "typeName") : undefined; + return typeof expression === "object" && expression !== null && Reflect.get(expression, "type") === "ArrayExpression" + && typeof typeName === "object" && typeName !== null && Reflect.get(typeName, "type") === "Identifier" && Reflect.get(typeName, "name") === "const"; +}; + +/** Nodes whose code runs when called, not when the module loads. */ +const functions: ReadonlySet = new Set(["ArrowFunctionExpression", "FunctionExpression", "FunctionDeclaration", "ClassDeclaration", "ClassExpression"]); + +/** A top-level statement's variable declaration, exported or not. */ +const variablesOf = (statement: Statement): Declarations | null => { + const declared = statement.type === "ExportNamedDeclaration" ? statement.declaration : statement; + return declared?.type === "VariableDeclaration" ? declared : null; +}; + +/** Whether a module binds at top level a global the appended function uses. */ +const bindsGlobals = (module: Module): boolean => module.program.body.some(statement => { + const declared = statement.type === "ExportNamedDeclaration" ? statement.declaration : statement; + const names = declared?.type === "VariableDeclaration" ? declared.declarations.map(declarator => declarator.id.type === "Identifier" ? declarator.id.name : "") + : (declared?.type === "FunctionDeclaration" || declared?.type === "ClassDeclaration") ? [declared.id?.name ?? ""] + : statement.type === "ImportDeclaration" ? statement.specifiers.map(specifier => specifier.local.name) + : []; + return names.some(name => name === "Symbol" || name === "globalThis" || name === "Object"); +}); + +/** + * Gives each top-level list of keys declared `as const`, `const List = [a, b] as const`, a program-wide index: its + * members are the module's `Symbol()` constants or value imports, and the appended function indexes it once they're + * all declared components. `as const` makes it a readonly tuple, which nothing changes, as an indexed list must not. + * A list naming one of the module's keys twice fails; one naming an import twice is left without an index. + */ +export const archetypeInsertions = (module: Module): PassResult | null => { + const { code, program } = module; + if (!code.includes("as const") || bindsGlobals(module)) return null; + const keys = new Set(); + const local = new Set(); + for (const [name, imported] of module.imports) if (imported.value && imported.imported !== null) keys.add(name); + for (const statement of program.body) { + const variables = variablesOf(statement); + if (variables?.kind !== "const") continue; + for (const { id, init } of variables.declarations) { + if (id.type === "Identifier" && init?.type === "CallExpression" && init.callee.type === "Identifier" && init.callee.name === "Symbol") { + keys.add(id.name); + local.add(id.name); + } + } + } + if (keys.size === 0 && module.imports.size === 0) return null; + /** The module's top-level constants holding lists it indexes, which other lists may spread. */ + const lists = new Set(); + const insertions: Insertion[] = []; + const name = uniqueName(code, "archetype$"); + /** Lists naming one of the module's keys twice, which fail the build. */ + const repeated: { readonly name: string; readonly at: number }[] = []; + /** Whether a list declared `as const` names only keys: the module's or imported, or spreads lists of them. */ + const indexes = (list: ConstList): boolean => { + const named: string[] = []; + for (const element of list.expression.elements) { + if (element?.type === "Identifier" && keys.has(element.name)) named.push(element.name); + // A namespace's member, `Report.run`, is a key of the package or a module imported. + else if (element?.type === "MemberExpression" && !element.computed && element.object.type === "Identifier" && element.property.type === "Identifier" + && module.imports.get(element.object.name)?.value === true) named.push(`${element.object.name}.${element.property.name}`); + else if (element?.type !== "SpreadElement" || element.argument.type !== "Identifier" || !(lists.has(element.argument.name) || module.imports.get(element.argument.name)?.value === true)) return false; + } + const twice = named.find((member, k) => named.indexOf(member) < k); + if (twice === undefined) return true; + if (local.has(twice)) repeated.push({ name: twice, at: list.start }); + return false; + }; + // Lists made when the module loads: those in functions are made on every call, so they aren't indexed. + const visit = (node: unknown, binding: string | null): void => { + if (Array.isArray(node)) { + for (const item of node) visit(item, null); + return; + } + if (typeof node !== "object" || node === null || !isNode(node) || functions.has(node.type)) return; + if (isConstList(node) && node.expression.elements.length > 0 && indexes(node)) { + insertions.push({ at: node.start, text: `${name}(` }, { at: node.end, text: ")" }); + if (binding !== null) lists.add(binding); + return; + } + for (const key of Object.keys(node)) if (key !== "parent") visit(Reflect.get(node, key), null); + }; + for (const statement of program.body) { + const variables = variablesOf(statement); + // A list a top-level `const` holds may be spread by those after it. + if (variables?.kind === "const") for (const { id, init } of variables.declarations) visit(init, id.type === "Identifier" ? id.name : null); + else visit(statement, null); + } + const [twice] = repeated; + if (twice !== undefined) return { failure: { message: `this list names ${twice.name} twice; a list of components names each once`, position: twice.at, frames: [] }, dependencies: [] }; + if (insertions.length === 0) return null; + insertions.push({ at: code.length, text: `\n${archetypeFunction(name)}\n` }); + return { insertions, dependencies: [] }; +}; diff --git a/packages/ecs/src/implementation/compiler/augmentations.test.ts b/packages/ecs/src/implementation/compiler/augmentations.test.ts new file mode 100644 index 000000000..ebf725d4f --- /dev/null +++ b/packages/ecs/src/implementation/compiler/augmentations.test.ts @@ -0,0 +1,410 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { describe, expect, test } from 'vitest'; +import { parseAst } from 'vite'; +import { augmentationInsertions, registeredReductionsIn } from './augmentations.js'; +import { insertInto } from './source-map.js'; +import { hostOf } from './type-host.test-helper.js'; +import { moduleOf } from './type-schemas.js'; + +const header = `import { Entity, type F32, type U32, type Vec3 } from "@adobe/ecs";\n`; + +/** The module as the pass leaves it, or its failure's message. */ +const passedWhole = async (body: string, files: { readonly [path: string]: string } = {}): Promise => { + const code = header + body; + const host = hostOf(files); + const result = await augmentationInsertions(moduleOf("/src/keys.ts", code, host.parse(code, "/src/keys.ts")), host); + if (result === null) return null; + if ("failure" in result) return `failure: ${result.failure.message}`; + return insertInto(code, result.insertions, "/src/keys.ts").code; +}; + +/** The module as the pass leaves it without the function it appends, or its failure's message. */ +const passed = async (body: string, files: { readonly [path: string]: string } = {}): Promise => + (await passedWhole(body, files))?.replace(/\nfunction declare\$\d*\(key, kind, declaration\) \{.*\}\n$/, "") ?? null; + +/** The declarations the pass records, one per line, without the function recording them. */ +const registrations = async (body: string, files: { readonly [path: string]: string } = {}): Promise => { + const code = await passed(body, files); + if (code === null || code.startsWith("failure: ")) return code; + return code.split("\n").filter(line => /^declare\$\(/.test(line)); +}; + +const keys = (...names: readonly string[]) => names.map(name => `const ${name} = Symbol();`).join("\n") + "\n"; + +describe("augmentationInsertions", () => { + test("registers each component and resource with the schema of its type", async () => { + expect(await registrations(`${keys("position", "done", "label", "speed", "count")}declare module "@adobe/ecs" { + interface Components { [position]: { type: Vec3 }; [done]: { type: boolean }; [label]: { type: string } } + interface Resources { [speed]: { type: F32 | null }; [count]: { type: U32 | null } } +}`)).toEqual([ + `declare$(position, "component", { type: {"type":"array","items":{"type":"number","precision":1},"minItems":3,"maxItems":3} });`, + `declare$(done, "component", { type: {"type":"boolean"} });`, + `declare$(label, "component", { type: {"type":"string"} });`, + `declare$(speed, "resource", { type: {"anyOf":[{"type":"number","precision":1},{"type":"null"}]} });`, + `declare$(count, "resource", { type: {"anyOf":[{"type":"integer","minimum":0,"maximum":4294967295},{"type":"null"}]} });`, + ]); + }); + test("appends the function recording its declarations, which imports nothing", async () => { + const code = await passedWhole(`${keys("tag")}declare module "@adobe/ecs" { interface Components { [tag]: { type: true } } }`); + expect(code?.split("\n").filter(line => line.startsWith("import"))).toEqual([header.trimEnd()]); + expect(code?.trimEnd().split("\n").at(-1)).toBe(`function declare$(key, kind, declaration) { (globalThis[Symbol.for("@adobe/ecs/declarations@1")] ??= new Map()).set(key, { kind, declaration }); }`); + }); + test("names the function apart from the module's own names", async () => { + const code = await passedWhole(`const declare$ = 1;\n${keys("tag")}declare module "@adobe/ecs" { interface Components { [tag]: { type: true } } }`); + expect(code?.split("\n").filter(line => line.includes("declare$1"))).toEqual([ + `declare$1(tag, "component", { type: {"const":true} });`, + `function declare$1(key, kind, declaration) { (globalThis[Symbol.for("@adobe/ecs/declarations@1")] ??= new Map()).set(key, { kind, declaration }); }`, + ]); + }); + test("fails for a module binding a global the recording function uses", async () => { + for (const binding of [`const Map = 1;`, `function Symbol() {}`, `import { globalThis } from "./globals.js";`]) { + expect(await passed(`${binding}\n${keys("tag")}declare module "@adobe/ecs" { interface Components { [tag]: { type: true } } }`)).toContain("which the code recording its declarations uses"); + } + }); + test("gives a literal default beside the schema", async () => { + expect(await registrations(`${keys("gravity", "mode", "origin")}declare module "@adobe/ecs" { + interface Resources { + [gravity]: { type: F32; default: -9.8 }; + [mode]: { type: "all" | "done"; default: "all" }; + [origin]: { type: Vec3; default: [1, 2, 3] }; + } +}`)).toEqual([ + `declare$(gravity, "resource", { type: {"type":"number","precision":1}, default: -9.8 });`, + `declare$(mode, "resource", { type: {"anyOf":[{"const":"all"},{"const":"done"}]}, default: "all" });`, + `declare$(origin, "resource", { type: {"type":"array","items":{"type":"number","precision":1},"minItems":3,"maxItems":3}, default: [1,2,3] });`, + ]); + }); + test("derives a resource's or derived component's default from its type: its namespace's, null, a zero value, or a fresh ReadonlyMap or ReadonlySet", async () => { + expect(await registrations(`${keys("layer", "drawn", "speed", "count", "on", "text", "at", "stats", "focus", "frame", "list", "maybe", "names", "tags", "cache", "audio")}declare module "@adobe/ecs" { + interface Components { + [layer]: { type: number; derived: true }; + [drawn]: { type: true; derived: true }; + [speed]: { type: number }; + } + interface Resources { + [count]: { type: U32 }; + [on]: { type: boolean }; + [text]: { type: string }; + [at]: { type: Vec3 }; + [stats]: { type: { readonly sprites: U32; readonly frames: U32 } }; + [focus]: { type: Entity }; + [frame]: { type: 0 | 1 | 2 }; + [list]: { type: readonly string[] }; + [maybe]: { type: F32 | null }; + [names]: { type: ReadonlyMap }; + [tags]: { type: ReadonlySet }; + [cache]: { type: Map | null; thread: "main" }; + [audio]: { type: AudioContext | null }; + } +}`)).toEqual([ + `declare$(layer, "component", { type: {"type":"number"}, default: 0, derived: true });`, + `declare$(drawn, "component", { type: {"const":true}, derived: true });`, + `declare$(speed, "component", { type: {"type":"number"} });`, + // U32 and Vec3 are imported as types, so their namespaces' defaults are written in; Entity is a value. + `declare$(count, "resource", { type: {"type":"integer","minimum":0,"maximum":4294967295}, default: 0 });`, + `declare$(on, "resource", { type: {"type":"boolean"}, default: false });`, + `declare$(text, "resource", { type: {"type":"string"}, default: "" });`, + `declare$(at, "resource", { type: {"type":"array","items":{"type":"number","precision":1},"minItems":3,"maxItems":3}, default: [0,0,0] });`, + `declare$(stats, "resource", { type: {"type":"object","properties":{"sprites":{"type":"integer","minimum":0,"maximum":4294967295},"frames":{"type":"integer","minimum":0,"maximum":4294967295}}}, default: {"sprites":0,"frames":0} });`, + `declare$(focus, "resource", { type: {"type":"integer","format":"entity"}, default: Entity.default });`, + `declare$(frame, "resource", { type: {"anyOf":[{"const":0},{"const":1},{"const":2}]}, default: 0 });`, + `declare$(list, "resource", { type: {"type":"array","items":{"type":"string"}}, default: [] });`, + `declare$(maybe, "resource", { type: {"anyOf":[{"type":"number","precision":1},{"type":"null"}]} });`, + `declare$(names, "resource", { type: {"type":"map","keys":{"type":"string"},"values":{"type":"number"}}, fresh: "Map" });`, + `declare$(tags, "resource", { type: {"type":"set","items":{"type":"string"}}, fresh: "Set" });`, + `declare$(cache, "resource", { thread: "main" });`, + `declare$(audio, "resource", {});`, + ]); + }); + test("takes its type's namespace's default, after the value holding it, unless a literal is given", async () => { + const files = { "/src/bounds.ts": `import type { F32 } from "@adobe/ecs";\nexport type Upper = F32;\nexport const Upper = { default: Infinity } as const;` }; + const code = await passed(`import { Upper } from "./bounds.js";\n${keys("low", "high", "given", "mark")}declare module "@adobe/ecs" { + interface Components { [mark]: { type: Lower } } + interface Resources { [low]: { type: Lower }; [high]: { type: Upper }; [given]: { type: Lower; default: 1 } } +} +type Lower = F32; +const Lower = { default: -Infinity } as const; +const after = 1;`, files); + expect(code?.split("\n").filter(line => line.startsWith("declare$"))).toEqual([ + `declare$(high, "resource", { type: {"type":"number","precision":1}, default: Upper.default });`, + `declare$(given, "resource", { type: {"type":"number","precision":1}, default: 1 });`, + `declare$(mark, "component", { type: {"type":"number","precision":1} });`, + `declare$(low, "resource", { type: {"type":"number","precision":1}, default: Lower.default });`, + ]); + expect(code?.split("\n").slice(-3)).toEqual([ + `const Lower = { default: -Infinity } as const;`, + `declare$(low, "resource", { type: {"type":"number","precision":1}, default: Lower.default });`, + `const after = 1;`, + ]); + }); + test("takes the package's types' namespaces' defaults: identities for a quaternion and a matrix", async () => { + expect(await registrations(`import { Quat, type Mat4x4 } from "@adobe/ecs";\n${keys("turn", "view")}declare module "@adobe/ecs" { interface Resources { [turn]: { type: Quat }; [view]: { type: Mat4x4 } } }`)).toEqual([ + `declare$(turn, "resource", { type: {"type":"array","items":{"type":"number","precision":1},"minItems":4,"maxItems":4}, default: Quat.default });`, + `declare$(view, "resource", { type: {"type":"array","items":{"type":"number","precision":1},"minItems":16,"maxItems":16}, default: [1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1] });`, + ]); + }); + test("starts a reduced resource at its operator's identity, within its type's range", async () => { + expect(await registrations(`${keys("sum", "product", "least", "most", "all", "any", "bounds", "masked")}declare module "@adobe/ecs" { + interface Resources { + [sum]: { type: F32; reduce: "+" }; + [product]: { type: F32; reduce: "*" }; + [least]: { type: F32; reduce: "min" }; + [most]: { type: U32; reduce: "max" }; + [all]: { type: boolean; reduce: "&" }; + [any]: { type: boolean; reduce: "|" }; + [bounds]: { type: { readonly low: F32; readonly high: F32 }; reduce: "max" }; + [masked]: { type: U32; reduce: "&" }; + } +}`)).toEqual([ + `declare$(sum, "resource", { type: {"type":"number","precision":1}, default: 0, reduce: "+" });`, + `declare$(product, "resource", { type: {"type":"number","precision":1}, default: 1, reduce: "*" });`, + `declare$(least, "resource", { type: {"type":"number","precision":1}, default: Infinity, reduce: "min" });`, + `declare$(most, "resource", { type: {"type":"integer","minimum":0,"maximum":4294967295}, default: 0, reduce: "max" });`, + `declare$(all, "resource", { type: {"type":"boolean"}, default: true, reduce: "&" });`, + `declare$(any, "resource", { type: {"type":"boolean"}, default: false, reduce: "|" });`, + `declare$(bounds, "resource", { type: {"type":"object","properties":{"low":{"type":"number","precision":1},"high":{"type":"number","precision":1}}}, default: {"low":-Infinity,"high":-Infinity}, reduce: "max" });`, + `declare$(masked, "resource", { type: {"type":"integer","minimum":0,"maximum":4294967295}, default: 4294967295, reduce: "&" });`, + ]); + }); + test("takes a type's namespace's default only when it's deeply readonly, as a default is shared", async () => { + const declared = (holder: string) => `${keys("key")}declare module "@adobe/ecs" { interface Resources { [key]: { type: Settings } } }\ntype Settings = { readonly size: number };\n${holder}`; + const shared = "a default is shared"; + expect(await registrations(declared(`const Settings = { default: { size: 1 } } as const;`))).toEqual([`declare$(key, "resource", { type: {"type":"object","properties":{"size":{"type":"number"}}}, default: Settings.default });`]); + expect(await registrations(declared(`const start: Readonly<{ size: number }> = { size: 2 };\nconst Settings = { default: start };`))).toEqual([`declare$(key, "resource", { type: {"type":"object","properties":{"size":{"type":"number"}}}, default: Settings.default });`]); + expect(await registrations(declared(`const start = { size: 2 } as const;\nconst Settings = { default: start };`))).toEqual([`declare$(key, "resource", { type: {"type":"object","properties":{"size":{"type":"number"}}}, default: Settings.default });`]); + expect(await passed(declared(`const Settings = { default: { size: 1 } };`))).toContain("is shared by every row and world that starts from it"); + expect(await passed(declared(`const start = { size: 2 };\nconst Settings = { default: start } as const;`))).toContain("as const"); + const maps = `${keys("key")}declare module "@adobe/ecs" { interface Resources { [key]: { type: Names } } }\ntype Names = ReadonlyMap;\n`; + expect(await passed(`${maps}const Names = { default: new Map([["a", 1]]) };`)).toContain("is shared by every row and world"); + expect(await passed(`${maps}const names: Map = new Map();\nconst Names = { default: names };`)).toContain("ReadonlyMap"); + expect(await registrations(`${maps}const names: ReadonlyMap = new Map([["a", 1]]);\nconst Names = { default: names };`)).toEqual([`declare$(key, "resource", { type: {"type":"map","keys":{"type":"string"},"values":{"type":"number"}}, default: Names.default });`]); + void shared; + }); + test("takes a call in a readonly default when its function's declared return type is readonly", async () => { + const declared = (code: string, files: { readonly [path: string]: string } = {}) => registrations(`${keys("k")}declare module "@adobe/ecs" { interface Resources { [k]: { type: Note } } }\ntype Note = { readonly color: number; readonly tags: readonly string[] };\n${code}`, files); + const note = (call: string) => `const Note = { default: { color: ${call}, tags: [] } } as const;`; + expect(await declared(`function pack(r: number, g: number): number { return r + g; }\n${note("pack(255, 214)")}`)).toEqual([expect.stringContaining("default: Note.default")]); + expect(await declared(`import { pack } from "./color.js";\n${note("pack(255, 214)")}`, { "/src/color.ts": `import type { U32 } from "@adobe/ecs";\nexport const pack = (r: number, g: number): U32 => r + g;` })).toEqual([expect.stringContaining("default: Note.default")]); + expect(await declared(`const tagsOf = (): readonly string[] => [];\nconst Note = { default: { color: 1, tags: tagsOf() } } as const;`)).toEqual([expect.stringContaining("default: Note.default")]); + expect(await declared(`function pack(r: number, g: number) { return r + g; }\n${note("pack(255, 214)")}`)).toEqual(expect.stringMatching(/^failure: Note\.default is shared .*`pack\(255, 214\)` returns a type the build can't see is readonly: annotate its return type/)); + expect(await declared(`const tagsOf = (): string[] => [];\nconst Note = { default: { color: 1, tags: tagsOf() } } as const;`)).toEqual(expect.stringMatching(/^failure: Note\.default is shared .*`tagsOf\(\)` returns a type that isn't readonly/)); + }); + test("writes a literal -0 as itself", async () => { + expect(await registrations(`${keys("floor")}declare module "@adobe/ecs" { interface Resources { [floor]: { type: number; default: -0 } } }`)) + .toEqual([`declare$(floor, "resource", { type: {"type":"number"}, default: -0 });`]); + }); + test("starts a struct's fields and a tuple's items at their own types' defaults", async () => { + const code = await passed(`import type { Schema } from "@adobe/ecs";\n${keys("level", "box", "pair")}declare module "@adobe/ecs" { + interface Resources { + [level]: { type: { readonly value: Level } }; + [box]: { type: { readonly height: Height; readonly width: F32 } }; + [pair]: { type: readonly [Height, F32] }; + } +} +type Level = Schema.Type<{ type: "integer"; minimum: 1; maximum: 9 }>; +const Level = { default: 5 } as const; +type Height = F32; +const Height = { default: -1 } as const;`); + expect(code?.split("\n").filter(line => line.startsWith("declare$"))).toEqual([ + `declare$(level, "resource", { type: {"type":"object","properties":{"value":{"type":"integer","minimum":1,"maximum":9}}}, default: { "value": Level.default } });`, + `declare$(box, "resource", { type: {"type":"object","properties":{"height":{"type":"number","precision":1},"width":{"type":"number","precision":1}}}, default: { "height": Height.default, "width": 0 } });`, + `declare$(pair, "resource", { type: {"type":"array","items":{"type":"number","precision":1},"minItems":2,"maxItems":2}, default: [Height.default, 0] });`, + ]); + }); + test("gives a mutable Map or Set no start of its own: null when its type allows it, else a failure", async () => { + expect(await registrations(`${keys("pool")}declare module "@adobe/ecs" { interface Resources { [pool]: { type: Pool | null } } }\ntype Pool = Map;`)).toEqual([`declare$(pool, "resource", {});`]); + expect(await passed(`${keys("pool")}declare module "@adobe/ecs" { interface Resources { [pool]: { type: Pool } } }\ntype Pool = Map;`)).toContain("a ReadonlyMap or ReadonlySet starts fresh in each world, a Map or Set doesn't"); + }); + test("gives a value that isn't data no schema, and its traits", async () => { + const files = { "/src/row.ts": `export class Row extends HTMLElement {}\nexport class Pool {}` }; + expect(await registrations(`import { Row, Pool } from "./row.js";\n${keys("row", "pool", "canvas", "callback")}declare module "@adobe/ecs" { + interface Components { [row]: { type: Row; thread: "main" } } + interface Resources { + [pool]: { type: Pool | null; default: null }; + [canvas]: { type: OffscreenCanvas | null; transfer: true }; + [callback]: { type: (() => void) | null }; + } +}`, files)).toEqual([ + `declare$(row, "component", { thread: "main" });`, + `declare$(pool, "resource", { default: null });`, + `declare$(canvas, "resource", { transfer: true });`, + `declare$(callback, "resource", {});`, + ]); + }); + test("gives traits as declared, a trait of your own by its key, and registers traits `Traits` declares", async () => { + expect(await registrations(`${keys("instanced", "host", "outline", "owner", "drawn", "total")}declare module "@adobe/ecs" { + interface Traits { readonly [instanced]: true } + interface Components { + [host]: { type: true }; + [outline]: { type: number; within: typeof host; [instanced]: true }; + [owner]: { type: Entity; reference: "owner" }; + [drawn]: { type: true; derived: true }; + } + interface Resources { [total]: { type: F32; default: 0; reduce: "+" } } +}`)).toEqual([ + `declare$(instanced, "trait");`, + `declare$(host, "component", { type: {"const":true} });`, + `declare$(outline, "component", { type: {"type":"number"}, within: host, [instanced]: true });`, + `declare$(owner, "component", { type: {"type":"integer","format":"entity"}, reference: "owner" });`, + `declare$(drawn, "component", { type: {"const":true}, derived: true });`, + `declare$(total, "resource", { type: {"type":"number","precision":1}, default: 0, reduce: "+" });`, + ]); + }); + test("registers a block's traits before the components declaring them", async () => { + expect(await registrations(`${keys("marked", "mark")}declare module "@adobe/ecs" { + interface Components { [marked]: { type: true; [mark]: true } } + interface Traits { readonly [mark]: true } +}`)).toEqual([`declare$(mark, "trait");`, `declare$(marked, "component", { type: {"const":true}, [mark]: true });`]); + }); + test("follows a type named by Schema.Type that another module declares", async () => { + const files = { "/src/units.ts": `import type { Schema } from "@adobe/ecs";\nexport type Level = Schema.Type<{ type: "integer"; minimum: 0; maximum: 9 }>;` }; + expect(await registrations(`import type { Level } from "./units.js";\n${keys("level")}declare module "@adobe/ecs" { interface Components { [level]: { type: Level } } }`, files)).toEqual([ + `declare$(level, "component", { type: {"type":"integer","minimum":0,"maximum":9} });`, + ]); + }); + test("registers right after its key, and after the bindings its declaration names, wherever the block is", async () => { + const code = await passed(`declare module "@adobe/ecs" { interface Resources { [later]: { type: Start } } } +const later = Symbol(); +type Start = Vec3; +const Start = { default: [1, 2, 3] } as const; +const after = 1;`); + expect(code?.split("\n").slice(-3)).toEqual([`const Start = { default: [1, 2, 3] } as const;`, `declare$(later, "resource", { type: {"type":"array","items":{"type":"number","precision":1},"minItems":3,"maxItems":3}, default: Start.default });`, `const after = 1;`]); + // Code between the key and a block below uses the key registered. + const above = await passed(`const speed = Symbol();\nconst uses = [speed];\ndeclare module "@adobe/ecs" { interface Resources { [speed]: { type: F32; default: 1 } } }`); + expect(above?.split("\n").slice(1, 3)).toEqual([`const speed = Symbol();`, `declare$(speed, "resource", { type: {"type":"number","precision":1}, default: 1 });`]); + }); + test("registers a trait before what declares it, its keys declared below the blocks, the Traits block last", async () => { + const code = await passed(`declare module "@adobe/ecs" { + interface Components { [marked]: { type: true; [mark]: true } } +} +declare module "@adobe/ecs" { + interface Traits { readonly [mark]: true } +} +const marked = Symbol(); +const mark = Symbol();`); + expect(code?.split("\n").slice(-4)).toEqual([`const marked = Symbol();`, `const mark = Symbol();`, `declare$(mark, "trait");`, `declare$(marked, "component", { type: {"const":true}, [mark]: true });`]); + }); + test("takes a type that allows null through its aliases", async () => { + const files = { "/src/maybe.ts": `export type Maybe = Pool | null;\nexport class Pool {}` }; + expect(await registrations(`import type { Maybe } from "./maybe.js";\n${keys("pool")}declare module "@adobe/ecs" { interface Resources { [pool]: { type: Maybe } } }`, files)).toEqual([`declare$(pool, "resource", {});`]); + }); + test("follows types a declare global block declares, here or in a module imported, before taking a name as a global class", async () => { + const files = { "/src/globals.ts": `declare global {\n interface Point { readonly x: number; readonly y: number }\n}\nexport {};` }; + expect(await registrations(`import "./globals.js";\ndeclare global { type Weight = number }\n${keys("at", "weight")}declare module "@adobe/ecs" { interface Components { [at]: { type: Point }; [weight]: { type: Weight } } }`, files)).toEqual([ + `declare$(at, "component", { type: {"type":"object","properties":{"x":{"type":"number"},"y":{"type":"number"}}} });`, + `declare$(weight, "component", { type: {"type":"number"} });`, + ]); + }); + test("checks the built-in traits' values, as far as their literals and types tell", async () => { + const block = (body: string, more = "") => `${keys("key", "other")}const notKey = 1;\n${more}declare module "@adobe/ecs" { ${body} }`; + const components = (traits: string) => block(`interface Components { [key]: { ${traits} }; [other]: { type: true } }`); + expect(await passed(components(`type: true; derived: false`))).toContain("derived is `true`"); + expect(await passed(block(`interface Resources { [key]: { type: number; default: 0; derived: true } }`))).toContain("only a component is derived"); + expect(await passed(components(`type: Entity; reference: "strong"`))).toContain(`reference is "owner" or "weak"`); + expect(await passed(components(`type: number; reference: "owner"`))).toContain("a reference's type is Entity"); + expect(await passed(components(`type: true; within: "other"`))).toContain("within is `typeof` a component's key"); + expect(await passed(components(`type: true; within: typeof notKey`))).toContain("within is `typeof` a component's key"); + expect(await passed(components(`type: Map; transfer: true`))).toContain("transfer takes a Transferable class"); + expect(await passed(block(`interface Resources { [key]: { type: number; default: 0; reduce: "-" } }`))).toContain("reduce is one of"); + expect(await passed(components(`type: number; reduce: "+"`))).toContain("only a resource is reduced"); + expect(await passed(components(`type: string; thread: "main"`))).toContain("a string lives in shared memory"); + expect(await passed(components(`type: Vec3; thread: "main"`))).toContain("a struct lives in shared memory"); + // A Transferable class, through an alias and with null. + expect(await registrations(components(`type: Picture; transfer: true`).replace("const notKey", "type Picture = ImageBitmap | null;\nconst notKey"))).toContain(`declare$(key, "component", { transfer: true });`); + }); + test("starts a struct's fields and a tuple's items at imported namespaces' defaults, imported as values or types", async () => { + const height = { "/src/height.ts": `import type { F32 } from "@adobe/ecs";\nexport type Height = F32;\nexport const Height = { default: 7 } as const;` }; + expect(await registrations(`import { Quat as Q } from "@adobe/ecs";\n${keys("e")}declare module "@adobe/ecs" { interface Resources { [e]: { type: { readonly q: Q } } } }`)) + .toEqual([`declare$(e, "resource", { type: {"type":"object","properties":{"q":{"type":"array","items":{"type":"number","precision":1},"minItems":4,"maxItems":4}}}, default: { "q": Q.default } });`]); + expect(await registrations(`import type { Quat } from "@adobe/ecs";\n${keys("e")}declare module "@adobe/ecs" { interface Resources { [e]: { type: { readonly q: Quat } } } }`)) + .toEqual([expect.stringContaining(`default: {"q":[0,0,0,1]}`)]); + expect(await registrations(`import type { Height } from "./height.js";\n${keys("e")}declare module "@adobe/ecs" { interface Resources { [e]: { type: readonly [Height, F32] } } }`, height)) + .toEqual([expect.stringContaining(`default: [7,0]`)]); + }); + test("starts a reduced resource at its operator's identity only when it's one of its type's values", async () => { + const reduced = (type: string, operator: string) => registrations(`import type { Schema } from "@adobe/ecs";\n${keys("k")}declare module "@adobe/ecs" { interface Resources { [k]: { type: ${type}; reduce: "${operator}" } } }`); + expect(await reduced("Entity", "min")).toEqual([expect.stringContaining("default: Entity.default")]); + expect(await reduced(`Schema.Type<{ type: "integer"; minimum: 0 }>`, "min")).toEqual([expect.stringContaining("default: 0")]); + expect(await reduced(`Schema.Type<{ type: "integer"; minimum: 0; maximum: 100 }>`, "&")).toEqual([expect.stringContaining("default: 0")]); + expect(await reduced("U32", "&")).toEqual([expect.stringContaining("default: 4294967295")]); + expect(await reduced(`Schema.Type<{ type: "integer"; minimum: 1; maximum: 9 }>`, "+")).toEqual(expect.stringMatching(/^failure: .*default/)); + expect(await reduced(`Schema.Type<{ type: "integer"; minimum: 2; maximum: 9 }>`, "*")).toEqual(expect.stringMatching(/^failure: .*default/)); + }); + test("starts a reduced struct's fields at their own types' namespaces' defaults before the identity", async () => { + expect(await registrations(`${keys("k")}declare module "@adobe/ecs" { interface Resources { [k]: { type: { readonly h: Height; readonly w: F32 }; reduce: "max" } } }\ntype Height = F32;\nconst Height = { default: 7 } as const;`)) + .toEqual([expect.stringContaining(`default: { "h": Height.default, "w": -Infinity }`)]); + }); + test("fails for an object component whose namespace is imported as a type only, since its start is one object", async () => { + expect(await registrations(`import type { Label } from "./label.js";\n${keys("k")}declare module "@adobe/ecs" { interface Components { [k]: { type: Label; thread: "main" } } }`, + { "/src/label.ts": `export type Label = { readonly text: string };\nexport const Label = { default: { text: "x" } } as const;` })).toEqual(expect.stringMatching(/^failure: .*import Label as a value/)); + }); + test("fails for a namespace's default a cast makes mutable, wherever the cast is", async () => { + const named = (namespace: string) => registrations(`${keys("k")}declare module "@adobe/ecs" { interface Resources { [k]: { type: L } } }\ntype L = readonly number[];\n${namespace}`); + expect(await named("const L = { default: [] as number[] } as const;")).toEqual(expect.stringMatching(/^failure: L\.default is shared/)); + expect(await named("const L = { default: [1, 2] as number[] } as const;")).toEqual(expect.stringMatching(/^failure: L\.default is shared/)); + expect(await named("const L = { default: ([1, 2] as const) as unknown as number[] };")).toEqual(expect.stringMatching(/^failure: L\.default is shared/)); + expect(await named("const L = { default: [1, 2] as const satisfies readonly number[] };")).toEqual([expect.stringContaining("default: L.default")]); + }); + test("finds a default only where a schema's keywords are, and names the alias holding one", async () => { + const typed = (type: string) => registrations(`import type { Schema } from "@adobe/ecs";\n${keys("k")}declare module "@adobe/ecs" { interface Resources { [k]: { type: ${type} } } }`); + expect(await typed(`Schema.Type<{ type: "object"; properties: { default: { type: "number" } }; required: ["default"] }>`)).toEqual([expect.stringContaining(`default: {"default":0}`)]); + expect(await typed(`Schema.Type<{ const: { default: 1 } }>`)).toEqual([expect.stringContaining(`"const":{"default":1}`)]); + expect(await registrations(`import type { Schema } from "@adobe/ecs";\n${keys("k")}declare module "@adobe/ecs" { interface Resources { [k]: { type: Bad } } }\ntype Bad = Schema.Type<{ type: "number"; default: 1 }>;`)) + .toEqual(expect.stringMatching(/^failure: .*Bad/)); + }); + test("checks a namespace's default against its type when it can read it", async () => { + expect(await registrations(`${keys("k")}declare module "@adobe/ecs" { interface Resources { [k]: { type: Mode } } }\ntype Mode = "a" | "b";\nconst Mode = { default: "c" } as const;`)) + .toEqual(expect.stringMatching(/^failure: Mode\.default/)); + }); + test("writes in a namespace's -NaN imported as a type only", async () => { + expect(await registrations(`import type { Z } from "./z.js";\n${keys("k")}declare module "@adobe/ecs" { interface Resources { [k]: { type: Z } } }`, + { "/src/z.ts": `export type Z = number;\nexport const Z = { default: -NaN } as const;` })).toEqual([expect.stringContaining("default: NaN")]); + }); + test("passes over modules without a declaration of the package", async () => { + expect(await passed(`declare module "elsewhere" { interface Components { [x]: { type: number } } }\nconst x = Symbol();`)).toBe(null); + }); + test("fails, saying why, for what a declaration can't be", async () => { + const block = (body: string) => `declare module "@adobe/ecs" { ${body} }`; + expect(await passed(`const key = Symbol.for("key");\n${block(`interface Components { [key]: { type: number } }`)}`)).toContain("top-level `Symbol()` constant"); + const files = { "/src/keys.ts": `export const elsewhere = Symbol();\nexport const layer = Symbol();` }; + expect(await passed(`import { elsewhere } from "./keys.js";\n${block(`interface Components { [elsewhere]: { type: number } }`)}`, files)).toContain(`elsewhere is imported from "./keys.js": declare it in the module that declares the key`); + expect(await passed(`import type { layer } from "./keys.js";\n${keys("key")}${block(`interface Components { [key]: { type: true; [layer]: true } }`)}`, files)).toContain("layer, imported as a type only"); + expect(await passed(`${keys("key")}${block(`interface Resources { [key]: { type: 1 | 2 | 4 } }`)}`)).toContain(`a union of literals without 0, false or "" starts at none of them`); + expect(await passed(`${keys("key")}${block(`interface Resources { [key]: { type: "all" | "done" } }`)}`)).toContain("declare `default:` one of them"); + expect(await passed(`const Mode = { a: 1, b: 2 } as const;\n${keys("key")}${block(`interface Resources { [key]: { type: (typeof Mode)[keyof typeof Mode] } }`)}`)).toContain("starts at none of them"); + expect(await passed(`class Pool {}\n${keys("key")}${block(`interface Resources { [key]: { type: Pool } }`)}`)).toContain("let its type allow null"); + expect(await passed(`${keys("key")}${block(`interface Resources { [key]: { type: OffscreenCanvas } }`)}`)).toContain("let its type allow null"); + expect(await passed(`import type { Schema } from "@adobe/ecs";\n${keys("key")}${block(`interface Resources { [key]: { type: Schema.Type<{ type: "integer"; minimum: 1; maximum: 9 }> } }`)}`)) + .toContain("name its type and give the type a namespace with a `default`"); + expect(await passed(`${keys("key")}type Level = 1 | 2 | 4;\n${block(`interface Resources { [key]: { type: Level } }`)}`)).toContain("give Level a namespace with a `default`: `const Level = { default: ... }`"); + expect(await passed(`import type { Schema } from "@adobe/ecs";\n${keys("key")}${block(`interface Resources { [key]: { type: Schema.Type<{ type: "integer"; default: 5 }> } }`)}`)).toContain("a schema holds no default"); + expect(await passed(`import type { Schema } from "@adobe/ecs";\n${keys("key")}${block(`interface Resources { [key]: { type: Schema.Type<{ type: "integer"; minimum: 1; maximum: 9 }> } }`)}`)).toContain("0 isn't one of its values"); + expect(await passed(`const start = 1;\n${keys("key")}${block(`interface Resources { [key]: { type: number; default: typeof start } }`)}`)).toContain("default is a literal type, never `typeof` a value"); + expect(await passed(`import type { Lower } from "./bounds.js";\n${keys("key")}${block(`interface Resources { [key]: { type: Lower } }`)}`, { "/src/bounds.ts": `export type Lower = number;\nconst start: number = Date.now();\nexport const Lower = { default: start } as const;` })).toContain("import Lower as a value"); + expect(await passed(`${keys("key")}${block(`interface Resources { [key]: { type: U32; default: -1 } }`)}`)).toContain("isn't of its declared type"); + expect(await passed(`${keys("key")}${block(`interface Components { [key]: { type: HTMLDivElement } }`)}`)).toContain(`thread: "main"`); + expect(await passed(`${keys("key")}${block(`interface Components { [key]: { type: Pick<{ a: number }, "a"> } }`)}`)).toContain("type arguments"); + expect(await passed(`${keys("key")}${block(`interface Components { [key]: { derived: true } }`)}`)).toContain("declares its type"); + expect(await passed(block(`interface Traits { readonly layer: true }`))).toContain("keyed by a `Symbol()` constant"); + expect(await passed(`enum Mode { On, Off }\n${keys("key")}${block(`interface Components { [key]: { type: Mode } }`)}`)).toContain("an enum"); + expect(await passed(`import { Mode } from "./mode.js";\n${keys("key")}${block(`interface Components { [key]: { type: Mode } }`)}`, { "/src/mode.ts": `export enum Mode { On, Off }` })).toContain("an enum"); + expect(await passed(block(`interface Components { "named": { type: number } }`))).toContain("a component's key is a `Symbol()` constant"); + expect(await passed(block(`interface Resources { "time": { type: number; default: 0 } }`))).toContain("a resource's key is a `Symbol()` constant"); + }); +}); + +describe("registeredReductionsIn", () => { + const read = (code: string) => Object.fromEntries(registeredReductionsIn(parseAst(code, { lang: "js" }))); + test("reads the operators a library's declarations give, however its bundle wrote them", () => { + expect(read(`export const total = Symbol(); declare$(total, "resource", { type: {}, reduce: "+" }); function declare$(key, kind, declaration) {}`)).toEqual({ total: "+" }); + expect(read(`const t = Symbol(), m = Symbol(); d(t, "resource", { reduce: "+" }), d(m, "resource", { reduce: "min" }); export { t as total, m as low };`)).toEqual({ t: "+", m: "min" }); + expect(read(`const t = Symbol(); void d(t, "resource", { reduce: "+" });`)).toEqual({ t: "+" }); + expect(read(`const t = Symbol(); (globalThis[Symbol.for("@adobe/ecs/declarations@1")] ??= new Map()).set(t, { kind: "resource", declaration: { reduce: "+" } });`)).toEqual({ t: "+" }); + expect(read(`const t = Symbol(); (g ??= new Map).set(t, { kind: "resource", declaration: { reduce: "max" } }), e.f(1);`)).toEqual({ t: "max" }); + }); + test("ignores components, traits, and keys that aren't symbols", () => { + expect(read(`const t = 1; d(t, "resource", { reduce: "+" });`)).toEqual({}); + expect(read(`const t = Symbol(); d(t, "component", { reduce: "+" }); d(t, "trait");`)).toEqual({}); + expect(read(`const t = Symbol(); m.set(t, { kind: "component", declaration: { reduce: "+" } });`)).toEqual({}); + }); +}); diff --git a/packages/ecs/src/implementation/compiler/augmentations.ts b/packages/ecs/src/implementation/compiler/augmentations.ts new file mode 100644 index 000000000..c96dd7349 --- /dev/null +++ b/packages/ecs/src/implementation/compiler/augmentations.ts @@ -0,0 +1,843 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { reductionOperators } from '../../interface/registry/reduction.js'; +import { isEntity, isString, isStruct, matchesSchema } from '../../interface/registry/schemas.js'; +import type { Schema } from '../../interface/schema/schema.js'; +import { declarationsName } from '../../interface/registry/ids.js'; +import type { PassResult } from './input-schemas.js'; +import type { Insertion } from './source-map.js'; +import { createConverter, isNotData, isThrown, type Declarator, type Module, type Node, type Statement, type TypedProgram, type TypeHost, type TypeNode } from './type-schemas.js'; + +export type AugmentationHost = TypeHost; + +type Block = Extract; +type Interface = Extract; +type Member = Interface["body"]["body"][number]; +type Property = Extract; +type Value = string | number | boolean | null | readonly Value[] | { readonly [field: string]: Value }; + +const kinds = { Components: "component", Resources: "resource", Traits: "trait" } as const; +const isKind = (name: string): name is keyof typeof kinds => name in kinds; + +/** The interfaces of each top-level `declare module "@adobe/ecs"` block, with the block. */ +const augmentationsOf = (module: Pick): readonly { readonly block: Block; readonly interfaces: readonly Interface[] }[] => + module.program.body.flatMap(statement => { + if (statement.type !== "TSModuleDeclaration" || statement.global !== false || statement.id.type !== "Literal" || statement.id.value !== "@adobe/ecs") return []; + const body = statement.body?.type === "TSModuleBlock" ? statement.body.body : []; + const interfaces = body.flatMap(inner => { + const declared = inner.type === "ExportNamedDeclaration" ? inner.declaration : inner; + return declared?.type === "TSInterfaceDeclaration" && isKind(declared.id.name) ? [declared] : []; + }); + return [{ block: statement, interfaces }]; + }); + +/** + * The operator each resource a module's `declare module "@adobe/ecs"` blocks declare with a `reduce` trait reduces + * with, by its key's binding: what a kernel reducing into it folds with. + */ +export const reductionsIn = (program: TypedProgram): ReadonlyMap => { + const found = new Map(); + for (const { interfaces } of augmentationsOf({ program })) { + for (const declared of interfaces) { + if (declared.id.name !== "Resources") continue; + for (const member of declared.body.body) { + if (member.type !== "TSPropertySignature" || !member.computed || member.key.type !== "Identifier") continue; + const declaration = member.typeAnnotation?.typeAnnotation; + if (declaration?.type !== "TSTypeLiteral") continue; + for (const trait of declaration.members) { + if (trait.type !== "TSPropertySignature" || trait.computed || trait.key.type !== "Identifier" || trait.key.name !== "reduce") continue; + const value = trait.typeAnnotation?.typeAnnotation; + if (value?.type === "TSLiteralType" && value.literal.type === "Literal" && typeof value.literal.value === "string") found.set(member.key.name, value.literal.value); + } + } + } + } + return found; +}; + +type Expression = Extract["expression"]; +type Call = Extract; + +/** A call's statement forms as a bundle leaves them: alone, in a sequence, or `void`ed. */ +const callsOf = (expression: Expression): readonly Call[] => { + if (expression.type === "CallExpression") return [expression]; + if (expression.type === "SequenceExpression") return expression.expressions.flatMap(callsOf); + if (expression.type === "UnaryExpression" && expression.operator === "void") return callsOf(expression.argument); + return []; +}; + +/** An object literal's property named `name`, unless computed. */ +const propertyNamed = (object: Extract, name: string): Expression | null => { + for (const property of object.properties) { + if (property.type === "Property" && !property.computed && property.key.type === "Identifier" && property.key.name === name) return property.value; + } + return null; +}; + +/** + * The operator each resource a module's generated declarations give, by its key's binding: what a library's build in + * library mode leaves of its declarations in its JavaScript. They record `key, "resource", { ..., reduce }` through the + * function appended to the module, under any name a bundle gives it, or, inlined, set `{ kind: "resource", declaration }` + * on the declarations map; the key is a top-level `Symbol()` constant. + */ +export const registeredReductionsIn = (program: TypedProgram): ReadonlyMap => { + const symbols = new Set(); + for (const statement of program.body) { + const declaration = statement.type === "ExportNamedDeclaration" ? statement.declaration : statement; + if (declaration?.type !== "VariableDeclaration" || declaration.kind !== "const") continue; + for (const declarator of declaration.declarations) { + const init = declarator.init; + if (declarator.id.type === "Identifier" && init?.type === "CallExpression" && init.callee.type === "Identifier" && init.callee.name === "Symbol") symbols.add(declarator.id.name); + } + } + /** The declaration a call records for a resource keyed by a `Symbol()` constant, by either form, with its key. */ + const recorded = ({ callee, arguments: parameters }: Call): { readonly key: string; readonly declaration: Expression } | null => { + const [key, second, third] = parameters; + if (key?.type !== "Identifier" || !symbols.has(key.name)) return null; + if (second?.type === "Literal" && second.value === "resource" && third?.type === "ObjectExpression") return { key: key.name, declaration: third }; + const setsEntry = callee.type === "MemberExpression" && !callee.computed && callee.property.type === "Identifier" && callee.property.name === "set"; + if (!setsEntry || second?.type !== "ObjectExpression") return null; + const kind = propertyNamed(second, "kind"); + const declaration = propertyNamed(second, "declaration"); + return kind?.type === "Literal" && kind.value === "resource" && declaration !== null ? { key: key.name, declaration } : null; + }; + const found = new Map(); + for (const statement of program.body) { + if (statement.type !== "ExpressionStatement") continue; + for (const call of callsOf(statement.expression)) { + const entry = recorded(call); + if (entry === null || entry.declaration.type !== "ObjectExpression") continue; + const operator = propertyNamed(entry.declaration, "reduce"); + if (operator?.type === "Literal" && typeof operator.value === "string") found.set(entry.key, operator.value); + } + } + return found; +}; + +type QueryName = Exclude["exprName"], { readonly type: "TSImportType" }>; + +type TypeName = Extract["typeName"]; + +/** A type reference's name and the namespaces it's read through. */ +const typeNameParts = (name: TypeName | Extract["left"]): readonly string[] => + name.type === "Identifier" ? [name.name] : name.type === "TSQualifiedName" ? [...typeNameParts(name.left), name.right.name] : []; + +/** A `typeof` query's name and the properties read from it; null for `this`. */ +const qualifiedParts = (name: QueryName | Extract["left"]): readonly string[] | null => { + if (name.type === "Identifier") return [name.name]; + if (name.type !== "TSQualifiedName") return null; + const left = qualifiedParts(name.left); + return left === null ? null : [...left, name.right.name]; +}; + +/** The platform's Transferable classes, which `transfer` takes. */ +const transferables: ReadonlySet = new Set([ + "ArrayBuffer", "ImageBitmap", "OffscreenCanvas", "MessagePort", "VideoFrame", "AudioData", + "ReadableStream", "WritableStream", "TransformStream", "RTCDataChannel", +]); + +/** Tests and their helpers in the package, which declare keys as consumers do. */ +const testModule = /\.(test|type-test|fixture|test-helper|cases)\.tsx?$/; + +/** Whether a file is one of the package's own modules, whose built-in keys register themselves. */ +const isLibraryModule = (path: string | null, file: string): boolean => + path !== null && (path.startsWith("interface/") || path.startsWith("implementation/")) && !testModule.test(file); + +/** Whether a module's top level binds `name`: imported, or declared by a variable, function or class. */ +const bindsAtTopLevel = (program: TypedProgram, name: string): boolean => program.body.some(statement => { + if (statement.type === "ImportDeclaration") return statement.specifiers.some(specifier => specifier.local.name === name); + const declared = statement.type === "ExportNamedDeclaration" || statement.type === "ExportDefaultDeclaration" ? statement.declaration : statement; + if (declared?.type === "VariableDeclaration") return declared.declarations.some(declarator => declarator.id.type === "Identifier" && declarator.id.name === name); + return (declared?.type === "FunctionDeclaration" || declared?.type === "ClassDeclaration") && declared.id?.name === name; +}); + +/** `base`, or it numbered, so the name appears nowhere in the code. */ +const uniqueName = (code: string, base: string): string => { + let name = base; + for (let n = 1; new RegExp(`${name.replace("$", "\\$")}(?![\\w$])`).test(code); n++) name = `${base}${n}`; + return name; +}; + +const allowsNullSchema = (schema: Schema): boolean => + ("type" in schema && schema.type === "null") || ("const" in schema && schema.const === null) || ("anyOf" in schema && schema.anyOf.some(allowsNullSchema)); + +/** A value as code: JSON, but -0 and the numbers JSON can't write as themselves. */ +const codeOf = (value: unknown): string => { + if (Object.is(value, -0)) return "-0"; + if (typeof value === "number" && !Number.isFinite(value)) return Number.isNaN(value) ? "NaN" : value > 0 ? "Infinity" : "-Infinity"; + if (Array.isArray(value)) return `[${value.map(codeOf).join(",")}]`; + if (typeof value === "object" && value !== null) return `{${Object.entries(value).map(([field, item]) => `${JSON.stringify(field)}:${codeOf(item)}`).join(",")}}`; + return JSON.stringify(value); +}; + +/** Whether a schema's value is one a literal default can be: what the build reads from literal types. */ +const isValue = (value: unknown): value is Value => + value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean" + || (Array.isArray(value) ? value.every(isValue) : typeof value === "object" && Object.values(value).every(isValue)); + +/** What a value declared without a default starts at, by its type: a value, a fresh `Map` or `Set` per world, or why neither. */ +type Start = { readonly value: Value } | { readonly fresh: "Map" | "Set" } | { readonly none: string }; + +/** A schema's zero value: 0, false or "", each item's and field's, a union's literal that is one, or a fresh set or map. */ +const zeroOf = (schema: Schema): Start => { + if ("const" in schema) return { value: schema.const }; + if ("anyOf" in schema) { + for (const member of schema.anyOf) if ("const" in member && (member.const === 0 || member.const === false || member.const === "")) return { value: member.const }; + return { none: schema.anyOf.every(member => "const" in member) ? `a union of literals without 0, false or "" starts at none of them: declare \`default:\` one of them` : "a union of types starts at none of them" }; + } + if ("allOf" in schema) return { none: "a value of any type has no zero value" }; + switch (schema.type) { + case "number": case "integer": { + const minimum = "minimum" in schema ? schema.minimum ?? -Infinity : -Infinity; + const maximum = "maximum" in schema ? schema.maximum ?? Infinity : Infinity; + return minimum <= 0 && maximum >= 0 ? { value: 0 } : { none: "0 isn't one of its values" }; + } + case "boolean": return { value: false }; + case "string": return (schema.minLength ?? 0) === 0 ? { value: "" } : { none: `"" isn't one of its values` }; + case "null": return { value: null }; + case "set": return { fresh: "Set" }; + case "map": return { fresh: "Map" }; + case "array": { + const count = schema.minItems ?? 0; + if (count === 0) return { value: [] }; + if (schema.maxItems !== count) return { none: `an array of ${count} items or more has no zero value` }; + const item = zeroOf(schema.items); + return "value" in item ? { value: Array.from({ length: count }, () => item.value) } : { none: "fresh" in item ? "an item that's a set or a map has no zero value" : item.none }; + } + case "object": { + const value: { [field: string]: Value } = {}; + for (const field of schema.required ?? Object.keys(schema.properties)) { + const property = schema.properties[field]; + const zero = property === undefined ? { none: "it isn't a property" } : zeroOf(property); + if (!("value" in zero)) return { none: `its field ${field} starts at nothing: ${"fresh" in zero ? "a set or a map in a struct has no zero value" : zero.none}` }; + value[field] = zero.value; + } + return { value }; + } + } +}; + +/** + * What a resource reduced with `operator` starts at when it declares no start: the operator's identity, leaf by leaf, + * as a fold of no contributions leaves it; null for a schema of other leaves. Used only when it's one of the + * schema's values. + */ +const identityOf = (schema: Schema, operator: unknown): Value | null => { + if ("type" in schema && schema.type === "boolean") return operator === "&"; + if ("type" in schema && (schema.type === "number" || schema.type === "integer")) { + const minimum = "minimum" in schema ? schema.minimum : undefined; + const maximum = "maximum" in schema ? schema.maximum : undefined; + switch (operator) { + case "+": case "|": case "^": return 0; + case "*": return 1; + // Every bit of its column set: u32's all ones, else -1. + case "&": return minimum !== undefined && minimum >= 0 && maximum !== undefined && maximum <= 0xFFFFFFFF ? 0xFFFFFFFF : -1; + case "min": return maximum ?? Infinity; + case "max": return minimum ?? -Infinity; + default: return null; + } + } + if ("type" in schema && schema.type === "array" && schema.minItems !== undefined && schema.minItems === schema.maxItems) { + const item = identityOf(schema.items, operator); + return item === null ? null : Array.from({ length: schema.minItems }, () => item); + } + if ("type" in schema && schema.type === "object") { + const value: { [field: string]: Value } = {}; + for (const [field, property] of Object.entries(schema.properties)) { + const leaf = identityOf(property, operator); + if (leaf === null) return null; + value[field] = leaf; + } + return value; + } + return null; +}; + +/** + * Registers what each `declare module "@adobe/ecs"` block declares, so consumers never write registration: each + * component and resource of `Components` and `Resources`, with the schema of its `type`, its `default` and its other + * traits as declared, and each trait of `Traits` keyed by a symbol. A key is a top-level `Symbol()` constant of this + * module, so importing a key registers it: its registration comes right after it, after any binding its declaration + * names, and after a trait of this module it declares, wherever the block is. A literal default and the built-in + * traits' values are checked as far as literals and types show. A resource or derived component without a literal + * default starts at its type's: its namespaced schema's, null, a zero value, or a fresh `Map` or `Set` per world. + * Returns null when the module declares nothing. + */ +export const augmentationInsertions = async (module: Module, host: AugmentationHost): Promise => { + const { code, file } = module; + if (!code.includes("@adobe/ecs") || !code.includes("declare module") || isLibraryModule(host.packagePath(file), file)) return null; + const augmentations = augmentationsOf(module); + if (augmentations.length === 0) return null; + const converter = createConverter(host, file); + const { fail } = converter; + const insertions: Insertion[] = []; + // A name the module doesn't use for the function appended to record its declarations. + const record = uniqueName(code, "declare$"); + + /** + * Where a local binding has its value, which a registration naming it comes after: the end of the top-level + * statement declaring it, and 0 for a function, hoisted with its value, or anything imported. + */ + const endOfLocal = (name: string): number => { + for (const statement of module.program.body) { + const declared = statement.type === "ExportNamedDeclaration" || statement.type === "ExportDefaultDeclaration" ? statement.declaration : statement; + if (declared?.type === "VariableDeclaration" && declared.declarations.some(declarator => declarator.id.type === "Identifier" && declarator.id.name === name)) return statement.end; + if ((declared?.type === "ClassDeclaration" || declared?.type === "TSEnumDeclaration") && declared.id?.name === name) return statement.end; + } + return 0; + }; + + /** Whether an initializer is a call of the global `Symbol`. */ + const isSymbolCall = (init: Declarator["init"]): boolean => { + let expression = init; + while (expression !== null && (expression.type === "TSAsExpression" || expression.type === "TSSatisfiesExpression" || expression.type === "ParenthesizedExpression")) expression = expression.expression; + return expression?.type === "CallExpression" && expression.callee.type === "Identifier" && expression.callee.name === "Symbol"; + }; + + /** + * Fails unless a key a block declares is a top-level `Symbol()` constant of this module, so importing the key + * registers it. + */ + const requireOwnKey = (name: string, at: Node, what: string): void => { + const imported = module.imports.get(name); + if (imported !== undefined) fail(module, at, `${what} ${name} is imported from "${imported.source}": declare it in the module that declares the key`); + if (!isSymbolCall(module.constants.get(name)?.init ?? null)) fail(module, at, `${what} ${name} is a top-level \`Symbol()\` constant of this module`); + }; + + /** Fails unless a trait key a declaration names is a `Symbol()` constant, here or imported as a value. */ + const requireTraitKey = async (name: string, at: Node): Promise => { + const imported = module.imports.get(name); + if (imported !== undefined && !imported.value) fail(module, at, `a trait's key names ${name}, imported as a type only: import it as a value, which the registration passes`); + const found = await converter.constantNamed([name], module); + if (!isSymbolCall(found?.declaration.init ?? null)) fail(module, at, `a trait's key ${name} is a top-level \`Symbol()\` constant, of this module or one it imports`); + }; + + /** Whether a type, as written, allows `null`, through the aliases it names. */ + const allowsNullType = async (type: TypeNode, at: Module, seen: Set): Promise => { + if (type.type === "TSNullKeyword") return true; + if (type.type === "TSParenthesizedType") return await allowsNullType(type.typeAnnotation, at, seen); + if (type.type === "TSUnionType") { + for (const member of type.types) if (await allowsNullType(member, at, seen)) return true; + return false; + } + if (type.type !== "TSTypeReference" || type.typeArguments !== null) return false; + const found = await converter.typeNamed(typeNameParts(type.typeName), at); + if (found === null || typeof found === "string" || found.declaration.type !== "TSTypeAliasDeclaration" || seen.has(found.declaration)) return false; + seen.add(found.declaration); + return await allowsNullType(found.declaration.typeAnnotation, found.module, seen); + }; + + /** Whether a type, as written, is a Transferable class, or one or null, through the aliases it names. */ + const isTransferableType = async (type: TypeNode, at: Module, seen: Set): Promise => { + if (type.type === "TSParenthesizedType") return await isTransferableType(type.typeAnnotation, at, seen); + if (type.type === "TSUnionType") { + const members = type.types.filter(member => member.type !== "TSNullKeyword"); + if (members.length === 0) return false; + for (const member of members) if (!await isTransferableType(member, at, seen)) return false; + return true; + } + if (type.type !== "TSTypeReference" || type.typeArguments !== null) return false; + const parts = typeNameParts(type.typeName); + const found = await converter.typeNamed(parts, at); + if (found === "global") return parts.length === 1 && transferables.has(parts[0] ?? ""); + if (found === null || typeof found === "string" || found.declaration.type !== "TSTypeAliasDeclaration" || seen.has(found.declaration)) return false; + seen.add(found.declaration); + return await isTransferableType(found.declaration.typeAnnotation, found.module, seen); + }; + + /** Fails for a built-in trait whose value, or whose key's type, its literal and type show can't hold. */ + const checkBuiltIns = async (name: string, kind: "component" | "resource", type: TypeNode, schema: Schema | undefined, builtIns: ReadonlyMap): Promise => { + const derived = builtIns.get("derived"); + if (derived !== undefined && derived.text !== "true") fail(module, derived.node, `${name}'s derived is \`true\``); + if (derived !== undefined && kind !== "component") fail(module, derived.node, `${name} is a resource: only a component is derived`); + const reference = builtIns.get("reference"); + if (reference !== undefined && reference.text !== `"owner"` && reference.text !== `"weak"`) fail(module, reference.node, `${name}'s reference is "owner" or "weak"`); + if (reference !== undefined && (schema === undefined || !isEntity(schema))) fail(module, reference.node, `${name} is a reference, so a reference's type is Entity`); + const within = builtIns.get("within"); + if (within !== undefined) { + const host = within.node.type === "TSTypeQuery" && within.node.exprName.type === "Identifier" ? within.node.exprName.name : null; + const found = host === null ? null : await converter.constantNamed([host], module); + if (found === null || !isSymbolCall(found.declaration.init)) fail(module, within.node, `${name}'s within is \`typeof\` a component's key, a \`Symbol()\` constant`); + } + const transfer = builtIns.get("transfer"); + if (transfer !== undefined && (transfer.text !== "true" || schema !== undefined || !await isTransferableType(type, module, new Set()))) { + fail(module, transfer.node, `${name}'s transfer takes a Transferable class: ${[...transferables].join(", ")}`); + } + const reduce = builtIns.get("reduce"); + if (reduce !== undefined && kind !== "resource") fail(module, reduce.node, `${name} is a component: only a resource is reduced`); + if (reduce !== undefined && !reductionOperators.some(operator => JSON.stringify(operator) === reduce.text)) fail(module, reduce.node, `${name}'s reduce is one of ${reductionOperators.map(operator => `"${operator}"`).join(", ")}`); + const placed = builtIns.get("thread"); + if (placed !== undefined && !/^"[^"]+"$/.test(placed.text)) fail(module, placed.node, `${name}'s thread is a thread's name`); + if (placed !== undefined && schema !== undefined && isString(schema)) fail(module, placed.node, `${name} is a string: a string lives in shared memory, so it has no thread`); + if (placed !== undefined && schema !== undefined && isStruct(schema)) fail(module, placed.node, `${name} is a struct: a struct lives in shared memory, so it has no thread`); + }; + + /** An expression without the casts and parentheses around it. */ + const unwrapped = (expression: Declarator["init"]): Declarator["init"] => { + let inner = expression; + while (inner !== null && (inner.type === "TSAsExpression" || inner.type === "TSSatisfiesExpression" || inner.type === "TSNonNullExpression" || inner.type === "ParenthesizedExpression")) inner = inner.expression; + return inner; + }; + + /** The names a name or a chain of non-computed property reads reads; null for any other expression. */ + const pathOf = (expression: NonNullable): readonly string[] | null => { + if (expression.type === "Identifier") return [expression.name]; + if (expression.type !== "MemberExpression" || expression.computed || expression.property.type !== "Identifier" || expression.object.type === "Super") return null; + const object = pathOf(expression.object); + return object === null ? null : [...object, expression.property.name]; + }; + + /** A value as its module writes it, and whether an `as const` around it makes what it writes in place readonly. */ + type Written = { readonly expression: NonNullable; readonly module: Module; readonly constant: boolean; readonly annotation?: TypeNode }; + + /** + * The outermost assertion on an expression, under parentheses, `satisfies` and `!`: "const" for `as const`, the + * type of any other `as`, or null without one. + */ + const outerAssertion = (expression: Declarator["init"]): "const" | TypeNode | null => { + let inner = expression; + while (inner !== null && (inner.type === "TSSatisfiesExpression" || inner.type === "TSNonNullExpression" || inner.type === "ParenthesizedExpression")) inner = inner.expression; + if (inner?.type !== "TSAsExpression") return null; + const { typeAnnotation } = inner; + return typeAnnotation.type === "TSTypeReference" && typeAnnotation.typeName.type === "Identifier" && typeAnnotation.typeName.name === "const" ? "const" : typeAnnotation; + }; + + /** Whether an expression's outermost assertion is `as const`. */ + const isConstAssertion = (expression: Declarator["init"]): boolean => outerAssertion(expression) === "const"; + + /** The `default` property's value of an object a module writes: in place, or through constants and spreads. */ + const defaultProperty = async (expression: Declarator["init"], at: Module, constant: boolean, seen: Set): Promise => { + const value = unwrapped(expression); + if (value === null || seen.has(value)) return null; + seen.add(value); + const inConst = constant || isConstAssertion(expression); + if (value.type === "Identifier" || value.type === "MemberExpression") { + const path = pathOf(value); + const found = path === null ? null : await converter.constantNamed(path, at); + return found === null ? null : await defaultProperty(found.declaration.init, found.module, false, seen); + } + if (value.type !== "ObjectExpression") return null; + for (const property of value.properties) { + if (property.type === "SpreadElement") { + const found = await defaultProperty(property.argument, at, inConst, seen); + if (found !== null) return found; + } + else if (!property.computed && property.key.type === "Identifier" && property.key.name === "default") { + return { expression: property.value, module: at, constant: inConst }; + } + } + return null; + }; + + /** The value of a type's namespace's `default`, as `Vec3` names its namespace beside its type: a namespace's export, or a constant object's property. */ + const defaultWritten = async (path: readonly string[]): Promise => { + const member = await converter.constantNamed([...path, "default"], module); + if (member !== null && member.declaration.init !== null) { + const annotation = member.declaration.id.type === "Identifier" ? member.declaration.id.typeAnnotation?.typeAnnotation : undefined; + return { expression: member.declaration.init, module: member.module, constant: false, annotation }; + } + const holder = await converter.constantNamed(path, module); + return holder === null ? null : await defaultProperty(holder.declaration.init, holder.module, false, new Set()); + }; + + /** + * Whether a type is deeply readonly as written: no array, field, map or set of it can change in place. `fields` + * takes an object's fields as readonly, as `Readonly<...>` makes them. + */ + const readonlyType = async (type: TypeNode, at: Module, seen: Set, fields = false): Promise => { + switch (type.type) { + case "TSNumberKeyword": case "TSStringKeyword": case "TSBooleanKeyword": case "TSBigIntKeyword": case "TSNullKeyword": case "TSUndefinedKeyword": case "TSLiteralType": return true; + case "TSParenthesizedType": return await readonlyType(type.typeAnnotation, at, seen); + case "TSUnionType": return (await Promise.all(type.types.map(member => readonlyType(member, at, seen)))).every(Boolean); + case "TSTypeOperator": { + if (type.operator !== "readonly") return false; + const inner = type.typeAnnotation.type === "TSParenthesizedType" ? type.typeAnnotation.typeAnnotation : type.typeAnnotation; + if (inner.type === "TSArrayType") return await readonlyType(inner.elementType, at, seen); + if (inner.type === "TSTupleType") { + return (await Promise.all(inner.elementTypes.map(element => element.type === "TSOptionalType" || element.type === "TSRestType" || element.type === "TSNamedTupleMember" ? false : readonlyType(element, at, seen)))).every(Boolean); + } + return false; + } + case "TSTypeLiteral": { + for (const member of type.members) { + if (member.type !== "TSPropertySignature" || (member.readonly !== true && !fields) || member.typeAnnotation === null || !await readonlyType(member.typeAnnotation.typeAnnotation, at, seen)) return false; + } + return true; + } + case "TSTypeReference": { + const parts = typeNameParts(type.typeName); + const parameters = type.typeArguments?.params ?? []; + const [only] = parts; + if (parts.length === 1 && only !== undefined && !at.types.has(only) && !at.imports.has(only) && (only === "ReadonlyMap" || only === "ReadonlySet" || only === "ReadonlyArray")) { + return (await Promise.all(parameters.map(parameter => readonlyType(parameter, at, seen)))).every(Boolean); + } + const [argument] = parameters; + if (parts.length === 1 && only === "Readonly" && !at.types.has(only) && !at.imports.has(only) && argument !== undefined) return await readonlyType(argument, at, seen, true); + const found = await converter.typeNamed(parts, at); + if (found === null || typeof found === "string" || seen.has(found.declaration)) return false; + // A type a schema names, as the package's math types are, is a readonly value. + if (host.packagePath(found.module.file) === "interface/schema/schema" && found.declaration.id?.name === "Type") return true; + if (host.packagePath(found.module.file) === "interface/entity/entity" && found.declaration.id?.name === "Entity") return true; + seen.add(found.declaration); + if (found.declaration.type === "TSTypeAliasDeclaration") return found.declaration.typeParameters === null && await readonlyType(found.declaration.typeAnnotation, found.module, seen, fields); + if (found.declaration.type !== "TSInterfaceDeclaration" || found.declaration.extends.length > 0) return false; + for (const member of found.declaration.body.body) { + if (member.type !== "TSPropertySignature" || (member.readonly !== true && !fields) || member.typeAnnotation === null || !await readonlyType(member.typeAnnotation.typeAnnotation, found.module, seen)) return false; + } + return true; + } + default: return false; + } + }; + + /** Where a value a module writes isn't evidently readonly: the part that isn't, and why. */ + type Unreadonly = { readonly module: Module; readonly expression: NonNullable; readonly why: string }; + + /** The source text of a part of a module, for failures. */ + const textOf = (at: Module, expression: { readonly start: number; readonly end: number }): string => at.code.slice(expression.start, expression.end); + + /** + * Where a value a module writes isn't deeply readonly by its type, or null when it is: a literal, `as const` data, a + * binding typed readonly, or a call of a function whose declared return type is readonly. + */ + const unreadonly = async (written: Written, seen: Set): Promise => { + const { module: at, annotation, expression } = written; + const failing = (why: string): Unreadonly => ({ module: at, expression, why: `\`${textOf(at, expression)}\` ${why}` }); + if (annotation !== undefined) return await readonlyType(annotation, at, seen) ? null : failing("is typed with a type that isn't readonly"); + // A cast to a type is that type, whatever `as const` around it says. + const assertion = outerAssertion(expression); + if (assertion !== null && assertion !== "const") return await readonlyType(assertion, at, seen) ? null : failing("is cast to a type that isn't readonly"); + const constant = written.constant || assertion === "const"; + const value = unwrapped(expression); + if (value === null || seen.has(value)) return failing("isn't a value the build can read"); + seen.add(value); + const each = async (items: readonly Written[]): Promise => { + for (const item of items) { + const found = await unreadonly(item, seen); + if (found !== null) return found; + } + return null; + }; + switch (value.type) { + case "Literal": return null; + case "TemplateLiteral": return value.expressions.length === 0 ? null : failing("isn't a literal"); + case "UnaryExpression": + return value.operator === "-" && (value.argument.type === "Literal" || (value.argument.type === "Identifier" && (value.argument.name === "Infinity" || value.argument.name === "NaN"))) ? null : failing("isn't a literal"); + case "ArrayExpression": { + if (!constant) return failing("isn't under `as const`"); + const items: Written[] = []; + for (const element of value.elements) { + if (element === null || element.type === "SpreadElement") return failing("spreads or skips items, which the build can't see are readonly"); + items.push({ expression: element, module: at, constant }); + } + return await each(items); + } + case "ObjectExpression": { + if (!constant) return failing("isn't under `as const`"); + const fields: Written[] = []; + for (const property of value.properties) { + if (property.type === "SpreadElement" || property.computed) return failing("spreads or computes a field, which the build can't see is readonly"); + fields.push({ expression: property.value, module: at, constant }); + } + return await each(fields); + } + case "CallExpression": { + const path = value.callee.type === "Super" ? null : pathOf(value.callee); + const returned = path === null ? null : await converter.returnTypeNamed(path, at); + if (returned === null) return failing("calls a function the build can't find"); + if (returned === undefined) return failing("returns a type the build can't see is readonly: annotate its return type"); + return await readonlyType(returned.type, returned.module, seen) ? null : failing("returns a type that isn't readonly"); + } + case "Identifier": case "MemberExpression": { + if (value.type === "Identifier" && (value.name === "Infinity" || value.name === "NaN" || value.name === "undefined")) return null; + const path = pathOf(value); + const found = path === null ? null : await converter.constantNamed(path, at); + if (found === null || found.declaration.init === null) return failing("names a value the build can't read"); + const declared = found.declaration.id.type === "Identifier" ? found.declaration.id.typeAnnotation?.typeAnnotation : undefined; + return await unreadonly({ expression: found.declaration.init, module: found.module, constant: false, annotation: declared }, seen); + } + default: return failing("isn't a literal, `as const` data, a constant typed readonly or a call returning a readonly type"); + } + }; + + /** The value a module writes, when it's data the build reads: literals, and arrays and objects of them, through constants; else undefined. */ + const staticValue = async (written: Written, seen: Set): Promise => { + const value = unwrapped(written.expression); + if (value === null || seen.has(value)) return undefined; + seen.add(value); + const at = written.module; + switch (value.type) { + case "Literal": return typeof value.value === "string" || typeof value.value === "number" || typeof value.value === "boolean" || value.value === null ? value.value : undefined; + case "UnaryExpression": + if (value.operator !== "-") return undefined; + if (value.argument.type === "Identifier" && (value.argument.name === "Infinity" || value.argument.name === "NaN")) return -(value.argument.name === "Infinity" ? Infinity : NaN); + return value.argument.type === "Literal" && typeof value.argument.value === "number" ? -value.argument.value : undefined; + case "ArrayExpression": { + const items: Value[] = []; + for (const element of value.elements) { + const item = element === null || element.type === "SpreadElement" ? undefined : await staticValue({ expression: element, module: at, constant: written.constant }, seen); + if (item === undefined) return undefined; + items.push(item); + } + return items; + } + case "ObjectExpression": { + const fields: { [field: string]: Value } = {}; + for (const property of value.properties) { + if (property.type === "SpreadElement" || property.computed || property.key.type !== "Identifier") return undefined; + const field = await staticValue({ expression: property.value, module: at, constant: written.constant }, seen); + if (field === undefined) return undefined; + fields[property.key.name] = field; + } + return fields; + } + case "Identifier": case "MemberExpression": { + if (value.type === "Identifier" && (value.name === "Infinity" || value.name === "NaN")) return value.name === "Infinity" ? Infinity : NaN; + const path = pathOf(value); + const found = path === null ? null : await converter.constantNamed(path, at); + return found === null || found.declaration.init === null ? undefined : await staticValue({ expression: found.declaration.init, module: found.module, constant: false }, seen); + } + default: return undefined; + } + }; + + /** + * A declaration's type's namespace's default, `T.default`, as code reading it, and the local binding it reads; null + * without one. Checked against `schema` when the build can read it. `oneObject` is for a start a joined thread + * knows by identity, which is read, never copied. + */ + const namespacedDefault = async (type: TypeNode, name: string, schema: Schema | undefined, oneObject: boolean): Promise<{ readonly text: string; readonly value?: Value; readonly local?: string; readonly packaged: boolean } | null> => { + const inner = type.type === "TSParenthesizedType" ? type.typeAnnotation : type; + if (inner.type !== "TSTypeReference" || inner.typeArguments !== null) return null; + const path = typeNameParts(inner.typeName); + const [first] = path; + const written = first === undefined ? null : await defaultWritten(path); + if (first === undefined || written === null) return null; + // The package's own types' defaults are their zero values and identities, which a reduction's identity outranks. + const packaged = host.packagePath(written.module.file) !== null; + const mutable = await unreadonly(written, new Set()); + if (mutable !== null) { + fail(mutable.module, mutable.expression, `${path.join(".")}.default is shared by every row and world that starts from it, so it's deeply readonly, but ${mutable.why}. Readonly values are \`const ${path.at(-1) ?? "T"} = { default: ... } as const\`, consts typed readonly (ReadonlyMap, ReadonlySet, Readonly<...>, readonly arrays and fields), and calls of functions whose declared return type is readonly`); + } + const value = await staticValue(written, new Set()); + if (schema !== undefined && value !== undefined && !matchesSchema(schema, value)) fail(written.module, written.expression, `${path.join(".")}.default, ${codeOf(value)}, isn't one of its type's values`); + const imported = module.imports.get(first); + if (imported !== undefined && !imported.value) { + // Imported as a type alone, the namespace can't be read here, so a default the build can read is written in. + if (value === undefined || (oneObject && typeof value === "object" && value !== null)) { + fail(module, inner, `${name}'s type ${path.join(".")} starts at ${path.join(".")}.default${oneObject ? ", one object a joined thread knows by identity" : ""}: import ${first} as a value, which the registration reads`); + } + return { text: codeOf(value ?? null), value: value ?? null, packaged }; + } + return { text: `${path.join(".")}.default`, local: imported === undefined ? first : undefined, packaged }; + }; + + /** + * A start as code, the local bindings it reads, whether a namespace or an identity gave it, not zero values alone, + * and its value when it's data the build wrote in. + */ + type Started = { readonly text: string; readonly locals: readonly string[]; readonly given: boolean; readonly value?: Value }; + + /** + * What a value of `type`, of `schema`, starts at as code: its namespace's default of yours; else for a reduction by + * `operator`, its identity when that's one of its values; else the package's namespace's; else each field of a + * struct and item of a tuple by these rules; else its zero value, as `zeroOf` says. + */ + const startOf = async (type: TypeNode, schema: Schema, operator: unknown, oneObject: boolean): Promise => { + const unwrapped = type.type === "TSParenthesizedType" ? type.typeAnnotation : type; + const inner = unwrapped.type === "TSTypeOperator" && unwrapped.operator === "readonly" && unwrapped.typeAnnotation !== undefined ? unwrapped.typeAnnotation : unwrapped; + const namespaced = await namespacedDefault(inner, "it", schema, oneObject); + const named = namespaced === null ? null : { text: namespaced.text, value: namespaced.value, locals: namespaced.local === undefined ? [] : [namespaced.local], given: true }; + if (named !== null && namespaced !== null && !namespaced.packaged) return named; + const parts = inner.type === "TSTypeLiteral" && "type" in schema && schema.type === "object" + ? inner.members.flatMap(field => field.type === "TSPropertySignature" && !field.computed && field.key.type === "Identifier" && field.typeAnnotation !== null + ? [{ name: field.key.name, type: field.typeAnnotation.typeAnnotation, schema: schema.properties[field.key.name] }] : []) + : inner.type === "TSTupleType" && "type" in schema && schema.type === "array" + ? inner.elementTypes.flatMap((element, k) => element.type === "TSOptionalType" || element.type === "TSRestType" || element.type === "TSNamedTupleMember" ? [] : [{ name: String(k), type: element, schema: schema.items }]) + : null; + if (parts === null || parts.length === 0) { + const identity = operator === null ? null : identityOf(schema, operator); + if (identity !== null && matchesSchema(schema, identity)) return { text: codeOf(identity), value: identity, locals: [], given: true }; + if (named !== null) return named; + const zero = zeroOf(schema); + return "value" in zero ? { text: codeOf(zero.value), value: zero.value, locals: [], given: false } : zero; + } + const texts: string[] = []; + const locals: string[] = []; + const values: Value[] = []; + const fields: { [field: string]: Value } = {}; + let given = false; + let data = true; + for (const part of parts) { + const started = part.schema === undefined ? { none: "it isn't a property" } : await startOf(part.type, part.schema, operator, false); + if (!("text" in started)) return { none: `its ${inner.type === "TSTupleType" ? "item" : "field"} ${part.name} starts at nothing: ${"fresh" in started ? "a set or a map in a struct has no zero value" : started.none}` }; + texts.push(inner.type === "TSTupleType" ? started.text : `${JSON.stringify(part.name)}: ${started.text}`); + locals.push(...started.locals); + given ||= started.given; + if (started.value === undefined) data = false; + else { + values.push(started.value); + fields[part.name] = started.value; + } + } + // With zero values alone, the schema's zero value says the same, as data. + if (!given) { + const zero = zeroOf(schema); + if ("value" in zero) return { text: codeOf(zero.value), value: zero.value, locals, given }; + } + if (data) { + const value = inner.type === "TSTupleType" ? values : fields; + return { text: codeOf(value), value, locals, given }; + } + return { text: inner.type === "TSTupleType" ? `[${texts.join(", ")}]` : `{ ${texts.join(", ")} }`, locals, given }; + }; + + /** A declared default: a literal type, never `typeof` a value. */ + const literalDefault = async (type: TypeNode, name: string): Promise => { + if (type.type === "TSTypeQuery") fail(module, type, `${name}'s default is a literal type, never \`typeof\` a value: without one, it starts at its type's namespace's \`default\`, its reduction's identity, null, or its type's zero value`); + return await converter.valueOfType(type, module, new Set()); + }; + + /** How to give a type's values a start no zero value gives: a literal, or its type's namespace's `default`. */ + const workaround = (type: TypeNode): string => { + const name = type.type === "TSTypeReference" && type.typeArguments === null ? typeNameParts(type.typeName).join(".") : null; + return name === null + ? "declare `default:` as a literal, or name its type and give the type a namespace with a `default`: `type Start = ...; const Start = { default: ... }`" + : `declare \`default:\` as a literal, or give ${name} a namespace with a \`default\`: \`const ${name} = { default: ... }\``; + }; + + /** Where a trait a block of this module declares is registered, which what declares it comes after. */ + const traitPoints = new Map(); + + /** A trait's value as code: a literal, or `typeof` a value, which names it; and that value's local binding. */ + const valueText = async (type: TypeNode, what: string): Promise<{ readonly text: string; readonly value?: Value; readonly local?: string }> => { + if (type.type === "TSTypeQuery") { + const named = type.exprName.type === "TSImportType" ? null : qualifiedParts(type.exprName); + const [first] = named ?? []; + if (named === null || first === undefined || type.typeArguments !== null) return fail(module, type, `${what} is a literal type, or \`typeof\` a value`); + const imported = module.imports.get(first); + if (imported !== undefined && !imported.value) fail(module, type, `${what} names ${first}, imported as a type only: import it as a value, which the registration passes`); + return { text: named.join("."), local: imported === undefined ? first : undefined }; + } + const value = await converter.valueOfType(type, module, new Set()); + return { text: codeOf(value), value }; + }; + + try { + const shadowed = ["globalThis", "Symbol", "Map"].find(name => bindsAtTopLevel(module.program, name)); + const first = augmentations[0]; + if (shadowed !== undefined && first !== undefined) fail(module, first.block, `the module binds ${shadowed}, which the code recording its declarations uses`); + // Traits first, so a component declaring one registers after it. + const ordered = augmentations.flatMap(({ block, interfaces }) => interfaces.map(declared => ({ block, declared }))) + .sort((a, b) => Number(b.declared.id.name === "Traits") - Number(a.declared.id.name === "Traits")); + for (const { declared } of ordered) { + const interfaceName = declared.id.name; + if (!isKind(interfaceName)) continue; + const kind = kinds[interfaceName]; + for (const member of declared.body.body) { + if (member.type !== "TSPropertySignature") { + if (member.type === "TSIndexSignature" || member.type === "TSMethodSignature") fail(module, member, `${interfaceName}'s members are keys and their declarations`); + continue; + } + const { key } = member; + if (kind === "trait") { + // String-named traits are the package's own. + if (!member.computed || key.type !== "Identifier") return fail(module, member, "a trait of your own is keyed by a `Symbol()` constant: `[layer]: ...`"); + requireOwnKey(key.name, member, "a trait's key"); + const point = endOfLocal(key.name); + traitPoints.set(key.name, point); + insertions.push({ at: point, text: `\n${record}(${key.name}, "trait");` }); + continue; + } + if (!member.computed || key.type !== "Identifier") return fail(module, member, `a ${kind}'s key is a \`Symbol()\` constant, declared as \`[key]: { type: ... }\``); + requireOwnKey(key.name, member, `a ${kind}'s key`); + const declaration = member.typeAnnotation?.typeAnnotation; + if (declaration?.type !== "TSTypeLiteral") return fail(module, member, `${key.name}'s declaration is a type literal of its traits: \`{ type: ... }\``); + converter.site.at = member.start; + let at = endOfLocal(key.name); + let typeNode: TypeNode | undefined; + let defaultNode: TypeNode | undefined; + const traits: string[] = []; + let thread = false; + /** The built-in traits declared, by name: their values' types and code. */ + const builtIns = new Map(); + for (const trait of declaration.members) { + if (trait.type !== "TSPropertySignature" || trait.typeAnnotation === null) return fail(module, trait, `${key.name}'s traits are properties with values`); + const property: Property = trait; + const value = trait.typeAnnotation.typeAnnotation; + const name = !property.computed && property.key.type === "Identifier" ? property.key.name + : property.computed && property.key.type === "Identifier" ? null + : fail(module, trait, `${key.name}'s traits are named, or keyed by a trait's \`Symbol()\` constant`); + if (name === "type") typeNode = value; + else if (name === "default") defaultNode = value; + else { + const text = await valueText(value, `${key.name}'s trait`); + if (text.local !== undefined) at = Math.max(at, endOfLocal(text.local)); + if (name === "thread") thread = true; + if (name !== null) builtIns.set(name, { node: value, text: text.text }); + if (name === null && property.key.type === "Identifier") { + await requireTraitKey(property.key.name, trait); + at = Math.max(at, endOfLocal(property.key.name), traitPoints.get(property.key.name) ?? 0); + } + traits.push(name === null ? `[${property.key.type === "Identifier" ? property.key.name : ""}]: ${text.text}` : `${name}: ${text.text}`); + } + } + if (typeNode === undefined) return fail(module, member, `${key.name} declares its type: \`{ type: ... }\``); + let schema: Schema | undefined; + try { + schema = await converter.convert(typeNode, module, new Set(), "declaration"); + } + catch (error) { + if (!isNotData(error)) throw error; + if (error.notData.node && !thread) fail(module, member, `${key.name} is a DOM Node, which lives on "main": declare \`thread: "main"\``); + } + await checkBuiltIns(key.name, kind, typeNode, schema, builtIns); + const given = defaultNode === undefined ? undefined : await literalDefault(defaultNode, key.name); + if (schema !== undefined && given !== undefined && !matchesSchema(schema, given)) fail(module, defaultNode ?? member, `${key.name}'s default isn't of its declared type`); + let start = given === undefined ? undefined : codeOf(given); + let fresh: "Map" | "Set" | null = null; + // Without a literal, only resources and derived components need a start of their own, and a component + // holding an object, whose start a joined thread knows by identity. + const needed = kind === "resource" || builtIns.has("derived"); + const oneObject = kind === "component" && (schema === undefined || !isStruct(schema)); + const reduced = builtIns.get("reduce"); + const operator: unknown = kind === "resource" && reduced !== undefined ? JSON.parse(reduced.text) : null; + const read = (found: Started): void => { + start = found.text; + for (const local of found.locals) at = Math.max(at, endOfLocal(local)); + }; + if (given === undefined && (needed || oneObject)) { + if (schema === undefined) { + const namespaced = await namespacedDefault(typeNode, key.name, undefined, oneObject); + if (namespaced !== null) read({ text: namespaced.text, locals: namespaced.local === undefined ? [] : [namespaced.local], given: true }); + else if (needed && !await allowsNullType(typeNode, module, new Set())) { + fail(module, member, `${kind} ${key.name} holds a value that isn't data, so nothing gives it a start: let its type allow null, or give its type a namespace with a deeply readonly \`default\`; a ReadonlyMap or ReadonlySet starts fresh in each world, a Map or Set doesn't`); + } + } + else { + const found = await startOf(typeNode, schema, operator, oneObject); + if ("text" in found && found.given) read(found); + // Else it starts at null, as the world reads a value with neither, when its type allows it. + else if (needed && !allowsNullSchema(schema)) { + if ("fresh" in found) fresh = found.fresh; + else if ("none" in found) fail(module, member, `${kind} ${key.name} starts at no value its type gives, since ${found.none}; ${workaround(typeNode)}`); + // A constant is the one value it can start at, which the world reads from its schema. + else if (!("const" in schema)) read(found); + } + } + } + const parts = schema === undefined ? [] : [`type: ${codeOf(schema)}`]; + if (start !== undefined) parts.push(`default: ${start}`); + parts.push(...traits); + if (fresh !== null) parts.push(`fresh: "${fresh}"`); + insertions.push({ at, text: `\n${record}(${key.name}, "${kind}", ${parts.length === 0 ? "{}" : `{ ${parts.join(", ")} }`});` }); + } + } + } + catch (error) { + if (isThrown(error)) return { failure: error.typeFailure, dependencies: [...converter.read] }; + throw error; + } + if (insertions.length === 0) return null; + insertions.push({ at: code.length, text: `\nfunction ${record}(key, kind, declaration) { (globalThis[Symbol.for(${JSON.stringify(declarationsName)})] ??= new Map()).set(key, { kind, declaration }); }\n` }); + return { insertions, dependencies: [...converter.read] }; +}; diff --git a/packages/ecs/src/implementation/compiler/bind.ts b/packages/ecs/src/implementation/compiler/bind.ts index 88b644800..29e37b1cb 100644 --- a/packages/ecs/src/implementation/compiler/bind.ts +++ b/packages/ecs/src/implementation/compiler/bind.ts @@ -2,7 +2,7 @@ import type { Identifier, Node, Pattern } from 'estree'; import { access } from './access.js'; import { set } from './booleans.js'; -import { componentRead } from './columns.js'; +import { componentRead, presence } from './columns.js'; import { dataValue, mapLeaves, toData } from './data.js'; import { fail } from './failure.js'; import { local, type Expression } from './ir.js'; @@ -42,13 +42,14 @@ const declared = (node: Identifier, value: Value, binding: Binding, scope: Scope return dataValue(mapLeaves(toData(value, node, scope), copy)); case "component": { if (value.up !== true) requireReadable(node, scope, value.component); - const snapshot = createSnapshot(keyword, node.name, path => componentRead(unit, value, path)); + const optional = value.up !== true && value.path.length === 0 && unit.signature.optional.has(value.component); + const snapshot = createSnapshot(keyword, node.name, path => componentRead(unit, value, path), optional ? presence(unit, value.component) : null); out.push(snapshotDeclaration(snapshot)); return { kind: "snapshot", snapshot, path: [] }; } case "snapshot": { const source = value; - const snapshot = createSnapshot(keyword, node.name, path => local(snapshotLeaf(unit, source.snapshot, [...source.path, ...path]))); + const snapshot = createSnapshot(keyword, node.name, path => local(snapshotLeaf(unit, source.snapshot, [...source.path, ...path])), source.path.length === 0 ? source.snapshot.presence : null); out.push(snapshotDeclaration(snapshot)); return { kind: "snapshot", snapshot, path: [] }; } diff --git a/packages/ecs/src/implementation/compiler/body-access.ts b/packages/ecs/src/implementation/compiler/body-access.ts index 70b5e2a46..6b9b5b812 100644 --- a/packages/ecs/src/implementation/compiler/body-access.ts +++ b/packages/ecs/src/implementation/compiler/body-access.ts @@ -1,10 +1,22 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { Node, ObjectExpression, Pattern } from 'estree'; import { children, declaredIn, patternNames } from './estree.js'; -import type { Used } from './exact.js'; import type { ModuleScope } from './module-scope.js'; -const unknown: Used = { query: null, resources: null, lookups: null }; +/** Keys read and written, and those changed inside their values (`row[k].a = 1`), which registration judges by kind. */ +type Access = { readonly read: ReadonlySet; readonly write: ReadonlySet; readonly mutate: ReadonlySet }; + +/** The keys a body reads, writes and contributes to, by module-scope binding name; null where the body doesn't show every use. */ +export type Used = { + readonly query: Access | null; + readonly resources: (Access & { readonly reduce: ReadonlySet }) | null; + /** The components read from each lookup's results, by lookup name: null for one whose results aren't followed. */ + readonly lookups: ReadonlyMap | null> | null; + /** The components read from `up`. */ + readonly up: ReadonlySet | null; +}; + +const unknown: Used = { query: null, resources: null, lookups: null, up: null }; /** Whether `node`, a child of `parent`, names a property or label, or declares a binding, rather than referring to one. */ const isName = (node: Node, parent: Node | undefined): boolean => { @@ -18,20 +30,47 @@ const isName = (node: Node, parent: Node | undefined): boolean => { } }; -/** How `member`, a `name[key]` expression, is used: read, written, or both; null when it's a destructuring target or deleted. */ -const useOf = (member: Node, parent: Node | undefined): { readonly read: boolean; readonly write: boolean } | null => { +type Use = { readonly read: boolean; readonly write: boolean; readonly mutate?: true }; + +const read: Use = { read: true, write: false }; +const written: Use = { read: false, write: true }; +const mutated: Use = { read: true, write: false, mutate: true }; + +/** + * How `member`, a `name[key]` expression, is used: read, written, or both; null when it's deleted. A target of a + * destructuring assignment, or of a `for…of` or `for…in` loop, is written; a pattern in a declaration only binds names. + */ +const useOf = (member: Node, parent: Node | undefined, grandparent: Node | undefined): Use | null => { switch (parent?.type) { case "AssignmentExpression": - return parent.left !== member ? { read: true, write: false } : { read: parent.operator !== "=", write: true }; + return parent.left !== member ? read : { read: parent.operator !== "=", write: true }; case "UpdateExpression": return { read: true, write: true }; - case "UnaryExpression": return parent.operator === "delete" ? null : { read: true, write: false }; - case "ArrayPattern": case "RestElement": case "AssignmentPattern": case "ForInStatement": case "ForOfStatement": - return null; - case "Property": return parent.value === member && parent.shorthand ? null : { read: true, write: false }; - default: return { read: true, write: false }; + case "UnaryExpression": return parent.operator === "delete" ? null : read; + case "ArrayPattern": case "RestElement": return written; + case "AssignmentPattern": return parent.left === member ? written : read; + case "ForInStatement": case "ForOfStatement": return parent.left === member ? written : read; + case "Property": return parent.value === member && grandparent?.type === "ObjectPattern" ? written : read; + default: return read; } }; +/** + * How `member`, a `name[key]` expression, is used where `ancestors` ends with its parent: as `useOf` says, and changed + * in place when something inside its value is written (`row[key].a.b = 1`, `delete row[key].a`, `[row[key][0]] = pair`): + * an object changes so, while a data value, replaced whole, can't, which registration tells apart by its kind. + */ +const deepUseOf = (member: Node, ancestors: readonly Node[], at: number): Use | null => { + let inner = member; + let index = at; + for (let parent = ancestors[index]; parent?.type === "MemberExpression" && parent.object === inner; parent = ancestors[index]) { + inner = parent; + index--; + } + const use = useOf(inner, ancestors[index], ancestors[index - 1]); + if (inner === member) return use; + return use === null || use.write ? mutated : read; +}; + /** Whether a lookup result `value`, a child of `parent`, is only tested: compared with null, or used as a condition. */ const isTested = (value: Node, parent: Node | undefined): boolean => { switch (parent?.type) { @@ -74,13 +113,13 @@ const indexLookupNames = (declaration: ObjectExpression): ReadonlySet => ? [property.key.name] : [])); }; -type Group = "resources" | "lookup" | "reduce"; +type Group = "resources" | "lookup" | "reduce" | "up"; -const isGroup = (name: string): name is Group => name === "resources" || name === "lookup" || name === "reduce"; +const isGroup = (name: string): name is Group => name === "resources" || name === "lookup" || name === "reduce" || name === "up"; /** - * What a main-thread body reads, writes and contributes to through its row and context, for the parts where every use - * shows in its source. `row` and `context` are the indexes of those parameters, or -1 for none. The row and the + * What a body reads, writes and contributes to through its row and context, for the parts where every use shows in + * its source. `row` and `context` are the indexes of those parameters, or -1 for none. The row and the * context's `resources`, `lookup` and `reduce` are indexed only by module-scope keys the body doesn't shadow, or * destructured by them, and never passed on, aliased or spread. A lookup's results are followed where each is indexed * by such keys or tested, directly or through a `const` declared once; a lookup through an index's lists where they're @@ -89,15 +128,27 @@ const isGroup = (name: string): name is Group => name === "resources" || name == export const usedBy = (declaration: ObjectExpression, body: Node, module: ModuleScope, positions: { readonly row: number; readonly context: number }): Used => { if (!isFunctionBody(body)) return unknown; if (body.type === "FunctionExpression" && mentions(body.body, "arguments")) return unknown; + // Code `eval` runs can use anything. + if (mentions(body, "eval")) return unknown; const counts = new Map(); declaredIn(body.body, name => counts.set(name, (counts.get(name) ?? 0) + 1)); const parameters = new Set(body.params.flatMap(parameter => patternNames(parameter))); const isKey = (name: string): boolean => module.has(name) && !counts.has(name) && !parameters.has(name); + /** The key `node` names: a module-scope binding, or a namespace's member (`Report.run`), as its source; null for any other. */ + const keyOf = (node: Node): string | null => { + if (node.type === "Identifier") return isKey(node.name) ? node.name : null; + if (node.type === "MemberExpression" && !node.computed && node.object.type === "Identifier" && node.property.type === "Identifier") { + return isKey(node.object.name) ? `${node.object.name}.${node.property.name}` : null; + } + return null; + }; const lookups = lookupNames(declaration); const indexed = indexLookupNames(declaration); - const query = { read: new Set(), write: new Set() }; - const resources = { read: new Set(), write: new Set(), reduce: new Set() }; + const query = { read: new Set(), write: new Set(), mutate: new Set() }; + const resources = { read: new Set(), write: new Set(), mutate: new Set(), reduce: new Set() }; + const up = new Set(); + let upKnown = true; const lookupReads = new Map | null>((lookups ?? []).map(name => [name, new Set()])); let queryKnown = true; let resourcesKnown = true; @@ -105,6 +156,7 @@ export const usedBy = (declaration: ObjectExpression, body: Node, module: Module const unknownLookup = (lookup: string): void => { lookupReads.set(lookup, null); }; const unknownGroup = (group: Group): void => { if (group === "lookup") lookupsKnown = false; + else if (group === "up") upKnown = false; else resourcesKnown = false; }; @@ -121,12 +173,14 @@ export const usedBy = (declaration: ObjectExpression, body: Node, module: Module const destructure = (pattern: Pattern, named: (property: string, value: Pattern) => boolean, keyed: (key: string) => void): boolean => { if (pattern.type !== "ObjectPattern") return false; for (const property of pattern.properties) { - if (property.type === "RestElement" || property.key.type !== "Identifier") return false; + if (property.type === "RestElement") return false; if (!property.computed) { - if (!named(property.key.name, property.value)) return false; + if (property.key.type !== "Identifier" || !named(property.key.name, property.value)) return false; + continue; } - else if (isKey(property.key.name)) keyed(property.key.name); - else return false; + const key = keyOf(property.key); + if (key === null) return false; + keyed(key); } return true; }; @@ -145,6 +199,9 @@ export const usedBy = (declaration: ObjectExpression, body: Node, module: Module else if (group === "resources") { if (!destructure(pattern, () => false, key => resources.read.add(key))) resourcesKnown = false; } + else if (group === "up") { + if (!destructure(pattern, () => false, key => up.add(key))) upKnown = false; + } else resourcesKnown = false; }; /** Binds the context's groups from a destructuring of it; `declaredOnce` says whether a bound name is safe to follow. */ @@ -158,6 +215,7 @@ export const usedBy = (declaration: ObjectExpression, body: Node, module: Module const unknownContext = (): void => { resourcesKnown = false; lookupsKnown = false; + upKnown = false; }; const row = positions.row < 0 ? undefined : body.params[positions.row]; @@ -171,8 +229,11 @@ export const usedBy = (declaration: ObjectExpression, body: Node, module: Module const rowOf = (lookup: string, value: Node, ancestors: readonly Node[], at: number): void => { const parent = ancestors[at]; if (parent?.type === "MemberExpression" && parent.object === value) { - if (parent.computed && parent.property.type === "Identifier" && isKey(parent.property.name)) lookupReads.get(lookup)?.add(parent.property.name); - else if (!(indexed.has(lookup) && !parent.computed && parent.property.type === "Identifier" && parent.property.name === "entity")) unknownLookup(lookup); + const key = parent.computed ? keyOf(parent.property) : null; + if (key !== null) lookupReads.get(lookup)?.add(key); + // A row's `changed` says whether what its lookup watches changed; an index's rows name their entity. + else if (!(!parent.computed && parent.property.type === "Identifier" + && (parent.property.name === "changed" || (indexed.has(lookup) && parent.property.name === "entity")))) unknownLookup(lookup); } else if (parent?.type === "VariableDeclarator" && parent.init === value && parent.id.type === "Identifier" && (results.get(parent.id.name) === lookup || rows.get(parent.id.name) === lookup)) return; @@ -255,6 +316,8 @@ export const usedBy = (declaration: ObjectExpression, body: Node, module: Module if (followed.has(node)) return; const parent = ancestors[at]; const grandparent = ancestors[at - 1]; + // `up` is null for a row without a parent, so it's tested. + if (group === "up" && isTested(node, parent)) return; if (parent?.type !== "MemberExpression" || parent.object !== node) return unknownGroup(group); const { property } = parent; if (group === "lookup") { @@ -262,16 +325,24 @@ export const usedBy = (declaration: ObjectExpression, body: Node, module: Module else lookupsKnown = false; return; } - if (!parent.computed || property.type !== "Identifier" || !isKey(property.name)) return unknownGroup(group); + const key = parent.computed ? keyOf(property) : null; + if (key === null) return unknownGroup(group); if (group === "reduce") { - if (grandparent?.type === "CallExpression" && grandparent.callee === parent) resources.reduce.add(property.name); + if (grandparent?.type === "CallExpression" && grandparent.callee === parent) resources.reduce.add(key); else resourcesKnown = false; return; } - const use = useOf(parent, grandparent); + if (group === "up") { + const use = deepUseOf(parent, ancestors, at - 1); + if (use === null || use.write || use.mutate) upKnown = false; + else up.add(key); + return; + } + const use = deepUseOf(parent, ancestors, at - 1); if (use === null) return unknownGroup(group); - if (use.read) resources.read.add(property.name); - if (use.write) resources.write.add(property.name); + if (use.read) resources.read.add(key); + if (use.write) resources.write.add(key); + if (use.mutate) resources.mutate.add(key); }; /** Classifies a reference; `ancestors` ends with its parent. */ @@ -286,11 +357,14 @@ export const usedBy = (declaration: ObjectExpression, body: Node, module: Module if (property.type !== "Identifier" || !rowNamed(property.name)) queryKnown = false; return; } - const use = property.type === "Identifier" && isKey(property.name) ? useOf(parent, grandparent) : null; - if (use === null || property.type !== "Identifier") queryKnown = false; + const key = keyOf(property); + // A part of a value written, `row[key][0] = …`, writes it, which the compiler rejects as it must. + const use = key === null ? null : deepUseOf(parent, ancestors, ancestors.length - 2); + if (use === null || key === null) queryKnown = false; else { - if (use.read) query.read.add(property.name); - if (use.write) query.write.add(property.name); + if (use.read) query.read.add(key); + if (use.write) query.write.add(key); + if (use.mutate) query.mutate.add(key); } } else if (!(parent?.type === "VariableDeclarator" && parent.init === node && destructure(parent.id, rowNamed, key => query.read.add(key)))) queryKnown = false; @@ -317,17 +391,44 @@ export const usedBy = (declaration: ObjectExpression, body: Node, module: Module }; const ancestors: Node[] = []; - /** A declaration's target pattern only binds names, so only its initializer holds references. */ const visit = (node: Node): void => { if (node.type === "Identifier" && !isName(node, ancestors.at(-1))) reference(node, ancestors); ancestors.push(node); - for (const child of node.type === "VariableDeclarator" ? (node.init === null || node.init === undefined ? [] : [node.init]) : children(node)) visit(child); + if (node.type === "VariableDeclarator") { + visitPattern(node.id); + if (node.init !== null && node.init !== undefined) visit(node.init); + } + else for (const child of children(node)) visit(child); + ancestors.pop(); + }; + /** A pattern that binds names: its names aren't references, but its defaults and computed keys hold them. */ + const visitPattern = (pattern: Node): void => { + ancestors.push(pattern); + if (pattern.type === "ObjectPattern") { + for (const property of pattern.properties) { + ancestors.push(property); + if (property.type === "RestElement") visitPattern(property.argument); + else { + if (property.computed) visit(property.key); + visitPattern(property.value); + } + ancestors.pop(); + } + } + else if (pattern.type === "ArrayPattern") for (const element of pattern.elements) { if (element !== null) visitPattern(element); } + else if (pattern.type === "RestElement") visitPattern(pattern.argument); + else if (pattern.type === "AssignmentPattern") { + visitPattern(pattern.left); + visit(pattern.right); + } ancestors.pop(); }; + for (const parameter of body.params) visitPattern(parameter); visit(body.body); return { query: queryKnown ? query : null, resources: resourcesKnown ? resources : null, lookups: lookupsKnown ? lookupReads : null, + up: upKnown ? up : null, }; }; diff --git a/packages/ecs/src/implementation/compiler/columns.ts b/packages/ecs/src/implementation/compiler/columns.ts index e9b259bce..7e990d9fc 100644 --- a/packages/ecs/src/implementation/compiler/columns.ts +++ b/packages/ecs/src/implementation/compiler/columns.ts @@ -1,7 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { Path } from '../world/kernel.js'; import type { Expression } from './ir.js'; -import type { Unit } from './unit.js'; +import { keyIndex, type Unit } from './unit.js'; const column = (unit: Unit, identity: string, description: string): number => { const existing = unit.columnIndex.get(identity); @@ -14,17 +14,32 @@ const column = (unit: Unit, identity: string, description: string): number => { /** The column of a scalar leaf of a component. */ export const fieldColumn = (unit: Unit, component: string, path: Path): number => { const json = JSON.stringify(path); - return column(unit, `field ${component} ${json}`, `{ kind: "field", component: ${component}, path: ${json} }`); + const index = column(unit, `field ${component} ${json}`, `{ kind: "field", component: ${component}, path: ${json} }`); + if (unit.signature.optional.has(component) && !unit.optionalColumns.has(index)) unit.optionalColumns.set(index, keyIndex(unit, component)); + return index; +}; + +/** + * An optional component's presence column, which an archetype with it gives non-empty and one without empty, so a + * kernel tells them apart once per call whatever the component's shape. + */ +export const presence = (unit: Unit, component: string): Expression => { + const index = column(unit, `present ${component}`, `{ kind: "present", component: ${component} }`); + unit.presences.set(component, index); + keyIndex(unit, component); + return { kind: "present", column: index }; }; export const fieldRead = (unit: Unit, component: string, path: Path): Expression => ({ kind: "column", column: fieldColumn(unit, component, path) }); -/** A component's changed column, immediately followed by its dense chunk column. */ -export const changedColumns = (unit: Unit, component: string): { readonly column: number; readonly chunks: number } => { +/** A component's changed column, immediately followed by its dense chunk and block columns. */ +export const changedColumns = (unit: Unit, component: string): { readonly column: number; readonly chunks: number; readonly blocks: number } => { const changed = column(unit, `changed ${component}`, `{ kind: "changed", component: ${component} }`); const chunks = column(unit, `chunks ${component}`, `{ kind: "chunks", component: ${component} }`); + const blocks = column(unit, `blocks ${component}`, `{ kind: "blocks", component: ${component} }`); unit.chunks.add(chunks); - return { column: changed, chunks }; + unit.blocks.add(blocks); + return { column: changed, chunks, blocks }; }; /** A scalar leaf of a component of the row's parent, read through `up`. */ diff --git a/packages/ecs/src/implementation/compiler/compile-kernel.ts b/packages/ecs/src/implementation/compiler/compile-kernel.ts index 96637b0a5..6f47e4f79 100644 --- a/packages/ecs/src/implementation/compiler/compile-kernel.ts +++ b/packages/ecs/src/implementation/compiler/compile-kernel.ts @@ -1,7 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { Node, ObjectExpression } from 'estree'; import { bind } from './bind.js'; -import { requireExact } from './exact.js'; import { expression } from './expression.js'; import { fail } from './failure.js'; import type { Link } from './link.js'; @@ -12,14 +11,15 @@ import { localName, printStatements } from './print.js'; import { toProgram } from './program.js'; import { serialize } from './serialize.js'; import { unconditionalChunks } from './stamps.js'; -import { chunkShift, type Program } from '../world/kernel.js'; +import { blockShift, chunkShift, type Program } from '../world/kernel.js'; import { rootScope } from './scope.js'; import { signature } from './signature.js'; import { rowLabel, statement } from './statement.js'; import { createUnit } from './unit.js'; import type { Value } from './value.js'; -const parameterValues: readonly Value[] = [{ kind: "row" }, { kind: "context" }]; +/** A kernel body takes its row, its context and its input, the run's arguments. */ +const parameterValues: readonly Value[] = [{ kind: "row" }, { kind: "context" }, { kind: "dynamic", expression: { kind: "args", path: [] } }]; /** * One compiled kernel: `description(url)` is the source of its `KernelDescription`, with keys as the declaration's @@ -41,14 +41,14 @@ const loops = (statements: readonly Program.Statement[]): boolean => statements. }); /** `target` "gpu" also checks, before anything runs, what only a GPU kernel can't do; schemas are checked at registration. */ -export const compileKernel = (declaration: ObjectExpression, body: Node, module: Module, target: "cpu" | "gpu" = "cpu", link: Link | null = null): CompiledKernel => { +export const compileKernel = (declaration: ObjectExpression, body: Node, module: Module, target: "cpu" | "gpu" = "cpu", link: Link | null = null, tried = false): CompiledKernel => { if (body.type !== "ArrowFunctionExpression" && body.type !== "FunctionExpression") { return fail(body, "a kernel body must be an inline function"); } if (body.async || body.generator) return fail(body, "a kernel body can't be async or a generator"); - if (body.params.length > 2) return fail(body, "a kernel body takes row and context"); + if (body.params.length > 3) return fail(body, "a kernel body takes its row, context and input"); const lists = createLists(module, link, [declaration, body]); - const unit = createUnit(signature(declaration, module.bindings, lists), module, lists, expression, statement, link, target); + const unit = createUnit(signature(declaration, module.bindings, lists), module, lists, expression, statement, link, target, tried); const scope = rootScope(unit, false); const prelude: Out = []; const rows: Out = []; @@ -59,14 +59,24 @@ export const compileKernel = (declaration: ObjectExpression, body: Node, module: statement({ type: "ExpressionStatement", expression: body.body, start: body.body.start, end: body.body.end }, scope, rows); } const statements = [...resolve(prelude), ...resolve(rows)]; - for (const chunks of unconditionalChunks(resolve(rows))) unit.stampedPerCall.add(chunks); + for (const [chunks, perCall] of unconditionalChunks(resolve(rows))) unit.stampedPerCall.set(chunks, perCall); const kernel = printStatements(unit, resolve(rows)); + // Which optional components a call's archetype has is the same for every row, so a kernel with them runs loops + // specialized for all present and all absent, and the general one only for archetypes with some of them. + const specialized = unit.presences.size === 0 ? null : ([true, false] as const).map(present => { + unit.presentAs = present; + const body = printStatements(unit, resolve(rows)); + unit.presentAs = null; + return body; + }); const preludeCode = printStatements(unit, resolve(prelude)); const accumulators = unit.accumulators.map(entry => entry.local); const { signature: { resources, lookups, up, ordered, filtered, reduced }, columns, booleans, keys } = unit; /** Whether it runs a list of rows, which needn't be consecutive: `up`'s depth order, or an index's. */ const listed = up !== null || ordered; - const program = toProgram(statements, target === "gpu" ? local => localName(unit, local) : undefined, accumulators, unit.up, listed); + // WASM doesn't read components an archetype may lack yet, so such a kernel runs as JS. + // WASM reads a lookup's rows only where every component it reads is there. + const program = (unit.optionalColumns.size + unit.presences.size > 0 && listed) || unit.signature.lookups.some(lookup => lookup.optional) ? null : toProgram(statements, target === "gpu" ? local => localName(unit, local) : undefined, accumulators, unit.up, listed); if (target === "gpu") { if (lookups.length > 0) fail(declaration, "a GPU kernel can't use lookups yet"); if (filtered) fail(declaration, "a GPU kernel can't use change filters yet"); @@ -74,11 +84,12 @@ export const compileKernel = (declaration: ObjectExpression, body: Node, module: if (program === null) fail(body, "a GPU kernel can't issue commands or use values that aren't numbers or booleans"); else if (loops(program.body)) fail(body, "a GPU kernel can't use loops or labeled blocks yet"); } - const contributed = new Set(unit.accumulators.flatMap(entry => reduced[entry.reduced]?.resource ?? [])); - requireExact(declaration, { query: unit.used, resources: { read: unit.used.resources, write: new Set(), reduce: contributed }, lookups: unit.used.lookups, up: unit.used.up }, module.bindings, lists); - const strided = columns.flatMap((_, index) => unit.chunks.has(index) ? [] : [index]); + // Chunk, block and presence columns aren't read at a row's element. + const strided = columns.flatMap((column, index) => unit.chunks.has(index) || unit.blocks.has(index) || column.startsWith("{ kind: \"present\"") ? [] : [index]); const locals = [ ...columns.map((_, index) => `const c${index} = columns[${index}];`), + // Whether this call's archetype has each optional component, whose column is empty where it doesn't. + ...[...unit.presences.values()].map(index => `const q${index} = c${index}.length !== 0;`), ...resources.map((_, index) => `const r${index} = resources[${index}];`), ...lookups.map((_, index) => `const l${index} = lookups[${index}];`), ...unit.spawns.map((_, index) => `const spawn${index} = commands.spawners[${index}];`), @@ -88,6 +99,15 @@ export const compileKernel = (declaration: ObjectExpression, body: Node, module: ...unit.upColumns.map((_, index) => `const u${index} = upColumns[${index}];`), ], ]; + const presences = [...unit.presences.values()]; + /** The row loop around a body. */ + const loop = (body: string): string => [ + listed + ? `${rowLabel}: for (let n = start; n < end; n++) { const i = rows[n], p = parents[n]; ${[...strided.map(index => `const x${index} = i * s${index} + o${index};`), ...[...unit.stampedPerCall].map(([chunks, { blocks, when }]) => `${when === null ? "" : `if (q${when}) `}{ c${blocks}[i >> ${blockShift}] = tick; c${chunks}[i >> ${chunkShift}] = tick; }`)].join(" ")}` + : `${rowLabel}: for (let ${["i = start", ...strided.map(index => `x${index} = start * s${index} + o${index}`)].join(", ")}; i < end; ${["i++", ...strided.map(index => `x${index} += s${index}`)].join(", ")}) {`, + body, + "}", + ].join("\n"); return { description: url => [ "{", @@ -104,6 +124,7 @@ export const compileKernel = (declaration: ObjectExpression, body: Node, module: `url: ${url},`, `program: ${serialize(program)},`, `reducedShapes: [${[...unit.reducedShapes].join(", ")}],`, + ...unit.tried ? [] : ["storagePrecision: true,"], `reductions: [${unit.accumulators.map(entry => { const { resource, operator } = reduced[entry.reduced] ?? fail(declaration, "unknown reduction"); return `{ resource: ${resource}, operator: ${JSON.stringify(operator)}, path: ${JSON.stringify(entry.path)} }`; @@ -113,8 +134,11 @@ export const compileKernel = (declaration: ObjectExpression, body: Node, module: // Per-registration constants live in the closure. Each column's element index `i * s + o` is strength-reduced to // an induction variable in the loop update, which `continue rows` still runs. create: [ - "(booleans, strides, offsets, upBooleans, upStrides, upOffsets) => {", + "(booleans, strides, offsets, upBooleans, upStrides, upOffsets, labels) => {", ...[...booleans].map(index => `const b${index} = booleans[${index}];`), + ...unit.optionalColumns.size + unit.presences.size === 0 ? [] : [ + "const absent = k => { throw new Error(`An entity lacks \"${labels[k]}\", optional in its system: add it with commands.add rather than assigning it.`); };", + ], ...[...unit.upBooleans].map(index => `const ub${index} = upBooleans[${index}];`), ...unit.upColumns.map((_, index) => `const us${index} = upStrides[${index}], uo${index} = upOffsets[${index}];`), ...strided.map(index => `const s${index} = strides[${index}], o${index} = offsets[${index}];`), @@ -124,14 +148,16 @@ export const compileKernel = (declaration: ObjectExpression, body: Node, module: ...locals, preludeCode, ...accumulators.map((local, k) => `let ${localName(unit, local)} = partials[partial + ${k}];`), - // A kernel that runs a list of rows, which needn't be consecutive, stamps each row's own chunk. - ...listed ? [] : [...unit.stampedPerCall].map(chunks => - `if (start < end) for (let k = start >> ${chunkShift}, last = (end - 1) >> ${chunkShift}; k <= last; k++) c${chunks}[k] = tick;`), - listed - ? `${rowLabel}: for (let n = start; n < end; n++) { const i = rows[n], p = parents[n]; ${[...strided.map(index => `const x${index} = i * s${index} + o${index};`), ...[...unit.stampedPerCall].map(chunks => `c${chunks}[i >> ${chunkShift}] = tick;`)].join(" ")}` - : `${rowLabel}: for (let ${["i = start", ...strided.map(index => `x${index} = start * s${index} + o${index}`)].join(", ")}; i < end; ${["i++", ...strided.map(index => `x${index} += s${index}`)].join(", ")}) {`, - kernel, - "}", + // A kernel that runs a list of rows, which needn't be consecutive, stamps each row's own block and chunk. + ...listed ? [] : [...unit.stampedPerCall].flatMap(([chunks, { blocks, when }]) => [ + `if (${when === null ? "" : `q${when} && `}start < end) for (let k = start >> ${blockShift}, last = (end - 1) >> ${blockShift}; k <= last; k++) c${blocks}[k] = tick;`, + `if (${when === null ? "" : `q${when} && `}start < end) for (let k = start >> ${chunkShift}, last = (end - 1) >> ${chunkShift}; k <= last; k++) c${chunks}[k] = tick;`, + ]), + ...specialized === null ? [loop(kernel)] : [ + `if (${presences.map(index => `q${index}`).join(" && ")}) {`, loop(specialized[0]), + `} else if (${presences.map(index => `!q${index}`).join(" && ")}) {`, loop(specialized[1]), + "} else {", loop(kernel), "}", + ], ...accumulators.map((local, k) => `partials[partial + ${k}] = ${localName(unit, local)};`), "};", "}", diff --git a/packages/ecs/src/implementation/compiler/compiler.ts b/packages/ecs/src/implementation/compiler/compiler.ts new file mode 100644 index 000000000..9e2f5b06f --- /dev/null +++ b/packages/ecs/src/implementation/compiler/compiler.ts @@ -0,0 +1,9 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +export type Compiler = never; + +/** + * The build's compiler: `Compiler.plugin()` in an application's Vite config, `Compiler.plugin({ library: true })` in a + * library's. + */ +export * as Compiler from './public.js'; diff --git a/packages/ecs/src/implementation/compiler/exact.ts b/packages/ecs/src/implementation/compiler/exact.ts deleted file mode 100644 index 8d449146d..000000000 --- a/packages/ecs/src/implementation/compiler/exact.ts +++ /dev/null @@ -1,78 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { Expression, ObjectExpression, Pattern } from 'estree'; -import { fail } from './failure.js'; -import type { Lists } from './lists.js'; -import type { ModuleScope } from './module-scope.js'; - -type Access = { readonly read: ReadonlySet; readonly write: ReadonlySet }; - -/** The keys a body reads, writes and contributes to, by module-scope binding name; null where the body doesn't show every use. */ -export type Used = { - readonly query: Access | null; - readonly resources: (Access & { readonly reduce: ReadonlySet }) | null; - /** The components read from each lookup's results, by lookup name: null for one whose results aren't followed. */ - readonly lookups: ReadonlyMap | null> | null; - /** The components read from `up`, when the body's uses are all known. */ - readonly up?: ReadonlySet; -}; - -/** An object literal's plain-named properties; none for anything else. */ -const fields = (node: Expression | Pattern | undefined): ReadonlyMap => new Map(node?.type !== "ObjectExpression" ? [] : - node.properties.flatMap((property): (readonly [string, Expression | Pattern])[] => - property.type === "Property" && !property.computed && property.key.type === "Identifier" ? [[property.key.name, property.value]] : [])); - -/** - * Requires a list to name only keys in `used`. A list `lists` can't read isn't checked, since its keys can't be matched - * to the body's. - */ -const requireUsed = (node: Expression | Pattern | undefined, used: ReadonlySet, lists: Lists, message: (name: string) => string): void => { - const names = node === undefined ? null : lists.names(node); - if (node === undefined || names === null || "error" in names) return; - // A key of an array literal is reported where it's listed, and one of a list declared elsewhere where the system names the list. - names.forEach((name, k) => { - const element = node.type === "ArrayExpression" ? node.elements[k] : null; - if (!used.has(name)) fail(element ?? node, message(name)); - }); -}; - -/** Requires `reduce` to name only resources in `contributed`, when it names each by a computed module-scope key. */ -const requireContributed = (reduce: Expression | Pattern | undefined, contributed: ReadonlySet, module: ModuleScope): void => { - if (reduce?.type !== "ObjectExpression") return; - const keys = reduce.properties.map(property => - property.type === "Property" && property.computed && property.key.type === "Identifier" && module.has(property.key.name) ? property.key : null); - for (const key of keys) if (key === null) return; - for (const key of keys) if (key !== null && !contributed.has(key.name)) fail(key, `${key.name} is in reduce but never contributed to; remove it`); -}; - -/** - * Requires a system's declared `read`, `write` and `reduce` to be exactly what its body uses: a key it never uses - * would still narrow which entities match, and hold back systems that could otherwise run alongside it. - */ -export const requireExact = (declaration: ObjectExpression, used: Used, module: ModuleScope, lists: Lists): void => { - const declared = fields(declaration); - const components = fields(declared.get("components")); - const resources = fields(declared.get("resources")); - if (used.query !== null) { - requireUsed(components.get("read"), used.query.read, lists, name => `${name} is in components read but never read; remove it, or move it to components has to keep requiring it`); - requireUsed(components.get("write"), used.query.write, lists, name => `${name} is in components write but never written; remove it, or move it to components read if it's only read`); - } - if (used.resources !== null) { - requireUsed(resources.get("read"), used.resources.read, lists, name => `${name} is in resources read but never read; remove it`); - requireUsed(resources.get("write"), used.resources.write, lists, name => `${name} is in resources write but never written; remove it, or move it to resources read if it's only read`); - requireContributed(declared.get("reduce"), used.resources.reduce, module); - } - if (used.up !== undefined) { - requireUsed(fields(declared.get("up")).get("read"), used.up, lists, - name => `${name} is in up.read but never read; remove it`); - } - if (used.lookups !== null) { - for (const [lookup, value] of fields(declared.get("lookup"))) { - const read = used.lookups.get(lookup); - if (read === null) continue; - const index = fields(value).has("index"); - requireUsed(fields(value).get("read"), read ?? new Set(), lists, name => index - ? `${name} is in lookup.${lookup} read but never read; remove it` - : `${name} is in lookup.${lookup} read but never read; remove it, or move it to lookup.${lookup} has to keep requiring it`); - } - } -}; diff --git a/packages/ecs/src/implementation/compiler/expression.ts b/packages/ecs/src/implementation/compiler/expression.ts index fc3852a7b..13d1900ce 100644 --- a/packages/ecs/src/implementation/compiler/expression.ts +++ b/packages/ecs/src/implementation/compiler/expression.ts @@ -11,6 +11,7 @@ import { memberKey } from './key.js'; import { moduleValue } from './module-value.js'; import type { Out } from './out.js'; import { scalar } from './scalar.js'; +import { presence } from './columns.js'; import { findLocal, rootScope, type Scope } from './scope.js'; import { fresh } from './unit.js'; import { scalarValue, type Value } from './value.js'; @@ -65,6 +66,21 @@ const literal = (node: Extract): Value => { return fail(node, "only number, boolean and null literals are in the kernel subset"); }; +/** + * `value === undefined` and its variants, `== null` included, for an optional component's whole value: whether the + * call's archetype lacks it, or has it. Null for anything else. + */ +const presenceTest = (operator: string, value: Value, other: Value, scope: Scope): Expression | null => { + if (!["===", "!==", "==", "!="].includes(operator) || other.kind !== "scalar" || other.expression.kind !== "nullish") return null; + if (other.expression.value === "null" && (operator === "===" || operator === "!==")) return null; + const { unit } = scope; + const present = value.kind === "snapshot" && value.path.length === 0 ? value.snapshot.presence + : value.kind === "component" && value.up !== true && value.path.length === 0 && unit.signature.optional.has(value.component) ? presence(unit, value.component) + : null; + if (present === null) return null; + return operator === "!==" || operator === "!=" ? present : not(present); +}; + /** Compiles an expression to a value, emitting any statements it needs into `out`. */ export const expression = (node: Node, scope: Scope, out: Out): Value => { switch (node.type) { @@ -96,8 +112,13 @@ export const expression = (node: Node, scope: Scope, out: Out): Value => { case "BinaryExpression": { const operator = node.operator; if (!isBinary(operator) || node.left.type === "PrivateIdentifier") return fail(node, `${node.operator} is not in the kernel subset`); - const left = scalar(expression(node.left, scope, out), node.left, scope); - const right = scalar(expression(node.right, scope, out), node.right, scope); + const leftValue = expression(node.left, scope, out); + const rightValue = expression(node.right, scope, out); + // An optional component tested for undefined is its archetype's presence, the same for every row of a call. + const tested = presenceTest(operator, leftValue, rightValue, scope) ?? presenceTest(operator, rightValue, leftValue, scope); + if (tested !== null) return scalarValue(tested); + const left = scalar(leftValue, node.left, scope); + const right = scalar(rightValue, node.right, scope); // Strict equality tells true from 1, so boolean reads are converted here. const strict = operator === "===" || operator === "!=="; return scalarValue(strict diff --git a/packages/ecs/src/implementation/compiler/hygiene.test.ts b/packages/ecs/src/implementation/compiler/hygiene.test.ts index 3a98f1c4b..d9426c1ad 100644 --- a/packages/ecs/src/implementation/compiler/hygiene.test.ts +++ b/packages/ecs/src/implementation/compiler/hygiene.test.ts @@ -1,6 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { expect, it } from "vitest"; -import { Component, System } from "../../index.js"; +import { System } from "../../index.js"; import { workers } from "../../interface/resources/workers.js"; import { createWorld } from "../index.js"; @@ -25,14 +25,6 @@ declare module "@adobe/ecs" { } } -const number = { type: { type: "number" } } as const; -Component.register(p, number); -Component.register(c, number); -Component.register(l, number); -Component.register(s, number); -Component.register(e, number); -Component.register(v0, number); -Component.register(v1, number); const spawn = System.create(({ commands }) => { const spawned = commands.spawn([p, c, l, s])({ [p]: 1, [c]: 2, [l]: 3, [s]: 4 }); @@ -42,13 +34,13 @@ const spawn = System.create(({ commands }) => { }); const read: number[][] = []; -const readAll = System.create({ observer: true, components: { read: [p, c, l, s, e, v0, v1] } }, row => { +const readAll = System.create({ observer: true, components: { with: [p, c, l, s, e, v0, v1] } }, row => { read.push([row[p], row[c], row[l], row[s], row[e], row[v0], row[v1]]); }); // A kernel whose module constant and locals are named as the kernel emitter's bindings. const i = 100; -const grow = System.create({ kernel: "cpu", components: { read: [c, l], write: [p, s] } }, row => { +const grow = System.create({ kernel: true, components: { with: [c, l, p, s] } }, row => { const n = row[c]; const x0 = row[l]; const s0 = n + x0; diff --git a/packages/ecs/src/implementation/compiler/identify.test.ts b/packages/ecs/src/implementation/compiler/identify.test.ts index ef71e9dbf..5a2cc9deb 100644 --- a/packages/ecs/src/implementation/compiler/identify.test.ts +++ b/packages/ecs/src/implementation/compiler/identify.test.ts @@ -3,14 +3,18 @@ import { describe, expect, test } from 'vitest'; import { parseAst } from 'vite'; import { idOfKey, takeIdOfDeclaration } from '../../interface/registry/ids.js'; import { estreeOf } from './estree.js'; -import { identify as identifyModule, moduleIdOf } from './identify.js'; -import { attachKernelPath } from './paths.js'; +import { identify as identifyModule, moduleIdOf, moduleLabelOf } from './identify.js'; +import { ownCopy } from './paths.js'; import { decodeMappings } from './source-map.js'; const parse = (code: string) => estreeOf(parseAst(code)); /** Imports from the package, and from `./system.js` for `System` alone, are the package's namespaces. */ const resolve = async (source: string, namespace: string) => source === "@adobe/ecs" || (source === "./system.js" && namespace === "System"); -const identify = (code: string) => identifyModule(code, "src/keys.ts", parse, resolve); +const identify = async (code: string) => { + const result = await identifyModule(code, "src/keys.ts", parse, resolve); + if (result !== null && "failure" in result) throw new Error(result.failure); + return result; +}; /** The calls the rewrite wraps around each identified `Symbol(...)`, as `[symbol source, id]`. */ const wrapped = (code: string | undefined): readonly (readonly [string, string])[] => @@ -82,75 +86,124 @@ describe("identify, for systems, rules and finalizers", () => { test("gives the declaration of each top-level binding's constructor call its module's id and binding", async () => { const result = await identify([ `import { System as S, Rule, Finalizer, Index } from "@adobe/ecs";`, - `const move = S.create({ name: "move" }, () => {});`, - `export const grid = Index.create({ name: "grid" });`, - `export const draw = S.render({ name: "draw" }, () => {}), main = S.create({ name: "main", systems: [move] });`, + `const move = S.create({ tag: "move" }, () => {});`, + `export const grid = Index.create({ tag: "grid" });`, + `export const draw = S.render({ tag: "draw" }, () => {}), main = S.create({ tag: "main", systems: [move] });`, `const all = S.create([move]);`, - `let shown = Rule.create({ name: "shown" });`, - `var release = Finalizer.create({ name: "release" }, () => {});`, - `export default Finalizer.changes({ name: "save" }, () => {});`, + `let shown = Rule.create({ tag: "shown" });`, + `var release = Finalizer.create({ tag: "release" }, () => {});`, + `export default Finalizer.changes({ tag: "save" }, () => {});`, ].join("\n")); expect(declared(result?.code)).toEqual([ - [`{ name: "move" }`, "src/keys.ts#move"], [`{ name: "grid" }`, "src/keys.ts#grid"], [`{ name: "draw" }`, "src/keys.ts#draw"], [`{ name: "main", systems: [move] }`, "src/keys.ts#main"], - [`{ name: "shown" }`, "src/keys.ts#shown"], [`{ name: "release" }`, "src/keys.ts#release"], [`{ name: "save" }`, "src/keys.ts#default"], + [`{ tag: "move" }`, "src/keys.ts#move"], [`{ tag: "grid" }`, "src/keys.ts#grid"], [`{ tag: "draw" }`, "src/keys.ts#draw"], [`{ tag: "main", systems: [move] }`, "src/keys.ts#main"], + [`{ tag: "shown" }`, "src/keys.ts#shown"], [`{ tag: "release" }`, "src/keys.ts#release"], [`{ tag: "save" }`, "src/keys.ts#default"], ]); // A composite's list of members takes its id as a declaration does. expect(result?.code).toContain(`identify$([move], "src/keys.ts#all")`); }); test("records each id where the registry takes it, passing the same declaration", async () => { - const result = await identify(`import { System } from "@adobe/ecs";\nconst move = System.create({ name: "move" });`); + const result = await identify(`import { System } from "@adobe/ecs";\nconst move = System.create({ tag: "move" });`); const evaluated = (result?.code ?? "").replace(/^import .*$/m, "const System = { create: (declaration) => declaration };"); const declaration: unknown = new Function(`${evaluated}\nreturn move;`)(); - expect(typeof declaration === "object" && declaration !== null ? [Reflect.get(declaration, "name"), takeIdOfDeclaration(declaration), takeIdOfDeclaration(declaration)] : declaration).toEqual(["move", "src/keys.ts#move", undefined]); + expect(typeof declaration === "object" && declaration !== null ? [Reflect.get(declaration, "tag"), takeIdOfDeclaration(declaration), takeIdOfDeclaration(declaration)] : declaration).toEqual(["move", "src/keys.ts#move", undefined]); }); test("identifies a kernel's call the kernel transform wrapped", async () => { - const result = await identify(`import { attachKernel as attachKernel$ } from ${JSON.stringify(attachKernelPath)};\nimport { System } from "@adobe/ecs";\nconst grow = attachKernel$(System.create({ name: "grow", kernel: "cpu" }, () => {}), {});`); - expect(declared(result?.code)).toEqual([[`{ name: "grow", kernel: "cpu" }`, "src/keys.ts#grow"]]); + const result = await identify(`import { attachKernel as attachKernel$ } from ${JSON.stringify(ownCopy.attachKernel)};\nimport { System } from "@adobe/ecs";\nconst grow = attachKernel$(System.create({ tag: "grow", kernel: true }, () => {}), {});`); + expect(declared(result?.code)).toEqual([[`{ tag: "grow", kernel: true }`, "src/keys.ts#grow"]]); }); test("follows the namespace to the module that exports it, under any local name", async () => { - const result = await identify(`import { System as Systems, Rule } from "./system.js";\nconst a = Systems.create({ name: "a" });\nconst b = Rule.create({ name: "b" });`); - expect(declared(result?.code)).toEqual([[`{ name: "a" }`, "src/keys.ts#a"]]); + const result = await identify(`import { System as Systems, Rule } from "./system.js";\nconst a = Systems.create({ tag: "a" });\nconst b = Rule.create({});`); + expect(declared(result?.code)).toEqual([[`{ tag: "a" }`, "src/keys.ts#a"]]); }); - test("leaves calls that aren't a top-level binding's, other namespaces and other methods", async () => { - expect(await identify([ + test("labels a call that isn't a top-level binding's by the binding, function, property or method it's in, and leaves other namespaces and methods", async () => { + const result = await identify([ `import { System, Component } from "@adobe/ecs";`, `import { System as Other } from "elsewhere";`, - `function make() { const inner = System.create({ name: "inner" }); return inner; }`, - `System.create({ name: "statement" });`, - `const wrapped = [System.create({ name: "wrapped" })];`, - `const other = Other.create({ name: "other" });`, - `const registered = Component.register({ name: "registered" });`, - `const spread = System.create(...[{ name: "spread" }]);`, - `const computed = System["create"]({ name: "computed" });`, - ].join("\n"))).toBe(null); + `function make() { return System.create({ tag: "make" }); }`, + `const mover = (speed) => { const inner = System.create({ tag: "inner" }); return inner; };`, + `const systems = { move: System.create({ tag: "move" }) };`, + `System.create({ tag: "alone" });`, + `const wrapped = [System.create({ tag: "wrapped" })];`, + `const other = Other.create({ tag: "other" });`, + `const registered = Component.register({ tag: "registered" });`, + `const spread = System.create(...[{ tag: "spread" }]);`, + `const computed = System["create"]({ tag: "computed" });`, + ].join("\n")); + expect([...(result?.code ?? "").matchAll(/label\$\((\{[^}]*\}), "(\w+)"\)/g)].map(match => [match[1], match[2]])).toEqual([ + [`{ tag: "make" }`, "make"], [`{ tag: "inner" }`, "inner"], [`{ tag: "move" }`, "move"], [`{ tag: "wrapped" }`, "wrapped"], + ]); + expect(result?.code).not.toContain("identify$"); + }); + test("labels a call made in another's arguments by the name nearest it", async () => { + const result = await identify(`import { System } from "@adobe/ecs";\nfunction make() { return System.create({ systems: [System.create({ tag: "nested" })] }); }`); + expect([...(result?.code ?? "").matchAll(/label\$\((\{ tag[^}]*\}), "(\w+)"\)/g)].map(match => [match[1], match[2]])).toEqual([[`{ tag: "nested" }`, "systems"]]); + }); + test("keeps a label already given, as an internal factory gives its own, in a map holding declarations weakly", async () => { + const result = await identify(`import { System } from "@adobe/ecs";\nfunction make(given) { return System.create(given); }\nfunction fresh() { return System.create({}); }`); + const evaluated = (result?.code ?? "").replace(/^import .*$/m, "const System = { create: (declaration) => declaration };"); + const declaration = {}; + const labels = new WeakMap([[declaration, "given"]]); + Reflect.set(globalThis, Symbol.for("@adobe/ecs/labels@1"), labels); + const [make] = new Function(`${evaluated}\nreturn [make, fresh];`)(); + make(declaration); + expect(labels.get(declaration)).toBe("given"); + // A declaration whose constructor throws before taking its label isn't kept alive by it. + Reflect.deleteProperty(globalThis, Symbol.for("@adobe/ecs/labels@1")); + new Function(`${evaluated}\nreturn fresh;`)()(); + expect(Reflect.get(globalThis, Symbol.for("@adobe/ecs/labels@1"))).toBeInstanceOf(WeakMap); + }); + test("fails for a module outside a named package, which has nothing to name its ids by", async () => { + const result = await identifyModule(`export const position = Symbol();`, null, parse, resolve); + expect(result !== null && "failure" in result ? result.failure : result).toContain("package.json"); + expect(await identifyModule(`export const two = 2;`, null, parse, resolve)).toBe(null); + }); +}); + +describe("moduleLabelOf", () => { + test("is the module's package and path in it, else a dependency's package and subpath, else its file's name", () => { + const app = (directory: string) => directory === "/repo/app" ? { name: "@scope/app", version: "1.0.0" } : undefined; + expect(moduleLabelOf("/repo/app/src/features/a/model.ts?t=1", app)).toBe("@scope/app/src/features/a/model.ts"); + expect(moduleLabelOf("/x/node_modules/keys/index.js", () => undefined)).toBe("keys/index.js"); + expect(moduleLabelOf("/loose/model.ts", () => undefined)).toBe("model.ts"); }); }); describe("moduleIdOf", () => { - /** Packages by directory, as their package.json files name them. */ - const packages = (named: { readonly [directory: string]: string | null }) => (directory: string) => { - const name = named[directory]; - return name === undefined ? undefined : name; + /** Packages by directory, as their package.json files name and version them. */ + const packages = (named: { readonly [directory: string]: { readonly name: string; readonly version?: string } | null }) => (directory: string) => { + const found = named[directory]; + return found === undefined ? undefined : found; }; const none = packages({}); - test("is the nearest package's name and the path in it, without a query", async () => { - const app = packages({ "/app": "app", "/repo/packages/shared": "@scope/shared", "/repo/packages/shared/src/nested": null }); - expect(moduleIdOf("/app/src/keys.ts?t=1712", "/app", app)).toBe("app/src/keys.ts"); - expect(moduleIdOf("/app/src/worker.ts?worker_file&type=module", "/app/", app)).toBe("app/src/worker.ts"); + test("is the nearest package's name, its version and the path in it, without a query", async () => { + const app = packages({ "/app": { name: "app", version: "1.2.0" }, "/repo/packages/shared": { name: "@scope/shared", version: "0.3.1" }, "/repo/packages/shared/src/nested": null }); + expect(moduleIdOf("/app/src/keys.ts?t=1712", app)).toBe("app@1.2.0/src/keys.ts"); + expect(moduleIdOf("/app/src/worker.ts?worker_file&type=module", app)).toBe("app@1.2.0/src/worker.ts"); // A workspace package linked from outside the root is named by its package, not its directories. - expect(moduleIdOf("/repo/packages/shared/src/nested/keys.ts", "/repo/apps/web", app)).toBe("@scope/shared/src/nested/keys.ts"); + expect(moduleIdOf("/repo/packages/shared/src/nested/keys.ts", app)).toBe("@scope/shared@0.3.1/src/nested/keys.ts"); }); - test("is a dependency's package name and subpath", async () => { - const scoped = packages({ "/app/node_modules/.pnpm/@scope+keys@1.2.0/node_modules/@scope/keys": "@scope/keys" }); - expect(moduleIdOf("/app/node_modules/.pnpm/@scope+keys@1.2.0/node_modules/@scope/keys/dist/keys.js?v=3f2a", "/app", scoped)).toBe("@scope/keys/dist/keys.js"); - expect(moduleIdOf("/elsewhere/node_modules/keys/index.js", "/app", none)).toBe("keys/index.js"); + test("leaves out a version the package doesn't give", async () => { + expect(moduleIdOf("/app/src/keys.ts", packages({ "/app": { name: "app" } }))).toBe("app/src/keys.ts"); }); - test("is the path from the root without a named package", async () => { - expect(moduleIdOf("/app/src/keys.ts?t=1712", "/app", none)).toBe("src/keys.ts"); - expect(moduleIdOf("/libraries/shared/keys.ts?import", "/app", none)).toBe("../libraries/shared/keys.ts"); + test("is a dependency's package name, version and subpath", async () => { + const scoped = packages({ "/app/node_modules/.pnpm/@scope+keys@1.2.0/node_modules/@scope/keys": { name: "@scope/keys", version: "1.2.0" } }); + expect(moduleIdOf("/app/node_modules/.pnpm/@scope+keys@1.2.0/node_modules/@scope/keys/dist/keys.js?v=3f2a", scoped)).toBe("@scope/keys@1.2.0/dist/keys.js"); + expect(moduleIdOf("/elsewhere/node_modules/keys/index.js", none)).toBe("keys/index.js"); + }); + test("is null outside a named package", async () => { + expect(moduleIdOf("/app/src/keys.ts?t=1712", none)).toBe(null); + expect(moduleIdOf("/libraries/shared/keys.ts", packages({ "/libraries": null }))).toBe(null); + }); + test("stops at a drive's root, never reading the working directory's package", async () => { + const read: string[] = []; + const cwd = (directory: string) => { + read.push(directory); + return directory === "." ? { name: "cwd", version: "1.0.0" } : undefined; + }; + expect(moduleIdOf("C:\\elsewhere\\keys.ts", cwd)).toBe(null); + expect(read).toEqual(["C:/elsewhere", "C:"]); }); test("uses forward slashes", async () => { - expect(moduleIdOf("C:\\app\\src\\keys.ts", "C:\\app", none)).toBe("src/keys.ts"); - expect(moduleIdOf("C:\\app\\src\\keys.ts", "C:\\app", packages({ "C:/app": "app" }))).toBe("app/src/keys.ts"); + expect(moduleIdOf("C:\\app\\src\\keys.ts", packages({ "C:/app": { name: "app", version: "1.0.0" } }))).toBe("app@1.0.0/src/keys.ts"); }); }); diff --git a/packages/ecs/src/implementation/compiler/identify.ts b/packages/ecs/src/implementation/compiler/identify.ts index a2e205892..985805cf5 100644 --- a/packages/ecs/src/implementation/compiler/identify.ts +++ b/packages/ecs/src/implementation/compiler/identify.ts @@ -2,10 +2,10 @@ /// import type { Expression, Node, Program } from 'estree'; import { posix } from 'node:path'; -import { idsName } from '../../interface/registry/ids.js'; +import { idsName, labelsName } from '../../interface/registry/ids.js'; import { children, isFunction } from './estree.js'; import { moduleScope } from './module-scope.js'; -import { attachKernelPath } from './paths.js'; +import { isAttachKernelModule } from './paths.js'; import { insertInto, type Insertion, type SourceMap } from './source-map.js'; export type Parse = (code: string) => Program; @@ -16,25 +16,40 @@ export type Identified = { readonly code: string; readonly map: SourceMap }; /** A dependency's path inside the last `node_modules`: its package name and subpath. */ const dependency = /\/node_modules\/(?!.*\/node_modules\/)(.+)$/; -/** The name a directory's package.json gives: undefined without one, null for one without a name. */ -export type PackageNameOf = (directory: string) => string | null | undefined; +/** What a directory's package.json gives: undefined without one, null for one without a name. */ +export type PackageOf = (directory: string) => { readonly name: string; readonly version?: string } | null | undefined; -/** - * A module's id: the name of the nearest package.json above it that has one and its path in that package, else for a - * dependency its package name and subpath, else its path from `root`. Without a query and with forward slashes, so it's - * the same on every machine and names no directory outside its package. - */ -export const moduleIdOf = (file: string, root: string, packageNameOf: PackageNameOf): string => { - const path = (file.split("?")[0] ?? file).replace(/\\/g, "/"); +/** A module's file without a query, with forward slashes. */ +const pathOf = (file: string): string => (file.split("?")[0] ?? file).replace(/\\/g, "/"); + +/** The nearest package.json above a module that has a name, with the module's path in it. */ +const packageAbove = (file: string, packageOf: PackageOf): { readonly name: string; readonly version?: string; readonly path: string } | null => { + const path = pathOf(file); for (let directory = posix.dirname(path); ; directory = posix.dirname(directory)) { - const name = packageNameOf(directory); - if (typeof name === "string") return `${name}/${posix.relative(directory, path)}`; - if (posix.dirname(directory) === directory) break; + const found = packageOf(directory); + if (found !== null && found !== undefined) return { ...found, path: posix.relative(directory, path) }; + // A filesystem's root, `/` or a drive's `C:`, whose parent posix paths give as itself or as `.`. + const parent = posix.dirname(directory); + if (parent === directory || parent === ".") return null; } - const found = dependency.exec(path)?.[1]; - if (found !== undefined) return found; - const relative = posix.relative(root.replace(/\\/g, "/"), path); - return posix.isAbsolute(relative) ? posix.basename(path) : relative; +}; + +/** + * A module's id: the name and version of the nearest package.json above it that has a name, and its path in that + * package, else for a dependency its package name and subpath; null outside both. Without a query and with forward + * slashes, so it's the same on every machine and names no directory outside its package. Copies of one package at + * different versions have different ids. + */ +export const moduleIdOf = (file: string, packageOf: PackageOf): string | null => { + const found = packageAbove(file, packageOf); + if (found !== null) return `${found.name}${found.version === undefined ? "" : `@${found.version}`}/${found.path}`; + return dependency.exec(pathOf(file))?.[1] ?? null; +}; + +/** How messages name a module: its package's name and its path there, as its id has them, else the file's name. */ +export const moduleLabelOf = (file: string, packageOf: PackageOf): string => { + const found = packageAbove(file, packageOf); + return found !== null ? `${found.name}/${found.path}` : dependency.exec(pathOf(file))?.[1] ?? posix.basename(pathOf(file)); }; /** Whether a binding named `name` is declared in the module's scope: at top level, or as a `var` in any of its blocks. */ @@ -76,7 +91,7 @@ const localsOf = async (program: Program, resolve: ResolveNamespace): Promise<{ for (const specifier of statement.specifiers) { if (specifier.type !== "ImportSpecifier" || specifier.imported.type !== "Identifier") continue; const imported = specifier.imported.name; - if (statement.source.value === attachKernelPath && imported === "attachKernel") attach.add(specifier.local.name); + if (imported === "attachKernel" && isAttachKernelModule(statement.source.value)) attach.add(specifier.local.name); else if (isNamespace(imported) && await resolve(statement.source.value, imported)) namespaces.set(specifier.local.name, imported); } } @@ -120,9 +135,10 @@ const mayDeclare = /\.(create|render|changes)\(/; * * The same bindings initialized by a constructor call of the package's `System`, `Rule`, `Finalizer` or `Index`, imported under * any local name, give the call's declaration their id, which the constructor takes from the map: a kernel's call as - * the kernel transform wrapped it too. Returns null when nothing is identified. + * the kernel transform wrapped it too. Returns null when nothing is identified, and a failure when something is but the + * module has no id (`moduleIdOf`). */ -export const identify = async (code: string, id: string, parse: Parse, resolve: ResolveNamespace): Promise => { +export const identify = async (code: string, id: string | null, parse: Parse, resolve: ResolveNamespace): Promise => { const symbols = code.includes("Symbol("); const items = mayDeclare.test(code); if (!symbols && !items) return null; @@ -144,13 +160,54 @@ export const identify = async (code: string, id: string, parse: Parse, resolve: consider(statement.declaration, "default"); } } - if (found.length === 0 || declaresInModule(program, "Symbol") || declaresInModule(program, "globalThis")) return null; - const identifier = uniqueName(code, "identify$"); + const labeled = namespaces.size > 0 ? labelsOf(program, namespaces, attach, new Set(found.map(({ value }) => value))) : []; + if ((found.length === 0 && labeled.length === 0) || declaresInModule(program, "Symbol") || declaresInModule(program, "globalThis")) return null; const insertions: Insertion[] = []; - for (const { value, binding } of found) { - insertions.push({ at: value.start, text: `${identifier}(` }, { at: value.end, text: `, ${JSON.stringify(`${id}#${binding}`)})` }); + if (found.length > 0) { + if (id === null) return { failure: "this module's keys and systems need ids, which name its package, but no package.json above it gives a name: give its package one" }; + const identifier = uniqueName(code, "identify$"); + for (const { value, binding } of found) { + insertions.push({ at: value.start, text: `${identifier}(` }, { at: value.end, text: `, ${JSON.stringify(`${id}#${binding}`)})` }); + } + const ids = JSON.stringify(idsName); + insertions.push({ at: code.length, text: `\nfunction ${identifier}(value, id) { const ids = Symbol.for(${ids}); (globalThis[ids] || (globalThis[ids] = new Map())).set(value, id); return value; }\n` }); } - const ids = JSON.stringify(idsName); - insertions.push({ at: code.length, text: `\nfunction ${identifier}(value, id) { const ids = Symbol.for(${ids}); (globalThis[ids] || (globalThis[ids] = new Map())).set(value, id); return value; }\n` }); - return insertInto(code, insertions, id); + if (labeled.length > 0) { + const identifier = uniqueName(code, "label$"); + for (const { value, label } of labeled) insertions.push({ at: value.start, text: `${identifier}(` }, { at: value.end, text: `, ${JSON.stringify(label)})` }); + const labels = JSON.stringify(labelsName); + // A label already given, as an internal factory gives its own, stands. + insertions.push({ at: code.length, text: `\nfunction ${identifier}(value, label) { const labels = Symbol.for(${labels}); const map = globalThis[labels] || (globalThis[labels] = new WeakMap()); if (!map.has(value)) map.set(value, label); return value; }\n` }); + } + return insertInto(code, insertions, id ?? ""); +}; + +/** The name a node gives what's declared in it: a variable's, a function's, a property's or a method's. */ +const nameIn = (node: Node): string | null => { + switch (node.type) { + case "VariableDeclarator": return node.id.type === "Identifier" ? node.id.name : null; + case "FunctionDeclaration": case "FunctionExpression": return node.id?.name ?? null; + case "Property": case "MethodDefinition": case "PropertyDefinition": return !node.computed && node.key.type === "Identifier" ? node.key.name : null; + case "AssignmentExpression": return node.left.type === "Identifier" ? node.left.name : null; + default: return null; + } +}; + +/** + * The declarations of constructor calls that `identified` leaves out, each with a label: the name of the nearest + * variable, function, property or method it's made in, as `mover` for one made in `const mover = (speed) => ...`. + */ +const labelsOf = (program: Program, namespaces: ReadonlyMap, attach: ReadonlySet, identified: ReadonlySet): readonly { readonly value: Expression; readonly label: string }[] => { + const labeled: { readonly value: Expression; readonly label: string }[] = []; + const visit = (node: Node, names: readonly string[]): void => { + const declaration = declarationOf(node, namespaces, attach); + const label = names.at(-1); + // Calls in its arguments are labeled too, by the names nearest them. + if (declaration !== undefined && !identified.has(declaration) && label !== undefined) labeled.push({ value: declaration, label }); + const name = nameIn(node); + const inner = name === null ? names : [...names, name]; + for (const child of children(node)) visit(child, inner); + }; + visit(program, []); + return labeled; }; diff --git a/packages/ecs/src/implementation/compiler/index.ts b/packages/ecs/src/implementation/compiler/index.ts index ce6096c04..2c4ff5a5f 100644 --- a/packages/ecs/src/implementation/compiler/index.ts +++ b/packages/ecs/src/implementation/compiler/index.ts @@ -1,2 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -export * from './plugin.js'; +/** + * The build plugin, which every application and library using the ECS builds with. + * @module @adobe/ecs/compiler + */ +export { Compiler } from './compiler.js'; diff --git a/packages/ecs/src/implementation/compiler/inferred.test.ts b/packages/ecs/src/implementation/compiler/inferred.test.ts new file mode 100644 index 000000000..cba605678 --- /dev/null +++ b/packages/ecs/src/implementation/compiler/inferred.test.ts @@ -0,0 +1,32 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { describe, expect, test } from "vitest"; +import { parseAst } from "vite"; +import type { ObjectExpression } from "estree"; +import { estreeOf } from "./estree.js"; +import { resolvedDeclaration } from "./inferred.js"; + +/** The declaration written as `source`, an object literal. */ +const declarationOf = (source: string): ObjectExpression => { + const statement = estreeOf(parseAst(`(${source});`)).body[0]; + if (statement?.type !== "ExpressionStatement" || statement.expression.type !== "ObjectExpression") throw new Error("not an object literal"); + return statement.expression; +}; + +/** The names a resolved declaration's lookup `name` reads, or null without a `read` list. */ +const readOf = (declaration: ObjectExpression, name: string): readonly string[] | null => { + const field = (object: ObjectExpression, key: string) => object.properties.find(property => + property.type === "Property" && property.key.type === "Identifier" && property.key.name === key); + const lookup = field(declaration, "lookup"); + const entry = lookup?.type === "Property" && lookup.value.type === "ObjectExpression" ? field(lookup.value, name) : undefined; + const read = entry?.type === "Property" && entry.value.type === "ObjectExpression" ? field(entry.value, "read") : undefined; + if (read?.type !== "Property" || read.value.type !== "ArrayExpression") return null; + return read.value.elements.map(element => element?.type === "Identifier" ? element.name : "?"); +}; + +describe("resolvedDeclaration", () => { + test("takes a lookup by entity whose rows the build didn't follow as reading all it requires", () => { + const declaration = declarationOf("{ lookup: { targets: { with: [mass, position] } } }"); + const resolved = resolvedDeclaration(declaration, { query: null, resources: null, lookups: new Map([["targets", null]]), up: null }); + expect(readOf(resolved, "targets")).toEqual(["mass", "position"]); + }); +}); diff --git a/packages/ecs/src/implementation/compiler/inferred.ts b/packages/ecs/src/implementation/compiler/inferred.ts new file mode 100644 index 000000000..f84ce406f --- /dev/null +++ b/packages/ecs/src/implementation/compiler/inferred.ts @@ -0,0 +1,195 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { ArrayExpression, Expression, Identifier, MemberExpression, ObjectExpression, Pattern, Property } from 'estree'; +import type { Used } from './body-access.js'; + +/** An object literal's plain-named properties; none for anything else. */ +const fields = (node: Expression | Pattern | undefined): ReadonlyMap => new Map(node?.type !== "ObjectExpression" ? [] : + node.properties.flatMap((property): (readonly [string, Property])[] => + property.type === "Property" && !property.computed && property.key.type === "Identifier" ? [[property.key.name, property]] : [])); + +/** + * Whether the build saw every use the body makes of its row, resources and parent, and of its lookups through an + * index: a kernel compiles from them. A lookup by entity whose rows it didn't follow reads all of its `with`. + */ +export const followsAll = (used: Used, declaration: ObjectExpression): boolean => { + if (used.query === null || used.resources === null || used.up === null) return false; + const lookups = fields(valueOf(fields(declaration).get("lookup"))); + if (used.lookups === null) return lookups.size === 0; + return [...used.lookups].every(([name, read]) => read !== null || !fields(valueOf(lookups.get(name))).has("index")); +}; + +/** `names` in the order `first` lists them, then the rest in the order the body first used them. */ +const ordered = (names: ReadonlySet, first: readonly string[]): ReadonlySet => + new Set([...first.filter(name => names.has(name)), ...names]); + +/** + * What a body uses, its row's and each lookup's keys in the order their `with` lists them, so the lists the world + * takes follow the declaration as written. + */ +export const inOrder = (used: Used, declaration: ObjectExpression): Used => { + const declared = fields(declaration); + const components = fields(valueOf(declared.get("components"))); + const members = [...namesIn(valueOf(components.get("with"))), ...namesIn(valueOf(components.get("optional")))]; + const lookups = fields(valueOf(declared.get("lookup"))); + return { + ...used, + query: used.query === null ? null : { read: ordered(used.query.read, members), write: ordered(used.query.write, members), mutate: ordered(used.query.mutate, members) }, + lookups: used.lookups === null ? null : new Map([...used.lookups].map(([name, read]) => + [name, read === null ? null : ordered(read, namesIn(valueOf(fields(valueOf(lookups.get(name))).get("with"))))])), + }; +}; + +/** + * The first key a body uses of its row, or of a lookup's rows by entity, that its declaration doesn't require, and + * where it's required: types reject it too, but a body the build reads may have none. Null when every one is + * required, or a list isn't an array literal the build can read. + */ +export const unrequired = (used: Used, declaration: ObjectExpression): { readonly key: string; readonly list: string } | null => { + const declared = fields(declaration); + const components = fields(valueOf(declared.get("components"))); + const literal = (names: readonly (Expression | undefined)[]): readonly string[] | null => { + const lists = names.filter(node => node !== undefined); + return lists.every(node => node.type === "ArrayExpression" && node.elements.every(element => element?.type === "Identifier" || element?.type === "MemberExpression")) + ? lists.flatMap(node => namesIn(node)) : null; + }; + const members = literal(["with", "optional", "added", "changed"].map(name => valueOf(components.get(name)))); + if (used.query !== null && members !== null) { + const key = [...used.query.read, ...used.query.write].find(name => !members.includes(name)); + if (key !== undefined) return { key, list: "components with or optional" }; + } + if (used.lookups !== null) { + for (const [name, lookup] of fields(valueOf(declared.get("lookup")))) { + const parts = fields(valueOf(lookup)); + const read = used.lookups.get(name); + if (parts.has("index") || read === null || read === undefined) continue; + const required = literal([valueOf(parts.get("with")), valueOf(parts.get("optional")), valueOf(parts.get("changed"))]); + const key = required === null ? undefined : [...read].find(key => !required.includes(key)); + if (key !== undefined) return { key, list: `lookup.${name}.with or optional` }; + } + } + return null; +}; + +const list = (names: Iterable): string => `[${[...names].join(", ")}]`; + +/** + * The source of what the build inferred a body uses, `Access` in `interface/system/resolve.ts`: its keys as the + * module-scope bindings the body indexes by, null for a group it couldn't follow, and `at` where the body is. + */ +export const accessText = (used: Used, at: string): string => { + const { query, resources, lookups, up } = used; + // Keys changed inside their values are listed only when there are some, for registration to judge by kind. + const mutate = (keys: ReadonlySet): string => keys.size === 0 ? "" : `, mutate: ${list(keys)}`; + const components = query === null ? "null" : `{ read: ${list([...query.read].filter(key => !query.write.has(key)))}, write: ${list(query.write)}${mutate(query.mutate)} }`; + const resourceText = resources === null ? "null" : `{ read: ${list(resources.read)}, write: ${list(resources.write)}, reduce: ${list(resources.reduce)}${mutate(resources.mutate)} }`; + const lookupText = lookups === null ? "null" : `{ ${[...lookups].map(([name, read]) => `${JSON.stringify(name)}: ${read === null ? "null" : list(read)}`).join(", ")} }`; + return `{ components: ${components}, resources: ${resourceText}, lookup: ${lookupText}, up: ${up === null ? "null" : list(up)}, at: ${JSON.stringify(at)} }`; +}; + +/** A declaration's parts the build makes, located at `at`, so a failure in them points at the declaration. */ +const made = (at: { readonly start: number; readonly end: number }) => { + const identifier = (name: string): Identifier => ({ type: "Identifier", name, start: at.start, end: at.end }); + /** A key as the body named it: a binding, or a namespace's member. */ + const key = (name: string): Identifier | MemberExpression => { + const [object, member] = name.split("."); + return member === undefined || object === undefined ? identifier(name) : { + type: "MemberExpression", object: identifier(object), property: identifier(member), computed: false, optional: false, start: at.start, end: at.end, + }; + }; + const array = (names: Iterable): ArrayExpression => ({ type: "ArrayExpression", elements: [...names].map(key), start: at.start, end: at.end }); + const property = (name: string, value: Expression): Property => ({ + type: "Property", key: identifier(name), value, kind: "init", computed: false, method: false, shorthand: false, start: at.start, end: at.end, + }); + const object = (properties: readonly Property[]): ObjectExpression => ({ type: "ObjectExpression", properties: [...properties], start: at.start, end: at.end }); + return { identifier, array, property, object }; +}; + +/** `node`'s value when it's a property whose value is an expression. */ +const valueOf = (property: Property | undefined): Expression | undefined => + property !== undefined && property.value.type !== "ObjectPattern" && property.value.type !== "ArrayPattern" && property.value.type !== "RestElement" && property.value.type !== "AssignmentPattern" + ? property.value : undefined; + +/** The names an array literal of keys lists: bindings, or namespaces' members (`Report.run`). */ +const namesIn = (node: Expression | undefined): readonly string[] => node?.type !== "ArrayExpression" ? [] : + node.elements.flatMap(element => element?.type === "Identifier" ? [element.name] + : element?.type === "MemberExpression" && !element.computed && element.object.type === "Identifier" && element.property.type === "Identifier" ? [`${element.object.name}.${element.property.name}`] + : []); + +/** + * A kernel's declaration as the compiler reads it, from the declaration written and what the build inferred its body + * uses: `components` with `read` and `write` from the body beside its `with`, `resources` with `read` and `reduce`, + * each lookup with `read`, and `up` with `read`. `used` follows every use (`followsAll`). + */ +export const resolvedDeclaration = (declaration: ObjectExpression, used: Used): ObjectExpression => { + const { array, property, object } = made(declaration); + const keep = (from: ReadonlyMap, names: readonly string[]): Property[] => + names.flatMap(name => from.get(name) ?? []); + const properties: Property[] = []; + let resourcesDeclared = false; + for (const original of declaration.properties) { + if (original.type !== "Property" || original.computed || original.key.type !== "Identifier") { + properties.push(...original.type === "Property" ? [original] : []); + continue; + } + const value = valueOf(original); + const parts = fields(value); + switch (original.key.name) { + case "components": { + if (value?.type !== "ObjectExpression" || used.query === null) break; + const { read, write, mutate } = used.query; + // A change inside a value is a write to the compiler, which rejects it for values written whole. + const written = new Set([...write, ...mutate]); + properties.push(property("components", object([ + property("read", array([...read].filter(key => !written.has(key)))), + property("write", array(written)), + ...keep(parts, ["with", "optional", "not", "added", "changed"]), + ]))); + continue; + } + case "resources": { + if (value?.type !== "ObjectExpression" || used.resources === null) break; + resourcesDeclared = true; + properties.push(property("resources", resourcesOf(used.resources, keep(parts, ["is", "not", "changed"]), namesIn(valueOf(parts.get("is"))), array, property, object))); + continue; + } + case "lookup": { + if (value?.type !== "ObjectExpression" || used.lookups === null) break; + properties.push(property("lookup", object([...parts].map(([name, lookup]) => { + const lookupParts = fields(valueOf(lookup)); + // A lookup the body never uses reads nothing; null, one whose rows it didn't follow. + const found = used.lookups?.get(name); + const reads = found === undefined ? [] : found; + if (lookupParts.has("index")) { + // Rows the build didn't follow read everything the index holds, as the world resolves it. + const read = reads === null ? [] : [property("read", array(reads))]; + return property(name, object([...read, ...keep(lookupParts, ["index", "changed"])])); + } + const withList = valueOf(lookupParts.get("with")); + // Rows the build didn't follow read everything the lookup requires. + const read = reads === null ? withList ?? array([]) : array(reads); + return property(name, object([property("read", read), ...keep(lookupParts, ["with", "optional", "not", "changed"])])); + })))); + continue; + } + case "up": { + if (value?.type !== "ObjectExpression" || used.up === null) break; + properties.push(property("up", object([...keep(parts, ["through"]), property("read", array(used.up))]))); + continue; + } + } + properties.push(original); + } + if (!resourcesDeclared && used.resources !== null) properties.push(property("resources", resourcesOf(used.resources, [], [], array, property, object))); + return object(properties); +}; + +/** A kernel's `resources`: what its body reads, but conditions it reads already, and reduces into; a write is refused by the compiler. */ +const resourcesOf = ( + resources: NonNullable, declared: readonly Property[], is: readonly string[], + array: (names: Iterable) => ArrayExpression, property: (name: string, value: Expression) => Property, object: (properties: readonly Property[]) => ObjectExpression, +): ObjectExpression => object([ + property("read", array([...resources.read].filter(key => !resources.write.has(key) && !is.includes(key)))), + ...resources.write.size === 0 ? [] : [property("write", array(resources.write))], + ...resources.reduce.size === 0 ? [] : [property("reduce", array(resources.reduce))], + ...declared, +]); diff --git a/packages/ecs/src/implementation/compiler/input-schemas.test.ts b/packages/ecs/src/implementation/compiler/input-schemas.test.ts new file mode 100644 index 000000000..fcf37b3f8 --- /dev/null +++ b/packages/ecs/src/implementation/compiler/input-schemas.test.ts @@ -0,0 +1,182 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { describe, expect, test } from 'vitest'; +import { inputInsertions, type InputHost } from './input-schemas.js'; +import { insertInto } from './source-map.js'; +import { hostOf } from './type-host.test-helper.js'; +import { moduleOf } from './type-schemas.js'; + +/** The module as the pass leaves it, with the files whose types it read, or its failure; null when it inserts nothing. */ +const passInputSchemas = async (code: string, file: string, host: InputHost) => { + const result = await inputInsertions(moduleOf(file, code, host.parse(code, file)), host); + if (result === null || "failure" in result) return result; + return { ...insertInto(code, result.insertions, file), dependencies: result.dependencies }; +}; + +const header = `import { System, type Entity, type Vec2, type F32, type I32 } from "@adobe/ecs";\n`; + +/** The schemas the pass gives each system of `body`, in order. */ +const schemasOf = async (body: string, files: { readonly [path: string]: string } = {}): Promise => { + const code = header + body; + const result = await passInputSchemas(code, "/src/main.ts", hostOf(files)); + if (result === null) return null; + if ("failure" in result) return { failure: result.failure.message }; + return [...result.code.matchAll(/, (\{.*\})\);?$/gm)].map(match => JSON.parse(match[1] ?? "null")); +}; + +describe("passInputSchemas", () => { + test("gives a system the schema of its body's parameter after its context", async () => { + expect(await schemasOf(`System.create({}, (_context, input: { readonly count: number; label: string; on: boolean; none: null }) => {});`)).toEqual([ + { type: "object", properties: { count: { type: "number" }, label: { type: "string" }, on: { type: "boolean" }, none: { type: "null" } } }, + ]); + }); + test("finds the input after the row when the declaration has components", async () => { + expect(await schemasOf(`System.create({ components: { with: [a] } }, (row, context, input: { dx: number }) => {});`)).toEqual([ + { type: "object", properties: { dx: { type: "number" } } }, + ]); + expect(await schemasOf(`System.create({ components: { with: [a] } }, (row, context) => {});`)).toBe(null); + }); + test("takes a declaration not written in place's input from a third parameter, or a second with a type", async () => { + expect(await schemasOf([ + `System.create(declaration, (row, context, input: { a: number }) => {});`, + `System.create(declaration, (context, input: { b: number }) => {});`, + `System.create(declaration, (row, context) => {});`, + ].join("\n"))).toEqual([{ type: "object", properties: { a: { type: "number" } } }, { type: "object", properties: { b: { type: "number" } } }]); + }); + test("passes nothing for a body without input", async () => { + expect(await schemasOf(`System.create({ resources: {} }, ({ resources }) => {});\nSystem.create(context => {});`)).toBe(null); + }); + test("reads optional fields, literals, unions, arrays, tuples, sets and maps", async () => { + expect(await schemasOf(`System.create({}, (_c, input: { + mode?: "add" | "remove"; + level: 1 | -1; + tags: readonly string[]; + ids: ReadonlyArray; + pair: [number, number]; + seen: ReadonlySet; + names: ReadonlyMap; + maybe: string | null; + }) => {});`)).toEqual([{ + type: "object", + properties: { + mode: { anyOf: [{ const: "add" }, { const: "remove" }] }, + level: { anyOf: [{ const: 1 }, { const: -1 }] }, + tags: { type: "array", items: { type: "string" } }, + ids: { type: "array", items: { type: "integer", format: "entity" } }, + pair: { type: "array", items: { type: "number" }, minItems: 2, maxItems: 2 }, + seen: { type: "set", items: { type: "string" } }, + names: { type: "map", keys: { type: "integer", format: "entity" }, values: { type: "string" } }, + maybe: { anyOf: [{ type: "string" }, { type: "null" }] }, + }, + required: ["level", "tags", "ids", "pair", "seen", "names", "maybe"], + }]); + }); + test("gives the package's types their schemas", async () => { + expect(await schemasOf(`System.create({}, (_c, input: { entity: Entity; at: Vec2; cell: Vec2.I32; dt: F32; steps: I32 }) => {});`)).toEqual([{ + type: "object", + properties: { + entity: { type: "integer", format: "entity" }, + at: { type: "array", items: { type: "number", precision: 1 }, minItems: 2, maxItems: 2 }, + cell: { type: "array", items: { type: "integer", minimum: -2147483648, maximum: 2147483647 }, minItems: 2, maxItems: 2 }, + dt: { type: "number", precision: 1 }, + steps: { type: "integer", minimum: -2147483648, maximum: 2147483647 }, + }, + }]); + }); + test("follows a type named by Schema.Type to its schema, written as a literal type or `typeof` a constant", async () => { + const files = { "/src/units.ts": `import type { Schema } from "@adobe/ecs";\nexport type Level = Schema.Type<{ type: "integer"; minimum: 0; maximum: 9 }>;\nconst title = { type: "string", maxLength: 64 } as const;\nexport type Title = Schema.Type;` }; + expect(await schemasOf([ + `import type { Level, Title } from "./units.js";`, + `type Cell = readonly [Level, Level];`, + `System.create({}, (_c, input: { level: Level; title: Title; cell: Cell }) => {});`, + ].join("\n"), files)).toEqual([{ + type: "object", + properties: { + level: { type: "integer", minimum: 0, maximum: 9 }, + title: { type: "string", maxLength: 64 }, + cell: { type: "array", items: { type: "integer", minimum: 0, maximum: 9 }, minItems: 2, maxItems: 2 }, + }, + }]); + }); + test("reads the package's types through any namespace", async () => { + expect(await schemasOf(`import * as ecs from "@adobe/ecs";\nSystem.create({}, (_c, input: { at: ecs.Vec3; cell: ecs.Vec2.U32 }) => {});`)).toEqual([{ + type: "object", + properties: { + at: { type: "array", items: { type: "number", precision: 1 }, minItems: 3, maxItems: 3 }, + cell: { type: "array", items: { type: "integer", minimum: 0, maximum: 0xFFFFFFFF }, minItems: 2, maxItems: 2 }, + }, + }]); + }); + test("takes any value for a class a module exports", async () => { + const files = { "/src/row.ts": `export class Row extends HTMLElement {}` }; + expect(await schemasOf(`import { Row } from "./row.js";\nSystem.create({}, (_c, input: { row: Row }) => {});`, files)).toEqual([{ type: "object", properties: { row: { allOf: [] } } }]); + }); + test("takes any value for a global class or unknown", async () => { + expect(await schemasOf(`System.create({}, (_c, input: { canvas: OffscreenCanvas; bitmap: ImageBitmap; element: HTMLCanvasElement; anything: unknown }) => {});`)).toEqual([ + { type: "object", properties: { canvas: { allOf: [] }, bitmap: { allOf: [] }, element: { allOf: [] }, anything: { allOf: [] } } }, + ]); + }); + test("reads the values of an `as const` object, here or imported, as a union of their literals", async () => { + const files = { "/src/align.ts": `export const Align = { start: "start", end: "end", again: "start" } as const;\nexport type Align = (typeof Align)[keyof typeof Align];` }; + expect(await schemasOf([ + `import { Align } from "./align.js";`, + `const Level = { low: 1, high: -1, on: true } as const satisfies Record;`, + `type Level = (typeof Level)[keyof typeof Level];`, + `System.create({}, (_c, input: { align: Align; level: Level; inline: (typeof Level)[keyof typeof Level] }) => {});`, + ].join("\n"), files)).toEqual([{ + type: "object", + properties: { + align: { anyOf: [{ const: "start" }, { const: "end" }] }, + level: { anyOf: [{ const: 1 }, { const: -1 }, { const: true }] }, + inline: { anyOf: [{ const: 1 }, { const: -1 }, { const: true }] }, + }, + }]); + }); + test("follows aliases and interfaces, here and in modules imported", async () => { + const files = { + "/src/types.ts": `export type Move = { readonly by: number };\nexport interface Named { readonly name: string }\nexport { type Tagged } from "./more.js";\nexport * from "./all.js";`, + "/src/more.ts": `export interface Tagged extends Base { readonly tag: string }\ninterface Base { readonly base: boolean }`, + "/src/all.ts": `export type Spread = { readonly spread: number };`, + }; + expect(await schemasOf([ + `import type { Move, Named, Tagged, Spread } from "./types.js";`, + `type Local = Move & Named;`, + `System.create({}, (_c, input: Local) => {});`, + `System.create({}, (_c, input: Tagged) => {});`, + `System.create({}, (_c, input: Spread) => {});`, + ].join("\n"), files)).toEqual([ + { type: "object", properties: { by: { type: "number" }, name: { type: "string" } } }, + { type: "object", properties: { base: { type: "boolean" }, tag: { type: "string" } } }, + { type: "object", properties: { spread: { type: "number" } } }, + ]); + }); + test("names the module of a type it can't read", async () => { + const failed = await passInputSchemas(`${header}import type { Bad } from "./bad.js";\nSystem.create({}, (_c, input: Bad) => {});`, "/src/main.ts", hostOf({ "/src/bad.ts": `export type Bad = Pick;` })); + expect(failed !== null && "failure" in failed ? failed.failure.frames.map(frame => [frame.file, frame.line]) : failed).toEqual([["/src/bad.ts", 1]]); + expect(failed !== null && "failure" in failed ? failed.dependencies : failed).toEqual(["/src/bad.ts"]); + }); + test("fails, saying why, for what an input type can't be", async () => { + const failure = async (body: string) => { + const schemas = await schemasOf(body); + return typeof schemas === "object" && schemas !== null && "failure" in schemas ? schemas.failure : schemas; + }; + expect(await failure(`System.create({}, (_c, input) => {});`)).toContain("has a type"); + expect(await failure(`System.create({}, (_c, input: { a?: number | undefined }) => {});`)).toContain("optional"); + expect(await failure(`System.create({}, (_c, input: Pick) => {});`)).toContain("type arguments"); + expect(await failure(`type Tree = { readonly child: Tree };\nSystem.create({}, (_c, input: Tree) => {});`)).toContain("refers to itself"); + expect(await failure(`type Box = { readonly value: T };\nSystem.create({}, (_c, input: Box) => {});`)).toContain("generic"); + expect(await failure(`System.create({}, (_c, input: [number, string]) => {});`)).toContain("one type"); + expect(await failure(`System.create({}, (_c, input: number) => {});`)).toContain("object of named fields"); + expect(await failure(`const Loose = { a: "a" };\nSystem.create({}, (_c, input: { a: (typeof Loose)[keyof typeof Loose] }) => {});`)).toContain("as const"); + expect(await failure(`const Nested = { a: { b: 1 } } as const;\nSystem.create({}, (_c, input: { a: (typeof Nested)[keyof typeof Nested] }) => {});`)).toContain("literals"); + expect(await failure(`System.create({}, (_c, input: { a: Big["x"] }) => {});`)).toContain("keyof typeof"); + expect(await failure(`System.create({}, (_c, ...rest: [{ a: number }]) => {});`)).toContain("rest parameter"); + }); + test("inserts each schema after the body, mapping the rest of the line back", async () => { + const result = await passInputSchemas(`${header}System.create({}, (_c, input: { a: number }) => {}); const after = 1;`, "/src/main.ts", hostOf({})); + expect(result !== null && "code" in result ? result.code.split("\n")[1] : result).toBe(`System.create({}, (_c, input: { a: number }) => {}, {"type":"object","properties":{"a":{"type":"number"}}}); const after = 1;`); + expect(result !== null && "map" in result ? result.map.mappings.length > 0 : result).toBe(true); + }); + test("leaves calls of another System", async () => { + expect(await passInputSchemas(`import { System } from "elsewhere";\nSystem.create({}, (_c, input: { a: number }) => {});`, "/src/main.ts", hostOf({}))).toBe(null); + }); +}); diff --git a/packages/ecs/src/implementation/compiler/input-schemas.ts b/packages/ecs/src/implementation/compiler/input-schemas.ts new file mode 100644 index 000000000..3b2f2f842 --- /dev/null +++ b/packages/ecs/src/implementation/compiler/input-schemas.ts @@ -0,0 +1,107 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Failure } from './failure.js'; +import type { Insertion } from './source-map.js'; +import { createConverter, isThrown, type Module, type Node, type Statement, type TypeHost, type TypeNode } from './type-schemas.js'; + +/** Whether an import specifier, resolved from `importer`, is a module of the package exporting `System`. */ +export type IsSystem = (specifier: string, importer: string) => Promise; + +export type InputHost = TypeHost & { readonly isSystem: IsSystem }; + +/** What a pass inserts into a module, and the files whose types it read; or why it failed. */ +export type PassResult = + | { readonly insertions: readonly Insertion[]; readonly dependencies: readonly string[] } + | { readonly failure: Failure; readonly dependencies: readonly string[] }; + +type Expression = Extract["expression"]; +type Call = Extract; +type FunctionNode = Extract; + +/** Every node under `node`, in source order. */ +const each = (node: unknown, visit: (node: Node) => void): void => { + if (Array.isArray(node)) { + for (const item of node) each(item, visit); + return; + } + if (typeof node !== "object" || node === null || typeof Reflect.get(node, "type") !== "string") return; + if (isNode(node)) visit(node); + for (const key of Object.keys(node)) if (key !== "parent") each(Reflect.get(node, key), visit); +}; + +const isNode = (value: object): value is Node => typeof Reflect.get(value, "start") === "number" && typeof Reflect.get(value, "end") === "number"; + +// Each call's arguments are its own expressions. +const isCall = (node: Node): node is Call => node.type === "CallExpression"; + +const unwrapped = (node: Call["arguments"][number]): Call["arguments"][number] => + node.type === "ParenthesizedExpression" ? unwrapped(node.expression) : node; + +const isFunction = (node: Call["arguments"][number]): node is FunctionNode => node.type === "ArrowFunctionExpression" || node.type === "FunctionExpression"; + +/** Whether a declaration written in place declares `components`; null when it isn't written in place. */ +const declaresComponents = (declaration: Call["arguments"][number]): boolean | null => { + if (declaration.type !== "ObjectExpression") return null; + return declaration.properties.some(property => property.type === "Property" && !property.computed + && ((property.key.type === "Identifier" && property.key.name === "components") || (property.key.type === "Literal" && property.key.value === "components"))); +}; + +/** + * Passes each `System.create(declaration, body)` the schema of its body's input, the type of its parameter after its + * context, as a third argument: `create` takes the schema from there. The type is read from its syntax, through aliases + * and interfaces of the module and those it imports, so it needs no type checker. Returns null when no system here + * takes input. + */ +export const inputInsertions = async (module: Module, host: InputHost): Promise => { + const { code, file, program } = module; + if (!code.includes("System") || !code.includes(".create(")) return null; + const converter = createConverter(host, file); + const { fail } = converter; + const systems = new Set(); + for (const [local, entry] of module.imports) { + if (entry.imported === "System" && await host.isSystem(entry.source, file)) systems.add(local); + } + if (systems.size === 0) return null; + const calls: Call[] = []; + each(program.body, node => { + if (!isCall(node)) return; + const { callee } = node; + if (callee.type === "MemberExpression" && !callee.computed && callee.object.type === "Identifier" && systems.has(callee.object.name) + && callee.property.type === "Identifier" && callee.property.name === "create" && node.arguments.length === 2) calls.push(node); + }); + const insertions: Insertion[] = []; + try { + for (const call of calls) { + const [declaration, second] = call.arguments; + if (declaration === undefined || second === undefined) continue; + const body = unwrapped(second); + if (!isFunction(body)) continue; + const components = declaresComponents(declaration); + // For a declaration not written in place, a third parameter is the input, as is a second one with a type: a + // context is rarely given one. + const before = components === null ? (body.params.length > 2 || annotationOf(body.params[1]) === null ? 2 : 1) : components ? 2 : 1; + const parameter = body.params[before]; + if (parameter === undefined) continue; + if (parameter.type === "RestElement") fail(module, parameter, "a body's input is one parameter, not a rest parameter"); + if (parameter.type === "AssignmentPattern") fail(module, parameter, "a body's input has no default"); + const annotation = annotationOf(parameter); + if (annotation === null) return fail(module, parameter, "a body's input parameter has a type, which its schema is made from"); + converter.site.at = parameter.start; + const schema = await converter.convert(annotation, module, new Set(), "input"); + if (!("type" in schema) || schema.type !== "object") fail(module, parameter, "a body's input is an object of named fields"); + insertions.push({ at: second.end, text: `, ${JSON.stringify(schema)}` }); + } + } catch (error) { + if (isThrown(error)) return { failure: error.typeFailure, dependencies: [...converter.read] }; + throw error; + } + if (insertions.length === 0) return null; + return { insertions, dependencies: [...converter.read] }; +}; + +type Parameter = FunctionNode["params"][number]; + +const annotationOf = (parameter: Parameter | undefined): TypeNode | null => { + if (parameter === undefined || parameter.type === "TSParameterProperty") return null; + const annotation = parameter.type === "RestElement" ? parameter.typeAnnotation : parameter.typeAnnotation; + return annotation === null || annotation === undefined ? null : annotation.typeAnnotation; +}; diff --git a/packages/ecs/src/implementation/compiler/ir.ts b/packages/ecs/src/implementation/compiler/ir.ts index f7444e697..d23f88e09 100644 --- a/packages/ecs/src/implementation/compiler/ir.ts +++ b/packages/ecs/src/implementation/compiler/ir.ts @@ -23,6 +23,8 @@ export type Expression = | { readonly kind: "number"; readonly value: number } | { readonly kind: "boolean"; readonly value: boolean } | { readonly kind: "column"; readonly column: number } + /** Whether this call's archetype has an optional component, by its presence column: the same for every row. */ + | { readonly kind: "present"; readonly column: number } /** A column of the row's parent, at the parent's row: `Unit.upColumns`. */ | { readonly kind: "upColumn"; readonly column: number } | { readonly kind: "resource"; readonly index: number; readonly path: Path } @@ -55,8 +57,8 @@ export type Statement = /** Declares `let` locals that every path assigns before reading. */ | { readonly kind: "declare"; readonly locals: readonly number[] } | { readonly kind: "store"; readonly column: number; readonly value: Expression } - /** Stamps a changed column at the row and its chunk column at the row's chunk. */ - | { readonly kind: "stamp"; readonly column: number; readonly chunks: number } + /** Stamps a changed column at the row, and its chunk and block columns at the row's chunk and block. */ + | { readonly kind: "stamp"; readonly column: number; readonly chunks: number; readonly blocks: number } | { readonly kind: "if"; readonly test: Expression; readonly consequent: Out; readonly alternate: Out } | { readonly kind: "block"; readonly label: number; readonly body: Out } | { readonly kind: "loop"; readonly label: number; readonly body: Out } diff --git a/packages/ecs/src/implementation/compiler/link.ts b/packages/ecs/src/implementation/compiler/link.ts index f605c2757..f6131ae80 100644 --- a/packages/ecs/src/implementation/compiler/link.ts +++ b/packages/ecs/src/implementation/compiler/link.ts @@ -14,6 +14,8 @@ export type LinkHost = { readonly resolve: (specifier: string, importer: string) => Promise; /** A file's code as JavaScript, and how offsets in it map to source lines when it was compiled from another language. */ readonly load: (file: string) => Promise<{ readonly code: string; readonly locate?: Module["locate"] } | { readonly error: string }>; + /** The operator each resource a file declares with a `reduce` trait reduces with, by its key's binding. */ + readonly reductions?: (file: string) => ReadonlyMap; }; /** @@ -23,6 +25,8 @@ export type LinkHost = { export type Link = { readonly module: (file: string) => Module | { readonly error: string }; readonly resolve: (specifier: string, importer: string) => string | Unresolved; + /** The operator the resource `binding` of `file` declares in its `reduce` trait, if the build can read one. */ + readonly reductionOf?: (file: string, binding: string) => string | undefined; }; /** A cache of modules and resolutions shared across transforms. */ @@ -108,12 +112,19 @@ export const createLinker = (host: LinkHost, parse: (code: string) => Program): for (const exported of module.exports.values()) if (exported.kind !== "local") void prefetch(exported.specifier, file); for (const star of module.stars) void prefetch(star, file); }; + const reductions = new Map>(); return { module: file => modules.get(file) ?? pending(startLoad(file)), + reductionOf: (file, binding) => { + let declared = reductions.get(file); + if (declared === undefined && host.reductions !== undefined) reductions.set(file, declared = host.reductions(file)); + return declared?.get(binding); + }, resolve: (specifier, importer) => resolutions.get(`${importer}\0${specifier}`) ?? pending(prefetch(specifier, importer)), add: module => { modules.set(module.file, module); }, forget: (file, all) => { modules.delete(file); + reductions.delete(file); loading.delete(file); versions.set(file, (versions.get(file) ?? 0) + 1); if (all) { diff --git a/packages/ecs/src/implementation/compiler/lists.ts b/packages/ecs/src/implementation/compiler/lists.ts index 19e8f1736..39ea399b9 100644 --- a/packages/ecs/src/implementation/compiler/lists.ts +++ b/packages/ecs/src/implementation/compiler/lists.ts @@ -11,7 +11,7 @@ export type ListNames = readonly string[] | { readonly error: string }; export type Lists = { /** * The keys a list names: an array literal of the kernel's module-scope identifiers, or a constant, of the kernel's - * module or imported through any re-exports and namespaces, holding an array literal or `Archetype.create` of one. + * module or imported through any re-exports and namespaces, holding an array literal. * A key of another module's list is named by the kernel's binding of the same definition, or else by the list * itself, as `List[k]`. */ @@ -23,6 +23,12 @@ export type Lists = { * external to it, is taken as a key, and registration rejects a list there. Anything else fails with the reason. */ readonly component: (node: Node) => Named; + /** + * The operator the resource the kernel's module names `name` reduces with, its declaration's `reduce` trait, read + * through the build from the module declaring it, undefined where the build can't read one, and that module's file; + * null for a name the module doesn't bind. + */ + readonly reduction: (name: string) => { readonly operator: string | undefined; readonly file: string } | null; }; /** What a command's component argument names, or why the compiler can't tell. */ @@ -50,14 +56,11 @@ const isKey = (node: Expression, module: Module): boolean => { return module.bindings.get(node.callee.name)?.kind === "function" && symbol !== undefined && symbol.type !== "SpreadElement" && isSymbolCall(symbol, module); }; -/** `Archetype.create()`, with `Archetype` imported under any name: its array literal, else null. */ -const createdList = (node: Expression, module: Module): ArrayExpression | null => { - if (node.type !== "CallExpression" || node.arguments.length !== 1) return null; - const { callee } = node; - if (callee.type !== "MemberExpression" || callee.computed || callee.object.type !== "Identifier" || callee.property.type !== "Identifier" || callee.property.name !== "create") return null; - const binding = module.bindings.get(callee.object.name); +/** A list's literal as the plugin gives it an index, `archetype$([...])` with its appended function: the literal, else null. */ +const indexedList = (node: Expression, module: Module): ArrayExpression | null => { + if (node.type !== "CallExpression" || node.arguments.length !== 1 || node.callee.type !== "Identifier" || !/^archetype\$\d*$/.test(node.callee.name)) return null; const [list] = node.arguments; - return binding?.kind === "import" && binding.imported === "Archetype" && list.type === "ArrayExpression" ? list : null; + return module.bindings.get(node.callee.name)?.kind === "function" && list.type === "ArrayExpression" ? list : null; }; const fileOf = (module: Module, kernel: Module): string => module === kernel ? "this module" : module.file; @@ -72,14 +75,14 @@ const follow = (name: string, binding: ModuleBinding, module: Module, kernel: Mo case "const": { const { init } = binding; if (init.type === "ArrayExpression") return { literal: init, module, reference }; - const created = createdList(init, module); - if (created !== null) return { literal: created, module, reference }; + const indexed = indexedList(init, module); + if (indexed !== null) return { literal: indexed, module, reference }; if (init.type === "Identifier") { const aliased = module.bindings.get(init.name); if (aliased !== undefined) return follow(init.name, aliased, module, kernel, link, reference, wanted, seen); } if (isKey(init, module)) return { key: identity }; - const declare = wanted === "list" ? "declare it as an array literal, or Archetype.create of one" : "declare a key as Symbol(...), or a list as an array literal or Archetype.create of one"; + const declare = wanted === "list" ? "declare it as an array literal, `as const`" : "declare a key as Symbol(...), or a list as an array literal, `as const`"; return { error: `${reference} is computed when ${fileOf(module, kernel)} runs, so ${unread}; ${declare}` }; } case "import": case "importNamespace": @@ -111,7 +114,7 @@ const chain = (node: Node): readonly string[] | null => { const find = (node: Node, kernel: Module, link: Link | null, wanted: Wanted): Followed => { if (node.type === "ArrayExpression") return { literal: node, module: kernel, reference: "" }; const names = chain(node); - if (names === null) return { error: "commands and kernel declarations name a list as an array literal, or a constant holding one or Archetype.create of one" }; + if (names === null) return { error: "commands and kernel declarations name a list as an array literal, or a constant holding one" }; const [first, ...members] = names; const reference = names.join("."); const binding = kernel.bindings.get(first); @@ -176,6 +179,13 @@ export const createLists = (kernel: Module, link: Link | null, nodes: readonly N const result: string[] = []; for (let k = 0; k < literal.elements.length; k++) { const element = literal.elements[k]; + // The kernel's own list may spread another it can read: `with: [...Unit, tagged]`. + if (element?.type === "SpreadElement" && module === kernel) { + const spread = names(element.argument); + if ("error" in spread) return spread; + result.push(...spread); + continue; + } const binding = element?.type === "Identifier" ? module.bindings.get(element.name) : undefined; if (element?.type !== "Identifier" || binding === undefined) { return { error: module === kernel ? "lists name keys by identifiers bound at module scope" : `${reference} names a key by something other than an identifier bound at the scope of ${module.file}` }; @@ -199,5 +209,12 @@ export const createLists = (kernel: Module, link: Link | null, nodes: readonly N // its definition where it has one. return { kind: "key", name: node.type === "Identifier" || found.key === null ? reference : nameOf(found.key, reference) }; }; - return { names, component }; + const reduction = (name: string): { readonly operator: string | undefined; readonly file: string } | null => { + const binding = kernel.bindings.get(name); + const identity = binding === undefined ? null : definition(kernel, name, binding, link); + if (identity === null) return null; + const [file = "", defined = ""] = identity.split("\0"); + return { operator: link?.reductionOf?.(file, defined), file }; + }; + return { names, component, reduction }; }; diff --git a/packages/ecs/src/implementation/compiler/module-value.ts b/packages/ecs/src/implementation/compiler/module-value.ts index 58d03221d..0d75d48dc 100644 --- a/packages/ecs/src/implementation/compiler/module-value.ts +++ b/packages/ecs/src/implementation/compiler/module-value.ts @@ -4,7 +4,7 @@ import type { Program } from '../world/kernel.js'; import { isValueType } from '../world/intrinsics.js'; import { fail, within, type Frame } from './failure.js'; import { resolveBinding, type Resolved } from './link.js'; -import { isIntrinsicModule } from './paths.js'; +import { isIntrinsicModule, packagePathOf } from './paths.js'; import type { Module, ModuleBinding } from './module-scope.js'; import type { Out } from './out.js'; import { rootScope, type Scope } from './scope.js'; @@ -13,7 +13,7 @@ import type { Value } from './value.js'; /** Whether `name` is one of the kernel's component or resource keys. */ export const isKey = (scope: Scope, name: string): boolean => { const signature = scope.unit.signature; - return signature.read.has(name) || signature.write.has(name) || signature.resources.includes(name) + return signature.read.has(name) || signature.write.has(name) || signature.has.has(name) || signature.resources.includes(name) || signature.lookups.some(lookup => lookup.read.includes(name)); }; @@ -71,6 +71,16 @@ const intrinsicDefinition = (init: SourceExpression, module: Module, scope: Scop return { implementation, intrinsic: intrinsicDeclaration(declaration) }; }; +/** Whether a value holds data rather than only functions and namespaces. */ +const holdsData = (value: Value): boolean => { + switch (value.kind) { + case "tuple": return value.items.some(holdsData); + case "struct": return value.fields.some(([, field]) => holdsData(field)); + case "function": case "namespace": case "method": return false; + default: return true; + } +}; + /** A definition in `module`: a function to inline, or a constant compiled in its own module. */ const definition = (node: Node, name: string, binding: ModuleBinding, module: Module, scope: Scope, out: Out): Value => { const unit = scope.unit; @@ -94,6 +104,11 @@ const definition = (node: Node, name: string, binding: ModuleBinding, module: Mo const compile = (): Value => unit.expression(init, rootScope(unit, true, module), out); const value = module === unit.module ? compile() : within(node, frameIn(module, display), compile); unit.active.delete(identity); + // An object or array holding data may be written after the build, whatever made it, and a kernel would have + // inlined it; a primitive const can't change, and the interface's own objects are fixed. + if (unit.tried && value.kind !== "scalar" && holdsData(value) && packagePathOf(module.file)?.startsWith("interface/") !== true) { + return fail(node, `${display} holds values that may change, which a kernel would inline`); + } return value; } case "import": case "importNamespace": diff --git a/packages/ecs/src/implementation/compiler/paths.ts b/packages/ecs/src/implementation/compiler/paths.ts index 4dddc4af6..430aab37d 100644 --- a/packages/ecs/src/implementation/compiler/paths.ts +++ b/packages/ecs/src/implementation/compiler/paths.ts @@ -1,44 +1,188 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. /// -import { extname } from 'node:path'; +import { existsSync, readFileSync, realpathSync } from 'node:fs'; +import { dirname, extname, isAbsolute } from 'node:path'; import { fileURLToPath } from 'node:url'; -const here = fileURLToPath(import.meta.url); -const extension = extname(here); +const packageName = "@adobe/ecs"; const posix = (path: string): string => path.replace(/\\/g, "/"); -const packageRoot = posix(fileURLToPath(new URL("../../..", import.meta.url))).replace(/\/$/, ""); +const fileOf = (id: string): string => posix(id.split("?")[0] ?? id); -/** The runtime hook emitted code imports: the source file when running from `src`, the built one from `dist`. */ -export const attachKernelPath = posix(fileURLToPath(new URL(`../world/attach-kernel${extension}`, import.meta.url))); +/** + * The files of one copy of the package that compiled code imports. A copy is judged by the files an importer resolves + * into it, so emitted code always binds to the copy, and the registry, the importer itself uses. + */ +export type PackageCopy = { + /** The runtime hook kernels attach through. */ + readonly attachKernel: string; + /** The runtime hook for spawns and adds whose values pass positionally. */ + readonly preparedSpawn: string; + /** The runtime hook for index lookups whose keys pass positionally. */ + readonly preparedLookup: string; + /** The package root's module, for kernels that import the package when a build treats it as external. */ + readonly index: string; +}; -/** The runtime hook for spawns whose values pass positionally. */ -export const prepareSpawnPath = posix(fileURLToPath(new URL(`../world/prepared-spawn${extension}`, import.meta.url))); +/** The root of the copy of the package containing each directory, or null; found once per directory. */ +const roots = new Map(); -/** The runtime hook for index lookups whose keys pass positionally. */ -export const prepareLookupPath = posix(fileURLToPath(new URL(`../world/prepared-lookup${extension}`, import.meta.url))); +/** The name the package.json in a directory gives, or null when it has none that parses. */ +const packageNameAt = (directory: string): unknown => { + try { + const json: unknown = JSON.parse(readFileSync(`${directory}/package.json`, "utf8")); + return typeof json === "object" && json !== null ? Reflect.get(json, "name") : undefined; + } + catch { + // No package.json here, or none that parses. + return null; + } +}; -const intrinsicModules: ReadonlySet = new Set(["src/interface/math/intrinsic.ts", "src/interface/math/intrinsic.js", "dist/interface/math/intrinsic.js"].map(path => `${packageRoot}/${path}`)); +const isPackageRoot = (directory: string): boolean => packageNameAt(directory) === packageName; -/** Whether a file is the module math functions declare their WGSL builtins with. */ -export const isIntrinsicModule = (file: string): boolean => intrinsicModules.has(posix(file)); +const rootOfDirectory = (directory: string): string | null => { + const known = roots.get(directory); + if (known !== undefined) return known; + const name = packageNameAt(directory); + const parent = dirname(directory); + // The nearest package.json owns the file, whichever package it names. + const root = name === packageName ? directory : name !== null || parent === directory ? null : rootOfDirectory(parent); + roots.set(directory, root); + return root; +}; -/** The package root's module, for kernels that import the package when a build treats it as external. */ -export const packageIndex = posix(fileURLToPath(new URL(`../../index${extension}`, import.meta.url))); +/** A file's path in the copy of the package containing it, as `src/…` or `dist/…`, or null outside every copy. */ +const pathInCopy = (file: string): { readonly root: string; readonly path: string } | null => { + if (!isAbsolute(file)) return null; + const root = rootOfDirectory(dirname(file)); + return root === null ? null : { root, path: file.slice(root.length + 1) }; +}; -const systemModules: ReadonlySet = new Set([ - "src/interface/system/system.ts", "src/interface/system/system.js", "dist/interface/system/system.js", - "src/interface/index.ts", "src/interface/index.js", "dist/interface/index.js", "src/index.ts", "src/index.js", "dist/index.js", -].map(path => `${packageRoot}/${path}`)); +const copies = new Map(); -/** Whether a resolved module id is this package's `System` namespace, directly or through the package root. */ -export const isSystemModule = (id: string): boolean => systemModules.has(posix(id.split("?")[0] ?? id)); +/** The copy at `root`, laid out as its `src` with `extension`, or as its built `dist`. */ +const copyAt = (root: string, source: boolean, extension: string): PackageCopy => { + const key = `${root}\0${source ? extension : ""}`; + const known = copies.get(key); + if (known !== undefined) return known; + const prefix = source ? `${root}/src` : `${root}/dist`; + const suffix = source ? extension : ".js"; + const copy: PackageCopy = { + attachKernel: `${prefix}/implementation/world/attach-kernel${suffix}`, + preparedSpawn: `${prefix}/implementation/world/prepared-spawn${suffix}`, + preparedLookup: `${prefix}/implementation/world/prepared-lookup${suffix}`, + index: `${prefix}/index${suffix}`, + }; + copies.set(key, copy); + return copy; +}; -const roots = ["src/interface/index.ts", "src/interface/index.js", "dist/interface/index.js", "src/index.ts", "src/index.js", "dist/index.js"]; +const here = posix(fileURLToPath(import.meta.url)); +const ownRoot = posix(fileURLToPath(new URL("../../..", import.meta.url))).replace(/\/$/, ""); + +/** The copy this compiler runs from: its sources when running from `src`, its `dist` otherwise. */ +export const ownCopy: PackageCopy = copyAt(ownRoot, here.startsWith(`${ownRoot}/src/`), extname(here)); + +/** + * The copy at `root`, in the layout of a file in it. This compiler's own copy keeps the compiler's layout, so modules + * importing its sources and its `dist` share the runtime the compiler runs with. + */ +const copyIn = (root: string, path: string, extension: string): PackageCopy => + root === ownRoot ? ownCopy : copyAt(root, path.startsWith("src/"), extension); + +/** The copy a file belongs to, or null outside every copy. */ +const copyOfFile = (file: string): PackageCopy | null => { + const found = pathInCopy(file); + return found === null ? null : copyIn(found.root, found.path, extname(file)); +}; + +/** The copy installed for each directory's modules, found once per directory. */ +const installed = new Map(); + +const installedFrom = (directory: string): PackageCopy | null => { + const known = installed.get(directory); + if (known !== undefined) return known; + let copy: PackageCopy | null = null; + const candidate = `${directory}/node_modules/${packageName}`; + if (isPackageRoot(candidate)) { + copy = copyIn(posix(realpathSync(candidate)), "dist/", ".js"); + } else { + const parent = dirname(directory); + copy = parent === directory ? null : installedFrom(parent); + } + installed.set(directory, copy); + return copy; +}; + +/** + * The copy an importer that can't resolve the package through the build uses: the copy it is part of, or else the one + * installed nearest it, or else this compiler's own. + */ +export const copyForImporter = (importer: string): PackageCopy => { + const file = fileOf(importer); + return copyOfFile(file) ?? installedFrom(dirname(file)) ?? ownCopy; +}; + +const layouts = (module: string, extensions: readonly string[] = [".ts", ".js"]): readonly string[] => + [...extensions.map(extension => `src/${module}${extension}`), `dist/${module}.js`]; + +const intrinsicModules: ReadonlySet = new Set(layouts("interface/math/intrinsic")); + +/** Whether a file is the module math functions declare their WGSL builtins with, in any copy of the package. */ +export const isIntrinsicModule = (file: string): boolean => { + const posixFile = posix(file); + if (!posixFile.includes("/intrinsic.")) return false; + const found = pathInCopy(posixFile); + return found !== null && intrinsicModules.has(found.path); +}; + +const attachKernelModules: ReadonlySet = new Set(layouts("implementation/world/attach-kernel")); + +/** Whether an import specifier is the runtime hook kernels attach through, in any copy of the package. */ +export const isAttachKernelModule = (specifier: string): boolean => { + if (!specifier.includes("/attach-kernel.")) return false; + const found = pathInCopy(fileOf(specifier)); + return found !== null && attachKernelModules.has(found.path); +}; + +const rootModules = [...layouts("interface/index"), ...layouts("index")]; const namespaceModules: { readonly [namespace: string]: ReadonlySet } = Object.fromEntries( - [["System", "system/system"], ["Rule", "rule/rule"], ["Finalizer", "finalizer/finalizer"], ["Index", "indexes/index-type"]].map(([namespace, module]) => [namespace, new Set( - [`src/interface/${module}.ts`, `src/interface/${module}.js`, `dist/interface/${module}.js`, ...roots].map(path => `${packageRoot}/${path}`), - )]), + [["System", "system/system"], ["Rule", "rule/rule"], ["Finalizer", "finalizer/finalizer"], ["Index", "indexes/index-type"]] + .map(([namespace, module]) => [namespace, new Set([...layouts(`interface/${module}`), ...rootModules])]), ); -/** Whether a resolved module id is the module of this package's `namespace`, directly or through the package root. */ -export const isNamespaceModule = (id: string, namespace: string): boolean => namespaceModules[namespace]?.has(posix(id.split("?")[0] ?? id)) ?? false; +/** + * The copy of the package whose module of `namespace` a resolved module id is, directly or through the package root, + * in the id's layout; null when the id is no such module. + */ +export const namespaceCopyOf = (id: string, namespace: string): PackageCopy | null => { + const modules = namespaceModules[namespace]; + if (modules === undefined) return null; + const file = fileOf(id); + const found = pathInCopy(file); + return found !== null && modules.has(found.path) ? copyIn(found.root, found.path, extname(file)) : null; +}; + + +/** A file's path in a copy of the package, without `src/` or `dist/` and its extension; null outside every copy. */ +export const packagePathOf = (file: string): string | null => { + const found = pathInCopy(fileOf(file)); + if (found === null) return null; + const path = found.path.replace(/^(src|dist)\//, ""); + return path === found.path ? null : path.replace(/(\.d)?\.[cm]?[jt]sx?$/, ""); +}; + +/** + * The file whose types a resolved module's are: a copy of the package's `dist` module's source, which the package + * ships, else a JavaScript module's declarations beside it, else the module itself. + */ +export const typesFileOf = (file: string): string => { + const path = fileOf(file); + const found = pathInCopy(path); + if (found !== null && found.path.startsWith("dist/") && path.endsWith(".js")) { + const source = `${found.root}/src/${found.path.slice("dist/".length, -".js".length)}.ts`; + if (existsSync(source)) return source; + } + const declarations = path.replace(/\.([cm]?)js$/, ".d.$1ts"); + return declarations !== path && existsSync(declarations) ? declarations : path; +}; diff --git a/packages/ecs/src/implementation/compiler/plugin.test.ts b/packages/ecs/src/implementation/compiler/plugin.test.ts index 9dea11dce..753bfb3a5 100644 --- a/packages/ecs/src/implementation/compiler/plugin.test.ts +++ b/packages/ecs/src/implementation/compiler/plugin.test.ts @@ -1,25 +1,27 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. /// -import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { cpSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { dirname, join, relative, resolve } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { afterAll, expect, test } from 'vitest'; -import { build, createServer, type Rollup, type ViteDevServer } from 'vite'; +import { build, createServer, parseAst, transformWithOxc, type Rollup, type ViteDevServer } from 'vite'; import type { KernelDescription, KernelModule } from '../world/kernel.js'; -import { kernelPlugin } from './plugin.js'; +import { plugin } from './plugin.js'; const source = (path: string) => JSON.stringify(fileURLToPath(new URL(path, import.meta.url))); const directory = realpathSync(mkdtempSync(join(tmpdir(), "ecs-kernel-"))); +/** The function a module the plugin built appends, which records its keys' declarations for the registry to read. */ +const recorder = `function declare$(key, kind, declaration) { (globalThis[Symbol.for("@adobe/ecs/declarations@1")] ??= new Map()).set(key, { kind, declaration }); }`; // The application's package, which names its modules' ids. writeFileSync(join(directory, "package.json"), JSON.stringify({ name: "fixture-app", type: "module" })); const fixture = join(directory, "fixture.js"); writeFileSync(fixture, `import { System } from ${source("../../interface/system/system.ts")}; -import { Component } from ${source("../../interface/component/component.ts")}; +import { registerComponent } from ${source("../../interface/registry/declarations.ts")}; import { kernelOf } from ${source("../world/attach-kernel.ts")}; const score = Symbol("score"); -Component.register(score, { type: { type: "number" } }); -const kernel = System.create({ name: "fixture", kernel: "cpu", components: { write: [score] } }, (row) => { row[score] = row.entity * 2; }); +registerComponent(score, { type: { type: "number" } }); +const kernel = System.create({ kernel: true, components: { with: [score] } }, (row) => { row[score] = row.entity * 2; }); export const description = kernelOf(kernel); `); /** A kernel inlining a TypeScript helper and a function from a package's `dist`. */ @@ -31,9 +33,9 @@ mkdirSync(join(directory, "node_modules/fake-math/dist"), { recursive: true }); writeFileSync(join(directory, "node_modules/fake-math/package.json"), JSON.stringify({ name: "fake-math", type: "module", exports: "./dist/index.js" })); writeFileSync(join(directory, "node_modules/fake-math/dist/index.js"), "export const increment = (x) => x + 1;\n"); /** The linked kernel's keys, in a module of their own that the edit doesn't evaluate again. */ -writeFileSync(join(directory, "linked-keys.js"), `import { Component } from ${source("../../interface/component/component.ts")}; +writeFileSync(join(directory, "linked-keys.js"), `import { registerComponent } from ${source("../../interface/registry/declarations.ts")}; export const score = Symbol("score"); -Component.register(score, { type: { type: "number" } }); +registerComponent(score, { type: { type: "number" } }); `); writeFileSync(linked, `import { System } from ${source("../../interface/system/system.ts")}; import { kernelOf } from ${source("../world/attach-kernel.ts")}; @@ -41,16 +43,27 @@ import { scaled } from "./helper.ts"; import { increment } from "fake-math"; import { score } from "./linked-keys.js"; // Serving evaluates this module again after an edit, declaring the same system again with the same keys. -const kernel = System.create({ name: "linked", kernel: "cpu", components: { write: [score] } }, (row) => { row[score] = increment(scaled(row.entity)); }); +const kernel = System.create({ kernel: true, components: { with: [score] } }, (row) => { row[score] = increment(scaled(row.entity)); }); export const description = kernelOf(kernel); `); +/** Systems taking input, of a type another TypeScript module declares, which the test edits. */ +const inputs = join(directory, "inputs.ts"); +const inputTypes = join(directory, "input-types.ts"); +const writeInputTypes = (field: string) => writeFileSync(inputTypes, `import type { Entity } from ${source("../../interface/entity/entity.ts")};\nexport type Push = { readonly entity: Entity; readonly ${field}: number };\n`); +writeInputTypes("by"); +writeFileSync(inputs, `import { System } from ${source("../../interface/system/system.ts")}; +import type { Push } from "./input-types.ts"; +const push = System.create({}, (_context, input: Push) => { void input; }); +const label = System.create({}, (_context, input: { readonly text?: string }) => { void input; }); +export const schemas = [push, label].map(system => Reflect.get(system, "input")); +`); /** A kernel spawning through a list another module declares, which the test edits. */ const listing = join(directory, "listing.js"); const listed = join(directory, "listed.js"); -const writeListed = (keys: string) => writeFileSync(listed, `import { Component } from ${source("../../interface/component/component.ts")}; +const writeListed = (keys: string) => writeFileSync(listed, `import { registerComponent } from ${source("../../interface/registry/declarations.ts")}; import { score } from "./linked-keys.js"; export const extra = Symbol("extra"); -Component.register(extra, { type: { const: true } }); +registerComponent(extra, { type: { const: true } }); export const Listed = [${keys}]; `); writeListed("score"); @@ -58,49 +71,34 @@ writeFileSync(listing, `import { System } from ${source("../../interface/system/ import { kernelOf } from ${source("../world/attach-kernel.ts")}; import { score } from "./linked-keys.js"; import { Listed } from "./listed.js"; -const kernel = System.create({ name: "listing", kernel: "cpu", components: { read: [score] } }, (row, { commands }) => { commands.spawn(Listed)({ [score]: row[score] }); }); -export const description = kernelOf(kernel); -`); -/** An application importing a kernel a library ships uncompiled, from its `dist`. */ -const application = join(directory, "application.js"); -mkdirSync(join(directory, "node_modules/@adobe"), { recursive: true }); -symlinkSync(fileURLToPath(new URL("../../..", import.meta.url)), join(directory, "node_modules/@adobe/ecs")); -mkdirSync(join(directory, "node_modules/kernel-library/dist"), { recursive: true }); -writeFileSync(join(directory, "node_modules/kernel-library/package.json"), JSON.stringify({ name: "kernel-library", type: "module", exports: "./dist/index.js" })); -writeFileSync(join(directory, "node_modules/kernel-library/dist/index.js"), `import { Component, System } from "@adobe/ecs"; -const score = Symbol("score"); -Component.register(score, { type: { type: "number" } }); -export const kernel = System.create({ name: "library", kernel: "cpu", components: { write: [score] } }, (row) => { row[score] = row.entity * 2; }); -`); -writeFileSync(application, `import { kernelOf } from ${source("../world/attach-kernel.ts")}; -import { kernel } from "kernel-library"; +const kernel = System.create({ kernel: true, components: { with: [score] } }, (row, { commands }) => { commands.spawn(Listed)({ [score]: row[score] }); }); export const description = kernelOf(kernel); `); /** Two kernels in one module, writing different functions of the entity. */ const pair = join(directory, "pair.js"); writeFileSync(pair, `import { System } from ${source("../../interface/system/system.ts")}; -import { Component } from ${source("../../interface/component/component.ts")}; +import { registerComponent } from ${source("../../interface/registry/declarations.ts")}; import { kernelOf } from ${source("../world/attach-kernel.ts")}; const score = Symbol("score"); -Component.register(score, { type: { type: "number" } }); -const doubled = System.create({ name: "doubled", kernel: "cpu", components: { write: [score] } }, (row) => { row[score] = row.entity * 2; }); -const tripled = System.create({ name: "tripled", kernel: "cpu", components: { write: [score] } }, (row) => { row[score] = row.entity * 3; }); +registerComponent(score, { type: { type: "number" } }); +const doubled = System.create({ kernel: true, components: { with: [score] } }, (row) => { row[score] = row.entity * 2; }); +const tripled = System.create({ kernel: true, components: { with: [score] } }, (row) => { row[score] = row.entity * 3; }); export const descriptions = [kernelOf(doubled), kernelOf(tripled)]; `); /** A module of keys and what it declares with them, which serving evaluates again under another query. */ const keys = (declaration: string, released = "score") => `import { System as Systems } from ${source("../../interface/system/system.ts")}; -import { Component } from ${source("../../interface/component/component.ts")}; +import { registerComponent } from ${source("../../interface/registry/declarations.ts")}; import { Finalizer } from ${source("../../interface/finalizer/finalizer.ts")}; import { Rule } from ${source("../../interface/rule/rule.ts")}; import { labelOf } from ${source("../../interface/registry/names.ts")}; export const score = Symbol("score"); const shown = Symbol("shown"); -Component.register(score, ${declaration}); -Component.register(shown, { type: { const: true }, derived: true }); -export const rule = Rule.create({ name: "keys/shown", components: { has: [score] }, derive: [shown] }); -export const release = Finalizer.create({ name: "keys/release", components: { read: [${released}] } }, () => {}); -export const system = Systems.create({ name: "keys/write", components: { write: [score] } }, (row) => { row[score] = 1; }); -export const kernel = Systems.create({ name: "keys/write", kernel: "cpu", components: { write: [score] } }, (row) => { row[score] = 2; }); +registerComponent(score, ${declaration}); +registerComponent(shown, { type: { const: true }, derived: true }); +export const rule = Rule.create({ components: { with: [score] }, derive: [shown] }); +export const release = Finalizer.create({ components: { with: [${released}] } }, () => {}); +export const system = Systems.create({ components: { with: [score] } }, (row) => { row[score] = 1; }); +export const kernel = Systems.create({ kernel: true, components: { with: [score] } }, (row) => { row[score] = 2; }); export const label = labelOf(score); `; const evaluated = join(directory, "evaluated.js"); @@ -116,13 +114,13 @@ writeFileSync(join(directory, "node_modules/key-library/package.json"), JSON.str writeFileSync(join(directory, "node_modules/key-library/dist/index.js"), `export const marker = Symbol("marker");\n`); writeFileSync(join(directory, "worker-keys.js"), `export const message = Symbol("message");\nself.postMessage(String(message.description));\n`); writeFileSync(identified, `import { labelOf } from ${source("../../interface/registry/names.ts")}; -import { Component } from ${source("../../interface/component/component.ts")}; +import { registerComponent } from ${source("../../interface/registry/declarations.ts")}; import { marker } from "key-library"; export const score = Symbol("score"); const twin = Symbol("score"); export default Symbol(); -Component.register(score, { type: { type: "number" } }); -Component.register(twin, { type: { type: "number" } }); +registerComponent(score, { type: { type: "number" } }); +registerComponent(twin, { type: { type: "number" } }); export const labels = [labelOf(score), labelOf(twin), labelOf(marker)]; export const worker = () => new Worker(new URL("./worker-keys.js", import.meta.url), { type: "module" }); `); @@ -131,12 +129,12 @@ mkdirSync(join(directory, "node_modules/external-keys"), { recursive: true }); writeFileSync(join(directory, "node_modules/external-keys/package.json"), JSON.stringify({ name: "external-keys", type: "module", exports: "./index.js" })); writeFileSync(join(directory, "node_modules/external-keys/index.js"), `export const heat = Symbol("heat");\nexport const Pair = [Symbol("left"), Symbol("right")];\n`); const externalKernel = (name: string, added: string) => `import { System } from ${source("../../interface/system/system.ts")}; -import { Component } from ${source("../../interface/component/component.ts")}; +import { registerComponent } from ${source("../../interface/registry/declarations.ts")}; import { kernelOf } from ${source("../world/attach-kernel.ts")}; import { ${added} } from "external-keys"; const score = Symbol("score"); -Component.register(score, { type: { type: "number" } }); -const kernel = System.create({ name: "${name}", kernel: "cpu", components: { read: [score] } }, (row, { commands }) => { commands.add(row.entity, ${added}, row[score]); }); +registerComponent(score, { type: { type: "number" } }); +const kernel = System.create({ kernel: true, components: { with: [score] } }, (row, { commands }) => { commands.add(row.entity, ${added}, row[score]); }); export const description = kernelOf(kernel); `; const externalKey = join(directory, "external-key.js"); @@ -145,6 +143,49 @@ const externalList = join(directory, "external-list.js"); writeFileSync(externalList, externalKernel("externalList", "Pair")); afterAll(() => rmSync(directory, { recursive: true, force: true })); +const own = fileURLToPath(new URL("../../..", import.meta.url)); +/** The modules an installed copy's code starts from: its root, its worker, and those compiled code imports. */ +const copyEntries = ["index", "implementation/world/worker", "implementation/world/attach-kernel", "implementation/world/prepared-spawn", "implementation/world/prepared-lookup"]; +let builtDist: Promise> | null = null; + +/** Every relative module a module's code imports or re-exports from. */ +const relativeImports = (code: string): readonly string[] => parseAst(code).body.flatMap(statement => + (statement.type === "ImportDeclaration" || statement.type === "ExportAllDeclaration" || statement.type === "ExportNamedDeclaration") + && typeof statement.source?.value === "string" && statement.source.value.startsWith(".") ? [statement.source.value] : []); + +/** + * The package's `dist` code by path within it, built from the sources as `tsc` emits each module on its own: the + * modules the entries reach at runtime, so no test reads a previous build. Built once per test file. + */ +const distFromSources = (): Promise> => builtDist ??= (async () => { + const sources = join(own, "src"); + const files = new Map(); + const visit = async (file: string): Promise => { + const path = relative(sources, file).replace(/\.ts$/, ".js"); + if (files.has(path)) return; + files.set(path, ""); + const { code } = await transformWithOxc(readFileSync(file, "utf8"), file, { lang: "ts" }); + files.set(path, code); + await Promise.all(relativeImports(code).map(specifier => visit(resolve(dirname(file), specifier.replace(/\.js$/, ".ts"))))); + }; + await Promise.all(copyEntries.map(entry => visit(join(sources, `${entry}.ts`)))); + return files; +})(); + +/** Installs a built copy of the package in a project's `node_modules`, a directory of its own as an install makes. */ +const installCopy = async (project: string): Promise => { + const copy = join(project, "node_modules/@adobe/ecs"); + mkdirSync(copy, { recursive: true }); + writeFileSync(join(copy, "package.json"), readFileSync(join(own, "package.json"))); + // The package ships its sources beside its build, as types for its build read them. + cpSync(join(own, "src"), join(copy, "src"), { recursive: true }); + for (const [path, code] of await distFromSources()) { + mkdirSync(dirname(join(copy, "dist", path)), { recursive: true }); + writeFileSync(join(copy, "dist", path), code); + } + return copy; +}; + /** Runs a one-column kernel writing a function of the entity, for entities 3 and 4. */ const runs = (module: KernelModule, description: KernelDescription): readonly number[] => { const entities = Float64Array.from([3, 4]); @@ -157,7 +198,7 @@ const runs = (module: KernelModule, description: KernelDescription): readonly nu test("a build emits each kernel as its own chunk, referenced by the description's url", async () => { const outDir = join(directory, "dist"); const output = await build({ - configFile: false, logLevel: "silent", root: directory, plugins: [kernelPlugin()], + configFile: false, logLevel: "silent", root: directory, plugins: [plugin()], build: { outDir, minify: false, lib: { entry: fixture, formats: ["es"], fileName: "main" } }, }); const chunks = (Array.isArray(output) ? output : [output]).flatMap((result: Rollup.RollupOutput | Rollup.RollupWatcher) => "output" in result ? result.output : []) @@ -174,7 +215,7 @@ test("a build emits each kernel as its own chunk, referenced by the description' const serve = async (use: (server: ViteDevServer) => Promise): Promise => { const server = await createServer({ - configFile: false, logLevel: "silent", root: directory, plugins: [kernelPlugin()], + configFile: false, logLevel: "silent", root: directory, plugins: [plugin()], appType: "custom", server: { middlewareMode: true, hmr: false, watch: null }, optimizeDeps: { noDiscovery: true }, }); try { @@ -187,7 +228,7 @@ const serve = async (use: (server: ViteDevServer) => Promise): Promise test("a build gives each kernel the url of its own chunk", async () => { const outDir = join(directory, "dist-pair"); const output = await build({ - configFile: false, logLevel: "silent", root: directory, plugins: [kernelPlugin()], + configFile: false, logLevel: "silent", root: directory, plugins: [plugin()], build: { outDir, minify: false, lib: { entry: pair, formats: ["es"], fileName: "main" } }, }); const main = (Array.isArray(output) ? output : [output]).flatMap((result: Rollup.RollupOutput | Rollup.RollupWatcher) => "output" in result ? result.output : []) @@ -236,7 +277,7 @@ test("serve rejects a url for a different version of the kernel", async () => { test("a build inlines functions from TypeScript modules and packages", async () => { const outDir = join(directory, "dist-linked"); const output = await build({ - configFile: false, logLevel: "silent", root: directory, plugins: [kernelPlugin()], + configFile: false, logLevel: "silent", root: directory, plugins: [plugin()], build: { outDir, minify: false, lib: { entry: linked, formats: ["es"], fileName: "main" } }, }); const chunks = (Array.isArray(output) ? output : [output]).flatMap((result: Rollup.RollupOutput | Rollup.RollupWatcher) => "output" in result ? result.output : []) @@ -280,9 +321,28 @@ test("serve compiles kernels again when a list they import changes", async () => }); test("an application's build compiles the kernels a library ships uncompiled", async () => { - const outDir = join(directory, "dist-application"); + // An application importing a kernel a library ships uncompiled, from its `dist`, with the package installed. + const project = join(directory, "shipped"); + const copy = await installCopy(project); + writeFileSync(join(project, "package.json"), JSON.stringify({ name: "shipped-app", type: "module" })); + mkdirSync(join(project, "node_modules/kernel-library/dist"), { recursive: true }); + writeFileSync(join(project, "node_modules/kernel-library/package.json"), JSON.stringify({ name: "kernel-library", type: "module", exports: "./dist/index.js" })); + // As the library's own build with the plugin left it, recording its keys' declarations for every copy to read. + writeFileSync(join(project, "node_modules/kernel-library/dist/index.js"), `import { System } from "@adobe/ecs"; +const score = Symbol("score"); +declare$(score, "component", { type: { type: "number" } }); +${recorder} +export const kernel = System.create({ kernel: true, components: { with: [score] } }, (row) => { row[score] = row.entity * 2; }); +`); + // The test hook comes from the installed copy, which the library's compiled kernel attaches to. + const application = join(project, "application.js"); + writeFileSync(application, `import { kernelOf } from ${JSON.stringify(join(copy, "dist/implementation/world/attach-kernel.js"))}; +import { kernel } from "kernel-library"; +export const description = kernelOf(kernel); +`); + const outDir = join(project, "dist"); const output = await build({ - configFile: false, logLevel: "silent", root: directory, plugins: [kernelPlugin()], + configFile: false, logLevel: "silent", root: project, plugins: [plugin()], build: { outDir, minify: false, lib: { entry: application, formats: ["es"], fileName: "main" } }, }); const main = (Array.isArray(output) ? output : [output]).flatMap((result: Rollup.RollupOutput | Rollup.RollupWatcher) => "output" in result ? result.output : []) @@ -293,19 +353,132 @@ test("an application's build compiles the kernels a library ships uncompiled", a expect(description === undefined ? [] : runs(module, description)).toEqual([6, 8]); }); +test("an application's build compiles a library's reducing kernel, with the operator the library's registration gives", async () => { + const project = join(directory, "reducing"); + const copy = await installCopy(project); + writeFileSync(join(project, "package.json"), JSON.stringify({ name: "reducing-app", type: "module" })); + mkdirSync(join(project, "node_modules/reducing-library/dist"), { recursive: true }); + writeFileSync(join(project, "node_modules/reducing-library/package.json"), JSON.stringify({ name: "reducing-library", type: "module", exports: "./dist/index.js" })); + // As its build in library mode left it: types stripped, the registrations it gave in their place. + writeFileSync(join(project, "node_modules/reducing-library/dist/index.js"), `import { System } from "@adobe/ecs"; +const score = Symbol(); +declare$(score, "component", { type: { type: "number" } }); +const largest = Symbol(); +declare$(largest, "resource", { type: { type: "number" }, default: 0, reduce: "max" }); +${recorder} +export const kernel = System.create({ kernel: true, components: { with: [score] } }, (row, { reduce }) => { reduce[largest](row[score]); }); +`); + const application = join(project, "application.js"); + writeFileSync(application, `import { kernelOf } from ${JSON.stringify(join(copy, "dist/implementation/world/attach-kernel.js"))}; +import { kernel } from "reducing-library"; +export const description = kernelOf(kernel); +`); + const outDir = join(project, "dist"); + const output = await build({ + configFile: false, logLevel: "silent", root: project, plugins: [plugin()], + build: { outDir, minify: false, lib: { entry: application, formats: ["es"], fileName: "main" } }, + }); + const main = (Array.isArray(output) ? output : [output]).flatMap((result: Rollup.RollupOutput | Rollup.RollupWatcher) => "output" in result ? result.output : []) + .find((item): item is Rollup.OutputChunk => item.type === "chunk" && item.facadeModuleId === application); + const { description }: { description: KernelDescription | undefined } = await import(/* @vite-ignore */ pathToFileURL(join(outDir, main?.fileName ?? "")).href); + expect(description?.reductions.map(reduced => reduced.operator)).toEqual(["max"]); +}); + +test("an application's build says so when a library's dist doesn't register a resource it reduces", async () => { + const project = join(directory, "unregistered"); + await installCopy(project); + writeFileSync(join(project, "package.json"), JSON.stringify({ name: "unregistered-app", type: "module" })); + mkdirSync(join(project, "node_modules/unregistered-library/dist"), { recursive: true }); + writeFileSync(join(project, "node_modules/unregistered-library/package.json"), JSON.stringify({ name: "unregistered-library", type: "module", exports: "./dist/index.js" })); + // As a build without library mode leaves it: the types gone and no registrations in their place. + writeFileSync(join(project, "node_modules/unregistered-library/dist/index.js"), `import { System } from "@adobe/ecs"; +const score = Symbol(); +const largest = Symbol(); +export const kernel = System.create({ kernel: true, components: { with: [score] } }, (row, { reduce }) => { reduce[largest](row[score]); }); +`); + const application = join(project, "application.js"); + writeFileSync(application, `export { kernel } from "unregistered-library"; +`); + const built = build({ + configFile: false, logLevel: "silent", root: project, plugins: [plugin()], + build: { outDir: join(project, "dist"), minify: false, lib: { entry: application, formats: ["es"], fileName: "main" } }, + }); + await expect(built).rejects.toThrow("which its module's JavaScript doesn't register: declare it in TypeScript, and build a library declaring it with Compiler.plugin({ library: true })"); +}); + +test("a build records the keys a module declares, which the copy of the package the application loads registers", async () => { + const project = join(directory, "declaring"); + const copy = await installCopy(project); + writeFileSync(join(project, "package.json"), JSON.stringify({ name: "declaring-app", version: "1.0.0", type: "module" })); + const keys = join(project, "keys.ts"); + writeFileSync(keys, `import type { F32 } from "@adobe/ecs"; +export const speed = Symbol(); +declare module "@adobe/ecs" { + interface Resources { [speed]: { type: F32; default: 2 } } +} +`); + const application = join(project, "application.js"); + writeFileSync(application, `export { speed } from "./keys.ts";\n`); + const outDir = join(project, "dist"); + const output = await build({ + configFile: false, logLevel: "silent", root: project, plugins: [plugin()], + build: { outDir, minify: false, lib: { entry: application, formats: ["es"], fileName: "main" }, rolldownOptions: { external: ["@adobe/ecs"] } }, + }); + const chunks = (Array.isArray(output) ? output : [output]).flatMap((result: Rollup.RollupOutput | Rollup.RollupWatcher) => "output" in result ? result.output : []) + .filter((item): item is Rollup.OutputChunk => item.type === "chunk"); + // A library's build ships this: its declarations recorded for every copy of the package, importing nothing. + expect(chunks.map(chunk => chunk.code).join("\n")).toContain(`Symbol.for("@adobe/ecs/declarations@1")`); + expect(chunks.map(chunk => chunk.code).join("\n")).not.toMatch(/^import /m); + const main = chunks.find(chunk => chunk.facadeModuleId === application); + const { speed }: { speed: symbol } = await import(/* @vite-ignore */ pathToFileURL(join(outDir, main?.fileName ?? "")).href); + // The registry of the installed copy, loaded as an application loads the package, reads it. + await import(/* @vite-ignore */ pathToFileURL(join(copy, "dist/index.js")).href); + const { declarationOf }: { declarationOf: (key: unknown) => unknown } = await import(/* @vite-ignore */ pathToFileURL(join(copy, "dist/interface/registry/names.js")).href); + expect(declarationOf(speed)).toEqual({ type: { type: "number", precision: 1 }, default: 2 }); +}); + +test("a library's build in library mode ships its registrations and input schemas, its kernels as source and no ids", async () => { + const project = join(directory, "library"); + await installCopy(project); + writeFileSync(join(project, "package.json"), JSON.stringify({ name: "a-library", version: "2.0.0", type: "module" })); + const entry = join(project, "library.ts"); + writeFileSync(entry, `import { System, type F32 } from "@adobe/ecs"; +export const speed = Symbol(); +declare module "@adobe/ecs" { + interface Components { [speed]: { type: F32 } } +} +export const push = System.create({}, (_context, input: { readonly by: number }) => { void input; }); +export const slow = System.create({ kernel: true, components: { with: [speed] } }, row => { row[speed] = row[speed] / 2; }); +`); + const outDir = join(project, "dist"); + const output = await build({ + configFile: false, logLevel: "silent", root: project, plugins: [plugin({ library: true })], + build: { outDir, minify: false, lib: { entry, formats: ["es"], fileName: "index" }, rolldownOptions: { external: ["@adobe/ecs"] } }, + }); + const code = (Array.isArray(output) ? output : [output]).flatMap((result: Rollup.RollupOutput | Rollup.RollupWatcher) => "output" in result ? result.output : []) + .flatMap(item => item.type === "chunk" ? [item.code] : []).join("\n"); + expect(code).toContain(`Symbol.for("@adobe/ecs/declarations@1")`); + expect(code).toMatch(/declare\$\(speed, "component", \{ type: \{\s*"type": "number",\s*"precision": 1\s*\} \}\)/); + // The input's schema, passed to its create. + expect(code).toMatch(/"by": \{\s*"type": "number"\s*\}/); + expect(code).toContain(`row[speed] = row[speed] / 2`); + expect(code).not.toContain("attachKernel"); + expect(code).not.toContain("identify$"); +}); + test("serve accepts a module of keys evaluated again, which makes the same keys, systems, rules and finalizers by their ids", async () => { await serve(async server => { const first = await server.ssrLoadModule(evaluated); const again = await server.ssrLoadModule(`${evaluated}?evaluated=again`); - const { labelOfItem, sameItem } = await server.ssrLoadModule(JSON.parse(source("../../interface/registry/items.ts"))); + const { sameItem } = await server.ssrLoadModule(JSON.parse(source("../../interface/registry/items.ts"))); expect(again.score).not.toBe(first.score); expect([first.label, again.label]).toEqual(["score", "score"]); expect(again.system).not.toBe(first.system); expect([sameItem(again.system, first.system), sameItem(again.kernel, first.kernel), sameItem(again.kernel, again.system)]).toEqual([true, true, false]); expect(again.rule).toBe(first.rule); expect(again.release).toBe(first.release); - // The two systems share a name, so each is labeled with its id. - expect([labelOfItem(again.system), labelOfItem(first.kernel)]).toEqual(["keys/write (fixture-app/evaluated.js#system)", "keys/write (fixture-app/evaluated.js#kernel)"]); + // Each is labeled by its binding. + expect([again.system.name, first.kernel.name]).toEqual(["system", "kernel"]); }); }); @@ -313,7 +486,7 @@ test("serve reports a finalizer evaluated again under its id with another declar await serve(async server => { await server.ssrLoadModule(changedItems); writeFileSync(changedItems, keys(`{ type: { type: "number" } }`, "shown")); - await expect(server.ssrLoadModule(`${changedItems}?evaluated=changed`)).rejects.toThrow(`Cannot declare "keys/release" (fixture-app/changed-items.js#release): its id is another item's, with another declaration; if a module was evaluated again with a changed declaration, reload the page.`); + await expect(server.ssrLoadModule(`${changedItems}?evaluated=changed`)).rejects.toThrow(`Cannot declare "release" (fixture-app/changed-items.js#release): its id is another item's, with another declaration; if a module was evaluated again with a changed declaration, reload the page.`); }); }); @@ -321,7 +494,7 @@ test("serve reports a key evaluated again with another declaration", async () => await serve(async server => { await server.ssrLoadModule(changed); writeFileSync(changed, keys(`{ type: { type: "string" } }`)); - await expect(server.ssrLoadModule(`${changed}?evaluated=changed`)).rejects.toThrow(`Cannot register component "fixture-app/changed.js#score": its id is another key's, with another declaration`); + await expect(server.ssrLoadModule(`${changed}?evaluated=changed`)).rejects.toThrow(`Cannot register component "score" (fixture-app/changed.js#score): its id is another key's, with another declaration`); }); }); @@ -331,7 +504,7 @@ const idsIn = (code: string): readonly string[] => [...code.matchAll(/identify\$ test("a build gives top-level symbols ids naming no absolute path, also in workers", async () => { const outDir = join(directory, "dist-identified"); const output = await build({ - configFile: false, logLevel: "silent", root: directory, plugins: [kernelPlugin()], + configFile: false, logLevel: "silent", root: directory, plugins: [plugin()], build: { outDir, minify: false, rolldownOptions: { input: identified, preserveEntrySignatures: "strict" } }, }); const items = (Array.isArray(output) ? output : [output]).flatMap((result: Rollup.RollupOutput | Rollup.RollupWatcher) => "output" in result ? result.output : []); @@ -342,14 +515,14 @@ test("a build gives top-level symbols ids naming no absolute path, also in worke const ids = items.flatMap(item => idsIn(item.type === "chunk" ? item.code : String(item.source))); expect(ids.filter(id => id.startsWith("/") || id.includes(directory) || id.includes(tmpdir()))).toEqual([]); const { labels }: { labels: readonly string[] } = await import(/* @vite-ignore */ pathToFileURL(join(outDir, main?.fileName ?? "")).href); - // Keys sharing a description are labeled by their ids; a key whose description is its own, by its description. - expect(labels).toEqual(["fixture-app/identified.js#score", "fixture-app/identified.js#twin", "marker"]); + // Keys are labeled by their descriptions, however many share one. + expect(labels).toEqual(["score", "score", "marker"]); }); test("a second instance of the plugin in one bundle does nothing, as when an application also lists it for workers", async () => { const outDir = join(directory, "dist-twice"); const output = await build({ - configFile: false, logLevel: "silent", root: directory, plugins: [kernelPlugin(), kernelPlugin()], + configFile: false, logLevel: "silent", root: directory, plugins: [plugin(), plugin()], build: { outDir, minify: false, lib: { entry: fixture, formats: ["es"], fileName: "main" } }, }); const chunks = (Array.isArray(output) ? output : [output]).flatMap((result: Rollup.RollupOutput | Rollup.RollupWatcher) => "output" in result ? result.output : []) @@ -364,7 +537,7 @@ test("a second instance of the plugin in one bundle does nothing, as when an app test("a build takes a name from a package external to it as one key, and registration rejects a list there", async () => { const outDir = join(directory, "dist-external"); const output = await build({ - configFile: false, logLevel: "silent", root: directory, plugins: [kernelPlugin()], + configFile: false, logLevel: "silent", root: directory, plugins: [plugin()], build: { outDir, minify: false, rolldownOptions: { input: [externalKey, externalList], external: ["external-keys"], preserveEntrySignatures: "strict" } }, }); const chunks = (Array.isArray(output) ? output : [output]).flatMap((result: Rollup.RollupOutput | Rollup.RollupWatcher) => "output" in result ? result.output : []) @@ -372,5 +545,119 @@ test("a build takes a name from a package external to it as one key, and registr const url = (entry: string) => pathToFileURL(join(outDir, chunks.find(chunk => chunk.facadeModuleId === entry)?.fileName ?? "")).href; const { description }: { description: KernelDescription } = await import(/* @vite-ignore */ url(externalKey)); expect(description.keys.map(String)).toEqual(["Symbol(heat)"]); - await expect(import(/* @vite-ignore */ url(externalList))).rejects.toThrow(`System "externalList" names a list of components where a command takes one component; its kernel's build couldn't read the list, so name it from a module the build reads, or as an array literal.`); + await expect(import(/* @vite-ignore */ url(externalList))).rejects.toThrow(`names a list of components where a command takes one component; its kernel's build couldn't read the list, so name it from a module the build reads, or as an array literal.`); }); + +test("compiled code imports the runtime from the copy of the package its module imports, not the plugin's own", async () => { + // A project whose installed copy of the package is a directory of its own, as a lockfile's copy is beside a linked one. + const project = join(directory, "copied"); + await installCopy(project); + writeFileSync(join(project, "package.json"), JSON.stringify({ name: "copy-app", type: "module" })); + const module = join(project, "copied.js"); + writeFileSync(module, `import { System, Component } from "@adobe/ecs"; +const score = Symbol("score"); +declare$(score, "component", { type: { type: "number" } }); +export const kernel = System.create({ kernel: true, components: { with: [score] } }, (row) => { row[score] = row.entity * 2; }); +${recorder} +export const spawner = System.create({ kernel: false, components: { with: [score] } }, (row, { commands }) => { commands.spawn([score])({ [score]: row[score] }); }); +`); + const server = await createServer({ + configFile: false, logLevel: "silent", root: project, plugins: [plugin()], + appType: "custom", server: { middlewareMode: true, hmr: false, watch: null }, optimizeDeps: { noDiscovery: true }, + }); + try { + const code = (await server.transformRequest(module))?.code ?? ""; + const imported = [...code.matchAll(/^import .* from "([^"]+)";$/gm)].map(match => match[1] ?? ""); + // The server serves files under its root by their path from it: here, the project's copy. + expect(imported.filter(path => path.includes("/implementation/world/"))).toEqual([ + "/node_modules/@adobe/ecs/dist/implementation/world/attach-kernel.js", + "/node_modules/@adobe/ecs/dist/implementation/world/prepared-spawn.js", + ]); + expect(code).not.toContain(own); + // The copy's System namespace is recognized, so its declarations take their ids. + expect(code).toContain(`"copy-app/copied.js#kernel"`); + } finally { + await server.close(); + } +}); + +test("serve passes each system the schema of its body's input, from types other modules declare, and again when they change", async () => { + try { + await serve(async server => { + expect((await server.ssrLoadModule(inputs)).schemas).toEqual([ + { type: "object", properties: { entity: { type: "integer", format: "entity" }, by: { type: "number" } } }, + { type: "object", properties: { text: { type: "string" } }, required: [] }, + ]); + writeInputTypes("to"); + server.watcher.emit("change", inputTypes); + const [push] = (await server.ssrLoadModule(inputs)).schemas; + expect(push).toEqual({ type: "object", properties: { entity: { type: "integer", format: "entity" }, to: { type: "number" } } }); + }); + } finally { + writeInputTypes("by"); + } +}); + +test("a build fails at a system's input whose type can't be one", async () => { + const entry = join(directory, "bad-input.ts"); + writeFileSync(entry, `import { System } from ${source("../../interface/system/system.ts")};\nexport const bad = System.create({}, (_context, input: { readonly when: Big["when"] }) => { void input; });\n`); + await expect(build({ + configFile: false, logLevel: "silent", root: directory, plugins: [plugin()], + build: { outDir: join(directory, "dist-bad-input"), minify: false, lib: { entry, formats: ["es"], fileName: "main" } }, + })).rejects.toThrow("(typeof X)[keyof typeof X]"); +}); + +test("two copies of the package in one application register the keys it declares, and index lists apart", async () => { + // The application's copy, and another a library installs under its own node_modules. + const project = join(directory, "copies"); + await installCopy(project); + const library = join(project, "node_modules/other-library"); + await installCopy(library); + writeFileSync(join(project, "package.json"), JSON.stringify({ name: "copies-app", type: "module" })); + mkdirSync(join(library, "dist"), { recursive: true }); + writeFileSync(join(library, "package.json"), JSON.stringify({ name: "other-library", type: "module", exports: "./dist/index.js" })); + // Its systems are made when called, spawning a list made on every call. + writeFileSync(join(library, "dist/index.js"), `import { System, World } from "@adobe/ecs"; +export { World as OtherWorld }; +export const runOther = async (key) => { + const spawn = System.create(({ commands }) => { commands.spawn([key].slice())({ [key]: 10 }); }); + let total = 0; + const count = System.create({ observer: true, components: { with: [key] } }, (row) => { total += row[key]; }); + const world = World.create(); + await world.run(spawn); + await world.run(spawn); + await world.run(count); + await world.dispose(); + return total; +}; +`); + const application = join(project, "application.js"); + writeFileSync(application, `import { System, World } from "@adobe/ecs"; +import { OtherWorld, runOther } from "other-library"; +export const score = Symbol(); +declare$(score, "component", { type: { type: "number" } }); +${recorder} +const spawn = System.create(({ commands }) => { commands.spawn([score].slice())({ [score]: 1 }); }); +let counted = 0; +const count = System.create({ observer: true, components: { with: [score] } }, (row) => { counted += row[score]; }); +export const copies = World === OtherWorld ? 1 : 2; +export const run = async () => { + const world = World.create(); + await world.run(spawn); + await world.run(spawn); + await world.run(count); + await world.dispose(); + return [counted, await runOther(score)]; +}; +`); + const outDir = join(project, "dist"); + const output = await build({ + configFile: false, logLevel: "silent", root: project, plugins: [plugin()], + build: { outDir, minify: false, lib: { entry: application, formats: ["es"], fileName: "main" } }, + }); + const main = (Array.isArray(output) ? output : [output]).flatMap((result: Rollup.RollupOutput | Rollup.RollupWatcher) => "output" in result ? result.output : []) + .find((item): item is Rollup.OutputChunk => item.type === "chunk" && item.facadeModuleId === application); + const built: { copies: number; run: () => Promise } = await import(/* @vite-ignore */ pathToFileURL(join(outDir, main?.fileName ?? "")).href); + expect(built.copies).toBe(2); + expect(await built.run()).toEqual([2, 20]); +}, 120_000); diff --git a/packages/ecs/src/implementation/compiler/plugin.ts b/packages/ecs/src/implementation/compiler/plugin.ts index 928f760bd..9ebac9210 100644 --- a/packages/ecs/src/implementation/compiler/plugin.ts +++ b/packages/ecs/src/implementation/compiler/plugin.ts @@ -6,11 +6,15 @@ import { isAbsolute } from 'node:path'; import type { Plugin, Rollup, ViteDevServer } from 'vite'; import { estreeOf } from './estree.js'; import { describeFailure } from './failure.js'; -import { identify as identifyModule, moduleIdOf, type PackageNameOf } from './identify.js'; +import { identify as identifyModule, moduleIdOf, moduleLabelOf, type PackageOf } from './identify.js'; import { buildId, kernelHash, parseKernelId, serveUrl } from './kernel-id.js'; import { createLinker, type LinkHost, type Linker } from './link.js'; -import { isNamespaceModule, isSystemModule, packageIndex } from './paths.js'; -import { locateThroughMap } from './source-map.js'; +import { archetypeInsertions } from './archetypes.js'; +import { augmentationInsertions, reductionsIn, registeredReductionsIn, type AugmentationHost } from './augmentations.js'; +import { inputInsertions, type InputHost } from './input-schemas.js'; +import { copyForImporter, namespaceCopyOf, packagePathOf, typesFileOf } from './paths.js'; +import { moduleOf, type TypedProgram } from './type-schemas.js'; +import { insertInto, locateThroughMap } from './source-map.js'; import { transformKernels } from './transform.js'; const scripts = /\.[cm]?[jt]sx?$/; @@ -30,33 +34,61 @@ const loadFile = async (file: string): Promise<{ readonly code: string; readonly return { code: result.code, locate: result.map === undefined ? undefined : locateThroughMap(result.code, result.map.mappings) }; }; -/** The name each directory's package.json gives, read once per directory. */ -const packageNames = (): PackageNameOf => { - const names = new Map(); +/** The name and version each directory's package.json gives, read once per directory. */ +const packages = (): PackageOf => { + const found = new Map>(); return directory => { - if (names.has(directory)) return names.get(directory); - let name: string | null | undefined; + if (found.has(directory)) return found.get(directory); + let entry: ReturnType; try { const json: unknown = JSON.parse(readFileSync(`${directory}/package.json`, "utf8")); - const named: unknown = typeof json === "object" && json !== null ? Reflect.get(json, "name") : undefined; - name = typeof named === "string" && named !== "" ? named : null; + const name: unknown = typeof json === "object" && json !== null ? Reflect.get(json, "name") : undefined; + const version: unknown = typeof json === "object" && json !== null ? Reflect.get(json, "version") : undefined; + entry = typeof name !== "string" || name === "" ? null : typeof version === "string" && version !== "" ? { name, version } : { name }; } catch { // No package.json here, or none that parses. - name = undefined; + entry = undefined; } - names.set(directory, name); - return name; + found.set(directory, entry); + return entry; }; }; +/** Vite's parser of TypeScript, loaded by the first kernel transform, which reads `reduce` traits with it. */ +let parseTypes: typeof import("vite").parseAst | null = null; + +const noReductions: ReadonlyMap = new Map(); + +/** + * The `reduce` traits a file's declarations give: read from a TypeScript file's source, and from the registrations a + * JavaScript file's build gave them, as a library's `dist` ships them. + */ +const reductionsOf = (file: string): ReadonlyMap => { + const extension = file.slice(file.lastIndexOf(".") + 1); + const lang = loaders[extension] ?? (extension === "js" || extension === "mjs" ? "js" : undefined); + if (parseTypes === null || lang === undefined) return noReductions; + let code: string; + try { + code = readFileSync(file, "utf8"); + } + catch { + return noReductions; + } + if (!code.includes("reduce")) return noReductions; + if (lang === "ts" || lang === "tsx") return code.includes("declare module") ? reductionsIn(parseTypes(code, { lang }, file)) : noReductions; + // A bundle may have renamed the registrations' import, so any JavaScript giving `reduce` is read. + return registeredReductionsIn(parseTypes(code, { lang }, file)); +}; + /** Resolves imports through the build, from whichever transform runs. */ const buildHost = (resolver: () => Rollup.PluginContext): LinkHost => ({ + reductions: reductionsOf, resolve: async (specifier, importer) => { const resolved = await resolver().resolve(specifier, importer, { skipSelf: true }); if (resolved === null) return { error: `${specifier} can't be resolved from ${importer}` }; if (resolved.external) { - return specifier === packageName ? packageIndex : { error: `${specifier} is external to the build, so kernels can't inline from it`, unreadable: true }; + return specifier === packageName ? copyForImporter(importer).index : { error: `${specifier} is external to the build, so kernels can't inline from it`, unreadable: true }; } const id = resolved.id.split("?")[0] ?? resolved.id; return id.includes("\0") || !isAbsolute(id) ? { error: `${specifier} isn't a file` } : id; @@ -65,7 +97,7 @@ const buildHost = (resolver: () => Rollup.PluginContext): LinkHost => ({ }); /** - * Compiles kernel systems (`kernel: "cpu"` or `"gpu"`). Runs after TypeScript is stripped and before minification, so it sees plain JS + * Compiles kernel systems (`kernel: true` or `"gpu"`). Runs after TypeScript is stripped and before minification, so it sees plain JS * with source names. Each kernel's `create` becomes its own module at `description.url`: served from a * root-absolute url in dev, and emitted as a chunk in a build. * @@ -93,13 +125,109 @@ const inlineKernels = (code: string, kernels: readonly string[], hashes: readonl JSON.stringify(`data:text/javascript;base64,${Buffer.from(source).toString("base64")}`), ), code); -export const kernelPlugin = (): Plugin => { +const typed = /\.[cm]?tsx?$/; + +/** + * Registers the keys `declare module "@adobe/ecs"` blocks declare (`augmentations.ts`), passes each system's + * `create` the schema of its body's input, from the type of the body's parameter after its context + * (`input-schemas.ts`), and gives lists of keys declared `as const` their indexes (`archetypes.ts`). It runs before + * TypeScript is stripped, which all three read. + */ +const inputPlugin = (): Plugin => { + let repeated = false; + let server: ViteDevServer | undefined; + /** Modules whose input types each file declares, and the files each module's input types were read from. */ + const dependents = new Map>(); + const dependencies = new Map(); + const depend = (parent: string, files: readonly string[]): void => { + for (const file of dependencies.get(parent) ?? []) dependents.get(file)?.delete(parent); + dependencies.set(parent, files); + for (const file of files) { + const parents = dependents.get(file) ?? new Set(); + parents.add(parent); + dependents.set(file, parents); + } + }; + /** The package's own files' syntax as last parsed, which every module reading the package's types shares. */ + const parsed = new Map(); + const instance: Plugin = { + name: `${packageName}/input`, + enforce: "pre", + configResolved: config => { + repeated = config.plugins.find(other => other.name === instance.name) !== instance; + }, + configureServer: devServer => { + server = devServer; + }, + // A module whose input types come from a changed file is transformed again, for its schemas. + watchChange(id) { + for (const parent of dependents.get(id) ?? []) { + for (const module of server?.moduleGraph.getModulesByFile(parent) ?? []) server?.moduleGraph.invalidateModule(module); + } + }, + async transform(code, id) { + const file = id.split("?")[0] ?? id; + if (repeated || id.includes("\0") || !typed.test(file)) return null; + // Only a module declaring keys, creating systems or listing keys `as const` has anything for the passes. + if (!(code.includes("declare module") && code.includes("@adobe/ecs")) && !(code.includes("System") && code.includes(".create(")) && !code.includes("as const")) return null; + const { parseAst } = await import("vite"); + const resolved = async (specifier: string, importer: string) => await this.resolve(specifier, importer, { skipSelf: true }); + const host: InputHost & AugmentationHost = { + parse: (source, path) => { + const known = parsed.get(path); + if (known !== undefined && known.code === source) return known.program; + const program = parseAst(source, { lang: path.endsWith("x") ? "tsx" : "ts" }, path); + if (packagePathOf(path) !== null) parsed.set(path, { code: source, program }); + return program; + }, + resolve: async (specifier, importer) => { + const found = await resolved(specifier, importer); + if (found === null) return null; + // A build that treats the package as external still reads its types, from the importer's copy. + const path = found.external ? (specifier === packageName ? copyForImporter(importer).index : null) : found.id.split("?")[0] ?? found.id; + return path === null || path.includes("\0") || !isAbsolute(path) ? null : typesFileOf(path); + }, + load: file => readFile(file, "utf8"), + packagePath: packagePathOf, + isSystem: async (specifier, importer) => { + const found = await resolved(specifier, importer); + return found !== null && (found.external ? specifier === packageName : namespaceCopyOf(found.id, "System") !== null); + }, + }; + const module = moduleOf(file, code, host.parse(code, file)); + const results = [await augmentationInsertions(module, host), await inputInsertions(module, host), archetypeInsertions(module)].filter(result => result !== null); + const read = [...new Set(results.flatMap(result => result.dependencies))].filter(dependency => dependency !== file); + for (const dependency of read) this.addWatchFile(dependency); + depend(file, read); + for (const result of results) if ("failure" in result) return this.error(describeFailure(result.failure), result.failure.position); + const insertions = results.flatMap(result => "insertions" in result ? result.insertions : []); + return insertions.length === 0 ? null : insertInto(code, insertions, file); + }, + }; + return instance; +}; + +/** + * The plugin's two parts: registrations and input schemas, before TypeScript is stripped, and kernels and ids, + * after. A library's build takes `{ library: true }`, the first part alone: its build then ships the registrations and + * schemas its types give, which an application's build, reading only JavaScript there, can't make, and leaves its + * kernels as source and its keys without ids, which the application's plugin compiles and gives. + */ +export const plugin = (options: { readonly library?: boolean } = {}): Plugin[] => pluginWith(options); + +/** + * The plugin, for this package's tests: with `automatic: false`, unflagged systems run as written, so suites of the + * row path run their bodies that way too. + */ +export const pluginWith = ({ library = false, automatic = true }: { readonly library?: boolean; readonly automatic?: boolean }): Plugin[] => + library ? [inputPlugin()] : [inputPlugin(), compilePlugin(automatic)]; + +const compilePlugin = (automatic: boolean): Plugin => { let serve = true; - let root = process.cwd(); /** Whether an earlier instance of the plugin is in the same bundle. */ let repeated = false; let server: ViteDevServer | undefined; - const packageNameOf = packageNames(); + const packageOf = packages(); /** * A linker per environment, since client and SSR builds resolve imports under different conditions. Each resolves * through its environment's latest transform. @@ -140,15 +268,18 @@ export const kernelPlugin = (): Plugin => { chunks.add(referenceId); return referenceId; }; - const plugin: Plugin = { + const instance: Plugin = { name: `${packageName}/kernel`, enforce: "post", - // Dep pre-bundling breaks the runtime's `new Worker(new URL("./worker.js", import.meta.url))`. - config: () => ({ optimizeDeps: { exclude: [packageName] }, worker: { plugins: () => [kernelPlugin()] } }), + // Dep pre-bundling breaks the runtime's `new Worker(new URL("./worker.js", import.meta.url))`. Kernels are chunks + // of their own, so a worker bundle holding one is a module, unless the configuration says otherwise. + config: user => ({ + optimizeDeps: { exclude: [packageName] }, + worker: { plugins: () => plugin(), ...user.worker?.format === undefined ? { format: "es" as const } : {} }, + }), configResolved: config => { serve = config.command === "serve"; - root = config.root; - repeated = config.plugins.find(other => other.name === plugin.name) !== plugin; + repeated = config.plugins.find(other => other.name === instance.name) !== instance; }, configureServer: devServer => { server = devServer; @@ -189,23 +320,27 @@ export const kernelPlugin = (): Plugin => { async transform(code, id, options) { if (repeated || id.includes("\0") || !scripts.test(id.split("?")[0] ?? id)) return null; const parse = (input: string) => estreeOf(this.parse(input)); - const identify = (input: string) => identifyModule(input, moduleIdOf(id, root, packageNameOf), parse, async (source, namespace) => { - const resolved = await this.resolve(source, id); - return resolved !== null && (resolved.external ? source === packageName : isNamespaceModule(resolved.id, namespace)); - }); + const identify = async (input: string) => { + const identified = await identifyModule(input, moduleIdOf(id, packageOf), parse, async (source, namespace) => { + const resolved = await this.resolve(source, id); + return resolved !== null && (resolved.external ? source === packageName : namespaceCopyOf(resolved.id, namespace) !== null); + }); + return identified !== null && "failure" in identified ? this.error(identified.failure) : identified; + }; const ssr = options?.ssr === true; + parseTypes ??= (await import("vite")).parseAst; const linker = linkerFor(ssr, this); const hashes: string[] = []; - const transformed = await transformKernels(code, id, parse, async source => { + const transformed = await transformKernels(code, id, parse, async (source, namespace) => { const resolved = await this.resolve(source, id); - return resolved !== null && isSystemModule(resolved.id); + return resolved === null ? null : namespaceCopyOf(resolved.id, namespace); }, (index, identity) => { const hash = kernelHash(identity); hashes[index] = hash; if (!serve) return `import.meta.ROLLUP_FILE_URL_${emitChunk(this, buildId(id, index), hash)}`; // Served to node, a kernel's module is its url, so a pool's workers, which import it outside the server, read it too. return JSON.stringify(ssr ? inlinePlaceholder(index, hash) : serveUrl(id, index, hash)); - }, linker); + }, linker, automatic, moduleLabelOf(id, packageOf)); const result = ssr && transformed !== null && !("failure" in transformed) ? { ...transformed, code: inlineKernels(transformed.code, transformed.kernels, hashes) } : transformed; const parent = id.split("?")[0] ?? id; if (result === null) { @@ -216,9 +351,10 @@ export const kernelPlugin = (): Plugin => { for (const dependency of result.dependencies) this.addWatchFile(dependency); depend(parent, result.dependencies); if ("failure" in result) return this.error(describeFailure(result.failure), result.failure.position); + for (const warning of result.warnings) this.warn(warning); kernels.set(id, result.kernels.map((source, index) => ({ source, hash: hashes[index] ?? "" }))); return await identify(result.code) ?? { code: result.code, map: null }; }, }; - return plugin; + return instance; }; diff --git a/packages/ecs/src/implementation/compiler/print.ts b/packages/ecs/src/implementation/compiler/print.ts index 2201e6ba1..4f73d970a 100644 --- a/packages/ecs/src/implementation/compiler/print.ts +++ b/packages/ecs/src/implementation/compiler/print.ts @@ -1,9 +1,10 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { chunkShift, type Path } from '../world/kernel.js'; +import { blockShift, chunkShift, type Path } from '../world/kernel.js'; import type { Expression, Statement } from './ir.js'; import { resolve, type Out } from './out.js'; import { rowLabel } from './statement.js'; import type { Unit } from './unit.js'; +import { presence } from './columns.js'; /** A number as JS source that reads back as the same f64. */ export const printNumber = (value: number): string => @@ -11,6 +12,9 @@ export const printNumber = (value: number): string => const printPath = (path: Path): string => path.map(item => `[${JSON.stringify(item)}]`).join(""); +/** The presence of the optional component the key at `key` names. */ +const presenceOf = (unit: Unit, key: number): Expression => presence(unit, unit.keys[key] ?? ""); + export const localName = (unit: Unit, local: number): string => `${unit.names.get(local) ?? "t"}_${local}`; /** Prints an expression. `observed` converts boolean column reads. */ @@ -21,10 +25,16 @@ export const printExpression = (unit: Unit, expression: Expression, observed: bo case "boolean": return String(expression.value); case "column": { const column = expression.column; - if (!observed) return `c${column}[x${column}]`; - unit.booleans.add(column); - return `(b${column} ? c${column}[x${column}] !== 0 : c${column}[x${column}])`; + // In the loop for archetypes without every optional component, theirs read undefined without a load. + if (unit.presentAs === false && unit.optionalColumns.has(column)) return "undefined"; + // An optional component's column is empty in an archetype without it, where it reads undefined. + const read = observed + ? (unit.booleans.add(column), `(b${column} ? c${column}[x${column}] !== 0 : c${column}[x${column}])`) + : `c${column}[x${column}]`; + const key = unit.optionalColumns.get(column); + return key !== undefined && observed ? `(${print(presenceOf(unit, key))} ? ${read} : undefined)` : read; } + case "present": return unit.presentAs === null ? `q${expression.column}` : String(unit.presentAs); case "upColumn": { const column = expression.column; const read = `u${column}[p * us${column} + uo${column}]`; @@ -66,10 +76,18 @@ const printStatement = (unit: Unit, statement: Statement, labels: Labels): strin return `${declare}${localName(unit, statement.local)} = ${printExpression(unit, statement.value, unit.observed.has(statement.local))};`; } case "declare": return `let ${statement.locals.map(item => localName(unit, item)).join(", ")};`; - case "store": return `c${statement.column}[x${statement.column}] = ${value(statement.value)};`; + case "store": { + const { column } = statement; + const store = `c${column}[x${column}] = ${value(statement.value)};`; + // Assigning an optional component an entity lacks fails its system, as a body run as written does. + const key = unit.optionalColumns.get(column); + if (key === undefined) return store; + const present = printExpression(unit, presenceOf(unit, key), false); + return present === "true" ? store : present === "false" ? `absent(${key});` : `if (${present}) ${store} else absent(${key});`; + } case "stamp": { const row = `c${statement.column}[x${statement.column}] = tick;`; - return unit.stampedPerCall.has(statement.chunks) ? row : `${row}\nc${statement.chunks}[i >> ${chunkShift}] = tick;`; + return unit.stampedPerCall.has(statement.chunks) ? row : `${row}\nc${statement.blocks}[i >> ${blockShift}] = tick;\nc${statement.chunks}[i >> ${chunkShift}] = tick;`; } case "if": { const alternate = printStatements(unit, statement.alternate, labels); diff --git a/packages/ecs/src/implementation/compiler/program.ts b/packages/ecs/src/implementation/compiler/program.ts index 790af9962..f0d566a35 100644 --- a/packages/ecs/src/implementation/compiler/program.ts +++ b/packages/ecs/src/implementation/compiler/program.ts @@ -109,6 +109,8 @@ export const toProgram = (statements: readonly Statement[], nameOf?: (local: num switch (node.kind) { case "number": case "boolean": case "column": case "resource": case "args": return node; + // WASM runs only archetypes with every optional component; the others run the JS kernel. + case "present": return { kind: "boolean", value: true }; // JS throws reading a parent that isn't there, so WASM reads one only where a test proves it is. case "upColumn": return guards.has(upHandle) ? { kind: "upLeaf", column: node.column } : undefined; case "local": return { kind: "local", local: localIndex(node.local) }; diff --git a/packages/ecs/src/interface/archetype/index.ts b/packages/ecs/src/implementation/compiler/public.ts similarity index 60% rename from packages/ecs/src/interface/archetype/index.ts rename to packages/ecs/src/implementation/compiler/public.ts index 08656ff16..2fa02a6e6 100644 --- a/packages/ecs/src/interface/archetype/index.ts +++ b/packages/ecs/src/implementation/compiler/public.ts @@ -1,2 +1,2 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -export * from './create.js'; +export { plugin } from './plugin.js'; diff --git a/packages/ecs/src/implementation/compiler/signature.ts b/packages/ecs/src/implementation/compiler/signature.ts index 7dbcf071f..b71c510bf 100644 --- a/packages/ecs/src/implementation/compiler/signature.ts +++ b/packages/ecs/src/implementation/compiler/signature.ts @@ -10,7 +10,7 @@ import type { ModuleScope } from './module-scope.js'; * A lookup by entity, or through an index (`index`), whose calls take a key and give a list of rows. A lookup by * entity that `watches` declares `changed`: its result has `changed` after what it reads. */ -export type Lookup = { readonly name: string; readonly read: readonly string[]; readonly index: boolean; readonly watches: boolean }; +export type Lookup = { readonly name: string; readonly read: readonly string[]; readonly index: boolean; readonly watches: boolean; readonly optional: boolean }; /** The reference rows run down, and what the context's `up` reads of the entity it names. */ export type Up = { readonly through: string; readonly read: readonly string[] }; @@ -25,13 +25,16 @@ export type Reduced = { readonly resource: string; readonly operator: Reduction export type Signature = { readonly read: ReadonlySet; readonly write: ReadonlySet; + /** What it requires of its rows, its `with`. */ + readonly has: ReadonlySet; + /** Components its rows may lack, its `optional`: an archetype without one gives the kernel an empty column. */ + readonly optional: ReadonlySet; readonly resources: readonly string[]; readonly reduced: readonly Reduced[]; readonly lookups: readonly Lookup[]; readonly up: Up | null; /** Whether its rows run in an index's order, as a list, as `up`'s do. */ readonly ordered: boolean; - readonly input: boolean; /** Whether it filters on `added` or `changed` components, or on changed resources. */ readonly filtered: boolean; }; @@ -62,30 +65,61 @@ const keyLists = (node: Expression | Pattern, what: string, allowed: readonly st ? entry(name, identifiers(property.value, `${what}.${name}`, lists)) : fail(property, `${what}.${name} is not allowed in a kernel declaration`))); -/** `reduce: { [resource]: "operator", ... }`, with resources bound at module scope. */ -const reductions = (node: Expression | Pattern, module: ModuleScope): readonly Reduced[] => { - if (node.type !== "ObjectExpression") return fail(node, "reduce must be an object literal in a kernel declaration"); - return node.properties.map((property): Reduced => { - if (property.type === "SpreadElement" || !property.computed || property.key.type !== "Identifier" || !module.has(property.key.name)) { - return fail(property, "reduce names each resource as a computed key bound at module scope, as in { [total]: \"+\" }"); +/** `resources: { reduce: [resource, ...] }`, each folded with its declaration's `reduce` trait. */ +const reductions = (node: Expression | Pattern, lists: Lists): readonly Reduced[] => { + const names = identifiers(node, "resources.reduce", lists); + return names.map((resource): Reduced => { + const found = lists.reduction(resource); + const operator = found?.operator; + if (!isReduction(operator)) { + const missing = found !== null && /\.m?js$/.test(found.file) + ? `which its module's JavaScript doesn't register: declare it in TypeScript, and build a library declaring it with Compiler.plugin({ library: true })` + : "which it can't find: declare the resource with reduce in the module that makes its key"; + return fail(node, `${resource} is reduced, so the build reads its operator from its declaration's reduce trait, ${operator === undefined ? missing : `"${operator}", which isn't one of ${reductionOperators.map(item => JSON.stringify(item)).join(", ")}`}`); } - const { value } = property; - const operator = value.type === "Literal" ? value.value : undefined; - if (!isReduction(operator)) return fail(value, `a resource is reduced with one of ${reductionOperators.map(item => JSON.stringify(item)).join(", ")}`); - return { resource: property.key.name, operator }; + return { resource, operator }; }).map((entry, index, all) => all.findIndex(other => other.resource === entry.resource) === index ? entry : fail(node, `${entry.resource} is reduced twice`)); }; -/** What kind of kernel a `System.create` declaration compiles to, or null when it isn't a kernel. */ -export const kernelOf = (declaration: ObjectExpression): "cpu" | "gpu" | null => { +/** How a `System.create` declaration's body compiles: to `target`, and whether its `kernel` requires it or it's tried. */ +export type KernelRequest = { readonly target: "cpu" | "gpu"; readonly required: boolean }; + +/** Whether a declaration has property `name`, written out. */ +const declares = (declaration: ObjectExpression, name: string): boolean => declaration.properties.some(property => + property.type === "Property" && !property.computed && property.key.type === "Identifier" && property.key.name === name); + +/** A declaration's `components.optional` property, or null without one. */ +const hasOptional = (declaration: ObjectExpression): Property | null => { + for (const property of declaration.properties) { + if (property.type !== "Property" || property.computed || property.key.type !== "Identifier" || property.key.name !== "components") continue; + if (property.value.type !== "ObjectExpression") return null; + for (const field of property.value.properties) { + if (field.type === "Property" && !field.computed && field.key.type === "Identifier" && field.key.name === "optional") return field; + } + } + return null; +}; + +/** + * How a `System.create` declaration compiles to a kernel, or null when it doesn't: `kernel: true` or `"gpu"` requires + * it, `false` rules it out, and without it a system with components is tried, unless it observes, runs on one entity + * or spreads what the build can't read. + */ +export const kernelOf = (declaration: ObjectExpression): KernelRequest | null => { + const optional = hasOptional(declaration); for (const property of declaration.properties) { if (property.type === "Property" && !property.computed && property.key.type === "Identifier" && property.key.name === "kernel") { const { value } = property; - if (value.type === "Literal" && (value.value === "cpu" || value.value === "gpu")) return value.value; - return fail(value, `kernel must be the literal "cpu" or "gpu"`); + if (value.type === "Literal" && value.value === "gpu" && optional !== null) return fail(optional, "a GPU kernel can't have optional components: WGSL has no undefined to read where an archetype lacks one, and a GPU kernel can't fail its system for one row; list them in with, or use kernel: true to run it on the CPU"); + if (value.type === "Literal" && value.value === true) return { target: "cpu", required: true }; + if (value.type === "Literal" && value.value === "gpu") return { target: "gpu", required: true }; + if (value.type === "Literal" && value.value === false) return null; + return fail(value, `kernel must be the literal true, false or "gpu"`); } } - return null; + if (declaration.properties.some(property => property.type === "SpreadElement")) return null; + if (!declares(declaration, "components") || declares(declaration, "observer") || declares(declaration, "target")) return null; + return { target: "cpu", required: false }; }; /** Reads and validates a kernel declaration; `lists` reads its lists of keys. */ @@ -94,27 +128,25 @@ export const signature = (declaration: ObjectExpression, module: ModuleScope, li let resources: readonly string[] = []; let reduced: readonly Reduced[] = []; let changedResources: readonly string[] = []; - let input = false; let up: Up | null = null; let ordered = false; const lookups: Lookup[] = []; for (const [name, property] of properties(declaration, "a kernel declaration")) { switch (name) { case "components": - components = keyLists(property.value, "components", ["read", "write", "has", "not", "added", "changed"], lists); + components = keyLists(property.value, "components", ["read", "write", "with", "optional", "not", "added", "changed"], lists); break; case "resources": for (const [list, value] of properties(property.value, "resources")) { // A condition in `is` is readable too; one in `not` is only checked before the kernel runs. if (list === "read" || list === "is") resources = [...resources, ...identifiers(value.value, `resources.${list}`, lists)]; else if (list === "changed") changedResources = identifiers(value.value, "resources.changed", lists); + else if (list === "reduce") reduced = reductions(value.value, lists); else if (list === "not") continue; + else if (list === "write") fail(value, "a kernel can't write resources: write them from a system that runs as written, or reduce into them"); else fail(value, `resources.${list} is not allowed in a kernel declaration`); } break; - case "reduce": - reduced = reductions(property.value, module); - break; case "lookup": for (const [lookup, value] of properties(property.value, "lookup")) { // The index is a value the world resolves; only that a lookup goes through one changes how it compiles. @@ -122,13 +154,13 @@ export const signature = (declaration: ObjectExpression, module: ModuleScope, li const index = fields.some(([field]) => field === "index"); const listed = fields.filter(([field]) => field !== "index"); const read = listed.flatMap(([field, item]) => { - if (index ? field !== "read" && field !== "changed" : field !== "read" && field !== "has" && field !== "not" && field !== "changed") return fail(item, `lookup.${lookup}.${field} is not allowed in a kernel declaration`); + if (index ? field !== "read" && field !== "changed" : field !== "read" && field !== "with" && field !== "optional" && field !== "not" && field !== "changed") return fail(item, `lookup.${lookup}.${field} is not allowed in a kernel declaration`); // An index lookup's `changed: true` filters the system's runs, which the world does before calling it. if (index && field === "changed" && item.value.type === "Literal" && item.value.value === true) return []; const names = identifiers(item.value, `lookup.${lookup}.${field}`, lists); return field === "read" ? names : []; }); - lookups.push({ name: lookup, read, index, watches: !index && listed.some(([field]) => field === "changed") }); + lookups.push({ name: lookup, read, index, watches: !index && listed.some(([field]) => field === "changed"), optional: listed.some(([field]) => field === "optional") }); } break; case "up": { @@ -145,20 +177,17 @@ export const signature = (declaration: ObjectExpression, module: ModuleScope, li up = { through: through ?? fail(property, "up needs through"), read }; break; } - case "input": - input = true; - break; // The index is a value the world resolves; only that rows run as a list changes how it compiles. case "order": ordered = true; break; - case "name": case "kernel": case "before": case "after": case "during": + case "kernel": case "before": case "after": case "during": break; default: fail(property, `${name} is not allowed in a kernel declaration`); } } - if (components === undefined) return fail(declaration, "a kernel needs components"); + if (components === undefined) return fail(declaration, "a system without components runs once, so there's nothing to compile"); for (const { resource } of reduced) { if (resources.includes(resource) || changedResources.includes(resource)) fail(declaration, `${resource} is reduced, so it can't also be read or filtered on`); } @@ -167,5 +196,5 @@ export const signature = (declaration: ObjectExpression, module: ModuleScope, li if (both !== undefined) fail(declaration, `${both} is in both components read and write; write already makes it readable`); const rowFiltered = (components.get("added")?.length ?? 0) + (components.get("changed")?.length ?? 0) > 0; if (up !== null && ordered) fail(declaration, "a kernel can't use both up and order, which both decide the order of its rows"); - return { read: new Set(components.get("read")), write: new Set(write), resources, reduced, lookups, up, ordered, input, filtered: changedResources.length > 0 || rowFiltered }; + return { read: new Set(components.get("read")), write: new Set(write), has: new Set(components.get("with")), optional: new Set(components.get("optional")), resources, reduced, lookups, up, ordered, filtered: changedResources.length > 0 || rowFiltered }; }; diff --git a/packages/ecs/src/implementation/compiler/snapshot.ts b/packages/ecs/src/implementation/compiler/snapshot.ts index 89fc66e3d..41ba99803 100644 --- a/packages/ecs/src/implementation/compiler/snapshot.ts +++ b/packages/ecs/src/implementation/compiler/snapshot.ts @@ -13,10 +13,12 @@ export type Snapshot = { readonly hint: string; readonly read: (path: Path) => Expression; readonly leaves: Map; + /** Of an optional component's whole value: whether the call's archetype has it, which tests for undefined read. */ + readonly presence: Expression | null; }; -export const createSnapshot = (keyword: "const" | "let", hint: string, read: (path: Path) => Expression): Snapshot => - ({ keyword, hint, read, leaves: new Map() }); +export const createSnapshot = (keyword: "const" | "let", hint: string, read: (path: Path) => Expression, presence: Expression | null = null): Snapshot => + ({ keyword, hint, read, leaves: new Map(), presence }); /** The local holding one leaf of `snapshot`. */ export const snapshotLeaf = (unit: Unit, snapshot: Snapshot, path: Path): number => { diff --git a/packages/ecs/src/implementation/compiler/spawns.ts b/packages/ecs/src/implementation/compiler/spawns.ts index a6615a7b9..4cb5e3187 100644 --- a/packages/ecs/src/implementation/compiler/spawns.ts +++ b/packages/ecs/src/implementation/compiler/spawns.ts @@ -1,6 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { Expression, Node, Property, SpreadElement } from 'estree'; -import { children, declaredIn } from './estree.js'; +import { children, declaredIn, patternNames } from './estree.js'; import { fail } from './failure.js'; import type { ModuleScope } from './module-scope.js'; @@ -31,15 +31,15 @@ const keyOf = (property: Property | SpreadElement, module: ModuleScope, declared return module.has(key.name) && !declared.has(key.name) ? key.name : null; }; -/** Whether an add's components are a list as written: an array literal, or a module constant of one or of `Archetype.create`. */ +/** Whether an add's components are a list as written: an array literal, or a module constant of one, as the plugin may give it an index. */ const listed = (components: Expression, module: ModuleScope, declared: ReadonlySet): boolean => { if (components.type === "ArrayExpression") return true; if (components.type !== "Identifier" || declared.has(components.name)) return false; const binding = module.get(components.name); if (binding?.kind !== "const") return false; const { init } = binding; - return init.type === "ArrayExpression" || (init.type === "CallExpression" && init.callee.type === "MemberExpression" && !init.callee.computed - && init.callee.property.type === "Identifier" && init.callee.property.name === "create" && init.arguments[0]?.type === "ArrayExpression"); + return init.type === "ArrayExpression" || (init.type === "CallExpression" && init.callee.type === "Identifier" && /^archetype\$\d*$/.test(init.callee.name) + && init.arguments[0]?.type === "ArrayExpression"); }; const plainKeyFailure = "a command's keys are its components' symbols, as [key]; a plain or string key is never a component's"; @@ -62,13 +62,16 @@ const commandsOf = (parameter: Node | undefined): { readonly name: string; reado * an object literal of computed keys, each a module-scope constant. A body that redeclares the context or its commands anywhere is left alone, and so is a * key it shadows. */ -export const spawnSites = (body: Node, module: ModuleScope, context: number): { readonly commands: string; readonly sites: readonly SpawnSite[] } | null => { +export const spawnSites = (body: Node, module: ModuleScope, context: number, shadowed: ReadonlySet = new Set()): { readonly commands: string; readonly sites: readonly SpawnSite[] } | null => { if (body.type !== "ArrowFunctionExpression" && body.type !== "FunctionExpression") return null; const reached = commandsOf(body.params[context]); if (reached === null) return null; const declared = new Set(); declaredIn(body.body, name => declared.add(name)); if (declared.has(reached.name)) return null; + // Names declared around the body, and its other parameters, shadow a module's keys too. + for (const name of shadowed) declared.add(name); + for (const name of body.params.flatMap(parameter => parameter === body.params[context] ? [] : patternNames(parameter))) declared.add(name); const isCommands = (node: Node): boolean => reached.viaContext ? node.type === "MemberExpression" && !node.computed && !node.optional && node.object.type === "Identifier" && node.object.name === reached.name && node.property.type === "Identifier" && node.property.name === "commands" @@ -113,3 +116,56 @@ export const spawnSites = (body: Node, module: ModuleScope, context: number): { visit(body.body); return { commands: reached.viaContext ? `${reached.name}.commands` : reached.name, sites }; }; + +/** An array literal of keys a command takes as its components, and the keys' names. */ +export type ListSite = { readonly start: number; readonly end: number; readonly names: readonly string[] }; + +/** Whether a module-scope name is a key: a constant of `Symbol(...)`, or an import, which the appended function checks. */ +const isKeyName = (name: string, module: ModuleScope, declared: ReadonlySet): boolean => { + if (declared.has(name)) return false; + const binding = module.get(name); + if (binding?.kind === "import") return true; + return binding?.kind === "const" && binding.init.type === "CallExpression" && binding.init.callee.type === "Identifier" && binding.init.callee.name === "Symbol"; +}; + +/** + * The array literals of keys a main-thread system body passes its own commands as components, `spawn([...])`, + * `add(entity, [...], ...)` and `remove(entity, [...])`, which the transform makes once, with an index, rather than on + * every call. A list naming a key twice fails. + */ +export const listSites = (body: Node, module: ModuleScope, context: number, shadowed: ReadonlySet = new Set()): readonly ListSite[] => { + if (body.type !== "ArrowFunctionExpression" && body.type !== "FunctionExpression") return []; + const reached = commandsOf(body.params[context]); + if (reached === null) return []; + const declared = new Set(); + declaredIn(body.body, name => declared.add(name)); + if (declared.has(reached.name)) return []; + // Names declared around the body, and its other parameters, shadow a module's keys too. + for (const name of shadowed) declared.add(name); + for (const name of body.params.flatMap(parameter => parameter === body.params[context] ? [] : patternNames(parameter))) declared.add(name); + const isCommands = (node: Node): boolean => reached.viaContext + ? node.type === "MemberExpression" && !node.computed && !node.optional && node.object.type === "Identifier" && node.object.name === reached.name + && node.property.type === "Identifier" && node.property.name === "commands" + : node.type === "Identifier" && node.name === reached.name; + const sites: ListSite[] = []; + const visit = (node: Node): void => { + if (node.type === "CallExpression" && !node.optional && node.callee.type === "MemberExpression" && !node.callee.computed && !node.callee.optional + && isCommands(node.callee.object) && node.callee.property.type === "Identifier") { + const { name } = node.callee.property; + const list = name === "spawn" && node.arguments.length === 1 ? node.arguments[0] + : (name === "add" || name === "remove") && node.arguments.length >= 2 ? node.arguments[1] + : undefined; + if (list?.type === "ArrayExpression" && list.elements.length > 0) { + const names = list.elements.map(element => element?.type === "Identifier" && isKeyName(element.name, module, declared) ? element.name : null); + if (names.every((key): key is string => key !== null)) { + const repeated = names.find((key, k) => names.indexOf(key) < k); + if (repeated !== undefined) return fail(list, `this list names ${repeated} twice; a list of components names each once`); + sites.push({ start: list.start, end: list.end, names }); + } + } + } + for (const child of children(node)) visit(child); + }; + visit(body.body); + return sites; +}; diff --git a/packages/ecs/src/implementation/compiler/stamps.ts b/packages/ecs/src/implementation/compiler/stamps.ts index 5db1c3cb9..f1fb7796f 100644 --- a/packages/ecs/src/implementation/compiler/stamps.ts +++ b/packages/ecs/src/implementation/compiler/stamps.ts @@ -11,15 +11,36 @@ const exits = (statement: Statement): boolean => { } }; +/** A chunk column stamped once per call: its block column, and the presence column the call's stamp depends on, if any. */ +export type PerCall = { readonly blocks: number; readonly when: number | null }; + /** - * Chunk columns stamped on every row: by a top-level stamp that no earlier `next` can skip. Every row in - * `[start, end)` runs the body, so these chunks can be stamped once per call instead of per row. + * Chunk columns stamped on every row, with their block columns: by a top-level stamp that no earlier `next` can skip, + * or by stamps all in tests of one optional component's presence, the same for every row of a call. Every row in + * `[start, end)` runs the body, so these chunks and blocks can be stamped once per call instead of per row. A chunk + * stamped under tests of different components is left to its stamps per row, since no one test covers it. */ -export const unconditionalChunks = (rows: readonly Statement[]): ReadonlySet => { - const chunks = new Set(); - for (const statement of rows) { - if (exits(statement)) break; - if (statement.kind === "stamp") chunks.add(statement.chunks); - } +export const unconditionalChunks = (rows: readonly Statement[]): ReadonlyMap => { + const chunks = new Map(); + const mixed = new Set(); + const visit = (statements: readonly Statement[], when: number | null): void => { + for (const statement of statements) { + if (exits(statement)) break; + if (statement.kind === "stamp") { + const found = chunks.get(statement.chunks); + if (when === null) { + chunks.set(statement.chunks, { blocks: statement.blocks, when }); + mixed.delete(statement.chunks); + } + else if (found === undefined && !mixed.has(statement.chunks)) chunks.set(statement.chunks, { blocks: statement.blocks, when }); + else if (found !== undefined && found.when !== null && found.when !== when) { + chunks.delete(statement.chunks); + mixed.add(statement.chunks); + } + } + if (statement.kind === "if" && when === null && statement.test.kind === "present") visit(resolve(statement.consequent), statement.test.column); + } + }; + visit(rows, null); return chunks; }; diff --git a/packages/ecs/src/implementation/compiler/subset.md b/packages/ecs/src/implementation/compiler/subset.md index 478f11bd2..92de16f55 100644 --- a/packages/ecs/src/implementation/compiler/subset.md +++ b/packages/ecs/src/implementation/compiler/subset.md @@ -1,6 +1,6 @@ # Kernel subset -What a kernel body (`kernel: "cpu"` or `"gpu"`) may contain. The build rejects anything else with a message at the +What a kernel body (`kernel: true` or `"gpu"`) may contain. The build rejects anything else with a message at the offending code. It is plain TypeScript; types are stripped before compiling. ``` @@ -62,7 +62,7 @@ call = Math-function | helper | lookup | command | reducer - **Commands:** `context.commands.spawn(components)({ [component]: value, ... })`, `despawn(entity)`, `add(entity, component, value)` or `add(entity, components, { ... })`, and `remove(entity, component)` or `remove(entity, components)`, with component keys bound at module scope. A list of components is an array literal of - them, or a constant holding one or `Archetype.create` of one, in the kernel's module or imported, also through + them, or a constant holding one (`as const`), in the kernel's module or imported, also through re-exports and namespaces, its keys read in the scope of the module that declares it; a component it lists that a command gives no value starts at its start. A list the build can't read, such as one computed when its module runs, fails the build at the command. `add` and `remove` take a name as one key when its definition is `Symbol(...)`, as a diff --git a/packages/ecs/src/implementation/compiler/transform.test.ts b/packages/ecs/src/implementation/compiler/transform.test.ts index bd257acac..bea7ec9d1 100644 --- a/packages/ecs/src/implementation/compiler/transform.test.ts +++ b/packages/ecs/src/implementation/compiler/transform.test.ts @@ -5,10 +5,10 @@ import { readFile } from 'node:fs/promises'; import { dirname, resolve } from 'node:path'; import { describe, expect, test } from 'vitest'; import { parseAst } from 'vite'; -import { chunkShift, type KernelDescription, type KernelModule, type Program } from '../world/kernel.js'; +import { blockShift, chunkShift, type KernelDescription, type KernelModule, type Program } from '../world/kernel.js'; import { transformKernels } from './transform.js'; import { createLinker, type LinkHost } from './link.js'; -import { packageIndex } from './paths.js'; +import { ownCopy } from './paths.js'; import { locateThroughMap } from './source-map.js'; import { estreeOf } from './estree.js'; import { Mat4x4, Quat, Vec3 } from '../../interface/math/index.js'; @@ -28,10 +28,18 @@ const delta = "delta"; const target = "target"; `; -/** Modules by absolute path, for tests of imports: real files for anything not in `files`, and "ecs" for the package. */ -const host = (files: { readonly [file: string]: string }): LinkHost => ({ +/** The operators the test module's resources declare in their `reduce` traits, by binding. */ +type Reductions = { readonly [binding: string]: string }; +const reductions: Reductions = { total: "+", low: "min", bits: "|" }; + +/** + * Modules by absolute path, for tests of imports: real files for anything not in `files`, and "ecs" for the package; + * the test module's resources reduce with `reduced`. + */ +const host = (files: { readonly [file: string]: string }, reduced: Reductions = reductions): LinkHost => ({ + reductions: file => new Map(file === "/test.js" ? Object.entries(reduced) : []), resolve: async (specifier, importer) => { - if (specifier === "ecs") return packageIndex; + if (specifier === "ecs") return ownCopy.index; if (specifier === "external") return { error: "external is external to the build", unreadable: true }; if (!specifier.startsWith(".")) return { error: `${specifier} can't be resolved from ${importer}` }; const path = resolve(dirname(importer), specifier); @@ -47,8 +55,8 @@ const host = (files: { readonly [file: string]: string }): LinkHost => ({ }, }); -const transform = (source: string, files: { readonly [file: string]: string } = {}) => transformKernels(`${header}${source}`, "/test.js", parse, - async specifier => specifier === "ecs", index => JSON.stringify(`kernel:${index}`), createLinker(host(files), parse)); +const transform = (source: string, files: { readonly [file: string]: string } = {}, reduced: Reductions = reductions, automatic = true) => transformKernels(`${header}${source}`, "/test.js", parse, + async specifier => specifier === "ecs" ? ownCopy : null, index => JSON.stringify(`kernel:${index}`), createLinker(host(files, reduced), parse), automatic); type Compiled = KernelDescription & KernelModule; @@ -56,8 +64,8 @@ type Compiled = KernelDescription & KernelModule; * Compiles `source`, which assigns its kernel system to `kernel`: the attached description plus its kernel module's * `create`. `imported` gives the values of the bindings `source` imports, since its imports aren't evaluated. */ -const compile = async (source: string, files: { readonly [file: string]: string } = {}, imported: { readonly [name: string]: unknown } = {}): Promise => { - const result = await transform(source, files); +const compile = async (source: string, files: { readonly [file: string]: string } = {}, imported: { readonly [name: string]: unknown } = {}, reduced: Reductions = reductions): Promise => { + const result = await transform(source, files, reduced); if (result === null || "failure" in result) throw new Error(result === null ? "no kernel" : result.failure.message); const module = result.code.split("\n").filter(line => !line.startsWith("import ")).join("\n"); const evaluate = new Function("System", "attachKernel$", "Archetype", ...Object.keys(imported), `${module}\nreturn kernel;`); @@ -68,8 +76,8 @@ const compile = async (source: string, files: { readonly [file: string]: string return { ...description, ...kernelModule }; }; -const failure = async (source: string, files: { readonly [file: string]: string } = {}): Promise => { - const result = await transform(source, files); +const failure = async (source: string, files: { readonly [file: string]: string } = {}, reduced: Reductions = reductions): Promise => { + const result = await transform(source, files, reduced); return result !== null && "failure" in result ? result.failure.message : "compiled"; }; @@ -196,8 +204,10 @@ const interpret = (program: Program, columns: readonly (Float64Array | Uint32Arr case "stamp": { const column = columns[statement.column]; const chunks = columns[statement.chunks]; + const blocks = columns[statement.blocks]; if (column) column[at(statement.column, row)] = tick; if (chunks) chunks[row >> chunkShift] = tick; + if (blocks) blocks[row >> blockShift] = tick; return normal; } case "if": return execute(evaluate(statement.test) ? statement.consequent : statement.alternate); @@ -248,6 +258,8 @@ const run = (description: Compiled, rows: number, fields: Fields, options: Optio if (column.kind === "entity") return Float64Array.from({ length: rows }, (_, row) => 100 + row); if (column.kind === "changed") return new Uint32Array(rows); if (column.kind === "chunks") return new Float64Array(Math.ceil(rows / 2 ** chunkShift)); + if (column.kind === "blocks") return new Float64Array(Math.ceil(rows / 2 ** blockShift)); + if (column.kind === "present") return new Float64Array(rows); const component = String(column.component); const values = fields[component]?.[JSON.stringify(column.path)] ?? new Array(rows).fill(0); const stride = interleaved[component]?.stride; @@ -308,11 +320,63 @@ const run = (description: Compiled, rows: number, fields: Fields, options: Optio const vec3 = (x: readonly number[], y: readonly number[], z: readonly number[]) => ({ "[0]": x, "[1]": y, "[2]": z }); +describe("kernels by default", () => { + /** Whether the transform attached a kernel to the module's system, and the failure if it failed. */ + const attached = async (source: string, automatic = true): Promise => { + const result = await transform(source, {}, reductions, automatic); + if (result === null) return false; + return "failure" in result ? result.failure.message : result.code.includes("attachKernel$("); + }; + test("compiles a system with components whose body is in the subset, unflagged", async () => { + expect(await attached(`const kernel = System.create({ components: { with: [mass, score] } }, row => { row[score] = row[mass] * 2; });`)).toBe(true); + }); + test("runs as written, without failing, a body that leaves the subset or a declaration a kernel can't have", async () => { + expect(await attached(`const log = []; const kernel = System.create({ components: { with: [mass] } }, row => { log.push(row[mass]); });`)).toBe(false); + expect(await attached(`const kernel = System.create({ components: { with: [mass] } }, (row, { resources }) => { resources[delta] = row[mass]; });`)).toBe(false); + expect(await attached(`const kernel = System.create({ observer: true, components: { with: [mass] } }, row => { void row[mass]; });`)).toBe(false); + expect(await attached(`const kernel = System.create({ target: "entity", components: { with: [score] } }, row => { row[score] = 1; });`)).toBe(false); + expect(await attached(`const make = () => System.create({ components: { with: [score] } }, row => { row[score] = 1; });`)).toBe(false); + }); + test("runs as written a body reading data of a module object or array, which may change, and compiles one reading constants and functions", async () => { + expect(await attached(`export const scene = { subject: 0 }; const kernel = System.create({ components: { with: [score] } }, row => { row[score] = scene.subject; });`)).toBe(false); + expect(await attached(`const values = [1, 2]; const kernel = System.create({ components: { with: [score] } }, row => { row[score] = values[0]; });`)).toBe(false); + expect(await attached(`const make = () => ({ speed: 2 }); export const settings = make(); const kernel = System.create({ components: { with: [score] } }, row => { row[score] = settings.speed; });`)).toBe(false); + expect(await attached(`const pair = (a, b) => [a, b]; export const range = pair(1, 2); const kernel = System.create({ components: { with: [score] } }, row => { row[score] = range[1]; });`)).toBe(false); + expect(await attached(`const wide = true; export const shape = wide ? { size: 2 } : { size: 1 }; const kernel = System.create({ components: { with: [score] } }, row => { row[score] = shape.size; });`)).toBe(false); + expect(await attached(`const make = () => 3; const three = make(); const kernel = System.create({ components: { with: [score] } }, row => { row[score] = three; });`)).toBe(true); + expect(await attached(`const half = 0.5; const twice = x => x * 2; const kernel = System.create({ components: { with: [mass, score] } }, row => { row[score] = twice(row[mass]) * half; });`)).toBe(true); + expect(await failure(`export const scene = { subject: 0 }; const kernel = System.create({ kernel: true, components: { with: [score] } }, row => { row[score] = scene.subject; });`)).toBe("compiled"); + }); + test("tries none unflagged when automatic compilation is off, as the suites of bodies as written turn it", async () => { + expect(await attached(`const kernel = System.create({ components: { with: [mass, score] } }, row => { row[score] = row[mass] * 2; });`, false)).toBe(false); + expect(await attached(`const kernel = System.create({ kernel: true, components: { with: [mass, score] } }, row => { row[score] = row[mass] * 2; });`, false)).toBe(true); + }); + test("never compiles a system without components or with kernel: false", async () => { + expect(await attached(`const kernel = System.create({ resources: {} }, ({ resources }) => { void resources[delta]; });`)).toBe(false); + expect(await attached(`const kernel = System.create({ kernel: false, components: { with: [mass, score] } }, row => { row[score] = row[mass]; });`)).toBe(false); + }); + test("compiles a system with optional components, testing their presence once per call, and fails a GPU kernel with them", async () => { + expect(await attached(`const kernel = System.create({ components: { with: [score], optional: [mass] } }, row => { const found = row[mass]; if (found !== undefined) row[score] = found; });`)).toBe(true); + expect(await attached(`const kernel = System.create({ kernel: true, components: { with: [score], optional: [mass] } }, row => { if (row[mass] !== undefined) row[mass] = row[score]; });`)).toBe(true); + expect(await failure(`const kernel = System.create({ kernel: "gpu", components: { with: [score], optional: [mass] } }, row => { row[score] = 1; });`)) + .toBe("a GPU kernel can't have optional components: WGSL has no undefined to read where an archetype lacks one, and a GPU kernel can't fail its system for one row; list them in with, or use kernel: true to run it on the CPU"); + expect(await failure(`const kernel = System.create({ components: { with: [score] } }, row => { row[score] = row[mass]; });`)) + .toBe("mass isn't in components with or optional, so the system doesn't require it: add it there to use it"); + }); + test("fails, saying why, a system with kernel: true that can't compile", async () => { + expect(await attached(`const kernel = System.create({ kernel: true }, ({ resources }) => { void resources[delta]; });`)) + .toBe("a system without components runs once, so there's nothing to compile"); + expect(await attached(`const log = []; const kernel = System.create({ kernel: true, components: { with: [mass] } }, row => { log.push(row[mass]); });`)).not.toBe(true); + expect(await attached(`const make = () => System.create({ kernel: true, components: { with: [score] } }, row => { row[score] = 1; });`)) + .toBe("kernel systems must be declared at module scope"); + }); +}); + describe("particles", () => { test("gravity and integrate", async () => { const gravity = await compile(`const kernel = System.create({ - name: "particles/gravity", kernel: "cpu", - components: { write: [velocity] }, resources: { read: [delta] }, + kernel: true, + components: { with: [velocity] }, }, (row, { resources }) => { const [x, y, z] = row[velocity]; row[velocity] = [x, y - 9.8 * resources[delta], z]; @@ -324,8 +388,8 @@ describe("particles", () => { expect(gravity.resources).toEqual(["delta"]); const integrate = await compile(`const kernel = System.create({ - name: "particles/integrate", kernel: "cpu", - components: { read: [velocity], write: [position] }, resources: { read: [delta] }, after: [{ name: "particles/gravity" }], + kernel: true, + components: { with: [velocity, position] }, after: [{}], }, (row, { resources }) => { const [px, py, pz] = row[position]; const [vx, vy, vz] = row[velocity]; @@ -343,7 +407,7 @@ describe("particles", () => { describe("layout", () => { test("interleaved components are read and written at row * stride + offset, leaving padding untouched", async () => { - const kernel = await compile(`const kernel = System.create({ name: "a", kernel: "cpu", components: { read: [alive], write: [velocity] } }, (row) => { + const kernel = await compile(`const kernel = System.create({ kernel: true, components: { with: [alive, velocity] } }, (row) => { const [x, y, z] = row[velocity]; row[velocity] = [z, row[alive] === true ? y * 10 : y, x]; });`); @@ -356,7 +420,7 @@ describe("layout", () => { }); test("stamps set the changed row and its chunk; each changed column is followed by its chunk column", async () => { - const kernel = await compile(`const kernel = System.create({ name: "a", kernel: "cpu", components: { read: [mass], write: [score] } }, (row) => { + const kernel = await compile(`const kernel = System.create({ kernel: true, components: { with: [mass, score] } }, (row) => { if (row[mass] > 0) row[score] = 1; });`); const rows = 2 * 2 ** chunkShift + 5; @@ -370,12 +434,12 @@ describe("layout", () => { }); test("an unconditional stamp stamps the call's chunks once; a stamp after return stays per row", async () => { - const always = await compile(`const kernel = System.create({ name: "a", kernel: "cpu", components: { read: [mass], write: [score] } }, (row) => { + const always = await compile(`const kernel = System.create({ kernel: true, components: { with: [mass, score] } }, (row) => { row[score] = row[mass]; });`); expect(String(always.create)).not.toContain(">> 10] = tick"); expect(run(always, 3200, {}, { range: [1000, 2100] }).chunks("score")).toEqual([tick, tick, tick, 0]); - const after = await compile(`const kernel = System.create({ name: "a", kernel: "cpu", components: { read: [mass], write: [score] } }, (row) => { + const after = await compile(`const kernel = System.create({ kernel: true, components: { with: [mass, score] } }, (row) => { if (row[mass] > 0) return; row[score] = 1; });`); @@ -385,7 +449,7 @@ describe("layout", () => { }); test("indices stay in step when return skips the rest of a row", async () => { - const kernel = await compile(`const kernel = System.create({ name: "a", kernel: "cpu", components: { write: [velocity] } }, (row) => { + const kernel = await compile(`const kernel = System.create({ kernel: true, components: { with: [velocity] } }, (row) => { const [x, y, z] = row[velocity]; if (x > 1 && x < 5) return; row[velocity] = [z, y, x]; @@ -397,14 +461,14 @@ describe("layout", () => { }); describe("reductions", () => { - const reducing = (reduce: string, body: string, read = "[position, mass]") => `const total = "total"; + const reducing = (body: string, read = "[position, mass]") => `const total = "total"; const low = "low"; const bits = "bits"; -const kernel = System.create({ name: "a", kernel: "cpu", components: { read: ${read} }, reduce: { ${reduce} } }, (row, { reduce }) => { ${body} });`; +const kernel = System.create({ kernel: true, components: { with: ${read} } }, (row, { reduce, resources }) => { ${body} });`; const fields = { position: vec3([1, -4, 2], [5, 0, -1], [3, 9, 6]), mass: { "[]": [2, 3, 12] } }; test("fold each contribution into its resource, leaf by leaf, from the partial", async () => { - const kernel = await compile(reducing(`[total]: "+", [low]: "min", [bits]: "|"`, ` + const kernel = await compile(reducing(` const [x, y, z] = row[position]; reduce[total](row[mass]); reduce[total](1); @@ -419,23 +483,22 @@ const kernel = System.create({ name: "a", kernel: "cpu", components: { read: ${r }); test("fold the values of inlined functions", async () => { - const kernel = await compile(`import { Vec3 } from "ecs";\n${reducing(`[low]: "max"`, "reduce[low](Vec3.scale(row[position], 2));", "[position]")}`); + const kernel = await compile(`import { Vec3 } from "ecs";\n${reducing("reduce[low](Vec3.scale(row[position], 2));", "[position]")}`, {}, {}, { low: "max" }); expect([0, 1, 2].map(k => run(kernel, 3, fields).reduced("low", [k]))).toEqual([4, 10, 18]); }); test("that can't be compiled fail with the reason", async () => { - expect(await failure(reducing(`[total]: "+"`, "reduce[total](1, 2);"))).toBe("total takes the one value it reduces"); - expect(await failure(reducing(`[total]: "avg"`, "reduce[total](1);"))).toBe(`a resource is reduced with one of "+", "*", "min", "max", "&", "|", "^"`); - expect(await failure(reducing(`total: "+"`, "reduce[total](1);"))).toBe(`reduce names each resource as a computed key bound at module scope, as in { [total]: "+" }`); - expect(await failure(reducing(`[total]: "+"`, "reduce[total](1);").replace(`kernel: "cpu"`, `kernel: "gpu"`))).toBe("a GPU kernel can't use reductions yet"); - expect(await failure(reducing(`[total]: "+"`, "reduce[total](1);").replace("reduce: {", "resources: { read: [total] }, reduce: {"))).toBe("total is reduced, so it can't also be read or filtered on"); - expect(await failure(reducing(`[total]: "+", [total]: "max"`, "reduce[total](1);"))).toBe("total is reduced twice"); + expect(await failure(reducing("reduce[total](1, 2);"))).toBe("total takes the one value it reduces"); + expect(await failure(reducing("reduce[total](1);"), {}, { total: "avg" })).toBe(`total is reduced, so the build reads its operator from its declaration's reduce trait, "avg", which isn't one of "+", "*", "min", "max", "&", "|", "^"`); + expect(await failure(reducing("reduce[total](1);"), {}, {})).toBe(`total is reduced, so the build reads its operator from its declaration's reduce trait, ${"which its module's JavaScript doesn't register: declare it in TypeScript, and build a library declaring it with Compiler.plugin({ library: true })"}`); + expect(await failure(reducing("reduce[total](1);").replace(`kernel: true`, `kernel: "gpu"`))).toBe("a GPU kernel can't use reductions yet"); + expect(await failure(reducing("reduce[total](resources[total]);"))).toBe("total is reduced, so it can't also be read or filtered on"); }); }); describe("let locals of component values", () => { test("written back whole after a tuple assignment have the tuple's shape", async () => { - const kernel = await compile(`const kernel = System.create({ name: "a", kernel: "cpu", components: { read: [alive], write: [position] } }, (row) => { + const kernel = await compile(`const kernel = System.create({ kernel: true, components: { with: [alive, position] } }, (row) => { let v = row[position]; if (row[alive]) v = [7, 8, 9]; row[position] = v; @@ -448,22 +511,22 @@ describe("let locals of component values", () => { describe("binding identity", () => { test("a local shadowing a component key is not that component", async () => { - expect(await failure(`const kernel = System.create({ name: "a", kernel: "cpu", components: { read: [velocity], write: [position] } }, (row) => { + expect(await failure(`const kernel = System.create({ kernel: true, components: { with: [velocity, position] } }, (row) => { const velocity = 1; row[position] = row[velocity]; - });`)).toBe("row is indexed by a component in components read or write, or .entity"); + });`)).toBe("a kernel's body indexes its row and context by key directly, or destructures them by key, so the build sees what it reads and writes; it can't pass them on, alias or spread them"); }); test("another binding with the same key string is not the declared component", async () => { expect(await failure(`const speed = "velocity"; - const kernel = System.create({ name: "a", kernel: "cpu", components: { read: [velocity], write: [position] } }, (row) => { + const kernel = System.create({ kernel: true, components: { with: [velocity, position] } }, (row) => { row[position] = row[speed]; - });`)).toBe("speed is not in components read or write"); + });`)).toBe("speed isn't in components with or optional, so the system doesn't require it: add it there to use it"); }); test("a helper parameter shadows a module binding only inside the helper", async () => { const kernel = await compile(`const twice = (velocity) => velocity * 2; - const kernel = System.create({ name: "a", kernel: "cpu", components: { read: [velocity], write: [position] } }, (row) => { + const kernel = System.create({ kernel: true, components: { with: [velocity, position] } }, (row) => { row[position] = twice(row[velocity]) + row[velocity]; });`); expect(run(kernel, 1, { velocity: { "[]": [3] } }).field("position")).toEqual([9]); @@ -472,7 +535,7 @@ describe("binding identity", () => { describe("value semantics", () => { test("tuple locals are copies, and snapshots read before later writes", async () => { - const kernel = await compile(`const kernel = System.create({ name: "a", kernel: "cpu", components: { write: [position, score] } }, (row) => { + const kernel = await compile(`const kernel = System.create({ kernel: true, components: { with: [position, score] } }, (row) => { const before = row[position]; let v = [before[0], before[1]]; const w = v; @@ -486,7 +549,7 @@ describe("value semantics", () => { }); test("whole-value writes evaluate every leaf before storing", async () => { - const kernel = await compile(`const kernel = System.create({ name: "a", kernel: "cpu", components: { write: [position, score] } }, (row) => { + const kernel = await compile(`const kernel = System.create({ kernel: true, components: { with: [position, score] } }, (row) => { row[position] = [row[position][1], row[position][0]]; let a = 1; let b = 2; @@ -499,7 +562,7 @@ describe("value semantics", () => { }); test("struct values and conditional tuples", async () => { - const kernel = await compile(`const kernel = System.create({ name: "a", kernel: "cpu", components: { read: [mass], write: [position] } }, (row) => { + const kernel = await compile(`const kernel = System.create({ kernel: true, components: { with: [mass, position] } }, (row) => { const p = row[mass] > 1 ? { x: 1, y: [2, 3] } : { x: 4, y: [5, 6] }; row[position] = p; });`); @@ -517,7 +580,7 @@ describe("helpers", () => { return [x * k, y * k]; }; const length = ([x, y]) => Math.sqrt(x * x + y * y); - const kernel = System.create({ name: "a", kernel: "cpu", components: { write: [position, score] } }, (row) => { + const kernel = System.create({ kernel: true, components: { with: [position, score] } }, (row) => { const k = 100; const x = 1; row[position] = scale(row[position], 0.5); @@ -530,14 +593,14 @@ describe("helpers", () => { test("recursive helpers are rejected", async () => { expect(await failure(`const f = (x) => f(x); - const kernel = System.create({ name: "a", kernel: "cpu", components: { write: [score] } }, (row) => { row[score] = f(1); });`)) + const kernel = System.create({ kernel: true, components: { with: [score] } }, (row) => { row[score] = f(1); });`)) .toBe("f is recursive"); }); }); describe("control flow", () => { test("return ends the row, also from inside a loop", async () => { - const kernel = await compile(`const kernel = System.create({ name: "a", kernel: "cpu", components: { read: [mass], write: [score] } }, (row) => { + const kernel = await compile(`const kernel = System.create({ kernel: true, components: { with: [mass, score] } }, (row) => { if (row[mass] < 0) return; let total = 0; for (let i = 0; i < 10; i++) { @@ -552,7 +615,7 @@ describe("control flow", () => { test("a loop test that inlines a helper is re-evaluated each iteration", async () => { const kernel = await compile(`const below = (a, b) => { const limit = b * 2; return a < limit; }; - const kernel = System.create({ name: "a", kernel: "cpu", components: { read: [mass], write: [score] } }, (row) => { + const kernel = System.create({ kernel: true, components: { with: [mass, score] } }, (row) => { let n = 0; while (below(n, row[mass])) { n++; } let m = 0; @@ -566,7 +629,7 @@ describe("control flow", () => { describe("context", () => { test("lookups map components to their read index, with null guards", async () => { const kernel = await compile(`const half = (value) => { const h = value / 2; return h; }; - const kernel = System.create({ name: "a", kernel: "cpu", components: { read: [target], write: [position, score] }, lookup: { targets: { read: [mass, position] } } }, (row, { lookup: { targets } }) => { + const kernel = System.create({ kernel: true, components: { with: [target, position, score] }, lookup: { targets: { with: [mass, position] } } }, (row, { lookup: { targets } }) => { const other = targets(row[target]); if (other !== null) { row[position] = other[position] - other[mass]; } row[score] = (other !== null && half(other[position])) || -1; @@ -579,7 +642,7 @@ describe("context", () => { }); test("optional lookup access, destructured context groups and input", async () => { - const kernel = await compile(`const kernel = System.create({ name: "a", kernel: "cpu", input: { type: "number" }, components: { read: [target], write: [score] }, resources: { read: [delta] }, lookup: { targets: { read: [mass] } } }, (row, { resources: { [delta]: d }, lookup: { targets }, input }) => { + const kernel = await compile(`const kernel = System.create({ kernel: true, components: { with: [target, score] }, lookup: { targets: { with: [mass] } } }, (row, { resources: { [delta]: d }, lookup: { targets } }, input) => { row[score] = (targets(row[target])?.[mass] ?? 0) + d + input; });`); const found = (entity: number) => entity === 1 ? [3] : null; @@ -587,16 +650,16 @@ describe("context", () => { }); test("context groups reached through the context parameter", async () => { - const kernel = await compile(`const kernel = System.create({ name: "a", kernel: "cpu", input: { type: "number" }, components: { read: [target], write: [score] }, resources: { read: [delta] }, lookup: { targets: { read: [mass] } } }, (row, context) => { + const kernel = await compile(`const kernel = System.create({ kernel: true, components: { with: [target, score] }, lookup: { targets: { with: [mass] } } }, (row, context, input) => { const { resources } = context; - row[score] = (context.lookup.targets(row[target])?.[mass] ?? 0) + resources[delta] + context.input; + row[score] = (context.lookup.targets(row[target])?.[mass] ?? 0) + resources[delta] + input; });`); const found = (entity: number) => entity === 1 ? [3] : null; expect(run(kernel, 2, { target: { "[]": [1, 2] } }, { lookups: [found], resources: [10], args: 100, program: false }).field("score")).toEqual([113, 110]); }); test("boolean columns read as booleans", async () => { - const kernel = await compile(`const kernel = System.create({ name: "a", kernel: "cpu", components: { read: [alive], write: [score] } }, (row) => { + const kernel = await compile(`const kernel = System.create({ kernel: true, components: { with: [alive, score] } }, (row) => { row[score] = row[alive] === true ? 1 : 2; });`); expect(run(kernel, 2, { alive: { "[]": [1, 0] } }, { booleans: ["alive"] }).field("score")).toEqual([1, 2]); @@ -604,7 +667,7 @@ describe("context", () => { }); test("boolean reads convert only where observable, including through locals", async () => { - const kernel = await compile(`const kernel = System.create({ name: "a", kernel: "cpu", components: { read: [alive, mass], write: [score] } }, (row, { commands }) => { + const kernel = await compile(`const kernel = System.create({ kernel: true, components: { with: [alive, mass, score] } }, (row, { commands }) => { const flag = row[alive]; const copy = flag; row[score] = (copy === true ? 10 : 20) + row[mass] * 2 + (flag ? 1 : 0); @@ -623,7 +686,7 @@ describe("context", () => { }); test("a spawn nested in an add's value is issued before the add begins", async () => { - const kernel = await compile(`const kernel = System.create({ name: "a", kernel: "cpu", components: { read: [mass] } }, (row, { commands }) => { + const kernel = await compile(`const kernel = System.create({ kernel: true, components: { with: [mass] } }, (row, { commands }) => { commands.add(row.entity, [target, score], { [target]: commands.spawn([mass])({ [mass]: row[mass] }), [score]: 1 }); commands.despawn(row.entity); });`); @@ -637,7 +700,7 @@ describe("context", () => { }); test("commands name components through keys", async () => { - const kernel = await compile(`const kernel = System.create({ name: "a", kernel: "cpu", components: { read: [position] } }, (row, context) => { + const kernel = await compile(`const kernel = System.create({ kernel: true, components: { with: [position] } }, (row, context) => { const { commands } = context; const spawned = commands.spawn([position, alive])({ [position]: [row[position][0], 1] }); commands.add(spawned, score, row.entity); @@ -658,11 +721,11 @@ describe("context", () => { ]); }); - test("commands name lists through module constants: array literals and Archetype.create", async () => { - const kernel = await compile(`import { Archetype } from "ecs"; - const unit = Archetype.create([position, alive, score]); + test("commands name lists through module constants: array literals, and lists the build gave an index", async () => { + const kernel = await compile(`function archetype$(list) { return list; } + const unit = archetype$([position, alive, score]); const pair = [mass, score]; - const kernel = System.create({ name: "a", kernel: "cpu", components: { read: [position] } }, (row, { commands }) => { + const kernel = System.create({ kernel: true, components: { with: [position] } }, (row, { commands }) => { const spawned = commands.spawn(unit)({ [score]: 2, [position]: [row[position][0], 1] }); commands.add(spawned, pair, { [mass]: 3 }); commands.remove(row.entity, unit); @@ -677,9 +740,9 @@ describe("context", () => { }); test("kernel declarations list components through module constants", async () => { - const kernel = await compile(`import { Archetype } from "ecs"; - const moving = Archetype.create([position, velocity]); - const kernel = System.create({ name: "a", kernel: "cpu", components: { read: moving, write: [score] } }, row => { + const kernel = await compile(` + const moving = [position, velocity]; + const kernel = System.create({ kernel: true, components: { with: [score, ...moving] } }, row => { row[score] = row[position] + row[velocity]; });`); expect(run(kernel, 2, { position: { "[]": [1, 2] }, velocity: { "[]": [10, 20] } }).field("score")).toEqual([11, 22]); @@ -688,19 +751,19 @@ describe("context", () => { test.each([ ["a value for a component the list doesn't name", "commands.spawn([mass])({ [mass]: 1, [score]: 2 });", "score isn't one of the command's components"], ["a spawner kept rather than called", "const spawn = commands.spawn([mass]); spawn({ [mass]: 1 });", "a kernel calls the spawner where it gets it, as commands.spawn(components)(values)"], - ["a list holding a spread", "commands.spawn([...pair])({});", "commands.spawn: lists name keys by identifiers bound at module scope"], + ["a list holding a spread of what isn't a list", "commands.spawn([...mass])({});", "commands.spawn: mass is a key, not a list of components; list it as [mass]"], ["a list that isn't a module constant", "const list = 1; commands.add(row.entity, list, {});", "commands name components by identifiers bound at module scope"], ["a component listed twice", "commands.remove(row.entity, [mass, mass]);", "mass is listed twice"], ["an add with too many arguments", "commands.add(row.entity, mass, 1, 2);", "an add is commands.add(entity, component, value) or commands.add(entity, components, values)"], ["a remove with rest components", "commands.remove(row.entity, mass, score);", "a remove is commands.remove(entity, component) or commands.remove(entity, components)"], ])("fail the build for %s", async (_, body, message) => { - expect(await failure(`const pair = [mass, score];\nconst kernel = System.create({ name: "a", kernel: "cpu", components: { read: [mass] } }, (row, { commands }) => { const m = row[mass]; ${body} });`)).toBe(message); + expect(await failure(`const pair = [mass, score];\nconst kernel = System.create({ kernel: true, components: { with: [mass] } }, (row, { commands }) => { const m = row[mass]; ${body} });`)).toBe(message); }); }); describe("program", () => { test("input and resource paths are scalars in the program", async () => { - const kernel = await compile(`const kernel = System.create({ name: "a", kernel: "cpu", input: { type: "object" }, components: { write: [score] }, resources: { read: [delta] } }, (row, { resources: { [delta]: d }, input }) => { + const kernel = await compile(`const kernel = System.create({ kernel: true, components: { with: [score] } }, (row, { resources: { [delta]: d } }, input) => { const [, y] = d; row[score] = y * input.scale + Math.max(input.scale, Math.PI) + (input.flag ? 1 : 0); });`); @@ -711,7 +774,7 @@ describe("program", () => { }); test("runtime indexing into a resource has no program", async () => { - const kernel = await compile(`const kernel = System.create({ name: "a", kernel: "cpu", components: { read: [mass], write: [score] }, resources: { read: [delta] } }, (row, { resources: { [delta]: d } }) => { + const kernel = await compile(`const kernel = System.create({ kernel: true, components: { with: [mass, score] } }, (row, { resources: { [delta]: d } }) => { row[score] = d[row[mass]]; });`); expect(run(kernel, 1, { mass: { "[]": [1] } }, { resources: [[5, 6]], program: false }).field("score")).toEqual([6]); @@ -720,17 +783,17 @@ describe("program", () => { describe("composites", () => { test("one argument to System.create that isn't a body is a composite, never a kernel", async () => { - const result = await transform(`const a = System.create({ name: "a" }, () => {}); + const result = await transform(`const a = System.create({}, () => {}); const listed = System.create([a]); -const declared = System.create({ kernel: "cpu", systems: [a], components: { write: [score] } });`); +const declared = System.create({ kernel: true, systems: [a], components: { with: [score] } });`); expect(result !== null && "failure" in result ? result.failure.message : result?.kernels ?? []).toEqual([]); }); }); describe("lookups in the program", () => { const targets = (entity: number) => entity === 1 ? [3, [10, 20, 30]] : entity === 2 ? [4, [40, 50, 60]] : null; - const declaration = (body: string, read = "[target]", lookup = "targets: { read: [mass, position] }") => `const kernel = System.create({ name: "a", kernel: "cpu", - components: { read: ${read}, write: [score] }, lookup: { ${lookup} } }, + const declaration = (body: string, read = "[target]", lookup = "targets: { with: [mass, position] }") => `const kernel = System.create({ kernel: true, + components: { with: ${read.replace("]", ", score]")} }, lookup: { ${lookup} } }, (row, { lookup: { ${lookup.includes("others") ? "targets, others" : "targets"} } }) => { ${body} });`; test("guarded leaf access, early return and a second lookup in its own handle", async () => { @@ -741,7 +804,7 @@ describe("lookups in the program", () => { const second = others(row[mass]); const extra = second !== null ? second[position][2] : 0; row[score] = tx - other[mass] + ty * 0 + extra; - `, "[target, mass]", "targets: { read: [mass, position] }, others: { read: [position] }")); + `, "[target, mass]", "targets: { with: [mass, position] }, others: { with: [position] }")); expect(kernel.program?.handles).toBe(2); const others = (entity: number) => entity === 2 ? [[0, 0, 7]] : null; expect(run(kernel, 3, { target: { "[]": [1, 2, 3] }, mass: { "[]": [2, 0, 0] }, score: { "[]": [-1, -1, -1] } }, { lookups: [targets, others] }).field("score")) @@ -749,7 +812,7 @@ describe("lookups in the program", () => { }); test("optional chains read NaN when nothing was found, anywhere", async () => { - const kernel = await compile(declaration(`row[score] = targets(row[target])?.[position]?.[1] * 2;`, "[target]", "targets: { read: [position] }")); + const kernel = await compile(declaration(`row[score] = targets(row[target])?.[position]?.[1] * 2;`, "[target]", "targets: { with: [position] }")); expect(kernel.program).not.toBeNull(); expect(run(kernel, 2, { target: { "[]": [1, 5] } }, { lookups: [entity => targets(entity)?.slice(1) ?? null] }).field("score")).toEqual([40, NaN]); }); @@ -764,9 +827,9 @@ describe("lookups in the program", () => { }); test.each([ - ["an unguarded leaf access", `const other = targets(row[target]); row[score] = other[mass];`, "targets: { read: [mass] }"], - ["a lookup result used whole", `const other = targets(row[target]); row[score] = other;`, "targets: { has: [mass] }"], - ["a leaf access after the guard's scope", `const other = targets(row[target]); if (other !== null) { row[score] = 1; } row[score] = other[mass];`, "targets: { read: [mass] }"], + ["an unguarded leaf access", `const other = targets(row[target]); row[score] = other[mass];`, "targets: { with: [mass] }"], + ["a lookup result used whole", `const other = targets(row[target]); row[score] = other;`, "targets: { with: [mass] }"], + ["a leaf access after the guard's scope", `const other = targets(row[target]); if (other !== null) { row[score] = 1; } row[score] = other[mass];`, "targets: { with: [mass] }"], ])("no program for %s", async (_, body, lookup) => { const kernel = await compile(declaration(body, "[target]", lookup)); expect(kernel.program).toBeNull(); @@ -774,11 +837,11 @@ describe("lookups in the program", () => { }); describe("subset", () => { - const kernel = (body: string, components = "{ read: [mass], write: [score] }") => - `const kernel = System.create({ name: "a", kernel: "cpu", components: ${components} }, (row, { commands }) => { ${body} });`; + const kernel = (body: string, components = "{ with: [mass, score] }") => + `const kernel = System.create({ kernel: true, components: ${components} }, (row, { commands }) => { ${body} });`; test.each([ - ["row[mass] = 1;", "mass is not in components write"], + ["row[mass] = 1;", "compiled"], ["row[score] = row[score] + row[mass];", "compiled"], ["const f = () => 1;", "functions can't be created in a kernel; define helpers at module scope"], ["row[score] = \"a\";", "only number, boolean and null literals are in the kernel subset"], @@ -794,64 +857,65 @@ describe("subset", () => { }); test("component values are written whole", async () => { - expect(await failure(kernel("row[position][0] = 1;", "{ write: [position] }"))).toBe("component values are immutable; write the whole value"); + expect(await failure(kernel("row[position][0] = 1;", "{ with: [position] }"))).toBe("component values are immutable; write the whole value"); }); test("GPU kernels", async () => { const gpu = (declaration: string, body: string) => - `const kernel = System.create({ name: "a", kernel: "gpu", ${declaration} }, (row, context) => { ${body} });`; - expect(await failure(gpu("components: { read: [mass], write: [score] }", "row[score] = row[mass] * 2;"))).toBe("compiled"); - expect(await failure(gpu("components: { read: [target], write: [score] }, lookup: { targets: { read: [mass] } }", "row[score] = 1;"))) + `const kernel = System.create({ kernel: "gpu", ${declaration} }, (row, context) => { ${body} });`; + expect(await failure(gpu("components: { with: [mass, score] }", "row[score] = row[mass] * 2;"))).toBe("compiled"); + expect(await failure(gpu("components: { with: [target, score] }, lookup: { targets: { with: [mass] } }", "row[score] = 1;"))) .toBe("a GPU kernel can't use lookups yet"); - expect(await failure(gpu("components: { write: [score], changed: [mass] }", "row[score] = 1;"))).toBe("a GPU kernel can't use change filters yet"); - expect(await failure(gpu("components: { read: [mass], write: [score] }", "context.commands.despawn(row.entity);"))) + expect(await failure(gpu("components: { with: [score], changed: [mass] }", "row[score] = 1;"))).toBe("a GPU kernel can't use change filters yet"); + expect(await failure(gpu("components: { with: [mass, score] }", "context.commands.despawn(row.entity);"))) .toBe("a GPU kernel can't issue commands or use values that aren't numbers or booleans"); - expect(await failure(gpu("components: { read: [mass], write: [score] }", "let s = 0; for (let i = 0; i < 3; i++) s += i; row[score] = s;"))) + expect(await failure(gpu("components: { with: [mass, score] }", "let s = 0; for (let i = 0; i < 3; i++) s += i; row[score] = s;"))) .toBe("a GPU kernel can't use loops or labeled blocks yet"); - expect(await failure(`const kernel = System.create({ name: "a", kernel: true, components: { write: [score] } }, (row) => {});`)) - .toBe(`kernel must be the literal "cpu" or "gpu"`); + expect(await failure(`const kernel = System.create({ kernel: "cpu", components: { with: [score] } }, (row) => {});`)) + .toBe(`kernel must be the literal true, false or "gpu"`); }); test("GPU programs carry their locals' names", async () => { const body = "(row) => { const doubled = row[mass] * 2; row[score] = doubled; }"; - const gpu = await compile(`const kernel = System.create({ name: "a", kernel: "gpu", components: { read: [mass], write: [score] } }, ${body});`); - const cpu = await compile(`const kernel = System.create({ name: "a", kernel: "cpu", components: { read: [mass], write: [score] } }, ${body});`); + const gpu = await compile(`const kernel = System.create({ kernel: "gpu", components: { with: [mass, score] } }, ${body});`); + const cpu = await compile(`const kernel = System.create({ kernel: true, components: { with: [mass, score] } }, ${body});`); expect(gpu.program?.names).toEqual([expect.stringMatching(/^doubled_\d+$/)]); expect(cpu.program?.names).toBeUndefined(); }); test("kernels inside functions", async () => { - expect(await failure(`const make = () => System.create({ name: "a", kernel: "cpu", components: { write: [score] } }, (row) => {});`)) + expect(await failure(`const make = () => System.create({ kernel: true, components: { with: [score] } }, (row) => {});`)) .toBe("kernel systems must be declared at module scope"); }); test("failures carry the offending position", async () => { - const source = kernel("row[mass] = 1;"); + const source = kernel("var x = 1;"); const result = await transform(source); - const position = `${header}${source}`.indexOf("row[mass] = 1"); + const position = `${header}${source}`.indexOf("var x = 1"); expect(result !== null && "failure" in result ? result.failure.position : -1).toBe(position); }); test("non-kernel systems that may reach their commands and other modules are left untouched", async () => { - expect(await transform(`const s = System.create({ name: "a", components: { read: [mass] } }, (row, context) => { const f = () => [row, context]; });`)).toBeNull(); - expect(await transformKernels(`import { System } from "other";\nSystem.create({ name: "a", kernel: "cpu" }, () => {});`, "/x.js", parse, async () => false, () => "")).toBeNull(); + const reaching = await transform(`const s = System.create({ components: { with: [mass] } }, (row, context) => { const f = () => [row, context]; });`); + expect(reaching !== null && "code" in reaching ? reaching.code : "").not.toContain("terminal$("); + expect(await transformKernels(`import { System } from "other";\nSystem.create({ kernel: true }, () => {});`, "/x.js", parse, async () => null, () => "")).toBeNull(); }); }); describe("terminal bodies", () => { /** Whether the transform marks a main-thread system's body as one that never reaches its commands. */ - const marked = async (parameters: string, body: string, declaration = `{ name: "a" }`): Promise => { + const marked = async (parameters: string, body: string, declaration = `{}`): Promise => { const result = await transform(`const system = System.create(${declaration}, ${parameters} => { ${body} });`); if (result !== null && "failure" in result) throw new Error(result.failure.message); const code = result?.code ?? ""; return code.includes("terminal$((") && code.includes("function terminal$(body)"); }; test.each([ - ["no context", "(row)", "console.log(row);", `{ name: "a", components: { has: [mass] } }`], - ["a context destructured without commands", "({ resources })", "console.log(resources);", `{ name: "a" }`], - ["a context used only through its other properties", "(context)", "console.log(context.input);", `{ name: "a", input: { type: "number" } }`], - ["a context reaching resources and lookups", "(row, context)", "console.log(context.resources, context.lookup, row);", `{ name: "a", components: { has: [mass] } }`], - ["nested destructuring", "({ resources: { [delta]: d } })", "console.log(d);", `{ name: "a", resources: { read: [delta] } }`], + ["no context", "(row)", "console.log(row);", `{ components: { with: [mass] } }`], + ["a context destructured without commands", "({ resources })", "console.log(resources);", `{}`], + ["a context used only through its other properties", "(context)", "console.log(context.resources);", `{ resources: {} }`], + ["a context reaching resources and lookups", "(row, context)", "console.log(context.resources, context.lookup, row);", `{ components: { with: [mass] } }`], + ["nested destructuring", "({ resources: { [delta]: d } })", "console.log(d);", `{ resources: {} }`], ])("mark a body with %s", async (_, parameters, body, declaration) => { expect(await marked(parameters, body, declaration)).toBe(true); }); @@ -871,23 +935,23 @@ describe("terminal bodies", () => { expect(await marked(parameters, body)).toBe(false); }); test("leave a function body unmarked that uses arguments", async () => { - const result = await transform(`const system = System.create({ name: "a" }, function () { console.log(arguments); });`); - expect(result).toBeNull(); + const result = await transform(`const system = System.create({}, function () { console.log(arguments); });`); + expect(result !== null && "code" in result ? result.code : "").not.toContain("terminal$("); }); test("mark a system made from its body alone, and no kernel or finalizer", async () => { const alone = await transform("const system = System.create(() => { console.log(1); });"); - expect(alone !== null && "code" in alone ? alone.code : "").toContain("System.create(terminal$(() =>"); - const kernel = await transform(`const kernel = System.create({ name: "k", kernel: "cpu", components: { write: [score] } }, (row) => { row[score] = 1; });`); + expect(alone !== null && "code" in alone ? alone.code : "").toContain("System.create(access$(terminal$(() =>"); + const kernel = await transform(`const kernel = System.create({ kernel: true, components: { with: [score] } }, (row) => { row[score] = 1; });`); expect(kernel !== null && "code" in kernel ? kernel.code : "").not.toContain("terminal$"); - const finalizer = await transformKernels(`import { Finalizer } from "ecs";\nconst f = Finalizer.create({ components: { read: [] } }, () => {});`, "/x.js", parse, async () => true, () => ""); - expect(finalizer).toBeNull(); + const finalizer = await transformKernels(`import { Finalizer } from "ecs";\nconst f = Finalizer.create({ components: { with: [] } }, () => {});`, "/x.js", parse, async () => ownCopy, () => ""); + expect(finalizer !== null && "code" in finalizer ? finalizer.code : "").not.toContain("terminal$("); }); }); describe("index lookups in main-thread systems", () => { /** Runs the transformed body once, recording each lookup as the runtime hook receives it. */ const looked = async (body: string, parameters = "({ lookup })", lookup = "near: { index: grid }"): Promise<{ readonly code: string; readonly calls: readonly unknown[] }> => { - const source = `const grid = "grid";\nconst system = System.create({ name: "a", lookup: { ${lookup} } }, ${parameters} => { ${body} });`; + const source = `const grid = "grid";\nconst system = System.create({ lookup: { ${lookup} } }, ${parameters} => { ${body} });`; const result = await transform(source); if (result !== null && "failure" in result) return { code: result.failure.message, calls: [] }; const module = (result?.code ?? `${header}${source}`).split("\n").filter(line => !line.startsWith("import ")).join("\n"); @@ -926,7 +990,7 @@ describe("index lookups in main-thread systems", () => { describe("commands in main-thread systems", () => { /** Runs the transformed system's body once, recording each spawn's and add's keys and values as the runtime hooks receive them. */ - const spawned = async (body: string, parameters = "({ commands })", declaration = `{ name: "a" }`): Promise<{ readonly code: string; readonly calls: readonly unknown[] }> => { + const spawned = async (body: string, parameters = "({ commands })", declaration = `{}`): Promise<{ readonly code: string; readonly calls: readonly unknown[] }> => { const source = `const system = System.create(${declaration}, ${parameters} => { ${body} });`; const result = await transform(source); if (result !== null && "failure" in result) return { code: result.failure.message, calls: [] }; @@ -970,10 +1034,10 @@ describe("commands in main-thread systems", () => { }); test.each([ - ["the context's commands", "context.commands.spawn([score])({ [score]: 1 });", "(context)", `{ name: "a" }`], - ["renamed commands", "issue.spawn([score])({ [score]: 1 });", "({ commands: issue })", `{ name: "a" }`], - ["a system with components", "commands.spawn([score])({ [score]: 1 });", "(row, { commands })", `{ name: "a", components: { has: [mass] } }`], - ["the context of a system with components", "context.commands.spawn([score])({ [score]: 1 });", "(row, context)", `{ name: "a", components: { has: [mass] } }`], + ["the context's commands", "context.commands.spawn([score])({ [score]: 1 });", "(context)", `{}`], + ["renamed commands", "issue.spawn([score])({ [score]: 1 });", "({ commands: issue })", `{}`], + ["a system with components", "commands.spawn([score])({ [score]: 1 });", "(row, { commands })", `{ kernel: false, components: { with: [mass] } }`], + ["the context of a system with components", "context.commands.spawn([score])({ [score]: 1 });", "(row, context)", `{ kernel: false, components: { with: [mass] } }`], ])("pass values positionally through %s", async (_, body, parameters, declaration) => { const { calls } = await spawned(body, parameters, declaration); expect(calls).toEqual([["prepared", ["score"], ["score"], [1]]]); @@ -996,6 +1060,35 @@ describe("commands in main-thread systems", () => { expect(calls.every(call => Array.isArray(call) && call[0] === "spawn")).toBe(true); expect(calls.length).toBe(1); }); + describe("lists of keys", () => { + const keys = "const a = Symbol(); const b = Symbol();\n"; + test("are made once, with an index, for spawns, adds and removes", async () => { + const result = await transform(`${keys}const system = System.create(({ commands }) => { commands.spawn([a, b])({ [a]: 1, [b]: 2 }); commands.add(1, [b], { [b]: 3 }); commands.remove(1, [a, b]); commands.spawn([a]); });`); + const transformed = result !== null && "code" in result ? result.code : ""; + expect(transformed.match(/\(list\$\d+ \?\?= archetype\$\(\[[ab, ]+\]\)\)/g)).toEqual([ + "(list$0 ??= archetype$([a, b]))", "(list$1 ??= archetype$([b]))", "(list$2 ??= archetype$([a, b]))", "(list$3 ??= archetype$([a]))", + ]); + expect(transformed).toContain("function archetype$(list)"); + }); + test("leave a list naming anything but module keys as written", async () => { + const result = await transform(`${keys}const system = System.create(({ commands }) => { const c = a; commands.spawn([a, c]); commands.remove(1, [a, 2]); });`); + const transformed = result !== null && "code" in result ? result.code : ""; + expect(transformed).toContain("commands.spawn([a, c]); commands.remove(1, [a, 2]);"); + expect(transformed).not.toContain("archetype$"); + }); + test("leave a list naming a key shadowed around the body, or by its parameters, as written", async () => { + const result = await transform(`${keys}const make = (a) => System.create(({ commands }) => { commands.spawn([a]); }); +const remove = System.create(({ commands }, b) => { commands.remove(1, [a, b]); });`); + const transformed = result !== null && "code" in result ? result.code : ""; + expect(transformed).toContain("commands.spawn([a]);"); + expect(transformed).toContain("commands.remove(1, [a, b]);"); + expect(transformed).not.toContain("archetype$"); + }); + test("fail the build for a list naming a key twice", async () => { + const result = await transform(`${keys}const system = System.create(({ commands }) => { commands.remove(1, [a, b, a]); });`); + expect(result !== null && "failure" in result ? result.failure.message : null).toBe("this list names a twice; a list of components names each once"); + }); + }); describe("adds", () => { test("pass literal values positionally, after the entity, in source order", async () => { const { code, calls } = await spawned("let i = 0; commands.add(++i, [score, mass, ++i], { [score]: ++i, [mass]: ++i });"); @@ -1029,7 +1122,7 @@ describe("helpers that return early", () => { } return [c, 2]; }; - const kernel = System.create({ name: "a", kernel: "cpu", components: { read: [mass], write: [position] } }, (row) => { + const kernel = System.create({ kernel: true, components: { with: [mass, position] } }, (row) => { const [value, path] = pick(row[mass], 4); row[position] = [value, path, 0]; });`); @@ -1045,7 +1138,7 @@ describe("math builtins", () => { const results = (result: ReturnType) => [0, 1, 2].map(row => [0, 1, 2].map(k => result.field("position", [k])[row])); test("inline as scalar code, nested namespaces and helpers they call included", async () => { - const kernel = await compile(`${math}const kernel = System.create({ name: "a", kernel: "cpu", components: { read: [velocity], write: [position, score] } }, (row) => { + const kernel = await compile(`${math}const kernel = System.create({ kernel: true, components: { with: [velocity, position, score] } }, (row) => { const p = row[position]; const v = row[velocity]; row[position] = Vec3.add(Vec3.normalize(Vec3.cross(p, v)), Vec3.F32.scale(Vec3.clamp(v, Vec3.zero, [1, 1, 1]), Vec3.dot(p, v))); @@ -1064,7 +1157,7 @@ describe("math builtins", () => { }); test("across namespaces: quaternions rotating vectors, and matrices", async () => { - const kernel = await compile(`${math}const kernel = System.create({ name: "a", kernel: "cpu", components: { write: [position] } }, (row) => { + const kernel = await compile(`${math}const kernel = System.create({ kernel: true, components: { with: [position] } }, (row) => { const q = Quat.fromAxisAngle([0, 0, 1], Math.PI / 2); const m = Mat4x4.multiply(Mat4x4.translation(1, 2, 3), Mat4x4.scaling(2, 2, 2)); row[position] = Mat4x4.multiplyVec3(m, Quat.rotateVec3(q, row[position])); @@ -1077,9 +1170,9 @@ describe("math builtins", () => { test("in GPU kernels, become regions WGSL emits as builtins, and other backends run as the inlined body", async () => { const body = `(row) => { row[position] = Vec3.scale(Vec3.normalize(Vec3.cross(row[position], row[velocity])), Vec3.dot(row[position], row[velocity])); }`; - const declaration = `components: { read: [velocity], write: [position] }`; - const gpu = await compile(`${math}const kernel = System.create({ name: "a", kernel: "gpu", ${declaration} }, ${body});`); - const cpu = await compile(`${math}const kernel = System.create({ name: "a", kernel: "cpu", ${declaration} }, ${body});`); + const declaration = `components: { with: [velocity, position] }`; + const gpu = await compile(`${math}const kernel = System.create({ kernel: "gpu", ${declaration} }, ${body});`); + const cpu = await compile(`${math}const kernel = System.create({ kernel: true, ${declaration} }, ${body});`); const regions = (gpu.program?.body ?? []).flatMap(statement => statement.kind === "intrinsic" ? [statement.intrinsic.operation] : []); expect(regions).toEqual(["cross", "normalize", "dot", "*"]); expect((cpu.program?.body ?? []).some(statement => statement.kind === "intrinsic")).toBe(false); @@ -1093,17 +1186,17 @@ describe("math builtins", () => { }); test("that leave the kernel subset fail with the function's reason", async () => { - expect(await failure(`${math}const kernel = System.create({ name: "a", kernel: "cpu", components: { write: [position] } }, (row) => { + expect(await failure(`${math}const kernel = System.create({ kernel: true, components: { with: [position] } }, (row) => { row[position] = Vec3.random(); });`)).toBe("Math.random is not in the kernel subset"); - expect(await failure(`${math}const kernel = System.create({ name: "a", kernel: "cpu", components: { write: [position] } }, (row) => { + expect(await failure(`${math}const kernel = System.create({ kernel: true, components: { with: [position] } }, (row) => { row[position] = Vec3.missing([1, 2, 3]); });`)).toMatch(/^missing isn't exported by .*\/math\/vec3\/public\.ts$/); }); }); describe("imported functions and constants", () => { - const kernel = (imports: string, body: string) => `${imports}\nconst kernel = System.create({ name: "a", kernel: "cpu", components: { read: [mass], write: [score] } }, (row) => { ${body} });`; + const kernel = (imports: string, body: string) => `${imports}\nconst kernel = System.create({ kernel: true, components: { with: [mass, score] } }, (row) => { ${body} });`; const scores = async (source: string, files: { readonly [file: string]: string }) => run(await compile(source, files), 2, { mass: { "[]": [2, 3] } }).field("score"); @@ -1172,10 +1265,10 @@ describe("imported lists", () => { const files = { "/components.js": `export const health = "health";\nexport const armor = "armor";\nexport const tagged = "tagged";\n`, "/archetypes.js": [ - `import { Archetype } from "ecs";`, `import { health, armor, tagged } from "./components.js";`, `export const shield = "shield";`, - `export const Unit = Archetype.create([health, armor, tagged]);`, + `export const Unit = archetype$([health, armor, tagged]);`, + `function archetype$(list) { return list; }`, `export const Guard = [shield, armor];`, `export const Alias = Unit;`, `export const Computed = [health].concat([armor]);`, @@ -1188,7 +1281,7 @@ describe("imported lists", () => { const Unit = ["health", "armor", "tagged"]; const Guard = ["shield", "armor"]; const commanding = (imports: string, body: string) => - `${imports}\nconst kernel = System.create({ name: "a", kernel: "cpu", components: { read: [mass] } }, (row, { commands }) => { ${body} });`; + `${imports}\nconst kernel = System.create({ kernel: true, components: { with: [mass] } }, (row, { commands }) => { ${body} });`; test("name the keys of a list imported from another module, by the kernel's bindings or else through the list", async () => { const source = commanding(`import { health, armor } from "./components.js";\nimport { Unit, Guard } from "./archetypes.js";`, ` @@ -1227,16 +1320,16 @@ describe("imported lists", () => { test("in a declaration, bind keys the list's module defines to the kernel's imports of them, under any name", async () => { const source = `import { shield, Guard } from "./archetypes.js";\nimport { armor as plate } from "./components.js"; -const kernel = System.create({ name: "a", kernel: "cpu", components: { read: Guard, write: [score] } }, (row) => { row[score] = row[shield] * 10 + row[plate]; });`; +const kernel = System.create({ kernel: true, components: { with: [score, ...Guard] } }, (row) => { row[score] = row[shield] * 10 + row[plate]; });`; const kernel = await compile(source, files, { shield: "shield", Guard, plate: "armor" }); expect(run(kernel, 2, { shield: { "[]": [1, 2] }, armor: { "[]": [3, 4] } }).field("score")).toEqual([13, 24]); + // What it requires but doesn't use, it only requires. expect(await failure(`import { shield, Guard } from "./archetypes.js"; -const kernel = System.create({ name: "a", kernel: "cpu", components: { read: Guard, write: [score] } }, (row) => { row[score] = row[shield]; });`, files)) - .toBe("Guard[1] is in components read but never read; remove it, or move it to components has to keep requiring it"); +const kernel = System.create({ kernel: true, components: { with: [score, ...Guard] } }, (row) => { row[score] = row[shield]; });`, files)).toBe("compiled"); }); test.each([ - ["computed when its module runs", "commands.spawn(Computed)({});", "commands.spawn: Computed is computed when /archetypes.js runs, so the compiler can't read its components; declare it as an array literal, or Archetype.create of one"], + ["computed when its module runs", "commands.spawn(Computed)({});", "commands.spawn: Computed is computed when /archetypes.js runs, so the compiler can't read its components; declare it as an array literal, `as const`"], ["not a const", "commands.spawn(Changing)({});", "commands.spawn: Changing isn't a module-scope const, so the compiler can't read its components"], ["holding a spread", "commands.spawn(Spread)({});", "commands.spawn: Spread names a key by something other than an identifier bound at the scope of /archetypes.js"], ["not exported", "commands.spawn(Missing)({});", "commands.spawn: Missing isn't exported by /archetypes.js"], @@ -1267,7 +1360,7 @@ const kernel = System.create({ name: "a", kernel: "cpu", components: { read: Gua }); test.each([ - ["computed when its module runs", "commands.add(row.entity, Computed, {});", "commands.add: Computed is computed when /archetypes.js runs, so the compiler can't tell whether it's a key or a list; declare a key as Symbol(...), or a list as an array literal or Archetype.create of one"], + ["computed when its module runs", "commands.add(row.entity, Computed, {});", "commands.add: Computed is computed when /archetypes.js runs, so the compiler can't tell whether it's a key or a list; declare a key as Symbol(...), or a list as an array literal, `as const`"], ["not a const", "commands.remove(row.entity, Changing);", "commands.remove: Changing isn't a module-scope const, so the compiler can't tell whether it's a key or a list"], ["not exported", "commands.remove(row.entity, Missing);", "commands.remove: Missing isn't exported by /archetypes.js"], ["a namespace", "commands.add(row.entity, Archetypes, 1);", "commands.add: Archetypes is a module namespace, not a key or a list of components"], @@ -1283,140 +1376,146 @@ const kernel = System.create({ name: "a", kernel: "cpu", components: { read: Gua test("fail the build for a declaration's list computed when its module runs", async () => { expect(await failure(`import { Computed } from "./archetypes.js"; -const kernel = System.create({ name: "a", kernel: "cpu", components: { read: Computed, write: [score] } }, (row) => { row[score] = 1; });`, files)) - .toBe("components.read: Computed is computed when /archetypes.js runs, so the compiler can't read its components; declare it as an array literal, or Archetype.create of one"); +const kernel = System.create({ kernel: true, components: { with: Computed } }, (row) => { void row.entity; });`, files)) + .toBe("components.with: Computed is computed when /archetypes.js runs, so the compiler can't read its components; declare it as an array literal, `as const`"); }); }); -describe("declared access", () => { - const kernel = (declaration: string, body: string) => - `const kernel = System.create({ name: "a", kernel: "cpu", ${declaration} }, (row, { resources, lookup, reduce }) => { ${body} });`; - const system = (declaration: string, body: string) => `const system = System.create({ name: "a", ${declaration} }, ${body});`; - - test("a kernel uses every component and resource it lists", async () => { - expect(await failure(kernel("components: { read: [position, velocity], write: [score] }", "row[score] = row[velocity];"))) - .toBe("position is in components read but never read; remove it, or move it to components has to keep requiring it"); - expect(await failure(kernel("components: { read: [position], write: [velocity, mass] }", "row[velocity] = row[position];"))) - .toBe("mass is in components write but never written; remove it, or move it to components read if it's only read"); - expect(await failure(kernel("components: { write: [score] }, resources: { read: [delta] }", "row[score] = 1;"))) - .toBe("delta is in resources read but never read; remove it"); - expect(await failure(kernel("components: { read: [target], write: [score] }, lookup: { targets: { read: [mass, position] } }", - "const found = lookup.targets(row[target]); row[score] = found === null ? 0 : found[mass];"))) - .toBe("position is in lookup.targets read but never read; remove it, or move it to lookup.targets has to keep requiring it"); - expect(await failure(kernel("components: { read: [mass], has: [alive] }", "const entity = row.entity;"))) - .toBe("mass is in components read but never read; remove it, or move it to components has to keep requiring it"); - expect(await failure(kernel("components: { read: [position, mass], write: [score] }, resources: { read: [delta] }", - "row[score] = row[mass] * resources[delta] + row[position][0];"))).toBe("compiled"); - }); - - test("a kernel reads every component it lists in up.read, and up holds only those", async () => { - const up = (kind: string, read: string, body: string) => - `const kernel = System.create({ name: "a", kernel: "${kind}", components: { read: [mass], write: [score] }, up: { through: target, read: [${read}] } }, (row, { up }) => { ${body} });`; - const propagate = "row[score] = (up === null ? 0 : up[score]) + row[mass];"; - expect(await failure(up("cpu", "score, position", propagate))).toBe("position is in up.read but never read; remove it"); - expect(await failure(up("cpu", "score", "row[score] = up === null ? row[mass] : up[position];"))).toBe("up is indexed by a component in up.read"); - expect(await failure(up("gpu", "score", propagate))).toBe("compiled"); - expect(await failure(up("cpu", "score", propagate))).toBe("compiled"); - }); - - test("a main-thread system uses every component and resource it lists, where its body shows every access", async () => { - const unread = "mass is in components read but never read; remove it, or move it to components has to keep requiring it"; - expect(await failure(system("components: { read: [position, mass], write: [velocity] }", "(row) => { row[velocity] = row[position]; }"))).toBe(unread); - expect(await failure(system("components: { read: [position], write: [velocity] }", "(row) => { console.log(row[position]); }"))) - .toBe("velocity is in components write but never written; remove it, or move it to components read if it's only read"); - expect(await failure(system("components: { read: [mass] }", "() => {}"))).toBe(unread); - expect(await failure(system("components: { read: [mass] }", "(row, { commands }) => commands.despawn(row.entity)"))).toBe(unread); - expect(await failure(system("components: { write: [score] }", "(row) => { row[score] += 1; }"))).toBe("compiled"); - }); - - test("a main-thread system uses every resource it lists, through its context's resources however it reaches them", async () => { - const unread = "delta is in resources read but never read; remove it"; - // Without components, the context is the first parameter. - expect(await failure(system("resources: { read: [delta], write: [score] }", "(context) => { context.resources[score] = 1; }"))).toBe(unread); - expect(await failure(system("resources: { read: [delta], write: [score] }", "({ resources }) => { resources[score] = 1; }"))).toBe(unread); - expect(await failure(system("resources: { read: [delta], write: [score] }", "(context) => { const { resources } = context; resources[score] = 1; }"))).toBe(unread); - expect(await failure(system("resources: { read: [delta] }, input: { type: \"number\" }", "({ input }) => { console.log(input); }"))).toBe(unread); - expect(await failure(system("resources: { write: [score, mass] }", "({ resources }) => { resources[score] = 1; }"))).toBe("mass is in resources write but never written; remove it, or move it to resources read if it's only read"); - expect(await failure(system("components: { has: [mass] }, resources: { read: [delta], write: [score] }", - "(_, context) => { context.resources[score] = context.resources[delta]; }"))).toBe("compiled"); - expect(await failure(system("resources: { read: [delta] }", "({ resources: { [delta]: d } }) => { console.log(d); }"))).toBe("compiled"); - }); - - test("a system contributes to every resource it reduces", async () => { - const totals = `const total = "total";\nconst low = "low";\n`; - const missing = "low is in reduce but never contributed to; remove it"; - expect(await failure(`${totals}${kernel(`components: { read: [mass] }, reduce: { [total]: "+", [low]: "min" }`, "reduce[total](row[mass]);")}`)).toBe(missing); - expect(await failure(`${totals}${system(`components: { read: [mass] }, reduce: { [total]: "+", [low]: "min" }`, "(row, { reduce }) => { reduce[total](row[mass]); }")}`)).toBe(missing); - expect(await failure(`${totals}${system(`components: { read: [mass] }, reduce: { [total]: "+", [low]: "min" }`, "(row, context) => { context.reduce[total](row[mass]); }")}`)).toBe(missing); - expect(await failure(`${totals}${system(`components: { read: [mass] }, reduce: { [total]: "+" }`, "(row, { reduce }) => { const add = reduce[total]; add(row[mass]); }")}`)).toBe("compiled"); - }); - - test("a main-thread system reads every component it lists in a lookup, where its body shows every use of the result", async () => { - const looking = (lookup: string, body: string) => system(`components: { read: [target] }, lookup: { targets: { read: ${lookup} } }`, body); - const unread = "position is in lookup.targets read but never read; remove it, or move it to lookup.targets has to keep requiring it"; - expect(await failure(looking("[mass, position]", "(row, context) => { const found = context.lookup.targets(row[target]); if (found !== null) console.log(found[mass]); }"))).toBe(unread); - expect(await failure(looking("[mass, position]", "(row, { lookup }) => { console.log(lookup.targets(row[target])?.[mass]); }"))).toBe(unread); - expect(await failure(looking("[mass, position]", "(row, { lookup: { targets } }) => { console.log(targets(row[target])?.[mass]); }"))).toBe(unread); - expect(await failure(looking("[mass]", "(row, { lookup: { targets } }) => { const found = targets(row[target]); if (!found) return; console.log(found[mass]); }"))) - .toBe("compiled"); - expect(await failure(looking("[mass, position]", "(row, { lookup: { targets } }) => { const found = targets(row[target]); console.log(found); }"))).toBe("compiled"); - expect(await failure(looking("[mass, position]", "(row, { lookup: { targets } }) => { let found = targets(row[target]); console.log(found?.[mass]); }"))).toBe("compiled"); - expect(await failure(looking("[mass, position]", "(row, { lookup }) => { helper(lookup); console.log(row[target]); }"))).toBe("compiled"); - }); - - test("a main-thread system reads every component it lists in a lookup through an index, from its lists' rows", async () => { - const looking = (lookup: string, body: string) => system(`components: { read: [target] }, lookup: { near: { index: grid, read: ${lookup} } }`, body); - const unread = "position is in lookup.near read but never read; remove it"; - expect(await failure(looking("[mass, position]", "(row, { lookup }) => { const near = lookup.near(row[target]); for (let i = 0; i < near.length; i++) console.log(near.at(i)[mass], near.changed); }"))).toBe(unread); - expect(await failure(looking("[mass, position]", "(row, context) => { const other = context.lookup.near(row[target]).at(0); console.log(other[mass], other.entity); }"))).toBe(unread); - expect(await failure(looking("[mass, position]", "(row, { lookup: { near } }) => { console.log(near(row[target])?.[mass]); }"))).toBe(unread); - expect(await failure(looking("[mass, position]", "(row, { lookup }) => { const near = lookup.near(row[target]); console.log(near.at(0)[mass], near.at(1)[position]); }"))).toBe("compiled"); - expect(await failure(looking("[mass, position]", "(row, { lookup }) => { helper(lookup.near(row[target])); }"))).toBe("compiled"); - expect(await failure(looking("[mass, position]", "(row, { lookup }) => { const near = lookup.near(row[target]); const all = near.at; console.log(all); }"))).toBe("compiled"); - }); - - test("a main-thread system reads every component it lists through a destructured row", async () => { - const unread = "mass is in components read but never read; remove it, or move it to components has to keep requiring it"; - expect(await failure(system("components: { read: [position, mass] }", "({ [position]: p, entity }) => { console.log(p, entity); }"))).toBe(unread); - expect(await failure(system("components: { read: [position, mass] }", "(row) => { const { [position]: p } = row; console.log(p); }"))).toBe(unread); - expect(await failure(system("components: { read: [position] }", "({ ...all }) => { console.log(all); }"))).toBe("compiled"); - }); - - test("finalizers are checked as main-thread bodies, in files that import only Finalizer", async () => { - const finalizing = (source: string) => transformKernels(source, "/finalizer.js", parse, async specifier => specifier === "ecs", index => JSON.stringify(`kernel:${index}`)); - const failed = async (source: string) => { - const result = await finalizing(source); - return result !== null && "failure" in result ? result.failure.message : "compiled"; - }; - const header = `import { Finalizer } from "ecs";\nconst handle = "handle";\nconst count = "count";\n`; - expect(await failed(`${header}const f = Finalizer.create({ name: "f", components: { read: [handle, count] } }, (row) => { console.log(row[handle]); });`)) - .toBe("count is in components read but never read; remove it, or move it to components has to keep requiring it"); - expect(await failed(`${header}const f = Finalizer.changes({ name: "f", resources: { read: [count] } }, (changes, { resources }) => { console.log(changes); });`)) - .toBe("count is in resources read but never read; remove it"); - expect(await failed(`${header}const f = Finalizer.changes({ name: "f", resources: { read: [count] } }, (changes, { resources }) => { console.log(changes, resources[count]); });`)) - .toBe("compiled"); - expect(await failed(`${header}const f = Finalizer.create({ name: "f", components: { read: [handle] } }, (row) => { console.log(row[handle]); });`)).toBe("compiled"); - }); - - test("a list declared as a module constant is checked as its array literal", async () => { - const moving = `const moving = [position, velocity];\n`; - expect(await failure(`${moving}${system("components: { read: moving }", "(row) => { console.log(row[position]); }")}`)) - .toBe("velocity is in components read but never read; remove it, or move it to components has to keep requiring it"); - expect(await failure(`${moving}${system("components: { read: moving }", "(row) => { console.log(row[position], row[velocity]); }")}`)).toBe("compiled"); - expect(await failure(`${moving}${kernel("components: { read: moving, write: [score] }", "row[score] = row[position];")}`)) - .toBe("velocity is in components read but never read; remove it, or move it to components has to keep requiring it"); - }); - - test("a main-thread system whose body hides an access isn't checked", async () => { - expect(await failure(system("components: { read: [mass] }", "(row) => { helper(row); }"))).toBe("compiled"); - expect(await failure(system("components: { read: [mass] }", "(row) => { for (const key of [mass]) console.log(row[key]); }"))).toBe("compiled"); - expect(await failure(system("components: { read: [mass] }", "(row) => { const mass = \"other\"; console.log(row[mass]); }"))).toBe("compiled"); - expect(await failure(system("components: { read: [mass] }", "function (row) { console.log(arguments[0][mass]); }"))).toBe("compiled"); - expect(await failure(system("resources: { read: [delta] }", "({ ...all }) => { console.log(all); }"))).toBe("compiled"); - expect(await failure(system("resources: { read: [delta] }", "({ resources: { ...all } }) => { console.log(all); }"))).toBe("compiled"); - expect(await failure(system("resources: { read: [delta] }", "(context) => { helper(context.resources); }"))).toBe("compiled"); - expect(await failure(system("resources: { read: [delta] }", "(context) => { let r = context.resources; console.log(r); }"))).toBe("compiled"); - expect(await failure(system("components: { read: [mass] }", "body"))).toBe("compiled"); - expect(await failure("const decay = (k) => System.create({ name: \"a\", components: { read: [k] } }, (row) => { console.log(row[k]); });")).toBe("compiled"); +describe("inferred access", () => { + /** What the build inferred the first system's body uses, as the source it gives its declaration, or why it failed. */ + const inferred = async (source: string): Promise => { + const result = await transform(source); + if (result === null) return "none"; + if ("failure" in result) return result.failure.message; + return /\{ components: .*?, at: "[^"]*" \}/.exec(result.code)?.[0] ?? "none"; + }; + const system = (declaration: string, body: string) => `const system = System.create({ ${declaration} }, ${body});`; + + test("a row's reads and writes, its members in with's order", async () => { + expect(await inferred(system("components: { with: [position, velocity, mass] }", "(row) => { row[velocity] = row[mass] + row[position]; }"))) + .toContain("components: { read: [position, mass], write: [velocity] }"); + expect(await inferred(system("components: { with: [score] }", "(row) => { row[score] += 1; }"))).toContain("components: { read: [], write: [score] }"); + expect(await inferred(system("components: { with: [position, mass] }", "({ [position]: p, entity }) => { console.log(p, entity); }"))) + .toContain("components: { read: [position], write: [] }"); + }); + + test("a row it can't follow as unknown, which the world takes as every member written, with a warning", async () => { + const result = await transform(system("components: { with: [position] }", "(row) => { helper(row); }")); + expect(result !== null && "code" in result ? [result.code.includes("components: null"), result.warnings.length] : null).toEqual([true, 1]); + }); + + test("an observer's row it can't follow as unknown without a warning, since the world takes an observer's as read", async () => { + const result = await transform(system("observer: true, components: { with: [position] }", "(row) => { helper(row); }")); + expect(result !== null && "code" in result ? [result.code.includes("components: null"), result.warnings] : null).toEqual([true, []]); + }); + + test("a row it can't follow with a warning unless the declaration says observer: true, as the world reads it", async () => { + for (const observer of ["observer: false, ", "observer: watching, "]) { + const result = await transform(`const watching = true;\n${system(`${observer}components: { with: [position] }`, "(row) => { helper(row); }")}`); + expect(result !== null && "code" in result ? result.warnings.length : null).toBe(1); + } + }); + + test("a lookup's rows it can't follow without a warning, since what they hold is what the declaration says", async () => { + const result = await transform(system("components: { with: [target] }, lookup: { targets: { with: [mass] } }", "(row, { lookup }) => { helper(lookup.targets(row[target])); }")); + expect(result !== null && "code" in result ? [result.code.includes(`"targets": null`), result.warnings] : null).toEqual([true, []]); + }); + + test("warnings name the module by the label given, with line and column", async () => { + const result = await transformKernels(`${header}${system("components: { with: [position] }", "(row) => { helper(row); }")}`, "/repo/app/src/features/a/model.ts", parse, + async specifier => specifier === "ecs" ? ownCopy : null, () => "", createLinker(host({}, reductions), parse), true, "src/features/a/model.ts"); + expect(result !== null && "code" in result ? result.warnings.map(warning => /^[^ ]*:/.exec(warning)?.[0]) : null).toEqual([`src/features/a/model.ts:${header.split("\n").length}:${"const system = System.create({ components: { with: [position] } }, ".length + 1}:`]); + }); + + test("resources read, written and reduced, however its context reaches them", async () => { + expect(await inferred(system("", "(context) => { context.resources[score] = context.resources[delta]; }"))) + .toContain("resources: { read: [delta], write: [score], reduce: [] }"); + expect(await inferred(system("", "(context) => { const { resources } = context; resources[score] += 1; }"))) + .toContain("resources: { read: [score], write: [score], reduce: [] }"); + expect(await inferred(system("", "({ resources: { [delta]: d } }) => { console.log(d); }"))).toContain("resources: { read: [delta], write: [], reduce: [] }"); + expect(await inferred(`const total = "total";\n${system("components: { with: [mass] }", "(row, { reduce }) => { reduce[total](row[mass]); }")}`)) + .toContain("resources: { read: [], write: [], reduce: [total] }"); + expect(await inferred(system("", "(context) => { helper(context); }"))).toContain("resources: null"); + }); + + test("what a lookup's rows are read for, by entity and through an index, or unknown", async () => { + expect(await inferred(system("components: { with: [target] }, lookup: { targets: { with: [mass, position] } }", + "(row, { lookup }) => { console.log(lookup.targets(row[target])?.[mass]); }"))).toContain(`lookup: { "targets": [mass] }`); + expect(await inferred(system("components: { with: [target] }, lookup: { near: { index: grid } }", + "(row, { lookup }) => { const near = lookup.near(row[target]); console.log(near.at(0)[mass], near.length); }"))).toContain(`lookup: { "near": [mass] }`); + expect(await inferred(system("components: { with: [target] }, lookup: { targets: { with: [mass] } }", + "(row, { lookup }) => { helper(lookup.targets(row[target])); }"))).toContain(`lookup: { "targets": null }`); + }); + + test("what its body reads of its parent", async () => { + expect(await inferred(system("components: { with: [mass, score] }, up: { through: target }", "(row, { up }) => { row[score] = (up === null ? 0 : up[score]) + row[mass]; }"))) + .toContain("up: [score]"); + }); + + test("fail the build for a key a body uses that its declaration doesn't require", async () => { + expect(await inferred(system("components: { with: [position] }", "(row) => { console.log(row[mass]); }"))) + .toBe("mass isn't in components with or optional, so the system doesn't require it: add it there to use it"); + expect(await inferred(system("components: { with: [target] }, lookup: { targets: { with: [mass] } }", "(row, { lookup }) => { console.log(lookup.targets(row[target])?.[position]); }"))) + .toBe("position isn't in lookup.targets.with or optional, so the system doesn't require it: add it there to use it"); + }); + + test("finalizers, in files that import only Finalizer", async () => { + const result = await transformKernels(`import { Finalizer } from "ecs";\nconst handle = "handle";\nconst count = "count";\nconst f = Finalizer.create({ components: { with: [handle, count] } }, (row, { resources }) => { console.log(row[handle], resources[count]); });`, + "/finalizer.js", parse, async specifier => specifier === "ecs" ? ownCopy : null, index => JSON.stringify(`kernel:${index}`)); + expect(result !== null && "code" in result ? result.code : "").toContain("{ components: { read: [handle], write: [] }, resources: { read: [count], write: [], reduce: [] }"); + }); + + test("a destructuring target, or a for…of target, written", async () => { + expect(await inferred(system("components: { with: [position] }", "(row) => { ({ x: row[position] } = source); }"))) + .toContain("components: { read: [], write: [position] }"); + expect(await inferred(system("components: { with: [position] }", "(row) => { for ({ x: row[position] } of sources); }"))) + .toContain("components: { read: [], write: [position] }"); + expect(await inferred(system("components: { with: [position] }", "(row) => { [row[position]] = pair; }"))) + .toContain("components: { read: [], write: [position] }"); + expect(await inferred(system("", "(context) => { ({ x: context.resources[score] } = source); }"))) + .toContain("resources: { read: [], write: [score], reduce: [] }"); + }); + + test("defaults and computed keys of declared patterns, and of parameters, as uses", async () => { + expect(await inferred(system("components: { with: [position] }", "(row) => { const { a = (row[position] = 5) } = {}; }"))) + .toContain("components: { read: [], write: [position] }"); + expect(await inferred(system("components: { with: [position, velocity] }", "(row) => { const { [row[velocity]]: v } = table; console.log(v); }"))) + .toContain("components: { read: [velocity], write: [] }"); + expect(await inferred(system("", "(context) => { const { a = context.resources[delta] } = {}; console.log(a); }"))) + .toContain("resources: { read: [delta], write: [], reduce: [] }"); + expect(await inferred(system("components: { with: [position, velocity] }", "(row, context, step = row[velocity]) => { console.log(step); }"))) + .toContain("components: { read: [velocity], write: [] }"); + }); + + test("what a for…of or for…in loop goes over, as a read", async () => { + expect(await inferred(system("", "({ resources }) => { for (const name of resources[delta]) console.log(name); }"))) + .toContain("resources: { read: [delta], write: [], reduce: [] }"); + expect(await inferred(system("components: { with: [position] }", "(row) => { for (const x of row[position]) console.log(x); }"))) + .toContain("components: { read: [position], write: [] }"); + }); + + test("a change inside a value, at any depth, as a change in place, for registration to judge by its kind", async () => { + expect(await inferred(system("components: { with: [position] }", "(row) => { row[position].a.b = 1; }"))) + .toContain("components: { read: [position], write: [], mutate: [position] }"); + expect(await inferred(system("components: { with: [position] }", "(row) => { delete row[position].a; }"))) + .toContain("components: { read: [position], write: [], mutate: [position] }"); + expect(await inferred(system("components: { with: [position] }", "(row) => { [row[position][0]] = pair; }"))) + .toContain("components: { read: [position], write: [], mutate: [position] }"); + expect(await inferred(system("", "(context) => { context.resources[score].a = 1; }"))) + .toContain("resources: { read: [score], write: [], reduce: [], mutate: [score] }"); + }); + + test("a body mentioning eval as one it can't follow", async () => { + expect(await inferred(system("components: { with: [position] }", "(row) => { eval(\"row[position] = 1\"); }"))).toContain("components: null"); + }); + + test("fail the build for an async or generator body", async () => { + expect(await inferred(system("components: { with: [position] }", "async (row) => { row[position] = 1; }"))).toContain("a body can't be async"); + expect(await inferred(system("components: { with: [position] }", "function* (row) { row[position] = 1; }"))).toContain("a body can't be a generator"); + }); + + test("a body given by name, read where it's bound", async () => { + expect(await inferred(`const move = (row) => { row[position] = row[velocity]; };\n${system("components: { with: [position, velocity] }", "move")}`)) + .toContain("components: { read: [velocity], write: [position] }"); }); }); diff --git a/packages/ecs/src/implementation/compiler/transform.ts b/packages/ecs/src/implementation/compiler/transform.ts index 1bb93bb95..1a74ccfa1 100644 --- a/packages/ecs/src/implementation/compiler/transform.ts +++ b/packages/ecs/src/implementation/compiler/transform.ts @@ -1,23 +1,28 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import type { Node, ObjectExpression, Program, SimpleCallExpression } from 'estree'; +import type { FunctionDeclaration, MaybeNamedFunctionDeclaration, Node, ObjectExpression, Program, SimpleCallExpression } from 'estree'; +import { posix } from 'node:path'; import { compileKernel, type CompiledKernel } from './compile-kernel.js'; -import { children, isFunction } from './estree.js'; +import { children, declaredIn, isFunction } from './estree.js'; import { fail, failureOf, type Failure } from './failure.js'; import { isPending, type Link, type Linker } from './link.js'; -import { createModule, locateIn, type Module } from './module-scope.js'; -import { attachKernelPath, prepareLookupPath, prepareSpawnPath } from './paths.js'; -import { spawnSites } from './spawns.js'; -import { kernelOf } from './signature.js'; -import { usedBy } from './body-access.js'; -import { requireExact } from './exact.js'; -import { createLists } from './lists.js'; +import { createModule, locateIn, type Module, type ModuleBinding } from './module-scope.js'; +import type { PackageCopy } from './paths.js'; +import { archetypeFunction } from './archetypes.js'; +import { listSites, spawnSites } from './spawns.js'; +import { kernelOf, type KernelRequest } from './signature.js'; +import { usedBy, type Used } from './body-access.js'; +import { accessText, followsAll, inOrder, resolvedDeclaration, unrequired } from './inferred.js'; import { neverReachesCommands } from './terminal.js'; import { lookupSites } from './lookups.js'; import { terminalBodiesName } from '../world/terminal.js'; +import { accessName as accessName$ } from '../../interface/registry/ids.js'; export type Parse = (code: string) => Program; -/** Whether an import specifier, resolved from the module being transformed, is the package's, which exports `System` and `Finalizer`. */ -export type ResolveSystemImport = (source: string) => Promise; +/** + * The copy of the package an import specifier, resolved from the module being transformed, is a module of that exports + * `namespace`; null when it's no such module. Compiled code imports the runtime from that copy. + */ +export type ResolveSystemImport = (source: string, namespace: "System" | "Finalizer") => Promise; /** * The JS expression for kernel `index`'s module url. `identity` is everything the description and module are * compiled to (the description without its url, plus the module source), so it can version the url. @@ -28,39 +33,48 @@ export type KernelUrl = (index: number, identity: string) => string; * whose functions and constants the kernels inline, or that compiling them read before failing. */ export type TransformResult = - | { readonly code: string; readonly kernels: readonly string[]; readonly dependencies: readonly string[] } + | { readonly code: string; readonly kernels: readonly string[]; readonly dependencies: readonly string[]; readonly warnings: readonly string[] } | { readonly failure: Failure; readonly dependencies: readonly string[] }; type Edit = { readonly start: number; readonly end: number; readonly text: string }; /** The local names of the package's `System` and `Finalizer` namespaces, by the namespace each names. */ -const systemLocals = async (program: Program, resolveSystemImport: ResolveSystemImport): Promise> => { +/** The module's local names of `System` and `Finalizer`, and the copy of the package the first of them comes from. */ +const systemLocals = async (program: Program, resolveSystemImport: ResolveSystemImport): Promise<{ readonly locals: ReadonlyMap; readonly copy: PackageCopy | null }> => { const locals = new Map(); + let copy: PackageCopy | null = null; for (const statement of program.body) { if (statement.type !== "ImportDeclaration" || typeof statement.source.value !== "string") continue; const specifiers = statement.specifiers.flatMap(specifier => specifier.type === "ImportSpecifier" && specifier.imported.type === "Identifier" && (specifier.imported.name === "System" || specifier.imported.name === "Finalizer") ? [{ local: specifier.local.name, imported: specifier.imported.name }] : []); - if (specifiers.length > 0 && await resolveSystemImport(statement.source.value)) { - for (const { local, imported } of specifiers) locals.set(local, imported === "System" ? "System" : "Finalizer"); + for (const { local, imported } of specifiers) { + const namespace = imported === "System" ? "System" : "Finalizer"; + const resolved = await resolveSystemImport(statement.source.value, namespace); + if (resolved === null) continue; + copy ??= resolved; + locals.set(local, namespace); } } - return locals; + return { locals, copy }; }; -type Created = "system" | "finalizer" | "changes"; +type Created = "system" | "render" | "finalizer" | "changes"; -/** `System.create`, or `Finalizer.create` or `Finalizer.changes`, whose bodies run on the main thread; null for any other call. */ +/** `System.create` or `System.render`, or `Finalizer.create` or `Finalizer.changes`, whose bodies the build reads; null for any other call. */ const createdBy = (node: SimpleCallExpression, locals: ReadonlyMap): Created | null => { if (node.callee.type !== "MemberExpression" || node.callee.computed || node.callee.object.type !== "Identifier" || node.callee.property.type !== "Identifier") return null; const namespace = locals.get(node.callee.object.name); const method = node.callee.property.name; - if (namespace === "System") return method === "create" ? "system" : null; + if (namespace === "System") return method === "create" ? "system" : method === "render" ? "render" : null; return namespace === "Finalizer" && (method === "create" || method === "changes") ? (method === "create" ? "finalizer" : "changes") : null; }; -/** Which parameters are the row and the context: a system without `components` takes only its context, and a changes finalizer takes its changes first. */ +/** + * Which parameters are the row and the context: a system without `components` takes only its context, a render + * system its GPU context first, and a changes finalizer its changes first. + */ const positionsOf = (created: Created, declaration: ObjectExpression): { readonly row: number; readonly context: number } => { - if (created === "changes") return { row: -1, context: 1 }; + if (created === "changes" || created === "render") return { row: -1, context: 1 }; const rowed = declaration.properties.some(property => property.type === "Property" && !property.computed && property.key.type === "Identifier" && property.key.name === "components"); return rowed ? { row: 0, context: 1 } : { row: -1, context: 0 }; @@ -96,6 +110,41 @@ const addFunction = (name: string, prepare: string, keys: readonly string[]): st ].join("\n"); }; +/** + * A declaration written in place: an object literal, or one passed to a call that wraps it, as a helper giving it a + * label or an id does; undefined for anything else. + */ +const literalOf = (node: Node | undefined): ObjectExpression | undefined => { + if (node?.type === "ObjectExpression") return node; + if (node?.type === "CallExpression" && node.arguments[0]?.type === "ObjectExpression") return node.arguments[0]; + return undefined; +}; + +/** Fails the build for a key the body uses that its declaration doesn't require, which would otherwise narrow what it matches unseen. */ +const requireRequired = (used: Used, declaration: ObjectExpression, body: Node): void => { + const found = unrequired(used, declaration); + if (found !== null) fail(body, `${found.key} isn't in ${found.list}, so the system doesn't require it: add it there to use it`); +}; + +/** A function declaration with a name, which `export default` may leave out. */ +const isNamed = (node: FunctionDeclaration | MaybeNamedFunctionDeclaration): node is FunctionDeclaration => node.id !== null; + +/** The function a module-scope binding holds: a function declaration, or a `const` initialized by one. */ +const functionOf = (binding: ModuleBinding | undefined): Node | undefined => { + if (binding?.kind === "function") return isNamed(binding.node) ? binding.node : undefined; + if (binding?.kind === "const" && isFunction(binding.init)) return binding.init; + return undefined; +}; + +/** The declaration of a system that declares nothing, made from its body alone. */ +const empty: ObjectExpression = { type: "ObjectExpression", properties: [], start: 0, end: 0 }; + +/** Whether a declaration has property `name`, written out. */ +/** Whether a declaration gives `name` the literal `true`, as the world takes `observer`. */ +const declaresTrue = (declaration: ObjectExpression, name: string): boolean => declaration.properties.some(property => + property.type === "Property" && !property.computed && property.key.type === "Identifier" && property.key.name === name + && property.value.type === "Literal" && property.value.value === true); + const uniqueName = (code: string, base: string): string => { let name = base; for (let n = 1; code.includes(name); n++) name = `${base}${n}`; @@ -103,10 +152,10 @@ const uniqueName = (code: string, base: string): string => { }; /** Compiles a kernel, loading what it imports as compiling reaches it. */ -const compileLinked = async (declaration: ObjectExpression, body: Node, module: Module, target: "cpu" | "gpu", link: Link | null): Promise => { +const compileLinked = async (declaration: ObjectExpression, body: Node, module: Module, request: KernelRequest, link: Link | null): Promise => { for (;;) { try { - return compileKernel(declaration, body, module, target, link); + return compileKernel(declaration, body, module, request.target, link, !request.required); } catch (error) { if (!isPending(error)) throw error; await error.pending; @@ -114,7 +163,38 @@ const compileLinked = async (declaration: ObjectExpression, body: Node, module: } }; -type KernelSite = { readonly node: SimpleCallExpression; readonly declaration: ObjectExpression; readonly body: Node; readonly target: "cpu" | "gpu" }; +type KernelSite = { readonly node: SimpleCallExpression; readonly declaration: ObjectExpression; readonly body: Node; readonly request: KernelRequest; readonly used: Used }; + +/** + * The module's `System.create` calls that compile to kernels, required or, when `automatic`, tried: at module scope, + * with an inline body. Throws for a required one elsewhere or without one; a tried one there runs as written. + */ +const kernelSites = (program: Program, locals: ReadonlyMap, automatic: boolean, module: Module): readonly KernelSite[] => { + const sites: KernelSite[] = []; + const visit = (node: Node, nested: boolean): void => { + if (node.type === "CallExpression" && createdBy(node, locals) === "system") { + const [declaration, body] = node.arguments; + // One argument makes a composite, or a system from its body alone: never a kernel. + const request = declaration?.type === "ObjectExpression" && body !== undefined ? kernelOf(declaration) : null; + if (declaration?.type === "ObjectExpression" && request !== null) { + const inline = body !== undefined && isFunction(body); + if (request.required && nested) fail(node, "kernel systems must be declared at module scope"); + if (request.required && !inline) fail(node, "a kernel needs an inline body"); + if (!nested && inline && (request.required || automatic)) { + const used = inOrder(usedBy(declaration, body, module.bindings, positionsOf("system", declaration)), declaration); + requireRequired(used, declaration, body); + // A kernel compiles from what its body uses, so the build must see every use. + if (followsAll(used, declaration)) sites.push({ node, declaration, body, request, used }); + else if (request.required) fail(body, "a kernel's body indexes its row and context by key directly, or destructures them by key, so the build sees what it reads and writes; it can't pass them on, alias or spread them"); + } + } + } + const inner = nested || isFunction(node); + for (const child of children(node)) visit(child, inner); + }; + visit(program, false); + return sites; +}; /** * Wraps each kernel `System.create(declaration, body)` call as `attachKernel(call, description)`, and returns each @@ -125,23 +205,33 @@ type KernelSite = { readonly node: SimpleCallExpression; readonly declaration: O */ export const transformKernels = async ( code: string, id: string, parse: Parse, resolveSystemImport: ResolveSystemImport, kernelUrl: KernelUrl, linker?: Linker, + /** Whether unflagged systems are tried as kernels; tests turn it off to run their bodies as written. */ + automatic = true, + /** How messages name the module: its path in its package; the file's name without one. */ + label?: string, ): Promise => { if ((!code.includes("System") && !code.includes("Finalizer")) || id.includes("\0")) return null; const program = parse(code); - const locals = await systemLocals(program, resolveSystemImport); - if (locals.size === 0) return null; + const { locals, copy } = await systemLocals(program, resolveSystemImport); + if (copy === null) return null; const file = id.split("?")[0] ?? id; const module = createModule(file, program, locateIn(code)); linker?.add(module); const dependencies = new Set(); - /** Main-thread declarations are checked only against lists of their own module. */ - const mainThreadLists = createLists(module, null, []); + const at = (node: Node): string => { + const { line, column } = module.locate(node.start); + return `${label ?? posix.basename(file)}:${line}:${column}`; + }; + const warnings: string[] = []; + const accessName = uniqueName(code, "access$"); + let accessing = false; const link: Link | null = linker === undefined ? null : { module: dependency => { if (dependency !== file) dependencies.add(dependency); return linker.module(dependency); }, resolve: linker.resolve, + reductionOf: linker.reductionOf, }; const attach = uniqueName(code, "attachKernel$"); const prepare = uniqueName(code, "prepareSpawn$"); @@ -150,45 +240,102 @@ export const transformKernels = async ( const terminal = uniqueName(code, "terminal$"); const prepareLookupName = uniqueName(code, "prepareLookup$"); const lookupName = uniqueName(code, "lookup$"); + const listName = uniqueName(code, "list$"); + const archetypeName = uniqueName(code, "archetype$"); + let indexing = false; let looking = false; let terminals = false; const edits: Edit[] = []; const kernels: string[] = []; - const sites: KernelSite[] = []; + /** Each compiled kernel's description, by its call, and whether its declaration required it. */ + const compiled = new Map(); /** Module-scope declarations of prepared spawns, appended after everything they name is declared. */ const prepared: string[] = []; let adding = false; let spawning = false; - const visit = (node: Node, nested: boolean): void => { + /** The names `outermost`, the outermost function around a node, declares anywhere: any may shadow a module's key there. */ + const shadowedBy = (outermost: Node | null): ReadonlySet => { + const names = new Set(); + if (outermost !== null) declaredIn(outermost, name => names.add(name)); + return names; + }; + const visit = (node: Node, nested: boolean, outermost: Node | null = null): void => { const created = node.type === "CallExpression" ? createdBy(node, locals) : null; const [first, second] = node.type === "CallExpression" ? node.arguments : []; + /** Whether `node` is a function, written in place or bound by name at module scope. */ + const functional = (node: Node | undefined): boolean => node !== undefined && (isFunction(node) || (node.type === "Identifier" && functionOf(module.bindings.get(node.name)) !== undefined)); // One argument that isn't a body makes a composite, of a list or a declaration: never a kernel, nor checked. - const composite = created === "system" && first !== undefined && second === undefined && !isFunction(first); + const composite = created === "system" && first !== undefined && second === undefined && !functional(first); if (node.type === "CallExpression" && created !== null && !composite) { // A system that declares nothing is made from its body alone, which takes only its context. - const alone = created === "system" && first !== undefined && isFunction(first) && second === undefined; - const declaration = alone ? undefined : first; - const body = alone ? first : second; - const target = declaration?.type === "ObjectExpression" ? kernelOf(declaration) : null; - if (declaration?.type === "ObjectExpression" && target !== null) { - if (nested) fail(node, "kernel systems must be declared at module scope"); - if (body === undefined || body.type === "SpreadElement") return fail(node, "a kernel needs an inline body"); - sites.push({ node, declaration, body, target }); + const alone = created === "system" && first !== undefined && functional(first) && second === undefined; + const declaration = alone ? undefined : literalOf(first); + const given = alone ? first : second; + // A body given by name is the function its module binds it to, which the build reads in its place. + const body = given?.type === "Identifier" ? functionOf(module.bindings.get(given.name)) ?? given : given; + const kernel = compiled.get(node); + const positions = alone ? { row: -1, context: 0 } : declaration?.type === "ObjectExpression" ? positionsOf(created, declaration) : created === "render" ? positionsOf(created, empty) : null; + // A declaration not written in place, or a body that isn't a function written in place, is one the build can't + // read: it takes what it uses from an internal factory, or fails where it's made. + if (positions === null || first === undefined || first.type === "SpreadElement" || body === undefined || body.type === "SpreadElement" || !isFunction(body)) { + for (const child of children(node)) visit(child, nested || isFunction(node), outermost ?? (isFunction(node) ? node : null)); return; } - const positions = alone ? { row: -1, context: 0 } : declaration?.type === "ObjectExpression" ? positionsOf(created, declaration) : null; - if (declaration?.type === "ObjectExpression" && positions !== null && body !== undefined && body.type !== "SpreadElement") { - requireExact(declaration, usedBy(declaration, body, module.bindings, positions), module.bindings, mainThreadLists); + // A row is bound to each entity in turn, so a body resuming later would read and write another's. + if ((body.type === "ArrowFunctionExpression" || body.type === "FunctionExpression" || body.type === "FunctionDeclaration") && (body.async || body.generator)) { + fail(body, `a body can't be ${body.async ? "async" : "a generator"}: its row and context are rebound for the next entity while it waits, so start the work and finish it in a later run`); + } + const declared = declaration?.type === "ObjectExpression" ? declaration : empty; + const used = kernel?.used ?? inOrder(usedBy(declared, body, module.bindings, positions), declared); + requireRequired(used, declared, body); + const where = at(body); + // An observer's row, and a lookup's rows, it can't follow are taken as read whole, which is what their + // declarations already say; only a row taken as written whole costs what the author should know of. + if (used.query === null && !declaresTrue(declared, "observer")) warnings.push(`${where}: the build can't follow how this body uses its row, so it takes every component in with, added and changed as written: index the row by key directly, or destructure it by key, to let it tell what the body reads and writes.`); + accessing = true; + const access = accessText(used, where); + if (kernel !== undefined) { + // `first` is the declaration: a kernel's call has one written in place. + const call = `${code.slice(node.start, first.start)}${accessName}(${code.slice(first.start, first.end)}, ${access})${code.slice(first.end, node.end)}`; + edits.push({ start: node.start, end: node.end, text: `${attach}(${call}, ${kernel.description}${kernel.required ? "" : ", true"})` }); + return; + } + // Edits within a body apply only to one written in place; a body given by name is only read. + if (given !== body) { + edits.push({ start: first.start, end: first.start, text: `${accessName}(` }, { start: first.end, end: first.end, text: `, ${access})` }); + return; } // A system whose body never reaches its commands is terminal where its declaration allows (`world/tails.ts`). - if (created === "system" && positions !== null && body !== undefined && body.type !== "SpreadElement" && neverReachesCommands(body, positions.context)) { - terminals = true; - edits.push({ start: body.start, end: body.start, text: `${terminal}(` }, { start: body.end, end: body.end, text: ")" }); + const terminalBody = created === "system" && neverReachesCommands(body, positions.context); + if (terminalBody) terminals = true; + if (alone) { + // The body is the system's first argument, which takes what it uses, outside its terminal mark. + edits.push( + { start: body.start, end: body.start, text: `${accessName}(${terminalBody ? `${terminal}(` : ""}` }, + { start: body.end, end: body.end, text: `${terminalBody ? ")" : ""}, ${access})` }, + ); + } + else { + edits.push({ start: first.start, end: first.start, text: `${accessName}(` }, { start: first.end, end: first.end, text: `, ${access})` }); + if (terminalBody) edits.push({ start: body.start, end: body.start, text: `${terminal}(` }, { start: body.end, end: body.end, text: ")" }); } - const spawns = positions === null || body === undefined || body.type === "SpreadElement" ? null : spawnSites(body, module.bindings, positions.context); + // Names declared around the body may shadow a module's keys, so only the module's own are hoisted. + const shadowed = shadowedBy(outermost); + const spawns = spawnSites(body, module.bindings, positions.context, shadowed); // Index lookups pass their key positionally, through a list of their own (`world/prepared-lookup.ts`). - const lookups = positions === null || body === undefined || body.type === "SpreadElement" || declaration?.type !== "ObjectExpression" ? [] : lookupSites(body, declaration, positions.context); + const lookups = declaration?.type !== "ObjectExpression" ? [] : lookupSites(body, declaration, positions.context); const spawned = spawns?.sites ?? []; + // Lists of keys its commands take are made once, with an index, rather than on every call. + const made = new Map(); + const listed = listSites(body, module.bindings, positions.context, shadowed); + for (const site of listed) { + const inside = [...spawned, ...lookups].some(other => other.start <= site.start && site.end <= other.end && !("components" in other && other.components.start === site.start)); + if (inside) continue; + const name = `${listName}${prepared.length}`; + prepared.push(`var ${name};`); + indexing = true; + made.set(site.start, { end: site.end, text: `(${name} ??= ${archetypeName}([${site.names.join(", ")}]))` }); + } for (const site of lookups) { if (spawned.some(other => other.start < site.end && site.start < other.end)) continue; const name = `${lookupName}${prepared.length}`; @@ -203,24 +350,37 @@ export const transformKernels = async ( prepared.push(site.kind === "spawn" ? spawnFunction(name, prepare, site.keys) : addFunction(name, prepareAddName, site.keys)); if (site.kind === "add") adding = true; else spawning = true; - const values = [...site.entity === null ? [] : [site.entity], site.components, ...site.values].map(value => code.slice(value.start, value.end)); + const values = [...site.entity === null ? [] : [site.entity], site.components, ...site.values].map(value => { + const list = made.get(value.start); + if (list === undefined || list.end !== value.end) return code.slice(value.start, value.end); + made.delete(value.start); + return list.text; + }); edits.push({ start: site.start, end: site.end, text: `${name}(${[spawns.commands, ...values].join(", ")})` }); } + for (const [start, { end, text }] of made) edits.push({ start, end, text }); return; } + for (const [start, { end, text }] of made) edits.push({ start, end, text }); } const inner = nested || isFunction(node); - for (const child of children(node)) visit(child, inner); + for (const child of children(node)) visit(child, inner, outermost ?? (isFunction(node) ? node : null)); }; try { - visit(program, false); - for (const { node, declaration, body, target } of sites) { - const compiled = await compileLinked(declaration, body, module, target, link); - const source = `export const create = ${compiled.create};\n`; - const description = compiled.description(kernelUrl(kernels.length, `${compiled.description("")}\n${source}`)); + for (const { node, declaration, body, request, used } of kernelSites(program, locals, automatic, module)) { + let kernel: CompiledKernel; + try { + kernel = await compileLinked(resolvedDeclaration(declaration, used), body, module, request, link); + } catch (error) { + // A body tried unflagged that leaves the subset runs as written. + if (!request.required && failureOf(error) !== undefined) continue; + throw error; + } + const source = `export const create = ${kernel.create};\n`; + compiled.set(node, { description: kernel.description(kernelUrl(kernels.length, `${kernel.description("")}\n${source}`)), required: request.required, used }); kernels.push(source); - edits.push({ start: node.start, end: node.end, text: `${attach}(${code.slice(node.start, node.end)}, ${description})` }); } + visit(program, false); } catch (error) { const failure = failureOf(error); if (failure === undefined) throw error; @@ -230,11 +390,14 @@ export const transformKernels = async ( let result = code; for (const edit of [...edits].sort((a, b) => b.start - a.start)) result = result.slice(0, edit.start) + edit.text + result.slice(edit.end); const imports = [ - ...kernels.length > 0 ? [`import { attachKernel as ${attach} } from ${JSON.stringify(attachKernelPath)};`] : [], - ...spawning ? [`import { prepareSpawn as ${prepare} } from ${JSON.stringify(prepareSpawnPath)};`] : [], - ...adding ? [`import { prepareAdd as ${prepareAddName} } from ${JSON.stringify(prepareSpawnPath)};`] : [], - ...looking ? [`import { prepareLookup as ${prepareLookupName} } from ${JSON.stringify(prepareLookupPath)};`] : [], + ...kernels.length > 0 ? [`import { attachKernel as ${attach} } from ${JSON.stringify(copy.attachKernel)};`] : [], + ...spawning ? [`import { prepareSpawn as ${prepare} } from ${JSON.stringify(copy.preparedSpawn)};`] : [], + ...adding ? [`import { prepareAdd as ${prepareAddName} } from ${JSON.stringify(copy.preparedSpawn)};`] : [], + ...looking ? [`import { prepareLookup as ${prepareLookupName} } from ${JSON.stringify(copy.preparedLookup)};`] : [], ]; const marking = terminals ? [`function ${terminal}(body) { const set = Symbol.for(${JSON.stringify(terminalBodiesName)}); (globalThis[set] || (globalThis[set] = new WeakSet())).add(body); return body; }`] : []; - return { code: [...imports, result, ...prepared, ...marking].join("\n"), kernels, dependencies: [...dependencies] }; + // An internal factory may have given its declaration what its body uses first, which stands. + const giving = accessing ? [`function ${accessName}(made, uses) { const key = Symbol.for(${JSON.stringify(accessName$)}); const map = globalThis[key] || (globalThis[key] = new WeakMap()); if (!map.has(made)) map.set(made, uses); return made; }`] : []; + const indexed = indexing ? [archetypeFunction(archetypeName)] : []; + return { code: [...imports, result, ...prepared, ...marking, ...giving, ...indexed].join("\n"), kernels, dependencies: [...dependencies], warnings }; }; diff --git a/packages/ecs/src/implementation/compiler/type-host.test-helper.ts b/packages/ecs/src/implementation/compiler/type-host.test-helper.ts new file mode 100644 index 000000000..a3a1ad53e --- /dev/null +++ b/packages/ecs/src/implementation/compiler/type-host.test-helper.ts @@ -0,0 +1,27 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { existsSync, readFileSync } from 'node:fs'; +import { posix } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { parseAst } from 'vite'; +import type { AugmentationHost } from './augmentations.js'; +import type { InputHost } from './input-schemas.js'; +import { packagePathOf } from './paths.js'; + +const packageRoot = fileURLToPath(new URL("../../..", import.meta.url)).replace(/\/$/, ""); + +/** + * Files by path, beside the package's own sources, which `@adobe/ecs` resolves to, as it exports `System`; emitted + * registrations import from `declare.js`. + */ +export const hostOf = (files: { readonly [path: string]: string }): InputHost & AugmentationHost => ({ + parse: (code, file) => parseAst(code, { lang: "ts" }, file), + resolve: async (specifier, importer) => { + if (specifier === "@adobe/ecs") return `${packageRoot}/src/index.ts`; + if (!specifier.startsWith(".")) return null; + const target = posix.resolve(posix.dirname(importer), specifier).replace(/\.js$/, ".ts"); + return target in files || existsSync(target) ? target : null; + }, + load: async file => files[file] ?? readFileSync(file, "utf8"), + packagePath: packagePathOf, + isSystem: async specifier => specifier === "@adobe/ecs", +}); diff --git a/packages/ecs/src/implementation/compiler/type-schemas.ts b/packages/ecs/src/implementation/compiler/type-schemas.ts new file mode 100644 index 000000000..fd1806989 --- /dev/null +++ b/packages/ecs/src/implementation/compiler/type-schemas.ts @@ -0,0 +1,714 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { parseAst } from 'vite'; +import type { Schema } from '../../interface/schema/schema.js'; +import type { Failure } from './failure.js'; + +/** A TypeScript module's syntax, types included. */ +export type TypedProgram = ReturnType; +export type Statement = TypedProgram["body"][number]; +type Alias = Extract; +type Interface = Extract; +type Class = Extract; +type Namespace = Extract; +type Import = Extract; +type Enum = Extract; +/** A function a statement declares, by the shape every function shares. */ +type FunctionDeclared = Extract; +export type Declarator = Extract["declarations"][number]; +export type TypeNode = Alias["typeAnnotation"]; +type Member = Interface["body"]["body"][number]; +type Named = Extract["typeName"]; +type Expression = NonNullable; +/** Any node of a module, by the fields this pass reads. */ +export type Node = { readonly type: string; readonly start: number; readonly end: number }; + +/** A type's declaration: an alias, an interface or a class. */ +type Declared = Alias | Interface | Class; + +/** Parses a TypeScript module, types included. */ +export type ParseTyped = (code: string, file: string) => TypedProgram; +/** The file an import specifier resolves to from `importer`, or null when it's none this pass can read. */ +export type ResolveFile = (specifier: string, importer: string) => Promise; +/** A file's code. */ +export type LoadFile = (file: string) => Promise; +/** + * A file's path in a copy of the package, without its extension, as `interface/schema/schema` for both its `src` and + * its `dist`; null for any other file. + */ +export type PackagePathOf = (file: string) => string | null; + +export type TypeHost = { readonly parse: ParseTyped; readonly resolve: ResolveFile; readonly load: LoadFile; readonly packagePath: PackagePathOf }; + +/** What a type is converted for, which decides how values that aren't data are taken, and how failures read. */ +export type Use = "input" | "declaration"; + +/** Thrown inside a pass, and returned as its failure. */ +type Thrown = { readonly typeFailure: Failure }; +export const isThrown = (value: unknown): value is Thrown => typeof value === "object" && value !== null && "typeFailure" in value; + +/** Thrown, for a declaration, by a type that isn't data; `node` when it's a DOM `Node`, which lives on `"main"`. */ +type NotData = { readonly notData: { readonly node: boolean } }; +export const isNotData = (value: unknown): value is NotData => typeof value === "object" && value !== null && "notData" in value; + +/** A value of any type: a global class, such as the platform's, or `unknown`. */ +const anything: Schema = { allOf: [] }; + +/** Any JSON value, or a set or map's: what a schema type's argument and an `as const` object's values are. */ +type Value = string | number | boolean | null | readonly Value[] | { readonly [field: string]: Value }; + +/** An import's source and the name it imports, null for a whole module, and whether it brings a value. */ +type Imported = { readonly source: string; readonly imported: string | null; readonly value: boolean }; + +/** A parsed module: its declarations by name, and its imports by local name. */ +export type Module = { + readonly file: string; + readonly code: string; + readonly program: TypedProgram; + readonly types: ReadonlyMap; + readonly namespaces: ReadonlyMap; + readonly constants: ReadonlyMap; + readonly enums: ReadonlyMap; + readonly functions: ReadonlyMap; + readonly imports: ReadonlyMap; + /** The types its `declare global` blocks declare. */ + readonly globals: { readonly types: ReadonlyMap; readonly enums: ReadonlyMap }; + readonly exports: readonly Statement[]; +}; + +const declarationOf = (statement: Statement): Declared | Namespace | null => { + const declared = statement.type === "ExportNamedDeclaration" ? statement.declaration : statement; + if (declared === null || declared === undefined) return null; + if (declared.type === "TSModuleDeclaration") return declared.global === false && declared.id.type === "Identifier" ? declared : null; + return declared.type === "TSTypeAliasDeclaration" || declared.type === "TSInterfaceDeclaration" || declared.type === "ClassDeclaration" ? declared : null; +}; + +/** The top-level constants a statement declares. */ +const constantsOf = (statement: Statement): readonly Declarator[] => { + const declared = statement.type === "ExportNamedDeclaration" ? statement.declaration : statement; + return declared?.type === "VariableDeclaration" && declared.kind === "const" ? declared.declarations.filter(declarator => declarator.id.type === "Identifier") : []; +}; + +export const nameOfDeclarator = (declarator: Declarator): string => declarator.id.type === "Identifier" ? declarator.id.name : ""; + +const importsOf = (statement: Import): [string, Imported][] => + statement.specifiers.map(specifier => [specifier.local.name, { + source: statement.source.value, + // A namespace import names the module itself. + imported: specifier.type === "ImportSpecifier" ? (specifier.imported.type === "Identifier" ? specifier.imported.name : specifier.imported.value) + : specifier.type === "ImportDefaultSpecifier" ? "default" : null, + value: statement.importKind !== "type" && !(specifier.type === "ImportSpecifier" && specifier.importKind === "type"), + }]); + +/** The declarations of a module, or of a namespace's block in it. */ +const scopeOf = (statements: readonly Statement[]) => { + const types = new Map(); + const namespaces = new Map(); + const constants = new Map(); + const enums = new Map(); + const functions = new Map(); + for (const statement of statements) { + const inner = statement.type === "ExportNamedDeclaration" ? statement.declaration : statement; + if (inner?.type === "TSEnumDeclaration") enums.set(inner.id.name, inner); + if (inner?.type === "FunctionDeclaration" && inner.id !== null) functions.set(inner.id.name, inner); + const declared = declarationOf(statement); + if (declared?.type === "TSModuleDeclaration") { + if (declared.id.type === "Identifier") namespaces.set(declared.id.name, declared); + } + else if (declared !== null && declared.id !== null) types.set(declared.id.name, declared); + for (const declarator of constantsOf(statement)) constants.set(nameOfDeclarator(declarator), declarator); + } + return { types, namespaces, constants, enums, functions }; +}; + +export const moduleOf = (file: string, code: string, program: TypedProgram): Module => { + const imports = new Map(); + for (const statement of program.body) { + if (statement.type === "ImportDeclaration") for (const [local, entry] of importsOf(statement)) imports.set(local, entry); + } + // `declare global` blocks declare types every module names without importing them. + const globals = scopeOf(program.body.flatMap(statement => statement.type === "TSModuleDeclaration" && statement.global && statement.body !== null ? statement.body.body : [])); + return { + file, code, program, ...scopeOf(program.body), imports, globals, + exports: program.body.filter(statement => statement.type === "ExportNamedDeclaration" || statement.type === "ExportAllDeclaration"), + }; +}; + +/** The line and column of an offset, 1-based line, 0-based column, as a failure's position. */ +const positionOf = (code: string, offset: number): { readonly line: number; readonly column: number } => { + const before = code.slice(0, offset); + const line = before.split("\n").length; + return { line, column: offset - (before.lastIndexOf("\n") + 1) }; +}; + +/** The module being transformed, and where in it the type being converted is. */ +type Site = { readonly root: string; at: number }; + +/** A type found by name: where it's declared, and the namespaces it's declared in; an enum; or a global. */ +type Found = { readonly module: Module; readonly declaration: Declared; readonly within: readonly string[] } | "enum" | "global"; + +/** A namespace found by name: a module, or a TypeScript namespace declared in one. */ +type FoundNamespace = { readonly module: Module; readonly block: Namespace | null; readonly within: readonly string[] }; + +/** The DOM's classes of `Node`, which live on `"main"`. */ +const domNode = /^(Node|Element|Text|Comment|CharacterData|Document|DocumentFragment|ShadowRoot|Attr|HTML\w*Element|SVG\w*Element|MathMLElement)$/; + +const statementsOf = (block: Namespace): readonly Statement[] => block.body?.type === "TSModuleBlock" ? block.body.body : []; + +/** Converts types to schemas, reading the modules they're declared in through `host`. */ +export const createConverter = (host: TypeHost, root: string) => { + const site: Site = { root, at: 0 }; + const modules = new Map>(); + const read = new Set(); + const load = (file: string): Promise => { + const found = modules.get(file); + if (found !== undefined) return found; + const loading = (async () => { + read.add(file); + const code = await host.load(file); + return moduleOf(file, code, host.parse(code, file)); + })(); + modules.set(file, loading); + return loading; + }; + + /** Fails at `node`, or for a node of another module at the site, with a frame naming where in that module. */ + const fail = (module: Module, node: Node, message: string): never => { + const { line, column } = positionOf(module.code, node.start); + const inRoot = module.file === site.root; + const thrown: Thrown = { typeFailure: { message, position: inRoot ? node.start : site.at, frames: inRoot ? [] : [{ name: "type", file: module.file, line, column }] } }; + throw thrown; + }; + + const moduleAt = async (module: Module, source: string): Promise => { + // A specifier the build can't resolve names a module this pass can't read. + const file = await host.resolve(source, module.file).catch(() => null); + return file === null ? null : await load(file); + }; + + /** What a module exports as `name`, of `kind`, following re-exports; null when it exports none by that name. */ + const exported = async (module: Module, name: string, kind: (module: Module) => ReadonlyMap, seen: Set): Promise<{ readonly module: Module; readonly declaration: T } | null> => { + const key = `${module.file}#${name}`; + if (seen.has(key)) return null; + seen.add(key); + // A module's own declaration comes before what it re-exports. + const own = kind(module).get(name); + if (own !== undefined) return { module, declaration: own }; + for (const statement of module.exports) { + if (statement.type === "ExportNamedDeclaration") { + for (const specifier of statement.specifiers) { + const exportedName = specifier.exported.type === "Identifier" ? specifier.exported.name : specifier.exported.value; + if (exportedName !== name) continue; + const localName = specifier.local.type === "Identifier" ? specifier.local.name : specifier.local.value; + if (statement.source !== null) return await fromSource(module, statement.source.value, localName, kind, seen); + const imported = module.imports.get(localName); + if (imported !== undefined && imported.imported !== null) return await fromSource(module, imported.source, imported.imported, kind, seen); + const local = kind(module).get(localName); + if (local !== undefined) return { module, declaration: local }; + } + } + else if (statement.type === "ExportAllDeclaration" && statement.exported === null) { + const found = await fromSource(module, statement.source.value, name, kind, seen); + if (found !== null) return found; + } + } + return null; + }; + + const fromSource = async (module: Module, source: string, name: string, kind: (module: Module) => ReadonlyMap, seen: Set): Promise<{ readonly module: Module; readonly declaration: T } | null> => { + const target = await moduleAt(module, source); + return target === null ? null : await exported(target, name, kind, seen); + }; + + /** The namespace a module exports as `name`: a module re-exported whole, or a TypeScript namespace. */ + const exportedNamespace = async (module: Module, name: string, seen: Set): Promise => { + const key = `${module.file}#${name}`; + if (seen.has(key)) return null; + seen.add(key); + const own = module.namespaces.get(name); + if (own !== undefined) return { module, block: own, within: [name] }; + for (const statement of module.exports) { + if (statement.type === "ExportAllDeclaration") { + const exportedName = statement.exported === null ? null : statement.exported.type === "Identifier" ? statement.exported.name : statement.exported.value; + if (exportedName === name) { + const target = await moduleAt(module, statement.source.value); + return target === null ? null : { module: target, block: null, within: [] }; + } + if (exportedName === null) { + const target = await moduleAt(module, statement.source.value); + const found = target === null ? null : await exportedNamespace(target, name, seen); + if (found !== null) return found; + } + } + else if (statement.type === "ExportNamedDeclaration") { + for (const specifier of statement.specifiers) { + const exportedName = specifier.exported.type === "Identifier" ? specifier.exported.name : specifier.exported.value; + if (exportedName !== name) continue; + const localName = specifier.local.type === "Identifier" ? specifier.local.name : specifier.local.value; + if (statement.source !== null) { + const target = await moduleAt(module, statement.source.value); + return target === null ? null : await exportedNamespace(target, localName, seen); + } + return await namespaceNamed(module, localName, seen); + } + } + } + return null; + }; + + /** The namespace `name` names in a module: its own, one it imports whole, or one it imports by name. */ + const namespaceNamed = async (module: Module, name: string, seen: Set): Promise => { + const own = module.namespaces.get(name); + if (own !== undefined) return { module, block: own, within: [name] }; + const imported = module.imports.get(name); + if (imported === undefined) return null; + const target = await moduleAt(module, imported.source); + if (target === null) return null; + return imported.imported === null ? { module: target, block: null, within: [] } : await exportedNamespace(target, imported.imported, seen); + }; + + /** A member of a namespace: a nested namespace, a type, or a constant. */ + const memberNamespace = async (found: FoundNamespace, name: string): Promise => { + if (found.block === null) return await exportedNamespace(found.module, name, new Set()); + const nested = scopeOf(statementsOf(found.block)).namespaces.get(name); + return nested === undefined ? null : { module: found.module, block: nested, within: [...found.within, name] }; + }; + const memberType = async (found: FoundNamespace, name: string): Promise => { + if (found.block === null) { + const type = await exported(found.module, name, typesOf, new Set()); + return type === null ? null : { module: type.module, declaration: type.declaration, within: [] }; + } + const declaration = scopeOf(statementsOf(found.block)).types.get(name); + return declaration === undefined ? null : { module: found.module, declaration, within: found.within }; + }; + const memberConstant = async (found: FoundNamespace, name: string): Promise<{ readonly module: Module; readonly declaration: Declarator } | null> => { + if (found.block === null) return await exported(found.module, name, constantsIn, new Set()); + const declaration = scopeOf(statementsOf(found.block)).constants.get(name); + return declaration === undefined ? null : { module: found.module, declaration }; + }; + + const typesOf = (module: Module): ReadonlyMap => module.types; + const constantsIn = (module: Module): ReadonlyMap => module.constants; + + /** The namespace the leading parts of a path name, then its last part through `member`. */ + const throughNamespaces = async (module: Module, path: readonly string[], member: (found: FoundNamespace, name: string) => Promise): Promise => { + const [first, ...rest] = path; + const last = rest.pop(); + if (first === undefined || last === undefined) return null; + let found = await namespaceNamed(module, first, new Set()); + for (const name of rest) found = found === null ? null : await memberNamespace(found, name); + return found === null ? null : await member(found, last); + }; + + /** The type `path` names in `module`: its declaration, or a global when nothing declares or imports it. */ + const typeNamed = async (path: readonly string[], module: Module): Promise => { + const [first] = path; + if (first === undefined) return null; + if (path.length > 1) return await throughNamespaces(module, path, memberType); + const local = module.types.get(first); + if (local !== undefined) return { module, declaration: local, within: [] }; + if (module.enums.has(first)) return "enum"; + const imported = module.imports.get(first); + if (imported === undefined) return await globalNamed(first, module, new Set()) ?? "global"; + if (imported.imported === null) return null; + const found = await fromSource(module, imported.source, imported.imported, typesOf, new Set()); + if (found !== null) return { module: found.module, declaration: found.declaration, within: [] }; + return await fromSource(module, imported.source, imported.imported, enumsOf, new Set()) === null ? null : "enum"; + }; + + /** + * A type `declare global` declares, in a module or the modules of the application it imports relatively; null + * where none does. Packages' globals, the platform's among them, are classes. + */ + const globalNamed = async (name: string, module: Module, seen: Set): Promise => { + if (seen.has(module.file)) return null; + seen.add(module.file); + const declaration = module.globals.types.get(name); + if (declaration !== undefined) return { module, declaration, within: [] }; + if (module.globals.enums.has(name)) return "enum"; + for (const statement of module.program.body) { + if (statement.type !== "ImportDeclaration" || !statement.source.value.startsWith(".")) continue; + const target = await moduleAt(module, statement.source.value); + const found = target === null || host.packagePath(target.file) !== null ? null : await globalNamed(name, target, seen); + if (found !== null) return found; + } + return null; + }; + const enumsOf = (module: Module): ReadonlyMap => module.enums; + + /** The constant `path` names in `module`, through namespaces it imports. */ + const constantNamed = async (path: readonly string[], module: Module): Promise<{ readonly module: Module; readonly declaration: Declarator } | null> => { + const [first] = path; + if (first === undefined) return null; + if (path.length > 1) return await throughNamespaces(module, path, memberConstant); + const local = module.constants.get(first); + if (local !== undefined) return { module, declaration: local }; + const imported = module.imports.get(first); + return imported === undefined || imported.imported === null ? null : await fromSource(module, imported.source, imported.imported, constantsIn, new Set()); + }; + + const functionsIn = (module: Module): ReadonlyMap => module.functions; + + /** + * The declared return type of the function a name calls: a function declaration's, or a constant's holding a + * function or typed as one, through imports. Undefined for a function declaring none, null for no function found. + */ + const returnTypeNamed = async (path: readonly string[], module: Module): Promise<{ readonly module: Module; readonly type: TypeNode } | undefined | null> => { + const [first] = path; + if (first === undefined) return null; + if (path.length === 1) { + const local = module.functions.get(first); + const imported = module.imports.get(first); + const declared = local !== undefined ? { module, declaration: local } + : imported === undefined || imported.imported === null ? null : await fromSource(module, imported.source, imported.imported, functionsIn, new Set()); + if (declared !== null) { + const returned = declared.declaration.returnType?.typeAnnotation; + return returned === undefined ? undefined : { module: declared.module, type: returned }; + } + } + const constant = await constantNamed(path, module); + if (constant === null) return null; + const { id, init } = constant.declaration; + const annotation = id.type === "Identifier" ? id.typeAnnotation?.typeAnnotation : undefined; + if (annotation?.type === "TSFunctionType") return { module: constant.module, type: annotation.returnType.typeAnnotation }; + if (init?.type === "ArrowFunctionExpression" || init?.type === "FunctionExpression") { + const returned = init.returnType?.typeAnnotation; + return returned === undefined ? undefined : { module: constant.module, type: returned }; + } + return null; + }; + + /** The package's own declaration a found type is, by its path in the package: `Schema.Type` and `Entity`. */ + const intrinsic = (found: Found): "schema" | "entity" | null => { + if (typeof found === "string") return null; + const path = host.packagePath(found.module.file); + const { declaration, within } = found; + if (declaration.id === null) return null; + const name = [...within, declaration.id.name].join("."); + if (path === "interface/schema/schema" && name === "Schema.Type") return "schema"; + if (path === "interface/entity/entity" && name === "Entity") return "entity"; + return null; + }; + + const nameParts = (name: Named | Extract["left"]): readonly string[] => + name.type === "Identifier" ? [name.name] : name.type === "TSQualifiedName" ? [...nameParts(name.left), name.right.name] : []; + + /** What a use calls the thing it converts, in failures. */ + const words = (use: Use) => use === "input" + ? { a: "an input", the: "an input's", supported: "a type literal, an alias or interface of one, `number`, `string`, `boolean`, `null`, a literal, a union, an array, a tuple of one type, `ReadonlySet`, `ReadonlyMap`, the values of an `as const` object, a type named by `Schema.Type`, `Entity`, a class or `unknown`" } + : { a: "a declared type", the: "a declared type's", supported: "a type literal, an alias or interface of one, `number`, `string`, `boolean`, `null`, a literal, a union, an array, a tuple of one type, `ReadonlySet`, `ReadonlyMap`, the values of an `as const` object, a type named by `Schema.Type` or `Entity`, or a value that isn't data: a class, a function or `unknown`" }; + + /** For an input, any value; for a declaration, a throw saying the type isn't data. */ + const notData = (use: Use, node: boolean): Schema => { + if (use === "input") return anything; + const thrown: NotData = { notData: { node } }; + throw thrown; + }; + + /** Whether a class is a DOM `Node`: one of the DOM's, or one extending one, as far as its declarations are read. */ + const isNode = async (declaration: Class, module: Module, seen: Set): Promise => { + const { superClass } = declaration; + if (superClass === null || superClass.type !== "Identifier" || seen.has(declaration)) return false; + seen.add(declaration); + const found = await typeNamed([superClass.name], module); + if (found === "global") return domNode.test(superClass.name); + if (found === null || found === "enum" || found.declaration.type === "TSTypeAliasDeclaration" || found.declaration.type === "TSInterfaceDeclaration") return false; + return await isNode(found.declaration, found.module, seen); + }; + + /** The schema of a type, read in `module`; `active` holds the declarations being converted, so recursion fails. */ + const convert = async (type: TypeNode, module: Module, active: ReadonlySet, use: Use): Promise => { + const { a, the, supported } = words(use); + switch (type.type) { + case "TSNumberKeyword": return { type: "number" }; + case "TSStringKeyword": return { type: "string" }; + case "TSBooleanKeyword": return { type: "boolean" }; + case "TSNullKeyword": return { type: "null" }; + case "TSUnknownKeyword": return notData(use, false); + case "TSFunctionType": case "TSConstructorType": + return use === "declaration" ? notData(use, false) : fail(module, type, `${a} is ${supported}`); + case "TSParenthesizedType": return await convert(type.typeAnnotation, module, active, use); + case "TSTypeOperator": + return type.operator === "readonly" ? await convert(type.typeAnnotation, module, active, use) : fail(module, type, `\`${type.operator}\` isn't ${a}: ${a} is ${supported}`); + case "TSLiteralType": { + const { literal } = type; + if (literal.type === "Literal" && (typeof literal.value === "string" || typeof literal.value === "number" || typeof literal.value === "boolean")) return { const: literal.value }; + if (literal.type === "UnaryExpression" && literal.operator === "-" && literal.argument.type === "Literal" && typeof literal.argument.value === "number") return { const: -literal.argument.value }; + return fail(module, type, `${the} literal type is a string, number or boolean`); + } + case "TSUnionType": { + const types = type.types.filter(member => member.type !== "TSUndefinedKeyword"); + if (types.length !== type.types.length) return fail(module, type, `${a} that may be missing is optional (\`field?:\`) rather than \`| undefined\``); + const schemas = await Promise.all(types.map(member => convert(member, module, active, use))); + return { anyOf: schemas }; + } + case "TSArrayType": return { type: "array", items: await convert(type.elementType, module, active, use) }; + case "TSTupleType": { + const elements = type.elementTypes; + const first = elements[0]; + if (first === undefined) return fail(module, type, `${the} tuple has elements`); + const schemas = await Promise.all(elements.map(element => element.type === "TSOptionalType" || element.type === "TSRestType" || element.type === "TSNamedTupleMember" + ? fail(module, element, `${the} tuple has a fixed length, and its elements no names`) : convert(element, module, active, use))); + const items = schemas[0]; + if (items === undefined || schemas.some(schema => JSON.stringify(schema) !== JSON.stringify(items))) return fail(module, type, `${the} tuple has elements of one type`); + return { type: "array", items, minItems: elements.length, maxItems: elements.length }; + } + case "TSTypeLiteral": return await objectOf(type.members, [], module, active, type, use); + case "TSIntersectionType": { + const parts = await Promise.all(type.types.map(member => convert(member, module, active, use))); + const properties: { [field: string]: Schema } = {}; + const required: string[] = []; + let optional = false; + for (const part of parts) { + if (!("type" in part) || part.type !== "object") return fail(module, type, `${the} intersection joins object types`); + for (const [field, schema] of Object.entries(part.properties)) properties[field] = schema; + if (part.required === undefined) required.push(...Object.keys(part.properties)); + else { + optional = true; + required.push(...part.required); + } + } + return optional ? { type: "object", properties, required } : { type: "object", properties }; + } + case "TSTypeReference": return await referenced(type, module, active, use); + case "TSIndexedAccessType": return await valuesOf(type, module, use); + default: return fail(module, type, `${a} is ${supported}`); + } + }; + + const objectOf = async (members: readonly Member[], inherited: readonly Schema[], module: Module, active: ReadonlySet, at: Node, use: Use): Promise => { + const { a, the } = words(use); + const properties: { [field: string]: Schema } = {}; + const required: string[] = []; + let optional = false; + for (const schema of inherited) { + if (!("type" in schema) || schema.type !== "object") return fail(module, at, `${a} interface extends object types`); + for (const [field, property] of Object.entries(schema.properties)) properties[field] = property; + if (schema.required === undefined) required.push(...Object.keys(schema.properties)); + else { + optional = true; + required.push(...schema.required); + } + } + for (const member of members) { + if (member.type === "TSMethodSignature") return notData(use, false); + if (member.type !== "TSPropertySignature" || member.computed) return fail(module, member, `${the} fields are named properties`); + const { key } = member; + const field = key.type === "Identifier" ? key.name : key.type === "Literal" && typeof key.value === "string" ? key.value : fail(module, member, `${the} fields are named properties`); + if (member.typeAnnotation === null) return fail(module, member, `${use === "input" ? "input" : "declared"} field "${field}" has a type`); + properties[field] = await convert(member.typeAnnotation.typeAnnotation, module, active, use); + if (member.optional) optional = true; + else required.push(field); + } + return optional ? { type: "object", properties, required } : { type: "object", properties }; + }; + + const declared = async (found: Exclude, active: ReadonlySet, at: Node, module: Module, use: Use): Promise => { + const { declaration } = found; + const { a } = words(use); + const name = declaration.id?.name ?? "default"; + if (declaration.type !== "TSTypeAliasDeclaration" && declaration.type !== "TSInterfaceDeclaration") return notData(use, await isNode(declaration, found.module, new Set())); + if (active.has(declaration)) return fail(module, at, `${use === "input" ? "input" : "declared"} type ${name} refers to itself`); + if (declaration.typeParameters !== null) return fail(module, at, `${use === "input" ? "input" : "declared"} type ${name} is generic, which ${a} isn't`); + const inner = new Set(active).add(declaration); + if (declaration.type === "TSTypeAliasDeclaration") return await convert(declaration.typeAnnotation, found.module, inner, use); + const inherited = await Promise.all(declaration.extends.map(async heritage => { + const { expression } = heritage; + if (expression.type !== "Identifier" || heritage.typeArguments !== null) return fail(found.module, heritage, `${a} interface extends named, non-generic types`); + return await named([expression.name], found.module, inner, heritage, use); + })); + return await objectOf(declaration.body.body, inherited, found.module, inner, declaration, use); + }; + + /** The schema of the type `path` names in `module`. */ + const named = async (path: readonly string[], module: Module, active: ReadonlySet, at: Node, use: Use): Promise => { + const found = await typeNamed(path, module); + if (found === null) return fail(module, at, `${use === "input" ? "input" : "declared"} type ${path.join(".")} isn't a type alias, interface or class its module declares or exports, which this pass can read`); + if (found === "enum") return fail(module, at, `${path.join(".")} is an enum, which isn't ${words(use).a}: use a union of literals, or the values of an \`as const\` object`); + // Declared nowhere, it's a global, which TypeScript checked exists: a class, such as the platform's. + if (found === "global") return notData(use, path.length === 1 && domNode.test(path[0] ?? "")); + if (intrinsic(found) === "entity") return { type: "integer", format: "entity" }; + return await declared(found, active, at, module, use); + }; + + const referenced = async (type: Extract, module: Module, active: ReadonlySet, use: Use): Promise => { + const path = nameParts(type.typeName); + const parameters = type.typeArguments?.params ?? []; + const [only] = path; + if (path.length === 1 && only !== undefined && !module.types.has(only) && !module.imports.has(only)) { + const [first, second] = parameters; + if ((only === "ReadonlyArray" || only === "Array") && parameters.length === 1 && first !== undefined) return { type: "array", items: await convert(first, module, active, use) }; + // A Set or Map changes in place, so as a declared type it isn't data. + if ((only === "Set" || only === "Map") && use === "declaration") return notData(use, false); + if ((only === "ReadonlySet" || only === "Set") && parameters.length === 1 && first !== undefined) return { type: "set", items: await convert(first, module, active, use) }; + if ((only === "ReadonlyMap" || only === "Map") && parameters.length === 2 && first !== undefined && second !== undefined) { + return { type: "map", keys: await convert(first, module, active, use), values: await convert(second, module, active, use) }; + } + } + if (parameters.length === 0) return await named(path, module, active, type, use); + const found = await typeNamed(path, module); + const [argument] = parameters; + if (found !== null && intrinsic(found) === "schema" && parameters.length === 1 && argument !== undefined) { + const schema = await valueOfType(argument, module, new Set()); + if (!isSchema(schema)) return fail(module, argument, "Schema.Type takes a schema"); + if (!holdsDefault(schema)) return schema; + // The alias being read, the latest declaration entered, is the one to give a namespace. + const alias = [...active].at(-1); + const name = alias?.type === "TSTypeAliasDeclaration" ? alias.id.name : "its alias"; + return fail(module, argument, `a schema holds no default: give ${name} a namespace with one, \`const ${alias?.type === "TSTypeAliasDeclaration" ? name : "T"} = { default: ... }\``); + } + return fail(module, type, `${use === "input" ? "input" : "declared"} type ${path.join(".")} takes type arguments, which ${words(use).a} doesn't, except Schema.Type`); + }; + + /** The value a literal type writes, as `Schema.Type`'s argument does: literals, tuples, object types, `typeof` a constant. */ + const valueOfType = async (type: TypeNode, module: Module, active: ReadonlySet): Promise => { + const unreadable = (): never => fail(module, type, "a schema type's argument is written as a literal type, or `typeof` a constant whose value is a literal"); + switch (type.type) { + case "TSParenthesizedType": return await valueOfType(type.typeAnnotation, module, active); + case "TSTypeOperator": return type.operator === "readonly" ? await valueOfType(type.typeAnnotation, module, active) : unreadable(); + case "TSNullKeyword": return null; + case "TSLiteralType": { + const { literal } = type; + if (literal.type === "Literal" && (typeof literal.value === "string" || typeof literal.value === "number" || typeof literal.value === "boolean")) return literal.value; + if (literal.type === "UnaryExpression" && literal.operator === "-" && literal.argument.type === "Literal" && typeof literal.argument.value === "number") return -literal.argument.value; + return unreadable(); + } + case "TSTupleType": return await Promise.all(type.elementTypes.map(element => element.type === "TSOptionalType" || element.type === "TSRestType" || element.type === "TSNamedTupleMember" ? unreadable() : valueOfType(element, module, active))); + case "TSTypeLiteral": { + const value: { [field: string]: Value } = {}; + for (const member of type.members) { + if (member.type !== "TSPropertySignature" || member.computed || member.typeAnnotation === null) return unreadable(); + const { key } = member; + const field = key.type === "Identifier" ? key.name : key.type === "Literal" && typeof key.value === "string" ? key.value : unreadable(); + value[field] = await valueOfType(member.typeAnnotation.typeAnnotation, module, active); + } + return value; + } + case "TSTypeReference": { + if (type.typeArguments !== null) return unreadable(); + const found = await typeNamed(nameParts(type.typeName), module); + if (found === null || typeof found === "string" || found.declaration.type !== "TSTypeAliasDeclaration" || active.has(found.declaration)) return unreadable(); + return await valueOfType(found.declaration.typeAnnotation, found.module, new Set(active).add(found.declaration)); + } + case "TSTypeQuery": { + if (type.exprName.type === "TSImportType" || type.exprName.type === "ThisExpression" || type.typeArguments !== null) return unreadable(); + const found = await constantNamed(nameParts(type.exprName), module); + if (found === null) return fail(module, type, `${nameParts(type.exprName).join(".")} isn't a constant its module declares or exports, which this pass can read`); + return await valueOfConstant(found.declaration, found.module, active); + } + default: return unreadable(); + } + }; + + /** A constant's value, from its initializer, or, declared without one, from its literal type. */ + const valueOfConstant = async (declarator: Declarator, module: Module, active: ReadonlySet): Promise => { + if (active.has(declarator)) return fail(module, declarator, `${nameOfDeclarator(declarator)} refers to itself`); + const inner = new Set(active).add(declarator); + if (declarator.init !== null) return await valueOf(declarator.init, module, inner); + const annotation = declarator.id.type === "Identifier" ? declarator.id.typeAnnotation : null; + return annotation === null || annotation === undefined ? fail(module, declarator, `${nameOfDeclarator(declarator)} has a value`) : await valueOfType(annotation.typeAnnotation, module, inner); + }; + + /** An expression's value, as a constant's initializer writes it: literals, arrays, objects and other constants. */ + const valueOf = async (expression: Expression, module: Module, active: ReadonlySet): Promise => { + const unreadable = (): never => fail(module, expression, "a constant read for its value is written as literals, arrays, objects and other constants"); + switch (expression.type) { + case "TSAsExpression": case "TSSatisfiesExpression": case "TSNonNullExpression": case "ParenthesizedExpression": + return await valueOf(expression.expression, module, active); + case "Literal": + return typeof expression.value === "string" || typeof expression.value === "number" || typeof expression.value === "boolean" || (expression.value === null && expression.raw === "null") ? expression.value : unreadable(); + case "UnaryExpression": + return expression.operator === "-" && expression.argument.type === "Literal" && typeof expression.argument.value === "number" ? -expression.argument.value : unreadable(); + case "ArrayExpression": + return await Promise.all(expression.elements.map(element => element === null || element.type === "SpreadElement" ? unreadable() : valueOf(element, module, active))); + case "ObjectExpression": { + const value: { [field: string]: Value } = {}; + for (const property of expression.properties) { + if (property.type === "SpreadElement") { + const spread = await valueOf(property.argument, module, active); + if (typeof spread !== "object" || spread === null || Array.isArray(spread)) return unreadable(); + Object.assign(value, spread); + continue; + } + if (property.computed || property.kind !== "init" || property.method) return unreadable(); + const { key } = property; + const field = key.type === "Identifier" ? key.name : key.type === "Literal" && typeof key.value === "string" ? key.value : unreadable(); + value[field] = await valueOf(property.value, module, active); + } + return value; + } + case "Identifier": case "MemberExpression": { + const path = pathOf(expression); + const found = path === null ? null : await constantNamed(path, module); + return found === null ? unreadable() : await valueOfConstant(found.declaration, found.module, active); + } + default: return unreadable(); + } + }; + + /** `(typeof X)[keyof typeof X]`: the values of the `as const` object `X`, as a union of their literals. */ + const valuesOf = async (type: Extract, module: Module, use: Use): Promise => { + const { the } = words(use); + const queried = (node: TypeNode): string | null => { + const inner = node.type === "TSParenthesizedType" ? node.typeAnnotation : node; + return inner.type === "TSTypeQuery" && inner.typeArguments === null && inner.exprName.type === "Identifier" ? inner.exprName.name : null; + }; + const name = queried(type.objectType); + const index = type.indexType.type === "TSTypeOperator" && type.indexType.operator === "keyof" ? queried(type.indexType.typeAnnotation) : null; + if (name === null || index !== name) return fail(module, type, `${the} indexed access type is \`(typeof X)[keyof typeof X]\`, the values of an \`as const\` object`); + const found = await constantNamed([name], module); + if (found === null) return fail(module, type, `${name} isn't a constant its module declares or exports, which this pass can read`); + const { declaration } = found; + let init = declaration.init; + let constant = false; + while (init !== null && (init.type === "TSAsExpression" || init.type === "TSSatisfiesExpression" || init.type === "ParenthesizedExpression")) { + if (init.type === "TSAsExpression" && init.typeAnnotation.type === "TSTypeReference" && init.typeAnnotation.typeName.type === "Identifier" && init.typeAnnotation.typeName.name === "const") constant = true; + init = init.expression; + } + if (init === null || init.type !== "ObjectExpression" || !constant) return fail(found.module, declaration, `${name} is an object declared \`as const\`, whose values are literals`); + const values: (string | number | boolean)[] = []; + for (const property of init.properties) { + const value = property.type === "Property" ? property.value : null; + const literal = value?.type === "Literal" && (typeof value.value === "string" || typeof value.value === "number" || typeof value.value === "boolean") ? value.value + : value?.type === "UnaryExpression" && value.operator === "-" && value.argument.type === "Literal" && typeof value.argument.value === "number" ? -value.argument.value + : undefined; + if (literal === undefined) return fail(found.module, property, `${name}'s values are string, number or boolean literals`); + if (!values.includes(literal)) values.push(literal); + } + return { anyOf: values.map(value => ({ const: value })) }; + }; + + return { convert, read, site, fail, typeNamed, constantNamed, returnTypeNamed, valueOfType }; +}; + +/** A name and the properties read from it, `Entity.none` as `["Entity", "none"]`; null for any other expression. */ +export const pathOf = (expression: Node): readonly string[] | null => { + if (expression.type === "Identifier" && "name" in expression && typeof expression.name === "string") return [expression.name]; + if (expression.type !== "MemberExpression" || !("object" in expression) || !("property" in expression) || ("computed" in expression && expression.computed === true)) return null; + const { object, property } = expression; + if (typeof object !== "object" || object === null || typeof property !== "object" || property === null || !isNode(object)) return null; + const head = pathOf(object); + const name: unknown = Reflect.get(property, "name"); + return head === null || typeof name !== "string" ? null : [...head, name]; +}; + +const isNode = (value: object): value is Node => typeof Reflect.get(value, "start") === "number" && typeof Reflect.get(value, "end") === "number" && typeof Reflect.get(value, "type") === "string"; + +// `Schema.Type`'s argument is constrained to `Schema` where it's written, so TypeScript checked an object's form. +/** + * Whether a schema a schema type writes holds a `default` as a keyword: at its top or in a schema inside it, never in + * a property's name or a `const`'s value. + */ +const holdsDefault = (schema: unknown): boolean => { + if (typeof schema !== "object" || schema === null || Array.isArray(schema)) return false; + if ("default" in schema) return true; + const inside = (keyword: string): unknown => Reflect.get(schema, keyword); + const listed = (keyword: string): boolean => { + const members = inside(keyword); + return Array.isArray(members) && members.some(holdsDefault); + }; + const properties = inside("properties"); + return holdsDefault(inside("items")) || holdsDefault(inside("keys")) || holdsDefault(inside("values")) || listed("anyOf") || listed("allOf") + || (typeof properties === "object" && properties !== null && Object.values(properties).some(holdsDefault)); +}; + +const isSchema = (value: unknown): value is Schema => typeof value === "object" && value !== null && !Array.isArray(value); diff --git a/packages/ecs/src/implementation/compiler/unit.ts b/packages/ecs/src/implementation/compiler/unit.ts index 71ef3575d..dba2eea48 100644 --- a/packages/ecs/src/implementation/compiler/unit.ts +++ b/packages/ecs/src/implementation/compiler/unit.ts @@ -7,6 +7,7 @@ import type { Module } from './module-scope.js'; import type { Out } from './out.js'; import type { Scope } from './scope.js'; import type { Signature } from './signature.js'; +import type { PerCall } from './stamps.js'; import type { Value } from './value.js'; export type ExpressionCompiler = (node: Node, scope: Scope, out: Out) => Value; @@ -26,15 +27,28 @@ export type Unit = { readonly link: Link | null; /** GPU kernels emit intrinsic regions for calls to functions that match WGSL builtins. */ readonly target: "cpu" | "gpu"; + /** Whether the body is only tried, unflagged: then module data, which may change, isn't inlined. */ + readonly tried: boolean; /** Column descriptions as source text, in `columns` order, indexed by an identity key. */ readonly columns: string[]; readonly columnIndex: Map; /** Chunk columns: dense, indexed by `i >> chunkShift` rather than a strided row index. */ readonly chunks: Set; - /** Chunk columns stamped once per call before the row loop, so their per-row stamps are dropped. */ - readonly stampedPerCall: Set; + /** Block columns: dense, indexed by `i >> blockShift`. */ + readonly blocks: Set; + /** + * Chunk columns stamped once per call before the row loop, with their block columns, so their per-row stamps are + * dropped. + */ + readonly stampedPerCall: Map; /** Columns read where a boolean's identity is observable, so they need conversion. */ readonly booleans: Set; + /** Columns of optional components, each by the index in `keys` of its component, which an archetype may lack. */ + readonly optionalColumns: Map; + /** Each optional component's presence column, by its binding name. */ + readonly presences: Map; + /** While a loop specialized for every optional component present, or every one absent, is printed: which; else null. */ + presentAs: boolean | null; /** Every value assigned to each local, to propagate observation. */ readonly assignments: Map; /** Locals whose value reaches an observable position. */ @@ -74,13 +88,13 @@ export type Unit = { readonly statement: StatementCompiler; }; -export const createUnit = (signature: Signature, module: Module, lists: Lists, expression: ExpressionCompiler, statement: StatementCompiler, link: Link | null = null, target: "cpu" | "gpu" = "cpu"): Unit => { +export const createUnit = (signature: Signature, module: Module, lists: Lists, expression: ExpressionCompiler, statement: StatementCompiler, link: Link | null = null, target: "cpu" | "gpu" = "cpu", tried = false): Unit => { let counter = 0; const names = new Map(); const up = signature.up === null ? null : counter++; if (up !== null) names.set(up, "up"); return { - signature, module, lists, link, target, columns: [], columnIndex: new Map(), chunks: new Set(), stampedPerCall: new Set(), booleans: new Set(), assignments: new Map(), observed: new Set(), names, shapes: new Set(), reducedShapes: new Set(), keys: [], structuredValues: new Set(), spawns: [], accumulators: [], active: new Set(), + signature, module, lists, link, target, tried, columns: [], columnIndex: new Map(), chunks: new Set(), blocks: new Set(), stampedPerCall: new Map(), booleans: new Set(), optionalColumns: new Map(), presences: new Map(), presentAs: null, assignments: new Map(), observed: new Set(), names, shapes: new Set(), reducedShapes: new Set(), keys: [], structuredValues: new Set(), spawns: [], accumulators: [], active: new Set(), used: { read: new Set(), write: new Set(), resources: new Set(), lookups: new Map(), up: new Set() }, up, upColumns: [], upColumnIndex: new Map(), upBooleans: new Set(), probes: [], rowLeaves: [], handles: new Set(), next: () => counter++, expression, statement, diff --git a/packages/ecs/src/implementation/world/activate.ts b/packages/ecs/src/implementation/world/activate.ts index bc923c83c..1a5fbcaa5 100644 --- a/packages/ecs/src/implementation/world/activate.ts +++ b/packages/ecs/src/implementation/world/activate.ts @@ -9,7 +9,7 @@ import { triggersOf } from './program.js'; import { ensureResources, findResource } from './resources.js'; import { idOf, metIdOf, type Key, type State } from './state.js'; import { restratify } from './structure.js'; -import { idOfItem, idOfKey, labelOf, labelOfItem, threadOf } from '../../interface/registry/index.js'; +import { idOfItem, idOfKey, labelOf, threadOf } from '../../interface/registry/index.js'; /** * Why a joined thread that imported the items of ids `imported` can't claim `threads`: a program finalizer on one that @@ -20,7 +20,7 @@ export const finalizerCrossing = (threads: readonly string[], imported: Readonly if (finalizer.kind !== "finalizer") continue; const thread = threadOf(finalizer.declaration, "Finalizer", finalizer.name); if (thread === null || !threads.includes(thread)) continue; - const label = labelOfItem(finalizer); + const label = finalizer.name; const id = idOfItem(finalizer); if (id === undefined) return `Finalizer "${label}" runs on thread "${thread}" and has no id, so a joined thread can't run it; the build gives top-level finalizers one.`; if (!imported.has(id)) return `Finalizer "${label}" runs on thread "${thread}", which the joined thread claiming it never imported.`; diff --git a/packages/ecs/src/implementation/world/archetype.ts b/packages/ecs/src/implementation/world/archetype.ts index 1af69b6df..5f9545097 100644 --- a/packages/ecs/src/implementation/world/archetype.ts +++ b/packages/ecs/src/implementation/world/archetype.ts @@ -2,7 +2,7 @@ import type { Entity } from '../../interface/entity/entity.js'; import type { Layout, Views } from '../../interface/registry/layout.js'; import { allocateArray, allocateViews, emptyViews, freeArray, freeViews, growArray } from './arrays.js'; -import { chunkShift } from './kernel.js'; +import { blockShift, chunkShift } from './kernel.js'; import type { Memory } from './memory.js'; import { nullTag, startTag, type MoveLog } from './move-log.js'; import type { StringStore } from './strings.js'; @@ -40,6 +40,12 @@ export type Storage = { /** The newest `added` and `changed` tick in each chunk of `2 ** chunkShift` rows, so change filters skip unchanged chunks. */ chunkAdded: Float64Array; chunkChanged: Float64Array; + /** + * The newest `added` and `changed` tick in each block of `2 ** blockShift` rows, so change filters skip the + * unchanged blocks of a chunk that changed: a filter's scan costs what changed, not the chunk. + */ + blockAdded: Float64Array; + blockChanged: Float64Array; /** * The newest tick at which every row may have changed, as a GPU kernel's writes do, so stamping them costs * nothing per row: change filters pass the whole column when it's newer than their `lastRun`. @@ -73,21 +79,32 @@ export const writeOf = (layout: Layout): { readonly write: number; readonly unit /** Chunks covering `capacity` rows. */ const chunksFor = (capacity: number): number => Math.max(1, Math.ceil(capacity / 2 ** chunkShift)); +/** Blocks covering those chunks, so every row of a chunk has a block. */ +const blocksFor = (capacity: number): number => chunksFor(capacity) << (chunkShift - blockShift); /** What stamping a row's ticks writes: a storage's, or a joined thread's view of one. */ -export type Stamped = Pick; +export type Stamped = Pick; -/** Stamps a row's `changed` tick and its chunk's. Ticks only increase, so the chunk's is the newest. */ +/** + * Stamps a row's `changed` tick, its block's and its chunk's. Ticks only increase, so theirs are the newest. Every + * writer stores the row, then the block, then the chunk, the reverse of the order filters read them, so a filter on + * another thread that finds a chunk newer finds its block and row newer too. + */ export const stampChanged = (storage: Stamped, row: number, tick: number): void => { if (!storage.ticked) return; storage.changed[row] = tick; + storage.blockChanged[row >> blockShift] = tick; storage.chunkChanged[row >> chunkShift] = tick; }; -export const stampAdded = (storage: Stamped, row: number, tick: number): void => { - if (!storage.ticked) return; - storage.added[row] = tick; - storage.chunkAdded[row >> chunkShift] = tick; +/** + * Raises the block and chunk ticks over rows `start` to `end - 1` to at least `tick`: for ticks written to those rows + * in bulk, or copied in from rows of other chunks. + */ +export const raiseRange = (chunks: Float64Array, blocks: Float64Array, start: number, end: number, tick: number): void => { + if (start >= end) return; + for (let block = start >> blockShift, last = (end - 1) >> blockShift; block <= last; block++) if (blocks[block] < tick) blocks[block] = tick; + for (let chunk = start >> chunkShift, last = (end - 1) >> chunkShift; chunk <= last; chunk++) if (chunks[chunk] < tick) chunks[chunk] = tick; }; /** A table of the entities that have exactly `components`. Rows are dense: `0` to `count`. */ @@ -156,6 +173,8 @@ const allocateTicks = (memory: Memory, storage: Storage, capacity: number, tick: storage.changed = storage.layout.kind === "constant" ? storage.added : allocateArray(memory, Float64Array, capacity).fill(tick); storage.chunkAdded = allocateArray(memory, Float64Array, chunksFor(capacity)).fill(tick); storage.chunkChanged = storage.layout.kind === "constant" ? storage.chunkAdded : allocateArray(memory, Float64Array, chunksFor(capacity)).fill(tick); + storage.blockAdded = allocateArray(memory, Float64Array, blocksFor(capacity)).fill(tick); + storage.blockChanged = storage.layout.kind === "constant" ? storage.blockAdded : allocateArray(memory, Float64Array, blocksFor(capacity)).fill(tick); }; /** Views of a new column, for a linear layout's or a string's `capacity` rows; a string's handles start at 0. */ @@ -181,6 +200,8 @@ const createStorage = (memory: Memory, strings: StringStore | null, component: n changed: noTicks, chunkAdded: noTicks, chunkChanged: noTicks, + blockAdded: noTicks, + blockChanged: noTicks, columnChanged: 0, remote: null, tags: noTags, @@ -262,6 +283,10 @@ const grow = (memory: Memory, archetype: Archetype, capacity = archetype.capacit const used = storage.chunkAdded.length; storage.chunkAdded = growArray(memory, storage.chunkAdded, Float64Array, chunks, used).fill(0, used); storage.chunkChanged = shared ? storage.chunkAdded : growArray(memory, storage.chunkChanged, Float64Array, chunks, used).fill(0, used); + const blocks = blocksFor(capacity); + const usedBlocks = storage.blockAdded.length; + storage.blockAdded = growArray(memory, storage.blockAdded, Float64Array, blocks, usedBlocks).fill(0, usedBlocks); + storage.blockChanged = shared ? storage.blockAdded : growArray(memory, storage.blockChanged, Float64Array, blocks, usedBlocks).fill(0, usedBlocks); } } archetype.entities = growArray(memory, archetype.entities, Float64Array, capacity, count); @@ -330,7 +355,10 @@ export const copyCell = (from: Storage, fromRow: number, to: Storage, toRow: num const changed = from.changed[fromRow]; to.added[toRow] = added; to.changed[toRow] = changed; - // A chunk's tick is the newest of its rows, so a row moving in can raise it. + // A block's and a chunk's tick are the newest of their rows, so a row moving in can raise them. + const block = toRow >> blockShift; + if (added > to.blockAdded[block]) to.blockAdded[block] = added; + if (changed > to.blockChanged[block]) to.blockChanged[block] = changed; const chunk = toRow >> chunkShift; if (added > to.chunkAdded[chunk]) to.chunkAdded[chunk] = added; if (changed > to.chunkChanged[chunk]) to.chunkChanged[chunk] = changed; diff --git a/packages/ecs/src/implementation/world/arrays.ts b/packages/ecs/src/implementation/world/arrays.ts index 57193994a..197b159de 100644 --- a/packages/ecs/src/implementation/world/arrays.ts +++ b/packages/ecs/src/implementation/world/arrays.ts @@ -1,5 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { viewsOf, type Views } from '../../interface/registry/layout.js'; +import { viewsOf, type Layout, type TypedArray, type Views } from '../../interface/registry/layout.js'; import type { Memory } from './memory.js'; export type ArrayConstructor = { @@ -42,3 +42,7 @@ export const freeViews = (memory: Memory, views: Views): void => freeArray(memor /** Views of no bytes, for components with nothing to store. */ export const emptyViews: Views = viewsOf(new ArrayBuffer(0), 0, 0); + +/** A kernel's column of a component an archetype lacks, optional in its system: empty, so its reads give undefined. */ +export const absentColumn = (layout: Layout, source: { readonly kind: string; readonly leaf?: number }): TypedArray => + source.kind === "field" && layout.kind === "linear" && source.leaf !== undefined ? emptyViews[layout.leaves[source.leaf].view] : emptyViews.f64; diff --git a/packages/ecs/src/implementation/world/async-loading.test.ts b/packages/ecs/src/implementation/world/async-loading.test.ts index 7236d81af..612383391 100644 --- a/packages/ecs/src/implementation/world/async-loading.test.ts +++ b/packages/ecs/src/implementation/world/async-loading.test.ts @@ -1,6 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { expect, it } from "vitest"; -import { Component, Entity, Finalizer, Resource, Rule, System, World } from "../../index.js"; +import { Entity, Finalizer, Rule, type Schema, System, World } from "../../index.js"; import { Observe } from "../../observe/index.js"; import { createWorld } from "../index.js"; @@ -18,28 +18,16 @@ declare module "@adobe/ecs" { [meshSource]: { type: string }; [hidden]: { type: true }; /** The token of the slot's load; a new one each time the slot starts over. */ - [meshSlot]: { type: number; derived: true }; + [meshSlot]: { type: Schema.Type<{ type: "integer" }>; derived: true; default: 0 }; /** The loaded mesh's handle, which exists only while its slot does. */ - [meshReady]: { type: number; within: typeof meshSlot }; + [meshReady]: { type: Schema.Type<{ type: "integer" }>; within: typeof meshSlot }; } interface Resources { - [nextToken]: { type: number }; - [requests]: { type: readonly { readonly entity: Entity; readonly token: number; readonly path: string }[] }; + [nextToken]: { type: Schema.Type<{ type: "integer" }>; default: 1 }; + [requests]: { type: Schema.Type<{ type: "array"; items: { type: "object"; properties: { entity: { type: "integer"; format: "entity" }; token: { type: "integer" }; path: { type: "string" } } } }>; default: [] }; } } -Component.register(meshSource, { type: { type: "string" } }); -Component.register(hidden, { type: { const: true } }); -Component.register(meshSlot, { type: { type: "integer", default: 0 }, derived: true }); -Component.register(meshReady, { type: { type: "integer" }, within: meshSlot }); -Resource.register(nextToken, { type: { type: "integer", default: 1 } }); -Resource.register(requests, { - type: { - type: "array", - items: { type: "object", properties: { entity: Entity.schema, token: { type: "integer" }, path: { type: "string" } } }, - default: [], - }, -}); /** Meshes loaded outside the world, by handle: as GPU buffers are. */ const loaded = new Map(); @@ -50,13 +38,11 @@ const load = async (path: string): Promise => { }; // 1. A rule derives a slot for every visible entity with a mesh source. -Rule.create({ name: "asyncLoading/slots", components: { has: [meshSource], not: [hidden] }, derive: [meshSlot] }); +Rule.create({ components: { with: [meshSource], not: [hidden] }, derive: [meshSlot] }); // 2. Each new slot gets a fresh token, and its load is requested; the application starts requested loads. const request = System.create({ - name: "asyncLoading/request", - components: { read: [meshSource], write: [meshSlot], added: [meshSlot] }, - resources: { write: [nextToken, requests] }, + components: { with: [meshSource, meshSlot], added: [meshSlot] }, }, (row, { resources }) => { const token = resources[nextToken]; resources[nextToken] = token + 1; @@ -66,22 +52,20 @@ const request = System.create({ // 3. A completed load arrives as a run of its own. A slot whose token still matches gets the mesh; a stale one releases it. const apply = System.create({ - name: "asyncLoading/apply", - input: { type: "object", properties: { entity: Entity.schema, token: { type: "integer" }, handle: { type: "integer" } } }, - lookup: { slots: { read: [meshSlot] } }, -}, ({ input, lookup, commands }) => { + lookup: { slots: { with: [meshSlot] } }, +}, ({ lookup, commands }, input: { readonly entity: Entity; readonly token: number; readonly handle: number }) => { if (lookup.slots(input.entity)?.[meshSlot] === input.token) commands.add(input.entity, meshReady, input.handle); else loaded.delete(input.handle); }); // 4. When the slot goes, its mesh goes with it, and the finalizer releases what was loaded. -Finalizer.create({ name: "asyncLoading/release", components: { read: [meshReady] } }, row => { loaded.delete(row[meshReady]); }); +Finalizer.create({ components: { with: [meshReady] } }, row => { loaded.delete(row[meshReady]); }); -const spawn = System.create({ name: "asyncLoading/spawn" }, ({ commands }) => { +const spawn = System.create({}, ({ commands }) => { commands.spawn([meshSource])({ [meshSource]: "ship.glb" }); commands.spawn([meshSource])({ [meshSource]: "rock.glb" }); }); -const hide = System.create({ name: "asyncLoading/hide", input: { type: "object", properties: { entity: Entity.schema } } }, ({ input, commands }) => { +const hide = System.create({}, ({ commands }, input: { readonly entity: Entity }) => { commands.add(input.entity, hidden); }); diff --git a/packages/ecs/src/implementation/world/attach-kernel.test.ts b/packages/ecs/src/implementation/world/attach-kernel.test.ts index 5414fc556..971cc3dbe 100644 --- a/packages/ecs/src/implementation/world/attach-kernel.test.ts +++ b/packages/ecs/src/implementation/world/attach-kernel.test.ts @@ -1,10 +1,17 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { expect, it } from "vitest"; +import { kernelOf } from "./attach-kernel.js"; it("rejects a kernel lookup of a string, which kernels don't read, where the kernel is declared", async () => { - await expect(import("./kernel-lookup.test-helper.js")).rejects.toThrow(`Kernel "kernelLookupTest/labels": a lookup reads "kernelLookupTest/label", a string, which kernels don't read yet.`); + await expect(import("./kernel-lookup.test-helper.js")).rejects.toThrow(`a lookup reads "kernelLookupTest/label", a string, which kernels don't read yet.`); }); it("rejects a compiled kernel that doesn't match its schemas where the kernel is declared", async () => { - await expect(import("./kernel-path.test-helper.js")).rejects.toThrow(`Kernel "kernelPathTest/beyond": "kernelPathTest/pair" has no scalar at [2]; destructure tuples and structs or index their fields.`); + await expect(import("./kernel-path.test-helper.js")).rejects.toThrow(`"kernelPathTest/pair" has no scalar at [2]; destructure tuples and structs or index their fields.`); +}); + +it("compiles an unflagged system whose body fits, and runs as written one whose kernel wouldn't match its schemas", async () => { + const { doubled, labels } = await import("./kernel-tried.test-helper.js"); + expect(kernelOf(doubled)).toBeDefined(); + expect(kernelOf(labels)).toBeUndefined(); }); diff --git a/packages/ecs/src/implementation/world/attach-kernel.ts b/packages/ecs/src/implementation/world/attach-kernel.ts index 1cd866151..e76e9a5de 100644 --- a/packages/ecs/src/implementation/world/attach-kernel.ts +++ b/packages/ecs/src/implementation/world/attach-kernel.ts @@ -1,7 +1,8 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { definitionOf, labelOfItem } from '../../interface/registry/index.js'; +import { definitionOf } from '../../interface/registry/index.js'; import type { System } from '../../interface/system/system.js'; -import { checkKernel } from './check-kernel.js'; +import { unreadByKernel } from '../../interface/system/check.js'; +import { checkKernel, kernelMismatch } from './check-kernel.js'; import type { KernelDescription } from './kernel.js'; /** By the system `System.create` returned, so a lookup doesn't depend on which copy of the package defined it. */ @@ -10,13 +11,18 @@ const kernels = new WeakMap, KernelDescription>(); /** * The compiler's only hook into the runtime: wraps each kernel system's `System.create` call. Throws for a kernel that * doesn't match its declaration and schemas (`checkKernel`), and for a key that's a list, which a command names as one - * component only where its build couldn't read the name's module. + * component only where its build couldn't read the name's module. A kernel the build only `tried`, for a system + * without a `kernel` flag, attaches only where it matches, and the system otherwise runs as written. */ -export const attachKernel = >(system: S, description: KernelDescription): S => { - for (const key of description.keys) { - if (Array.isArray(key)) throw new Error(`System "${labelOfItem(system)}" names a list of components where a command takes one component; its kernel's build couldn't read the list, so name it from a module the build reads, or as an array literal.`); - } +export const attachKernel = >(system: S, description: KernelDescription, tried = false): S => { + const listed = description.keys.some(key => Array.isArray(key)); const leaf = definitionOf(system); + if (tried) { + if (listed || leaf === undefined || leaf.kind === "composite" || unreadByKernel(leaf) !== null || kernelMismatch(leaf, description) !== null) return system; + kernels.set(system, description); + return system; + } + if (listed) throw new Error(`System "${system.name}" names a list of components where a command takes one component; its kernel's build couldn't read the list, so name it from a module the build reads, or as an array literal.`); if (leaf !== undefined && leaf.kind !== "composite") checkKernel(leaf, description); kernels.set(system, description); return system; diff --git a/packages/ecs/src/implementation/world/batches.test.ts b/packages/ecs/src/implementation/world/batches.test.ts index 58fb30f40..0881ba33b 100644 --- a/packages/ecs/src/implementation/world/batches.test.ts +++ b/packages/ecs/src/implementation/world/batches.test.ts @@ -1,6 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; -import { Component } from "../../interface/component/component.js"; import { Entity } from "../../interface/entity/entity.js"; import type { Schema } from "../../interface/schema/schema.js"; import { System } from "../../interface/system/system.js"; @@ -12,6 +11,7 @@ import { createBuffer } from "./buffer.js"; import { createCommands } from "./commands.js"; import { createState } from "./state.js"; import { runReported } from "./reports.test-helper.js"; +import type { F32 } from "../../interface/math/index.js"; type Vec3 = readonly [number, number, number]; const vec3 = { type: "array", items: { type: "number", precision: 1 }, minItems: 3, maxItems: 3 } as const satisfies Schema; @@ -21,29 +21,25 @@ const name = Symbol("batchesTest/name"); const tag = Symbol("batchesTest/tag"); declare module "@adobe/ecs" { export interface Components { - [position]: { type: readonly [number, number, number] }; - [score]: { type: number }; + [position]: { type: Schema.Type<{ type: "array"; items: { type: "number"; precision: 1 }; minItems: 3; maxItems: 3 }> }; + [score]: { type: F32 }; [name]: { type: string }; [tag]: { type: true }; } } -Component.register(position, { type: vec3 }); -Component.register(score, { type: { type: "number", precision: 1 } }); -Component.register(name, { type: { type: "string" } }); -Component.register(tag, { type: { const: true } }); describe("staged spawns", () => { it("are seen once by `added` observers in earlier, the same and later tiers, and not before the sync point", async () => { const seen = { early: 0, same: 0, late: 0 }; const spawned: Entity[] = []; - const early = System.create({ name: "batchesTest/early", components: { added: [score] } }, () => { seen.early++; }); - const spawn = System.create({ name: "batchesTest/spawn", after: [early] }, ({ commands }) => { + const early = System.create({ components: { added: [score] } }, () => { seen.early++; }); + const spawn = System.create({ after: [early] }, ({ commands }) => { if (spawned.length > 0) return; for (let i = 0; i < 100; i++) spawned.push(commands.spawn([position, score, name, tag])({ [position]: [i, i, i], [score]: i, [name]: `${i}`, [tag]: true })); }); - const same = System.create({ name: "batchesTest/same", components: { added: [score] }, after: [early] }, () => { seen.same++; }); - const late = System.create({ name: "batchesTest/late", components: { added: [score] }, after: [spawn, same] }, () => { seen.late++; }); - const frame = System.create({ name: "batchesTest/edit", systems: [early, spawn, same, late] }); + const same = System.create({ components: { added: [score] }, after: [early] }, () => { seen.same++; }); + const late = System.create({ components: { added: [score] }, after: [spawn, same] }, () => { seen.late++; }); + const frame = System.create({ systems: [early, spawn, same, late] }); const world = createWorld({ [workers]: 0 }); await world.run(frame); // Nothing saw the spawns during their own run. @@ -58,7 +54,7 @@ describe("staged spawns", () => { it("can be changed, removed, re-added or despawned in the run that spawns them", async () => { const spawned: Entity[] = []; - const edit = System.create({ name: "batchesTest/edit/2" }, ({ commands }) => { + const edit = System.create({}, ({ commands }) => { for (let i = 0; i < 4; i++) spawned.push(commands.spawn([score, name])({ [score]: i, [name]: `${i}` })); commands.add(spawned[0], score, 10); commands.remove(spawned[1], name); @@ -76,7 +72,7 @@ describe("staged spawns", () => { it("keep the spawns before a value that can't be written, and release that one's entity", async () => { const spawned: Entity[] = []; let poison = true; - const spawn = System.create({ name: "batchesTest/spawn/2" }, ({ commands }) => { + const spawn = System.create({}, ({ commands }) => { for (let i = 0; i < 3; i++) spawned.push(commands.spawn([position, score])({ [position]: [i, 0, 0], [score]: i })); // A symbol can't convert to a number, so writing it throws. if (poison) commands.spawn([position, score])({ [position]: [0, 0, 0], [score]: Symbol("x") as unknown as number }); @@ -93,7 +89,7 @@ describe("staged spawns", () => { it("take value objects built before the spawn", async () => { const spawned: Entity[] = []; - const spawn = System.create({ name: "batchesTest/spawn/4" }, ({ commands }) => { + const spawn = System.create({}, ({ commands }) => { for (let i = 0; i < 20; i++) { const values = i % 2 === 0 ? { [position]: [i, 0, 1] as const, [score]: i, [name]: `${i}` } : { [score]: i, [position]: [i, 0, 1] as const, [name]: `${i}` }; spawned.push(commands.spawn([position, score, name])(values)); @@ -118,7 +114,7 @@ describe("spawns keyed by symbols", () => { }); it("read only their components' values, as adds do", async () => { let spawned = Entity.none; - const edit = System.create({ name: "batchesTest/stringKeys" }, ({ commands }) => { + const edit = System.create({}, ({ commands }) => { const values = { [score]: 1, "batchesTest/score": 2, [name]: "a" }; spawned = commands.spawn([score])(values); commands.add(spawned, [score], values); diff --git a/packages/ecs/src/implementation/world/batches.ts b/packages/ecs/src/implementation/world/batches.ts index fed392c91..07166eb52 100644 --- a/packages/ecs/src/implementation/world/batches.ts +++ b/packages/ecs/src/implementation/world/batches.ts @@ -2,7 +2,7 @@ import { ensureCapacity, type Archetype } from './archetype.js'; import type { CommandBuffer } from './buffer.js'; import { indexOf } from './entities.js'; -import { chunkShift } from './kernel.js'; +import { blockShift, chunkShift } from './kernel.js'; import { createBatch, type SpawnBatch } from './staging.js'; import { archetypeOf, type State } from './state.js'; import { gainsOf } from './structure.js'; @@ -82,7 +82,9 @@ const commitRows = (state: State, batch: SpawnBatch, first: number, n: number, t if (!storage.ticked) continue; storage.added.fill(tick, start, start + n); if (storage.changed !== storage.added) storage.changed.fill(tick, start, start + n); - // Ticks only increase, so the newest tick in each chunk the rows reach is this one. + // Ticks only increase, so the newest tick in each chunk and block the rows reach is this one. + storage.blockAdded.fill(tick, start >> blockShift, ((start + n - 1) >> blockShift) + 1); + if (storage.blockChanged !== storage.blockAdded) storage.blockChanged.fill(tick, start >> blockShift, ((start + n - 1) >> blockShift) + 1); storage.chunkAdded.fill(tick, start >> chunkShift, ((start + n - 1) >> chunkShift) + 1); if (storage.chunkChanged !== storage.chunkAdded) storage.chunkChanged.fill(tick, start >> chunkShift, ((start + n - 1) >> chunkShift) + 1); } diff --git a/packages/ecs/src/implementation/world/buffer.ts b/packages/ecs/src/implementation/world/buffer.ts index 946bfdf4b..43dd5dbde 100644 --- a/packages/ecs/src/implementation/world/buffer.ts +++ b/packages/ecs/src/implementation/world/buffer.ts @@ -101,8 +101,13 @@ export const pushCommand = (buffer: CommandBuffer, kind: number, entity: number) buffer.count++; }; +/** Room for more component ids, outlined as `growCommands` is. */ +const growIds = (buffer: CommandBuffer): void => { + buffer.ids = grown(buffer.ids, length => new Int32Array(length)); +}; + export const pushComponent = (buffer: CommandBuffer, id: number, value: unknown): void => { - if (buffer.named === buffer.ids.length) buffer.ids = grown(buffer.ids, length => new Int32Array(length)); + if (buffer.named === buffer.ids.length) growIds(buffer); buffer.ids[buffer.named] = id; buffer.values[buffer.named] = value; buffer.named++; @@ -140,7 +145,32 @@ export const endOf = (buffer: CommandBuffer, command: number): number => */ export const createKernelCommands = (target: { readonly buffer: CommandBuffer }, ids: readonly number[], starts: readonly unknown[], spawners: readonly Positional[]): KernelCommands => ({ spawners, - begin: (kind, entity) => pushCommand(target.buffer, kind, entity), - value: (key, value) => pushComponent(target.buffer, ids[key], value === undefined ? starts[key] : value), - component: key => pushComponent(target.buffer, ids[key], undefined), + // Each writes the buffer itself rather than through pushCommand and pushComponent: a kernel's row loop inlines one + // call, and a second one left as a call boxes every entity, which ids past 2^32 make a double. + begin: (kind, entity) => { + const buffer = target.buffer; + const count = buffer.count; + if (count === buffer.kinds.length) growCommands(buffer); + buffer.kinds[count] = kind; + buffer.shapes[count] = -1; + buffer.entities[count] = entity; + buffer.starts[count] = buffer.named; + buffer.count = count + 1; + }, + value: (key, value) => { + const buffer = target.buffer; + const named = buffer.named; + if (named === buffer.ids.length) growIds(buffer); + buffer.ids[named] = ids[key]; + buffer.values[named] = value === undefined ? starts[key] : value; + buffer.named = named + 1; + }, + component: key => { + const buffer = target.buffer; + const named = buffer.named; + if (named === buffer.ids.length) growIds(buffer); + buffer.ids[named] = ids[key]; + buffer.values[named] = undefined; + buffer.named = named + 1; + }, }); diff --git a/packages/ecs/src/implementation/world/builtins.test.ts b/packages/ecs/src/implementation/world/builtins.test.ts index 6909271a7..fe2f01666 100644 --- a/packages/ecs/src/implementation/world/builtins.test.ts +++ b/packages/ecs/src/implementation/world/builtins.test.ts @@ -1,6 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; -import { Component } from "../../interface/component/component.js"; import { Mat4x4, Quat, Vec3 } from "../../interface/math/index.js"; import { System } from "../../interface/system/system.js"; import { readEach, select } from "./reads.test-helper.js"; @@ -23,13 +22,8 @@ declare module "@adobe/ecs" { } } -Component.register(position, { type: Vec3.schema }); -Component.register(velocity, { type: Vec3.schema }); -Component.register(bits, { type: Vec3.U32.schema }); -Component.register(counts, { type: Vec3.U32.schema }); -Component.register(sums, { type: Vec3.I32.schema }); -const spawn = System.create({ name: "builtinsTest/spawn" }, ({ commands }) => { +const spawn = System.create({}, ({ commands }) => { for (let i = 0; i < 64; i++) commands.spawn([position, velocity])({ [position]: [i, i % 5, -i], [velocity]: [1, 0.5 * (i % 3), 0.25] }); }); @@ -37,7 +31,7 @@ const rotation = Quat.fromAxisAngle([0, 1, 0], 0.5); const matrix = Mat4x4.multiply(Mat4x4.translation(1, 2, 3), Mat4x4.scaling(2, 2, 2)); /** Rotates, transforms and offsets each position, with functions from three math namespaces inlined. */ -const transform = System.create({ name: "builtinsTest/transform", kernel: "cpu", components: { read: [velocity], write: [position] } }, row => { +const transform = System.create({ kernel: true, components: { with: [velocity, position] } }, row => { const q = Quat.fromAxisAngle([0, 1, 0], 0.5); const m = Mat4x4.multiply(Mat4x4.translation(1, 2, 3), Mat4x4.scaling(2, 2, 2)); row[position] = Vec3.add(Mat4x4.multiplyVec3(m, Quat.rotateVec3(q, row[position])), Vec3.normalize(row[velocity])); @@ -48,7 +42,7 @@ const expected = (p: Vec3, v: Vec3): Vec3 => Vec3.add(Mat4x4.multiplyVec3(matrix const bitsAt = (i: number): Vec3.U32 => [Math.imul(i + 1, 0x9E3779B9) >>> 0, (0xFFFFFFFF - i) >>> 0, i << 26 >>> 0]; const sumsAt = (i: number): Vec3.I32 => [2 ** 31 - 1 - i, -(2 ** 31) + i, Math.imul(i, 65536)]; -const spawnBits = System.create({ name: "builtinsTest/spawnBits" }, ({ commands }) => { +const spawnBits = System.create({}, ({ commands }) => { for (let i = 0; i < 64; i++) commands.spawn([bits, counts, sums])({ [bits]: bitsAt(i), [counts]: [0, 0, 0], [sums]: sumsAt(i) }); }); @@ -58,7 +52,7 @@ const integerResults = (b: Vec3.U32, s: Vec3.I32): { readonly counts: Vec3.U32; sums: Vec3.I32.add(Vec3.I32.multiply(s, [65536, 3, 65536]), Vec3.I32.abs(Vec3.I32.negate(s))), }); -const integers = System.create({ name: "builtinsTest/integers", kernel: "cpu", components: { read: [bits], write: [counts, sums] } }, row => { +const integers = System.create({ kernel: true, components: { with: [bits, counts, sums] } }, row => { const b = row[bits]; const s = row[sums]; row[counts] = Vec3.U32.add(Vec3.U32.countOneBits(b), Vec3.U32.add(Vec3.U32.countTrailingZeros(b), Vec3.U32.multiply(Vec3.U32.reverseBits(b), b))); diff --git a/packages/ecs/src/implementation/world/change-blocks.browser.test.ts b/packages/ecs/src/implementation/world/change-blocks.browser.test.ts new file mode 100644 index 000000000..d7abfd87d --- /dev/null +++ b/packages/ecs/src/implementation/world/change-blocks.browser.test.ts @@ -0,0 +1,81 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { describe, expect, it } from "vitest"; +import { Entity } from "../../interface/entity/entity.js"; +import { gpuDevice } from "../../interface/resources/gpu-device.js"; +import { workers } from "../../interface/resources/workers.js"; +import { System } from "../../interface/system/system.js"; +import { createWorld, join } from "./create.js"; +import type { F32, U32 } from "../../interface/math/index.js"; + +// A GPU kernel stamps a whole column once; before a joined thread's systems filter on it, its rows, chunks and blocks +// take the stamp, so the thread's change filters find every row, and then only rows written after. + +const value = Symbol("changeBlocksGpuTest/value"); +const at = Symbol("changeBlocksGpuTest/at"); +const note = Symbol("changeBlocksGpuTest/note"); + +declare module "@adobe/ecs" { + interface Threads { + changeBlocksGpuTest: true; + } + interface Components { + [value]: { type: F32 }; + [at]: { type: U32 }; + [note]: { type: { readonly text: string } | null; thread: "changeBlocksGpuTest"; default: { text: "none" } }; + } +} +const text = { type: "object", properties: { text: { type: "string" } } } as const; + +const rows = 4500; +let entities: Entity[] = []; +const seed = System.create({}, ({ commands }) => { + entities = []; + for (let i = 0; i < rows; i++) entities.push(commands.spawn([value, at, note])({ [value]: 0, [at]: i })); +}); +const raise = System.create({ kernel: "gpu", components: { with: [value] } }, row => { + row[value] = row[value] + 1; +}); +const setValue = System.create({ target: "entity", components: { with: [value] } }, (row, _context, input: { readonly entity: Entity }) => { + row[value] += 1; +}); +let found: number[] = []; +const there = System.create({ components: { with: [at, note], changed: [value] } }, row => { if (row[note] !== undefined) found.push(row[at]); }); + +const device = async (): Promise => { + const adapter = await navigator.gpu.requestAdapter(); + if (adapter === null) throw new Error("No WebGPU adapter."); + return adapter.requestDevice(); +}; + +describe("chunk and block ticks after a GPU kernel's column stamp", () => { + it("let a joined thread's filters find every row a GPU kernel wrote, then a row the CPU wrote", async () => { + const world = createWorld({ [workers]: 0, [gpuDevice]: await device() }); + const channel = new MessageChannel(); + world.share(channel.port1); + const remote = join(channel.port2, { threads: ["changeBlocksGpuTest"] }); + try { + await new Promise(resolve => { + const stop = world.threads(threads => { + if (threads.at(-1)?.status !== "joined") return; + queueMicrotask(() => stop()); + resolve(); + }); + }); + await world.run(seed); + await world.run(there); + await world.run(raise); + found = []; + await world.run(there); + expect(found.length).toBe(rows); + const row = 2 * 1024 + 5 * 64 + 9; + await world.run(setValue, { entity: entities[row] }); + found = []; + await world.run(there); + expect(found).toEqual([row]); + } + finally { + await remote.dispose(); + await world.dispose(); + } + }); +}); diff --git a/packages/ecs/src/implementation/world/change-blocks.cases.ts b/packages/ecs/src/implementation/world/change-blocks.cases.ts new file mode 100644 index 000000000..f8a219e38 --- /dev/null +++ b/packages/ecs/src/implementation/world/change-blocks.cases.ts @@ -0,0 +1,14 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { describe, expect, it } from "vitest"; +import type * as Fixture from "./change-blocks.fixture.js"; + +/** Chunk and block ticks on workers: the cases, over the fixture built for node or from source. */ +export const changeBlocksCases = (load: () => Promise) => describe("chunk and block ticks on workers", () => { + for (const wasm of [false, true]) { + it(`let readers find the rows a kernel on workers wrote (${wasm ? "WASM" : "JS"} kernels)`, async () => { + const fixture = await load(); + const expected = [5, 2048 + 5 * 64 + 9, 4499]; + expect(await fixture.foundOnWorkers(wasm)).toEqual({ kernel: expected, main: expected }); + }); + } +}); diff --git a/packages/ecs/src/implementation/world/change-blocks.fixture.ts b/packages/ecs/src/implementation/world/change-blocks.fixture.ts new file mode 100644 index 000000000..9bde1968d --- /dev/null +++ b/packages/ecs/src/implementation/world/change-blocks.fixture.ts @@ -0,0 +1,70 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { I32 } from "../../interface/math/i32/index.js"; +import { workers } from "../../interface/resources/workers.js"; +import { System } from "../../interface/system/system.js"; +import { readEach, select } from "./reads.test-helper.js"; +import { createWorld } from "./create.js"; +import type { U32 } from "../../interface/math/index.js"; + +/** Scenarios of chunk and block ticks on workers, run built in node and from source in chromium. */ + +const value = Symbol("changeBlocksWorkersTest/value"); +const at = Symbol("changeBlocksWorkersTest/at"); +const seen = Symbol("changeBlocksWorkersTest/seen"); + +declare module "@adobe/ecs" { + export interface Components { + [value]: { type: number }; + [at]: { type: U32 }; + [seen]: { type: number }; + } +} + + +const rows = 4500; + +const seed = System.create({}, ({ commands }) => { + for (let i = 0; i < rows; i++) commands.spawn([value, at, seen])({ [value]: 0, [at]: i, [seen]: 0 }); +}); + +/** Writes the rows whose `at` is one of three, stamping row by row. */ +const writeSome = System.create({ kernel: true, components: { with: [at, value] } }, (row, _context, input: { readonly a: I32; readonly b: I32; readonly c: I32 }) => { + const here = row[at]; + if (here === input.a || here === input.b || here === input.c) row[value] += 1; +}); + +/** Counts each run of the rows whose value changed. */ +const watch = System.create({ kernel: true, components: { with: [seen], changed: [value] } }, row => { + row[seen] += 1; +}); + +let found: number[] = []; +const onMain = System.create({ components: { with: [at], changed: [value] } }, row => { found.push(row[at]); }); + +/** + * The rows a kernel on workers and a system on the world's thread find after a kernel on workers wrote three rows far + * apart: in the first block, a middle block of a middle chunk, and the last row. + */ +export const foundOnWorkers = async (wasm: boolean): Promise<{ readonly kernel: readonly number[]; readonly main: readonly number[] }> => { + const world = createWorld({ [workers]: 2 }, { wasm, minimumChunk: 64 }); + try { + await world.run(seed); + await world.run(watch); + await world.run(onMain); + const all = await select(world, { required: [seen] }); + const before = await readEach(world, all, seen); + await world.run(writeSome, { a: 5, b: 2048 + 5 * 64 + 9, c: rows - 1 }); + await world.run(watch); + found = []; + await world.run(onMain); + const after = await readEach(world, all, seen); + const ats = await readEach(world, all, at); + return { + kernel: all.flatMap((_, k) => after[k] !== before[k] ? [ats[k] ?? -1] : []).sort((a, b) => a - b), + main: [...found].sort((a, b) => a - b), + }; + } + finally { + await world.dispose(); + } +}; diff --git a/packages/ecs/src/implementation/world/change-blocks.test.ts b/packages/ecs/src/implementation/world/change-blocks.test.ts new file mode 100644 index 000000000..24a8d8626 --- /dev/null +++ b/packages/ecs/src/implementation/world/change-blocks.test.ts @@ -0,0 +1,270 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { describe, expect, it } from "vitest"; +import { Entity } from "../../interface/entity/entity.js"; +import { Index } from "../../interface/indexes/index-type.js"; +import type { I32 } from "../../interface/math/i32/index.js"; +import { workers } from "../../interface/resources/workers.js"; +import { System } from "../../interface/system/system.js"; +import type { World } from "../../interface/world/world.js"; +import { createWorld, join } from "./create.js"; +import { blockShift, chunkShift } from "./kernel.js"; +import { readEach, select } from "./reads.test-helper.js"; +import type { U32 } from "../../interface/math/index.js"; + +// Change filters skip chunks and blocks whose ticks aren't newer than their `lastRun`, so every writer of a row's tick +// raises its chunk's and block's. Each case writes a few rows far apart in an archetype of many chunks, through one +// writer, and every kind of reader must find exactly those rows. + +const value = Symbol("changeBlocksTest/value"); +const at = Symbol("changeBlocksTest/at"); +const seen = Symbol("changeBlocksTest/seen"); +const group = Symbol("changeBlocksTest/group"); +const tagged = Symbol("changeBlocksTest/tagged"); +const note = Symbol("changeBlocksTest/note"); +const parent = Symbol("changeBlocksTest/parent"); +const extra = Symbol("changeBlocksTest/extra"); +const height = Symbol("changeBlocksTest/height"); + +declare module "@adobe/ecs" { + interface Threads { + changeBlocksTest: true; + } + interface Components { + [value]: { type: number }; + [at]: { type: U32 }; + [seen]: { type: number }; + [group]: { type: U32 }; + [tagged]: { type: true }; + [note]: { type: { readonly text: string } | null; thread: "changeBlocksTest"; default: { text: "none" } }; + [parent]: { type: Entity; reference: "owner" }; + [extra]: { type: number }; + [height]: { type: number }; + } +} +const text = { type: "object", properties: { text: { type: "string" } } } as const; + +/** Rows in the archetype: five chunks, the last partial. */ +const rows = 4500; +const plain = [value, at, seen, group] as const; +const marked = [value, at, seen, group, tagged] as const; +const noted = [value, at, note] as const; + +let entities: Entity[] = []; +const seed = System.create({}, ({ commands }) => { + entities = []; + for (let i = 0; i < rows; i++) entities.push(commands.spawn(plain)({ [value]: 0, [at]: i, [seen]: 0, [group]: 0 })); + // Rows an entity tagged later moves after, so it lands far into that archetype. + for (let i = rows; i < 2 * rows; i++) entities.push(commands.spawn(marked)({ [value]: 0, [at]: i, [seen]: 0, [group]: 0, [tagged]: true })); +}); + +// Writers. +const setValue = System.create({ target: "entity", components: { with: [value] } }, (row, _context, input: { readonly entity: Entity }) => { + row[value] += 1; +}); +const replace = System.create({}, ({ commands }, input: { readonly entities: readonly Entity[] }) => { + for (const entity of input.entities) commands.add(entity, value, 7); +}); +const despawn = System.create({}, ({ commands }, input: { readonly entities: readonly Entity[] }) => { + for (const entity of input.entities) commands.despawn(entity); +}); +const tag = System.create({}, ({ commands }, input: { readonly entities: readonly Entity[] }) => { + for (const entity of input.entities) commands.add(entity, tagged); +}); +const spawnOne = System.create({}, ({ commands }, input: { readonly at: number }) => { + commands.spawn(plain)({ [value]: 0, [at]: input.at, [seen]: 0, [group]: 0 }); +}); +/** Writes the rows whose `at` is in a list of up to four, which stamps row by row. */ +const writeSome = System.create({ kernel: true, components: { with: [at, value] } }, (row, _context, input: { readonly a: I32; readonly b: I32; readonly c: I32; readonly d: I32 }) => { + const here = row[at]; + if (here === input.a || here === input.b || here === input.c || here === input.d) row[value] += 1; +}); +/** Writes every row, which stamps chunks and blocks once per call. */ +const writeAll = System.create({ kernel: true, components: { with: [value] } }, row => { + row[value] += 1; +}); +const setGroup = System.create({ target: "entity", components: { with: [group] } }, (row, _context, input: { readonly entity: Entity }) => { + row[group] = 1; +}); + +// Readers. +let found: number[] = []; +const onMain = System.create({ components: { with: [at], changed: [value] } }, row => { found.push(row[at]); }); +const observed = System.create({ observer: true, components: { with: [at], changed: [value] } }, row => { found.push(row[at]); }); +const ofTwo = System.create({ components: { with: [at], changed: [group, value] } }, row => { found.push(row[at]); }); +const inKernel = System.create({ kernel: true, components: { with: [seen], changed: [value] } }, row => { row[seen] += 1; }); +const inUp = System.create({ components: { with: [at], changed: [value] }, up: { through: parent } }, row => { found.push(row[at]); }); +const byGroup = Index.create({ key: [group] }); +let grouped: number[] = []; +const inGroup = System.create({ lookup: { byGroup: { index: byGroup } } }, ({ lookup }) => { + const list = lookup.byGroup(1); + grouped = Array.from({ length: list.length }, (_, i) => list.at(i).entity); +}); + +// On a joined thread, by the object they read: a writer, and readers of its writes and of the world's thread's. +const seedNoted = System.create({}, ({ commands }) => { + entities = []; + for (let i = 0; i < rows; i++) entities.push(commands.spawn(noted)({ [value]: 0, [at]: i })); +}); +const noteSome = System.create({ components: { with: [at, note] } }, (row, _context, input: { readonly ats: readonly number[] }) => { + if (input.ats.includes(row[at])) row[note] = { text: "noted" }; +}); +const notesThere = System.create({ components: { with: [at, note], changed: [note] } }, row => { if (row[note] !== undefined) found.push(row[at]); }); +const valuesThere = System.create({ components: { with: [at, note], changed: [value] } }, row => { if (row[note] !== undefined) found.push(row[at]); }); + +/** The `at` of the rows a main-thread reader ran, in order. */ +const ranOn = async (world: World, reader: System): Promise => { + found = []; + await world.run(reader); + return [...found].sort((a, b) => a - b); +}; + +/** The `at` of the rows the kernel reader ran, by the `seen` it raised. */ +const ranInKernel = async (world: World): Promise => { + const all = await select(world, { required: [seen] }); + const before = await readEach(world, all, seen); + await world.run(inKernel); + const after = await readEach(world, all, seen); + const ats = await readEach(world, all, at); + return all.flatMap((_, k) => after[k] !== before[k] ? [ats[k] ?? -1] : []).sort((a, b) => a - b); +}; + +const readers = { onMain, observed, ofTwo, inUp } as const; + +/** Every reader's rows after `write`, each having run once before, so each sees only what `write` changed. */ +const seenAfter = async (world: World, write: () => Promise): Promise<{ readonly [reader: string]: readonly number[] }> => { + for (const reader of Object.values(readers)) await world.run(reader); + await world.run(inKernel); + await write(); + const result: { [reader: string]: readonly number[] } = {}; + for (const [name, reader] of Object.entries(readers)) result[name] = await ranOn(world, reader); + result.inKernel = await ranInKernel(world); + return result; +}; + +const every = (expected: readonly number[]) => ({ onMain: expected, observed: expected, ofTwo: expected, inUp: expected, inKernel: expected }); + +/** Rows far apart: in the first block, a middle block of a middle chunk, and the last chunk's last block. */ +const spread = [5, (2 << chunkShift) + (5 << blockShift) + 9, rows - 1] as const; + +describe("chunk and block ticks", () => { + for (const wasm of [false, true]) { + const label = wasm ? "WASM" : "JS"; + const inline = () => createWorld({ [workers]: 0 }, { wasm }); + + it(`let readers find rows a main-thread system wrote (${label} kernels)`, async () => { + await using world = inline(); + await world.run(seed); + expect(await seenAfter(world, async () => { + for (const row of spread) await world.run(setValue, { entity: entities[row] }); + })).toEqual(every(spread)); + }); + + it(`let readers find rows a kernel wrote, row by row and once per call (${label} kernels)`, async () => { + await using world = inline(); + await world.run(seed); + expect(await seenAfter(world, () => world.run(writeSome, { a: spread[0], b: spread[1], c: spread[2], d: -1 }))).toEqual(every(spread)); + const all = Array.from({ length: 2 * rows }, (_, k) => k); + expect(await seenAfter(world, () => world.run(writeAll))).toEqual(every(all)); + }); + + it(`let readers find rows commands wrote, moved, swapped into and spawned (${label} kernels)`, async () => { + await using world = inline(); + await world.run(seed); + expect(await seenAfter(world, () => world.run(replace, { entities: spread.map(row => entities[row]) }))).toEqual(every(spread)); + // The last row, changed, is swapped into the first block's despawned row. + const last = rows - 1; + expect(await seenAfter(world, async () => { + await world.run(setValue, { entity: entities[last] }); + await world.run(despawn, { entities: [entities[3]] }); + })).toEqual(every([last])); + // A changed row moves to the end of another archetype, past its first chunks. + const moved = spread[1]; + expect(await seenAfter(world, async () => { + await world.run(setValue, { entity: entities[moved] }); + await world.run(tag, { entities: [entities[moved]] }); + })).toEqual(every([moved])); + expect(await seenAfter(world, () => world.run(spawnOne, { at: 9999 }))).toEqual(every([9999])); + }); + } + + it("let a filter of two components find a row only the other component changed in", async () => { + await using world = createWorld({ [workers]: 0 }); + await world.run(seed); + await world.run(ofTwo); + await world.run(setGroup, { entity: entities[spread[1]] }); + expect(await ranOn(world, ofTwo)).toEqual([spread[1]]); + }); + + it("let systems on a joined thread find rows written there and on the world's thread", async () => { + const world = createWorld({ [workers]: 0 }); + const channel = new MessageChannel(); + world.share(channel.port1); + const remote = join(channel.port2, { threads: ["changeBlocksTest"] }); + try { + await new Promise(resolve => { + const stop = world.threads(threads => { + if (threads.at(-1)?.status !== "joined") return; + queueMicrotask(() => stop()); + resolve(); + }); + }); + await world.run(seedNoted); + await world.run(valuesThere); + await world.run(notesThere); + for (const row of spread) await world.run(setValue, { entity: entities[row] }); + expect(await ranOn(world, valuesThere)).toEqual(spread); + await world.run(noteSome, { ats: [spread[1], spread[2]] }); + expect(await ranOn(world, notesThere)).toEqual([spread[1], spread[2]]); + } + finally { + await remote.dispose(); + await world.dispose(); + } + }); + + it("let an index refresh find a key written in a block of a chunk", async () => { + await using world = createWorld({ [workers]: 0 }); + await world.run(seed); + await world.run(inGroup); + expect(grouped).toEqual([]); + await world.run(setGroup, { entity: entities[spread[1]] }); + await world.run(inGroup); + expect(grouped).toEqual([entities[spread[1]]]); + }); + + it("let an added filter find a component a command added, and that row once swapped into an old block", async () => { + await using world = createWorld({ [workers]: 0 }); + await world.run(seedExtra); + await world.run(addedExtra); + // Rows `rows` and `rows + 1` gain the component and land after the archetype's rows, in its last block. + await world.run(addExtra, { entity: entities[rows] }); + expect(await ranOn(world, addedExtra)).toEqual([rows]); + // Its last row, just added, moves into the first block's despawned row. + await world.run(addExtra, { entity: entities[rows + 1] }); + await world.run(despawn, { entities: [entities[3]] }); + expect(await ranOn(world, addedExtra)).toEqual([rows + 1]); + }); + + it("let filters find what a JS kernel reading up wrote on every row", async () => { + await using world = createWorld({ [workers]: 0 }, { wasm: false }); + await world.run(seedExtra); + await world.run(heights); + await world.run(raiseHeights); + expect((await ranOn(world, heights)).length).toBe(rows + 2); + }); +}); + +// Rows that have `extra` from the start, then two that gain it; and a JS `up` kernel writing every row's height. +const seedExtra = System.create({}, ({ commands }) => { + entities = []; + for (let i = 0; i < rows; i++) entities.push(commands.spawn([at, extra, height])({ [at]: i, [extra]: 0, [height]: 0 })); + for (let i = rows; i < rows + 2; i++) entities.push(commands.spawn([at, height])({ [at]: i, [height]: 0 })); +}); +const addExtra = System.create({}, ({ commands }, input: { readonly entity: Entity }) => { + commands.add(input.entity, extra, 1); +}); +const addedExtra = System.create({ components: { with: [at], added: [extra] } }, row => { found.push(row[at]); }); +const raiseHeights = System.create({ kernel: true, components: { with: [height] }, up: { through: parent } }, (row, { up }) => { + row[height] = up === null ? 1 : up[height] + 1; +}); +const heights = System.create({ components: { with: [at], changed: [height] } }, row => { found.push(row[at]); }); diff --git a/packages/ecs/src/implementation/world/changes-finalizers.test.ts b/packages/ecs/src/implementation/world/changes-finalizers.test.ts index b3c04aa23..1c14300d1 100644 --- a/packages/ecs/src/implementation/world/changes-finalizers.test.ts +++ b/packages/ecs/src/implementation/world/changes-finalizers.test.ts @@ -1,6 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { expect, it } from "vitest"; -import { Component, Finalizer, Resource, System, World } from "../../index.js"; +import { Finalizer, System, World } from "../../index.js"; import { Observe } from "../../observe/index.js"; import { createWorld } from "../index.js"; @@ -14,24 +14,21 @@ declare module "@adobe/ecs" { [score]: { type: number }; } interface Resources { - [journal]: { type: readonly string[] }; + [journal]: { type: readonly string[]; default: [] }; } } -Component.register(name, { type: { type: "string" } }); -Component.register(score, { type: { type: "number" } }); -Resource.register(journal, { type: { type: "array", items: { type: "string" }, default: [] } }); // A changes finalizer sees each sync point's structural changes to the components it watches, grouped by how // they changed, with values before and after: for persistence, replication and undo. -Finalizer.changes({ name: "changesFinalizers/save", watch: [name, score], resources: { write: [journal] } }, (changes, { resources }) => { +Finalizer.changes({ watch: [name, score] }, (changes, { resources }) => { const lines = changes.groups.flatMap(group => group.entities.map(entity => `${changes.previous(entity, name) ?? changes.next(entity, name)}: +${group.added.length} -${group.removed.length} score ${changes.previous(entity, score)} → ${changes.next(entity, score)}`)); resources[journal] = [...resources[journal], ...lines]; }); -const spawn = System.create({ name: "changesFinalizers/spawn" }, ({ commands }) => { commands.spawn([name, score])({ [name]: "ada", [score]: 3 }); }); -const retire = System.create({ name: "changesFinalizers/retire", components: { has: [score] } }, (row, { commands }) => { commands.remove(row.entity, score); }); +const spawn = System.create({}, ({ commands }) => { commands.spawn([name, score])({ [name]: "ada", [score]: 3 }); }); +const retire = System.create({ components: { with: [score] } }, (row, { commands }) => { commands.remove(row.entity, score); }); it("records each sync point's structural changes", async () => { await using world = createWorld(); diff --git a/packages/ecs/src/implementation/world/check-kernel.ts b/packages/ecs/src/implementation/world/check-kernel.ts index 38e974cb2..7b131eda1 100644 --- a/packages/ecs/src/implementation/world/check-kernel.ts +++ b/packages/ecs/src/implementation/world/check-kernel.ts @@ -1,5 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { declarationOf, labelOf, labelOfItem } from '../../interface/registry/index.js'; +import { declarationOf, labelOf } from '../../interface/registry/index.js'; import { layoutOf, samePath, type Layout, type Path } from '../../interface/registry/layout.js'; import type { Leaf } from '../../interface/system/leaf.js'; import type { KernelDescription } from './kernel.js'; @@ -25,63 +25,72 @@ const exactly = (layout: Layout | null, leaves: readonly Path[]): boolean => { }; /** - * Checks a compiled kernel against its declaration and the registered schemas, which don't depend on a world, so - * registering the kernel rejects it where it's declared: every column, whole-value write, lookup, `up` read and - * reduction it uses is declared, and names scalar leaves of its component's schema, each spawn site names each of its - * components once, from its keys, and a tuple or struct a command or spawn gives goes to a component stored in linear - * memory. Throws with the first mismatch. + * How a compiled kernel doesn't match its declaration and the registered schemas, or null when it does. They don't + * depend on a world, so registering the kernel rejects it where it's declared: every column, whole-value write, lookup, + * `up` read and reduction it uses is declared, and names scalar leaves of its component's schema, each spawn site names + * each of its components once, from its keys, and a tuple or struct a command or spawn gives goes to a component + * stored in linear memory. The first mismatch. */ -export const checkKernel = (leaf: Leaf, description: KernelDescription): void => { - const { lookup, components: query, resources, up, reduce } = leaf.declaration; - const fail = (message: string): never => { throw new Error(`Kernel "${labelOfItem(leaf)}": ${message}`); }; +export const kernelMismatch = (leaf: Leaf, description: KernelDescription): string | null => { + const { lookup, components: query, resources, up } = leaf.declaration; + const reduces = new Set(resources?.reduce); const queried = new Set([...query?.read ?? [], ...query?.write ?? []]); const written = new Set(query?.write); // A condition in `is` is readable too, and the compiler lists it with the reads. const readable = new Set([...resources?.read ?? [], ...resources?.is ?? []]); - for (const key of description.resources) if (!readable.has(key)) fail(`reads resource "${labelOf(key)}", which isn't in resources.read.`); + for (const key of description.resources) if (!readable.has(key)) return `reads resource "${labelOf(key)}", which isn't in resources.read.`; for (const column of description.columns) { if (column.kind === "entity") continue; - if (!queried.has(column.component)) fail(`"${labelOf(column.component)}" isn't in its components' read or write.`); - if (column.kind === "changed" || column.kind === "chunks") { - if (!written.has(column.component)) fail(`writes "${labelOf(column.component)}", which isn't in its components' write.`); + if (!queried.has(column.component)) return `"${labelOf(column.component)}" isn't in its components' read or write.`; + if (column.kind === "changed" || column.kind === "chunks" || column.kind === "blocks") { + if (!written.has(column.component)) return `writes "${labelOf(column.component)}", which isn't in its components' write.`; continue; } + if (column.kind === "present") continue; const layout = layoutOfKey(column.component); - if (layout !== null && layout.kind !== "linear") fail(`"${labelOf(column.component)}" isn't stored in linear memory.`); - if (!hasLeaf(layout, column.path)) fail(`"${labelOf(column.component)}" has no scalar at [${column.path.join(", ")}]; destructure tuples and structs or index their fields.`); + if (layout !== null && layout.kind !== "linear") return `"${labelOf(column.component)}" isn't stored in linear memory.`; + if (!hasLeaf(layout, column.path)) return `"${labelOf(column.component)}" has no scalar at [${column.path.join(", ")}]; destructure tuples and structs or index their fields.`; } for (const shape of description.shapes) { - if (!written.has(shape.component)) fail(`writes "${labelOf(shape.component)}", which isn't in its components' write.`); - if (!exactly(layoutOfKey(shape.component), shape.leaves)) fail(`a whole-value write of "${labelOf(shape.component)}" doesn't match its schema.`); + if (!written.has(shape.component)) return `writes "${labelOf(shape.component)}", which isn't in its components' write.`; + if (!exactly(layoutOfKey(shape.component), shape.leaves)) return `a whole-value write of "${labelOf(shape.component)}" doesn't match its schema.`; } - for (const lookupName of description.lookups) if (lookup?.[lookupName] === undefined) fail(`unknown lookup "${lookupName}".`); - if ((description.up === true) !== (up !== undefined)) fail(description.up === true ? "reads up, which its declaration doesn't declare." : "its declaration declares up, which it doesn't read."); + for (const lookupName of description.lookups) if (lookup?.[lookupName] === undefined) return `unknown lookup "${lookupName}".`; + if ((description.up === true) !== (up !== undefined)) return description.up === true ? "reads up, which its declaration doesn't declare." : "its declaration declares up, which it doesn't read."; const upReadable = new Set(up?.read); for (const column of description.upColumns ?? []) { - if (column.kind !== "field" || !upReadable.has(column.component)) fail(`reads "${labelOf(column.kind === "field" ? column.component : column.kind)}" through up, which isn't in up.read.`); - if (column.kind !== "field") continue; + if (column.kind !== "field" || !upReadable.has(column.component)) return `reads "${labelOf(column.kind === "field" ? column.component : column.kind)}" through up, which isn't in up.read.`; const layout = layoutOfKey(column.component); - if (layout !== null && layout.kind !== "linear") fail(`up reads "${labelOf(column.component)}", which isn't stored in linear memory.`); - if (!hasLeaf(layout, column.path)) fail(`"${labelOf(column.component)}" has no scalar at [${column.path.join(", ")}]; destructure tuples and structs or index their fields.`); + if (layout !== null && layout.kind !== "linear") return `up reads "${labelOf(column.component)}", which isn't stored in linear memory.`; + if (!hasLeaf(layout, column.path)) return `"${labelOf(column.component)}" has no scalar at [${column.path.join(", ")}]; destructure tuples and structs or index their fields.`; } for (const reduced of description.reductions) { - if (Reflect.get(reduce ?? {}, reduced.resource) !== reduced.operator) fail(`reduces "${labelOf(reduced.resource)}" with "${reduced.operator}", which its reduce doesn't declare.`); - if (!hasLeaf(layoutOfKey(reduced.resource), reduced.path)) fail(`contributes to "${labelOf(reduced.resource)}" at [${reduced.path.join(", ")}], which isn't a scalar of its value; destructure tuples and structs or build them.`); + if (!reduces.has(reduced.resource)) return `reduces "${labelOf(reduced.resource)}", which isn't in its resources' reduce.`; + if (declarationOf(reduced.resource)?.reduce !== reduced.operator) return `reduces "${labelOf(reduced.resource)}" with "${reduced.operator}", but it's declared reduce: "${labelOf(declarationOf(reduced.resource)?.reduce)}"; rebuild.`; + if (!hasLeaf(layoutOfKey(reduced.resource), reduced.path)) return `contributes to "${labelOf(reduced.resource)}" at [${reduced.path.join(", ")}], which isn't a scalar of its value; destructure tuples and structs or build them.`; } for (const list of description.spawns) { - list.forEach((k, position) => { - if (!Number.isInteger(k) || k < 0 || k >= description.keys.length) fail(`spawns a list naming component ${k} of its ${description.keys.length} keys.`); - if (list.findIndex(other => description.keys[other] === description.keys[k]) < position) fail(`spawns a list naming "${labelOf(description.keys[k])}" twice.`); - }); + for (let position = 0; position < list.length; position++) { + const k = list[position] ?? -1; + if (!Number.isInteger(k) || k < 0 || k >= description.keys.length) return `spawns a list naming component ${k} of its ${description.keys.length} keys.`; + if (list.findIndex(other => description.keys[other] === description.keys[k]) < position) return `spawns a list naming "${labelOf(description.keys[k])}" twice.`; + } } for (const k of description.structuredValues) { const key = description.keys[k]; const layout = key === undefined ? null : layoutOfKey(key); - if (layout !== null && layout.kind !== "linear") fail(`gives "${labelOf(key)}" a tuple or struct, which only a component stored in linear memory takes.`); + if (layout !== null && layout.kind !== "linear") return `gives "${labelOf(key)}" a tuple or struct, which only a component stored in linear memory takes.`; } for (const shape of description.reducedShapes) { - if (Reflect.get(reduce ?? {}, shape.component) === undefined || !exactly(layoutOfKey(shape.component), shape.leaves)) { - fail(`contributes a value to "${labelOf(shape.component)}" that doesn't match its schema.`); + if (!reduces.has(shape.component) || !exactly(layoutOfKey(shape.component), shape.leaves)) { + return `contributes a value to "${labelOf(shape.component)}" that doesn't match its schema.`; } } + return null; +}; + +/** Throws with the first way a compiled kernel doesn't match its declaration and the registered schemas (`kernelMismatch`). */ +export const checkKernel = (leaf: Leaf, description: KernelDescription): void => { + const mismatch = kernelMismatch(leaf, description); + if (mismatch !== null) throw new Error(`Kernel "${leaf.name}": ${mismatch}`); }; diff --git a/packages/ecs/src/implementation/world/command-rings.cases.ts b/packages/ecs/src/implementation/world/command-rings.cases.ts index 1bc393d43..3e8d440ea 100644 --- a/packages/ecs/src/implementation/world/command-rings.cases.ts +++ b/packages/ecs/src/implementation/world/command-rings.cases.ts @@ -19,7 +19,7 @@ export const commandRingsCases = (load: () => Promise) => descri const fixture = await load(); const alone = await fixture.failed(0); const { failures, weights, extents, spawned } = alone; - expect(failures).toEqual(["commandRingsTest/failing"]); + expect(failures).toEqual(["failing"]); // Every row but the first, which has no target, added its weight and spawned before reading its target; only // the last one's target is none. expect(weights).toEqual([null, ...Array.from({ length: 999 }, (_, i) => i + 1)]); diff --git a/packages/ecs/src/implementation/world/command-rings.fixture.ts b/packages/ecs/src/implementation/world/command-rings.fixture.ts index 2fdfdb17e..81b25a3ca 100644 --- a/packages/ecs/src/implementation/world/command-rings.fixture.ts +++ b/packages/ecs/src/implementation/world/command-rings.fixture.ts @@ -1,5 +1,4 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Component } from "../../interface/component/component.js"; import { Entity } from "../../interface/entity/entity.js"; import type { Schema } from "../../interface/schema/schema.js"; import { System } from "../../interface/system/system.js"; @@ -9,6 +8,7 @@ import { createWorld } from "./create.js"; import { countingWorkers } from "./platforms.test-helper.js"; import { read, readEach, select } from "./reads.test-helper.js"; import { runReported } from "./reports.test-helper.js"; +import type { U32, Vec4 } from "../../interface/math/index.js"; /** Scenarios of workers' command rings against the main thread alone, run built in node and from source in chromium. */ @@ -26,30 +26,21 @@ const tally = Symbol("commandRingsTest/tally"); declare module "@adobe/ecs" { export interface Components { - [index]: { type: number }; + [index]: { type: U32 }; [weight]: { type: number }; - [extent]: { type: readonly [number, number, number, number] }; - [made]: { type: number }; + [extent]: { type: Vec4 }; + [made]: { type: U32 }; [doomed]: { type: true }; [target]: { type: Entity }; - [name]: { type: string | null }; + [name]: { type: string | null; default: "unnamed" }; [maybe]: { type: string | null }; [tally]: { type: number | null }; } } -Component.register(index, { type: { type: "integer", minimum: 0, maximum: 0xFFFFFFFF } }); -Component.register(weight, { type: { type: "number" } }); -Component.register(extent, { type: vec4 }); -Component.register(made, { type: { type: "integer", minimum: 0, maximum: 0xFFFFFFFF } }); -Component.register(doomed, { type: { const: true } }); -Component.register(target, { type: Entity.schema }); /** Objects: a nullable string starting at "unnamed", a nullable string, and a nullable number. */ -Component.register(name, { type: { anyOf: [{ type: "string" }, { type: "null" }], default: "unnamed" } }); -Component.register(maybe, { type: { anyOf: [{ type: "string" }, { type: "null" }] } }); -Component.register(tally, { type: { anyOf: [{ type: "number" }, { type: "null" }] } }); -const seed = System.create({ name: "commandRingsTest/seed", input: { type: "object", properties: { count: { type: "integer" } } } }, ({ input, commands }) => { +const seed = System.create({}, ({ commands }, input: { readonly count: number }) => { for (let i = 0; i < input.count; i++) commands.spawn([index])({ [index]: i }); }); @@ -58,11 +49,9 @@ const seed = System.create({ name: "commandRingsTest/seed", input: { type: "obje * of every seventh spawn's row, so a run of a few thousand rows overflows a worker's ring. */ const burst = System.create({ - name: "commandRingsTest/burst", - kernel: "cpu", - input: { type: "object", properties: { shift: { type: "number" } } }, - components: { read: [index] }, -}, (row, { input, commands }) => { + kernel: true, + components: { with: [index] }, +}, (row, { commands }, input: { readonly shift: number }) => { const i = row[index]; commands.add(row.entity, weight, -1); commands.add(row.entity, weight, i + input.shift); @@ -106,17 +95,16 @@ export const overflow = (count: number): Promise<{ readonly rows: readonly strin }; /** Rows targeting a live entity, but for the last, whose target never lived. */ -const targets = System.create({ name: "commandRingsTest/targets", input: { type: "object", properties: { count: { type: "integer" } } } }, ({ input, commands }) => { +const targets = System.create({}, ({ commands }, input: { readonly count: number }) => { const first = commands.spawn([index])({ [index]: 0 }); for (let i = 1; i < input.count; i++) commands.spawn([index, target])({ [index]: i, [target]: i === input.count - 1 ? Entity.none : first }); }); /** A spawn and an add for each row, then a read through its target, which throws at the last row, whose target is none. */ const failing = System.create({ - name: "commandRingsTest/failing", - kernel: "cpu", - components: { read: [index, target] }, - lookup: { targets: { read: [index] } }, + kernel: true, + components: { with: [index, target] }, + lookup: { targets: { with: [index] } }, }, (row, { lookup, commands }) => { commands.spawn([made])({ [made]: row[index] }); commands.add(row.entity, weight, row[index]); @@ -143,14 +131,13 @@ export const failed = (count: number): Promise => using(createWorld({ [w /** A spawn for every 64th row: each chunk uses one position of its block of eight and returns the rest. */ const sparse = System.create({ - name: "commandRingsTest/sparse", - kernel: "cpu", - components: { read: [index], not: [made] }, + kernel: true, + components: { with: [index], not: [made] }, }, (row, { commands }) => { if (row[index] % 64 === 0) commands.spawn([made])({ [made]: row[index] }); }); -const many = System.create({ name: "commandRingsTest/many" }, ({ commands }) => { +const many = System.create({}, ({ commands }) => { for (let i = 0; i < 5_000; i++) commands.spawn([made])({ [made]: 1_000_000 + i }); }); @@ -168,9 +155,8 @@ export const returned = (count: number): Promise<{ readonly spawned: number; rea /** Object components from a kernel: left out, null, and a number, by add and by spawn. */ const objects = System.create({ - name: "commandRingsTest/objects", - kernel: "cpu", - components: { read: [index] }, + kernel: true, + components: { with: [index] }, }, (row, { commands }) => { const i = row[index]; commands.add(row.entity, [name, maybe, tally], { [maybe]: null, [tally]: i }); diff --git a/packages/ecs/src/implementation/world/commands.test.ts b/packages/ecs/src/implementation/world/commands.test.ts index a66207634..d48929852 100644 --- a/packages/ecs/src/implementation/world/commands.test.ts +++ b/packages/ecs/src/implementation/world/commands.test.ts @@ -1,11 +1,8 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; -import { Archetype } from "../../interface/archetype/archetype.js"; import { Report } from "../../interface/report/report.js"; -import { Component } from "../../interface/component/component.js"; import type { Essential } from "../../interface/component/value.js"; import { Entity } from "../../interface/entity/entity.js"; -import { Resource } from "../../interface/resource/resource.js"; import { Rule } from "../../interface/rule/rule.js"; import type { Schema } from "../../interface/schema/schema.js"; import type { Commands } from "../../interface/system/commands.js"; @@ -16,6 +13,8 @@ import { Observe } from "../../observe/index.js"; import { count, read, readEntity, select } from "./reads.test-helper.js"; import { createWorld } from "./create.js"; import { runReported } from "./reports.test-helper.js"; +import { recordDeclaration } from "../../interface/registry/ids.test-helper.js"; +import type { F32 } from "../../interface/math/index.js"; type Vec3 = readonly [number, number, number]; const vec3 = { type: "array", items: { type: "number", precision: 1 }, minItems: 3, maxItems: 3 } as const satisfies Schema; @@ -26,6 +25,7 @@ const label = Symbol("commandsTest/label"); const tag = Symbol("commandsTest/tag"); const other = Symbol("commandsTest/other"); const slot = Symbol("commandsTest/slot"); +// Never declared, so never registered. const unregistered = Symbol("commandsTest/unregistered"); const level = Symbol("commandsTest/level"); const bit0 = Symbol("commandsTest/bit0"); @@ -35,14 +35,13 @@ const bit3 = Symbol("commandsTest/bit3"); const bits = [bit0, bit1, bit2, bit3] as const; declare module "@adobe/ecs" { export interface Components { - [position]: { type: readonly [number, number, number] }; - [score]: { type: number }; + [position]: { type: Schema.Type<{ type: "array"; items: { type: "number"; precision: 1 }; minItems: 3; maxItems: 3 }> }; + [score]: { type: F32 }; [health]: { type: number }; [label]: { type: string | null }; [tag]: { type: true }; [other]: { type: true }; [slot]: { type: number; derived: true }; - [unregistered]: { type: number }; [bit0]: { type: true }; [bit1]: { type: true }; [bit2]: { type: true }; @@ -52,23 +51,11 @@ declare module "@adobe/ecs" { [level]: { type: number }; } } -Component.register(position, { type: vec3 }); -Component.register(score, { type: { type: "number", precision: 1 } }); -Component.register(health, { type: { type: "number" } }); -Component.register(label, { type: { anyOf: [{ type: "string" }, { type: "null" }] } }); -Component.register(tag, { type: { const: true } }); -Component.register(other, { type: { const: true } }); -Component.register(slot, { type: { type: "number", default: 0 }, derived: true }); -Component.register(bit0, { type: { const: true } }); -Component.register(bit1, { type: { const: true } }); -Component.register(bit2, { type: { const: true } }); -Component.register(bit3, { type: { const: true } }); -Resource.register(level, { type: { type: "number", default: 0 } }); -Rule.create({ name: "commandsTest/slots", components: { has: [other, score] }, derive: [slot] }); +Rule.create({ components: { with: [other, score] }, derive: [slot] }); -const unit = Archetype.create([position, score, tag]); -const made = Archetype.create([score, tag]); -const marks = Archetype.create([other, label]); +const unit = [position, score, tag] as const; +const made = [score, tag] as const; +const marks = [other, label] as const; /** Every component of these tests an entity has, or `null` if it is dead. */ const components = (world: World, entity: Entity) => @@ -76,29 +63,29 @@ const components = (world: World, entity: Entity) => /** Spawns and adds with literal values, which the plugin passes positionally, from one site over several lists. */ const literals: Entity[] = []; -const spawnLiterals = System.create({ name: "commandsTest/spawnLiterals" }, ({ commands }) => { +const spawnLiterals = System.create({}, ({ commands }) => { for (let i = 0; i < 6; i++) literals.push(commands.spawn(i % 3 === 0 ? defaulted : i % 3 === 1 ? made : [score, label])({ [score]: i })); }); -const addLiterals = System.create({ name: "commandsTest/addLiterals", components: { has: [score], not: [health] } }, (row, { commands }) => { +const addLiterals = System.create({ components: { with: [score], not: [health] } }, (row, { commands }) => { commands.add(row.entity, [health, label], { [label]: "x", [health]: 5 }); }); -const spawnFrom = System.create({ name: "commandsTest/spawnFrom", kernel: "cpu", components: { read: [score], not: [tag, other] } }, (row, { commands }) => { +const spawnFrom = System.create({ kernel: true, components: { with: [score], not: [tag, other] } }, (row, { commands }) => { commands.spawn(made)({ [score]: row[score] + 10 }); }); -const mark = System.create({ name: "commandsTest/mark", kernel: "cpu", components: { has: [tag], not: [other] } }, (row, { commands }) => { +const mark = System.create({ kernel: true, components: { with: [tag], not: [other] } }, (row, { commands }) => { commands.add(row.entity, marks, {}); }); -const unmark = System.create({ name: "commandsTest/unmark", kernel: "cpu", components: { has: [other] } }, (row, { commands }) => { +const unmark = System.create({ kernel: true, components: { with: [other] } }, (row, { commands }) => { commands.remove(row.entity, marks); }); -const defaulted = Archetype.create([score, label, tag]); +const defaulted = [score, label, tag] as const; /** A world, and a function running `body` once in a system with the world's commands, rejecting with what it threw. */ const withCommands = () => { const world = createWorld({ [workers]: 0 }); let body: (commands: Commands) => void = () => {}; - const issue = System.create({ name: "commandsTest/issue" }, ({ commands }) => body(commands)); + const issue = System.create({}, ({ commands }) => body(commands)); const run = async (next: (commands: Commands) => void): Promise => { body = next; const [failure] = (await runReported(world, issue)).failures; @@ -108,7 +95,7 @@ const withCommands = () => { }; describe("spawn", () => { - it("spawns exactly the components listed, through Archetype.create, plain and inline lists", async () => { + it("spawns exactly the components listed, through lists declared as const, made at runtime and inline", async () => { const { world, run } = withCommands(); const plain: readonly Essential[] = [score, tag]; const spawned: Entity[] = []; @@ -122,6 +109,60 @@ describe("spawn", () => { expect(await components(world, spawned[2])).toEqual({ [position]: [6, 6, 6], [score]: 6 }); await world.dispose(); }); + it("spawns a list made at runtime as it is at each spawn, though it changed since the last", async () => { + const { world, run } = withCommands(); + const changing: Essential[] = [score]; + const spawned: Entity[] = []; + await run(commands => { spawned.push(commands.spawn(changing)({ [score]: 1 })); }); + changing.push(tag); + await run(commands => { spawned.push(commands.spawn(changing)({ [score]: 2 })); }); + expect(await components(world, spawned[0])).toEqual({ [score]: 1 }); + expect(await components(world, spawned[1])).toEqual({ [score]: 2, [tag]: true }); + await world.dispose(); + }); + it("gives lists made at runtime on every call one index per set of components, however many are made", async () => { + const { world, run } = withCommands(); + const count = (): number => { + const value: unknown = Reflect.get(globalThis, Symbol.for("@adobe/ecs/archetypes@1")); + return typeof value === "number" ? value : 0; + }; + // Fifteen subsets of four bits, more than are kept as recent, each list made afresh at every spawn. + const spawnAll = (commands: Commands): void => { + for (let subset = 1; subset < 16; subset++) commands.spawn(bits.filter((_, bit) => (subset & (1 << bit)) !== 0))({}); + }; + await run(spawnAll); + const before = count(); + for (let round = 0; round < 20; round++) await run(spawnAll); + expect(count() - before).toBe(0); + await world.dispose(); + }); + it("fails only the systems using a key whose declaration failed, each time, never another key's", async () => { + const { world, run } = withCommands(); + await run(() => {}); + // As a module imported after the world exists records it. + const broken = Symbol("commandsTest/broken"); + recordDeclaration(broken, "component", { type: { type: "number" }, reduce: "+" }); + await run(commands => { commands.spawn([bit0])({}); }); + for (let round = 0; round < 2; round++) { + // Undeclared to the types, so spawned as an untyped caller would. + await expect(run(commands => { Reflect.apply(Reflect.apply(commands.spawn, commands, [[broken]]), undefined, [{}]); })).rejects.toThrow("only a resource is reduced"); + } + await world.dispose(); + }); + it("spawns a list made at runtime in each world that uses it", async () => { + const first = withCommands(); + const second = withCommands(); + const shared: readonly Essential[] = [score, tag]; + const spawned: Entity[] = []; + for (let round = 0; round < 2; round++) { + await first.run(commands => { spawned.push(commands.spawn(shared)({ [score]: 1 })); }); + await second.run(commands => { spawned.push(commands.spawn(shared)({ [score]: 2 })); }); + } + expect(await components(first.world, spawned[2])).toEqual({ [score]: 1, [tag]: true }); + expect(await components(second.world, spawned[3])).toEqual({ [score]: 2, [tag]: true }); + await first.world.dispose(); + await second.world.dispose(); + }); it("starts a tag or a component whose type allows null, left out or given undefined, at true or null, never leaving it out", async () => { const { world, run } = withCommands(); const spawned: Entity[] = []; @@ -166,7 +207,7 @@ describe("spawn", () => { it("spawns into more archetypes than spawns cache, each with its values", async () => { const { world, run } = withCommands(); const lists = Array.from({ length: 16 }, (_, set): (typeof score | (typeof bits)[number])[] => [score, ...bits.filter((_, bit) => (set & (1 << bit)) !== 0)]); - const archetypes = lists.map(list => Archetype.create([...list])); + const archetypes = lists.map(list => [...list] as const); const spawned: Entity[] = []; await run(commands => { for (let i = 0; i < 64; i++) spawned.push(commands.spawn(i % 2 === 0 ? archetypes[i % 16] : [...lists[i % 16]])({ [score]: i })); @@ -205,14 +246,14 @@ describe("literal values in a system's body", () => { }); /** Three adds of one component to each row, by systems ordered first to third, the second adding twice. */ -const first = System.create({ name: "commandsTest/firstAdd", kernel: "cpu", components: { has: [position] } }, (row, { commands }) => { +const first = System.create({ kernel: true, components: { with: [position] } }, (row, { commands }) => { commands.add(row.entity, score, 1); }); -const second = System.create({ name: "commandsTest/secondAdd", components: { has: [position] }, after: [first] }, (row, { commands }) => { +const second = System.create({ components: { with: [position] }, after: [first] }, (row, { commands }) => { commands.add(row.entity, score, 2); commands.add(row.entity, score, 3); }); -const third = System.create({ name: "commandsTest/thirdAdd", kernel: "cpu", components: { has: [position] }, after: [second] }, (row, { commands }) => { +const third = System.create({ kernel: true, components: { with: [position] }, after: [second] }, (row, { commands }) => { commands.add(row.entity, score, 4); }); @@ -271,9 +312,9 @@ describe("add and remove", () => { }); const added: Entity[] = []; const changed: Entity[] = []; - const watchAdded = System.create({ name: "commandsTest/watchAdded", components: { has: [score], added: [tag] } }, row => { added.push(row.entity); }); - const watchChanged = System.create({ name: "commandsTest/watchChanged", components: { has: [score], changed: [position] } }, row => { changed.push(row.entity); }); - const watch = System.create({ name: "commandsTest/watch", systems: [watchAdded, watchChanged] }); + const watchAdded = System.create({ components: { with: [score], added: [tag] } }, row => { added.push(row.entity); }); + const watchChanged = System.create({ components: { with: [score], changed: [position] } }, row => { changed.push(row.entity); }); + const watch = System.create({ systems: [watchAdded, watchChanged] }); await world.run(watch); added.length = changed.length = 0; await run(commands => { @@ -311,7 +352,7 @@ describe("spawners where code can't be generated", () => { spawned.push(commands.spawn([score, label])(values)); }); // A spawn with literal values in a system's body, which the plugin passes positionally. - const literal = System.create({ name: "commandsTest/literal" }, ({ commands }) => { + const literal = System.create({}, ({ commands }) => { spawned.push(commands.spawn([score, bit2, label])({ [label]: "b", [score]: 8 })); }); await world.run(literal); @@ -329,7 +370,7 @@ describe("spawners where code can't be generated", () => { }); describe("kernels", () => { - it("spawn, add and remove through Archetype.create bindings", async () => { + it("spawn, add and remove through lists declared as const", async () => { const { world, run } = withCommands(); await run(commands => { for (let i = 0; i < 3; i++) commands.spawn([score])({ [score]: i }); }); await world.run(spawnFrom); diff --git a/packages/ecs/src/implementation/world/composite-input.test.ts b/packages/ecs/src/implementation/world/composite-input.test.ts index c41b86b02..631c26aa5 100644 --- a/packages/ecs/src/implementation/world/composite-input.test.ts +++ b/packages/ecs/src/implementation/world/composite-input.test.ts @@ -1,38 +1,39 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; +import { giveAccess } from "../../interface/registry/ids.test-helper.js"; import { System } from "../../interface/system/system.js"; import { createWorld } from "./create.js"; import { workers } from "../../interface/resources/workers.js"; const seen: { readonly system: string; readonly input: unknown }[] = []; const move = System.create({ - name: "compositeInputTest/move", - input: { type: "object", properties: { distance: { type: "number" } } }, -}, ({ input }) => { seen.push({ system: "move", input: { ...input } }); }); +}, (_context, input: { readonly distance: number }) => { seen.push({ system: "move", input: { ...input } }); }); const step = System.create({ - name: "compositeInputTest/step", - input: { type: "object", properties: { distance: { type: "number" }, dt: { type: "number" } } }, -}, ({ input }) => { seen.push({ system: "step", input: { ...input } }); }); -const idle = System.create({ name: "compositeInputTest/idle" }, () => {}); +}, (_context, input: { readonly distance: number; readonly dt: number }) => { seen.push({ system: "step", input: { ...input } }); }); +const idle = System.create({}, () => {}); const label = System.create({ - name: "compositeInputTest/label", - input: { type: "object", properties: { distance: { type: "string" } } }, -}, ({ input }) => { void input.distance; }); +}, (_context, input: { readonly distance: string }) => { void input.distance; }); describe("composite input", () => { it("passes each member the fields of the run's input that it declares", async () => { await using world = createWorld({ [workers]: 0 }); - await world.run(System.create({ name: "compositeInputTest/frame", systems: [move, step, idle] }), { distance: 1, dt: 0.5 }); + await world.run(System.create({ systems: [move, step, idle] }), { distance: 1, dt: 0.5 }); expect(seen).toEqual([{ system: "move", input: { distance: 1 } }, { system: "step", input: { distance: 1, dt: 0.5 } }]); }); it("rejects a field two members declare with different schemas, naming both", () => { // @ts-expect-error distance is a number in one and a string in the other - expect(() => System.create({ name: "compositeInputTest/conflict", systems: [move, label] })).toThrow('Systems "compositeInputTest/move" and "compositeInputTest/label" declare input field "distance" with different schemas.'); + expect(() => System.create({ systems: [move, label] })).toThrow('Systems "move" and "label" declare input field "distance" with different schemas.'); }); - it("rejects an input that isn't an object of named fields, for untyped declarations", () => { - // @ts-expect-error input is an object schema - expect(() => System.create({ name: "compositeInputTest/scalar", input: { type: "number" } }, () => {})).toThrow('System "compositeInputTest/scalar": input is an object schema of named fields.'); + it("rejects a body taking input without the schema the plugin gives from its type, as one not written in place has", () => { + const counting = (_context: unknown, input: { readonly count: number }): void => { void input.count; }; + expect(() => System.create({}, counting)).toThrow("has no access the build inferred from its body"); + expect(() => System.create(giveAccess({}), counting)).toThrow('takes input, whose schema the plugin gives from the type of its body\'s parameter: write its body in place and build it with Compiler.plugin().'); + }); + + it("rejects an input schema that isn't an object of named fields", () => { + const create: (...parameters: unknown[]) => unknown = System.create; + expect(() => create(giveAccess({}), (_context: unknown, _input: unknown) => {}, { type: "number" })).toThrow('input is an object schema of named fields.'); }); }); diff --git a/packages/ecs/src/implementation/world/conditions.test.ts b/packages/ecs/src/implementation/world/conditions.test.ts index baeaccc41..d264d7648 100644 --- a/packages/ecs/src/implementation/world/conditions.test.ts +++ b/packages/ecs/src/implementation/world/conditions.test.ts @@ -1,7 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; -import { Component } from "../../interface/component/component.js"; -import { Resource } from "../../interface/resource/resource.js"; import { System } from "../../interface/system/system.js"; import { read, readResource, select } from "./reads.test-helper.js"; import { createWorld } from "./create.js"; @@ -31,23 +29,15 @@ declare module "@adobe/ecs" { } } -Resource.register(paused, { type: { type: "boolean", default: false } }); -Resource.register(level, { type: { anyOf: [{ type: "object", properties: { size: { type: "number" } } }, { type: "null" }] } }); -Resource.register(ticks, { type: { type: "number", default: 0 } }); -Resource.register(after, { type: { type: "number", default: 0 } }); -Resource.register(sizes, { type: { type: "number", default: 0 } }); -Resource.register(count, { type: { type: "number", default: 0 } }); -Resource.register(total, { type: { type: "number", default: 0 }, reduce: "+" }); -Component.register(value, { type: { type: "number" } }); -const pause = System.create({ name: "conditionsTest/pause", resources: { write: [paused] } }, ({ resources }) => { resources[paused] = true; }); -const tick = System.create({ name: "conditionsTest/tick", resources: { not: [paused], write: [ticks] } }, ({ resources }) => { resources[ticks] += 1; }); -const afterTick = System.create({ name: "conditionsTest/afterTick", resources: { write: [after] }, after: [tick] }, ({ resources }) => { resources[after] += 1; }); -const frame = System.create({ name: "conditionsTest/frame", systems: [tick, afterTick] }); -const grow = System.create({ name: "conditionsTest/grow", kernel: "cpu", components: { write: [value] }, resources: { not: [paused] } }, row => { +const pause = System.create({}, ({ resources }) => { resources[paused] = true; }); +const tick = System.create({ resources: { not: [paused] } }, ({ resources }) => { resources[ticks] += 1; }); +const afterTick = System.create({ after: [tick] }, ({ resources }) => { resources[after] += 1; }); +const frame = System.create({ systems: [tick, afterTick] }); +const grow = System.create({ kernel: true, components: { with: [value] }, resources: { not: [paused] } }, row => { row[value] = row[value] + 1; }); -const growWhilePaused = System.create({ name: "conditionsTest/growWhilePaused", kernel: "cpu", components: { write: [value] }, resources: { is: [paused] } }, row => { +const growWhilePaused = System.create({ kernel: true, components: { with: [value] }, resources: { is: [paused] } }, row => { row[value] = row[value] + 1; }); @@ -63,10 +53,10 @@ describe("resource conditions", () => { it("read an `is` condition as holding", async () => { await using world = createWorld({ [workers]: 0 }); - const measure = System.create({ name: "conditionsTest/measure", resources: { is: [level], write: [sizes] } }, ({ resources }) => { resources[sizes] = resources[level].size; }); + const measure = System.create({ resources: { is: [level] } }, ({ resources }) => { resources[sizes] = resources[level].size; }); await world.run(measure); expect(await readResource(world, sizes)).toBe(0); - const load = System.create({ name: "conditionsTest/load", resources: { write: [level] } }, ({ resources }) => { resources[level] = { size: 3 }; }); + const load = System.create({}, ({ resources }) => { resources[level] = { size: 3 }; }); await world.run(load); await world.run(measure); expect(await readResource(world, sizes)).toBe(3); @@ -75,12 +65,12 @@ describe("resource conditions", () => { it("keep a skipped system's lastRun, so it sees every change once it runs again", async () => { await using world = createWorld({ [workers]: 0 }); const seen: number[] = []; - const watch = System.create({ name: "conditionsTest/watch", components: { read: [value], changed: [value] }, resources: { is: [level] } }, row => { seen.push(row[value]); }); - const spawn = System.create({ name: "conditionsTest/spawn" }, ({ commands }) => { commands.spawn([value])({ [value]: 7 }); }); + const watch = System.create({ components: { with: [value], changed: [value] }, resources: { is: [level] } }, row => { seen.push(row[value]); }); + const spawn = System.create({}, ({ commands }) => { commands.spawn([value])({ [value]: 7 }); }); await world.run(spawn); await world.run(watch); expect(seen).toEqual([]); - await world.run(System.create({ name: "conditionsTest/ready", resources: { write: [level] } }, ({ resources }) => { resources[level] = { size: 1 }; })); + await world.run(System.create({}, ({ resources }) => { resources[level] = { size: 1 }; })); await world.run(watch); expect(seen).toEqual([7]); }); @@ -88,7 +78,7 @@ describe("resource conditions", () => { it("leave a reduced resource as it was when every contributor is skipped", async () => { await using world = createWorld({ [workers]: 0 }); let next = 5; - const add = System.create({ name: "conditionsTest/add", reduce: { [total]: "+" }, resources: { not: [paused] } }, ({ reduce }) => { reduce[total](next++); }); + const add = System.create({ resources: { not: [paused] } }, ({ reduce }) => { reduce[total](next++); }); await world.run(add); expect(await readResource(world, total)).toBe(5); await world.run(pause); @@ -98,15 +88,13 @@ describe("resource conditions", () => { it("reject conditions that are never null or false, or listed twice, for untyped declarations, when declared", () => { // @ts-expect-error a number is never null or false - expect(() => System.create({ name: "conditionsTest/number", resources: { is: [count] } }, () => {})).toThrow("which is never null or false"); + expect(() => System.create({ resources: { is: [count] } }, () => {})).toThrow("which is never null or false"); // @ts-expect-error both is and not - expect(() => System.create({ name: "conditionsTest/both", resources: { is: [paused], not: [paused] } }, () => {})).toThrow("in both resources is and not"); - // @ts-expect-error is already makes it readable - expect(() => System.create({ name: "conditionsTest/read", resources: { is: [level], read: [level] } }, ({ resources }) => { void resources[level]; })).toThrow("is already makes it readable"); + expect(() => System.create({ resources: { is: [paused], not: [paused] } }, () => {})).toThrow("in both resources is and not"); }); it("skip a kernel the same way", async () => { await using world = createWorld({ [workers]: 0 }); - await world.run(System.create({ name: "conditionsTest/seed" }, ({ commands }) => { commands.spawn([value])({ [value]: 1 }); })); + await world.run(System.create({}, ({ commands }) => { commands.spawn([value])({ [value]: 1 }); })); await world.run(grow); await world.run(pause); await world.run(grow); @@ -115,7 +103,7 @@ describe("resource conditions", () => { }); it("run a kernel only while its `is` condition holds", async () => { await using world = createWorld({ [workers]: 0 }); - await world.run(System.create({ name: "conditionsTest/seed" }, ({ commands }) => { commands.spawn([value])({ [value]: 1 }); })); + await world.run(System.create({}, ({ commands }) => { commands.spawn([value])({ [value]: 1 }); })); await world.run(growWhilePaused); await world.run(pause); await world.run(growWhilePaused); diff --git a/packages/ecs/src/implementation/world/constant-unions.test.ts b/packages/ecs/src/implementation/world/constant-unions.test.ts new file mode 100644 index 000000000..26faae78b --- /dev/null +++ b/packages/ecs/src/implementation/world/constant-unions.test.ts @@ -0,0 +1,43 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { expect, it } from "vitest"; +import { System } from "../../interface/system/system.js"; +import { workers } from "../../interface/resources/workers.js"; +import { createWorld } from "./create.js"; +import { readEach, select } from "./reads.test-helper.js"; +import { registerResource } from "../../interface/registry/declarations.js"; +import { defaultOf } from "./state.js"; + +const Frame = { fixed: 0, autoHeight: 1, autoWidth: 2 } as const; +type Frame = (typeof Frame)[keyof typeof Frame]; + +const frame = Symbol("constantUnionsTest/frame"); +const shown = Symbol("constantUnionsTest/shown"); +declare module "@adobe/ecs" { + export interface Components { + [frame]: { type: Frame }; + [shown]: { type: { text: string } | null; thread: "main" }; + } +} + +const spawn = System.create({}, ({ commands }) => { + commands.spawn([frame, shown])({ [frame]: Frame.autoWidth, [shown]: null }); +}); +// Reads the union beside an object of the main thread: the union is a number in shared memory, on no thread. +const show = System.create({ components: { with: [frame, shown] } }, row => { + row[shown] = { text: `frame ${row[frame]}` }; +}); + +it("stores a union of number constants as a number in shared memory, read beside another thread's objects", async () => { + await using world = createWorld({ [workers]: 0 }); + await world.run(spawn); + await world.run(show); + expect(await readEach(world, await select(world, { required: [frame] }), frame)).toEqual([2]); +}); + +// Registered by hand without a default, as only code the build didn't generate can. +const mode = Symbol("constantUnionsTest/mode"); +registerResource(mode, { type: { anyOf: [{ const: 1 }, { const: 2 }, { const: 4 }] } }); + +it("starts a resource of a union of number constants without a default at its first constant, a value of its type", () => { + expect(defaultOf(mode)).toBe(1); +}); diff --git a/packages/ecs/src/implementation/world/create.test.ts b/packages/ecs/src/implementation/world/create.test.ts index 113efae0b..0361923ab 100644 --- a/packages/ecs/src/implementation/world/create.test.ts +++ b/packages/ecs/src/implementation/world/create.test.ts @@ -1,9 +1,8 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; +import { giveAccess } from "../../interface/registry/ids.test-helper.js"; import { identify } from "../../interface/registry/ids.test-helper.js"; -import { Component } from "../../interface/component/component.js"; import { Entity } from "../../interface/entity/entity.js"; -import { Resource } from "../../interface/resource/resource.js"; import { gpuDevice } from "../../interface/resources/gpu-device.js"; import { System } from "../../interface/system/system.js"; import { count, read, readEach, readEntity, readResource, select } from "./reads.test-helper.js"; @@ -12,6 +11,7 @@ import { attachKernel } from "./attach-kernel.js"; import { createWorld } from "../index.js"; import { fillReports, reportOf, runReported } from "./reports.test-helper.js"; import { Report } from "../../interface/report/report.js"; +import type { Schema } from "../../interface/schema/schema.js"; type Vec2 = readonly [number, number]; @@ -32,29 +32,20 @@ declare module "@adobe/ecs" { [marker]: { type: number }; } export interface Resources { - [spawnCount]: { type: number }; - [seen]: { type: number }; + [spawnCount]: { type: Schema.Type<{ type: "integer" }>; default: 3 }; + [seen]: { type: Schema.Type<{ type: "integer" }>; default: 0 }; } } -Component.register(position, { type: { type: "array", items: { type: "number" }, minItems: 2, maxItems: 2 } }); -Component.register(velocity, { type: { type: "number" } }); -Component.register(label, { type: { type: "string" } }); -Component.register(frozen, { type: { const: true } }); -Component.register(marker, { type: { type: "number" } }); -Resource.register(spawnCount, { type: { type: "integer", default: 3 } }); -Resource.register(seen, { type: { type: "integer", default: 0 } }); const spawn = System.create({ - name: "createTest/spawn", - resources: { read: [spawnCount], changed: [spawnCount] }, + resources: { changed: [spawnCount] }, }, ({ resources, commands }) => { for (let i = 0; i < resources[spawnCount]; i++) commands.spawn([position, velocity, label])({ [position]: [i, 0], [velocity]: 1, [label]: `e${i}` }); }); const move = System.create({ - name: "createTest/move", - components: { read: [velocity], write: [position], not: [frozen] }, + components: { with: [velocity, position], not: [frozen] }, after: [spawn], }, row => { const [x, y] = row[position]; @@ -63,9 +54,7 @@ const move = System.create({ /** Counts rows whose position may have changed since it last ran. */ const watch = System.create({ - name: "createTest/watch", components: { changed: [position] }, - resources: { write: [seen] }, after: [move], }, (_, { resources }) => { resources[seen] += 1; @@ -73,11 +62,11 @@ const watch = System.create({ /** A kernel whose module can't be imported. */ const missing = attachKernel( - System.create({ name: "createTest/missing", kernel: "cpu", components: { has: [velocity] } }, () => {}), + System.create({ kernel: true, components: { with: [velocity] } }, () => {}), { columns: [], shapes: [], resources: [], lookups: [], keys: [], spawns: [], structuredValues: [], url: "/@missing/kernel.js", program: null, reductions: [], reducedShapes: [] }, ); -const main = System.create({ name: "createTest/main", systems: [spawn, move, watch] }); +const main = System.create({ systems: [spawn, move, watch] }); const positions = async (world: World): Promise => (await readEach(world, await select(world, { required: [position], order: { [position]: true } }), position)).map(value => value ?? [NaN, NaN]); @@ -110,7 +99,7 @@ describe("World.create", () => { it("applies the net structural change of a sync point", async () => { let first = Entity.none; let second = Entity.none; - const edit = System.create({ name: "createTest/edit/net", input: { type: "object", properties: { action: { type: "string" } } } }, ({ input, commands }) => { + const edit = System.create({}, ({ commands }, input: { readonly action: string }) => { if (input.action === "spawn") { first = commands.spawn([velocity])({ [velocity]: 1 }); second = commands.spawn([velocity])({ [velocity]: 2 }); @@ -134,7 +123,7 @@ describe("World.create", () => { it("reuses a despawned index only after a sync point, with a new generation", async () => { const spawned: Entity[] = []; - const edit = System.create({ name: "createTest/edit/reuse", input: { type: "object", properties: { action: { type: "string" } } } }, ({ input, commands }) => { + const edit = System.create({}, ({ commands }, input: { readonly action: string }) => { if (input.action === "spawn") spawned.push(commands.spawn([velocity])({ [velocity]: spawned.length })); else for (const entity of spawned) commands.despawn(entity); }); @@ -153,7 +142,7 @@ describe("World.create", () => { it("keeps rows consistent across swap-removes and archetype growth", async () => { const spawned: Entity[] = []; - const edit = System.create({ name: "createTest/edit/rows", input: { type: "object", properties: { action: { type: "string" } } } }, ({ input, commands }) => { + const edit = System.create({}, ({ commands }, input: { readonly action: string }) => { if (input.action === "spawn") for (let i = 0; i < 100; i++) spawned.push(commands.spawn([velocity, label])({ [velocity]: i, [label]: `${i}` })); else for (let i = 0; i < 100; i += 3) commands.remove(spawned[i], label); }); @@ -169,11 +158,11 @@ describe("World.create", () => { it("reads a component the world had no id for at an earlier read, once it has one", async () => { await using world = createWorld(); - const spawnPlain = System.create({ name: "createTest/spawnPlain" }, ({ commands }) => { commands.spawn([velocity])({ [velocity]: 1 }); }); + const spawnPlain = System.create({}, ({ commands }) => { commands.spawn([velocity])({ [velocity]: 1 }); }); await world.run(spawnPlain); const [entity] = await select(world, { required: [velocity] }); expect(await read(world, entity, frozen)).toBe(null); - const freeze = System.create({ name: "createTest/freeze", input: { type: "object", properties: { entity: Entity.schema } } }, ({ input, commands }) => { + const freeze = System.create({}, ({ commands }, input: { readonly entity: Entity }) => { commands.add(input.entity, frozen, true); }); await world.run(freeze, { entity }); @@ -181,7 +170,7 @@ describe("World.create", () => { }); it("hides the resource entity from queries and commands", async () => { - const edit = System.create({ name: "createTest/edit/hidden", components: {} }, (row, { commands }) => { + const edit = System.create({ components: {} }, (row, { commands }) => { commands.despawn(row.entity); }); const world = createWorld(); @@ -191,14 +180,15 @@ describe("World.create", () => { }); it("throws at creating a composite with an unordered conflict, naming nothing", () => { - const push = System.create({ name: "createTest/push", components: { write: [position] } }, row => { row[position] = [0, 0]; }); - expect(() => System.create({ name: "createTest/conflicted", systems: [move, push] })).toThrow('"createTest/move" and "createTest/push"'); - expect(() => System.create({ name: "createTest/conflicted", systems: [move] })).not.toThrow(); + const push = System.create({ components: { with: [position] } }, row => { row[position] = [0, 0]; }); + expect(() => System.create({ systems: [move, push] })).toThrow('"move" and "push"'); + expect(() => System.create({ systems: [move] })).not.toThrow(); }); - it("gives a body's context only the kinds its declaration declares", async () => { + it("gives a body's context only the kinds its body uses", async () => { let keys: string[] = []; - const probe = System.create({ name: "createTest/keys", resources: { read: [seen] } }, context => { + // It passes its context on, so the build can't follow it, and it's given what it reads. + const probe = System.create(giveAccess({}, { resources: { read: [seen] } }), context => { keys = Object.keys(context); void context.resources[seen]; }); @@ -207,26 +197,20 @@ describe("World.create", () => { expect(keys).toEqual(["resources", "commands"]); }); - it("rejects a key listed in both read and write, since write already makes it readable", () => { - // @ts-expect-error write already makes it readable - expect(() => System.create({ name: "createTest/both", components: { read: [velocity], write: [velocity] } }, row => { row[velocity] += 1; })) - .toThrow(`lists "createTest/velocity" in both components read and write`); - }); - it("reports a system that throws, still applying its commands", async () => { - const fail = System.create({ name: "createTest/fail" }, ({ commands }) => { + const fail = System.create({}, ({ commands }) => { commands.spawn([velocity])({ [velocity]: 1 }); throw new Error("boom"); }); const world = createWorld(); const [failure] = (await runReported(world, fail)).failures; - expect([failure.kind, failure.name, failure.message]).toEqual(["system", "createTest/fail", "boom"]); + expect([failure.kind, failure.name, failure.message]).toEqual(["system", "fail", "boom"]); expect(failure.stack).toContain("boom"); expect(await count(world, { required: [velocity] })).toBe(1); }); it("gives every run a report, with no errors for runs with nothing to report", async () => { - const quiet = System.create({ name: "createTest/quiet" }, () => {}); + const quiet = System.create({}, () => {}); await using world = createWorld(); const first = await world.run(quiet); expect(await world.run(quiet)).toBe(first + 1); @@ -236,35 +220,34 @@ describe("World.create", () => { }); it("reports what isn't an Error as text, without a stack", async () => { - const fail = System.create({ name: "createTest/failText" }, () => { + const fail = System.create({}, () => { // A value that isn't an Error, which user code may throw. throw { code: 7 }; }); await using world = createWorld(); - expect((await runReported(world, fail)).failures).toEqual([{ kind: "system", name: "createTest/failText", message: "[object Object]", stack: null }]); + expect((await runReported(world, fail)).failures).toEqual([{ kind: "system", name: "fail", id: null, message: "[object Object]", stack: null }]); }); it("reports the systems a failed tier skips, and the commands that did nothing", async () => { - const doomed = System.create({ name: "createTest/doomed" }, ({ commands }) => { + const doomed = System.create({}, ({ commands }) => { const entity = commands.spawn([velocity])({ [velocity]: 1 }); commands.despawn(entity); commands.add(entity, frozen, true); commands.despawn(entity); }); - const fails = System.create({ name: "createTest/failsFirst" }, () => { throw new Error("first"); }); - const later = System.create({ name: "createTest/later", after: [fails] }, () => {}); + const fails = System.create({}, () => { throw new Error("first"); }); + const later = System.create({ after: [fails] }, () => {}); await using world = createWorld(); expect(await runReported(world, doomed)).toEqual({ failures: [], skipped: [], dropped: 2, warnings: [] }); - const report = await runReported(world, System.create({ name: "createTest/failedTier", systems: [fails, later] })); - expect(report.skipped).toEqual(["createTest/later"]); - expect(report.failures.map(failure => failure.name)).toEqual(["createTest/failsFirst"]); + const report = await runReported(world, System.create({ systems: [fails, later] })); + expect(report.skipped).toEqual(["later"]); + expect(report.failures.map(failure => failure.name)).toEqual(["fails"]); }); it("skips every row when a system's only filter is an unchanged resource", async () => { const count = System.create({ - name: "createTest/count", - components: { has: [velocity] }, - resources: { write: [seen], changed: [spawnCount] }, + components: { with: [velocity] }, + resources: { changed: [spawnCount] }, }, (_, { resources }) => { resources[seen] += 1; }); @@ -278,7 +261,7 @@ describe("World.create", () => { it("doesn't let a lookup match the resource entity", async () => { let found: object | null | undefined; - const probe = System.create({ name: "createTest/probe", lookup: { any: {} } }, ({ lookup: { any } }) => { + const probe = System.create({ lookup: { any: {} } }, ({ lookup: { any } }) => { // The resource entity is entity 1, since index 0 is never an entity and registration reserves the next first. // @ts-expect-error an untyped caller can pass any number found = any(1); @@ -297,15 +280,15 @@ describe("World.create", () => { it("sees a change moved into an older chunk by a swap-remove", async () => { const spawned: Entity[] = []; - const edit = System.create({ name: "createTest/edit/swap", input: { type: "object", properties: { action: { type: "string" } } } }, ({ input, commands }) => { + const edit = System.create({}, ({ commands }, input: { readonly action: string }) => { if (input.action === "spawn") for (let i = 0; i < 2100; i++) spawned.push(commands.spawn([velocity])({ [velocity]: i })); else commands.despawn(spawned[5]); }); - const writer = System.create({ name: "createTest/writer", components: { write: [velocity] } }, row => { + const writer = System.create({ components: { with: [velocity] } }, row => { if (row[velocity] === 2099) row[velocity] = -1; }); const changes: number[] = []; - const observer = System.create({ name: "createTest/observer", components: { read: [velocity], changed: [velocity] } }, row => { + const observer = System.create({ components: { with: [velocity], changed: [velocity] } }, row => { changes.push(row[velocity]); }); const world = createWorld(); @@ -320,7 +303,7 @@ describe("World.create", () => { }); it("selects with where and order", async () => { - const edit = System.create({ name: "createTest/edit/select" }, ({ commands }) => { + const edit = System.create({}, ({ commands }) => { for (let i = 0; i < 10; i++) commands.spawn([position, velocity])({ [position]: [i % 3, i], [velocity]: i }); }); const world = createWorld(); @@ -337,7 +320,7 @@ describe("World.create", () => { it("spawns components named by symbols", async () => { let spawned = Entity.none; - const edit = System.create({ name: "createTest/edit/symbols" }, ({ commands }) => { + const edit = System.create({}, ({ commands }) => { spawned = commands.spawn([velocity, marker])({ [velocity]: 2, [marker]: 7 }); }); const world = createWorld(); @@ -362,14 +345,14 @@ describe("World.create", () => { await using world = createWorld(); expect(await readResource(world, gpuDevice)).toBeNull(); let seen: unknown = undefined; - await world.run(System.create({ name: "createTest/readDevice", resources: { read: [gpuDevice] } }, ({ resources }) => { seen = resources[gpuDevice]; })); + await world.run(System.create({}, ({ resources }) => { seen = resources[gpuDevice]; })); expect(seen).toBeNull(); }); it("accepts a system declared again the same under its id, as a module evaluated again declares it, and rejects a different one", async () => { - const first = System.create(identify({ name: "createTest/named", resources: { write: [seen] } }, "createTest/systems.ts#named"), ({ resources }) => { resources[seen] = 1; }); - const again = System.create(identify({ name: "createTest/named", resources: { write: [seen] } }, "createTest/systems.ts#named"), ({ resources }) => { resources[seen] = 2; }); - expect(() => System.create(identify({ name: "createTest/named" }, "createTest/systems.ts#named"), () => {})).toThrow('Cannot declare "createTest/named" (createTest/systems.ts#named): its id is another item\'s, with another declaration; if a module was evaluated again with a changed declaration, reload the page.'); + const first = System.create(identify({ }, "createTest/systems.ts#named"), ({ resources }) => { resources[seen] = 1; }); + const again = System.create(identify({ }, "createTest/systems.ts#named"), ({ resources }) => { resources[seen] = 2; }); + expect(() => System.create(identify({}, "createTest/systems.ts#named"), () => {})).toThrow('Cannot declare "named" (createTest/systems.ts#named): its id is another item\'s, with another declaration; if a module was evaluated again with a changed declaration, reload the page.'); await using world = createWorld(); await world.run(first); await world.run(again); diff --git a/packages/ecs/src/implementation/world/create.ts b/packages/ecs/src/implementation/world/create.ts index 486bc1580..bd4b28a18 100644 --- a/packages/ecs/src/implementation/world/create.ts +++ b/packages/ecs/src/implementation/world/create.ts @@ -49,7 +49,7 @@ import { createState, defaultOf, idOf, reside, startTicking, type Key } from './ import { indexOf } from './entities.js'; import { isHeld } from './transfers.js'; import type { ReadonlyValue as ReadonlyResourceValue } from '../../interface/resource/value.js'; -import { labelOf, labelOfItem } from '../../interface/registry/index.js'; +import { labelOf, whyUnregistered } from '../../interface/registry/index.js'; type Queue = (task: () => T | Promise) => Promise; @@ -148,7 +148,7 @@ export const createWorld = (resources: InitialResources = {}, options: WorldOpti const initial = new Map(); // String keys are never a resource's, so they're ignored, as commands ignore them. for (const key of Object.getOwnPropertySymbols(resources)) { - if (kindOf(key) !== "resource") throw new Error(`"${labelOf(key)}" isn't a registered resource.`); + if (kindOf(key) !== "resource") throw new Error(`"${labelOf(key)}" isn't a registered resource: ${whyUnregistered(key)}.`); if (isWorldWritten(key)) throw new Error(`"${labelOf(key)}" is written only by the world.`); initial.set(key, Reflect.get(resources, key)); } @@ -178,6 +178,8 @@ export const createWorld = (resources: InitialResources = {}, options: WorldOpti let disposed = false; const ensured = { version: -1 }; + const minimumChunk = options.minimumChunk ?? defaultMinimumChunk; + /** Starts the pool with as many of the wanted workers as the platform has, or none: then kernels run here. */ const startPool = (): Pool | null => { const ports: WorkerPort[] = []; @@ -187,11 +189,15 @@ export const createWorld = (resources: InitialResources = {}, options: WorldOpti ports.push(port); } workerCount = state.workers = ports.length; - if (ports.length === 0) return null; + if (ports.length === 0) { + followPoolThreshold(); + return null; + } const started = createPool(ports, state.memory); pool = started; - context.workers = { pool: started, minimumChunk: options.minimumChunk ?? defaultMinimumChunk, next: 0 }; + context.workers = { pool: started, minimumChunk, next: 0 }; context.publish = createPublisher(state, started); + followPoolThreshold(); return started; }; @@ -208,9 +214,50 @@ export const createWorld = (resources: InitialResources = {}, options: WorldOpti return loadAll(runners); }; - /** Gives workers every kernel whose main-thread load is newer than theirs, including ones a failed registration reloaded. */ + /** Whether kernels failed to load on the workers a run started: then they run here, and no pool starts again. */ + let workersFailed = false; + + /** Whether an archetype a kernel matches has rows enough for workers, as kernels loading look before the pool starts. */ + const kernelsWarrant = (): boolean => { + for (const runner of registrar.kernels()) for (const archetype of runner.matched()) if (archetype.count >= state.poolThreshold) return true; + return false; + }; + + /** Lets kernels running here want the pool, at half a piece, while one could start; once started, none want it. */ + const followPoolThreshold = (): void => { + state.poolThreshold = pool === null && workerCount > 0 && !workersFailed ? minimumChunk / 2 : Infinity; + state.poolWanted = false; + }; + followPoolThreshold(); + + /** + * Starts the pool after a run whose kernel ran here over rows enough for workers. Kernels failing to load on it are a `"thread"` failure in the run's + * report, and the pool ends, so kernels run here from then on rather than every run trying again. + */ + const startWorkers = async (): Promise => { + try { + await loadOnWorkers(); + } + catch (error) { + workersFailed = true; + workersBehind = false; + const failed = pool; + pool = null; + context.workers = null; + context.publish = () => {}; + for (const runner of registrar.kernels()) runner.kernel?.unloadedOnWorkers(); + if (failed !== null) void failed.terminate(); + (observerFailures ??= []).push(failureOf("thread", { name: "workers", id: null }, error)); + } + followPoolThreshold(); + }; + + /** + * Gives workers every kernel whose main-thread load is newer than theirs, including ones a failed registration + * reloaded, starting the pool once a kernel ran here over rows enough for workers. + */ const loadOnWorkers = async (): Promise => { - if (workerCount === 0 || !workersBehind) return; + if (workerCount === 0 || !workersBehind || (pool === null && !state.poolWanted && !kernelsWarrant())) return; const behind = registrar.kernels().filter(runner => runner.kernel?.staleOnWorkers() === true); if (behind.length === 0) { workersBehind = false; @@ -333,6 +380,7 @@ export const createWorld = (resources: InitialResources = {}, options: WorldOpti const wanted = platform === null ? 0 : workersOf(platform, readResource(workers)); if (wanted === workerCount) return; workerCount = wanted; + workersFailed = false; state.workers = wanted; const old = pool; pool = null; @@ -340,6 +388,7 @@ export const createWorld = (resources: InitialResources = {}, options: WorldOpti context.publish = () => {}; for (const runner of registrar.kernels()) runner.kernel?.unloadedOnWorkers(); workersBehind = true; + followPoolThreshold(); // The old pool releases its control state once its workers have left their windows; runs don't wait for it. if (old !== null) void old.terminate(); await loadOnWorkers(); @@ -431,10 +480,10 @@ export const createWorld = (resources: InitialResources = {}, options: WorldOpti if (workersChanged()) await followWorkers(); if (deviceChanged()) await followDevice(); const schedule = registrar.registered(definition) ?? registered; - if (schedule === undefined) throw new Error(`System "${labelOfItem(definition)}" didn't register.`); + if (schedule === undefined) throw new Error(`System "${definition.name}" didn't register.`); // An input that moved to a joined thread runs only there: that thread went since, its claims with it. if (isHeld(value) && (definition.kind === "composite" || registrar.runnerOf(definition).remote?.channel !== value.channel)) { - throw new Error(`System "${labelOfItem(definition)}": the run's input moved to a joined thread that's gone.`); + throw new Error(`System "${definition.name}": the run's input moved to a joined thread that's gone.`); } const run = ++runs; const started = performance.now(); @@ -458,6 +507,8 @@ export const createWorld = (resources: InitialResources = {}, options: WorldOpti context.tails = null; if (deviceChanged()) await followDevice(); } + // A kernel that ran here over rows enough for workers asked for the pool, which the next run's kernels use. + if (state.poolWanted && workersBehind) await startWorkers(); const values = valuesOf(run, started, report.dropped); if (!pass) { tailing = tails; @@ -526,7 +577,7 @@ export const createWorld = (resources: InitialResources = {}, options: WorldOpti await complete(registrar.register(system)); } catch (error) { - (observerFailures ??= []).push(failureOf("system", labelOfItem(system), error)); + (observerFailures ??= []).push(failureOf("system", system, error)); } }); }; @@ -604,7 +655,7 @@ export const createWorld = (resources: InitialResources = {}, options: WorldOpti } startRan(state.ran); values = valuesOf(run, performance.now(), 0); - report = { failures: [failureOf("system", labelOfItem(composite), error)], skipped: none, dropped: 0, warnings: none }; + report = { failures: [failureOf("system", composite, error)], skipped: none, dropped: 0, warnings: none }; } report = withFailures(report, takeFailures()); const tails = tailing; diff --git a/packages/ecs/src/implementation/world/defaults.test.ts b/packages/ecs/src/implementation/world/defaults.test.ts new file mode 100644 index 000000000..8b6c4c9aa --- /dev/null +++ b/packages/ecs/src/implementation/world/defaults.test.ts @@ -0,0 +1,86 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { describe, expect, it } from "vitest"; +import { Rule } from "../../interface/rule/rule.js"; +import { System } from "../../interface/system/system.js"; +import { createWorld } from "../index.js"; +import { readResource } from "./reads.test-helper.js"; +import { Entity, F32, I32, Mat4x4, Quat, U32, Vec2, Vec3, Vec4 } from "../../index.js"; + +const pool = Symbol(); +const names = Symbol(); +const count = Symbol(); +const tagged = Symbol(); +const index = Symbol(); +const turn = Symbol(); +const least = Symbol(); +const all = Symbol(); + +/** A pool declared through an alias of ReadonlyMap. */ +type Pool = ReadonlyMap; + +declare module "@adobe/ecs" { + export interface Components { + [tagged]: { type: true }; + [index]: { type: ReadonlyMap; derived: true }; + } + export interface Resources { + [pool]: { type: Pool }; + [names]: { type: ReadonlyMap }; + [count]: { type: number }; + [turn]: { type: Quat }; + [least]: { type: F32; reduce: "min" }; + [all]: { type: boolean; reduce: "&" }; + } +} + +Rule.create({ components: { with: [tagged] }, derive: [index] }); + +describe("a resource declared without a default", () => { + it("starts at its type's zero value, and a ReadonlyMap or ReadonlySet at an empty one of its world's own", async () => { + await using first = createWorld(); + await using second = createWorld(); + const [firstPool, secondPool] = [await readResource(first, pool), await readResource(second, pool)]; + expect(firstPool).toEqual(new Map()); + expect(secondPool).toEqual(new Map()); + expect(firstPool).not.toBe(secondPool); + expect(await readResource(first, pool)).toBe(firstPool); + expect(await readResource(first, names)).toEqual(new Map()); + expect(await readResource(first, count)).toBe(0); + }); +}); + +describe("a derived component declared without a default", () => { + it("starts every row of a world at one empty Map of its own, since values are replaced whole", async () => { + const spawn = System.create(({ commands }) => { + commands.spawn([tagged])({}); + commands.spawn([tagged])({}); + }); + const seen: unknown[] = []; + const read = System.create({ observer: true, components: { with: [index] } }, row => { seen.push(row[index]); }); + await using first = createWorld(); + await using second = createWorld(); + for (const world of [first, second]) { + await world.run(spawn); + await world.run(read); + } + expect(seen).toHaveLength(4); + expect(seen[0]).toEqual(new Map()); + expect(seen[1]).toBe(seen[0]); + expect(seen[3]).toBe(seen[2]); + expect(seen[2]).not.toBe(seen[0]); + }); +}); + +describe("the package's types' defaults", () => { + it("are zeros, identities, and none for an entity", () => { + expect([F32.default, I32.default, U32.default, Vec2.default, Vec3.default, Vec4.default, Vec3.I32.default, Vec4.U32.default, Entity.default]) + .toEqual([0, 0, 0, [0, 0], [0, 0, 0], [0, 0, 0, 0], [0, 0, 0], [0, 0, 0, 0], Entity.none]); + expect([Quat.default, Mat4x4.default]).toEqual([Quat.identity, Mat4x4.identity]); + }); + it("start a world's resources, and a reduction its operator's identity", async () => { + await using world = createWorld(); + expect(await readResource(world, turn)).toEqual([0, 0, 0, 1]); + expect(await readResource(world, least)).toBe(Infinity); + expect(await readResource(world, all)).toBe(true); + }); +}); diff --git a/packages/ecs/src/implementation/world/directory.test.ts b/packages/ecs/src/implementation/world/directory.test.ts index e1c2d6205..f2167f4c2 100644 --- a/packages/ecs/src/implementation/world/directory.test.ts +++ b/packages/ecs/src/implementation/world/directory.test.ts @@ -1,6 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; -import { Resource } from "../../interface/resource/resource.js"; import { indexOf, reserve } from "./entities.js"; import { recordFlags, recordStorages, recordWords, resourceEntityWord, resourceFlag, storageColumn, storageComponent, storageWords, structureWord, tableOffsetWord } from "./layout-directory.js"; import { ensureResources } from "./resources.js"; @@ -10,10 +9,9 @@ import { applySync, prepareSync } from "./synchronize.js"; const gravity = Symbol("directoryTest/gravity"); declare module "@adobe/ecs" { export interface Resources { - [gravity]: { type: number }; + [gravity]: { type: number; default: -9.8 }; } } -Resource.register(gravity, { type: { type: "number", default: -9.8 } }); describe("the layout directory", () => { it("keeps the structure epoch odd from a sync point's start until its directory writes, and even otherwise, once a thread may read it", () => { diff --git a/packages/ecs/src/implementation/world/dispatch.fixture.ts b/packages/ecs/src/implementation/world/dispatch.fixture.ts index ec892fd73..0532e7311 100644 --- a/packages/ecs/src/implementation/world/dispatch.fixture.ts +++ b/packages/ecs/src/implementation/world/dispatch.fixture.ts @@ -1,11 +1,11 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Component } from "../../interface/component/component.js"; import type { Schema } from "../../interface/schema/schema.js"; import { System } from "../../interface/system/system.js"; import { readEach, select } from "./reads.test-helper.js"; import { createWorld } from "./create.js"; import { workers } from "../../interface/resources/workers.js"; import type { World } from "../../interface/world/world.js"; +import type { F32 } from "../../interface/math/index.js"; /** Scenarios of dispatching tasks to workers, run built in node and from source in chromium. */ @@ -24,45 +24,35 @@ const tagG = Symbol("dispatchTest/g"); const tags = [tagA, tagB, tagC, tagD, tagE, tagF, tagG] as const; declare module "@adobe/ecs" { export interface Components { - [position]: { type: readonly [number, number] }; - [velocity]: { type: readonly [number, number] }; - [offset]: { type: number }; + [position]: { type: Schema.Type<{ type: "array"; items: { type: "number"; precision: 1 }; minItems: 2; maxItems: 2 }> }; + [velocity]: { type: Schema.Type<{ type: "array"; items: { type: "number"; precision: 1 }; minItems: 2; maxItems: 2 }> }; + [offset]: { type: F32 }; [tagA]: { type: true }; [tagB]: { type: true }; [tagC]: { type: true }; [tagD]: { type: true }; [tagE]: { type: true }; [tagF]: { type: true }; [tagG]: { type: true }; } } -Component.register(position, { type: vec2 }); -Component.register(velocity, { type: vec2 }); -Component.register(offset, { type: { type: "number", precision: 1 } }); -Component.register(tagA, { type: { const: true } }); -Component.register(tagB, { type: { const: true } }); -Component.register(tagC, { type: { const: true } }); -Component.register(tagD, { type: { const: true } }); -Component.register(tagE, { type: { const: true } }); -Component.register(tagF, { type: { const: true } }); -Component.register(tagG, { type: { const: true } }); -const scatter = System.create({ name: "dispatchTest/scatter" }, ({ commands }) => { +const scatter = System.create({}, ({ commands }) => { for (let i = 0; i < 128 * 64; i++) { const components: (typeof position | typeof velocity | typeof offset | (typeof tags)[number])[] = [position, velocity, offset]; tags.forEach((tag, bit) => { if (((i >> 6) & (1 << bit)) !== 0) components.push(tag); }); commands.spawn(components)({ [position]: [i, 0], [velocity]: [1, i % 5], [offset]: 0 }); } }); -const seed = System.create({ name: "dispatchTest/seed" }, ({ commands }) => { +const seed = System.create({}, ({ commands }) => { for (let i = 0; i < 4096; i++) commands.spawn([position, velocity, offset])({ [position]: [i, 0], [velocity]: [1, i % 5], [offset]: 0 }); }); -const fastA = System.create({ name: "dispatchTest/fastA", kernel: "cpu", components: { read: [velocity], write: [position] } }, row => { +const fastA = System.create({ kernel: true, components: { with: [velocity, position] } }, row => { const [x, y] = row[position]; const [vx, vy] = row[velocity]; row[position] = [x + vx, y + vy]; }); // Commands make it a JS kernel, dispatched by message. -const slowB = System.create({ name: "dispatchTest/slowB", kernel: "cpu", components: { read: [velocity] } }, (row, { commands }) => { +const slowB = System.create({ kernel: true, components: { with: [velocity] } }, (row, { commands }) => { const [vx] = row[velocity]; if (vx > 100) commands.despawn(row.entity); }); -const fastC = System.create({ name: "dispatchTest/fastC", kernel: "cpu", components: { read: [velocity], write: [offset] } }, row => { +const fastC = System.create({ kernel: true, components: { with: [velocity, offset] } }, row => { const [vx, vy] = row[velocity]; row[offset] = vx + vy; }); @@ -70,15 +60,15 @@ const fastC = System.create({ name: "dispatchTest/fastC", kernel: "cpu", compone const withTimeout = (promise: Promise, ms: number): Promise => Promise.race([promise, new Promise<"timeout">(resolve => setTimeout(() => resolve("timeout"), ms))]); -const grow = System.create({ name: "dispatchTest/seed/2" }, ({ commands }) => { +const grow = System.create({}, ({ commands }) => { for (let i = 0; i < 3000; i++) commands.spawn([position, velocity, offset])({ [position]: [i, 1], [velocity]: [2, i % 3], [offset]: 0 }); }); let observedRows = 0; -const bump = System.create({ name: "dispatchTest/fastA/2", kernel: "cpu", components: { write: [offset] } }, row => { +const bump = System.create({ kernel: true, components: { with: [offset] } }, row => { row[offset] = row[offset] + 1; }); -const watch = System.create({ name: "dispatchTest/slowB/2", components: { changed: [offset] } }, () => { observedRows++; }); -const later = System.create({ name: "dispatchTest/fastC/2", kernel: "cpu", components: { read: [velocity], write: [offset] }, after: [watch] }, row => { +const watch = System.create({ components: { changed: [offset] } }, () => { observedRows++; }); +const later = System.create({ kernel: true, components: { with: [velocity, offset] }, after: [watch] }, row => { const [vx, vy] = row[velocity]; row[offset] = vx + vy; }); @@ -112,7 +102,7 @@ export const afterFailedRegistration = (failing = 2): Promise<{ readonly rejecte WebAssembly.compile = (bytes: BufferSource) => (++calls === failing ? Promise.reject(new Error("injected")) : original(bytes)); let rejected = "resolved"; try { - await world.run(System.create({ name: "dispatchTest/tier/2", systems: [watch, later] })); + await world.run(System.create({ systems: [watch, later] })); } catch (error) { rejected = String(error); @@ -159,7 +149,7 @@ let tiers = 0; /** A tier of `systems` run `frames` times on `count` workers after `prepare`: "ok", or "timeout" if a run hung. */ const runTier = (systems: readonly System[], count: number, frames: number, prepare: System): Promise<"ok" | "timeout"> => using(createWorld({ [workers]: count }, { minimumChunk: 64 }), async world => { - const tier = System.create({ name: `dispatchTest/tier/${3 + tiers++}`, systems: [...systems] }); + const tier = System.create({ systems: [...systems] }); await world.run(prepare); let result: unknown = "ok"; for (let frame = 0; frame < frames && result !== "timeout"; frame++) result = await withTimeout(world.run(tier), 3000); diff --git a/packages/ecs/src/implementation/world/entity-lookup-workers.fixture.ts b/packages/ecs/src/implementation/world/entity-lookup-workers.fixture.ts index 4b81b6d70..ff87d091d 100644 --- a/packages/ecs/src/implementation/world/entity-lookup-workers.fixture.ts +++ b/packages/ecs/src/implementation/world/entity-lookup-workers.fixture.ts @@ -1,5 +1,4 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Component } from "../../interface/component/component.js"; import { Entity } from "../../interface/entity/entity.js"; import { workers } from "../../interface/resources/workers.js"; import { System } from "../../interface/system/system.js"; @@ -26,21 +25,15 @@ declare module "@adobe/ecs" { [banned]: { type: true }; } } -Component.register(weight, { type: { type: "number" } }); -Component.register(mark, { type: { type: "number" } }); -Component.register(follows, { type: Entity.schema }); -Component.register(kernelSeen, { type: { type: "number" } }); -Component.register(plainSeen, { type: { type: "number" } }); -Component.register(banned, { type: { const: true } }); /** * Counts, in base 4 by run, what the lookups gave the entity a row follows: 0 not found, 1 unchanged, 2 changed, and * whether its mark changed in the next digit, then whether it has a mark and isn't banned. */ const watchKernel = System.create({ - name: "entityLookupWorkersTest/watchKernel", kernel: "cpu", - components: { read: [follows], write: [kernelSeen] }, - lookup: { found: { read: [weight], changed: [weight] }, marked: { read: [mark], changed: [mark] }, eligible: { has: [mark], not: [banned] } }, + kernel: true, + components: { with: [follows, kernelSeen] }, + lookup: { found: { with: [weight], changed: [weight] }, marked: { with: [mark], changed: [mark] }, eligible: { with: [mark], not: [banned] } }, }, (row, { lookup }) => { const found = lookup.found(row[follows]); const marked = lookup.marked(row[follows]); @@ -51,9 +44,8 @@ const watchKernel = System.create({ }); const watchPlain = System.create({ - name: "entityLookupWorkersTest/watchPlain", - components: { read: [follows], write: [plainSeen] }, - lookup: { found: { read: [weight], changed: [weight] }, marked: { read: [mark], changed: [mark] }, eligible: { has: [mark], not: [banned] } }, + components: { with: [follows, plainSeen] }, + lookup: { found: { with: [weight], changed: [weight] }, marked: { with: [mark], changed: [mark] }, eligible: { with: [mark], not: [banned] } }, }, (row, { lookup }) => { const found = lookup.found(row[follows]); const marked = lookup.marked(row[follows]); @@ -63,17 +55,17 @@ const watchPlain = System.create({ row[plainSeen] = ((row[plainSeen] * 4 + seen) % 1000003 * 4 + flagged) * 2 + eligible; }); -const tick = System.create({ name: "entityLookupWorkersTest/tick", systems: [watchKernel, watchPlain] }); +const tick = System.create({ systems: [watchKernel, watchPlain] }); const targets: Entity[] = []; -const seed = System.create({ name: "entityLookupWorkersTest/seed" }, ({ commands }) => { +const seed = System.create({}, ({ commands }) => { targets.length = 0; for (let k = 0; k < 500; k++) targets.push(k % 3 === 0 ? commands.spawn([weight, mark])({ [weight]: k, [mark]: 1 }) : commands.spawn([weight])({ [weight]: k })); for (let k = 0; k < 4000; k++) commands.spawn([follows, kernelSeen, plainSeen])({ [follows]: targets[(k * 7) % targets.length], [kernelSeen]: 0, [plainSeen]: 0 }); }); /** Writes some weights, marks and unmarks some targets, despawns some and spawns others: a different change each round. */ -const stir = System.create({ name: "entityLookupWorkersTest/stir", input: { type: "object", properties: { round: { type: "integer" } } }, components: { write: [weight] } }, (row, { input, commands }) => { +const stir = System.create({ components: { with: [weight] } }, (row, { commands }, input: { readonly round: number }) => { const w = row[weight]; if ((w + input.round) % 5 === 0) row[weight] = w + 1000; if ((w + input.round) % 11 === 0) commands.add(row.entity, mark, 2); diff --git a/packages/ecs/src/implementation/world/entity-lookups.browser.test.ts b/packages/ecs/src/implementation/world/entity-lookups.browser.test.ts index e380da8f2..d334d6d44 100644 --- a/packages/ecs/src/implementation/world/entity-lookups.browser.test.ts +++ b/packages/ecs/src/implementation/world/entity-lookups.browser.test.ts @@ -7,7 +7,7 @@ import { createWorld, join, type CreatedWorld } from "./create.js"; import { edit, probe, scene, threadProbe, value } from "./entity-lookups.test-helper.js"; /** Writes every `value` on the GPU, which stamps its whole column at once. */ -const bumpOnGpu = System.create({ name: "entityLookupsTest/bumpOnGpu", kernel: "gpu", components: { write: [value] } }, row => { +const bumpOnGpu = System.create({ kernel: "gpu", components: { with: [value] } }, row => { row[value] = row[value] + 1; }); diff --git a/packages/ecs/src/implementation/world/entity-lookups.test-helper.ts b/packages/ecs/src/implementation/world/entity-lookups.test-helper.ts index e2da4b0ad..840c1c1d5 100644 --- a/packages/ecs/src/implementation/world/entity-lookups.test-helper.ts +++ b/packages/ecs/src/implementation/world/entity-lookups.test-helper.ts @@ -1,9 +1,10 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Component } from "../../interface/component/component.js"; +import { giveAccess } from "../../interface/registry/ids.test-helper.js"; import { Entity } from "../../interface/entity/entity.js"; import { System } from "../../interface/system/system.js"; import type { World } from "../../interface/world/world.js"; import { fillReports } from "./reports.test-helper.js"; +import type { F32 } from "../../interface/math/index.js"; // Lookups by entity with `changed`, where systems run: probes on the world's thread, and on thread // "entityLookupsTest", which a joined thread fills, as they read a probe's object there. @@ -19,20 +20,14 @@ declare module "@adobe/ecs" { entityLookupsTest: true; } export interface Components { - [value]: { type: number }; + [value]: { type: F32 }; [other]: { type: number }; [tag]: { type: true }; [extra]: { type: number }; [target]: { type: Entity }; - [label]: { type: { readonly text: string } | null; thread: "entityLookupsTest" }; + [label]: { type: { readonly text: string } | null; thread: "entityLookupsTest"; default: { text: "probe" } }; } } -Component.register(value, { type: { type: "number", precision: 1 } }); -Component.register(other, { type: { type: "number" } }); -Component.register(tag, { type: { const: true } }); -Component.register(extra, { type: { type: "number" } }); -Component.register(target, { type: Entity.schema }); -Component.register(label, { type: { anyOf: [{ type: "object", properties: { text: { type: "string" } } }, { type: "null" }], default: { text: "probe" } }, thread: "entityLookupsTest" }); /** * What a probe saw of its target in one run: each lookup's `changed`, or null where it found nothing; the value it @@ -70,52 +65,53 @@ const seenOf = (subject: Entity, lookup: { }; /** On the world's thread. */ -export const probe = System.create({ - name: "entityLookupsTest/probe", - components: { read: [target] }, - lookup: { found: { read: [value], changed: [value] }, tagged: { has: [tag], changed: [tag] }, empty: {}, has: { has: [tag] } }, -}, (row, { lookup }) => { +/** + * What a probe's body reads: its lookups are passed to a helper, which the build can't follow, so they're given: only + * `found`'s rows are read, so the others keep the forms of lookups reading nothing. + */ +const probeReads = (read: readonly symbol[]) => ({ read, lookup: { found: [value], tagged: [], empty: [], has: [] } }); + +export const probe = System.create(giveAccess({ + components: { with: [target] }, + lookup: { found: { with: [value], changed: [value] }, tagged: { with: [tag], changed: [tag] }, empty: {}, has: { with: [tag] } }, +}, probeReads([target])), (row, { lookup }) => { scene.seen.push(seenOf(row[target], lookup)); }); /** The same, registered later, so its first run comes after every change. */ -export const lateProbe = System.create({ - name: "entityLookupsTest/lateProbe", - components: { read: [target] }, - lookup: { found: { read: [value], changed: [value] }, tagged: { has: [tag], changed: [tag] }, empty: {}, has: { has: [tag] } }, -}, (row, { lookup }) => { +export const lateProbe = System.create(giveAccess({ + components: { with: [target] }, + lookup: { found: { with: [value], changed: [value] }, tagged: { with: [tag], changed: [tag] }, empty: {}, has: { with: [tag] } }, +}, probeReads([target])), (row, { lookup }) => { scene.seen.push(seenOf(row[target], lookup)); }); /** On thread "entityLookupsTest", as it reads the probe's label there. */ -export const threadProbe = System.create({ - name: "entityLookupsTest/threadProbe", - components: { read: [target, label] }, - lookup: { found: { read: [value], changed: [value] }, tagged: { has: [tag], changed: [tag] }, empty: {}, has: { has: [tag] } }, -}, (row, { lookup }) => { +export const threadProbe = System.create(giveAccess({ + components: { with: [target, label] }, + lookup: { found: { with: [value], changed: [value] }, tagged: { with: [tag], changed: [tag] }, empty: {}, has: { with: [tag] } }, +}, probeReads([target, label])), (row, { lookup }) => { if (row[label]?.text === "probe") scene.seen.push(seenOf(row[target], lookup)); }); /** Writes every `value` in its rows, stamping them. */ -export const bump = System.create({ name: "entityLookupsTest/bump", components: { write: [value] } }, row => { +export const bump = System.create({ components: { with: [value] } }, row => { row[value] += 1; }); /** The same as a kernel, whose stamps are muted unless something watches `value`. */ -export const bumpKernel = System.create({ name: "entityLookupsTest/bumpKernel", kernel: "cpu", components: { write: [value] } }, row => { +export const bumpKernel = System.create({ kernel: true, components: { with: [value] } }, row => { row[value] = row[value] + 1; }); /** Writes every `other`, which no lookup watches. */ -export const touch = System.create({ name: "entityLookupsTest/touch", components: { write: [other] } }, row => { +export const touch = System.create({ components: { with: [other] } }, row => { row[other] += 1; }); /** Changes the subject by command: `action` names how. */ export const edit = System.create({ - name: "entityLookupsTest/edit", - input: { type: "object", properties: { action: { type: "string" } } }, -}, ({ input, commands }) => { +}, ({ commands }, input: { readonly action: string }) => { const { subject } = scene; switch (input.action) { case "spawn": @@ -135,7 +131,7 @@ export const edit = System.create({ }); /** Points every probe at the subject. */ -export const retarget = System.create({ name: "entityLookupsTest/retarget", components: { write: [target] } }, row => { +export const retarget = System.create({ components: { with: [target] } }, row => { row[target] = scene.subject; }); diff --git a/packages/ecs/src/implementation/world/entity-lookups.test.ts b/packages/ecs/src/implementation/world/entity-lookups.test.ts index c7b6932e5..c07bd7980 100644 --- a/packages/ecs/src/implementation/world/entity-lookups.test.ts +++ b/packages/ecs/src/implementation/world/entity-lookups.test.ts @@ -13,14 +13,14 @@ import { runReported } from "./reports.test-helper.js"; /** What a finalizer's lookup gave as probes were despawned. */ const finalized: (boolean | null)[] = []; -Finalizer.create({ name: "entityLookupsTest/finalize", components: { read: [target] }, lookup: { found: { read: [value], changed: [value] } } }, (row, { lookup }) => { +Finalizer.create({ components: { with: [target] }, lookup: { found: { with: [value], changed: [value] } } }, (row, { lookup }) => { finalized.push(lookup.found(row[target])?.changed ?? null); }); -const despawnProbes = System.create({ name: "entityLookupsTest/despawnProbes", components: { has: [target] } }, (row, { commands }) => { +const despawnProbes = System.create({ components: { with: [target] } }, (row, { commands }) => { commands.despawn(row.entity); }); -const keepTicking = System.create({ name: "entityLookupsTest/keepTicking" }, () => {}); +const keepTicking = System.create({}, () => {}); /** A world shared with a thread on this one, through a MessageChannel, which claims the probes' thread. */ const joined = async () => { @@ -58,7 +58,7 @@ describe("lookups by entity with changed", () => { const { seen, reused } = await scenario(world, threadProbe); expect(reused).toBe(true); expect(seen).toEqual(expected); - expect((await runReported(world, threadProbe)).warnings.map(warning => warning.name)).toEqual(["entityLookupsTest/threadProbe"]); + expect((await runReported(world, threadProbe)).warnings.map(warning => warning.name)).toEqual(["threadProbe"]); await remote.dispose(); await world.dispose(); }); diff --git a/packages/ecs/src/implementation/world/execute.ts b/packages/ecs/src/implementation/world/execute.ts index 0bbcf3944..5682cf50e 100644 --- a/packages/ecs/src/implementation/world/execute.ts +++ b/packages/ecs/src/implementation/world/execute.ts @@ -19,7 +19,7 @@ import type { RunReport } from './failures.js'; import { liftColumnStamps, type RemoteSystem, type TaskChannel } from './remote-systems.js'; import { failureOf } from './failures.js'; import { checkCommands } from './remote.js'; -import { firstKeyOf, labelOfItem } from '../../interface/registry/index.js'; +import { firstKeyOf } from '../../interface/registry/index.js'; import { mergeUses, refreshUses, type IndexUse } from './indexes.js'; /** Shared by every report without failures or skipped systems: they're read-only, so none needs its own. */ @@ -59,7 +59,7 @@ export const execute = async (context: ExecuteContext, schedule: Schedule, args: // An earlier run's GPU work reports its errors to this run: waiting for them there would hold every run until the // GPU process catches up. const earlier = gpu.takeError(); - const failures = earlier === null ? null : [failureOf("gpu", earlier.name, earlier.message)]; + const failures = earlier === null ? null : [failureOf("gpu", earlier, earlier.message)]; gpu.device.pushErrorScope("out-of-memory"); gpu.device.pushErrorScope("validation"); try { @@ -252,14 +252,14 @@ const isResults = (result: ChunkResult | readonly ChunkResult[]): result is read */ const settled = (state: State, folds: ReadonlyMap | null, outcome: Outcome, runner: Runner, leaf: { readonly name: string }, error: unknown): boolean => { if (error === gone) { - const label = labelOfItem(leaf); - (outcome.failures ??= []).push(failureOf("thread", label, runner.remote?.channel.goneError())); + const label = leaf.name; + (outcome.failures ??= []).push(failureOf("thread", leaf, runner.remote?.channel.goneError())); (outcome.skipped ??= []).push(label); if (folds !== null && runner.targets.length > 0) contribute(state, folds, runner, null, true); return false; } const failed = error !== undefined; - if (failed) (outcome.failures ??= []).push(failureOf("system", labelOfItem(leaf), error)); + if (failed) (outcome.failures ??= []).push(failureOf("system", leaf, error)); if (folds !== null && runner.targets.length > 0) contribute(state, folds, runner, runner.finish(failed), failed); return failed; }; @@ -338,7 +338,7 @@ const runFused = async (context: ExecuteContext, schedule: Schedule, prepared: P } if (gpu !== null) markWrites(state, gpu, runners); if (failed) { - for (let later = v + 1; later < schedule.tiers.length; later++) for (const leaf of schedule.tiers[later]) (outcome.skipped ??= []).push(labelOfItem(leaf)); + for (let later = v + 1; later < schedule.tiers.length; later++) for (const leaf of schedule.tiers[later]) (outcome.skipped ??= []).push(leaf.name); return -1; } } @@ -435,7 +435,7 @@ const executeTiers = async (context: ExecuteContext, whole: Schedule, args: unkn started[i] = noChunks; const failed = error !== undefined; if (failed) { - (outcome.failures ??= []).push(failureOf("system", labelOfItem(tier[i]), error)); + (outcome.failures ??= []).push(failureOf("system", tier[i], error)); tierFailed = true; } if (folds !== null && runner.targets.length > 0) contribute(state, folds, runner, runner.finish(failed), failed); @@ -444,7 +444,7 @@ const executeTiers = async (context: ExecuteContext, whole: Schedule, args: unkn countRan(state, runners); if (gpu !== null) markWrites(state, gpu, runners); if (tierFailed) { - for (let later = t + 1; later < schedule.tiers.length; later++) for (const leaf of schedule.tiers[later]) (outcome.skipped ??= []).push(labelOfItem(leaf)); + for (let later = t + 1; later < schedule.tiers.length; later++) for (const leaf of schedule.tiers[later]) (outcome.skipped ??= []).push(leaf.name); break; } } @@ -485,7 +485,7 @@ const executeTiers = async (context: ExecuteContext, whole: Schedule, args: unkn // Applied even when the readback fails, which only a lost device makes it do: the CPU state // stays whole. dropped = state.plan.dropped; - for (const { finalizer, error, kind } of applySync(state)) (outcome.failures ??= []).push(failureOf(kind ?? "finalizer", labelOfItem(finalizer), error)); + for (const { finalizer, error, kind } of applySync(state)) (outcome.failures ??= []).push(failureOf(kind ?? "finalizer", finalizer, error)); } } } diff --git a/packages/ecs/src/implementation/world/failures.ts b/packages/ecs/src/implementation/world/failures.ts index 0c4d09da2..93a232026 100644 --- a/packages/ecs/src/implementation/world/failures.ts +++ b/packages/ecs/src/implementation/world/failures.ts @@ -1,5 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { Failure, Warning } from '../../interface/report/failure.js'; +import { idOrNull } from '../../interface/registry/index.js'; /** What went wrong in a run or tick, before the world spawns its report: empty lists and a zero when it's clean. */ export type RunReport = { @@ -9,7 +10,14 @@ export type RunReport = { readonly warnings: readonly Warning[]; }; -/** A failure as data: an Error's message and stack, or String() of anything else thrown. */ -export const failureOf = (kind: Failure["kind"], name: string, error: unknown): Failure => error instanceof Error - ? { kind, name, message: error.message, stack: typeof error.stack === "string" ? error.stack : null } - : { kind, name, message: String(error), stack: null }; +/** + * A failure of `of` as data, labeled by its `name` and its `id`, or for an item without `id` the id the plugin gave it: + * an Error's message and stack, or String() of anything else thrown. + */ +export const failureOf = (kind: Failure["kind"], of: { readonly name: string; readonly id?: string | null }, error: unknown): Failure => { + const { name } = of; + const id = of.id !== undefined ? of.id : idOrNull(of); + return error instanceof Error + ? { kind, name, id, message: error.message, stack: typeof error.stack === "string" ? error.stack : null } + : { kind, name, id, message: String(error), stack: null }; +}; diff --git a/packages/ecs/src/implementation/world/finalizers.test.ts b/packages/ecs/src/implementation/world/finalizers.test.ts index e5b700bd8..f0a5f6379 100644 --- a/packages/ecs/src/implementation/world/finalizers.test.ts +++ b/packages/ecs/src/implementation/world/finalizers.test.ts @@ -1,10 +1,8 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; import { identify } from "../../interface/registry/ids.test-helper.js"; -import { Component } from "../../interface/component/component.js"; import { Entity } from "../../interface/entity/entity.js"; import { Finalizer } from "../../interface/finalizer/finalizer.js"; -import { Resource } from "../../interface/resource/resource.js"; import { Rule } from "../../interface/rule/rule.js"; import { System } from "../../interface/system/system.js"; import { createWorld } from "./create.js"; @@ -55,28 +53,11 @@ declare module "@adobe/ecs" { } } -Component.register(handle, { type: { type: "number" } }); -Component.register(hidden, { type: { const: true } }); -Component.register(source, { type: { type: "number" } }); -Component.register(slot, { type: { type: "number", default: 0 }, derived: true }); -Component.register(parent, { type: Entity.schema, reference: "owner" }); -Component.register(label, { type: { type: "string" } }); -Component.register(late, { type: { type: "number" } }); -Resource.register(released, { type: { type: "number", default: 0 } }); -Resource.register(level, { type: { type: "number", default: 0 } }); -Resource.register(written, { type: { type: "number", default: 0 } }); -Component.register(counted, { type: { type: "number" } }); -Component.register(pendingHandle, { type: { type: "number" } }); -Component.register(respawned, { type: { type: "number" } }); -Component.register(ordered, { type: { type: "number" } }); -Component.register(writing, { type: { type: "number" } }); -Component.register(disposing, { type: { type: "number" } }); -Component.register(reloaded, { type: { type: "number" } }); -Rule.create({ name: "finalizersTest/slots", components: { has: [source] }, derive: [slot] }); +Rule.create({ components: { with: [source] }, derive: [slot] }); let steps: ((commands: System.Commands) => void)[] = []; -const edit = System.create({ name: "finalizersTest/edit", input: { type: "object", properties: { step: { type: "integer" } } } }, ({ input, commands }) => { +const edit = System.create({}, ({ commands }, input: { readonly step: number }) => { steps[input.step]?.(commands); }); const step = (world: ReturnType) => (run: (commands: System.Commands) => void) => { @@ -87,7 +68,7 @@ const step = (world: ReturnType) => (run: (commands: System. describe("finalizers", () => { it("run for each entity that stops matching, by any cause, reading its values before the sync point applies", async () => { const seen: [Entity, number][] = []; - Finalizer.create({ name: "finalizersTest/release", components: { read: [handle], not: [hidden] } }, row => { seen.push([row.entity, row[handle]]); }); + Finalizer.create({ components: { with: [handle], not: [hidden] } }, row => { seen.push([row.entity, row[handle]]); }); const world = createWorld({ [workers]: 0 }); const run = step(world); const spawned: Entity[] = []; @@ -111,7 +92,7 @@ describe("finalizers", () => { it("run for rules' and references' removals too, and at disposal for every live match", async () => { const seen: number[] = []; - Finalizer.create({ name: "finalizersTest/slotReleased", components: { read: [slot, source] } }, row => { seen.push(row[source]); }); + Finalizer.create({ components: { with: [slot, source] } }, row => { seen.push(row[source]); }); const world = createWorld({ [workers]: 0 }); const run = step(world); let root = Entity.none, child = Entity.none; @@ -130,10 +111,8 @@ describe("finalizers", () => { it("write resources at the sync point, queue commands for the next one, and see lookups as they were", async () => { const found: (number | null)[] = []; Finalizer.create({ - name: "finalizersTest/count", - components: { read: [counted] }, - lookup: { handles: { read: [counted] } }, - resources: { write: [released] }, + components: { with: [counted] }, + lookup: { handles: { with: [counted] } }, }, (row, { resources, lookup, commands }) => { resources[released] += 1; found.push(lookup.handles(row.entity)?.[counted] ?? null); @@ -154,11 +133,11 @@ describe("finalizers", () => { it("leave their commands for the next run's sync point, even when that run first adds resources", async () => { let other = Entity.none; - Finalizer.create({ name: "finalizersTest/pending", components: { read: [pendingHandle] } }, (_, { commands }) => { commands.despawn(other); }); - const reads = System.create({ name: "finalizersTest/reads", resources: { read: [level] } }, ({ resources }) => { void resources[level]; }); + Finalizer.create({ components: { with: [pendingHandle] } }, (_, { commands }) => { commands.despawn(other); }); + const reads = System.create({}, ({ resources }) => { void resources[level]; }); // Read after every run, as a read through a run of its own would apply the commands left for it. let seen: string | null = null; - const watch = System.create({ name: "finalizersTest/watchOther", observer: true, lookup: { labels: { read: [label] } } }, ({ lookup }) => { + const watch = System.create({ observer: true, lookup: { labels: { with: [label] } } }, ({ lookup }) => { seen = lookup.labels(other)?.[label] ?? null; }); const world = createWorld({ [workers]: 0 }); @@ -174,7 +153,7 @@ describe("finalizers", () => { }); it("keep reservations they make apart from the indexes their sync point frees", async () => { - Finalizer.create({ name: "finalizersTest/respawn", components: { read: [respawned] } }, (row, { commands }) => { + Finalizer.create({ components: { with: [respawned] } }, (row, { commands }) => { commands.spawn([respawned])({ [respawned]: row[respawned] + 100 }); }); const world = createWorld({ [workers]: 0 }); @@ -192,21 +171,22 @@ describe("finalizers", () => { it("run in their order, and a throw still applies the sync point and is reported", async () => { const order: string[] = []; - const second = Finalizer.create({ name: "finalizersTest/second", components: { read: [ordered] } }, () => { order.push("second"); }); - Finalizer.create({ name: "finalizersTest/first", components: { read: [ordered] }, before: [second] }, () => { order.push("first"); }); - Finalizer.create({ name: "finalizersTest/throws", components: { read: [ordered] } }, () => { throw new Error("finalizer failed"); }); + const second = Finalizer.create({ components: { with: [ordered] } }, () => { order.push("second"); }); + Finalizer.create({ components: { with: [ordered] }, before: [second] }, () => { order.push("first"); }); + const throwing = Finalizer.create({ components: { with: [ordered] } }, () => { throw new Error("finalizer failed"); }); + void throwing; const world = createWorld({ [workers]: 0 }); const run = step(world); let a = Entity.none; await run(commands => { a = commands.spawn([ordered])({ [ordered]: 1 }); }); const { failures } = await reportOf(world, await run(commands => commands.despawn(a))); - expect(failures.map(({ kind, name, message }) => ({ kind, name, message }))).toEqual([{ kind: "finalizer", name: "finalizersTest/throws", message: "finalizer failed" }]); + expect(failures.map(({ kind, name, message }) => ({ kind, name, message }))).toEqual([{ kind: "finalizer", name: "throwing", message: "finalizer failed" }]); expect(order).toEqual(["first", "second"]); expect(await read(world, a, handle)).toBeNull(); - const writer = (name: "finalizersTest/writerA" | "finalizersTest/writerB") => - Finalizer.create({ name, components: { read: [writing] }, resources: { write: [written] } }, (_, { resources }) => { resources[written] = 1; }); - writer("finalizersTest/writerA"); - expect(() => writer("finalizersTest/writerB")).toThrow("order them with before or after"); + const writer = (name: "writerA" | "writerB") => + Finalizer.create({ components: { with: [writing] } }, (_, { resources }) => { resources[written] = 1; }); + writer("writerA"); + expect(() => writer("writerB")).toThrow("order them with before or after"); await world.dispose(); }); }); @@ -214,15 +194,15 @@ describe("finalizers", () => { describe("a finalizer declared again the same, as a module evaluated again declares it", () => { it("keeps its place in each world, and runs its new body once", async () => { const seen: string[] = []; - const first = Finalizer.create(identify({ name: "finalizersTest/reloaded", components: { read: [reloaded] } }, "finalizersTest/finalizers.ts#reloaded"), () => { seen.push("first"); }); + const first = Finalizer.create(identify({ components: { with: [reloaded] } }, "finalizersTest/finalizers.ts#reloaded"), () => { seen.push("first"); }); const world = createWorld({ [workers]: 0 }); let entity = Entity.none; await step(world)(commands => { entity = commands.spawn([reloaded])({ [reloaded]: 1 }); }); - const again = Finalizer.create(identify({ name: "finalizersTest/reloaded", components: { read: [reloaded] } }, "finalizersTest/finalizers.ts#reloaded"), () => { seen.push("again"); }); + const again = Finalizer.create(identify({ components: { with: [reloaded] } }, "finalizersTest/finalizers.ts#reloaded"), () => { seen.push("again"); }); expect(again).toBe(first); await step(world)(commands => { commands.despawn(entity); }); expect(seen).toEqual(["again"]); - expect(() => Finalizer.create(identify({ name: "finalizersTest/reloaded", components: { read: [handle] } }, "finalizersTest/finalizers.ts#reloaded"), () => {})).toThrow("reload the page"); + expect(() => Finalizer.create(identify({ components: { with: [handle] } }, "finalizersTest/finalizers.ts#reloaded"), () => {})).toThrow("reload the page"); await world.dispose(); }); }); @@ -232,7 +212,7 @@ describe("a finalizer created after a world has entities it matches", () => { const world = createWorld({ [workers]: 0 }); await step(world)(commands => { commands.spawn([disposing])({ [disposing]: 4 }); }); const seen: number[] = []; - Finalizer.create({ name: "finalizersTest/late", components: { read: [disposing] }, resources: { read: [level] } }, row => { seen.push(row[disposing]); }); + Finalizer.create({ components: { with: [disposing] } }, row => { seen.push(row[disposing]); }); await world.dispose(); expect(seen).toEqual([4]); }); @@ -241,7 +221,7 @@ describe("a finalizer created after a world has entities it matches", () => { describe("changes finalizers", () => { it("watch by default every component that isn't derived, including ones first used later, and no resources", async () => { const seen: unknown[] = []; - Finalizer.changes({ name: "finalizersTest/everything", resources: { read: [level] } }, (changes, { resources }) => { + Finalizer.changes({ }, (changes, { resources }) => { void resources[level]; for (const group of changes.groups) seen.push(group.to); }); @@ -254,7 +234,7 @@ describe("changes finalizers", () => { it("see each sync point's structural changes to their components, grouped, with values before and after", async () => { const groups: unknown[] = []; const values: unknown[] = []; - Finalizer.changes({ name: "finalizersTest/changes", watch: [handle, label] }, ({ groups: seen, previous, next }) => { + Finalizer.changes({ watch: [handle, label] }, ({ groups: seen, previous, next }) => { for (const group of seen) { groups.push({ ...group, entities: group.entities.length }); for (const entity of group.entities) values.push([previous(entity, handle), next(entity, handle)]); @@ -282,7 +262,7 @@ describe("changes finalizers", () => { it("see a value an add gives a component the entity keeps as replaced, removed and added again or not", async () => { const groups: unknown[] = []; const values: unknown[] = []; - Finalizer.changes({ name: "finalizersTest/replacing", watch: [handle, label] }, ({ groups: seen, previous, next }) => { + Finalizer.changes({ watch: [handle, label] }, ({ groups: seen, previous, next }) => { for (const group of seen) { groups.push({ ...group, entities: group.entities.length }); for (const entity of group.entities) values.push([previous(entity, handle), next(entity, handle)]); diff --git a/packages/ecs/src/implementation/world/finalizers.ts b/packages/ecs/src/implementation/world/finalizers.ts index 59e630522..83d85fb1a 100644 --- a/packages/ecs/src/implementation/world/finalizers.ts +++ b/packages/ecs/src/implementation/world/finalizers.ts @@ -15,8 +15,9 @@ import type { Queued } from './synchronize.js'; import type { Essential, ReadonlyValue } from '../../interface/component/value.js'; import type { Leaf } from '../../interface/finalizer/created.js'; import { newestKeyOf, threadOf } from '../../interface/registry/index.js'; +import type { Layout } from '../../interface/registry/layout.js'; import type { TaskChannel } from './remote-systems.js'; -import { writeStops, type StopsOf } from './stop-lists.js'; +import { resourceOffsetsOf, writeStops, type StopsOf } from './stop-lists.js'; import { acquireLookups, refreshUses, type IndexLookups } from './indexes.js'; import { indexWatchesOf } from './lookup-keys.js'; @@ -34,6 +35,8 @@ export type FinalizerRunner = { readonly target: { buffer: CommandBuffer }; readonly commands: Commands; readonly reads: readonly Key[]; + /** Its optional components, which its row reads where the entity had them. */ + readonly optional: readonly Key[]; /** The indexes its lookups go through, held for the world's life. */ readonly indexes: IndexLookups; /** Where a joined thread fills its thread: its stops go to that thread's stop lists, and it runs there. */ @@ -73,7 +76,7 @@ export const createFinalizerRunner = (state: State, finalizer: Finalizer): Final for (const key of indexWatchesOf(declaration.lookup)) state.watchesPending.push(idOf(state, key)); return { finalizer, - required: [...new Set([...reads, ...query?.has ?? []])].map(key => idOf(state, key)), + required: [...new Set([...reads, ...query?.has ?? []])].filter(key => !(query?.optional ?? []).includes(key)).map(key => idOf(state, key)), excluded: (query?.not ?? []).map(key => idOf(state, key)), watches, matches: [], @@ -81,6 +84,7 @@ export const createFinalizerRunner = (state: State, finalizer: Finalizer): Final target, commands, reads, + optional: query?.optional ?? [], indexes, remote: null, }; @@ -112,8 +116,30 @@ export const placeFinalizer = (state: State, runner: FinalizerRunner, channel: T copies.push(layout.kind === "string"); } } + // Its string and linear resources, in the order the thread's context reads them, copied as of before apply. + const stringResources: number[] = []; + const linearResources: number[] = []; + const resourceStrides: number[] = []; + const stringStarts: unknown[] = []; + const linearStarts: unknown[] = []; + const resourceLayouts: Layout[] = []; + for (const key of new Set(finalizer.declaration.resources?.read ?? [])) { + const id = idOf(state, key); + const layout = state.layouts[id]; + if (layout.kind === "string") { + stringResources.push(id); + stringStarts.push(state.starts[id]); + } + else if (layout.kind === "linear") { + linearResources.push(id); + resourceStrides.push(layout.stride); + linearStarts.push(state.starts[id]); + resourceLayouts.push(layout); + } + } + const resourceOffsets = resourceOffsetsOf(stringResources.length, resourceStrides); const index = channel.placeFinalizer(state, finalizer); - runner.remote = { finalizer, index, objects, linear, strides, copies, entries: [], channel }; + runner.remote = { finalizer, index, objects, linear, strides, copies, stringResources, linearResources, resourceStrides, resourceOffsets, stringStarts, linearStarts, resourceLayouts, entries: [], channel }; }; const matches = (runner: FinalizerRunner, archetype: Archetype): boolean => { @@ -182,7 +208,7 @@ const finalizeStops = (state: State, runner: FinalizerRunner, leaf: Leaf, errors if (entries === undefined) byArchetype.set(from, entries = []); entries.push(entry); } - const bind = rowBinder(state, runner.reads, []); + const bind = rowBinder(state, runner.reads, [], runner.optional); for (const [archetype, stopped] of byArchetype) { const row = bind(archetype); for (const entry of stopped) { @@ -382,7 +408,11 @@ export const runFinalizers = (state: State, queued: readonly Queued[], born: Map } if (state.claimed && staged(state)) { const { plan } = state; - const source = { entity: plan.entity, from: plan.from, despawned: plan.despawned, archetypes: state.archetypes, row: state.entities.row, holds: state.holds }; + const resource = state.resourceEntity < 0 ? -1 : indexOf(state.resourceEntity); + const source = { + entity: plan.entity, from: plan.from, despawned: plan.despawned, archetypes: state.archetypes, row: state.entities.row, holds: state.holds, + resourceArchetype: resource < 0 ? -1 : state.entities.archetype[resource], resourceRow: resource < 0 ? 0 : state.entities.row[resource], + }; for (const channel of state.channels) writeStops(channel.stops, tick, source); } } @@ -406,7 +436,7 @@ export const finalizeAll = (state: State): Thrown[] => { if (leaf.kind !== "finalizer" || runner.remote !== null) continue; const finalizer: Untyped = leaf; runner.context.bind(); - const bind = rowBinder(state, runner.reads, []); + const bind = rowBinder(state, runner.reads, [], runner.optional); for (const archetype of state.archetypes) { if (archetype.count === 0 || !matches(runner, archetype)) continue; const row = bind(archetype); diff --git a/packages/ecs/src/implementation/world/generated-code.md b/packages/ecs/src/implementation/world/generated-code.md index bf079d670..daa9aff89 100644 --- a/packages/ecs/src/implementation/world/generated-code.md +++ b/packages/ecs/src/implementation/world/generated-code.md @@ -21,7 +21,7 @@ import { readFile } from 'node:fs/promises'; import { dirname, resolve } from 'node:path'; import { transformKernels } from '../implementation/compiler/transform.js'; import { createLinker } from '../implementation/compiler/link.js'; -import { packageIndex } from '../implementation/compiler/paths.js'; +import { ownCopy } from '../implementation/compiler/paths.js'; import { estreeOf } from '../implementation/compiler/estree.js'; import { generateWgsl } from '../implementation/world/wgsl.js'; import { generateWasm, type WasmColumn } from '../implementation/world/wasm.js'; @@ -31,12 +31,12 @@ const parse = (code: string) => estreeOf(parseAst(code)); test("show", async () => { const source = `import { System, Vec3 } from "@adobe/ecs"; const position = "position"; const velocity = "velocity"; -const move = System.create({ name: "move", kernel: "gpu", components: { read: [velocity], write: [position] } }, +const move = System.create({ kernel: "gpu", components: { read: [velocity], write: [position] } }, row => { row[position] = Vec3.add(row[position], row[velocity]); });`; // Resolves the package to its source and relative imports to files, stripping types as the plugin does. const linker = createLinker({ resolve: async (specifier, importer) => { - if (specifier === "@adobe/ecs") return packageIndex; + if (specifier === "@adobe/ecs") return ownCopy.index; const path = resolve(dirname(importer), specifier); return existsSync(path.replace(/\.js$/, ".ts")) ? path.replace(/\.js$/, ".ts") : path; }, @@ -45,7 +45,7 @@ const move = System.create({ name: "move", kernel: "gpu", components: { read: [v return { code: file.endsWith(".ts") ? (await (await import("vite")).transformWithOxc(code, file, { lang: "ts" })).code : code }; }, }, parse); - const result = await transformKernels(source, "/move.js", parse, async s => s === "@adobe/ecs", i => JSON.stringify(`kernel:${i}`), linker); + const result = await transformKernels(source, "/move.js", parse, async s => s === "@adobe/ecs" ? ownCopy : null, i => JSON.stringify(`kernel:${i}`), linker); if (result === null || "failure" in result) throw new Error(JSON.stringify(result)); console.log(result.code); console.log(result.kernels[0]); @@ -112,9 +112,9 @@ fn main(@builtin(global_invocation_id) id: vec3u, @builtin(num_workgroups) group Calls nest as builtins do: `Vec3.scale(Vec3.normalize(Vec3.cross(p, v)), Vec3.dot(p, v))` emits `cross_6 = cross(…)`, `normalize_19 = normalize(cross_6)`, `dot_26 = dot(…)` and `scale_31 = (normalize_19 * dot_26)`. -### WASM (499 bytes, excerpts from `wasm2wat`) +### WASM (557 bytes, excerpts from `wasm2wat`) -Three loops: chunk stamps, four rows at a time, then one row at a time for the rest. +Four loops: block stamps, chunk stamps, four rows at a time, then one row at a time for the rest. ```wat ;; Four rows: 48 bytes per component, three v128 loads each. diff --git a/packages/ecs/src/implementation/world/gpu.browser.test.ts b/packages/ecs/src/implementation/world/gpu.browser.test.ts index 6b7482742..396384836 100644 --- a/packages/ecs/src/implementation/world/gpu.browser.test.ts +++ b/packages/ecs/src/implementation/world/gpu.browser.test.ts @@ -1,9 +1,8 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; -import { Component } from "../../interface/component/component.js"; import { Entity } from "../../interface/entity/entity.js"; import { Gpu } from "../../interface/gpu/gpu.js"; -import { Mat4x4, Quat, Vec3 } from "../../interface/math/index.js"; +import { type F32, Mat4x4, Quat, type U32, Vec3 } from "../../interface/math/index.js"; import type { Schema } from "../../interface/schema/schema.js"; import { gpuDevice } from "../../interface/resources/gpu-device.js"; import { System } from "../../interface/system/system.js"; @@ -30,12 +29,12 @@ const ownedBy = Symbol("gpuTest/ownedBy"); declare module "@adobe/ecs" { export interface Components { - [position]: { type: readonly [number, number, number] }; - [velocity]: { type: readonly [number, number, number] }; - [score]: { type: number }; + [position]: { type: Vec3 }; + [velocity]: { type: Vec3 }; + [score]: { type: F32 }; [tag]: { type: true }; - [code]: { type: number }; - [amount]: { type: number }; + [code]: { type: U32 }; + [amount]: { type: F32 }; [flag]: { type: boolean }; [followed]: { type: Entity }; [bits]: { type: Vec3.U32 }; @@ -44,60 +43,51 @@ declare module "@adobe/ecs" { } } -Component.register(position, { type: vec3 }); -Component.register(velocity, { type: vec3 }); -Component.register(score, { type: { type: "number", precision: 1 } }); -Component.register(tag, { type: { const: true } }); -Component.register(code, { type: { type: "integer", minimum: 0, maximum: 0xFFFFFFFF } }); -Component.register(amount, { type: { type: "number", precision: 1 } }); -Component.register(flag, { type: { type: "boolean" } }); -Component.register(followed, { type: Entity.schema }); -Component.register(ownedBy, { type: Entity.schema, reference: "owner" }); const count = 5000; -const spawn = System.create({ name: "gpuTest/spawn" }, ({ commands }) => { +const spawn = System.create({}, ({ commands }) => { for (let i = 0; i < count; i++) { if (i % 2 === 0) commands.spawn([position, velocity])({ [position]: [i, 0, -i], [velocity]: [1, i % 7, 0.5] }); else commands.spawn([position, velocity, tag])({ [position]: [i, 0, -i], [velocity]: [1, i % 7, 0.5], [tag]: true }); } }); -const move = System.create({ name: "gpuTest/move", kernel: "gpu", components: { read: [velocity], write: [position] } }, row => { +const move = System.create({ kernel: "gpu", components: { with: [velocity, position] } }, row => { const [px, py, pz] = row[position]; const [vx, vy, vz] = row[velocity]; row[position] = [px + vx, py + vy * 2, pz + vz]; }); /** Between two GPU kernels: reads what the first wrote, and changes what both read. */ -const reset = System.create({ name: "gpuTest/reset", components: { write: [position, velocity] }, after: [move] }, row => { +const reset = System.create({ components: { with: [position, velocity] }, after: [move] }, row => { const [px, py, pz] = row[position]; const [vx, vy, vz] = row[velocity]; row[position] = [px * 2, py, pz + 100]; row[velocity] = [vz, vx, vy]; }); -const moveAgain = System.create({ name: "gpuTest/moveAgain", kernel: "gpu", components: { read: [velocity], write: [position] }, after: [reset] }, row => { +const moveAgain = System.create({ kernel: "gpu", components: { with: [velocity, position] }, after: [reset] }, row => { const [px, py, pz] = row[position]; const [vx, vy, vz] = row[velocity]; row[position] = [px + vx, py + vy, pz + vz]; }); /** Between two GPU kernels: changes only what they read, so nothing needs reading back first. */ -const turn = System.create({ name: "gpuTest/turn", components: { write: [velocity] }, after: [move] }, row => { +const turn = System.create({ components: { with: [velocity] }, after: [move] }, row => { const [vx, vy, vz] = row[velocity]; row[velocity] = [vz * 3, vx, vy]; }); -const moveLater = System.create({ name: "gpuTest/moveLater", kernel: "gpu", components: { read: [velocity], write: [position] }, after: [turn] }, row => { +const moveLater = System.create({ kernel: "gpu", components: { with: [velocity, position] }, after: [turn] }, row => { const [px, py, pz] = row[position]; const [vx, vy, vz] = row[velocity]; row[position] = [px + vx, py + vy, pz + vz]; }); -const turning = System.create({ name: "gpuTest/turning", systems: [move, turn, moveLater] }); +const turning = System.create({ systems: [move, turn, moveLater] }); -const spawnNumbers = System.create({ name: "gpuTest/spawnNumbers" }, ({ commands }) => { +const spawnNumbers = System.create({}, ({ commands }) => { for (const [c, a] of [[0, 0], [3, -2.5], [0xff00ff00, 2.5], [0xffffff00, 7], [0x80000000, -0.25]]) commands.spawn([code, amount])({ [code]: c, [amount]: a }); }); @@ -105,7 +95,7 @@ const spawnNumbers = System.create({ name: "gpuTest/spawnNumbers" }, ({ commands * JS semantics the GPU keeps: u32 values past 2^31, integer and bitwise chains bit for bit, infinities, rounding. * Arithmetic is f32, so only results f32 holds exactly are compared; WGSL lets implementations assume no value is NaN. */ -const numbers = System.create({ name: "gpuTest/numbers", kernel: "gpu", components: { write: [code, amount] } }, row => { +const numbers = System.create({ kernel: "gpu", components: { with: [code, amount] } }, row => { const c = row[code]; const a = row[amount]; const mixed = (c ^ 0x0f0f0f0f) >>> 0; @@ -114,29 +104,28 @@ const numbers = System.create({ name: "gpuTest/numbers", kernel: "gpu", componen + (c | 0) / 0x1000000 + Math.clz32(c) + (c > 0x80000000 ? 10000 : 0) + Math.max(a, -1) * 100 + (a % 1.5) * 10000; }); -const flagScore = System.create({ name: "gpuTest/flagScore", kernel: "gpu", components: { read: [flag], write: [score] } }, row => { +const flagScore = System.create({ kernel: "gpu", components: { with: [flag, score] } }, row => { row[score] = row[flag] ? 1 : 0; }); -const spawnFollower = System.create({ name: "gpuTest/spawnFollower" }, ({ commands }) => { +const spawnFollower = System.create({}, ({ commands }) => { const leader = commands.spawn([position, velocity])({ [position]: [10, 0, 0], [velocity]: [5, 0, 0] }); commands.spawn([followed, score])({ [followed]: leader, [score]: 0 }); }); /** On the CPU, reads through a lookup what a GPU kernel wrote. */ const follow = System.create({ - name: "gpuTest/follow", - components: { read: [followed], write: [score] }, - lookup: { leader: { read: [position] } }, + components: { with: [followed, score] }, + lookup: { leader: { with: [position] } }, after: [move], }, (row, { lookup: { leader } }) => { row[score] = leader(row[followed])?.[position][0] ?? -1; }); -const following = System.create({ name: "gpuTest/following", systems: [move, follow] }); +const following = System.create({ systems: [move, follow] }); /** Despawns the first entity, moving the last into its row, then makes the sync point throw. */ -const poison = System.create({ name: "gpuTest/poison", components: { read: [position] } }, (row, { commands }) => { +const poison = System.create({ components: { with: [position] } }, (row, { commands }) => { if (row[position][0] === 2) commands.despawn(row.entity); if (row[position][0] === 4) commands.add(row.entity, score, Symbol("poison") as unknown as number); }); @@ -145,38 +134,36 @@ let frameNumber = 0; const spawnedNow: Entity[] = []; /** Spawns a few particles each frame, through growth of their archetypes; nothing on the CPU reads their positions. */ -const spawnFew = System.create({ name: "gpuTest/churn" }, ({ commands }) => { +const spawnFew = System.create({}, ({ commands }) => { for (let i = 0; i < 37; i++) spawnedNow.push(commands.spawn([position, velocity])({ [position]: [frameNumber, i, 0], [velocity]: [1, i % 3, 0.25] })); // Every fifth frame, a despawn moves another particle into a row the GPU wrote. if (frameNumber % 5 === 4) commands.despawn(spawnedNow[frameNumber * 3]); }); /** A render body that fails after starting its pass. */ -const fail = System.render({ name: "gpuTest/fail", shader: { accessors: { place: position } }, after: [move] }, ({ encoder }) => { +const fail = System.render({ shader: { accessors: { place: position } }, after: [move] }, ({ encoder }) => { encoder.beginComputePass(); throw new Error("draw failed"); }); -const failing = System.create({ name: "gpuTest/failing", systems: [move, fail] }); +const failing = System.create({ systems: [move, fail] }); /** Math from three namespaces, inlined into a compute shader. */ -const transform = System.create({ name: "gpuTest/transform", kernel: "gpu", components: { read: [velocity], write: [position] } }, row => { +const transform = System.create({ kernel: "gpu", components: { with: [velocity, position] } }, row => { const q = Quat.fromAxisAngle([0, 1, 0], 0.5); const m = Mat4x4.multiply(Mat4x4.translation(1, 2, 3), Mat4x4.scaling(2, 2, 2)); row[position] = Vec3.add(Mat4x4.multiplyVec3(m, Quat.rotateVec3(q, row[position])), Vec3.normalize(row[velocity])); }); -Component.register(bits, { type: Vec3.U32.schema }); -Component.register(sums, { type: Vec3.I32.schema }); -const spawnIntegers = System.create({ name: "gpuTest/spawnIntegers" }, ({ commands }) => { +const spawnIntegers = System.create({}, ({ commands }) => { for (let i = 0; i < 100; i++) { commands.spawn([bits, sums])({ [bits]: [Math.imul(i + 1, 0x9E3779B9) >>> 0, (0xFFFFFFFF - i) >>> 0, (i << 26) >>> 0], [sums]: [2 ** 31 - 1 - i, -(2 ** 31) + i, Math.imul(i, 65536)] }); } }); /** Integer builtins, which wrap and count bits as the functions do. */ -const integers = System.create({ name: "gpuTest/integers", kernel: "gpu", components: { write: [bits, sums] } }, row => { +const integers = System.create({ kernel: "gpu", components: { with: [bits, sums] } }, row => { const b = row[bits]; const s = row[sums]; row[bits] = Vec3.U32.add(Vec3.U32.countOneBits(b), Vec3.U32.add(Vec3.U32.countTrailingZeros(b), Vec3.U32.multiply(Vec3.U32.reverseBits(b), Vec3.U32.bitwiseXor(b, [7, 7, 7])))); @@ -184,7 +171,7 @@ const integers = System.create({ name: "gpuTest/integers", kernel: "gpu", compon }); /** Float builtins across vectors, quaternions and matrices, on in-domain inputs. */ -const shading = System.create({ name: "gpuTest/shading", kernel: "gpu", components: { read: [velocity], write: [position] } }, row => { +const shading = System.create({ kernel: "gpu", components: { with: [velocity, position] } }, row => { const p = row[position]; const v = Vec3.add(row[velocity], [0.25, 0.5, 0.75]); const n = Vec3.normalize(v); @@ -200,17 +187,17 @@ const shading = System.create({ name: "gpuTest/shading", kernel: "gpu", componen }); /** Reads the entity, an f64. */ -const entityScore = System.create({ name: "gpuTest/entityScore", kernel: "gpu", components: { write: [score] } }, row => { +const entityScore = System.create({ kernel: "gpu", components: { with: [score] } }, row => { row[score] = row.entity; }); let watched = 0; -const watch = System.create({ name: "gpuTest/watch", components: { changed: [position] } }, () => { watched++; }); +const watch = System.create({ components: { changed: [position] } }, () => { watched++; }); -const frame = System.create({ name: "gpuTest/frame", systems: [move, reset, moveAgain] }); +const frame = System.create({ systems: [move, reset, moveAgain] }); /** Added while others come and go, so ticks go on. */ -const steady = System.create({ name: "gpuTest/steady" }, () => {}); +const steady = System.create({}, () => {}); const device = async (): Promise => { const adapter = await navigator.gpu.requestAdapter(); @@ -222,14 +209,14 @@ const positions = async (world: ReturnType): Promise (await readEach(world, await select(world, { required: [position] }), position)).map(value => value ?? [NaN, NaN, NaN]); let nextDevice: GPUDevice | null = null; -const setDevice = System.create({ name: "gpuTest/setDevice", resources: { write: [gpuDevice] } }, ({ resources }) => { resources[gpuDevice] = nextDevice; }); +const setDevice = System.create({}, ({ resources }) => { resources[gpuDevice] = nextDevice; }); describe("removing GPU systems", () => { it("releases the GPU records of a render system and a GPU kernel once they're removed", async () => { const world = createWorld({ [workers]: 0, [always]: true, [gpuDevice]: await device() }); await world.run(spawn); let drawn = 0; - const draw = System.render({ name: "gpuTest/drawUntilRemoved", shader: { accessors: { place: position } }, after: [move] }, () => { drawn++; }); + const draw = System.render({ shader: { accessors: { place: position } }, after: [move] }, () => { drawn++; }); const ticks = async (count: number): Promise => { const from = (await Observe.toPromise(Observe.resource(world, clock))).tick; await new Promise(resolve => { @@ -277,12 +264,12 @@ describe("the gpuDevice resource", () => { const world = createWorld({ [workers]: 0, [gpuDevice]: lost }); const cpuWorld = createWorld({ [workers]: 0 }); let changes = 0; - const watchDevice = System.create({ name: "gpuTest/watchDevice", resources: { read: [gpuDevice], changed: [gpuDevice] } }, ({ resources }) => { + const watchDevice = System.create({ resources: { changed: [gpuDevice] } }, ({ resources }) => { void resources[gpuDevice]; changes++; }); let drawn = 0; - const draw = System.render({ name: "gpuTest/drawWhileLost", shader: { accessors: { place: position } } }, () => { drawn++; }); + const draw = System.render({ shader: { accessors: { place: position } } }, () => { drawn++; }); for (const each of [world, cpuWorld]) await each.run(spawn); await world.run(watchDevice); // This move stays on the lost device, so it's lost with it. @@ -325,7 +312,7 @@ describe("the gpuDevice resource", () => { const world = createWorld({ [workers]: 2, [gpuDevice]: await device() }, { minimumChunk: 1 }); const cpuWorld = createWorld({ [workers]: 0 }); let drawn = 0; - const draw = System.render({ name: "gpuTest/drawUntilNull", shader: { accessors: { place: position } } }, () => { drawn++; }); + const draw = System.render({ shader: { accessors: { place: position } } }, () => { drawn++; }); for (const each of [world, cpuWorld]) await each.run(spawn); await world.run(draw); await world.run(move); @@ -384,7 +371,7 @@ describe("GPU kernels", () => { }); it("keep their writes when the sync point moves rows of their archetypes", async () => { - const spawnOne = System.create({ name: "gpuTest/spawnOne" }, ({ commands }) => { + const spawnOne = System.create({}, ({ commands }) => { commands.spawn([position, velocity])({ [position]: [-1, -1, -1], [velocity]: [0, 0, 0] }); }); const world = createWorld({ [gpuDevice]: await device() }); @@ -402,12 +389,12 @@ describe("GPU kernels", () => { it("keep their writes when a cascade moves rows of their archetypes", async () => { const owners: Entity[] = []; - const spawnOwned = System.create({ name: "gpuTest/spawnOwned" }, ({ commands }) => { + const spawnOwned = System.create({}, ({ commands }) => { owners.push(commands.spawn([score])({ [score]: 0 }), commands.spawn([score])({ [score]: 1 })); for (let i = 0; i < 64; i++) commands.spawn([position, velocity, ownedBy])({ [position]: [i, 0, 0], [velocity]: [1, 0, 0], [ownedBy]: owners[i === 0 ? 0 : 1] }); }); // No command touches the owned rows: despawning their owner removes them. - const despawnOwner = System.create({ name: "gpuTest/despawnOwner" }, ({ commands }) => { + const despawnOwner = System.create({}, ({ commands }) => { const [owner] = owners; if (owner !== undefined) commands.despawn(owner); }); @@ -560,7 +547,7 @@ describe("render systems", () => { it("that record invalid work make the next run report the GPU's error, under the run that submitted it", async () => { // A dispatch without a pipeline is a validation error when the encoder finishes. - const invalid = System.render({ name: "gpuTest/invalid", shader: { accessors: { place: position } } }, ({ encoder }) => { + const invalid = System.render({ shader: { accessors: { place: position } } }, ({ encoder }) => { const pass = encoder.beginComputePass(); pass.dispatchWorkgroups(1); pass.end(); @@ -572,7 +559,7 @@ describe("render systems", () => { // The device reports the error once it has processed the run's work. await own.queue.onSubmittedWorkDone(); const [failure] = (await runReported(world, spawn)).failures; - expect([failure.kind, failure.name]).toEqual(["gpu", "gpuTest/invalid"]); + expect([failure.kind, failure.name]).toEqual(["gpu", "invalid"]); expect(failure.message).toMatch(/^validation: /); expect((await runReported(world, spawn)).failures).toEqual([]); await world.dispose(); @@ -580,18 +567,17 @@ describe("render systems", () => { it("name accessors anything WGSL allows, and only that", async () => { const own = await device(); - const named = (name: "gpuTest/count" | "gpuTest/target", accessor: string) => System.render({ name, shader: { accessors: { [accessor]: position } } }, () => {}); - const module = own.createShaderModule({ code: `${Gpu.wgsl(named("gpuTest/count", "count"))}\n@compute @workgroup_size(1) fn main() { _ = count(0u); }` }); + const named = (accessor: string) => System.render({ shader: { accessors: { [accessor]: position } } }, () => {}); + const module = own.createShaderModule({ code: `${Gpu.wgsl(named("count"))}\n@compute @workgroup_size(1) fn main() { _ = count(0u); }` }); expect((await module.getCompilationInfo()).messages.filter(message => message.type === "error")).toEqual([]); - expect(() => Gpu.wgsl(named("gpuTest/target", "target"))).toThrow(/isn't usable as a WGSL function name/); + expect(() => Gpu.wgsl(named("target"))).toThrow(/isn't usable as a WGSL function name/); }); it("read their components on the GPU through the generated accessors", async () => { const own = await device(); const output = own.createBuffer({ size: count * 16, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC }); const copy = System.render({ - name: "gpuTest/copy/2", - components: { has: [tag] }, + components: { with: [tag] }, shader: { group: 0, accessors: { place: position } }, after: [move], }, ({ encoder, tables }) => { @@ -616,7 +602,7 @@ describe("render systems", () => { }`, }); const pipeline = own.createComputePipeline({ layout: own.createPipelineLayout({ bindGroupLayouts: [Gpu.layout(own), outputLayout] }), compute: { module, entryPoint: "main" } }); - const frameWithCopy = System.create({ name: "gpuTest/frame/2", systems: [move, copy] }); + const frameWithCopy = System.create({ systems: [move, copy] }); await world.run(spawn); await world.run(frameWithCopy); const staging = own.createBuffer({ size: output.size, usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST }); @@ -638,12 +624,12 @@ describe("render systems", () => { /** The x a spawned position reaches after one `move`, of the one row the nudges below write. */ const nudged = 3000; -const nudgeRow = System.create({ name: "gpuTest/nudgeRow", components: { write: [position] } }, row => { +const nudgeRow = System.create({ components: { with: [position] } }, row => { const [px, py, pz] = row[position]; if (px === nudged) row[position] = [px, py + 1, pz]; }); -const nudgeKernel = System.create({ name: "gpuTest/nudgeKernel", kernel: "cpu", components: { write: [position] } }, row => { +const nudgeKernel = System.create({ kernel: true, components: { with: [position] } }, row => { const [px, py, pz] = row[position]; if (px === nudged) row[position] = [px, py + 1, pz]; }); diff --git a/packages/ecs/src/implementation/world/gpu.ts b/packages/ecs/src/implementation/world/gpu.ts index fbcd13cba..e29983cfe 100644 --- a/packages/ecs/src/implementation/world/gpu.ts +++ b/packages/ecs/src/implementation/world/gpu.ts @@ -1,5 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { labelOfItem } from '../../interface/registry/index.js'; +import { idOrNull } from '../../interface/registry/index.js'; import { layout } from '../../interface/gpu/layout.js'; import { recordBytes } from '../../interface/gpu/record.js'; import type { Archetype, Storage } from './archetype.js'; @@ -83,7 +83,7 @@ export type GpuEngine = { /** Keeps the first error of a run's GPU work, with the label of the system whose run submitted it. */ readonly reportErrors: (system: { readonly name: string }, validation: Promise, memory: Promise) => void; /** The recorded error, cleared. */ - readonly takeError: () => { readonly name: string; readonly message: string } | null; + readonly takeError: () => { readonly name: string; readonly id: string | null; readonly message: string } | null; readonly destroy: () => void; }; @@ -110,7 +110,7 @@ export const createGpu = (device: GPUDevice): GpuEngine => { let groups: { render: GPUBindGroup; compute: GPUBindGroup } | null = null; let encoder: GPUCommandEncoder | null = null; let lostReason: string | null = null; - let recorded: { readonly name: string; readonly message: string } | null = null; + let recorded: { readonly name: string; readonly id: string | null; readonly message: string } | null = null; const residents = new Map(); const ahead = new Set(); void device.lost.then(info => { lostReason = info.message === "" ? info.reason : info.message; }); @@ -376,8 +376,8 @@ export const createGpu = (device: GPUDevice): GpuEngine => { reportErrors: (system, validation, memory) => { void (async () => { const [invalid, exhausted] = await Promise.all([validation, memory]); - if (invalid !== null) recorded ??= { name: labelOfItem(system), message: `validation: ${invalid.message}` }; - else if (exhausted !== null) recorded ??= { name: labelOfItem(system), message: `out of memory: ${exhausted.message}` }; + if (invalid !== null) recorded ??= { name: system.name, id: idOrNull(system), message: `validation: ${invalid.message}` }; + else if (exhausted !== null) recorded ??= { name: system.name, id: idOrNull(system), message: `out of memory: ${exhausted.message}` }; })(); }, takeError: () => { diff --git a/packages/ecs/src/implementation/world/ids-changes.test-helper.ts b/packages/ecs/src/implementation/world/ids-changes.test-helper.ts new file mode 100644 index 000000000..e77d98f9b --- /dev/null +++ b/packages/ecs/src/implementation/world/ids-changes.test-helper.ts @@ -0,0 +1,4 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +/** The components each change set added, which every evaluation of `ids-keys.test-helper.ts` records into. */ +export const changes: (readonly symbol[])[] = []; diff --git a/packages/ecs/src/implementation/world/ids-keys.test-helper.ts b/packages/ecs/src/implementation/world/ids-keys.test-helper.ts new file mode 100644 index 000000000..bea980819 --- /dev/null +++ b/packages/ecs/src/implementation/world/ids-keys.test-helper.ts @@ -0,0 +1,21 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Finalizer } from "../../interface/finalizer/finalizer.js"; +import { Rule } from "../../interface/rule/rule.js"; +import { changes } from "./ids-changes.test-helper.js"; + +// A module of keys, a rule and a finalizer, which `ids.test.ts` evaluates again: each evaluation makes new symbols and +// values under the same ids. +export const score = Symbol("score"); +export const shown = Symbol("shown"); + +declare module "@adobe/ecs" { + export interface Components { + [score]: { type: number }; + [shown]: { type: true; derived: true }; + } +} + +export const showing = Rule.create({ components: { with: [score] }, derive: [shown] }); +export const recording = Finalizer.changes({ watch: [score] }, ({ groups }) => { + for (const group of groups) changes.push(group.added); +}); diff --git a/packages/ecs/src/implementation/world/ids.test.ts b/packages/ecs/src/implementation/world/ids.test.ts index 74e0025ca..b1898b4ac 100644 --- a/packages/ecs/src/implementation/world/ids.test.ts +++ b/packages/ecs/src/implementation/world/ids.test.ts @@ -1,69 +1,31 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; -import { Component } from "../../interface/component/component.js"; import type { Entity } from "../../interface/entity/entity.js"; -import { Finalizer } from "../../interface/finalizer/finalizer.js"; -import { identify } from "../../interface/registry/ids.test-helper.js"; import { workers } from "../../interface/resources/workers.js"; -import { Rule } from "../../interface/rule/rule.js"; import { System } from "../../interface/system/system.js"; import { createWorld } from "./create.js"; +import { changes } from "./ids-changes.test-helper.js"; +import { score, shown } from "./ids-keys.test-helper.js"; import { read } from "./reads.test-helper.js"; -// Each pair is one module's key and the key that module makes when evaluated again: one id, two symbols. -const score = Symbol("score"); -const scoreAgain = Symbol("score"); -const shown = Symbol("shown"); -const shownAgain = Symbol("shown"); - -identify(score, "worldIdsTest/keys.ts#score"); -identify(scoreAgain, "worldIdsTest/keys.ts#score"); -identify(shown, "worldIdsTest/keys.ts#shown"); -identify(shownAgain, "worldIdsTest/keys.ts#shown"); - -declare module "@adobe/ecs" { - export interface Components { - [score]: { type: number }; - [scoreAgain]: { type: number }; - [shown]: { type: true; derived: true }; - [shownAgain]: { type: true; derived: true }; - } -} - -Component.register(score, { type: { type: "number" } }); -Component.register(shown, { type: { const: true }, derived: true }); -Rule.create(identify({ name: "worldIdsTest/shown", components: { has: [score] }, derive: [shown] }, "worldIdsTest/systems.ts#shown")); -const changes: (readonly symbol[])[] = []; -Finalizer.changes(identify({ name: "worldIdsTest/changes", watch: [score] }, "worldIdsTest/systems.ts#changes"), ({ groups }) => { - for (const group of groups) changes.push(group.added); -}); - -// The module evaluated again, after the world met its first keys. -const evaluateAgain = (): void => { - Component.register(scoreAgain, { type: { type: "number" } }); - Component.register(shownAgain, { type: { const: true }, derived: true }); - Rule.create(identify({ name: "worldIdsTest/shown", components: { has: [scoreAgain] }, derive: [shownAgain] }, "worldIdsTest/systems.ts#shown")); - Finalizer.changes(identify({ name: "worldIdsTest/changes", watch: [scoreAgain] }, "worldIdsTest/systems.ts#changes"), ({ groups }) => { - for (const group of groups) changes.push(group.added); - }); -}; - -const spawn = System.create({ name: "worldIdsTest/spawn" }, ({ commands }) => { spawned.push(commands.spawn([score])({ [score]: 1 })); }); const spawned: Entity[] = []; +const spawn = System.create({}, ({ commands }) => { spawned.push(commands.spawn([score])({ [score]: 1 })); }); describe("a world given keys with ids", () => { it("stores the symbols of one id as one component, giving values by the newest", async () => { const world = createWorld({ [workers]: 0 }); await world.run(spawn); - evaluateAgain(); - const spawnAgain = System.create({ name: "worldIdsTest/spawnAgain" }, ({ commands }) => { spawned.push(commands.spawn([scoreAgain])({ [scoreAgain]: 2 })); }); - const add = System.create({ name: "worldIdsTest/add", components: { write: [scoreAgain] } }, row => { row[scoreAgain] += 10; }); + // The keys' module evaluated again, after the world met its first keys: a new module by its query, its ids the same. + const again: typeof import("./ids-keys.test-helper.js") = await import(/* @vite-ignore */ `./ids-keys.test-helper.js?again=${Date.now()}`); + expect(again.score).not.toBe(score); + const spawnAgain = System.create({}, ({ commands }) => { spawned.push(commands.spawn([again.score])({ [again.score]: 2 })); }); + const add = System.create({ components: { with: [again.score] } }, row => { row[again.score] += 10; }); await world.run(spawnAgain); await world.run(add); const [first, second] = spawned; - expect([await read(world, first, score), await read(world, first, scoreAgain), await read(world, second, score)]).toEqual([11, 11, 12]); - expect([await read(world, first, shownAgain), await read(world, second, shown)]).toEqual([true, true]); - expect(changes).toEqual([[score], [scoreAgain]]); + expect([await read(world, first, score), await read(world, first, again.score), await read(world, second, score)]).toEqual([11, 11, 12]); + expect([await read(world, first, again.shown), await read(world, second, shown)]).toEqual([true, true]); + expect(changes).toEqual([[score], [again.score]]); await world.dispose(); }); }); diff --git a/packages/ecs/src/implementation/world/imported-lists.test-helper.ts b/packages/ecs/src/implementation/world/imported-lists.test-helper.ts index 8050b1c83..ed006038e 100644 --- a/packages/ecs/src/implementation/world/imported-lists.test-helper.ts +++ b/packages/ecs/src/implementation/world/imported-lists.test-helper.ts @@ -1,6 +1,4 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Archetype } from '../../interface/archetype/archetype.js'; -import { Component } from '../../interface/component/component.js'; /** Keys and lists another module's kernels import, in `imported-lists.test.ts`. */ @@ -15,9 +13,6 @@ declare module "@adobe/ecs" { [marked]: { type: true }; } } -Component.register(ember, { type: { type: "number" } }); -Component.register(glow, { type: { const: true } }); -Component.register(marked, { type: { const: true } }); -export const Spark = Archetype.create([ember, glow]); +export const Spark = [ember, glow] as const; export const Marks = [ember, marked] as const; diff --git a/packages/ecs/src/implementation/world/imported-lists.test.ts b/packages/ecs/src/implementation/world/imported-lists.test.ts index 8610134a7..6c2246c23 100644 --- a/packages/ecs/src/implementation/world/imported-lists.test.ts +++ b/packages/ecs/src/implementation/world/imported-lists.test.ts @@ -1,6 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { expect, it } from "vitest"; -import { Component } from "../../interface/component/component.js"; import { System } from "../../interface/system/system.js"; import { count, read, select } from "./reads.test-helper.js"; import { createWorld } from "./create.js"; @@ -14,21 +13,20 @@ declare module "@adobe/ecs" { [heat]: { type: number }; } } -Component.register(heat, { type: { type: "number" } }); -const seed = System.create({ name: "importedListsTest/seed" }, ({ commands }) => { +const seed = System.create({}, ({ commands }) => { commands.spawn([heat])({ [heat]: 2 }); commands.spawn([heat])({ [heat]: 5 }); }); /** Spawns and adds through lists another module declares, naming one of their keys only through the list. */ -const ignite = System.create({ name: "importedListsTest/ignite", kernel: "cpu", components: { read: [heat] } }, (row, { commands }) => { +const ignite = System.create({ kernel: true, components: { with: [heat] } }, (row, { commands }) => { commands.spawn(Spark)({ [ember]: row[heat] * 2 }); commands.add(row.entity, Lists.Marks, { [ember]: row[heat] }); }); /** Declares an imported list, and removes through one. */ -const cool = System.create({ name: "importedListsTest/cool", kernel: "cpu", components: { read: [ember], has: Lists.Spark } }, (row, { commands }) => { +const cool = System.create({ kernel: true, components: { with: [ember, ...Lists.Spark] } }, (row, { commands }) => { if (row[ember] > 4) commands.remove(row.entity, Spark); }); diff --git a/packages/ecs/src/implementation/world/index-buckets.test.ts b/packages/ecs/src/implementation/world/index-buckets.test.ts new file mode 100644 index 000000000..4771273cf --- /dev/null +++ b/packages/ecs/src/implementation/world/index-buckets.test.ts @@ -0,0 +1,276 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { describe, expect, it } from "vitest"; +import type { Entity } from "../../interface/entity/entity.js"; +import { Index } from "../../interface/indexes/index-type.js"; +import { workers } from "../../interface/resources/workers.js"; +import { System } from "../../interface/system/system.js"; +import { createWorld, type CreatedWorld } from "./create.js"; +import type { U32 } from "../../interface/math/index.js"; + +// Long buckets of a hash index, which a change may leave unsorted until a list is read by position: every reader still +// gets each bucket's rows in entity order. + +const group = Symbol("indexBucketsTest/group"); +const serial = Symbol("indexBucketsTest/serial"); +const hidden = Symbol("indexBucketsTest/hidden"); +const extra = Symbol("indexBucketsTest/extra"); +const reads = Symbol("indexBucketsTest/reads"); +const sum = Symbol("indexBucketsTest/sum"); +const sumByHand = Symbol("indexBucketsTest/sumByHand"); + +declare module "@adobe/ecs" { + interface Components { + [group]: { type: U32 }; + [serial]: { type: U32 }; + [hidden]: { type: true }; + [extra]: { type: true }; + [reads]: { type: U32 }; + [sum]: { type: number }; + [sumByHand]: { type: number }; + } +} + +const groups = [0, 1, 2]; +const byGroup = Index.create({ key: [group], components: { with: [serial], not: [hidden] } }); + +/** What a reader saw: each bucket's length, or each bucket's rows as entity index and serial, in order. */ +let lengths: number[] = []; +let rows: (readonly [number, number])[][] = []; +const readLengths = System.create({ lookup: { byGroup: { index: byGroup } } }, ({ lookup }) => { + lengths = groups.map(value => lookup.byGroup(value).length); +}); +const readRows = System.create({ lookup: { byGroup: { index: byGroup } } }, ({ lookup }) => { + rows = groups.map(value => { + const list = lookup.byGroup(value); + const found: (readonly [number, number])[] = []; + for (let i = 0; i < list.length; i++) { + const row = list.at(i); + found.push([row.entity >>> 0, row[serial]]); + } + return found; + }); +}); + +/** Each reader row's bucket's serials, each times its position plus one: a sum that tells the order. */ +const orderSum = System.create({ + kernel: true, + components: { with: [reads, sum] }, + lookup: { byGroup: { index: byGroup } }, +}, (row, { lookup }) => { + const list = lookup.byGroup(row[reads]); + let total = 0; + for (let i = 0; i < list.length; i++) total += (i + 1) * list.at(i)[serial]; + row[sum] = total; +}); +const orderSumByHand = System.create({ + components: { with: [reads, sumByHand] }, + lookup: { byGroup: { index: byGroup } }, +}, (row, { lookup }) => { + const list = lookup.byGroup(row[reads]); + let total = 0; + for (let i = 0; i < list.length; i++) total += (i + 1) * list.at(i)[serial]; + row[sumByHand] = total; +}); +let sums: { kernel: number[]; byHand: number[] } = { kernel: [], byHand: [] }; +const readSums = System.create({ components: { with: [reads, sum, sumByHand] } }, row => { + sums.kernel[row[reads]] = row[sum]; + sums.byHand[row[reads]] = row[sumByHand]; +}); +const spawnReaders = System.create({}, ({ commands }) => { + for (const value of groups) commands.spawn([reads, sum, sumByHand])({ [reads]: value, [sum]: 0, [sumByHand]: 0 }); +}); + +/** An entity as the test keeps it, and the changes a round makes. */ +type Held = { group: number; readonly serial: number; hidden: boolean }; +type Change = + | { readonly kind: "spawn"; readonly group: number; readonly serial: number; readonly extra: boolean } + | { readonly kind: "despawn" | "hide" | "show" | "extra" | "plain"; readonly entity: Entity } + | { readonly kind: "group"; readonly entity: Entity; readonly group: number }; + +let spawned: Entity[] = []; +const apply = System.create({}, ({ commands }, input: { readonly changes: readonly Change[] }) => { + spawned = []; + for (const change of input.changes) { + switch (change.kind) { + case "spawn": + spawned.push(change.extra + ? commands.spawn([group, serial, extra])({ [group]: change.group, [serial]: change.serial, [extra]: true }) + : commands.spawn([group, serial])({ [group]: change.group, [serial]: change.serial })); + break; + case "despawn": commands.despawn(change.entity); break; + case "hide": commands.add(change.entity, hidden); break; + case "show": commands.remove(change.entity, hidden); break; + case "extra": commands.add(change.entity, extra); break; + case "plain": commands.remove(change.entity, extra); break; + case "group": commands.add(change.entity, group, change.group); break; + } + } +}); + +/** A seeded generator of numbers in [0, 1). */ +const generator = (seed: number): (() => number) => { + let state = seed >>> 0; + return () => { + state = (state + 0x6D2B79F5) >>> 0; + let t = state; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +}; + +/** The world and what it holds, as the test keeps it: entities by entity, and the next serial. */ +type Scenario = { readonly world: CreatedWorld; readonly held: Map; next: number }; + +const applyChanges = async (scenario: Scenario, changes: readonly Change[]): Promise => { + await scenario.world.run(apply, { changes }); + let k = 0; + for (const change of changes) { + if (change.kind === "spawn") scenario.held.set(spawned[k++], { group: change.group, serial: change.serial, hidden: false }); + else if (change.kind === "despawn") scenario.held.delete(change.entity); + else if (change.kind === "hide" || change.kind === "show") { + const found = scenario.held.get(change.entity); + if (found !== undefined) found.hidden = change.kind === "hide"; + } + else if (change.kind === "group") { + const found = scenario.held.get(change.entity); + if (found !== undefined) found.group = change.group; + } + } +}; + +const spawnMany = (scenario: Scenario, count: number, groupOf: (k: number) => number): Change[] => + Array.from({ length: count }, (_, k): Change => ({ kind: "spawn", group: groupOf(k), serial: scenario.next++, extra: k % 3 === 0 })); + +/** Each bucket's rows as the index must give them: by entity index. */ +const expectedRows = (scenario: Scenario): (readonly [number, number])[][] => groups.map(value => [...scenario.held] + .filter(([, held]) => held.group === value && !held.hidden) + .map(([entity, held]): readonly [number, number] => [entity >>> 0, held.serial]) + .sort((a, b) => a[0] - b[0])); + +const orderSumOf = (bucket: readonly (readonly [number, number])[]): number => bucket.reduce((total, [, value], i) => total + (i + 1) * value, 0); + +const checkRows = async (scenario: Scenario): Promise => { + await scenario.world.run(readRows); + expect(rows).toEqual(expectedRows(scenario)); +}; +const checkLengths = async (scenario: Scenario): Promise => { + await scenario.world.run(readLengths); + expect(lengths).toEqual(expectedRows(scenario).map(bucket => bucket.length)); +}; + +/** A round of random changes: entities moved between buckets, hidden and shown, given a tag and plain again, despawned and spawned. */ +const randomChanges = (scenario: Scenario, next: () => number, count: number): Change[] => { + const entities = [...scenario.held.keys()]; + const pick = (): Entity => entities[Math.floor(next() * entities.length)]; + const used = new Set(); + const changes: Change[] = []; + for (let k = 0; k < count; k++) { + const entity = pick(); + const choice = next(); + if (choice < 0.08) { + changes.push({ kind: "spawn", group: Math.floor(next() * 3), serial: scenario.next++, extra: next() < 0.5 }); + continue; + } + // One change an entity a round, so the test's model applies them as the sync point does. + if (used.has(entity)) continue; + used.add(entity); + const held = scenario.held.get(entity); + if (held === undefined) continue; + if (choice < 0.6) changes.push({ kind: "group", entity, group: (held.group + 1 + Math.floor(next() * 2)) % 3 }); + else if (choice < 0.7) changes.push({ kind: "despawn", entity }); + else if (choice < 0.8) changes.push({ kind: held.hidden ? "show" : "hide", entity }); + else changes.push({ kind: next() < 0.5 ? "extra" : "plain", entity }); + } + return changes; +}; + +const createScenario = (wasm = true): Scenario => ({ world: createWorld({ [workers]: 0 }, { wasm }), held: new Map(), next: 0 }); + +describe("a hash index's long buckets", () => { + it.each([[1], [2], [3], [4]])("give their rows in entity order after random changes, read by length and by position, seed %i", async seed => { + const scenario = createScenario(); + await using _world = scenario.world; + const next = generator(seed); + await applyChanges(scenario, spawnMany(scenario, 600, k => k % 3)); + await checkRows(scenario); + for (let round = 0; round < 80; round++) { + await applyChanges(scenario, randomChanges(scenario, next, Math.floor(next() * 60))); + const mode = Math.floor(next() * 4); + if (mode === 0 || mode === 2) await checkLengths(scenario); + if (mode === 1 || mode === 2) await checkRows(scenario); + } + await checkRows(scenario); + expect(expectedRows(scenario).every(bucket => bucket.length >= 64)).toBe(true); + }); + it("give their rows in entity order after an entity leaves and comes back, and one moves between buckets each run", async () => { + const scenario = createScenario(); + await using _world = scenario.world; + await applyChanges(scenario, spawnMany(scenario, 300, k => k % 2)); + await checkRows(scenario); + const entities = [...scenario.held.keys()]; + for (let k = 0; k < 400; k++) { + const entity = entities[(k * 37) % entities.length]; + const held = scenario.held.get(entity); + if (held === undefined) continue; + await applyChanges(scenario, [k % 5 === 0 ? { kind: held.hidden ? "show" : "hide", entity } : { kind: "group", entity, group: 1 - held.group }]); + if (k % 7 === 0) await checkRows(scenario); + else await checkLengths(scenario); + } + await checkRows(scenario); + }); + it("give their rows in entity order after a refresh that builds the index again, and changes after it", async () => { + const scenario = createScenario(); + await using _world = scenario.world; + await applyChanges(scenario, spawnMany(scenario, 6000, k => k % 3)); + await checkLengths(scenario); + const next = generator(9); + for (let round = 0; round < 6; round++) { + await applyChanges(scenario, randomChanges(scenario, next, 30)); + await checkLengths(scenario); + // More changes than a refresh patches: the index is built again, its unsorted buckets with it. + const everyOther = [...scenario.held].filter((_, k) => k % 2 === round % 2).map(([entity, held]): Change => ({ kind: "group", entity, group: (held.group + 1) % 3 })); + await applyChanges(scenario, [...randomChanges(scenario, next, 30).filter(change => change.kind === "spawn"), ...everyOther]); + await checkLengths(scenario); + await applyChanges(scenario, randomChanges(scenario, next, 30)); + await checkRows(scenario); + } + }); + it("give their rows in entity order after a build in phases, and changes after it", async () => { + const scenario = createScenario(); + await using _world = scenario.world; + // Enough entities for a build in phases (`rebuildPhased`). + await applyChanges(scenario, spawnMany(scenario, 70_000, k => k % 2)); + await checkLengths(scenario); + const next = generator(11); + for (let round = 0; round < 4; round++) { + await applyChanges(scenario, randomChanges(scenario, next, 50)); + await checkLengths(scenario); + await applyChanges(scenario, randomChanges(scenario, next, 50)); + await checkRows(scenario); + } + expect((await scenario.world.tables()).phasedBuilds).toBeGreaterThan(0); + }); +}); + +describe.each([[true], [false]])("kernels reading a hash index's long buckets, WASM %s", wasm => { + it("read their rows in entity order, as a main-thread system does, after changes left them unsorted", async () => { + const scenario = createScenario(wasm); + await using _world = scenario.world; + await scenario.world.run(spawnReaders); + await applyChanges(scenario, spawnMany(scenario, 600, k => k % 3)); + const next = generator(5); + for (let round = 0; round < 12; round++) { + await applyChanges(scenario, randomChanges(scenario, next, 40)); + // Read by length only, so the buckets stay unsorted for the kernel. + await checkLengths(scenario); + await scenario.world.run(orderSum); + await scenario.world.run(orderSumByHand); + sums = { kernel: [], byHand: [] }; + await scenario.world.run(readSums); + const expected = expectedRows(scenario).map(orderSumOf); + expect(sums.kernel).toEqual(expected); + expect(sums.byHand).toEqual(expected); + } + }); +}); diff --git a/packages/ecs/src/implementation/world/index-build-csp.test.ts b/packages/ecs/src/implementation/world/index-build-csp.test.ts index 6092ba392..7144013ab 100644 --- a/packages/ecs/src/implementation/world/index-build-csp.test.ts +++ b/packages/ecs/src/implementation/world/index-build-csp.test.ts @@ -1,27 +1,25 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; -import { Component } from "../../interface/component/component.js"; import { Index } from "../../interface/indexes/index-type.js"; import { workers } from "../../interface/resources/workers.js"; import { System } from "../../interface/system/system.js"; import { createWorld } from "./create.js"; +import type { Vec2 } from "../../interface/math/index.js"; +import type { Schema } from "../../interface/schema/schema.js"; const spot = Symbol("indexBuildCspTest/spot"); const size = Symbol("indexBuildCspTest/size"); const bulk = Symbol("indexBuildCspTest/bulk"); declare module "@adobe/ecs" { interface Components { - [spot]: { type: readonly [number, number] }; + [spot]: { type: Vec2.I32 }; [size]: { type: number }; - [bulk]: { type: number }; + [bulk]: { type: Schema.Type<{ type: "number"; precision: 2 }> }; } } -Component.register(spot, { type: { type: "array", items: { type: "integer", minimum: -1000, maximum: 1000 }, minItems: 2, maxItems: 2 } }); -Component.register(size, { type: { type: "number" } }); -Component.register(bulk, { type: { type: "number", precision: 2 } }); /** A key of a pair of i32s and an f64, so the steps encode three kinds of leaf. */ -const bySpot = Index.create({ key: [spot, bulk], components: { has: [size] } }); +const bySpot = Index.create({ key: [spot, bulk], components: { with: [size] } }); const count = 70_000; describe("index builds in phases where code can't be generated", () => { @@ -35,14 +33,14 @@ describe("index builds in phases where code can't be generated", () => { for (let k = 0; k < count; k++) commands.spawn([spot, size, bulk])({ [spot]: [k % 37 - 18, k % 11], [size]: k, [bulk]: (k % 3) / 4 }); })); const expected = new Map(); - await world.run(System.create({ components: { read: [spot, size, bulk] } }, row => { + await world.run(System.create({ components: { with: [spot, size, bulk] } }, row => { const text = `${row[spot][0]},${row[spot][1]},${row[bulk]}`; const held = expected.get(text) ?? []; held.push(row[size]); expected.set(text, held); })); const found = new Map(); - await world.run(System.create({ lookup: { at: { index: bySpot, read: [size] } } }, ({ lookup }) => { + await world.run(System.create({ lookup: { at: { index: bySpot } } }, ({ lookup }) => { for (const text of expected.keys()) { const [x, y, b] = text.split(",").map(Number); const list = lookup.at([x, y], b); diff --git a/packages/ecs/src/implementation/world/index-build.fixture.ts b/packages/ecs/src/implementation/world/index-build.fixture.ts index 003233c67..6043e9b53 100644 --- a/packages/ecs/src/implementation/world/index-build.fixture.ts +++ b/packages/ecs/src/implementation/world/index-build.fixture.ts @@ -1,10 +1,10 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Component } from "../../interface/component/component.js"; import { Index } from "../../interface/indexes/index-type.js"; import { workers } from "../../interface/resources/workers.js"; import { System } from "../../interface/system/system.js"; import { createWorld } from "./create.js"; import { runReported } from "./reports.test-helper.js"; +import type { U32, Vec2 } from "../../interface/math/index.js"; /** Scenarios of indexes built again across the pool, run built in node and from source in chromium. */ @@ -14,37 +14,34 @@ const weight = Symbol("indexBuildTest/weight"); declare module "@adobe/ecs" { interface Components { - [cell]: { type: readonly [number, number] }; - [serial]: { type: number }; + [cell]: { type: Vec2.I32 }; + [serial]: { type: U32 }; [weight]: { type: number }; } } -Component.register(cell, { type: { type: "array", items: { type: "integer", minimum: -1000, maximum: 1000 }, minItems: 2, maxItems: 2 } }); -Component.register(serial, { type: { type: "integer", minimum: 0, maximum: 10000000 } }); -Component.register(weight, { type: { type: "number" } }); -const grid = Index.create({ name: "indexBuildTest/grid", key: [cell], components: { has: [weight] } }); -const serials = Index.create({ name: "indexBuildTest/serials", key: [serial], unique: true }); +const grid = Index.create({ key: [cell], components: { with: [weight] } }); +const serials = Index.create({ key: [serial], unique: true }); const width = 300; const height = 200; const count = 150_000; /** Spawns entities in cells, with serials of which a few repeat. */ -const seed = System.create({ name: "indexBuildTest/seed" }, ({ commands }) => { +const seed = System.create({}, ({ commands }) => { for (let k = 0; k < count; k++) { commands.spawn([cell, serial, weight])({ [cell]: [(k * 7919) % width, (k * 104729) % height], [serial]: k % 50_000 === 7 ? 7 : k, [weight]: k % 13 }); } }); /** Moves every entity to another cell, so the next refresh builds the index again. */ -const shuffle = System.create({ name: "indexBuildTest/shuffle", components: { write: [cell] } }, row => { +const shuffle = System.create({ components: { with: [cell] } }, row => { const [x, y] = row[cell]; row[cell] = [(x * 7 + 3) % width, (y + x) % height]; }); /** A kernel, so the pool starts, as it does with a world's first kernel. */ -const warm = System.create({ name: "indexBuildTest/warm", kernel: "cpu", components: { read: [serial], write: [weight] } }, row => { +const warm = System.create({ kernel: true, components: { with: [serial, weight] } }, row => { row[weight] = row[serial] % 13; }); @@ -52,8 +49,7 @@ const seen = { grid: 0, rows: 0, serials: 0 }; /** Every cell's rows, in order, folded into one number; and the unique index's row of each serial. */ const read = System.create({ - name: "indexBuildTest/read", - lookup: { near: { index: grid, read: [weight] }, bySerial: { index: serials } }, + lookup: { near: { index: grid }, bySerial: { index: serials } }, }, ({ lookup }) => { let fold = 0; let rows = 0; @@ -75,7 +71,7 @@ const read = System.create({ }); /** The same folds without an index: each cell's rows sorted by entity, and each serial's lowest entity. */ -const reference = System.create({ name: "indexBuildTest/reference", components: { read: [cell, serial, weight] } }, row => { +const reference = System.create({ components: { with: [cell, serial, weight] } }, row => { const [x, y] = row[cell]; const cellRows = byCell[x * height + y] ??= []; cellRows.push([row.entity, row[weight]]); diff --git a/packages/ecs/src/implementation/world/index-changed.test.ts b/packages/ecs/src/implementation/world/index-changed.test.ts index 8340898c5..40116ec0b 100644 --- a/packages/ecs/src/implementation/world/index-changed.test.ts +++ b/packages/ecs/src/implementation/world/index-changed.test.ts @@ -1,13 +1,13 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; -import { Component } from "../../interface/component/component.js"; +import { giveAccess } from "../../interface/registry/ids.test-helper.js"; import { Entity } from "../../interface/entity/entity.js"; import { Finalizer } from "../../interface/finalizer/finalizer.js"; import { Index } from "../../interface/indexes/index-type.js"; -import { Resource } from "../../interface/resource/resource.js"; import { workers } from "../../interface/resources/workers.js"; import { System } from "../../interface/system/system.js"; import { createWorld } from "./create.js"; +import type { U32 } from "../../interface/math/index.js"; // An index lookup's `changed: true`: the index's entries changing is a change filter of its system. @@ -18,7 +18,7 @@ const paused = Symbol("indexChangedTest/paused"); declare module "@adobe/ecs" { interface Components { - [group]: { type: number }; + [group]: { type: U32 }; [weight]: { type: number }; [total]: { type: number }; } @@ -26,40 +26,35 @@ declare module "@adobe/ecs" { [paused]: { type: boolean }; } } -Component.register(group, { type: { type: "integer", minimum: 0, maximum: 1000 } }); -Component.register(weight, { type: { type: "number" } }); -Component.register(total, { type: { type: "number" } }); -Resource.register(paused, { type: { type: "boolean", default: false } }); -const byGroup = Index.create({ name: "indexChangedTest/byGroup", key: [group], components: { has: [weight] } }); -const inOrder = Index.create({ name: "indexChangedTest/inOrder", key: [group], sorted: true }); -const weighted = Index.create({ name: "indexChangedTest/weighted", key: [], components: { has: [weight] } }); +const byGroup = Index.create({ key: [group], components: { with: [weight] } }); +const inOrder = Index.create({ key: [group], sorted: true }); +const weighted = Index.create({ key: [], components: { with: [weight] } }); const one = { type: "object", properties: { entity: Entity.schema, value: { type: "number" } } } as const; -const entityOnly = { type: "object", properties: { entity: Entity.schema } } as const; let spawned: Entity[] = []; -const spawn = System.create({ name: "indexChangedTest/spawn", input: { type: "object", properties: { groups: { type: "array", items: { type: "integer" } } } } }, ({ input, commands }) => { +const spawn = System.create({}, ({ commands }, input: { readonly groups: readonly number[] }) => { for (const value of input.groups) spawned.push(commands.spawn([group, weight, total])({ [group]: value, [weight]: 1, [total]: 0 })); }); -const setGroup = System.create({ name: "indexChangedTest/setGroup", input: one, target: "entity", components: { write: [group] } }, (row, { input }) => { +const setGroup = System.create({ target: "entity", components: { with: [group] } }, (row, _context, input: { readonly entity: Entity; readonly value: number }) => { row[group] = input.value; }); -const setWeight = System.create({ name: "indexChangedTest/setWeight", input: one, target: "entity", components: { write: [weight] } }, (row, { input }) => { +const setWeight = System.create({ target: "entity", components: { with: [weight] } }, (row, _context, input: { readonly entity: Entity; readonly value: number }) => { row[weight] = input.value; }); -const despawn = System.create({ name: "indexChangedTest/despawn", input: entityOnly, target: "entity", components: { has: [group] } }, (row, { commands }) => { +const despawn = System.create({ target: "entity", components: { with: [group] } }, (row, { commands }, _input: { readonly entity: Entity }) => { commands.despawn(row.entity); }); -const dropWeight = System.create({ name: "indexChangedTest/dropWeight", input: entityOnly, target: "entity", components: { has: [weight] } }, (row, { commands }) => { +const dropWeight = System.create({ target: "entity", components: { with: [weight] } }, (row, { commands }, _input: { readonly entity: Entity }) => { commands.remove(row.entity, weight); }); -const pause = System.create({ name: "indexChangedTest/pause", input: { type: "object", properties: { paused: { type: "boolean" } } }, resources: { write: [paused] } }, ({ input, resources }) => { +const pause = System.create({}, ({ resources }, input: { readonly paused: boolean }) => { resources[paused] = input.paused; }); /** What each run of a gated system saw: its lists' lengths and `changed` for groups 1 and 2. */ let seen: string[] = []; -const counting = System.create({ name: "indexChangedTest/counting", lookup: { byGroup: { index: byGroup, changed: true } } }, ({ lookup }) => { +const counting = System.create({ lookup: { byGroup: { index: byGroup, changed: true } } }, ({ lookup }) => { const first = lookup.byGroup(1); const second = lookup.byGroup(2); seen.push(`${first.length}${first.changed ? "*" : ""} ${second.length}${second.changed ? "*" : ""}`); @@ -67,8 +62,8 @@ const counting = System.create({ name: "indexChangedTest/counting", lookup: { by /** Each row's total: how many rows share its group, by hundreds, and its weight; run only when the groups changed. */ const tally = System.create({ - name: "indexChangedTest/tally", kernel: "cpu", - components: { read: [group, weight], write: [total] }, + kernel: true, + components: { with: [group, weight, total] }, lookup: { byGroup: { index: byGroup, changed: true } }, }, (row, { lookup }) => { row[total] = lookup.byGroup(row[group]).length * 100 + row[weight]; @@ -114,7 +109,7 @@ describe("an index lookup's changed: true", () => { await using world = inline(); spawned = []; const orders: string[] = []; - const ordering = System.create({ name: "indexChangedTest/ordering", observer: true, lookup: { inOrder: { index: inOrder, changed: true } } }, ({ lookup }) => { + const ordering = System.create({ observer: true, lookup: { inOrder: { index: inOrder, changed: true } } }, ({ lookup }) => { const all = lookup.inOrder(); const groups: number[] = []; for (let i = 0; i < all.length; i++) groups.push(all.at(i).entity === spawned[0] ? -1 : 0); @@ -137,8 +132,7 @@ describe("an index lookup's changed: true", () => { spawned = []; const flags: boolean[] = []; const watching = System.create({ - name: "indexChangedTest/watching", - lookup: { byGroup: { index: byGroup, changed: true }, weights: { read: [weight], changed: [weight] } }, + lookup: { byGroup: { index: byGroup, changed: true }, weights: { with: [weight], changed: [weight] } }, }, ({ lookup }) => { flags.push(lookup.weights(spawned[0])?.changed ?? false); }); @@ -158,8 +152,7 @@ describe("an index lookup's changed: true", () => { spawned = []; const rows: number[] = []; const filtering = System.create({ - name: "indexChangedTest/filtering", - components: { read: [weight], changed: [weight] }, + components: { with: [weight], changed: [weight] }, lookup: { byGroup: { index: byGroup, changed: true } }, }, row => { rows.push(row[weight]); @@ -185,7 +178,6 @@ describe("an index lookup's changed: true", () => { spawned = []; let runs = 0; const both = System.create({ - name: "indexChangedTest/both", resources: { not: [paused] }, lookup: { byGroup: { index: byGroup, changed: true }, weighted: { index: weighted, changed: true } }, }, () => { @@ -213,7 +205,7 @@ describe("an index lookup's changed: true", () => { await using world = inline(); spawned = []; const totals: number[] = []; - const readTotals = System.create({ name: "indexChangedTest/readTotals", observer: true, components: { read: [total] } }, row => { + const readTotals = System.create({ observer: true, components: { with: [total] } }, row => { totals.push(row[total]); }); const totalsOf = async (): Promise => { @@ -233,11 +225,11 @@ describe("an index lookup's changed: true", () => { }); it("is only for a system's index lookups", () => { - const untypedSystem = (declaration: object): unknown => Reflect.apply(System.create, undefined, [declaration, () => {}]); - const untypedFinalizer = (declaration: object): unknown => Reflect.apply(Finalizer.create, undefined, [declaration, () => {}]); - expect(() => untypedSystem({ name: "indexChangedTest/byEntity", lookup: { weights: { read: [weight], changed: true } } })) - .toThrow(`System "indexChangedTest/byEntity": lookup "weights"'s changed is true, which only an index lookup takes; name the components it watches.`); - expect(() => untypedFinalizer({ name: "indexChangedTest/finalizer", components: { read: [group] }, lookup: { byGroup: { index: byGroup, changed: true } } })) - .toThrow(`Finalizer "indexChangedTest/finalizer": lookup "byGroup"'s changed is true, which only a system's index lookup takes; a finalizer runs on each entity that stops matching.`); + const untypedSystem = (declaration: object): unknown => Reflect.apply(System.create, undefined, [giveAccess(declaration), () => {}]); + const untypedFinalizer = (declaration: object): unknown => Reflect.apply(Finalizer.create, undefined, [giveAccess(declaration), () => {}]); + expect(() => untypedSystem({ lookup: { weights: { with: [weight], changed: true } } })) + .toThrow(`lookup "weights"'s changed is true, which only an index lookup takes; name the components it watches.`); + expect(() => untypedFinalizer({ components: { with: [group] }, lookup: { byGroup: { index: byGroup, changed: true } } })) + .toThrow(`lookup "byGroup"'s changed is true, which only a system's index lookup takes; a finalizer runs on each entity that stops matching.`); }); }); diff --git a/packages/ecs/src/implementation/world/index-hash.test.ts b/packages/ecs/src/implementation/world/index-hash.test.ts new file mode 100644 index 000000000..bd2fdf441 --- /dev/null +++ b/packages/ecs/src/implementation/world/index-hash.test.ts @@ -0,0 +1,34 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { describe, expect, it } from "vitest"; +import { bucketFor, coverEntities, createHashTable, entryWords, insertEntry, lengthOf, removeEntry, sortBucket, startOf } from "./index-hash.js"; +import { hashWords } from "./index-keys.js"; +import { createMemory } from "./memory.js"; + +describe("a hash table's unsorted buckets", () => { + it("are listed once each, however often one is sorted and left unsorted again while another stays unsorted", () => { + const table = createHashTable(createMemory(), 1); + coverEntities(table, 400); + const key = new Uint32Array(1); + const bucketOf = (value: number): number => { + key[0] = value; + return bucketFor(table, key, 0, hashWords(key, 0, 1), 0); + }; + const buckets = [bucketOf(0), bucketOf(1)]; + for (let entity = 1; entity <= 300; entity++) insertEntry(table, buckets[entity % 2], entity, 0, entity, 0, true); + // One entity leaves the first bucket once: it stays unsorted. + removeEntry(table, buckets[0], 2, 1, true); + let out = false; + for (let run = 0; run < 500; run++) { + // An entity of the second bucket leaves it or comes back, and a read by position sorts it. + if (out) insertEntry(table, buckets[1], 151, 0, 151, run, true); + else removeEntry(table, buckets[1], 151, run, true); + out = !out; + if (table.tails[buckets[1]] >= 0) sortBucket(table, buckets[1]); + } + expect(table.unsortedListed).toBeLessThanOrEqual(2); + sortBucket(table, buckets[0]); + const entities = (bucket: number): number[] => Array.from({ length: lengthOf(table, bucket) }, (_, k) => table.entries[(startOf(table, bucket) + k) * entryWords]); + expect(entities(buckets[0])).toEqual(Array.from({ length: 149 }, (_, k) => 2 * k + 4)); + expect(entities(buckets[1])).toEqual(Array.from({ length: 150 }, (_, k) => 2 * k + 1)); + }); +}); diff --git a/packages/ecs/src/implementation/world/index-hash.ts b/packages/ecs/src/implementation/world/index-hash.ts index b0b55d361..26b476d7e 100644 --- a/packages/ecs/src/implementation/world/index-hash.ts +++ b/packages/ecs/src/implementation/world/index-hash.ts @@ -18,9 +18,19 @@ const freedCapacity = 0xFFFFFFFF; /** * A hash index: an open-addressing table of buckets by key, linear probing, at most half full, each slot a key's hash * and its bucket, so a probe reads one line until a hash matches; each bucket a record of its hash, segment and key, - * its segment of one entries buffer with spare slots holding its entries in entity order. Every array is in the arena, - * so other threads read it where the world's thread wrote it. By entity index, the bucket it's filed in, so its old - * bucket is found without its old key. + * its segment of one entries buffer with spare slots holding its entries in entity order. Every array readers use is + * in the arena, so other threads read it where the world's thread wrote it. By entity index, the bucket it's filed in, + * so its old bucket is found without its old key. + * + * A change to a bucket of at least `unsortedMinimum` entries, in an index that isn't unique, may leave it unsorted: + * its entries in entity order up to its tail, with holes where entries left (archetype -1, the entity index kept for + * binary search), then a tail of entries appended in any order, each one's position kept by entity index. Its + * record's length counts its entries, not its holes, so reading `length` needs no order. It's sorted before anything + * reads its entries by position: on the world's thread by a list's `at`, and every unsorted bucket before the index's + * arrays are handed to a kernel or another thread; when its segment is full and has holes, which sorting closes in + * place; and once its holes and tail pass four times its entries. A segment an unsorted bucket outgrows gets half its + * length more spare slots, so sorts to make room come once every so many changes, O(1) amortized each. What only + * unsorted buckets use is the world's thread's alone, outside the arena. */ export type HashTable = { readonly memory: Memory; @@ -48,16 +58,42 @@ export type HashTable = { size: number; /** By component a lookup watches: by bucket, the newest tick its value may have changed in a row there. */ readonly watched: Map; + /** How many buckets are unsorted, and a list holding each of them once, and some sorted since; by bucket, 1 when listed. */ + unsorted: number; + unsortedBuckets: Int32Array; + unsortedListed: number; + listed: Uint8Array; + /** + * By bucket id: where its tail starts, or -1 when it's sorted; the slots its entries and holes take; and a position + * no hole is before. + */ + tails: Int32Array; + extents: Int32Array; + holes: Int32Array; + /** By entity index: its position in its bucket's tail, valid only while it's there. */ + positionOf: Int32Array; + /** Scratch for sorting a tail: twice its words. */ + spare: Int32Array; + /** The entries as 8-byte words, and a scratch as 8- and 4-byte words, for long moves (`moveWide`). */ + wide: Float64Array; + scratch: Float64Array; + scratchWords: Int32Array; }; /** What lookups read of a hash table: on the world's thread the table, on a joined thread its views of it. */ -export type HashView = Pick & { readonly watched: ReadonlyMap }; +export type HashView = Pick & { readonly watched: ReadonlyMap }; + +/** Whether a view is the world's table itself, whose unsorted buckets a list sorts before reading them by position. */ +export const isHashTable = (view: HashView): view is HashTable => "tails" in view; const minimumSlots = 16; /** Slots the table keeps for each bucket at least: it's at most half full. */ const slotsPerBucket = 2; +/** Entries a bucket holds at least before a change may leave it unsorted; a shorter one shifts its entries. */ +export const unsortedMinimum = 64; + /** Whether `slots` slots hold `buckets` buckets. */ export const slotsHold = (slots: number, buckets: number): boolean => slotsPerBucket * buckets + 2 <= slots; @@ -78,6 +114,18 @@ export const createHashTable = (memory: Memory, words: number): HashTable => ({ bucketOf: allocateArray(memory, Int32Array, 64).fill(-1), size: 0, watched: new Map(), + unsorted: 0, + unsortedBuckets: new Int32Array(8), + unsortedListed: 0, + listed: new Uint8Array(8), + tails: new Int32Array(8).fill(-1), + extents: new Int32Array(8), + holes: new Int32Array(8), + positionOf: new Int32Array(64), + spare: new Int32Array(0), + wide: new Float64Array(0), + scratch: new Float64Array(0), + scratchWords: new Int32Array(0), }); /** Frees every array, once nothing reads the table again. */ @@ -96,10 +144,19 @@ const regrow = (memory: Memor return grown; }; +/** A copy of `array`, `length` long, the rest filled with `fill`. */ +const grown = (array: Int32Array, length: number, fill: number): Int32Array => { + const copy = new Int32Array(length); + copy.set(array); + if (fill !== 0) copy.fill(fill, array.length); + return copy; +}; + /** Grows what's by entity index to cover `length` indexes. */ export const coverEntities = (table: HashTable, length: number): void => { if (table.bucketOf.length >= length) return; table.bucketOf = regrow(table.memory, table.bucketOf, Int32Array, Math.max(length, 2 * table.bucketOf.length), table.bucketOf.length, -1); + table.positionOf = grown(table.positionOf, table.bucketOf.length, 0); }; /** Grows what's by bucket id to hold `count` buckets. */ @@ -111,6 +168,12 @@ export const coverBuckets = (table: HashTable, count: number): void => { table.records = regrow(memory, table.records, Uint32Array, length * stride, capacity * stride, 0); table.ticks = regrow(memory, table.ticks, Float64Array, length, capacity, 0); for (const [component, ticks] of table.watched) table.watched.set(component, regrow(memory, ticks, Float64Array, length, capacity, 0)); + table.tails = grown(table.tails, length, -1); + table.extents = grown(table.extents, length, 0); + table.holes = grown(table.holes, length, 0); + const listed = new Uint8Array(length); + listed.set(table.listed); + table.listed = listed; }; /** A bucket's segment's first entry, length and capacity. */ @@ -203,29 +266,110 @@ const allocateSegment = (table: HashTable, capacity: number): number => { return start; }; -/** Gives a bucket room for `length` entries, moving its segment to the end of the buffer when it doesn't fit. */ -const reserve = (table: HashTable, bucket: number, length: number): void => { +/** The slots a bucket's entries take, with an unsorted one's holes. */ +const extentOf = (table: HashTable, bucket: number): number => table.tails[bucket] < 0 ? lengthOf(table, bucket) : table.extents[bucket]; + +/** + * Gives a bucket room for `slots` slots, moving its segment to the end of the buffer when they don't fit; an unsorted + * one's gets half as many again spare, since its tail grows into them. + */ +const reserve = (table: HashTable, bucket: number, slots: number): void => { const base = bucket * table.stride; - if (table.records[base + recordCapacity] >= length) return; - const capacity = capacityFor(length); + if (table.records[base + recordCapacity] >= slots) return; + const capacity = capacityFor(slots) + (table.tails[bucket] >= 0 ? slots >> 1 : 0); + const kept = extentOf(table, bucket); const start = allocateSegment(table, capacity); - const { entries, records } = table; - const from = records[base + recordStart] * entryWords; - entries.copyWithin(start * entryWords, from, from + records[base + recordLength] * entryWords); + const { records } = table; + move(table, start * entryWords, records[base + recordStart] * entryWords, kept * entryWords); table.garbage += records[base + recordCapacity]; records[base + recordStart] = start; records[base + recordCapacity] = capacity; }; +/** The table's entries as 8-byte words, made again when its entries were. */ +const wideOf = (table: HashTable): Float64Array => { + const { entries } = table; + // Its last 8-byte word may take 4 bytes past the entries, which are in its block: blocks are 64-byte aligned. + const length = (entries.length + 1) >> 1; + if (table.wide.buffer !== entries.buffer || table.wide.byteOffset !== entries.byteOffset || table.wide.length !== length) { + table.wide = new Float64Array(entries.buffer, entries.byteOffset, length); + } + return table.wide; +}; + +/** Scratch of at least `words` 4-byte words, as 4- and 8-byte words. */ +const scratchOf = (table: HashTable, words: number): Float64Array => { + if (2 * table.scratch.length < words) { + table.scratch = new Float64Array(Math.max((words + 1) >> 1, 2 * table.scratch.length)); + table.scratchWords = new Int32Array(table.scratch.buffer); + } + return table.scratch; +}; + /** - * Moves `words` of entries, a multiple of `entryWords`, from `from` to `to` within one buffer: by hand, an entry a step, - * when few, which a native call would cost more than. + * Words a move by an odd number of words takes at least before it goes through the scratch, which costs two views of + * it; a shorter one copies a word at a time, allocating nothing. A move this long takes about a microsecond, the views + * tens of nanoseconds. */ -const move = (entries: Int32Array, to: number, from: number, words: number): void => { - if (words > 96) { +const oddWideMinimum = 1024; + +/** + * Moves `words` of entries from `from` to `to` in a table's buffer. Shared memory copies an element at a time, so a + * long move copies 8-byte words, 8 times faster than 4-byte ones: directly when both ends have the same parity, an + * edge word by hand; else, past `oddWideMinimum`, through a scratch, which shifts them by a word, the target's edge + * words kept. + */ +const moveWide = (table: HashTable, to: number, from: number, words: number): void => { + const { entries } = table; + if (!table.memory.shared) { entries.copyWithin(to, from, from + words); return; } + const wide = wideOf(table); + if (((to ^ from) & 1) === 0) { + const odd = from & 1; + const even = (words - odd) & ~1; + const rest = words - odd - even; + if (to < from) { + if (odd === 1) entries[to] = entries[from]; + wide.copyWithin((to + odd) >> 1, (from + odd) >> 1, (from + odd + even) >> 1); + if (rest === 1) entries[to + words - 1] = entries[from + words - 1]; + } + else { + if (rest === 1) entries[to + words - 1] = entries[from + words - 1]; + wide.copyWithin((to + odd) >> 1, (from + odd) >> 1, (from + odd + even) >> 1); + if (odd === 1) entries[to] = entries[from]; + } + return; + } + if (words < oddWideMinimum) { + if (to < from) for (let k = 0; k < words; k++) entries[to + k] = entries[from + k]; + else for (let k = words - 1; k >= 0; k--) entries[to + k] = entries[from + k]; + return; + } + const sourceFirst = from & ~1; + const sourceEnd = (from + words + 1) & ~1; + const targetFirst = to & ~1; + const targetEnd = (to + words + 1) & ~1; + const scratch = scratchOf(table, Math.max(sourceEnd - sourceFirst, targetEnd - targetFirst) + 2); + const scratchWords = table.scratchWords; + scratch.set(wide.subarray(sourceFirst >> 1, sourceEnd >> 1)); + scratchWords.copyWithin(to - targetFirst, from - sourceFirst, from - sourceFirst + words); + if ((to & 1) === 1) scratchWords[0] = entries[targetFirst]; + if (((to + words) & 1) === 1) scratchWords[targetEnd - targetFirst - 1] = entries[to + words]; + wide.set(scratch.subarray(0, (targetEnd - targetFirst) >> 1), targetFirst >> 1); +}; + +/** + * Moves `words` of entries, a multiple of `entryWords`, from `from` to `to` within a table's buffer: by hand, an entry + * a step, when few, which a native call would cost more than. + */ +const move = (table: HashTable, to: number, from: number, words: number): void => { + const { entries } = table; + if (words > 96) { + moveWide(table, to, from, words); + return; + } if (to < from) { for (let k = 0; k < words; k += 3) { entries[to + k] = entries[from + k]; @@ -242,15 +386,16 @@ const move = (entries: Int32Array, to: number, from: number, words: number): voi } }; -/** Where entity index `index` is in a bucket, or where it would go, by binary search: `~position` when it isn't there. */ -export const positionIn = (table: HashTable, bucket: number, index: number): number => { - const { entries } = table; - const base = startOf(table, bucket); +/** + * Where entity index `index` is among the `end` entries in entity order from entry `start`, holes included, or where it + * would go, by binary search: `~position` when it isn't there. + */ +const search = (entries: Int32Array, start: number, end: number, index: number): number => { let low = 0; - let high = lengthOf(table, bucket); + let high = end; while (low < high) { const middle = (low + high) >>> 1; - const at = entries[(base + middle) * entryWords]; + const at = entries[(start + middle) * entryWords]; if (at < index) low = middle + 1; else if (at > index) high = middle; else return middle; @@ -258,9 +403,24 @@ export const positionIn = (table: HashTable, bucket: number, index: number): num return ~low; }; +/** Where entity index `index`'s entry is in a bucket, or -1 when it isn't there. */ +const findEntry = (table: HashTable, bucket: number, index: number): number => { + const { entries } = table; + const start = startOf(table, bucket); + const tail = table.tails[bucket]; + if (tail < 0) { + const position = search(entries, start, lengthOf(table, bucket), index); + return position < 0 ? -1 : position; + } + const position = table.positionOf[index]; + if (position >= tail && position < table.extents[bucket] && entries[(start + position) * entryWords] === index) return position; + const found = search(entries, start, tail, index); + return found >= 0 && entries[(start + found) * entryWords + 1] >= 0 ? found : -1; +}; + /** Updates where entity index `index` is: its archetype and row, in the bucket it's filed in. */ export const relocate = (table: HashTable, bucket: number, index: number, archetype: number, row: number): void => { - const position = positionIn(table, bucket, index); + const position = findEntry(table, bucket, index); if (position < 0) return; const at = (startOf(table, bucket) + position) * entryWords; table.entries[at + 1] = archetype; @@ -273,82 +433,299 @@ const countEmpty = (table: HashTable, before: number, after: number): void => { else if (before > 0 && after === 0) table.empty++; }; -/** Removes entity index `index` from a bucket, stamping it. */ -export const removeEntry = (table: HashTable, bucket: number, index: number, tick: number): void => { - const position = positionIn(table, bucket, index); - if (position < 0) return; +/** Empties the list of unsorted buckets, each one listed no longer. */ +const clearList = (table: HashTable): void => { + const list = table.unsortedBuckets; + for (let k = 0; k < table.unsortedListed; k++) table.listed[list[k]] = 0; + table.unsortedListed = 0; +}; + +/** Marks a sorted bucket unsorted, with an empty tail and no holes, listing it unless it's listed still. */ +const unsort = (table: HashTable, bucket: number): void => { + const length = lengthOf(table, bucket); + table.tails[bucket] = length; + table.extents[bucket] = length; + table.holes[bucket] = length; + table.unsorted++; + if (table.listed[bucket] === 1) return; + let listed = table.unsortedListed; + const list = table.unsortedBuckets; + // The list drops the buckets sorted since they were listed once they're most of it, so each listing costs O(1) + // amortized. + if (listed >= 2 * table.unsorted + 8) { + let kept = 0; + for (let k = 0; k < listed; k++) { + if (table.tails[list[k]] >= 0) list[kept++] = list[k]; + else table.listed[list[k]] = 0; + } + listed = kept; + } + if (listed === list.length) table.unsortedBuckets = grown(list, 2 * listed, 0); + table.unsortedBuckets[listed] = bucket; + table.unsortedListed = listed + 1; + table.listed[bucket] = 1; +}; + +/** Marks an unsorted bucket sorted. */ +const sorted = (table: HashTable, bucket: number): void => { + table.tails[bucket] = -1; + if (--table.unsorted === 0) clearList(table); +}; + +/** + * After a change to an unsorted bucket: sorted when it's empty or in order, or once its holes and tail pass four times + * its entries and 64 more, which bounds what a bucket nothing reads by position keeps. + */ +const settle = (table: HashTable, bucket: number): void => { + const length = lengthOf(table, bucket); + const extent = table.extents[bucket]; + const tail = table.tails[bucket]; + if (length === 0 || (extent === length && tail === extent)) sorted(table, bucket); + else if (2 * extent - length - tail > (length << 2) + unsortedMinimum) sortBucket(table, bucket); +}; + +/** Removes entity index `index` from a bucket, stamping it; a long one may be left unsorted when `unsorting`. */ +export const removeEntry = (table: HashTable, bucket: number, index: number, tick: number, unsorting: boolean): void => { const record = bucket * table.stride; const length = table.records[record + recordLength]; - const base = table.records[record + recordStart] * entryWords; - move(table.entries, base + position * entryWords, base + (position + 1) * entryWords, (length - position - 1) * entryWords); + const start = table.records[record + recordStart]; + const { entries } = table; + const tail = table.tails[bucket]; + if (tail < 0) { + const position = search(entries, start, length, index); + if (position < 0) return; + if (position < length - 1) { + if (!unsorting || length < unsortedMinimum) { + const base = start * entryWords; + move(table, base + position * entryWords, base + (position + 1) * entryWords, (length - position - 1) * entryWords); + } + else { + unsort(table, bucket); + entries[(start + position) * entryWords + 1] = -1; + table.holes[bucket] = position; + } + } + } + else { + const extent = table.extents[bucket]; + const position = table.positionOf[index]; + if (position >= tail && position < extent && entries[(start + position) * entryWords] === index) { + // Out of the tail, its last entry taking its place. + const at = (start + position) * entryWords; + const last = (start + extent - 1) * entryWords; + if (at !== last) { + entries[at] = entries[last]; + entries[at + 1] = entries[last + 1]; + entries[at + 2] = entries[last + 2]; + table.positionOf[entries[at]] = position; + } + table.extents[bucket] = extent - 1; + } + else { + const found = search(entries, start, tail, index); + if (found < 0 || entries[(start + found) * entryWords + 1] < 0) return; + if (found === tail - 1 && tail === extent) { + table.tails[bucket] = tail - 1; + table.extents[bucket] = extent - 1; + table.holes[bucket] = Math.min(table.holes[bucket], tail - 1); + } + else { + entries[(start + found) * entryWords + 1] = -1; + table.holes[bucket] = Math.min(table.holes[bucket], found); + } + } + } table.records[record + recordLength] = length - 1; table.ticks[bucket] = tick; table.size--; countEmpty(table, length, length - 1); + if (table.tails[bucket] >= 0) settle(table, bucket); }; -/** Inserts entity index `index` at its archetype and row into a bucket, in entity order, stamping it. */ -export const insertEntry = (table: HashTable, bucket: number, index: number, archetype: number, row: number, tick: number): void => { - const record = bucket * table.stride; - const length = table.records[record + recordLength]; - reserve(table, bucket, length + 1); - const found = positionIn(table, bucket, index); - const position = found < 0 ? ~found : found; - const base = table.records[record + recordStart] * entryWords; +/** Appends an entry to an unsorted bucket's tail. */ +const appendTail = (table: HashTable, bucket: number, index: number, archetype: number, row: number): void => { + const extent = table.extents[bucket]; + reserve(table, bucket, extent + 1); + const at = (startOf(table, bucket) + extent) * entryWords; const { entries } = table; - move(entries, base + (position + 1) * entryWords, base + position * entryWords, (length - position) * entryWords); - const at = base + position * entryWords; entries[at] = index; entries[at + 1] = archetype; entries[at + 2] = row; + table.positionOf[index] = extent; + table.extents[bucket] = extent + 1; +}; + +/** + * Inserts entity index `index`, which the bucket doesn't hold, at its archetype and row, stamping it: in entity order, + * or when `unsorting`, into a long bucket's tail, or the hole it left. + */ +export const insertEntry = (table: HashTable, bucket: number, index: number, archetype: number, row: number, tick: number, unsorting: boolean): void => { + const record = bucket * table.stride; + const length = table.records[record + recordLength]; + // A full unsorted bucket with holes closes them in place rather than moving its segment to grow it. + if (table.tails[bucket] >= 0 && table.extents[bucket] > length && table.extents[bucket] >= table.records[record + recordCapacity]) sortBucket(table, bucket); + const tail = table.tails[bucket]; + if (tail < 0) { + reserve(table, bucket, length + 1); + const start = table.records[record + recordStart]; + const { entries } = table; + const after = length === 0 || entries[(start + length - 1) * entryWords] < index; + if (after || !unsorting || length < unsortedMinimum) { + const found = after ? ~length : search(entries, start, length, index); + const position = found < 0 ? ~found : found; + const base = start * entryWords; + move(table, base + (position + 1) * entryWords, base + position * entryWords, (length - position) * entryWords); + const at = base + position * entryWords; + entries[at] = index; + entries[at + 1] = archetype; + entries[at + 2] = row; + } + else { + unsort(table, bucket); + appendTail(table, bucket, index, archetype, row); + } + } + else { + const start = table.records[record + recordStart]; + const { entries } = table; + const extent = table.extents[bucket]; + const found = search(entries, start, tail, index); + if (found >= 0) { + // The hole it left. + const at = (start + found) * entryWords; + entries[at + 1] = archetype; + entries[at + 2] = row; + } + else if (~found === tail && tail === extent) { + // After every entry in order, with no tail: still in order. + reserve(table, bucket, extent + 1); + const at = (startOf(table, bucket) + extent) * entryWords; + table.entries[at] = index; + table.entries[at + 1] = archetype; + table.entries[at + 2] = row; + if (table.holes[bucket] === tail) table.holes[bucket] = tail + 1; + table.tails[bucket] = tail + 1; + table.extents[bucket] = extent + 1; + } + else appendTail(table, bucket, index, archetype, row); + } table.records[record + recordLength] = length + 1; table.ticks[bucket] = tick; table.size++; countEmpty(table, length, length + 1); + if (table.tails[bucket] >= 0) settle(table, bucket); +}; + +/** Copies entry `from` of `source` to entry `to` of `target`. */ +const copyEntry = (target: Int32Array, to: number, source: Int32Array, from: number): void => { + target[to * entryWords] = source[from * entryWords]; + target[to * entryWords + 1] = source[from * entryWords + 1]; + target[to * entryWords + 2] = source[from * entryWords + 2]; }; /** - * Applies many removals and insertions to one bucket in one merge: `removed` entity indexes and `added` entries - * (`entryWords` each), both in entity order. Keeps the bucket's entries in entity order. + * Sorts `count` entries by entity index, the first `count * entryWords` words of `spare`, which holds twice that: runs + * of eight by insertion, then merged in passes between its halves. */ -export const mergeEntries = (table: HashTable, bucket: number, removed: Int32Array, removedCount: number, added: Int32Array, addedCount: number, scratch: Int32Array, tick: number): void => { - const record = bucket * table.stride; - const length = table.records[record + recordLength]; - const result = length - removedCount + addedCount; - const base = table.records[record + recordStart] * entryWords; - // The bucket's current entries, copied out, merged back with the insertions, skipping the removals. - scratch.set(table.entries.subarray(base, base + length * entryWords)); - reserve(table, bucket, result); - const target = table.entries; - let to = table.records[record + recordStart] * entryWords; - let r = 0; - let a = 0; - for (let k = 0; k < length; k++) { - const index = scratch[k * entryWords]; - while (r < removedCount && removed[r] < index) r++; - while (a < addedCount && added[a * entryWords] < index) { - target[to++] = added[a * entryWords]; - target[to++] = added[a * entryWords + 1]; - target[to++] = added[a * entryWords + 2]; - a++; +const sortEntries = (spare: Int32Array, count: number): void => { + const run = 8; + for (let low = 0; low < count; low += run) { + for (let k = low + 1, high = Math.min(count, low + run); k < high; k++) { + const index = spare[k * entryWords]; + const archetype = spare[k * entryWords + 1]; + const row = spare[k * entryWords + 2]; + let at = k; + while (at > low && spare[(at - 1) * entryWords] > index) { + copyEntry(spare, at, spare, at - 1); + at--; + } + spare[at * entryWords] = index; + spare[at * entryWords + 1] = archetype; + spare[at * entryWords + 2] = row; + } + } + let from = 0; + let to = count; + for (let width = run; width < count; width *= 2) { + for (let low = 0; low < count; low += 2 * width) { + const middle = Math.min(low + width, count); + const high = Math.min(low + 2 * width, count); + let a = low; + let b = middle; + for (let k = low; k < high; k++) { + if (b >= high || (a < middle && spare[(from + a) * entryWords] <= spare[(from + b) * entryWords])) copyEntry(spare, to + k, spare, from + a++); + else copyEntry(spare, to + k, spare, from + b++); + } } - if (r < removedCount && removed[r] === index) { - r++; + const swap = from; + from = to; + to = swap; + } + if (from !== 0) spare.copyWithin(0, from * entryWords, (from + count) * entryWords); +}; + +/** + * Puts an unsorted bucket's entries in entity order again: its tail sorted aside, its holes closed from the first, and + * the tail merged in from the end, so only the entries past the first hole and the first tail entry's place move. + */ +export const sortBucket = (table: HashTable, bucket: number): void => { + const tail = table.tails[bucket]; + const extent = table.extents[bucket]; + const start = startOf(table, bucket); + const { entries } = table; + const count = extent - tail; + if (table.spare.length < 2 * count * entryWords) table.spare = new Int32Array(Math.max(2 * count * entryWords, 2 * table.spare.length)); + const { spare } = table; + for (let k = 0; k < count; k++) copyEntry(spare, k, entries, start + tail + k); + sortEntries(spare, count); + // Holes closed, a run of entries at a time; past the last hole, the rest in one move. + let left = extent - lengthOf(table, bucket); + let write = Math.min(table.holes[bucket], tail); + let read = write; + while (read < tail) { + if (left === 0) { + if (write !== read) move(table, (start + write) * entryWords, (start + read) * entryWords, (tail - read) * entryWords); + write += tail - read; + break; + } + if (entries[(start + read) * entryWords + 1] < 0) { + read++; + left--; continue; } - target[to++] = index; - target[to++] = scratch[k * entryWords + 1]; - target[to++] = scratch[k * entryWords + 2]; + let end = read + 1; + while (end < tail && entries[(start + end) * entryWords + 1] >= 0) end++; + if (write !== read) move(table, (start + write) * entryWords, (start + read) * entryWords, (end - read) * entryWords); + write += end - read; + read = end; } - for (; a < addedCount; a++) { - target[to++] = added[a * entryWords]; - target[to++] = added[a * entryWords + 1]; - target[to++] = added[a * entryWords + 2]; + // The tail merged in from the end: each of its entries, from the last, after the run of entries above it. + let kept = write; + for (let k = count - 1; k >= 0; k--) { + const index = spare[k * entryWords]; + const found = search(entries, start, kept, index); + const position = found < 0 ? ~found : found; + const to = position + k + 1; + if (kept > position) move(table, (start + to) * entryWords, (start + position) * entryWords, (kept - position) * entryWords); + copyEntry(entries, start + position + k, spare, k); + kept = position; } - table.records[record + recordLength] = result; - table.ticks[bucket] = tick; - table.size += result - length; - countEmpty(table, length, result); + sorted(table, bucket); +}; + +/** Sorts every unsorted bucket, before the table's arrays are handed to a reader that reads entries by position. */ +export const sortHashTable = (table: HashTable): void => { + const list = table.unsortedBuckets; + // Sorting the last unsorted one empties the list. + for (let k = 0; k < table.unsortedListed; k++) if (table.tails[list[k]] >= 0) sortBucket(table, list[k]); +}; + +/** Forgets every unsorted bucket, before the table is built again. */ +export const forgetUnsorted = (table: HashTable): void => { + const list = table.unsortedBuckets; + for (let k = 0; k < table.unsortedListed; k++) table.tails[list[k]] = -1; + table.unsorted = 0; + clearList(table); }; /** @@ -374,8 +751,9 @@ export const tidyHashTable = (table: HashTable, tick: number): void => { if (table.garbage > Math.max(1024, table.used - table.garbage)) compact(table); }; -/** Lays every bucket's segment out again, one after another, with their spare slots. */ +/** Lays every bucket's segment out again, one after another, with their spare slots, each sorted first. */ const compact = (table: HashTable): void => { + sortHashTable(table); const { records, stride } = table; let total = 0; for (let bucket = 0; bucket < table.count; bucket++) if (!isFreed(table, bucket)) total += capacityFor(records[bucket * stride + recordLength]); @@ -405,6 +783,7 @@ const compact = (table: HashTable): void => { */ export const loadHashTable = (table: HashTable, count: number, entries: Int32Array, keys: Uint32Array, hashes: Uint32Array, bucketOfEntry: Int32Array, tick: number, entityCount: number): void => { const { words, stride } = table; + forgetUnsorted(table); coverEntities(table, entityCount); table.bucketOf.fill(-1); // Sized for as many buckets as it last held, growing as more are found, so the table is as small as its keys need. diff --git a/packages/ecs/src/implementation/world/index-joined.test.ts b/packages/ecs/src/implementation/world/index-joined.test.ts index 60bb57e1a..75dc93352 100644 --- a/packages/ecs/src/implementation/world/index-joined.test.ts +++ b/packages/ecs/src/implementation/world/index-joined.test.ts @@ -1,6 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; -import { Component } from "../../interface/component/component.js"; import type { Entity } from "../../interface/entity/entity.js"; import { Index } from "../../interface/indexes/index-type.js"; import { Observe } from "../../observe/index.js"; @@ -12,6 +11,7 @@ import type { World } from "../../interface/world/world.js"; import { createWorld, join, type CreatedWorld } from "./create.js"; import { backToBack } from "./loop.js"; import { dropReports, runReported } from "./reports.test-helper.js"; +import type { U32, Vec2 } from "../../interface/math/index.js"; // Systems on a joined thread looking up through the world's indexes, the world and the thread on this one, against a // world running them itself. @@ -25,45 +25,103 @@ const rank = Symbol("indexJoinedTest/rank"); const label = Symbol("indexJoinedTest/label"); const name = Symbol("indexJoinedTest/name"); const title = Symbol("indexJoinedTest/title"); +const band = Symbol("indexJoinedTest/band"); +const bandReader = Symbol("indexJoinedTest/bandReader"); +const marked = Symbol("indexJoinedTest/marked"); declare module "@adobe/ecs" { export interface Threads { indexJoinedTest: true; } interface Components { - [cell]: { type: readonly [number, number] }; + [cell]: { type: Vec2.I32 }; [weight]: { type: number }; - [serial]: { type: number }; - [target]: { type: number }; - [lane]: { type: number }; + [serial]: { type: U32 }; + [target]: { type: U32 }; + [lane]: { type: U32 }; [rank]: { type: number }; - [label]: { type: { readonly text: string }; thread: "indexJoinedTest" }; + [label]: { type: Label; thread: "indexJoinedTest" }; [name]: { type: { readonly text: string }; thread: "indexJoinedTest" }; [title]: { type: string }; + [band]: { type: U32 }; + [bandReader]: { type: true }; + [marked]: { type: true }; } } -Component.register(cell, { type: { type: "array", items: { type: "integer", minimum: -1000, maximum: 1000 }, minItems: 2, maxItems: 2 } }); -Component.register(weight, { type: { type: "number" } }); -Component.register(serial, { type: { type: "integer", minimum: 0, maximum: 1000000 } }); -Component.register(target, { type: { type: "integer", minimum: 0, maximum: 1000000 } }); -Component.register(lane, { type: { type: "integer", minimum: 0, maximum: 1000 } }); -Component.register(rank, { type: { type: "number" } }); const text = { type: "object", properties: { text: { type: "string" } } } as const; /** A label's start, which a command here gives it by this value. */ -const blank = { text: "" }; -Component.register(label, { type: { ...text, default: blank }, thread: "indexJoinedTest" }); -Component.register(name, { type: text, thread: "indexJoinedTest" }); -Component.register(title, { type: { type: "string" } }); +const blank = { text: "" } as const; +/** A label, which starts as `blank`, the value commands on other threads give it by identity. */ +type Label = { readonly text: string }; +const Label = { default: blank }; -const grid = Index.create({ name: "indexJoinedTest/grid", key: [cell], components: { has: [weight] } }); -const serials = Index.create({ name: "indexJoinedTest/serials", key: [serial], unique: true, components: { has: [weight] } }); -const lanes = Index.create({ name: "indexJoinedTest/lanes", key: [lane, rank], sorted: true }); -const names = Index.create({ name: "indexJoinedTest/names", key: [name] }); -const titles = Index.create({ name: "indexJoinedTest/titles", key: [title] }); -const weighted = Index.create({ name: "indexJoinedTest/weighted", key: [], components: { has: [weight] } }); +const grid = Index.create({ key: [cell], components: { with: [weight] } }); +const serials = Index.create({ key: [serial], unique: true, components: { with: [weight] } }); +const lanes = Index.create({ key: [lane, rank], sorted: true }); +const names = Index.create({ key: [name] }); +const titles = Index.create({ key: [title] }); +const weighted = Index.create({ key: [], components: { with: [weight] } }); +/** Two long buckets, which changes leave unsorted until a list is read by position. */ +const bands = Index.create({ key: [band], components: { with: [serial] } }); + +/** On the joined thread: the serials of a band's rows, in entity order. */ +const describeBand = System.create({ + components: { with: [target, label, bandReader] }, + lookup: { inBand: { index: bands } }, +}, (row, { lookup }) => { + const list = lookup.inBand(row[target]); + const serials: number[] = []; + for (let i = 0; i < list.length; i++) serials.push(list.at(i)[serial]); + row[label] = { text: `${row[target]}:${serials.join(",")}` }; +}); +const seedBands = System.create({}, ({ commands }) => { + for (let k = 0; k < 300; k++) commands.spawn([band, serial])({ [band]: k % 2, [serial]: k }); + for (let k = 0; k < 2; k++) commands.spawn([target, label, bandReader])({ [target]: k, [label]: blank }); +}); +/** Moves some entities to the other band, despawns some and spawns others. */ +const stirBands = System.create({ components: { with: [serial, band] } }, (row, { commands }, input: { readonly round: number }) => { + if ((row[serial] + input.round) % 7 === 0) row[band] = 1 - row[band]; + if ((row[serial] + input.round) % 29 === 0) commands.despawn(row.entity); + if (row[serial] % 31 === input.round) commands.spawn([band, serial])({ [band]: row[serial] % 2, [serial]: 1000 + row[serial] * 10 + input.round }); +}); +/** 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, + components: { with: [target, bandReader, label] }, + lookup: { inBand: { index: bands } }, +}, (row, { lookup }) => { + const list = lookup.inBand(row[target]); + const serials: number[] = []; + for (let i = 0; i < list.length; i++) serials.push(list.at(i)[serial]); + observedBands[row[target]] = serials.join(","); +}); +/** Seeds the bands, then observes them where `holder` runs observer systems, around changes; what it saw each time. */ +const observedBandScenario = async (world: CreatedWorld, holder: World): Promise => { + const seen: (readonly string[])[] = []; + await world.run(seedBands); + for (let round = 0; round < 6; round++) { + await world.run(stirBands, { round }); + observedBands = []; + await holder.run(observeBands); + seen.push([...observedBands]); + } + return seen; +}; +/** Seeds the bands, then reads them on the thread around changes; every label after each read. */ +const bandScenario = async (world: CreatedWorld, holder: World): Promise => { + const seen: (readonly string[])[] = []; + await world.run(seedBands); + for (let round = 0; round < 6; round++) { + await world.run(describeBand); + seen.push(await labels(holder, world)); + await world.run(stirBands, { round }); + } + return seen; +}; /** On the joined thread, in the tier before the lookups, so a task of consecutive tiers there would see the index before it: moves some cells. */ -const shift = System.create({ name: "indexJoinedTest/shift", components: { read: [serial], write: [cell, label] } }, row => { +const shift = System.create({ components: { with: [serial, cell, label] } }, row => { if (row[serial] % 17 !== 0) return; const [x, y] = row[cell]; row[cell] = [(x + 1) % 13, y]; @@ -72,9 +130,8 @@ const shift = System.create({ name: "indexJoinedTest/shift", components: { read: /** On the joined thread: each cell's neighbors' count and weights, whether its list changed, and its serial's target's weight. */ const describeCells = System.create({ - name: "indexJoinedTest/describeCells", - components: { read: [cell, target], write: [label] }, - lookup: { near: { index: grid, read: [weight], changed: [weight] }, bySerial: { index: serials, read: [weight] }, all: { index: weighted, read: [weight] } }, + components: { with: [cell, target, label] }, + lookup: { near: { index: grid, changed: [weight] }, bySerial: { index: serials }, all: { index: weighted } }, after: [shift], }, (row, { lookup }) => { const [x, y] = row[cell]; @@ -96,9 +153,8 @@ const describeCells = System.create({ /** On the joined thread: the rows of the row's lane within 5 of its rank, in key order. */ const describeRanks = System.create({ - name: "indexJoinedTest/describeRanks", - components: { read: [lane, rank], write: [label] }, - lookup: { inLane: { index: lanes, read: [rank] } }, + components: { with: [lane, rank, label] }, + lookup: { inLane: { index: lanes } }, after: [describeCells], }, (row, { lookup }) => { const r = row[rank]; @@ -108,29 +164,28 @@ const describeRanks = System.create({ row[label] = { text: ranks.join(",") }; }); -const tick = System.create({ name: "indexJoinedTest/tick", systems: [shift, describeCells, describeRanks] }); +const tick = System.create({ systems: [shift, describeCells, describeRanks] }); -const seed = System.create({ name: "indexJoinedTest/seed" }, ({ commands }) => { +const seed = System.create({}, ({ commands }) => { for (let k = 0; k < 200; k++) commands.spawn([cell, weight, serial, target, label])({ [cell]: [k % 13, k % 7], [weight]: k % 5, [serial]: k, [target]: (k * 7) % 230, [label]: blank }); for (let k = 0; k < 150; k++) commands.spawn([lane, rank, label])({ [lane]: k % 3, [rank]: (k * 37) % 97, [label]: blank }); }); /** Moves cells, changes weights and ranks, despawns some and spawns others. */ -const stir = System.create({ name: "indexJoinedTest/stir", input: { type: "object", properties: { round: { type: "integer" } } }, components: { read: [serial], write: [cell, weight] } }, (row, { input, commands }) => { +const stir = System.create({ components: { with: [serial, cell, weight] } }, (row, { commands }, input: { readonly round: number }) => { const [x, y] = row[cell]; if ((row[serial] + input.round) % 9 === 0) row[cell] = [(x + 1) % 13, y]; if ((row[serial] + input.round) % 11 === 0) row[weight] = row[weight] + 1; if ((row[serial] + input.round) % 23 === 0) commands.despawn(row.entity); if (row[serial] % 50 === input.round) commands.spawn([cell, weight, serial, target, label])({ [cell]: [input.round, 3], [weight]: 2, [serial]: 1000 + row[serial], [target]: row[serial], [label]: blank }); }); -const rerank = System.create({ name: "indexJoinedTest/rerank", input: { type: "object", properties: { round: { type: "integer" } } }, components: { write: [rank] } }, (row, { input }) => { +const rerank = System.create({ components: { with: [rank] } }, (row, _context, input: { readonly round: number }) => { if ((row[rank] + input.round) % 5 === 0) row[rank] = (row[rank] * 3 + 1) % 97; }); /** A system of the joined thread looking up through an index of its strings, which the world keeps on its own thread. */ const byName = System.create({ - name: "indexJoinedTest/byName", - components: { read: [name], write: [label] }, + components: { with: [name, label] }, lookup: { named: { index: names } }, }, (row, { lookup }) => { row[label] = { text: String(lookup.named(row[name]).length) }; @@ -138,41 +193,40 @@ const byName = System.create({ /** On the joined thread, added and removed: labels each cell by its bucket's length. */ const counting = System.create({ - name: "indexJoinedTest/counting", - components: { read: [cell], write: [label] }, + components: { with: [cell, label] }, lookup: { near: { index: grid, changed: [weight] } }, }, (row, { lookup }) => { const [x, y] = row[cell]; row[label] = { text: String(lookup.near([x, y]).length) }; }); -const keepTicking = System.create({ name: "indexJoinedTest/keepTicking" }, () => {}); +const keepTicking = System.create({}, () => {}); -const ranks = Index.create({ name: "indexJoinedTest/ranks", key: [rank], sorted: true }); +const ranks = Index.create({ key: [rank], sorted: true }); /** Systems of the joined thread writing a string, and looking up by strings, which only the world's thread does. */ -const writeTitle = System.create({ name: "indexJoinedTest/writeTitle", components: { read: [label], write: [title] } }, row => { +const writeTitle = System.create({ components: { with: [label, title] } }, row => { row[title] = row[label].text; }); -const byTitle = System.create({ name: "indexJoinedTest/byTitle", components: { read: [title], write: [label] }, lookup: { titled: { index: titles } } }, (row, { lookup }) => { +const byTitle = System.create({ components: { with: [title, label] }, lookup: { titled: { index: titles } } }, (row, { lookup }) => { row[label] = { text: String(lookup.titled(row[title]).length) }; }); /** A system of the joined thread whose rows are in an index's order, which runs only on the world's thread. */ -const inOrder = System.create({ name: "indexJoinedTest/inOrder", components: { read: [rank], write: [label] }, order: ranks }, row => { +const inOrder = System.create({ components: { with: [rank, label] }, order: ranks }, row => { row[label] = { text: String(row[rank]) }; }); /** Systems on the joined thread and an observer system of its objects, each with an index as a change filter, which run only on the world's thread. */ -const countWhenMoved = System.create({ name: "indexJoinedTest/countWhenMoved", components: { read: [cell], write: [label] }, lookup: { near: { index: grid, changed: true } } }, (row, { lookup }) => { +const countWhenMoved = System.create({ components: { with: [cell, label] }, lookup: { near: { index: grid, changed: true } } }, (row, { lookup }) => { row[label] = { text: String(lookup.near(row[cell]).length) }; }); -const readWhenMoved = System.create({ name: "indexJoinedTest/readWhenMoved", observer: true, components: { read: [label] }, lookup: { near: { index: grid, changed: true } } }, row => { +const readWhenMoved = System.create({ observer: true, components: { with: [label] }, lookup: { near: { index: grid, changed: true } } }, row => { void row[label]; }); -const ownWhenMoved = System.create({ name: "indexJoinedTest/ownWhenMoved", observer: true, lookup: { near: { index: grid, changed: true } } }, () => {}); +const ownWhenMoved = System.create({ observer: true, lookup: { near: { index: grid, changed: true } } }, () => {}); /** The entities `readLabels` reads, and their labels. */ const reading: { entities: readonly Entity[]; labels: readonly string[] } = { entities: [], labels: [] }; -const readLabels = System.create({ name: "indexJoinedTest/readLabels", observer: true, lookup: { labeled: { read: [label] } } }, ({ lookup }) => { +const readLabels = System.create({ observer: true, lookup: { labeled: { with: [label] } } }, ({ lookup }) => { reading.labels = reading.entities.map(entity => lookup.labeled(entity)?.[label].text ?? "missing"); }); @@ -211,6 +265,81 @@ const joinedStatus = (world: CreatedWorld): Promise => new Promise(resolve }); }); +/** Entities a keyless and a unique index hold, each with this thread's label and a string title. */ +const seedHeld = System.create({}, ({ commands }, input: { readonly from: number; readonly count: number }) => { + for (let k = input.from; k < input.from + input.count; k++) commands.spawn([serial, weight, label, title])({ [serial]: k, [weight]: k % 5, [label]: blank, [title]: `t${k}` }); +}); +/** On the joined thread: every label, by serial and round. */ +const relabelHeld = System.create({ components: { with: [serial, label, title] } }, (row, _context, input: { readonly round: number }) => { + row[label] = { text: `${row[serial]}.${input.round}` }; +}); +/** On the world's thread: every weight and title, by round. */ +const rewriteHeld = System.create({ components: { with: [serial, weight, title] } }, (row, _context, input: { readonly round: number }) => { + row[weight] = row[serial] * 10 + input.round; + row[title] = `t${row[serial]}.${input.round}`; +}); +/** Moves every held entity to the other archetype, marked on one run and not on the next. */ +const markHeld = System.create({ components: { with: [title], not: [marked] } }, (row, { commands }) => { + commands.add(row.entity, marked); +}); +const unmarkHeld = System.create({ components: { with: [title, marked] } }, (row, { commands }) => { + commands.remove(row.entity, marked); +}); +const held = Index.create({ key: [], components: { with: [serial, weight, label, title] } }); +const heldSerials = Index.create({ key: [serial], unique: true, components: { with: [weight, label, title] } }); +/** What `observeHeld` read last. */ +let observedHeld = ""; +/** An observer system reading every held row through the keyless index, and serial 3's through the unique one. */ +const observeHeld = System.create({ + observer: true, + lookup: { all: { index: held }, bySerial: { index: heldSerials } }, +}, ({ lookup }) => { + const all = lookup.all(); + const read: string[] = []; + for (let i = 0; i < all.length; i++) { + const row = all.at(i); + read.push(`${row[serial]}:${row[weight]}:${row[label].text}:${row[title]}`); + } + const one = lookup.bySerial(3); + observedHeld = `${read.sort().join(",")}|${one === null ? "none" : `${one[weight]}:${one[label].text}:${one[title]}`}`; +}); +/** + * Observes the held rows where `holder` runs observer systems, around what makes the rows a lookup gave stale: arrays + * grown, values and strings rewritten, the thread's labels rewritten, and every row moved past what its log holds. + */ +const heldScenario = async (world: CreatedWorld, holder: World): Promise => { + const seen: string[] = []; + const observe = async (): Promise => { + await holder.run(observeHeld); + seen.push(observedHeld); + }; + await world.run(seedHeld, { from: 0, count: 8 }); + await world.run(relabelHeld, { round: 0 }); + await observe(); + // Past the archetype's capacity, without growing the memory. + await world.run(seedHeld, { from: 8, count: 12 }); + await observe(); + await world.run(rewriteHeld, { round: 1 }); + await world.run(relabelHeld, { round: 1 }); + await observe(); + await world.run(seedHeld, { from: 20, count: 600 }); + await observe(); + // Both archetypes and the reports' grown, so lapping the log again replaces no arrays: only the mirror's rebuild + // makes views again. + const lap = async (): Promise => { + for (let k = 0; k < 30; k++) await world.run(k % 2 === 0 ? markHeld : unmarkHeld); + await world.run(dropReports); + }; + await lap(); + await observe(); + await lap(); + await observe(); + await world.run(relabelHeld, { round: 2 }); + await world.run(rewriteHeld, { round: 2 }); + await observe(); + return seen; +}; + describe("systems on a joined thread looking up through indexes", () => { it("find what the world's own systems find: hash buckets, unique rows, sorted ranges and changes", async () => { const inline = createWorld({ [workers]: 0 }); @@ -225,11 +354,53 @@ describe("systems on a joined thread looking up through indexes", () => { expect(there.labels).toEqual(alone.labels); expect(alone.labels.flat().some(text => text.includes("true"))).toBe(true); // The systems ran there, their tiers waiting for the thread. - expect(there.warnings.every(names => names.includes("indexJoinedTest/describeCells"))).toBe(true); + expect(there.warnings.every(names => names.includes("describeCells"))).toBe(true); expect(alone.warnings.flat()).toEqual([]); await remote.dispose(); await world.dispose(); }); + it("read a hash index's long buckets in entity order after changes left them unsorted, as the world's own systems do", async () => { + const inline = createWorld({ [workers]: 0 }); + const alone = await bandScenario(inline, inline); + await inline.dispose(); + const world = createWorld({ [workers]: 0 }); + const channel = new MessageChannel(); + world.share(channel.port1); + const remote = join(channel.port2, { threads: ["indexJoinedTest"] }); + await joinedStatus(world); + expect(await bandScenario(world, remote)).toEqual(alone); + expect(alone.every(round => round.length === 2 && round.every(text => text.split(",").length > 100))).toBe(true); + await remote.dispose(); + await world.dispose(); + }); + it("observe a hash index's long buckets in entity order after changes left them unsorted, as the world's own observer systems do", async () => { + const inline = createWorld({ [workers]: 0 }); + const alone = await observedBandScenario(inline, inline); + await inline.dispose(); + const world = createWorld({ [workers]: 0 }); + const channel = new MessageChannel(); + world.share(channel.port1); + const remote = join(channel.port2, { threads: ["indexJoinedTest"] }); + await joinedStatus(world); + expect(await observedBandScenario(world, remote)).toEqual(alone); + expect(alone.every(round => round.length === 2 && round.every(text => text.split(",").length > 100))).toBe(true); + await remote.dispose(); + await world.dispose(); + }); + it("read rows as of each run's sync point once arrays grew, values, strings and the thread's objects changed, and its log lapped", async () => { + const inline = createWorld({ [workers]: 0 }); + const alone = await heldScenario(inline, inline); + await inline.dispose(); + const world = createWorld({ [workers]: 0 }); + const channel = new MessageChannel(); + world.share(channel.port1); + const remote = join(channel.port2, { threads: ["indexJoinedTest"] }); + await joinedStatus(world); + expect(await heldScenario(world, remote)).toEqual(alone); + expect(alone.at(-1)).toContain("|32:3.2:t3.2"); + await remote.dispose(); + await world.dispose(); + }); it("release the index and its records once the last system using it there is removed", async () => { const driver = backToBack(); const world = createWorld({ [workers]: 0, [always]: true }, { loop: driver }); @@ -280,14 +451,14 @@ describe("systems on a joined thread looking up through indexes", () => { world.share(channel.port1); const remote = join(channel.port2, { threads: ["indexJoinedTest"] }); await joinedStatus(world); - await expect(world.run(byName)).rejects.toThrow('looks up through index "indexJoinedTest/names", whose key part "indexJoinedTest/name" is an object, so it runs only on the world\'s thread.'); + await expect(world.run(byName)).rejects.toThrow('looks up through index "names", whose key part "indexJoinedTest/name" is an object, so it runs only on the world\'s thread.'); await expect(world.run(inOrder)).rejects.toThrow("orders its rows by an index, which runs only on the world's thread"); - await expect(world.run(writeTitle)).rejects.toThrow(`System "indexJoinedTest/writeTitle" runs on thread "indexJoinedTest", which a joined thread fills, and writes "indexJoinedTest/title", a string, which only the world's thread writes.`); - await expect(world.run(byTitle)).rejects.toThrow('looks up through index "indexJoinedTest/titles", whose key part "indexJoinedTest/title" is a string, which the world\'s thread keeps keys of, so it runs only on the world\'s thread.'); - const moved = `and when index "indexJoinedTest/grid" changed, a lookup's changed being true, which runs only on the world's thread.`; - await expect(world.run(countWhenMoved)).rejects.toThrow(`System "indexJoinedTest/countWhenMoved" runs on thread "indexJoinedTest", which a joined thread fills, ${moved}`); - await expect(world.run(readWhenMoved)).rejects.toThrow(`System "indexJoinedTest/readWhenMoved" runs on thread "indexJoinedTest", which a joined thread fills, ${moved}`); - expect(() => remote.add(ownWhenMoved)).toThrow(`Observer system "indexJoinedTest/ownWhenMoved" runs when index "indexJoinedTest/grid" changed, as its lookup "near"'s changed is true, so it runs only on the world's thread.`); + await expect(world.run(writeTitle)).rejects.toThrow(`runs on thread "indexJoinedTest", which a joined thread fills, and writes "indexJoinedTest/title", a string, which only the world's thread writes.`); + await expect(world.run(byTitle)).rejects.toThrow('looks up through index "titles", whose key part "indexJoinedTest/title" is a string, which the world\'s thread keeps keys of, so it runs only on the world\'s thread.'); + const moved = `and when index "grid" changed, a lookup's changed being true, which runs only on the world's thread.`; + await expect(world.run(countWhenMoved)).rejects.toThrow(`runs on thread "indexJoinedTest", which a joined thread fills, ${moved}`); + await expect(world.run(readWhenMoved)).rejects.toThrow(`runs on thread "indexJoinedTest", which a joined thread fills, ${moved}`); + expect(() => remote.add(ownWhenMoved)).toThrow(`Observer system "ownWhenMoved" runs when index "grid" changed, as its lookup "near"'s changed is true, so it runs only on the world's thread.`); await remote.dispose(); await world.dispose(); }); diff --git a/packages/ecs/src/implementation/world/index-kernels.test.ts b/packages/ecs/src/implementation/world/index-kernels.test.ts index 53cb5a702..68cb7c735 100644 --- a/packages/ecs/src/implementation/world/index-kernels.test.ts +++ b/packages/ecs/src/implementation/world/index-kernels.test.ts @@ -7,6 +7,7 @@ import { Observe } from "../../observe/index.js"; import { workers } from "../../interface/resources/workers.js"; import { System } from "../../interface/system/system.js"; import { createWorld, type CreatedWorld } from "./create.js"; +import type { F32, U32, Vec2 } from "../../interface/math/index.js"; const cell = Symbol("indexKernelsTest/cell"); const weight = Symbol("indexKernelsTest/weight"); @@ -22,40 +23,29 @@ const ranked = Symbol("indexKernelsTest/ranked"); declare module "@adobe/ecs" { interface Components { - [cell]: { type: readonly [number, number] }; - [weight]: { type: number }; + [cell]: { type: Vec2.I32 }; + [weight]: { type: F32 }; [total]: { type: number }; [count]: { type: number }; - [lane]: { type: number }; + [lane]: { type: U32 }; [rank]: { type: number }; - [serial]: { type: number }; - [target]: { type: number }; + [serial]: { type: U32 }; + [target]: { type: U32 }; [seen]: { type: Entity }; [changes]: { type: number }; [ranked]: { type: true }; } } -Component.register(cell, { type: { type: "array", items: { type: "integer", minimum: -1000, maximum: 1000 }, minItems: 2, maxItems: 2 } }); -Component.register(weight, { type: { type: "number", precision: 1 } }); -Component.register(total, { type: { type: "number" } }); -Component.register(count, { type: { type: "number" } }); -Component.register(lane, { type: { type: "integer", minimum: 0, maximum: 1000 } }); -Component.register(rank, { type: { type: "number" } }); -Component.register(serial, { type: { type: "integer", minimum: 0, maximum: 1000000 } }); -Component.register(target, { type: { type: "integer", minimum: 0, maximum: 1000000 } }); -Component.register(seen, { type: Entity.schema }); -Component.register(changes, { type: { type: "number" } }); -Component.register(ranked, { type: { const: true } }); -const grid = Index.create({ name: "indexKernelsTest/grid", key: [cell], components: { has: [weight] } }); -const lanes = Index.create({ name: "indexKernelsTest/lanes", key: [lane, rank], sorted: true, components: { has: [ranked] } }); -const serials = Index.create({ name: "indexKernelsTest/serials", key: [serial], unique: true, components: { has: [weight] } }); +const grid = Index.create({ key: [cell], components: { with: [weight] } }); +const lanes = Index.create({ key: [lane, rank], sorted: true, components: { with: [ranked] } }); +const serials = Index.create({ key: [serial], unique: true, components: { with: [weight] } }); /** Each row's neighbors in the 3 by 3 cells around its own: how many, and their weights' sum. */ const neighbors = System.create({ - name: "indexKernelsTest/neighbors", kernel: "cpu", - components: { read: [cell], write: [total, count] }, - lookup: { near: { index: grid, read: [weight] } }, + kernel: true, + components: { with: [cell, total, count] }, + lookup: { near: { index: grid } }, }, (row, { lookup }) => { const [x, y] = row[cell]; let sum = 0; @@ -74,7 +64,7 @@ const neighbors = System.create({ }); /** The same, on the main thread, for comparison. */ -const neighborsByHand = System.create({ name: "indexKernelsTest/neighborsByHand", components: { read: [cell], write: [total, count] }, lookup: { near: { index: grid, read: [weight] } } }, (row, { lookup }) => { +const neighborsByHand = System.create({ components: { with: [cell, total, count] }, lookup: { near: { index: grid } } }, (row, { lookup }) => { const [x, y] = row[cell]; let sum = 0; let found = 0; @@ -91,8 +81,8 @@ const neighborsByHand = System.create({ name: "indexKernelsTest/neighborsByHand" /** How many rows of the row's lane have a rank within 5 of its own, and the first of them, through a sorted range. */ const window = System.create({ - name: "indexKernelsTest/window", kernel: "cpu", - components: { read: [lane, rank], write: [count, seen] }, + kernel: true, + components: { with: [lane, rank, count, seen] }, lookup: { inLane: { index: lanes } }, }, (row, { lookup }) => { const r = row[rank]; @@ -101,7 +91,7 @@ const window = System.create({ row[seen] = near.at(0).entity; }); -const windowByHand = System.create({ name: "indexKernelsTest/windowByHand", components: { read: [lane, rank], write: [count, seen] }, lookup: { inLane: { index: lanes } } }, (row, { lookup }) => { +const windowByHand = System.create({ components: { with: [lane, rank, count, seen] }, lookup: { inLane: { index: lanes } } }, (row, { lookup }) => { const r = row[rank]; const near = lookup.inLane(row[lane], { ">=": r - 5, "<": r + 5 }); row[count] = near.length; @@ -110,9 +100,9 @@ const windowByHand = System.create({ name: "indexKernelsTest/windowByHand", comp /** The weight of the entity whose serial is the row's target, or -1. */ const follow = System.create({ - name: "indexKernelsTest/follow", kernel: "cpu", - components: { read: [target], write: [total] }, - lookup: { bySerial: { index: serials, read: [weight] } }, + kernel: true, + components: { with: [target, total] }, + lookup: { bySerial: { index: serials } }, }, (row, { lookup }) => { const found = lookup.bySerial(row[target]); row[total] = found === null ? -1 : found[weight]; @@ -120,18 +110,18 @@ const follow = System.create({ /** Counts runs whose list of the row's cell changed. */ const watch = System.create({ - name: "indexKernelsTest/watch", kernel: "cpu", - components: { read: [cell], write: [changes] }, + kernel: true, + components: { with: [cell, changes] }, lookup: { near: { index: grid, changed: [weight] } }, }, (row, { lookup }) => { if (lookup.near([row[cell][0], row[cell][1]]).changed) row[changes] = row[changes] + 1; }); -const spawnGrid = System.create({ name: "indexKernelsTest/spawnGrid", input: { type: "object", properties: { count: { type: "integer" } } } }, ({ input, commands }) => { +const spawnGrid = System.create({}, ({ commands }, input: { readonly count: number }) => { for (let k = 0; k < input.count; k++) commands.spawn([cell, weight, total, count, changes, serial])({ [cell]: [(k * 7) % 5, (k * 3) % 4], [weight]: k % 9, [total]: 0, [count]: 0, [changes]: 0, [serial]: k }); }); -const spawnLanes = System.create({ name: "indexKernelsTest/spawnLanes", input: { type: "object", properties: { count: { type: "integer" } } } }, ({ input, commands }) => { +const spawnLanes = System.create({}, ({ commands }, input: { readonly count: number }) => { for (let k = 0; k < input.count; k++) commands.spawn([lane, rank, ranked, count, seen])({ [lane]: k % 3, [rank]: (k * 37) % 101, [ranked]: true, [count]: 0, [seen]: Entity.none }); }); @@ -188,8 +178,8 @@ describe.each([[true], [false]])("kernels looking up through indexes, WASM %s", /** A comparison on a hash index. */ const compared = System.create({ - name: "indexKernelsTest/compared", kernel: "cpu", - components: { read: [serial], write: [count] }, + kernel: true, + components: { with: [serial, count] }, lookup: { bySerial: { index: serials } }, }, (row, { lookup }) => { // @ts-expect-error a hash index takes whole keys diff --git a/packages/ecs/src/implementation/world/index-keyless.test.ts b/packages/ecs/src/implementation/world/index-keyless.test.ts index fa71c8a70..7190bd3b4 100644 --- a/packages/ecs/src/implementation/world/index-keyless.test.ts +++ b/packages/ecs/src/implementation/world/index-keyless.test.ts @@ -29,17 +29,10 @@ declare module "@adobe/ecs" { [visited]: { type: number }; } } -Component.register(weight, { type: { type: "number" } }); -Component.register(hidden, { type: { const: true } }); -Component.register(color, { type: { type: "number" } }); -Component.register(spin, { type: { type: "number" } }); -Component.register(total, { type: { type: "number" } }); -Component.register(count, { type: { type: "number" } }); -Component.register(visited, { type: { type: "number" } }); -const shown = Index.create({ name: "indexKeylessTest/shown", key: [], components: { has: [weight], not: [hidden] } }); -const everything = Index.create({ name: "indexKeylessTest/everything", key: [] }); -const inOrder = Index.create({ name: "indexKeylessTest/inOrder", key: [], sorted: true, components: { has: [weight] } }); +const shown = Index.create({ key: [], components: { with: [weight], not: [hidden] } }); +const everything = Index.create({ key: [] }); +const inOrder = Index.create({ key: [], sorted: true, components: { with: [weight] } }); /** What a lookup gave: its entities and weights, in its order, its length and whether it changed. */ type Seen = { readonly entities: readonly Entity[]; readonly weights: readonly number[]; readonly length: number; readonly changed: boolean }; @@ -47,8 +40,7 @@ let seen: Seen = { entities: [], weights: [], length: 0, changed: false }; let all: readonly Entity[] = []; const look = System.create({ - name: "indexKeylessTest/look", - lookup: { shown: { index: shown, read: [weight] }, all: { index: everything } }, + lookup: { shown: { index: shown }, all: { index: everything } }, }, ({ lookup }) => { const rows = lookup.shown(); const entities: Entity[] = []; @@ -64,7 +56,7 @@ const look = System.create({ /** Whether the lookup through `shown` changed, with `color` watched. */ let watchedChanged = false; -const watchColor = System.create({ name: "indexKeylessTest/watchColor", lookup: { shown: { index: shown, changed: [weight] } } }, ({ lookup }) => { +const watchColor = System.create({ lookup: { shown: { index: shown, changed: [weight] } } }, ({ lookup }) => { watchedChanged = lookup.shown().changed; }); @@ -76,7 +68,7 @@ const expected = async (world: CreatedWorld): Promise<{ readonly entities: reado all: [...await select(world, {})].sort(byIndex), }); -const spawn = System.create({ name: "indexKeylessTest/spawn", input: { type: "object", properties: { weights: { type: "array", items: { type: "number" } } } } }, ({ input, commands }) => { +const spawn = System.create({}, ({ commands }, input: { readonly weights: readonly number[] }) => { for (const value of input.weights) commands.spawn([weight, color])({ [weight]: value, [color]: 0 }); }); @@ -122,7 +114,7 @@ describe("an index without a key on the main thread", () => { await world.run(look); expect(seen.changed).toBe(false); // A write to what it holds by, unwatched, or to anything else, changes nothing. - await world.run(System.create({ components: { write: [weight, color] } }, row => { + await world.run(System.create({ components: { with: [weight, color] } }, row => { row[weight] = row[weight] + 1; row[color] = 2; })); @@ -143,10 +135,10 @@ describe("an index without a key on the main thread", () => { await world.run(watchColor); await world.run(watchColor); expect(watchedChanged).toBe(false); - await world.run(System.create({ components: { write: [color] } }, row => { row[color] = 5; })); + await world.run(System.create({ components: { with: [color] } }, row => { row[color] = 5; })); await world.run(watchColor); expect(watchedChanged).toBe(false); - await world.run(System.create({ components: { write: [weight] } }, row => { row[weight] = 5; })); + await world.run(System.create({ components: { with: [weight] } }, row => { row[weight] = 5; })); await world.run(watchColor); expect(watchedChanged).toBe(true); await world.run(watchColor); @@ -159,7 +151,7 @@ describe("an index without a key on the main thread", () => { // Moving the first to another archetype puts it last in storage order. await world.run(System.create(({ commands }) => { commands.add(entities[0], spin, 1); })); const order: Entity[] = []; - await world.run(System.create({ components: { read: [weight] }, order: inOrder }, row => { if (row[weight] > 0) order.push(row.entity); })); + await world.run(System.create({ components: { with: [weight] }, order: inOrder }, row => { if (row[weight] > 0) order.push(row.entity); })); expect(order).toEqual(entities); }); }); @@ -210,9 +202,9 @@ describe("an incrementally refreshed index without a key", () => { /** Each row's tally of the rows of `shown`: how many, and the sum of a window of their weights. */ const tally = System.create({ - name: "indexKeylessTest/tally", kernel: "cpu", - components: { read: [color], write: [total, count] }, - lookup: { shown: { index: shown, read: [weight] } }, + kernel: true, + components: { with: [color, total, count] }, + lookup: { shown: { index: shown } }, }, (row, { lookup }) => { const rows = lookup.shown(); let sum = 0; @@ -220,7 +212,7 @@ const tally = System.create({ row[total] = sum; row[count] = rows.length; }); -const tallyByHand = System.create({ name: "indexKeylessTest/tallyByHand", components: { read: [color], write: [total, count] }, lookup: { shown: { index: shown, read: [weight] } } }, (row, { lookup }) => { +const tallyByHand = System.create({ components: { with: [color, total, count] }, lookup: { shown: { index: shown } } }, (row, { lookup }) => { const rows = lookup.shown(); let sum = 0; for (let i = row[color] % 7; i < rows.length; i += 7) sum += rows.at(i)[weight]; @@ -229,8 +221,8 @@ const tallyByHand = System.create({ name: "indexKeylessTest/tallyByHand", compon }); /** Counts runs whose list changed, `weight` watched. */ const visit = System.create({ - name: "indexKeylessTest/visit", kernel: "cpu", - components: { write: [visited] }, + kernel: true, + components: { with: [visited] }, lookup: { shown: { index: shown, changed: [weight] } }, }, (row, { lookup }) => { if (lookup.shown().changed) row[visited] = row[visited] + 1; @@ -239,7 +231,7 @@ const visit = System.create({ const values = async (world: CreatedWorld, keys: readonly Component.Key[]): Promise => { let found: readonly unknown[] = []; const entities = [...await select(world, { required: [total] })].sort(byIndex); - await world.run(System.create({ lookup: { of: { read: [total, count, visited] } } }, ({ lookup }) => { + await world.run(System.create({ lookup: { of: { with: [total, count, visited] } } }, ({ lookup }) => { found = entities.map(entity => { const row = lookup.of(entity); return row === null ? null : keys.map(key => key === total ? row[total] : key === count ? row[count] : row[visited]); @@ -255,7 +247,7 @@ describe.each([[true], [false]])("kernels looking up through an index without a for (let k = 0; k < 300; k++) commands.spawn([weight, color, total, count, visited])({ [weight]: k % 11, [color]: k, [total]: 0, [count]: 0, [visited]: 0 }); for (let k = 0; k < 40; k++) commands.spawn([color, total, count, visited])({ [color]: k, [total]: 0, [count]: 0, [visited]: 0 }); })); - await world.run(System.create({ components: { read: [color] } }, (row, { commands }) => { + await world.run(System.create({ components: { with: [color] } }, (row, { commands }) => { if (row[color] % 5 === 0) commands.add(row.entity, hidden); if (row[color] % 9 === 0) commands.despawn(row.entity); })); @@ -269,10 +261,10 @@ describe.each([[true], [false]])("kernels looking up through an index without a const visits = async (): Promise => (await values(world, [visited])).reduce((sum, row) => sum + (Array.isArray(row) ? Number(row[0]) : 0), 0); const rows = (await values(world, [visited])).length; expect(await visits()).toBe(rows); - await world.run(System.create({ components: { write: [color] } }, row => { row[color] = row[color] + 1; })); + await world.run(System.create({ components: { with: [color] } }, row => { row[color] = row[color] + 1; })); await world.run(visit); expect(await visits()).toBe(rows); - await world.run(System.create({ components: { write: [weight] } }, row => { row[weight] = row[weight] + 1; })); + await world.run(System.create({ components: { with: [weight] } }, row => { row[weight] = row[weight] + 1; })); await world.run(visit); expect(await visits()).toBe(2 * rows); }); diff --git a/packages/ecs/src/implementation/world/index-lookups-csp.test.ts b/packages/ecs/src/implementation/world/index-lookups-csp.test.ts index a28b9a407..5597e5ab3 100644 --- a/packages/ecs/src/implementation/world/index-lookups-csp.test.ts +++ b/packages/ecs/src/implementation/world/index-lookups-csp.test.ts @@ -1,23 +1,21 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; -import { Component } from "../../interface/component/component.js"; import { Index } from "../../interface/indexes/index-type.js"; import { workers } from "../../interface/resources/workers.js"; import { System } from "../../interface/system/system.js"; import { createWorld } from "./create.js"; +import type { Vec2 } from "../../interface/math/index.js"; const spot = Symbol("indexLookupsCspTest/spot"); const size = Symbol("indexLookupsCspTest/size"); declare module "@adobe/ecs" { interface Components { - [spot]: { type: readonly [number, number] }; + [spot]: { type: Vec2.I32 }; [size]: { type: number }; } } -Component.register(spot, { type: { type: "array", items: { type: "integer", minimum: -100, maximum: 100 }, minItems: 2, maxItems: 2 } }); -Component.register(size, { type: { type: "number" } }); -const bySpot = Index.create({ key: [spot], components: { has: [size] } }); +const bySpot = Index.create({ key: [spot], components: { with: [size] } }); const bySize = Index.create({ key: [size], sorted: true, unique: true }); describe("index lookups where code can't be generated", () => { @@ -31,7 +29,7 @@ describe("index lookups where code can't be generated", () => { for (let k = 0; k < 6; k++) commands.spawn([spot, size])({ [spot]: [k % 2, 0], [size]: k }); })); let found: number[] = []; - await world.run(System.create({ lookup: { at: { index: bySpot, read: [size] }, sized: { index: bySize, read: [size] } } }, ({ lookup }) => { + await world.run(System.create({ lookup: { at: { index: bySpot }, sized: { index: bySize } } }, ({ lookup }) => { const list = lookup.at([1, 0]); const range = lookup.sized({ ">": 1, "<=": 4 }); found = [...Array.from({ length: list.length }, (_, i) => list.at(i)[size]), -1, ...Array.from({ length: range.length }, (_, i) => range.at(i)[size]), -1, lookup.sized(5)?.[size] ?? -2]; diff --git a/packages/ecs/src/implementation/world/index-lookups.ts b/packages/ecs/src/implementation/world/index-lookups.ts index 2ca976e1d..83a85063f 100644 --- a/packages/ecs/src/implementation/world/index-lookups.ts +++ b/packages/ecs/src/implementation/world/index-lookups.ts @@ -1,7 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { Scalar } from '../../interface/registry/layout.js'; import { encodeScalar, encodeValue, hashWords, orderF32, orderF64, partWords, type KeyPart } from './index-keys.js'; -import { entryWords, findBucket, lengthOf, recordKey, recordLength, recordStart, startOf, type HashView } from './index-hash.js'; +import { entryWords, findBucket, isHashTable, lengthOf, recordKey, recordLength, recordStart, sortBucket, startOf, type HashView } from './index-hash.js'; import { blockAt, blockBase, bound, firstPosition, treeMaximum, type SortedView } from './index-sorted.js'; import { sharedRows, type Row, type RowArchetype, type RowHost } from './rows.js'; import { idOf, type Key, type State } from './state.js'; @@ -41,10 +41,10 @@ export type IndexLookup = { readonly watched: readonly number[]; readonly since: Since; /** - * How many times the world's archetypes' arrays were replaced, while which rows bound to them stay current: on the - * world's thread. Null on a joined thread, whose rows check their archetype's version each time. + * How many times archetypes' arrays were replaced, while which rows bound to them stay current: the world's count on + * its thread; on a joined thread, its directory's, moved on once a run finds any archetype's views changed. */ - readonly replaced: { readonly count: number } | null; + readonly replaced: { readonly count: number }; }; /** The lookup behind each function bodies are given, for the plugin's call sites. */ @@ -59,6 +59,8 @@ type Pool = { readonly rows: (Row | undefined)[]; readonly archetypes: number[]; readonly versions: number[]; + /** By position: the archetype it was bound to, since a joined thread's view of one is made again when it changes. */ + readonly bound: (LookupArchetype | undefined)[]; readonly make: (archetype: RowArchetype) => Row; /** The lookup's `replaced` count when every row was last checked against its archetype's version. */ replaced: number; @@ -72,7 +74,7 @@ const makers = new WeakMap Row>(); const poolOf = (lookup: IndexLookup): Pool => { let make = makers.get(lookup); if (make === undefined) makers.set(lookup, make = sharedRows(lookup.rows, lookup.reads, lookup.readIds)); - return { rows: [], archetypes: [], versions: [], make, replaced: lookup.replaced?.count ?? 0, ahead: 0 }; + return { rows: [], archetypes: [], versions: [], bound: [], make, replaced: lookup.replaced.count, ahead: 0 }; }; /** Binds row `n` of a pool to an archetype, making it first if the pool has none there yet. */ @@ -83,12 +85,14 @@ const bindRow = (pool: Pool, n: number, archetypeId: number, archetype: LookupAr rows.push(undefined); pool.archetypes.push(-1); pool.versions.push(-1); + pool.bound.push(undefined); } let found = rows[n]; if (found === undefined) found = rows[n] = pool.make(archetype); else found.bind(archetype); pool.archetypes[n] = archetypeId; pool.versions[n] = archetype.version; + pool.bound[n] = archetype; return found; }; @@ -101,18 +105,22 @@ const madeAhead = 256; * body inlines. */ const growPool = (lookup: IndexLookup, pool: Pool, length: number, archetypeId: number): void => { - pool.ahead = length; + // A first entry that's an unsorted bucket's hole, archetype -1, names none: a later lookup makes them. const archetype = lookup.archetypeAt(archetypeId); if (archetype === undefined) return; + pool.ahead = length; const end = Math.min(length, madeAhead); for (let n = pool.rows.length; n < end; n++) bindRow(pool, n, archetypeId, archetype); }; -/** Binds again the rows whose archetype's arrays were replaced since the pool last checked, on the world's thread. */ +/** Whether row `n` of a pool is bound to an archetype as it is now: the same view, its arrays not replaced since. */ +const boundTo = (pool: Pool, n: number, archetype: LookupArchetype): boolean => pool.bound[n] === archetype && pool.versions[n] === archetype.version; + +/** Binds again the rows whose archetype's arrays were replaced since the pool last checked. */ const refreshPool = (lookup: IndexLookup, pool: Pool, replaced: { readonly count: number }): void => { for (let n = 0; n < pool.rows.length; n++) { const archetype = pool.rows[n] === undefined ? undefined : lookup.archetypeAt(pool.archetypes[n]); - if (archetype !== undefined && archetype.version !== pool.versions[n]) bindRow(pool, n, pool.archetypes[n], archetype); + if (archetype !== undefined && !boundTo(pool, n, archetype)) bindRow(pool, n, pool.archetypes[n], archetype); } pool.replaced = replaced.count; }; @@ -120,27 +128,22 @@ const refreshPool = (lookup: IndexLookup, pool: Pool, replaced: { readonly count /** Binds row `n` again to an entry's archetype, or makes it, when its archetype or that's arrays changed. */ const reboundAt = (lookup: IndexLookup, pool: Pool, n: number, archetypeId: number, row: number): object | undefined => { const { replaced } = lookup; - if (replaced !== null && pool.replaced !== replaced.count) refreshPool(lookup, pool, replaced); + if (pool.replaced !== replaced.count) refreshPool(lookup, pool, replaced); const archetype = lookup.archetypeAt(archetypeId); if (archetype === undefined) return undefined; const current = pool.rows[n]; - const found = current !== undefined && pool.archetypes[n] === archetypeId && pool.versions[n] === archetype.version ? current : bindRow(pool, n, archetypeId, archetype); + const found = current !== undefined && pool.archetypes[n] === archetypeId && boundTo(pool, n, archetype) ? current : bindRow(pool, n, archetypeId, archetype); found.index = row; return found.value; }; -/** Whether row `n` of a pool is bound to its archetype's current arrays, on a joined thread. */ -const currentVersion = (lookup: IndexLookup, pool: Pool, n: number, archetypeId: number): boolean => - pool.versions[n] === lookup.archetypeAt(archetypeId)?.version; - /** - * Points row `n` of a pool at an entry's archetype and row, binding it again only when its archetype or that's arrays - * changed: on the world's thread, when any archetype's were replaced since the pool last checked. + * Points row `n` of a pool at an entry's archetype and row, binding it again only when its archetype changed, or any + * archetype's arrays were replaced since the pool last checked. */ const rowAt = (lookup: IndexLookup, pool: Pool, n: number, archetypeId: number, row: number): object | undefined => { const found = pool.rows[n]; - const { replaced } = lookup; - if (found === undefined || pool.archetypes[n] !== archetypeId || (replaced === null ? !currentVersion(lookup, pool, n, archetypeId) : pool.replaced !== replaced.count)) return reboundAt(lookup, pool, n, archetypeId, row); + if (found === undefined || pool.archetypes[n] !== archetypeId || pool.replaced !== lookup.replaced.count) return reboundAt(lookup, pool, n, archetypeId, row); found.index = row; return found.value; }; @@ -192,6 +195,8 @@ const hashAt = function (this: HashList, position: number): object | undefined { const { bucket, hash } = found; const record = bucket * hash.stride; if (bucket < 0 || !(position >= 0 && position < hash.records[record + recordLength])) return undefined; + // On the world's thread, a bucket a change left unsorted is sorted by the first read by position. + if (hash.unsorted !== 0 && isHashTable(hash) && hash.tails[bucket] >= 0) sortBucket(hash, bucket); const at = (hash.records[record + recordStart] + position) * entryWords; return rowAt(found.lookup, found.pool, position, hash.entries[at + 1], hash.entries[at + 2]); }; @@ -308,8 +313,8 @@ const generatedFind = (lookup: IndexLookup, hash: HashView, shape: Shape, list: ...lookup.unique ? [ `if (bucket < 0 || records[bucket * ${hash.stride} + ${recordLength}] === 0) return null;`, `const at = records[bucket * ${hash.stride} + ${recordStart}] * ${entryWords};`, - "const archetypeId = table.entries[at + 1], archetype = archetypeAt(archetypeId), row = pool.rows[0];", - "if (row === undefined || archetype === undefined || pool.archetypes[0] !== archetypeId || pool.versions[0] !== archetype.version) return rowAt(lookup, pool, 0, archetypeId, table.entries[at + 2]);", + "const archetypeId = table.entries[at + 1], row = pool.rows[0];", + "if (row === undefined || pool.archetypes[0] !== archetypeId || pool.replaced !== replaced.count) return rowAt(lookup, pool, 0, archetypeId, table.entries[at + 2]);", "row.index = table.entries[at + 2];", "return row.value;", ] : [ @@ -320,9 +325,9 @@ const generatedFind = (lookup: IndexLookup, hash: HashView, shape: Shape, list: "};", ].join("\n"); try { - const factory = new Function("key", "table", "found", "list", "pool", "lookup", "rowAt", "growPool", "encodeValue", "parts", "orderF32", "orderF64", "f32", "f64", "u32", "u8", "archetypeAt", body); + const factory = new Function("key", "table", "found", "list", "pool", "lookup", "rowAt", "growPool", "encodeValue", "parts", "orderF32", "orderF64", "f32", "f64", "u32", "u8", "replaced", body); // The factory returns the function its body above makes. - const made: (...values: unknown[]) => unknown = factory(new Uint32Array(Math.max(1, words)), hash, found, list, found.pool, lookup, rowAt, growPool, encodeValue, index.parts, orderF32, orderF64, f32, f64, u32, u8, lookup.archetypeAt); + const made: (...values: unknown[]) => unknown = factory(new Uint32Array(Math.max(1, words)), hash, found, list, found.pool, lookup, rowAt, growPool, encodeValue, index.parts, orderF32, orderF64, f32, f64, u32, u8, lookup.replaced); return made; } catch { diff --git a/packages/ecs/src/implementation/world/index-order.test.ts b/packages/ecs/src/implementation/world/index-order.test.ts index 23df92ecf..4f5a0963a 100644 --- a/packages/ecs/src/implementation/world/index-order.test.ts +++ b/packages/ecs/src/implementation/world/index-order.test.ts @@ -1,6 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; -import { Component } from "../../interface/component/component.js"; import { Index } from "../../interface/indexes/index-type.js"; import { Observe } from "../../observe/index.js"; import { always } from "../../interface/resources/always.js"; @@ -9,6 +8,7 @@ import { workers } from "../../interface/resources/workers.js"; import { System } from "../../interface/system/system.js"; import { createWorld, type CreatedWorld } from "./create.js"; import { backToBack } from "./loop.js"; +import type { U32 } from "../../interface/math/index.js"; const rank = Symbol("indexOrderTest/rank"); const lane = Symbol("indexOrderTest/lane"); @@ -19,45 +19,40 @@ const marker = Symbol("indexOrderTest/marker"); declare module "@adobe/ecs" { interface Components { [rank]: { type: number }; - [lane]: { type: number }; + [lane]: { type: U32 }; [weight]: { type: number }; [extra]: { type: true }; [marker]: { type: number }; } } -Component.register(rank, { type: { type: "number" } }); -Component.register(lane, { type: { type: "integer", minimum: 0, maximum: 100 } }); -Component.register(weight, { type: { type: "number" } }); -Component.register(extra, { type: { const: true } }); -Component.register(marker, { type: { type: "number" } }); /** By lane, then rank, of the entities that have a weight. */ -const ranked = Index.create({ name: "indexOrderTest/ranked", key: [lane, rank], sorted: true, components: { has: [weight] } }); +const ranked = Index.create({ key: [lane, rank], sorted: true, components: { with: [weight] } }); /** Each row's lane and rank as one number, in the order rows ran. */ let order: number[] = []; -const visit = System.create({ name: "indexOrderTest/visit", components: { read: [lane, rank] }, order: ranked }, row => { +const visit = System.create({ components: { with: [lane, rank] }, order: ranked }, row => { order.push(row[lane] * 1000 + row[rank]); }); -const visitChanged = System.create({ name: "indexOrderTest/visitChanged", components: { read: [lane, rank], changed: [weight] }, order: ranked }, row => { +const visitChanged = System.create({ components: { with: [lane, rank], changed: [weight] }, order: ranked }, row => { order.push(row[lane] * 1000 + row[rank]); }); /** A kernel spawning a marker for each row in turn, so the markers' entities, in order, are the rows' order. */ -const markEach = System.create({ name: "indexOrderTest/markEach", kernel: "cpu", components: { read: [lane, rank] }, order: ranked }, (row, { commands }) => { +const markEach = System.create({ kernel: true, components: { with: [lane, rank] }, order: ranked }, (row, { commands }) => { commands.spawn([marker])({ [marker]: row[lane] * 1000 + row[rank] }); }); -const markChanged = System.create({ name: "indexOrderTest/markChanged", kernel: "cpu", components: { read: [lane, rank], changed: [weight] }, order: ranked }, (row, { commands }) => { +const markChanged = System.create({ kernel: true, components: { with: [lane, rank], changed: [weight] }, order: ranked }, (row, { commands }) => { commands.spawn([marker])({ [marker]: row[lane] * 1000 + row[rank] }); }); -const keepTicking = System.create({ name: "indexOrderTest/keepTicking" }, () => {}); +const keepTicking = System.create({}, () => {}); /** Changes the weight of every row whose weight ends in 3, a tenth of them. */ -const nudge = System.create({ name: "indexOrderTest/nudge", components: { write: [weight] } }, row => { +const nudge = System.create({ components: { with: [weight] } }, row => { if (row[weight] % 10 === 3) row[weight] = row[weight] + 1; }); /** Entities in two archetypes, interleaved in the index's order, some without the weight the index requires. */ -const seed = System.create({ name: "indexOrderTest/seed", input: { type: "object", properties: { count: { type: "integer" } } } }, ({ input, commands }) => { +const seed = System.create({}, ({ commands }, input: { readonly count: number }) => { for (let k = 0; k < input.count; k++) { const values = { [lane]: k % 3, [rank]: (k * 37) % 101, [weight]: k }; if (k % 5 === 4) commands.spawn([lane, rank])({ [lane]: k % 3, [rank]: (k * 37) % 101 }); @@ -77,7 +72,7 @@ const expected = async (world: CreatedWorld, only?: (weighed: number) => boolean }; const markerValues: number[] = []; -const readMarkers = System.create({ name: "indexOrderTest/readMarkers", observer: true, components: { read: [marker] } }, row => { markerValues.push(row[marker]); }); +const readMarkers = System.create({ observer: true, components: { 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 => { @@ -105,7 +100,7 @@ describe("systems ordering their rows by a sorted index", () => { expect(order).toEqual(await expected(world, weighed => weighed % 10 === 4)); expect(order.length).toBeGreaterThan(100); // Most rows change: the index is scanned rather than the rows' positions sorted. - await world.run(System.create({ components: { write: [weight] } }, row => { row[weight] = row[weight] + 2; })); + await world.run(System.create({ components: { with: [weight] } }, row => { row[weight] = row[weight] + 2; })); order = []; await world.run(visitChanged); expect(order).toEqual(await expected(world)); diff --git a/packages/ecs/src/implementation/world/index-sorted.ts b/packages/ecs/src/implementation/world/index-sorted.ts index 6cfee3907..b0a3dcfcc 100644 --- a/packages/ecs/src/implementation/world/index-sorted.ts +++ b/packages/ecs/src/implementation/world/index-sorted.ts @@ -585,12 +585,16 @@ const split = (table: SortedTable, k: number, tick: number): void => { reindexFrom(table, k, table.blocks); }; -/** Updates where entity index `entity` is: its archetype and row, at its entry. */ -export const relocateSorted = (table: SortedTable, entity: number, archetype: number, row: number): void => { +/** Updates where entity index `entity` is: its archetype and row, at its entry. Returns whether either changed. */ +export const relocateSorted = (table: SortedTable, entity: number, archetype: number, row: number): boolean => { const base = entryOf(table, entity); - if (base < 0) return; - table.entries[base + table.words + 1] = archetype; - table.entries[base + table.words + 2] = row; + if (base < 0) return false; + const at = base + table.words; + const { entries } = table; + if (entries[at + 1] === archetype && entries[at + 2] === row) return false; + entries[at + 1] = archetype; + entries[at + 2] = row; + return true; }; /** diff --git a/packages/ecs/src/implementation/world/index-workers.cases.ts b/packages/ecs/src/implementation/world/index-workers.cases.ts index 801f401d0..b19a7f826 100644 --- a/packages/ecs/src/implementation/world/index-workers.cases.ts +++ b/packages/ecs/src/implementation/world/index-workers.cases.ts @@ -15,4 +15,13 @@ export const indexWorkersCases = (load: () => Promise) => descri // Targets' tallies through the index without a key: its length in millions, then a stride of its weights. expect(three.rows.filter(row => /^\[\d+,-?\d+,\d{7,}\]$/.test(row)).length).toBeGreaterThan(900); }); + it.each([[true], [false]])("read a hash index's long buckets in entity order after changes left them unsorted, WASM %s", async wasm => { + const fixture = await load(); + for (const threads of [0, 3]) { + const orders = await fixture.bandOrders(threads, wasm); + expect(orders.kernel).toEqual(orders.byHand); + expect(orders.kernel.every(round => round[0] > 0)).toBe(true); + if (threads > 0) expect(orders.tasks).toBeGreaterThan(0); + } + }); }); diff --git a/packages/ecs/src/implementation/world/index-workers.fixture.ts b/packages/ecs/src/implementation/world/index-workers.fixture.ts index 8eb066e9b..af08231e4 100644 --- a/packages/ecs/src/implementation/world/index-workers.fixture.ts +++ b/packages/ecs/src/implementation/world/index-workers.fixture.ts @@ -1,5 +1,4 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Component } from "../../interface/component/component.js"; import { Entity } from "../../interface/entity/entity.js"; import { Index } from "../../interface/indexes/index-type.js"; import { workers } from "../../interface/resources/workers.js"; @@ -7,6 +6,7 @@ import { System } from "../../interface/system/system.js"; import { readEntity, select } from "./reads.test-helper.js"; import { createWorld } from "./create.js"; import { countingWorkers } from "./platforms.test-helper.js"; +import type { U32, Vec2 } from "../../interface/math/index.js"; /** Scenarios of kernels looking up through indexes across workers, run built in node and from source in chromium. */ @@ -21,44 +21,39 @@ const seen = Symbol("indexWorkersTest/seen"); const serial = Symbol("indexWorkersTest/serial"); const target = Symbol("indexWorkersTest/target"); const tally = Symbol("indexWorkersTest/tally"); +const band = Symbol("indexWorkersTest/band"); +const bandSum = Symbol("indexWorkersTest/bandSum"); +const bandSumByHand = Symbol("indexWorkersTest/bandSumByHand"); declare module "@adobe/ecs" { interface Components { - [cell]: { type: readonly [number, number] }; + [cell]: { type: Vec2.I32 }; [weight]: { type: number }; [total]: { type: number }; [count]: { type: number }; [changes]: { type: number }; - [lane]: { type: number }; + [lane]: { type: U32 }; [rank]: { type: number }; [seen]: { type: Entity }; - [serial]: { type: number }; - [target]: { type: number }; + [serial]: { type: U32 }; + [target]: { type: U32 }; [tally]: { type: number }; + [band]: { type: U32 }; + [bandSum]: { type: number }; + [bandSumByHand]: { type: number }; } } -Component.register(cell, { type: { type: "array", items: { type: "integer", minimum: -1000, maximum: 1000 }, minItems: 2, maxItems: 2 } }); -Component.register(weight, { type: { type: "number" } }); -Component.register(total, { type: { type: "number" } }); -Component.register(count, { type: { type: "number" } }); -Component.register(changes, { type: { type: "number" } }); -Component.register(lane, { type: { type: "integer", minimum: 0, maximum: 1000 } }); -Component.register(rank, { type: { type: "number" } }); -Component.register(seen, { type: Entity.schema }); -Component.register(serial, { type: { type: "integer", minimum: 0, maximum: 1000000 } }); -Component.register(target, { type: { type: "integer", minimum: 0, maximum: 1000000 } }); -Component.register(tally, { type: { type: "number" } }); - -const grid = Index.create({ name: "indexWorkersTest/grid", key: [cell], components: { has: [weight] } }); -const lanes = Index.create({ name: "indexWorkersTest/lanes", key: [lane, rank], sorted: true }); -const serials = Index.create({ name: "indexWorkersTest/serials", key: [serial], unique: true, components: { has: [weight] } }); -const weighted = Index.create({ name: "indexWorkersTest/weighted", key: [], components: { has: [weight] } }); + +const grid = Index.create({ key: [cell], components: { with: [weight] } }); +const lanes = Index.create({ key: [lane, rank], sorted: true }); +const serials = Index.create({ key: [serial], unique: true, components: { with: [weight] } }); +const weighted = Index.create({ key: [], components: { with: [weight] } }); /** Each row's neighbors in the 3 by 3 cells around its own: how many, and their weights' sum. */ const neighbors = System.create({ - name: "indexWorkersTest/neighbors", kernel: "cpu", - components: { read: [cell], write: [total, count] }, - lookup: { near: { index: grid, read: [weight] } }, + kernel: true, + components: { with: [cell, total, count] }, + lookup: { near: { index: grid } }, }, (row, { lookup }) => { const [x, y] = row[cell]; let sum = 0; @@ -78,8 +73,8 @@ const neighbors = System.create({ /** Counts runs whose list of the row's own cell changed. */ const watch = System.create({ - name: "indexWorkersTest/watch", kernel: "cpu", - components: { read: [cell], write: [changes] }, + kernel: true, + components: { with: [cell, changes] }, lookup: { near: { index: grid, changed: [weight] } }, after: [neighbors], }, (row, { lookup }) => { @@ -89,8 +84,8 @@ const watch = System.create({ /** How many rows of the row's lane have a rank within 5 of its own, and the first of them. */ const window = System.create({ - name: "indexWorkersTest/window", kernel: "cpu", - components: { read: [lane, rank], write: [count, seen] }, + kernel: true, + components: { with: [lane, rank, count, seen] }, lookup: { inLane: { index: lanes } }, after: [watch], }, (row, { lookup }) => { @@ -102,9 +97,9 @@ const window = System.create({ /** The weight of the entity whose serial is the row's target, or -1. */ const follow = System.create({ - name: "indexWorkersTest/follow", kernel: "cpu", - components: { read: [target], write: [total] }, - lookup: { bySerial: { index: serials, read: [weight] } }, + kernel: true, + components: { with: [target, total] }, + lookup: { bySerial: { index: serials } }, after: [window], }, (row, { lookup }) => { const found = lookup.bySerial(row[target]); @@ -113,9 +108,9 @@ const follow = System.create({ /** Through an index without a key: how many entities it holds, a stride of their weights, and whether it changed. */ const census = System.create({ - name: "indexWorkersTest/census", kernel: "cpu", - components: { read: [target], write: [tally] }, - lookup: { weighted: { index: weighted, read: [weight], changed: [weight] } }, + kernel: true, + components: { with: [target, tally] }, + lookup: { weighted: { index: weighted, changed: [weight] } }, after: [follow], }, (row, { lookup }) => { const all = lookup.weighted(); @@ -124,9 +119,9 @@ const census = System.create({ row[tally] = all.length * 1000000 + sum * 2 + (all.changed ? 1 : 0); }); -const tick = System.create({ name: "indexWorkersTest/tick", systems: [neighbors, watch, window, follow, census] }); +const tick = System.create({ systems: [neighbors, watch, window, follow, census] }); -const seed = System.create({ name: "indexWorkersTest/seed" }, ({ commands }) => { +const seed = System.create({}, ({ commands }) => { for (let k = 0; k < 3000; k++) { commands.spawn([cell, weight, total, count, changes, serial])({ [cell]: [(k * 7) % 31, (k * 3) % 29], [weight]: k % 9, [total]: 0, [count]: 0, [changes]: 0, [serial]: k }); } @@ -137,15 +132,16 @@ const seed = System.create({ name: "indexWorkersTest/seed" }, ({ commands }) => }); /** Moves some cells, changes some weights and ranks, despawns some serials and spawns more: a different input each time. */ -const stir = System.create({ name: "indexWorkersTest/stir", input: { type: "object", properties: { round: { type: "integer" } } }, components: { write: [cell, weight] } }, (row, { input }) => { +const stir = System.create({ components: { with: [cell, weight] } }, (row, _context, input: { readonly round: number }) => { const [x, y] = row[cell]; if ((x + y + input.round) % 11 === 0) row[cell] = [(x + 1) % 31, y]; if ((x * y + input.round) % 13 === 0) row[weight] = row[weight] + 1; }); -const rerank = System.create({ name: "indexWorkersTest/rerank", input: { type: "object", properties: { round: { type: "integer" } } }, components: { write: [rank] } }, (row, { input }) => { +const rerank = System.create({ components: { with: [rank] } }, (row, _context, input: { readonly round: number }) => { if ((row[rank] + input.round) % 7 === 0) row[rank] = (row[rank] * 3 + 1) % 1009; }); -const churn = System.create({ name: "indexWorkersTest/churn", input: { type: "object", properties: { round: { type: "integer" } } }, components: { read: [serial] } }, (row, { input, commands }) => { +// As written: spawned on workers, entities would take ids in an order that depends on the pool, which the census samples by. +const churn = System.create({ kernel: false, components: { with: [serial] } }, (row, { commands }, input: { readonly round: number }) => { if ((row[serial] + input.round) % 17 === 0) commands.despawn(row.entity); if (row[serial] % 101 === input.round) { commands.spawn([cell, weight, total, count, changes, serial])({ [cell]: [input.round, row[serial] % 29], [weight]: 4, [total]: 0, [count]: 0, [changes]: 0, [serial]: 3000 + row[serial] + input.round }); @@ -190,3 +186,65 @@ export const simulate = async (threads: number, wasm: boolean): Promise<{ readon await ecs.dispose(); } }; + +/** Two long buckets, which changes leave unsorted until something reads them by position. */ +const bands = Index.create({ key: [band], components: { with: [serial] } }); + +/** Each row's band's serials, each times its position plus one: a sum that tells the order. */ +const bandOrder = System.create({ + kernel: true, + components: { with: [target, bandSum] }, + lookup: { inBand: { index: bands } }, +}, (row, { lookup }) => { + const list = lookup.inBand(row[target] % 2); + let sum = 0; + for (let i = 0; i < list.length; i++) sum += (i + 1) * list.at(i)[serial]; + row[bandSum] = sum; +}); +const bandOrderByHand = System.create({ + components: { with: [target, bandSumByHand] }, + lookup: { inBand: { index: bands } }, +}, (row, { lookup }) => { + const list = lookup.inBand(row[target] % 2); + let sum = 0; + for (let i = 0; i < list.length; i++) sum += (i + 1) * list.at(i)[serial]; + row[bandSumByHand] = sum; +}); +const seedBands = System.create({}, ({ commands }) => { + for (let k = 0; k < 600; k++) commands.spawn([band, serial])({ [band]: k % 2, [serial]: k }); + for (let k = 0; k < 512; k++) commands.spawn([target, bandSum, bandSumByHand])({ [target]: k, [bandSum]: 0, [bandSumByHand]: 0 }); +}); +/** Moves some entities to the other band, despawns some and spawns others. */ +const stirBands = System.create({ components: { with: [serial, band] } }, (row, { commands }, input: { readonly round: number }) => { + if ((row[serial] + input.round) % 7 === 0) row[band] = 1 - row[band]; + if ((row[serial] + input.round) % 29 === 0) commands.despawn(row.entity); + if (row[serial] % 31 === input.round) commands.spawn([band, serial])({ [band]: row[serial] % 2, [serial]: 1000 + row[serial] * 10 + input.round }); +}); + +/** + * Over rounds of changes, what a kernel reading the bands in order wrote, and what a main-thread system reading them + * after it wrote, each round's sorted; and the run tasks posted to workers. + */ +export const bandOrders = async (threads: number, wasm: boolean): Promise<{ readonly kernel: readonly (readonly number[])[]; readonly byHand: readonly (readonly number[])[]; readonly tasks: number }> => { + const counting = countingWorkers(); + const ecs = createWorld({ [workers]: threads }, { minimumChunk: 64, wasm, pool: counting.platform }); + try { + await ecs.run(seedBands); + const posted = (): number => counting.posted("run") + counting.posted("window"); + const before = posted(); + const kernel: number[][] = []; + const byHand: number[][] = []; + for (let round = 0; round < 6; round++) { + await ecs.run(stirBands, { round }); + await ecs.run(bandOrder); + await ecs.run(bandOrderByHand); + const values = await Promise.all((await select(ecs, { required: [target] })).map(entity => readEntity(ecs, entity, [bandSum, bandSumByHand]))); + kernel.push(values.map(found => found?.[bandSum] ?? -1).sort((a, b) => a - b)); + byHand.push(values.map(found => found?.[bandSumByHand] ?? -1).sort((a, b) => a - b)); + } + return { kernel, byHand, tasks: posted() - before }; + } + finally { + await ecs.dispose(); + } +}; diff --git a/packages/ecs/src/implementation/world/indexes.test.ts b/packages/ecs/src/implementation/world/indexes.test.ts index 3638409c6..bb0c70bb6 100644 --- a/packages/ecs/src/implementation/world/indexes.test.ts +++ b/packages/ecs/src/implementation/world/indexes.test.ts @@ -1,6 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; -import { Component } from "../../interface/component/component.js"; import { Entity } from "../../interface/entity/entity.js"; import { Finalizer } from "../../interface/finalizer/finalizer.js"; import { Index } from "../../interface/indexes/index-type.js"; @@ -13,6 +12,7 @@ import { createWorld, type CreatedWorld } from "./create.js"; import { tablesAfterCollecting } from "./lifetime.test-helper.js"; import { backToBack } from "./loop.js"; import { runReported } from "./reports.test-helper.js"; +import type { I32, U32, Vec2 } from "../../interface/math/index.js"; const cell = Symbol("indexesTest/cell"); const weight = Symbol("indexesTest/weight"); @@ -24,30 +24,23 @@ const order = Symbol("indexesTest/order"); declare module "@adobe/ecs" { interface Components { - [cell]: { type: readonly [number, number] }; + [cell]: { type: Vec2.I32 }; [weight]: { type: number }; [label]: { type: string }; - [serverId]: { type: number }; + [serverId]: { type: U32 }; [owner]: { type: Entity; reference: "weak" }; [hidden]: { type: true }; [order]: { type: number }; } } -Component.register(cell, { type: { type: "array", items: { type: "integer", minimum: -1000, maximum: 1000 }, minItems: 2, maxItems: 2 } }); -Component.register(weight, { type: { type: "number" } }); -Component.register(label, { type: { type: "string" } }); -Component.register(serverId, { type: { type: "integer", minimum: 0, maximum: 1000000 } }); -Component.register(owner, { type: Entity.schema, reference: "weak" }); -Component.register(hidden, { type: { const: true } }); -Component.register(order, { type: { type: "number" } }); -const grid = Index.create({ name: "indexesTest/grid", key: [cell], components: { has: [weight], not: [hidden] } }); -const byServerId = Index.create({ name: "indexesTest/byServerId", key: [serverId], unique: true }); -const byLabel = Index.create({ name: "indexesTest/byLabel", key: [label] }); -const children = Index.create({ name: "indexesTest/children", key: [owner] }); +const grid = Index.create({ key: [cell], components: { with: [weight], not: [hidden] } }); +const byServerId = Index.create({ key: [serverId], unique: true }); +const byLabel = Index.create({ key: [label] }); +const children = Index.create({ key: [owner] }); -const spawnCells = System.create({ name: "indexesTest/spawnCells", input: { type: "object", properties: { cells: { type: "array", items: { type: "array", items: { type: "integer" } } } } } }, ({ input, commands }) => { +const spawnCells = System.create({}, ({ commands }, input: { readonly cells: readonly (readonly number[])[] }) => { input.cells.forEach(([x, y], k) => commands.spawn([cell, weight])({ [cell]: [x ?? 0, y ?? 0], [weight]: k })); }); @@ -55,10 +48,8 @@ const spawnCells = System.create({ name: "indexesTest/spawnCells", input: { type let seen: { readonly entities: readonly Entity[]; readonly weights: readonly number[]; readonly changed: boolean; readonly length: number } | null = null; const look = System.create({ - name: "indexesTest/look", - input: { type: "object", properties: { x: { type: "integer" }, y: { type: "integer" } } }, - lookup: { near: { index: grid, read: [weight] } }, -}, ({ input, lookup }) => { + lookup: { near: { index: grid } }, +}, ({ lookup }, input: { readonly x: number; readonly y: number }) => { const near = lookup.near([input.x, input.y]); const entities: Entity[] = []; const weights: number[] = []; @@ -70,14 +61,14 @@ const look = System.create({ }); /** Moves every entity in cell [0, 0] to [1, 0], before `lookAfter` in the same run. */ -const shift = System.create({ name: "indexesTest/shift", components: { write: [cell] } }, row => { +const shift = System.create({ components: { with: [cell] } }, row => { if (row[cell][0] === 0 && row[cell][1] === 0) row[cell] = [1, 0]; }); let afterShift: number | null = null; -const lookAfter = System.create({ name: "indexesTest/lookAfter", lookup: { near: { index: grid } }, after: [shift] }, ({ lookup }) => { +const lookAfter = System.create({ lookup: { near: { index: grid } }, after: [shift] }, ({ lookup }) => { afterShift = lookup.near([1, 0]).length; }); -const shiftThenLook = System.create({ name: "indexesTest/shiftThenLook", systems: [shift, lookAfter] }); +const shiftThenLook = System.create({ systems: [shift, lookAfter] }); const entitiesOf = (world: CreatedWorld): Promise => Observe.toPromise(Observe.select(world, { required: [cell] })); @@ -141,7 +132,7 @@ describe("hash indexes on the main thread", () => { it("key strings by value, and entities by their generation too", async () => { await using world = createWorld({ [workers]: 0 }); let found: readonly number[] = []; - const named = System.create({ input: { type: "object", properties: { name: { type: "string" } } }, lookup: { named: { index: byLabel } } }, ({ input, lookup }) => { + const named = System.create({ lookup: { named: { index: byLabel } } }, ({ lookup }, input: { readonly name: string }) => { const list = lookup.named(input.name); found = Array.from({ length: list.length }, (_, i) => list.at(i).entity); }); @@ -157,7 +148,7 @@ describe("hash indexes on the main thread", () => { expect(found).toEqual([]); const parent = labeled[1]; let held: readonly Entity[] = []; - const kids = System.create({ input: { type: "object", properties: { of: { type: "number" } } }, lookup: { of: { index: children } } }, ({ input, lookup }) => { + const kids = System.create({ lookup: { of: { index: children } } }, ({ lookup }, input: { readonly of: number }) => { const list = lookup.of(Entity.none); void list; const of = lookup.of(input.of === 0 ? parent : Entity.none); @@ -177,7 +168,7 @@ describe("hash indexes on the main thread", () => { describe("unique indexes", () => { let known: Entity | null = null; - const find = System.create({ input: { type: "object", properties: { id: { type: "integer" } } }, lookup: { known: { index: byServerId } } }, ({ input, lookup }) => { + const find = System.create({ lookup: { known: { index: byServerId } } }, ({ lookup }, input: { readonly id: number }) => { known = lookup.known(input.id)?.entity ?? null; }); it("give the row of a key or null, and report two entities with one key as an index failure, keeping the lower", async () => { @@ -197,8 +188,9 @@ describe("unique indexes", () => { expect(known).toBe(first); expect(conflicted.failures).toEqual([{ kind: "index", - name: "indexesTest/byServerId", - message: `Index "indexesTest/byServerId" is unique, and entities ${first} and ${second} both have key 7; lookups give ${first}, the one with the lower entity index.`, + name: "byServerId", + id: "@adobe/ecs@0.1.1/src/implementation/world/indexes.test.ts#byServerId", + message: `Index "byServerId" is unique, and entities ${first} and ${second} both have key 7; lookups give ${first}, the one with the lower entity index.`, stack: null, }]); // Found once: the next run reports nothing. @@ -211,7 +203,7 @@ describe("call sites", () => { await using world = createWorld({ [workers]: 0 }); await world.run(spawnCells, { cells: [[0, 0], [0, 0], [1, 1]] }); let pairs: number[] = []; - const both = System.create({ lookup: { near: { index: grid, read: [weight] } } }, ({ lookup }) => { + const both = System.create({ lookup: { near: { index: grid } } }, ({ lookup }) => { const a = lookup.near([0, 0]); const b = lookup.near([1, 1]); pairs = [a.length, b.length, a.at(0)[weight] + a.at(1)[weight], b.at(0)[weight]]; @@ -236,7 +228,7 @@ describe("call sites", () => { describe("finalizers", () => { const released: number[] = []; - Finalizer.create({ name: "indexesTest/release", components: { read: [order, cell] }, lookup: { near: { index: grid, read: [weight] } } }, (row, { lookup }) => { + Finalizer.create({ components: { with: [order, cell] }, lookup: { near: { index: grid } } }, (row, { lookup }) => { released.push(lookup.near(row[cell]).length); }); it("look up through indexes as they are before the sync point applies", async () => { @@ -254,9 +246,9 @@ describe("finalizers", () => { }); }); -const keepTicking = System.create({ name: "indexesTest/keepTicking" }, () => {}); +const keepTicking = System.create({}, () => {}); /** Uses `grid` every tick it's added to. */ -const watchGrid = System.create({ name: "indexesTest/watchGrid", lookup: { near: { index: grid } } }, ({ lookup }) => { void lookup.near([0, 0]).length; }); +const watchGrid = System.create({ lookup: { near: { index: grid } } }, ({ lookup }) => { void lookup.near([0, 0]).length; }); describe("index lifetime", () => { it("builds an index the first time a system uses it and releases it, arena blocks included, once none does", async () => { @@ -264,7 +256,7 @@ describe("index lifetime", () => { await using world = createWorld({ [workers]: 0, [always]: true }, { loop: driver }); await world.run(spawnCells, { cells: [[0, 0], [1, 1]] }); // Ticks of what an index is keyed by stay once started, as any component's do. - await world.run(System.create({ components: { read: [cell], changed: [cell] } }, row => { void row[cell]; })); + await world.run(System.create({ components: { with: [cell], changed: [cell] } }, row => { void row[cell]; })); const before = await tablesAfterCollecting(world); // A system made for one run is collected, and its index with it. await world.run(System.create({ lookup: { near: { index: grid } } }, ({ lookup }) => { void lookup.near([0, 0]).length; })); @@ -318,7 +310,7 @@ const bucketsOf = (lookup: (key: readonly [number, number]) => { readonly length const isOrdered = (bucket: readonly number[]): boolean => bucket.every((entity, k) => k === 0 || (bucket[k - 1] >>> 0) < (entity >>> 0)); let incremental: readonly (readonly number[])[] = []; -const probe = System.create({ name: "indexesTest/probe", lookup: { near: { index: grid } } }, ({ lookup }) => { incremental = bucketsOf(key => lookup.near(key)); }); +const probe = System.create({ lookup: { near: { index: grid } } }, ({ lookup }) => { incremental = bucketsOf(key => lookup.near(key)); }); const folded = Symbol("indexesTest/folded"); declare module "@adobe/ecs" { @@ -326,15 +318,14 @@ declare module "@adobe/ecs" { [folded]: { type: number }; } } -Component.register(folded, { type: { type: "number" } }); /** A list's entities folded into one number, by position: what `foldNear` writes. */ const foldOf = (bucket: readonly number[]): number => bucket.reduce((sum, entity, i) => sum + (entity % 997) * (i + 1), 0); /** A kernel folding the entities of its row's own cell's list, so kernels' lookups are checked against the same index. */ const foldNear = System.create({ - name: "indexesTest/foldNear", kernel: "cpu", - components: { read: [cell], write: [folded] }, + kernel: true, + components: { with: [cell, folded] }, lookup: { near: { index: grid } }, }, (row, { lookup }) => { const [x, y] = row[cell]; @@ -346,9 +337,8 @@ const foldNear = System.create({ /** Moves each row whose weight is below `share` of 1000 to the cell `cell` gives it, so keys change during runs. */ const rekey = System.create({ - name: "indexesTest/rekey", input: { type: "object", properties: { share: { type: "number" }, seed: { type: "integer" } } }, - components: { read: [weight], write: [cell] }, before: [probe], -}, (row, { input }) => { + components: { with: [weight, cell] }, before: [probe], +}, (row, _context, input: { readonly share: number; readonly seed: number }) => { if ((row[weight] * 7919 + input.seed) % 1000 < input.share * 1000) row[cell] = cells[(row[weight] + input.seed) % cells.length]; }); @@ -378,7 +368,7 @@ describe("an incrementally refreshed hash index", () => { })); // Now and then most rows' keys change, past what a patch takes. await world.run(System.create({ systems: [rekey, probe] }), { share: next() < 0.1 ? 0.9 : 0.05, seed: step }); - const scratch = Index.create({ key: [cell], components: { has: [weight], not: [hidden] } }); + const scratch = Index.create({ key: [cell], components: { with: [weight], not: [hidden] } }); let rebuilt: readonly (readonly number[])[] = []; await world.run(System.create({ lookup: { near: { index: scratch } } }, ({ lookup }) => { rebuilt = bucketsOf(key => lookup.near(key)); })); expect(incremental).toEqual(rebuilt); @@ -400,20 +390,16 @@ const pair = Symbol("indexesTest/pair"); declare module "@adobe/ecs" { interface Components { [rank]: { type: number }; - [lane]: { type: number }; + [lane]: { type: I32 }; [flag]: { type: boolean }; - [pair]: { type: readonly [number, number] }; + [pair]: { type: Vec2 }; } } -Component.register(rank, { type: { type: "number" } }); -Component.register(lane, { type: { type: "integer", minimum: -100, maximum: 100 } }); -Component.register(flag, { type: { type: "boolean" } }); -Component.register(pair, { type: { type: "array", items: { type: "number", precision: 1 }, minItems: 2, maxItems: 2 } }); -const byRank = Index.create({ name: "indexesTest/byRank", key: [rank], sorted: true }); -const byLaneRank = Index.create({ name: "indexesTest/byLaneRank", key: [lane, rank], sorted: true, components: { has: [weight] } }); -const byName = Index.create({ name: "indexesTest/byName", key: [label], sorted: true, unique: true }); -const byFlagPair = Index.create({ name: "indexesTest/byFlagPair", key: [flag, pair], sorted: true }); +const byRank = Index.create({ key: [rank], sorted: true }); +const byLaneRank = Index.create({ key: [lane, rank], sorted: true, components: { with: [weight] } }); +const byName = Index.create({ key: [label], sorted: true, unique: true }); +const byFlagPair = Index.create({ key: [flag, pair], sorted: true }); /** What a sorted lookup gave: its rows' entities and ranks, and whether it changed. */ let ranked: { readonly ranks: readonly number[]; readonly changed: boolean } = { ranks: [], changed: false }; @@ -423,13 +409,12 @@ const ranksOf = (list: { readonly length: number; readonly changed: boolean; at( }); describe("sorted indexes on the main thread", () => { - const spawnRanks = System.create({ input: { type: "object", properties: { ranks: { type: "array", items: { type: "number" } } } } }, ({ input, commands }) => { + const spawnRanks = System.create({}, ({ commands }, input: { readonly ranks: readonly number[] }) => { for (const value of input.ranks) commands.spawn([rank])({ [rank]: value }); }); const query = System.create({ - input: { type: "object", properties: { kind: { type: "string" }, value: { type: "number" }, other: { type: "number" } } }, - lookup: { ranks: { index: byRank, read: [rank] } }, - }, ({ input, lookup }) => { + lookup: { ranks: { index: byRank } }, + }, ({ lookup }, input: { readonly kind: string; readonly value: number; readonly other: number }) => { const { value, other } = input; switch (input.kind) { case "all": ranked = ranksOf(lookup.ranks()); break; @@ -486,7 +471,7 @@ describe("sorted indexes on the main thread", () => { model.forEach(([laneValue, rankValue], k) => commands.spawn([lane, rank, weight])({ [lane]: laneValue, [rank]: rankValue, [weight]: k })); })); let found: (readonly (readonly number[])[])[] = []; - await world.run(System.create({ lookup: { lanes: { index: byLaneRank, read: [lane, rank] } } }, ({ lookup }) => { + await world.run(System.create({ lookup: { lanes: { index: byLaneRank } } }, ({ lookup }) => { const read = (list: { readonly length: number; at(index: number): { readonly [lane]: number; readonly [rank]: number } }) => Array.from({ length: list.length }, (_, i) => [list.at(i)[lane], list.at(i)[rank]]); found = [read(lookup.lanes(0)), read(lookup.lanes(1, { "<": 10 })), read(lookup.lanes({ "<": 0 })), read(lookup.lanes(-1, 26)), read(lookup.lanes(-1, 27))]; })); @@ -509,7 +494,7 @@ describe("sorted indexes on the main thread", () => { let pairs: (readonly [boolean, readonly [number, number]])[] = []; let one: string | null = null; let none: string | null = "x"; - await world.run(System.create({ lookup: { names: { index: byName, read: [label] }, pairs: { index: byFlagPair, read: [flag, pair] } } }, ({ lookup }) => { + await world.run(System.create({ lookup: { names: { index: byName }, pairs: { index: byFlagPair } } }, ({ lookup }) => { const all = lookup.names(); names = Array.from({ length: all.length }, (_, i) => all.at(i)[label]); const ordered = lookup.pairs(); @@ -557,13 +542,13 @@ describe("sorted indexes on the main thread", () => { await world.run(System.create(({ commands }) => { commands.add(second, label, "one"); })); const report = await runReported(world, find); expect(kept).toBe(first); - expect(report.failures).toEqual([{ kind: "index", name: "indexesTest/byName", message: `Index "indexesTest/byName" is unique, and entities ${first} and ${second} both have key "one"; lookups give ${first}, the one with the lower entity index.`, stack: null }]); + expect(report.failures).toEqual([{ kind: "index", name: "byName", id: "@adobe/ecs@0.1.1/src/implementation/world/indexes.test.ts#byName", message: `Index "byName" is unique, and entities ${first} and ${second} both have key "one"; lookups give ${first}, the one with the lower entity index.`, stack: null }]); }); }); let incrementalRanks: readonly number[] = []; let incrementalLanes: readonly (readonly number[])[] = []; -const probeSorted = System.create({ name: "indexesTest/probeSorted", lookup: { lanes: { index: byLaneRank } } }, ({ lookup }) => { +const probeSorted = System.create({ lookup: { lanes: { index: byLaneRank } } }, ({ lookup }) => { const all = lookup.lanes(); incrementalRanks = Array.from({ length: all.length }, (_, i) => all.at(i).entity); incrementalLanes = [-1, 0, 1].map(laneValue => { @@ -572,16 +557,15 @@ const probeSorted = System.create({ name: "indexesTest/probeSorted", lookup: { l }); }); const rerank = System.create({ - name: "indexesTest/rerank", input: { type: "object", properties: { share: { type: "number" }, seed: { type: "integer" } } }, - components: { read: [weight], write: [rank] }, before: [probeSorted], -}, (row, { input }) => { + components: { with: [weight, rank] }, before: [probeSorted], +}, (row, _context, input: { readonly share: number; readonly seed: number }) => { if ((row[weight] * 7919 + input.seed) % 1000 < input.share * 1000) row[rank] = (row[weight] * 31 + input.seed * 17) % 1000; }); /** A kernel folding the entities of its row's lane's range of ranks within 50 of its own, so kernels' sorted lookups are checked against the same index. */ const foldLane = System.create({ - name: "indexesTest/foldLane", kernel: "cpu", - components: { read: [lane, rank], write: [folded] }, + kernel: true, + components: { with: [lane, rank, folded] }, lookup: { lanes: { index: byLaneRank } }, }, (row, { lookup }) => { const r = row[rank]; @@ -614,7 +598,7 @@ describe("an incrementally refreshed sorted index", () => { } })); await world.run(System.create({ systems: [rerank, probeSorted] }), { share: next() < 0.1 ? 0.9 : 0.05, seed: step }); - const scratch = Index.create({ key: [lane, rank], sorted: true, components: { has: [weight] } }); + const scratch = Index.create({ key: [lane, rank], sorted: true, components: { with: [weight] } }); let rebuiltRanks: readonly number[] = []; let rebuiltLanes: readonly (readonly number[])[] = []; await world.run(System.create({ lookup: { lanes: { index: scratch } } }, ({ lookup }) => { @@ -644,7 +628,7 @@ describe("an incrementally refreshed sorted index", () => { expect(kernel.map(values => values?.[folded] ?? -1)).toEqual(expected); held = Math.max(held, incrementalRanks.length); // Once its blocks have split, every entity of a lane goes, so whole blocks empty and are dropped. - if (step === 40) await world.run(System.create({ components: { read: [lane] } }, (row, { commands }) => { if (row[lane] < 1) commands.despawn(row.entity); })); + if (step === 40) await world.run(System.create({ components: { with: [lane] } }, (row, { commands }) => { if (row[lane] < 1) commands.despawn(row.entity); })); } expect(held).toBeGreaterThan(250); }); @@ -671,7 +655,7 @@ describe("lists and their rows", () => { })); let hashed: readonly object[] = []; let sorted: readonly object[] = []; - await world.run(System.create({ lookup: { near: { index: grid, read: [weight] }, ranks: { index: byRank, read: [rank] } } }, ({ lookup }) => { + await world.run(System.create({ lookup: { near: { index: grid }, ranks: { index: byRank } } }, ({ lookup }) => { const first = lookup.near([0, 0]); const second = lookup.near([1, 1]); hashed = [first, second, first.at(0), first.at(1), second.at(0)]; @@ -692,7 +676,7 @@ describe("a lookup's rows across sync points", () => { await using world = createWorld({ [workers]: 0 }); await world.run(spawnCells, { cells: [[0, 0], [0, 0]] }); let weights: number[] = []; - const read = System.create({ lookup: { near: { index: grid, read: [weight] } } }, ({ lookup }) => { + const read = System.create({ lookup: { near: { index: grid } } }, ({ lookup }) => { const near = lookup.near([0, 0]); weights = Array.from({ length: near.length }, (_, i) => near.at(i)[weight]); }); @@ -700,7 +684,7 @@ describe("a lookup's rows across sync points", () => { expect(weights).toEqual([0, 1]); // Enough spawns into the same archetype to grow its arrays, and new weights for the two already read. await world.run(spawnCells, { cells: Array.from({ length: 200 }, () => [5, 5]) }); - await world.run(System.create({ components: { write: [weight], read: [cell] } }, row => { + await world.run(System.create({ components: { with: [cell, weight] } }, row => { if (row[cell][0] === 0) row[weight] += 10; })); await world.run(read); @@ -717,7 +701,7 @@ describe("a long list's rows", () => { })); let fromStart: number[] = []; let fromEnd: number[] = []; - await world.run(System.create({ lookup: { ranks: { index: byRank, read: [rank] } } }, ({ lookup }) => { + await world.run(System.create({ lookup: { ranks: { index: byRank } } }, ({ lookup }) => { const all = lookup.ranks(); // Every row held at once, then read. const held = Array.from({ length: all.length }, (_, i) => all.at(i)); diff --git a/packages/ecs/src/implementation/world/indexes.ts b/packages/ecs/src/implementation/world/indexes.ts index df866c167..948db6543 100644 --- a/packages/ecs/src/implementation/world/indexes.ts +++ b/packages/ecs/src/implementation/world/indexes.ts @@ -1,15 +1,16 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { IndexDefinition } from '../../interface/registry/index.js'; -import { indexDefinitionOf, indexOfLookup, labelOfItem, watchedBy, watchesEntries } from '../../interface/registry/index.js'; +import { idOrNull, indexDefinitionOf, indexOfLookup, watchedBy, watchesEntries } from '../../interface/registry/index.js'; import type { Failure } from '../../interface/report/failure.js'; -import type { SystemLookup } from '../../interface/system/access.js'; +import type { SystemLookupLists } from '../../interface/system/access.js'; import type { Archetype, Storage } from './archetype.js'; import { readCell } from './cells.js'; -import { chunkShift } from './kernel.js'; +import { blockShift, chunkShift } from './kernel.js'; +import { forEachNewerRow } from './ranges.js'; import { journalEnd } from './journal.js'; import { encodeStored, hashWords, keyPartsOf, missing, release as releaseInterned, retain, type KeyPart } from './index-keys.js'; import { - bucketFor, coverBuckets, coverEntities, createHashTable, entryWords, insertEntry, lengthOf, loadHashTable, mergeEntries, place, placeAll, recordHash, + bucketFor, coverBuckets, coverEntities, createHashTable, entryWords, insertEntry, lengthOf, loadHashTable, forgetUnsorted, place, placeAll, recordHash, recordKey, rehash, relocate, slotsHold, releaseHashTable, removeEntry, startOf, tidyHashTable, unwatchHashComponent, watchHashComponent, type HashTable, } from './index-hash.js'; import { allocateArray, freeArray } from './arrays.js'; @@ -51,6 +52,11 @@ export type WorldIndex = { * it was built; 0 before. */ changed: number; + /** + * For a sorted index, the tick of the last refresh at which an entry moved to another archetype or row with its key + * and place unchanged, or it was built; 0 before. + */ + moved: number; /** By watched component: how many lookups watch it, and the tick its ticks were read up to. */ readonly watchers: Map; readonly scanned: Map; @@ -64,8 +70,6 @@ export type WorldIndex = { pending: Int32Array; pendingArchetypes: Int32Array; pendingRows: Int32Array; - /** What an incremental refresh changes, by bucket: removals' entity indexes and insertions' entries. */ - operations: Int32Array; /** A unique index's insertions of a refresh, as bucket and entity index pairs. */ inserted: Int32Array; /** @@ -75,8 +79,6 @@ export type WorldIndex = { readonly built: { entries: Int32Array; keys: Uint32Array; hashes: Uint32Array; buckets: Int32Array; order: Int32Array; spare: Int32Array; readonly histogram: Int32Array }; /** What a build across the pool lists, in the arena, kept for the next; null before the first. */ pooled: Pooled | null; - /** A merge's scratch, kept for the next: by bucket, its operations' start; operations in bucket order; removals, insertions and a segment. */ - merging: { starts: Int32Array; order: Int32Array; removed: Int32Array; added: Int32Array; segment: Int32Array }; }; /** A build across the pool's arrays in the arena (`index-build.ts`): by entry, by piece, by partition, and its plan. */ @@ -110,7 +112,7 @@ export const acquireIndex = (state: State, definition: IndexDefinition): WorldIn const sorted = keptSorted(definition.declaration); const index: WorldIndex = { definition, - label: labelOfItem(definition), + label: definition.name, users: 1, keys, has: (components?.has ?? []).map(component => idOf(state, component)), @@ -124,6 +126,7 @@ export const acquireIndex = (state: State, definition: IndexDefinition): WorldIn position: -1, refreshed: 0, changed: 0, + moved: 0, watchers: new Map(), scanned: new Map(), hash: sorted ? null : createHashTable(state.memory, words), @@ -134,11 +137,9 @@ export const acquireIndex = (state: State, definition: IndexDefinition): WorldIn pending: new Int32Array(64), pendingArchetypes: new Int32Array(64), pendingRows: new Int32Array(64), - operations: new Int32Array(64), inserted: new Int32Array(64), built: { entries: new Int32Array(0), keys: new Uint32Array(0), hashes: new Uint32Array(0), buckets: new Int32Array(0), order: new Int32Array(0), spare: new Int32Array(0), histogram: new Int32Array(sorted && words > 0 ? 65536 : 0) }, pooled: null, - merging: { starts: new Int32Array(0), order: new Int32Array(0), removed: new Int32Array(0), added: new Int32Array(0), segment: new Int32Array(0) }, }; state.indexes.set(definition, index); state.journal.readers++; @@ -274,6 +275,7 @@ const reportConflict = (state: State, index: WorldIndex, kept: number, other: nu failures.push({ kind: "index", name: index.label, + id: idOrNull(index.definition), message: `Index "${index.label}" is unique, and entities ${kept} and ${other} both have key ${keyText(state, index, archetype, row)}; lookups give ${kept}, the one with the lower entity index.`, stack: null, }); @@ -352,14 +354,11 @@ const pend = (index: WorldIndex, entity: number, archetype: number, row: number, /** Calls `each` with every row of a storage whose `changed` tick is after `since`, a whole column a GPU kernel wrote included. */ const changedRows = (archetype: Archetype, storage: Storage, since: number, each: (row: number) => void): void => { - const whole = storage.columnChanged > since; - const { changed, chunkChanged } = storage; - for (let chunk = 0, count = Math.ceil(archetype.count / 2 ** chunkShift); chunk < count; chunk++) { - if (!whole && !(chunkChanged[chunk] > since)) continue; - for (let row = chunk << chunkShift, end = Math.min(archetype.count, (chunk + 1) << chunkShift); row < end; row++) { - if (whole || changed[row] > since) each(row); - } + if (storage.columnChanged > since) { + for (let row = 0; row < archetype.count; row++) each(row); + return; } + forEachNewerRow(storage.changed, storage.chunkChanged, storage.blockChanged, archetype.count, since, each); }; /** Whether a component may have been written since `since`: by a system, or by a command's value at a sync point. */ @@ -434,6 +433,7 @@ const finishRefresh = async (state: State, index: WorldIndex, end: number, tick: /** Builds an index again: in phases, across the pool when there is one, for a large hash index keyed by structs; else here. */ const rebuildAny = (state: State, index: WorldIndex, tick: number, failures: Failure[], pool: Pool | null): Promise | null => { index.changed = tick; + index.moved = tick; const phased = rebuildPhased(state, index, tick, failures, pool); if (phased !== false) return phased; rebuild(state, index, tick, failures); @@ -468,10 +468,15 @@ const refresh = (state: State, index: WorldIndex, end: number, keyed: boolean, t const storage = archetype.storageById[part]; if (storage === undefined || !storage.ticked || !writtenSince(state, part, since)) continue; const whole = storage.columnChanged > since; - const { changed, chunkChanged } = storage; + const { changed, chunkChanged, blockChanged } = storage; for (let chunk = 0, chunks = Math.ceil(archetype.count / 2 ** chunkShift); chunk < chunks; chunk++) { if (!whole && !(chunkChanged[chunk] > since)) continue; for (let row = chunk << chunkShift, end = Math.min(archetype.count, (chunk + 1) << chunkShift); row < end; row++) { + // A block no row of which changed is passed over whole. + if (!whole && (row & (2 ** blockShift - 1)) === 0 && !(blockChanged[row >> blockShift] > since)) { + row += 2 ** blockShift - 1; + continue; + } if (!whole && !(changed[row] > since)) continue; count = marking ? pend(index, rows[row] >>> 0, id, row, count) : list(index, rows[row] >>> 0, id, row, count); if (count > most) return rebuildAny(state, index, tick, failures, pool); @@ -488,38 +493,17 @@ const refresh = (state: State, index: WorldIndex, end: number, keyed: boolean, t }; /** - * A bucket at least this long takes a refresh's operations in one merge, rather than a shift each; shorter ones take - * each as it comes. - */ -const mergeMinimum = 64; - -/** - * Files each pending entity in the bucket of its key, or none: an operation applies at once on a short bucket, and on - * a long one waits to merge with the bucket's others. A unique index's buckets holding two entries once every - * operation applied are conflicts. + * Files each pending entity in the bucket of its key, or none. A long bucket of an index that isn't unique may be left + * unsorted (`index-hash.ts`), so a change to it costs O(1) amortized; a unique index's stay sorted, and its buckets + * holding two entries once every change applied are conflicts. */ const patch = (state: State, index: WorldIndex, hash: HashTable, count: number, tick: number, failures: Failure[]): void => { const { pending, key, words, pendingArchetypes, pendingRows } = index; const { entities } = state; coverEntities(hash, entities.length); - // Each waiting operation: bucket, entity index, archetype id or -1 for a removal, row. - let waiting = 0; - const wait = (bucket: number, entity: number, archetype: number, row: number): void => { - if (4 * (waiting + 1) > index.operations.length) index.operations = fit(index.operations, 4 * (waiting + 1)); - const at = 4 * waiting++; - index.operations[at] = bucket; - index.operations[at + 1] = entity; - index.operations[at + 2] = archetype; - index.operations[at + 3] = row; - }; + const unsorting = !index.unique; // A unique index's insertions, as bucket and entity index pairs, checked once all apply. let inserted = 0; - const remove = (bucket: number, entity: number): void => { - index.changed = tick; - holdKey(hash, index, bucket, false); - if (lengthOf(hash, bucket) >= mergeMinimum) wait(bucket, entity, -1, 0); - else removeEntry(hash, bucket, entity, tick); - }; for (let k = 0; k < count; k++) { const entity = pending[k]; const scanned = pendingArchetypes[k]; @@ -527,7 +511,9 @@ const patch = (state: State, index: WorldIndex, hash: HashTable, count: number, const old = hash.bucketOf[entity]; if (archetypeId < 0 || index.member[archetypeId] !== 1) { if (old < 0) continue; - remove(old, entity); + index.changed = tick; + holdKey(hash, index, old, false); + removeEntry(hash, old, entity, tick, unsorting); hash.bucketOf[entity] = -1; continue; } @@ -544,9 +530,11 @@ const patch = (state: State, index: WorldIndex, hash: HashTable, count: number, } if (!fresh) holdKey(hash, index, bucket, true); index.changed = tick; - if (old >= 0) remove(old, entity); - if (lengthOf(hash, bucket) >= mergeMinimum) wait(bucket, entity, archetypeId, row); - else insertEntry(hash, bucket, entity, archetypeId, row, tick); + if (old >= 0) { + holdKey(hash, index, old, false); + removeEntry(hash, old, entity, tick, unsorting); + } + insertEntry(hash, bucket, entity, archetypeId, row, tick, unsorting); hash.bucketOf[entity] = bucket; if (index.unique) { if (2 * (inserted + 1) > index.inserted.length) index.inserted = fit(index.inserted, 2 * (inserted + 1)); @@ -554,7 +542,6 @@ const patch = (state: State, index: WorldIndex, hash: HashTable, count: number, index.inserted[2 * inserted++ + 1] = entity; } } - if (waiting > 0) merge(index, hash, waiting, tick); for (let k = 0; k < inserted; k++) { const bucket = index.inserted[2 * k]; if (lengthOf(hash, bucket) > 1 && hash.bucketOf[index.inserted[2 * k + 1]] === bucket) conflict(state, index, hash, bucket, index.inserted[2 * k + 1], failures); @@ -597,7 +584,7 @@ const patchSorted = (state: State, index: WorldIndex, sorted: SortedTable, count const fresh = index.objects && unknownValue(index, key); if (fresh) encodeRow(index, archetype, row, key, 0, true); if (old >= 0 && !fresh && sameStoredKey(sorted, old, key, 0)) { - relocateSorted(sorted, entity, archetypeId, row); + if (relocateSorted(sorted, entity, archetypeId, row)) index.moved = tick; continue; } if (objects && !fresh) holdWords(index, key, 0, true); @@ -615,59 +602,6 @@ const patchSorted = (state: State, index: WorldIndex, sorted: SortedTable, count for (let k = 0; k < inserted; k++) sortedConflict(state, index, sorted, index.inserted[k], failures); }; -/** Applies the operations long buckets wait with, each bucket's in one merge, in entity order. */ -const merge = (index: WorldIndex, hash: HashTable, count: number, tick: number): void => { - const operations = index.operations; - const merging = index.merging; - // By bucket, counted then placed, in the order they came; then each bucket's by entity index. - if (merging.starts.length < hash.count + 1) merging.starts = new Int32Array(Math.max(hash.count + 1, 2 * merging.starts.length)); - if (merging.order.length < count) { - const length = Math.max(count, 2 * merging.order.length); - merging.order = new Int32Array(length); - merging.removed = new Int32Array(length); - merging.added = new Int32Array(length * entryWords); - } - const { starts, order, removed, added } = merging; - starts.fill(0, 0, hash.count + 1); - for (let k = 0; k < count; k++) starts[operations[4 * k] + 1]++; - for (let bucket = 0; bucket < hash.count; bucket++) starts[bucket + 1] += starts[bucket]; - for (let k = 0; k < count; k++) order[starts[operations[4 * k]]++] = k; - let scratch = merging.segment; - for (let start = 0; start < count;) { - const bucket = operations[4 * order[start]]; - let end = start + 1; - while (end < count && operations[4 * order[end]] === bucket) end++; - // Insertion sort by entity index: a bucket's operations of one refresh are few. - for (let k = start + 1; k < end; k++) { - const moved = order[k]; - const entity = operations[4 * moved + 1]; - let at = k; - while (at > start && operations[4 * order[at - 1] + 1] > entity) { - order[at] = order[at - 1]; - at--; - } - order[at] = moved; - } - let removals = 0; - let insertions = 0; - for (let k = start; k < end; k++) { - const at = 4 * order[k]; - if (operations[at + 2] < 0) removed[removals++] = operations[at + 1]; - else { - added[insertions * entryWords] = operations[at + 1]; - added[insertions * entryWords + 1] = operations[at + 2]; - added[insertions * entryWords + 2] = operations[at + 3]; - insertions++; - } - } - const length = lengthOf(hash, bucket) * entryWords; - if (scratch.length < length) scratch = new Int32Array(Math.max(length, 2 * scratch.length)); - mergeEntries(hash, bucket, removed, removals, added, insertions, scratch, tick); - start = end; - } - merging.segment = scratch; -}; - /** Builds an index from every entity it holds, in entity order. */ const rebuild = (state: State, index: WorldIndex, tick: number, failures: Failure[]): void => { refreshMembers(state, index); @@ -875,6 +809,7 @@ const startBuild = (state: State, index: WorldIndex, hash: HashTable, total: num const entityEnd = entities.length; const pieces = Math.ceil(entityEnd / pieceEntities); coverEntities(hash, entityEnd); + forgetUnsorted(hash); // Sized for as many buckets as it last held, as `loadHashTable` sizes it. rehash(hash, Math.min(total, Math.max(16, hash.count - hash.freed.length - hash.empty))); const pooled = pooledFor(state, index, total, pieces); @@ -1060,6 +995,8 @@ const sortedOrder = (index: WorldIndex, count: number, keys: Uint32Array): Int32 order.set(ordered); return order; } + // Entries already in key order, as a load in document order lists them, need no sort. + if (inKeyOrder(keys, count, words)) return order; const histogram = built.histogram; for (let w = words - 1; w >= 0; w--) { for (let shift = 0; shift < 32; shift += 16) { @@ -1087,6 +1024,21 @@ const sortedOrder = (index: WorldIndex, count: number, keys: Uint32Array): Int32 return order; }; +/** Whether each of `count` entries' keys, `words` each, is at least the one before, comparing words from the first. */ +const inKeyOrder = (keys: Uint32Array, count: number, words: number): boolean => { + for (let k = 1; k < count; k++) { + const previous = (k - 1) * words; + const next = k * words; + for (let w = 0; w < words; w++) { + const a = keys[previous + w]; + const b = keys[next + w]; + if (a < b) break; + if (a > b) return false; + } + } + return true; +}; + /** Stamps the buckets holding rows whose watched component changed since it was last read, with `tick`. */ const scanWatched = (state: State, index: WorldIndex, component: number, tick: number): void => { const since = index.scanned.get(component) ?? tick; @@ -1150,7 +1102,7 @@ const noFilters: readonly WorldIndex[] = []; const noLookups: IndexLookups = { byName: noIndexes, ordered: null, uses: noUses, filters: noFilters, release: () => {} }; /** Acquires the indexes a declaration's lookups go through, watching what they watch, once each per index. */ -export const acquireLookups = (state: State, lookup: { readonly [name: string]: SystemLookup } | undefined, order?: unknown): IndexLookups => { +export const acquireLookups = (state: State, lookup: { readonly [name: string]: SystemLookupLists } | undefined, order?: unknown): IndexLookups => { const ordering = indexDefinitionOf(order) ?? null; if (lookup === undefined && ordering === null) return noLookups; const byName = new Map(); diff --git a/packages/ecs/src/implementation/world/input.ts b/packages/ecs/src/implementation/world/input.ts index 67b65ac5b..fab089965 100644 --- a/packages/ecs/src/implementation/world/input.ts +++ b/packages/ecs/src/implementation/world/input.ts @@ -3,7 +3,7 @@ import type { Leaf } from '../../interface/system/leaf.js'; /** The fields of a run's input that a leaf declares, so other members' fields aren't copied to its workers. */ export const inputFor = (leaf: Leaf): (input: unknown) => unknown => { - const { input } = leaf.declaration; + const { input } = leaf; if (input === undefined) return () => undefined; const fields = Object.keys(input.properties); return value => { diff --git a/packages/ecs/src/implementation/world/intrinsics.browser.test.ts b/packages/ecs/src/implementation/world/intrinsics.browser.test.ts index e35812efe..7e6a4284c 100644 --- a/packages/ecs/src/implementation/world/intrinsics.browser.test.ts +++ b/packages/ecs/src/implementation/world/intrinsics.browser.test.ts @@ -1,6 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; -import { Component } from "../../interface/component/component.js"; import { F32, Mat4x4, Quat, U32, Vec2, Vec3, Vec4 } from "../../interface/math/index.js"; import { System } from "../../interface/system/system.js"; import { readEach, select } from "./reads.test-helper.js"; @@ -92,45 +91,6 @@ declare module "@adobe/ecs" { } } -Component.register(f2a, { type: Vec2.schema }); -Component.register(f2b, { type: Vec2.schema }); -Component.register(f2p, { type: Vec2.schema }); -Component.register(f2o, { type: Vec2.schema }); -Component.register(i2a, { type: Vec2.I32.schema }); -Component.register(u2a, { type: Vec2.U32.schema }); -Component.register(i2b, { type: Vec2.I32.schema }); -Component.register(u2b, { type: Vec2.U32.schema }); -Component.register(i2o, { type: Vec2.I32.schema }); -Component.register(u2o, { type: Vec2.U32.schema }); -Component.register(f3a, { type: Vec3.schema }); -Component.register(f3b, { type: Vec3.schema }); -Component.register(f3p, { type: Vec3.schema }); -Component.register(f3o, { type: Vec3.schema }); -Component.register(i3a, { type: Vec3.I32.schema }); -Component.register(u3a, { type: Vec3.U32.schema }); -Component.register(i3b, { type: Vec3.I32.schema }); -Component.register(u3b, { type: Vec3.U32.schema }); -Component.register(i3o, { type: Vec3.I32.schema }); -Component.register(u3o, { type: Vec3.U32.schema }); -Component.register(f4a, { type: Vec4.schema }); -Component.register(f4b, { type: Vec4.schema }); -Component.register(f4p, { type: Vec4.schema }); -Component.register(f4o, { type: Vec4.schema }); -Component.register(i4a, { type: Vec4.I32.schema }); -Component.register(u4a, { type: Vec4.U32.schema }); -Component.register(i4b, { type: Vec4.I32.schema }); -Component.register(u4b, { type: Vec4.U32.schema }); -Component.register(i4o, { type: Vec4.I32.schema }); -Component.register(u4o, { type: Vec4.U32.schema }); -Component.register(fo, { type: F32.schema }); -Component.register(ma, { type: Mat4x4.schema }); -Component.register(mb, { type: Mat4x4.schema }); -Component.register(mo, { type: Mat4x4.schema }); -Component.register(qa, { type: Quat.schema }); -Component.register(qb, { type: Quat.schema }); -Component.register(qo, { type: Quat.schema }); -Component.register(ua, { type: U32.schema }); -Component.register(uo, { type: U32.schema }); /** A value in [-1, 1) that differs per row and lane. */ const unit = (row: number, lane: number): number => ((Math.imul(row * 31 + lane * 7 + 1, 0x9E3779B9) >>> 0) / 2 ** 32) * 2 - 1; @@ -143,7 +103,7 @@ const three = (row: number, value: (row: number, lane: number) => number): reado const four = (row: number, value: (row: number, lane: number) => number): readonly [number, number, number, number] => [value(row, 0), value(row, 1), value(row, 2), value(row, 3)]; const rows = 64; -const spawn = System.create({ name: "intrinsicsTest/spawn" }, ({ commands }) => { +const spawn = System.create({}, ({ commands }) => { for (let i = 0; i < rows; i++) { commands.spawn([f2a, f2b, f2p, f2o, i2a, i2b, i2o, u2a, u2b, u2o, f3a, f3b, f3p, f3o, i3a, i3b, i3o, u3a, u3b, u3o, f4a, f4b, f4p, f4o, i4a, i4b, i4o, u4a, u4b, u4o, fo, ma, mb, mo, qa, qb, qo, ua, uo])({ [f2a]: two(i, float(0)), @@ -189,9 +149,7 @@ const spawn = System.create({ name: "intrinsicsTest/spawn" }, ({ commands }) => } }); -const operation = { type: "object", properties: { op: { type: "number" } } } as const; - -const vec2f = System.create({ name: "intrinsicsTest/vec2f", kernel: "gpu", input: operation, components: { read: [f2a, f2b, f2p], write: [f2o] } }, (row, { input: { op } }) => { +const vec2f = System.create({ kernel: "gpu", components: { with: [f2a, f2b, f2p, f2o] } }, (row, _context, { op }: { readonly op: number }) => { const a = row[f2a]; const b = row[f2b]; const p = row[f2p]; @@ -236,7 +194,7 @@ const vec2f = System.create({ name: "intrinsicsTest/vec2f", kernel: "gpu", input else if (op === 38) row[f2o] = Vec2.faceforward(a, b, p); }); -const vec2fScalar = System.create({ name: "intrinsicsTest/vec2fScalar", kernel: "gpu", input: operation, components: { read: [f2a, f2b], write: [fo] } }, (row, { input: { op } }) => { +const vec2fScalar = System.create({ kernel: "gpu", components: { with: [f2a, f2b, fo] } }, (row, _context, { op }: { readonly op: number }) => { const a = row[f2a]; const b = row[f2b]; if (op === 0) row[fo] = Vec2.dot(a, b); @@ -244,7 +202,7 @@ const vec2fScalar = System.create({ name: "intrinsicsTest/vec2fScalar", kernel: else if (op === 2) row[fo] = Vec2.length(a); }); -const vec2i = System.create({ name: "intrinsicsTest/vec2i", kernel: "gpu", input: operation, components: { read: [i2a, i2b], write: [i2o] } }, (row, { input: { op } }) => { +const vec2i = System.create({ kernel: "gpu", components: { with: [i2a, i2b, i2o] } }, (row, _context, { op }: { readonly op: number }) => { const a = row[i2a]; const b = row[i2b]; if (op === 0) row[i2o] = Vec2.I32.add(a, b); @@ -265,7 +223,7 @@ const vec2i = System.create({ name: "intrinsicsTest/vec2i", kernel: "gpu", input else if (op === 15) row[i2o] = Vec2.I32.clamp(a, [-1000, -1000], [1000, 1000]); }); -const vec2u = System.create({ name: "intrinsicsTest/vec2u", kernel: "gpu", input: operation, components: { read: [u2a, u2b], write: [u2o] } }, (row, { input: { op } }) => { +const vec2u = System.create({ kernel: "gpu", components: { with: [u2a, u2b, u2o] } }, (row, _context, { op }: { readonly op: number }) => { const a = row[u2a]; const b = row[u2b]; if (op === 0) row[u2o] = Vec2.U32.add(a, b); @@ -284,7 +242,7 @@ const vec2u = System.create({ name: "intrinsicsTest/vec2u", kernel: "gpu", input else if (op === 13) row[u2o] = Vec2.U32.clamp(a, [1000, 1000], [100000, 100000]); }); -const vec3f = System.create({ name: "intrinsicsTest/vec3f", kernel: "gpu", input: operation, components: { read: [f3a, f3b, f3p], write: [f3o] } }, (row, { input: { op } }) => { +const vec3f = System.create({ kernel: "gpu", components: { with: [f3a, f3b, f3p, f3o] } }, (row, _context, { op }: { readonly op: number }) => { const a = row[f3a]; const b = row[f3b]; const p = row[f3p]; @@ -331,7 +289,7 @@ const vec3f = System.create({ name: "intrinsicsTest/vec3f", kernel: "gpu", input else if (op === 40) row[f3o] = Vec3.faceforward(a, b, p); }); -const vec3fScalar = System.create({ name: "intrinsicsTest/vec3fScalar", kernel: "gpu", input: operation, components: { read: [f3a, f3b], write: [fo] } }, (row, { input: { op } }) => { +const vec3fScalar = System.create({ kernel: "gpu", components: { with: [f3a, f3b, fo] } }, (row, _context, { op }: { readonly op: number }) => { const a = row[f3a]; const b = row[f3b]; if (op === 0) row[fo] = Vec3.dot(a, b); @@ -339,7 +297,7 @@ const vec3fScalar = System.create({ name: "intrinsicsTest/vec3fScalar", kernel: else if (op === 2) row[fo] = Vec3.length(a); }); -const vec3i = System.create({ name: "intrinsicsTest/vec3i", kernel: "gpu", input: operation, components: { read: [i3a, i3b], write: [i3o] } }, (row, { input: { op } }) => { +const vec3i = System.create({ kernel: "gpu", components: { with: [i3a, i3b, i3o] } }, (row, _context, { op }: { readonly op: number }) => { const a = row[i3a]; const b = row[i3b]; if (op === 0) row[i3o] = Vec3.I32.add(a, b); @@ -361,7 +319,7 @@ const vec3i = System.create({ name: "intrinsicsTest/vec3i", kernel: "gpu", input else if (op === 16) row[i3o] = Vec3.I32.clamp(a, [-1000, -1000, -1000], [1000, 1000, 1000]); }); -const vec3u = System.create({ name: "intrinsicsTest/vec3u", kernel: "gpu", input: operation, components: { read: [u3a, u3b], write: [u3o] } }, (row, { input: { op } }) => { +const vec3u = System.create({ kernel: "gpu", components: { with: [u3a, u3b, u3o] } }, (row, _context, { op }: { readonly op: number }) => { const a = row[u3a]; const b = row[u3b]; if (op === 0) row[u3o] = Vec3.U32.add(a, b); @@ -381,7 +339,7 @@ const vec3u = System.create({ name: "intrinsicsTest/vec3u", kernel: "gpu", input else if (op === 14) row[u3o] = Vec3.U32.clamp(a, [1000, 1000, 1000], [100000, 100000, 100000]); }); -const vec4f = System.create({ name: "intrinsicsTest/vec4f", kernel: "gpu", input: operation, components: { read: [f4a, f4b, f4p], write: [f4o] } }, (row, { input: { op } }) => { +const vec4f = System.create({ kernel: "gpu", components: { with: [f4a, f4b, f4p, f4o] } }, (row, _context, { op }: { readonly op: number }) => { const a = row[f4a]; const b = row[f4b]; const p = row[f4p]; @@ -427,7 +385,7 @@ const vec4f = System.create({ name: "intrinsicsTest/vec4f", kernel: "gpu", input else if (op === 39) row[f4o] = Vec4.faceforward(a, b, p); }); -const vec4fScalar = System.create({ name: "intrinsicsTest/vec4fScalar", kernel: "gpu", input: operation, components: { read: [f4a, f4b], write: [fo] } }, (row, { input: { op } }) => { +const vec4fScalar = System.create({ kernel: "gpu", components: { with: [f4a, f4b, fo] } }, (row, _context, { op }: { readonly op: number }) => { const a = row[f4a]; const b = row[f4b]; if (op === 0) row[fo] = Vec4.dot(a, b); @@ -435,7 +393,7 @@ const vec4fScalar = System.create({ name: "intrinsicsTest/vec4fScalar", kernel: else if (op === 2) row[fo] = Vec4.length(a); }); -const vec4i = System.create({ name: "intrinsicsTest/vec4i", kernel: "gpu", input: operation, components: { read: [i4a, i4b], write: [i4o] } }, (row, { input: { op } }) => { +const vec4i = System.create({ kernel: "gpu", components: { with: [i4a, i4b, i4o] } }, (row, _context, { op }: { readonly op: number }) => { const a = row[i4a]; const b = row[i4b]; if (op === 0) row[i4o] = Vec4.I32.add(a, b); @@ -457,7 +415,7 @@ const vec4i = System.create({ name: "intrinsicsTest/vec4i", kernel: "gpu", input else if (op === 16) row[i4o] = Vec4.I32.clamp(a, [-1000, -1000, -1000, -1000], [1000, 1000, 1000, 1000]); }); -const vec4u = System.create({ name: "intrinsicsTest/vec4u", kernel: "gpu", input: operation, components: { read: [u4a, u4b], write: [u4o] } }, (row, { input: { op } }) => { +const vec4u = System.create({ kernel: "gpu", components: { with: [u4a, u4b, u4o] } }, (row, _context, { op }: { readonly op: number }) => { const a = row[u4a]; const b = row[u4b]; if (op === 0) row[u4o] = Vec4.U32.add(a, b); @@ -477,7 +435,7 @@ const vec4u = System.create({ name: "intrinsicsTest/vec4u", kernel: "gpu", input else if (op === 14) row[u4o] = Vec4.U32.clamp(a, [1000, 1000, 1000, 1000], [100000, 100000, 100000, 100000]); }); -const mat4x4 = System.create({ name: "intrinsicsTest/mat4x4", kernel: "gpu", input: operation, components: { read: [ma, mb], write: [mo] } }, (row, { input: { op } }) => { +const mat4x4 = System.create({ kernel: "gpu", components: { with: [ma, mb, mo] } }, (row, _context, { op }: { readonly op: number }) => { const a = row[ma]; const b = row[mb]; if (op === 0) row[mo] = Mat4x4.multiply(a, b); @@ -487,18 +445,18 @@ const mat4x4 = System.create({ name: "intrinsicsTest/mat4x4", kernel: "gpu", inp else if (op === 4) row[mo] = Mat4x4.scale(a, 0.5); }); -const mat4x4Vector = System.create({ name: "intrinsicsTest/mat4x4Vector", kernel: "gpu", input: operation, components: { read: [ma, f4a], write: [f4o] } }, (row, { input: { op } }) => { +const mat4x4Vector = System.create({ kernel: "gpu", components: { with: [ma, f4a, f4o] } }, (row, _context, { op }: { readonly op: number }) => { const a = row[ma]; const b = row[f4a]; if (op === 0) row[f4o] = Mat4x4.multiplyVec4(a, b); }); -const mat4x4Scalar = System.create({ name: "intrinsicsTest/mat4x4Scalar", kernel: "gpu", input: operation, components: { read: [ma], write: [fo] } }, (row, { input: { op } }) => { +const mat4x4Scalar = System.create({ kernel: "gpu", components: { with: [ma, fo] } }, (row, _context, { op }: { readonly op: number }) => { const a = row[ma]; if (op === 0) row[fo] = Mat4x4.determinant(a); }); -const quat = System.create({ name: "intrinsicsTest/quat", kernel: "gpu", input: operation, components: { read: [qa, qb], write: [qo] } }, (row, { input: { op } }) => { +const quat = System.create({ kernel: "gpu", components: { with: [qa, qb, qo] } }, (row, _context, { op }: { readonly op: number }) => { const a = row[qa]; const b = row[qb]; if (op === 0) row[qo] = Quat.add(a, b); @@ -507,14 +465,14 @@ const quat = System.create({ name: "intrinsicsTest/quat", kernel: "gpu", input: else if (op === 3) row[qo] = Quat.negate(a); }); -const quatScalar = System.create({ name: "intrinsicsTest/quatScalar", kernel: "gpu", input: operation, components: { read: [qa, qb], write: [fo] } }, (row, { input: { op } }) => { +const quatScalar = System.create({ kernel: "gpu", components: { with: [qa, qb, fo] } }, (row, _context, { op }: { readonly op: number }) => { const a = row[qa]; const b = row[qb]; if (op === 0) row[fo] = Quat.dot(a, b); else if (op === 1) row[fo] = Quat.length(a); }); -const u32 = System.create({ name: "intrinsicsTest/u32", kernel: "gpu", input: operation, components: { read: [ua], write: [uo] } }, (row, { input: { op } }) => { +const u32 = System.create({ kernel: "gpu", components: { with: [ua, uo] } }, (row, _context, { op }: { readonly op: number }) => { const a = row[ua]; if (op === 0) row[uo] = U32.countLeadingZeros(a); else if (op === 1) row[uo] = U32.countOneBits(a); diff --git a/packages/ecs/src/implementation/world/join.test.ts b/packages/ecs/src/implementation/world/join.test.ts index 86250b914..4b7180260 100644 --- a/packages/ecs/src/implementation/world/join.test.ts +++ b/packages/ecs/src/implementation/world/join.test.ts @@ -1,9 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; -import { Component } from "../../interface/component/component.js"; -import { labelOfItem } from "../../interface/registry/index.js"; import { Observe } from "../../observe/index.js"; -import { Resource } from "../../interface/resource/resource.js"; import { workers } from "../../interface/resources/workers.js"; import { System } from "../../interface/system/system.js"; import type { World } from "../../interface/world/world.js"; @@ -28,37 +25,30 @@ declare module "@adobe/ecs" { } export interface Resources { [total]: { type: number }; - [names]: { type: readonly string[] }; + [names]: { type: readonly string[]; default: [] }; [counts]: { type: ReadonlyMap }; } } -Component.register(point, { type: { type: "array", items: { type: "number" }, minItems: 2, maxItems: 2 } }); -Component.register(label, { type: { type: "string" } }); -Component.register(marked, { type: { const: true } }); -Resource.register(total, { type: { type: "number", default: 0 } }); -Resource.register(names, { type: { type: "array", items: { type: "string" }, default: [] } }); -Component.register(words, { type: { type: "set", items: { type: "string" } } }); -Resource.register(counts, { type: { type: "map", keys: { type: "string" }, values: { type: "number" } }, default: new Map() }); /** Adds its struct input to `total`, and its object input's names to `names`. */ -const add = System.create({ name: "joinTest/add", input: { type: "object", properties: { amount: { type: "number" } } }, resources: { write: [total] } }, ({ input, resources }) => { +const add = System.create({}, ({ resources }, input: { readonly amount: number }) => { resources[total] = resources[total] * 10 + input.amount; }); -const append = System.create({ name: "joinTest/append", input: { type: "object", properties: { name: { type: "string" } } }, resources: { write: [names] } }, ({ input, resources }) => { +const append = System.create({}, ({ resources }, input: { readonly name: string }) => { resources[names] = [...resources[names], input.name]; }); -const spawn = System.create({ name: "joinTest/spawn", input: { type: "object", properties: { x: { type: "number" } } } }, ({ input, commands }) => { +const spawn = System.create({}, ({ commands }, input: { readonly x: number }) => { commands.spawn([point, label, marked])({ [point]: [input.x, -input.x], [label]: `p${input.x}` }); }); /** Counts each word of its input's set in `counts`, and spawns an entity holding the set. */ -const tally = System.create({ name: "joinTest/tally", input: { type: "object", properties: { words: { type: "set", items: { type: "string" } } } }, resources: { write: [counts] } }, ({ input, resources, commands }) => { +const tally = System.create({}, ({ resources, commands }, input: { readonly words: ReadonlySet }) => { const next = new Map(resources[counts]); for (const word of input.words) next.set(word, (next.get(word) ?? 0) + 1); resources[counts] = next; commands.spawn([words])({ [words]: input.words }); }); -const fail = System.create({ name: "joinTest/fail" }, () => { throw new Error("joinTest failure"); }); -const idle = System.create({ name: "joinTest/idle" }, () => {}); +const fail = System.create({}, () => { throw new Error("joinTest failure"); }); +const idle = System.create({}, () => {}); /** A world shared with a thread joined through a channel, and the joined world. */ const joined = (threads: readonly ("main")[] = []): { readonly world: CreatedWorld; readonly remote: World; readonly channel: MessageChannel } => { @@ -141,7 +131,7 @@ describe("joining", () => { const report = await reportOf(world, await remote.run(fail)); expect(report.failures.map(failure => failure.message)).toEqual(["joinTest failure"]); const local = System.create(() => {}); - await expect(remote.run(local)).rejects.toThrow(`System "${labelOfItem(local)}" has no id, so it can't run from another thread`); + await expect(remote.run(local)).rejects.toThrow(`has no id, so it can't run from another thread`); expect(await reportOf(world, await remote.run(idle))).toEqual({ failures: [], skipped: [], dropped: 0, warnings: [] }); await remote.dispose(); await world.dispose(); diff --git a/packages/ecs/src/implementation/world/join.ts b/packages/ecs/src/implementation/world/join.ts index 49e39d8bf..df31cbb58 100644 --- a/packages/ecs/src/implementation/world/join.ts +++ b/packages/ecs/src/implementation/world/join.ts @@ -1,7 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { Thread } from '../../interface/thread/thread.js'; import type { World } from '../../interface/world/world.js'; -import { declarationOf, identifiedItems, idOfItem, idOfKey, indexOfLookup, keyOfId, labelOf, labelOfItem, requireDefinition, threadOf, threadOfDeclaration, watchedBy, type Definition } from '../../interface/registry/index.js'; +import { declarationOf, identifiedItems, idOfItem, idOfKey, indexOfLookup, keyOfId, labelOf, requireDefinition, threadOf, threadOfDeclaration, watchedBy, type Definition } from '../../interface/registry/index.js'; import { unsharedKeyOf } from './index-keys.js'; import { layoutOf, type Views } from '../../interface/registry/layout.js'; import { @@ -220,7 +220,7 @@ export const joinWorld = (port: Parameters[0], options: { readon const base = slotsWord + slot * slotWords; control[base + slotStatus] = pending; control[base + slotNumber] = number; - const schema = definition.kind === "composite" ? undefined : definition.declaration.input; + const schema = definition.kind === "composite" ? undefined : definition.input; const layout = fitsInline(schema, input) ? inputLayoutOf(definition, schema) : null; if (layout !== null && layout.kind === "linear") { layout.write(views[slot], 0, input); @@ -410,7 +410,7 @@ export const joinWorld = (port: Parameters[0], options: { readon current: () => { if (replayed === null) return true; const state = replay(replayed); - return state !== "held" && (state !== "lapped" || rebuild(replayed)); + return state !== "held" && (state !== "lapped" || rebuild(replayed) === "rebuilt"); }, }); if (message.tasks !== null && mirror !== null) { @@ -530,7 +530,7 @@ export const joinWorld = (port: Parameters[0], options: { readon /** What an observer system reads, for the world, by this thread's numbers; throws for what can't cross. */ const describe = (leaf: Leaf): ObserverDeclaration => { - const label = labelOfItem(leaf); + const label = leaf.name; const readable = (keys: readonly unknown[] | undefined): number[] => (keys ?? []).map(key => { const symbol = symbolOf(key); if (layoutOf(declarationOf(symbol)?.type).kind === "object" && !ownObject(symbol, claims)) { @@ -549,21 +549,21 @@ export const joinWorld = (port: Parameters[0], options: { readon let indexed: string | ObserverIndex | null = null; if (index !== undefined) { const object = unsharedKeyOf(index.declaration.key); - if (object !== undefined) throw new Error(`Observer system "${label}" looks up through index "${labelOfItem(index)}", whose key part "${labelOf(object.key)}" is ${object.what}, so it runs only on the world's thread.`); - if (declared.changed === true) throw new Error(`Observer system "${label}" runs when index "${labelOfItem(index)}" changed, as its lookup "${name}"'s changed is true, so it runs only on the world's thread.`); + if (object !== undefined) throw new Error(`Observer system "${label}" looks up through index "${index.name}", whose key part "${labelOf(object.key)}" is ${object.what}, so it runs only on the world's thread.`); + if (declared.changed === true) throw new Error(`Observer system "${label}" runs when index "${index.name}" changed, as its lookup "${name}"'s changed is true, so it runs only on the world's thread.`); const { key, components: held, sorted, unique } = index.declaration; // One without an id, made in a function, is named by its declaration, and the world makes one like it. indexed = idOfItem(index) ?? { - name: labelOfItem(index), key: readable(key), has: present(held?.has), not: present(held?.not), sorted: sorted === true, unique: unique === true, + name: index.name, key: readable(key), has: present(held?.has), not: present(held?.not), sorted: sorted === true, unique: unique === true, }; } lookups.push({ - name, read, has: present("has" in declared ? declared.has : undefined), not: present("not" in declared ? declared.not : undefined), + name, read, has: present("has" in declared ? declared.has : undefined), optional: present("optional" in declared ? declared.optional : undefined), not: present("not" in declared ? declared.not : undefined), changed: "changed" in declared && declared.changed !== undefined ? readable(watchedBy(declared)) : null, index: indexed, }); } return { - components: components === undefined ? null : { read: readable(components.read), has: present(components.has), not: present(components.not), added: present(components.added), changed: readable(components.changed) }, + components: components === undefined ? null : { read: readable(components.read), has: present(components.has), optional: present(components.optional), not: present(components.not), added: present(components.added), changed: readable(components.changed) }, resources: resources === undefined ? null : { read: readable(resources.read), changed: present(resources.changed), is: readable(resources.is), not: readable(resources.not) }, lookup: lookups, }; @@ -581,7 +581,7 @@ export const joinWorld = (port: Parameters[0], options: { readon localsByLeaf.set(leaf, local); localsByNumber.set(number, new WeakRef(local)); collected.register(leaf, number); - post({ protocol: protocolName, kind: "observer", number, name: labelOfItem(leaf), declaration, added: false }); + post({ protocol: protocolName, kind: "observer", number, name: leaf.name, declaration, added: false }); return local; }; @@ -615,7 +615,7 @@ export const joinWorld = (port: Parameters[0], options: { readon return Promise.all(locals.map(local => runNumbered(local.number, local.leaf, undefined, local))).then(lastOf); } id = idOfItem(definition); - if (id === undefined) throw new Error(`System "${labelOfItem(definition)}" has no id, so it can't run from another thread; the build gives top-level systems one.`); + if (id === undefined) throw new Error(`System "${definition.name}" has no id, so it can't run from another thread; the build gives top-level systems one.`); } catch (error) { return Promise.reject(error); diff --git a/packages/ecs/src/implementation/world/join.type-test.ts b/packages/ecs/src/implementation/world/join.type-test.ts index f94ba2531..59cc6f61b 100644 --- a/packages/ecs/src/implementation/world/join.type-test.ts +++ b/packages/ecs/src/implementation/world/join.type-test.ts @@ -15,7 +15,7 @@ const joined: World = join(channel.port2, { threads: ["main"] }); join(worker); join(self, {}); join(port, { threads: [] }); -const ran: Promise = joined.run(System.create({ name: "joinTypeTest/idle" }, () => {})); +const ran: Promise = joined.run(System.create({}, () => {})); // @ts-expect-error a joined thread fills logical threads other than the world's join(port, { threads: ["world"] }); // @ts-expect-error it claims declared threads diff --git a/packages/ecs/src/implementation/world/joined-observer.test.ts b/packages/ecs/src/implementation/world/joined-observer.test.ts new file mode 100644 index 000000000..8857636f4 --- /dev/null +++ b/packages/ecs/src/implementation/world/joined-observer.test.ts @@ -0,0 +1,56 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { describe, expect, it } from "vitest"; +import { definitionOf } from "../../interface/registry/index.js"; +import { isLeaf } from "../../interface/registry/schedule.js"; +import { System } from "../../interface/system/system.js"; +import { createBuffer } from "./buffer.js"; +import { createCommands } from "./commands.js"; +import { cancelled, createJoinedObserverRunner, createObserverTarget, postObserver, settleObserverKeys, settlePosts } from "./joined-observer.js"; +import { createState, idOf } from "./state.js"; +import { synchronize } from "./synchronize.js"; +import { doneWord, progressWord, snapshotted, tailChunk } from "./tail-blocks.js"; + +// The world's side of an observer system on a joined thread, with this test standing for the thread: its claims and +// done word, in the order a loaded machine may give them. + +const amount = Symbol("joinedObserverTest/amount"); +declare module "@adobe/ecs" { + export interface Components { + [amount]: { type: number }; + } +} + +/** 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]); }); + +describe("an observer system on a joined thread", () => { + it("is waited for, not cancelled, by a sync point after a writer copied the rest of a post the thread claimed partway", async () => { + const state = createState(true); + const target = { buffer: createBuffer() }; + const commands = createCommands(state, target); + for (let k = 0; k < 200; k++) commands.spawn([amount])({ [amount]: k }); + state.queued.push({ buffer: target.buffer, guarded: true }); + synchronize(state); + const leaf = definitionOf(watch); + if (leaf === undefined || !isLeaf(leaf)) throw new Error("The observer system has no leaf."); + const observer = createJoinedObserverRunner(state, leaf, createObserverTarget(1, "joinedObserverTest", () => {}, () => {})).joined; + if (observer === null) throw new Error("The runner stands for no observer system on a joined thread."); + expect(postObserver(state, [], [observer], 0, null)).toBeNull(); + const { control } = observer; + // The thread claims the head and first rows, and reads them. + expect(Atomics.add(control, progressWord, tailChunk)).toBe(0); + Atomics.store(control, doneWord, tailChunk); + // A writer of what it reads copies the rest; the thread hasn't claimed again yet. + expect(settleObserverKeys(state, [idOf(state, amount)], [])).toBeNull(); + const settled = settlePosts(state); + expect(settled).not.toBeNull(); + // The thread's next claim reads the rest from the copy, and it finishes. + const claimed = Atomics.add(control, progressWord, tailChunk); + expect(claimed >= snapshotted && claimed < cancelled).toBe(true); + Atomics.store(control, doneWord, observer.end); + Atomics.notify(control, doneWord); + await settled; + expect(state.observer.posts).toEqual([]); + }); +}); diff --git a/packages/ecs/src/implementation/world/joined-observer.ts b/packages/ecs/src/implementation/world/joined-observer.ts index 577213fc5..6041509f4 100644 --- a/packages/ecs/src/implementation/world/joined-observer.ts +++ b/packages/ecs/src/implementation/world/joined-observer.ts @@ -1,7 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { Leaf } from '../../interface/system/leaf.js'; -import type { SystemLookup } from '../../interface/system/access.js'; -import { idOfItem, idOfKey, indexOfLookup, labelOf, labelOfItem, threadOf, watchedBy, watchesEntries } from '../../interface/registry/index.js'; +import type { SystemLookupLists } from '../../interface/system/access.js'; +import { idOfItem, idOfKey, idOrNull, indexOfLookup, labelOf, threadOf, watchedBy, watchesEntries } from '../../interface/registry/index.js'; import type { Archetype } from './archetype.js'; import { readCell } from './cells.js'; import { indexOf } from './entities.js'; @@ -88,6 +88,8 @@ export type JoinedObserver = Copied & { readonly target: ObserverTarget; readonly index: number; readonly label: string; + /** The id the plugin gave it, which reports give beside its label. */ + readonly id: string | null; readonly runner: Runner; /** Struct components its lookups read or watch, and the key parts of the indexes they go through. */ readonly lookupReads: readonly number[]; @@ -171,7 +173,7 @@ export const createObserverTarget = (thread: number, label: string, post: (messa const unique = (ids: readonly number[]): readonly number[] => [...new Set(ids)]; -const lookupReadsOf = (state: State, lookup: { readonly [name: string]: SystemLookup } | undefined): { readonly reads: readonly number[]; readonly watched: readonly number[] } => { +const lookupReadsOf = (state: State, lookup: { readonly [name: string]: SystemLookupLists } | undefined): { readonly reads: readonly number[]; readonly watched: readonly number[] } => { const reads: number[] = []; const watched: number[] = []; for (const name in lookup) { @@ -200,20 +202,20 @@ const startCounting = (state: State): void => { export const createJoinedObserverRunner = (state: State, leaf: Leaf, target: ObserverTarget, since = 0): Runner => { for (const lookup of Object.values(leaf.declaration.lookup ?? {})) { const index = indexOfLookup(lookup); - if (index !== undefined && watchesEntries(lookup)) throw new Error(entriesOnJoined(leaf, threadOf(leaf.declaration, "System", leaf.name) ?? target.label, labelOfItem(index))); + if (index !== undefined && watchesEntries(lookup)) throw new Error(entriesOnJoined(leaf, threadOf(leaf.declaration, "System", leaf.name) ?? target.label, index.name)); } // One placed there by the objects it names, rather than the thread's own, is named there by its id and its keys'. if (!state.observer.proxies.has(leaf)) { if (idOfItem(leaf) === undefined) { const thread = threadOf(leaf.declaration, "System", leaf.name) ?? target.label; - throw new Error(`System "${labelOfItem(leaf)}" runs on thread "${thread}", which a joined thread fills, and has no id, so that thread can't name it; the build gives top-level systems one.`); + throw new Error(`System "${leaf.name}" runs on thread "${thread}", which a joined thread fills, and has no id, so that thread can't name it; the build gives top-level systems one.`); } const unnamed = unnamedKey(leaf); if (unnamed !== null) throw new Error(unnamed); } - const base = createRunner(state, leaf, since); const { declaration } = leaf; const { components, resources } = declaration; + const base = createRunner(state, leaf, since); const ids = (keys: readonly Key[] | undefined): number[] => [...new Set((keys ?? []).map(key => idOf(state, key)))]; // Strings live in shared memory, as structs do. const isStruct = (id: number): boolean => state.layouts[id].kind !== "object"; @@ -261,7 +263,7 @@ export const createJoinedObserverRunner = (state: State, leaf: Leaf, target: Obs const index = target.free.pop() ?? target.placed.length; const proxy = state.observer.proxies.get(leaf); const observer: JoinedObserver = { - target, index, label: proxy?.label ?? labelOfItem(leaf), runner: base, + target, index, label: proxy?.label ?? leaf.name, id: proxy === undefined ? idOrNull(leaf) : null, runner: base, matched: base.matched, reads: ids([...components?.read ?? [], ...components?.added ?? [], ...components?.changed ?? []]).filter(isStruct), resourceReads: resourceIds.filter(isStruct), @@ -793,7 +795,7 @@ export const settleObserverKeys = (state: State, components: readonly number[], /** Why an observer system's key can't cross to a joined thread, or null when every one can. */ export const unnamedKey = (leaf: Leaf): string | null => { const unnamed = keysOf(leaf).find(key => idOfKey(key) === undefined); - return unnamed === undefined ? null : `System "${labelOfItem(leaf)}" names "${labelOf(unnamed)}", which has no id, so a joined thread can't name it; the build gives top-level keys one.`; + return unnamed === undefined ? null : `System "${leaf.name}" names "${labelOf(unnamed)}", which has no id, so a joined thread can't name it; the build gives top-level keys one.`; }; /** Runs of observer systems from joined threads, by the value standing for their input: the run's sequence there. */ diff --git a/packages/ecs/src/implementation/world/joining-worker.fixture.ts b/packages/ecs/src/implementation/world/joining-worker.fixture.ts index 9ef3e75d7..c314f97dc 100644 --- a/packages/ecs/src/implementation/world/joining-worker.fixture.ts +++ b/packages/ecs/src/implementation/world/joining-worker.fixture.ts @@ -10,7 +10,7 @@ import { drive, idle, names, total, until, withReports, type Driven, type Report /** A thread joining a world through the port it's sent and driving it, or holding a world it shares through it. */ /** A system only this thread imports, which a world elsewhere never did. */ -const unimportedByMain = System.create({ name: "joiningTest/unimportedByMain" }, () => {}); +const unimportedByMain = System.create({}, () => {}); type Port = MessagePort | NodeMessagePort; export type ToJoiningWorker = diff --git a/packages/ecs/src/implementation/world/joining.fixture.ts b/packages/ecs/src/implementation/world/joining.fixture.ts index 99b83e632..3a3cc3329 100644 --- a/packages/ecs/src/implementation/world/joining.fixture.ts +++ b/packages/ecs/src/implementation/world/joining.fixture.ts @@ -14,7 +14,7 @@ import { add, drive, names, total, until, withReports } from "./joining.test-hel */ /** A system only this thread imports, which a world on a worker never did. */ -const unimportedByWorker = System.create({ name: "joiningTest/unimportedByWorker" }, () => {}); +const unimportedByWorker = System.create({}, () => {}); type Worker = { readonly ask: (message: ToJoiningWorker, transfer?: readonly unknown[]) => Promise; diff --git a/packages/ecs/src/implementation/world/joining.test-helper.ts b/packages/ecs/src/implementation/world/joining.test-helper.ts index 6f5c9f0f9..f1af11bb9 100644 --- a/packages/ecs/src/implementation/world/joining.test-helper.ts +++ b/packages/ecs/src/implementation/world/joining.test-helper.ts @@ -1,6 +1,4 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Component } from "../../interface/component/component.js"; -import { Resource } from "../../interface/resource/resource.js"; import { System } from "../../interface/system/system.js"; import { Observe } from "../../observe/index.js"; import type { World } from "../../interface/world/world.js"; @@ -21,27 +19,22 @@ declare module "@adobe/ecs" { } export interface Resources { [total]: { type: number }; - [names]: { type: readonly string[] }; + [names]: { type: readonly string[]; default: [] }; } } -Component.register(point, { type: { type: "array", items: { type: "number" }, minItems: 2, maxItems: 2 } }); -Component.register(label, { type: { type: "string" } }); -Component.register(marked, { type: { const: true } }); -Resource.register(total, { type: { type: "number", default: 0 } }); -Resource.register(names, { type: { type: "array", items: { type: "string" }, default: [] } }); /** Appends a digit to `total`, so the order of runs shows in it. */ -export const add = System.create({ name: "joiningTest/add", input: { type: "object", properties: { amount: { type: "number" } } }, resources: { write: [total] } }, ({ input, resources }) => { +export const add = System.create({}, ({ resources }, input: { readonly amount: number }) => { resources[total] = resources[total] * 10 + input.amount; }); -export const append = System.create({ name: "joiningTest/append", input: { type: "object", properties: { name: { type: "string" } } }, resources: { write: [names] } }, ({ input, resources }) => { +export const append = System.create({}, ({ resources }, input: { readonly name: string }) => { resources[names] = [...resources[names], input.name]; }); -export const spawn = System.create({ name: "joiningTest/spawn", input: { type: "object", properties: { x: { type: "number" } } } }, ({ input, commands }) => { +export const spawn = System.create({}, ({ commands }, input: { readonly x: number }) => { commands.spawn([point, label, marked])({ [point]: [input.x, -input.x], [label]: `p${input.x}` }); }); -export const fail = System.create({ name: "joiningTest/fail" }, () => { throw new Error("joiningTest failure"); }); -export const idle = System.create({ name: "joiningTest/idle" }, () => {}); +export const fail = System.create({}, () => { throw new Error("joiningTest failure"); }); +export const idle = System.create({}, () => {}); /** The first value an observer gives that `accept` takes. */ export const until = (observe: Observe, accept: (value: T) => boolean): Promise => new Promise(resolve => { diff --git a/packages/ecs/src/implementation/world/joining.ts b/packages/ecs/src/implementation/world/joining.ts index a29fb4b3c..6d6c9447f 100644 --- a/packages/ecs/src/implementation/world/joining.ts +++ b/packages/ecs/src/implementation/world/joining.ts @@ -26,6 +26,7 @@ export type ObserverLookup = { readonly name: string; readonly read: readonly number[]; readonly has: readonly number[]; + readonly optional: readonly number[]; readonly not: readonly number[]; /** Null where it doesn't declare `changed`. */ readonly changed: readonly number[] | null; @@ -34,7 +35,7 @@ export type ObserverLookup = { /** What an observer system a joined thread declares reads, by that thread's numbers for its keys. */ export type ObserverDeclaration = { - readonly components: { readonly read: readonly number[]; readonly has: readonly number[]; readonly not: readonly number[]; readonly added: readonly number[]; readonly changed: readonly number[] } | null; + readonly components: { readonly read: readonly number[]; readonly has: readonly number[]; readonly optional: readonly number[]; readonly not: readonly number[]; readonly added: readonly number[]; readonly changed: readonly number[] } | null; readonly resources: { readonly read: readonly number[]; readonly changed: readonly number[]; readonly is: readonly number[]; readonly not: readonly number[] } | null; readonly lookup: readonly ObserverLookup[]; }; diff --git a/packages/ecs/src/implementation/world/kernel-imports-sparks.test-helper.ts b/packages/ecs/src/implementation/world/kernel-imports-sparks.test-helper.ts index f7b5f8799..f4dd0815f 100644 --- a/packages/ecs/src/implementation/world/kernel-imports-sparks.test-helper.ts +++ b/packages/ecs/src/implementation/world/kernel-imports-sparks.test-helper.ts @@ -1,5 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Archetype, Component, Vec3 } from "../../index.js"; +import { Vec3 } from "../../index.js"; export const position = Symbol("kernelImports/position"); export const spark = Symbol("kernelImports/spark"); @@ -11,8 +11,6 @@ declare module "@adobe/ecs" { } } -Component.register(position, { type: Vec3.schema }); -Component.register(spark, { type: { const: true } }); /** A list a kernel imports: the build reads its components here, and compiles the kernel again when this module changes. */ -export const sparks = Archetype.create([position, spark]); +export const sparks = [position, spark] as const; diff --git a/packages/ecs/src/implementation/world/kernel-imports.test.ts b/packages/ecs/src/implementation/world/kernel-imports.test.ts index d8dcc4550..4f68036c0 100644 --- a/packages/ecs/src/implementation/world/kernel-imports.test.ts +++ b/packages/ecs/src/implementation/world/kernel-imports.test.ts @@ -1,6 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { expect, it } from "vitest"; -import { Component, Entity, System, Vec3, World } from "../../index.js"; +import { Entity, System, Vec3, World } from "../../index.js"; import { Observe } from "../../observe/index.js"; import { position, spark, sparks } from "./kernel-imports-sparks.test-helper.js"; import { steer } from "./kernel-imports-steer.test-helper.js"; @@ -17,25 +17,22 @@ declare module "@adobe/ecs" { } } -Component.register(velocity, { type: Vec3.schema }); -Component.register(target, { type: Entity.schema }); -const spawn = System.create({ name: "kernelImports/spawn" }, ({ commands }) => { +const spawn = System.create({}, ({ commands }) => { const leader = commands.spawn([position, velocity])({ [position]: [10, 0, 0], [velocity]: [0, 0, 0] }); commands.spawn([position, velocity, target])({ [position]: [0, 0, 0], [velocity]: [0, 0, 0], [target]: leader }); }); -// `kernel: "cpu"` compiles the body at build time, to run in chunks across workers as WASM where it can. -const integrate = System.create({ name: "kernelImports/integrate", kernel: "cpu", components: { read: [velocity], write: [position] } }, row => { +// `kernel: true` compiles the body at build time, to run in chunks across workers as WASM where it can. +const integrate = System.create({ kernel: true, components: { with: [velocity, position] } }, row => { row[position] = Vec3.add(row[position], row[velocity]); }); // Kernels use lookups, imported pure functions, commands and imported lists too. const chase = System.create({ - name: "kernelImports/chase", - kernel: "cpu", - components: { read: [position, target], write: [velocity] }, - lookup: { targets: { read: [position] } }, + kernel: true, + components: { with: [position, target, velocity] }, + lookup: { targets: { with: [position] } }, }, (row, { lookup, commands }) => { const other = lookup.targets(row[target]); if (other === null) return; @@ -58,10 +55,10 @@ it("runs systems compiled as kernels", async () => { }); // A kernel's `add` and `remove` take an imported `Symbol(...)` as one key, and an imported list as a list. -const ignite = System.create({ name: "kernelImports/ignite" }, ({ commands }) => { +const ignite = System.create({}, ({ commands }) => { commands.spawn(sparks)({ [position]: [0, 0, 0] }); }); -const settle = System.create({ name: "kernelImports/settle", kernel: "cpu", components: { has: [spark] } }, (row, { commands }) => { +const settle = System.create({ kernel: true, components: { with: [spark] } }, (row, { commands }) => { commands.remove(row.entity, spark); }); diff --git a/packages/ecs/src/implementation/world/kernel-indexes.ts b/packages/ecs/src/implementation/world/kernel-indexes.ts index f34109b84..7c178d0a5 100644 --- a/packages/ecs/src/implementation/world/kernel-indexes.ts +++ b/packages/ecs/src/implementation/world/kernel-indexes.ts @@ -4,7 +4,7 @@ import { labelOf, watchedBy } from '../../interface/registry/index.js'; import type { KernelDescription } from './kernel.js'; import { partWords } from './index-keys.js'; import { sortedStride } from './index-sorted.js'; -import { recordKey } from './index-hash.js'; +import { recordKey, sortHashTable } from './index-hash.js'; import type { WorldIndex } from './indexes.js'; import type { IndexReaderSetup, IndexSource, RowLeaf } from './index-readers.js'; import { idOf, type Key, type State } from './state.js'; @@ -102,11 +102,16 @@ export const kernelIndexesOf = (state: State, description: KernelDescription, lo }; }); -/** Writes a lookup through an index's WASM parameters into `out` from `at`, as of now: its arrays' addresses, counts and ticks. */ +/** + * Writes a lookup through an index's WASM parameters into `out` from `at`, as of now: its arrays' addresses, counts and + * ticks. A hash index's unsorted buckets are sorted first, since its reader reads entries by position where it can't + * sort them. + */ export const writeIndexParameters = (bound: Pick, since: number, out: number[], at: number): number => { const { hash, sorted } = bound.index; let next = at; if (hash !== null) { + if (hash.unsorted !== 0) sortHashTable(hash); out[next++] = hash.slots.byteOffset; out[next++] = (hash.slots.length >> 1) - 1; out[next++] = hash.records.byteOffset; @@ -133,7 +138,10 @@ export const writeIndexParameters = (bound: Pick { const { hash, sorted } = bound.index; source.since = since; diff --git a/packages/ecs/src/implementation/world/kernel-lookup.test-helper.ts b/packages/ecs/src/implementation/world/kernel-lookup.test-helper.ts index 40a8876a2..d505a8f12 100644 --- a/packages/ecs/src/implementation/world/kernel-lookup.test-helper.ts +++ b/packages/ecs/src/implementation/world/kernel-lookup.test-helper.ts @@ -1,7 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Component } from '../../interface/component/component.js'; import { Entity } from '../../interface/entity/entity.js'; import { System } from '../../interface/system/system.js'; +import type { F32 } from '../../interface/math/index.js'; /** A kernel whose lookup reads a value outside linear memory, which `System.create` rejects as this module runs. */ @@ -12,19 +12,14 @@ declare module "@adobe/ecs" { export interface Components { [target]: { type: Entity }; [label]: { type: string }; - [offset]: { type: number }; + [offset]: { type: F32 }; } } -Component.register(target, { type: Entity.schema }); -Component.register(label, { type: { type: "string" } }); -Component.register(offset, { type: { type: "number", precision: 1 } }); export const labels = System.create({ - name: "kernelLookupTest/labels", - kernel: "cpu", - components: { read: [target], write: [offset] }, - // @ts-expect-error kernel lookups read linear values - lookup: { named: { read: [label] } }, + kernel: true, + components: { with: [target, offset] }, + lookup: { named: { with: [label] } }, }, (row, { lookup: { named } }) => { const found = named(row[target]); row[offset] = found === null ? 0 : +found[label]; diff --git a/packages/ecs/src/implementation/world/kernel-path.test-helper.ts b/packages/ecs/src/implementation/world/kernel-path.test-helper.ts index cecea482d..89f6682f4 100644 --- a/packages/ecs/src/implementation/world/kernel-path.test-helper.ts +++ b/packages/ecs/src/implementation/world/kernel-path.test-helper.ts @@ -1,5 +1,4 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Component } from '../../interface/component/component.js'; import { System } from '../../interface/system/system.js'; /** A kernel reading a leaf its component's schema doesn't have, which registering its compiled kernel rejects as this module runs. */ @@ -12,10 +11,8 @@ declare module "@adobe/ecs" { [sum]: { type: number }; } } -Component.register(pair, { type: { type: "array", items: { type: "number" }, minItems: 2, maxItems: 2 } }); -Component.register(sum, { type: { type: "number" } }); -export const beyond = System.create({ name: "kernelPathTest/beyond", kernel: "cpu", components: { read: [pair], write: [sum] } }, row => { +export const beyond = System.create({ kernel: true, components: { with: [pair, sum] } }, row => { // @ts-expect-error a pair has no third item row[sum] = row[pair][2]; }); diff --git a/packages/ecs/src/implementation/world/kernel-spawns.test.ts b/packages/ecs/src/implementation/world/kernel-spawns.test.ts index e3bb8b3f8..639085336 100644 --- a/packages/ecs/src/implementation/world/kernel-spawns.test.ts +++ b/packages/ecs/src/implementation/world/kernel-spawns.test.ts @@ -1,7 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; -import { Archetype } from "../../interface/archetype/archetype.js"; -import { Component } from "../../interface/component/component.js"; import { System } from "../../interface/system/system.js"; import { workers } from "../../interface/resources/workers.js"; import { count, read, readEach, select } from "./reads.test-helper.js"; @@ -9,6 +7,7 @@ import { createWorld } from "./create.js"; import { indexOf } from "./entities.js"; import { retainedRuns } from "./reports.js"; import { fillReports } from "./reports.test-helper.js"; +import type { F32, U32, Vec2 } from "../../interface/math/index.js"; const index = Symbol("kernelSpawnsTest/index"); const value = Symbol("kernelSpawnsTest/value"); @@ -20,60 +19,53 @@ const position = Symbol("kernelSpawnsTest/position"); declare module "@adobe/ecs" { export interface Components { - [index]: { type: number }; - [value]: { type: number }; - [extra]: { type: number }; + [index]: { type: U32 }; + [value]: { type: F32 }; + [extra]: { type: F32 }; [flag]: { type: true }; [made]: { type: true }; [name]: { type: string | null }; - [position]: { type: readonly [number, number] }; + [position]: { type: Vec2 }; } } -Component.register(index, { type: { type: "integer", minimum: 0, maximum: 0xFFFFFFFF } }); -Component.register(value, { type: { type: "number", precision: 1 } }); -Component.register(extra, { type: { type: "number", precision: 1 } }); -Component.register(flag, { type: { const: true } }); -Component.register(made, { type: { const: true } }); -Component.register(name, { type: { anyOf: [{ type: "string" }, { type: "null" }] } }); -Component.register(position, { type: { type: "array", items: { type: "number", precision: 1 }, minItems: 2, maxItems: 2 } }); -const spawned = Archetype.create([value, made]); +const spawned = [value, made] as const; -const seed = System.create({ name: "kernelSpawnsTest/seed", input: { type: "object", properties: { count: { type: "integer" } } } }, ({ input, commands }) => { +const seed = System.create({}, ({ commands }, input: { readonly count: number }) => { for (let i = 0; i < input.count; i++) commands.spawn([index])({ [index]: i }); }); /** One spawn per row, from a kernel. */ -const fromKernel = System.create({ name: "kernelSpawnsTest/fromKernel", kernel: "cpu", components: { read: [index] } }, (row, { commands }) => { +const fromKernel = System.create({ kernel: true, components: { with: [index] } }, (row, { commands }) => { commands.spawn(spawned)({ [value]: row[index] }); }); /** The same archetype from a main-thread system, before the kernel. */ -const fromMain = System.create({ name: "kernelSpawnsTest/fromMain", before: [fromKernel] }, ({ commands }) => { +const fromMain = System.create({ before: [fromKernel] }, ({ commands }) => { for (let i = 0; i < 3; i++) commands.spawn(spawned)({ [value]: 100 + i }); }); /** Spawns, then adds to and removes from what it spawned, in the same kernel and tick. */ -const spawnAndEdit = System.create({ name: "kernelSpawnsTest/spawnAndEdit", kernel: "cpu", components: { read: [index] } }, (row, { commands }) => { +const spawnAndEdit = System.create({ kernel: true, components: { with: [index] } }, (row, { commands }) => { const entity = commands.spawn([value, flag, made])({ [value]: row[index] }); commands.add(entity, extra, row[index] * 10); commands.remove(entity, flag); }); /** A spawn with a tuple, and an object component left to its start. */ -const withObjects = System.create({ name: "kernelSpawnsTest/withObjects", kernel: "cpu", components: { read: [index] } }, (row, { commands }) => { +const withObjects = System.create({ kernel: true, components: { with: [index] } }, (row, { commands }) => { commands.spawn([value, position, name, made])({ [value]: row[index], [position]: [row[index], -row[index]] }); }); -const despawnMade = System.create({ name: "kernelSpawnsTest/despawnMade", components: { has: [made] } }, (row, { commands }) => { commands.despawn(row.entity); }); +const despawnMade = System.create({ components: { with: [made] } }, (row, { commands }) => { commands.despawn(row.entity); }); const added = { seen: 0 }; -const countAdded = System.create({ name: "kernelSpawnsTest/countAdded", components: { added: [value], has: [made] } }, () => { added.seen++; }); +const countAdded = System.create({ components: { added: [value], with: [made] } }, () => { added.seen++; }); /** Each made entity's value, in storage order. */ const madeValues: number[] = []; -const readMade = System.create({ name: "kernelSpawnsTest/readMade", observer: true, components: { read: [value], has: [made] } }, row => { madeValues.push(row[value]); }); +const readMade = System.create({ observer: true, components: { with: [value, made] } }, row => { madeValues.push(row[value]); }); const valuesOf = async (world: ReturnType): Promise => { madeValues.length = 0; await world.run(readMade); @@ -96,7 +88,7 @@ describe("kernel spawns", () => { it("join main-thread spawns of the same archetype: the main thread's first, then the kernel's in row order", async () => { const world = createWorld({ [workers]: 0 }); await world.run(seed, { count: 4 }); - const frame = System.create({ name: "kernelSpawnsTest/mixed", systems: [fromKernel, fromMain] }); + const frame = System.create({ systems: [fromKernel, fromMain] }); await world.run(frame); expect(await valuesOf(world)).toEqual([100, 101, 102, 0, 1, 2, 3]); await world.run(frame); diff --git a/packages/ecs/src/implementation/world/kernel-tried.test-helper.ts b/packages/ecs/src/implementation/world/kernel-tried.test-helper.ts new file mode 100644 index 000000000..e5603e7f9 --- /dev/null +++ b/packages/ecs/src/implementation/world/kernel-tried.test-helper.ts @@ -0,0 +1,31 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Entity } from '../../interface/entity/entity.js'; +import { System } from '../../interface/system/system.js'; +import type { F32 } from '../../interface/math/index.js'; + +/** Systems without a kernel flag: one that compiles, and one whose lookup reads a string, which runs as written. */ + +const target = Symbol("kernelTriedTest/target"); +const label = Symbol("kernelTriedTest/label"); +const offset = Symbol("kernelTriedTest/offset"); +declare module "@adobe/ecs" { + export interface Components { + [target]: { type: Entity }; + [label]: { type: string }; + [offset]: { type: F32 }; + } +} + +export const doubled = System.create({ components: { with: [offset] } }, row => { + row[offset] = row[offset] * 2; +}); + +export const labels = System.create({ + components: { with: [target, offset] }, + lookup: { named: { with: [label] } }, +}, (row, { lookup: { named } }) => { + const found = named(row[target]); + row[offset] = found === null ? 0 : found[label].length; +}); + +export { label, offset, target }; diff --git a/packages/ecs/src/implementation/world/kernel.ts b/packages/ecs/src/implementation/world/kernel.ts index 2aa23f9e1..171bc2c78 100644 --- a/packages/ecs/src/implementation/world/kernel.ts +++ b/packages/ecs/src/implementation/world/kernel.ts @@ -20,13 +20,22 @@ export type Column = /** Float64Array of the component's changed ticks, stamped with `tick` on each write. */ | { readonly kind: "changed"; readonly component: Key } /** - * Float64Array of the component's newest changed tick per chunk of `chunkRows` rows, so change filters can skip - * unchanged chunks. Every `changed` column is followed by one, and each stamp sets both: `chunks[row >> chunkShift] = tick`. + * Float64Array of the component's newest changed tick per chunk of `2 ** chunkShift` rows, so change filters can + * skip unchanged chunks. Every `changed` column is followed by one, then by its `blocks` column. */ - | { readonly kind: "chunks"; readonly component: Key }; + | { readonly kind: "chunks"; readonly component: Key } + /** + * Float64Array of the component's newest changed tick per block of `2 ** blockShift` rows, so change filters skip + * the unchanged blocks of a chunk that changed. A stamp sets all three: the row's tick, its chunk's and its block's. + */ + | { readonly kind: "blocks"; readonly component: Key } + /** Of an optional component: non-empty in an archetype with it and empty in one without, so a kernel tests it once per call. */ + | { readonly kind: "present"; readonly component: Key }; /** Rows per chunk of change ticks is `2 ** chunkShift`. */ export const chunkShift = 10; +/** Rows per block of change ticks is `2 ** blockShift`: a chunk holds `2 ** (chunkShift - blockShift)` blocks. */ +export const blockShift = 6; /** A leaf of a resource a kernel reduces into, folded with `operator`. */ export type Reduced = { readonly resource: Key; readonly operator: Reduction; readonly path: Path }; @@ -146,8 +155,11 @@ export namespace Program { | { readonly kind: "set"; readonly local: number; readonly value: Expression } /** `columns[column][row] = value`, with typed-array conversion. */ | { readonly kind: "store"; readonly column: number; readonly value: Expression } - /** `columns[column][row] = tick` and `columns[chunks][row >> chunkShift] = tick`, for a changed column and its chunk column. */ - | { readonly kind: "stamp"; readonly column: number; readonly chunks: number } + /** + * `columns[column][row] = tick`, `columns[chunks][row >> chunkShift] = tick` and + * `columns[blocks][row >> blockShift] = tick`, for a changed column and its chunk and block columns. + */ + | { readonly kind: "stamp"; readonly column: number; readonly chunks: number; readonly blocks: number } | { readonly kind: "if"; readonly test: Expression; readonly consequent: readonly Statement[]; readonly alternate: readonly Statement[] } /** WASM `block`: a branch to it exits it. */ | { readonly kind: "block"; readonly body: readonly Statement[] } @@ -233,6 +245,11 @@ export type KernelDescription = { readonly reductions: readonly Reduced[]; /** The leaves of each value contributed whole, by resource. Registration checks they are all of the schema's leaves. */ readonly reducedShapes: readonly Shape[]; + /** + * Whether its arithmetic may round each step to storage precision, as SIMD lanes do: only for a kernel the + * declaration asks for. A body compiled unflagged computes as the body as written does. + */ + readonly storagePrecision?: true; }; /** What a kernel module at `KernelDescription.url` exports. */ @@ -241,5 +258,5 @@ export type KernelModule = { * Called once per registration. Per column `k`: `booleans[k]` is true when it stores booleans as 0 or 1, so reads convert them; * a row's element is at `row * strides[k] + offsets[k]`, in elements of the column's array. */ - readonly create: (booleans: readonly boolean[], strides: readonly number[], offsets: readonly number[], upBooleans?: readonly boolean[], upStrides?: readonly number[], upOffsets?: readonly number[]) => Kernel; + readonly create: (booleans: readonly boolean[], strides: readonly number[], offsets: readonly number[], upBooleans?: readonly boolean[], upStrides?: readonly number[], upOffsets?: readonly number[], labels?: readonly string[]) => Kernel; }; diff --git a/packages/ecs/src/implementation/world/kernels-csp.test.ts b/packages/ecs/src/implementation/world/kernels-csp.test.ts index 5d795d825..adbe103ee 100644 --- a/packages/ecs/src/implementation/world/kernels-csp.test.ts +++ b/packages/ecs/src/implementation/world/kernels-csp.test.ts @@ -1,26 +1,25 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; -import { Component } from "../../interface/component/component.js"; import { System } from "../../interface/system/system.js"; import { readEach, select } from "./reads.test-helper.js"; import { createWorld } from "./create.js"; import { workers } from "../../interface/resources/workers.js"; +import type { F32 } from "../../interface/math/index.js"; const value = Symbol("kernelsCspTest/value"); declare module "@adobe/ecs" { export interface Components { - [value]: { type: number }; + [value]: { type: F32 }; } } -Component.register(value, { type: { type: "number", precision: 1 } }); -const seed = System.create({ name: "kernelsCspTest/seed" }, ({ commands }) => { +const seed = System.create({}, ({ commands }) => { for (let i = 0; i < 10; i++) commands.spawn([value])({ [value]: i }); }); -const double = System.create({ name: "kernelsCspTest/double", kernel: "cpu", components: { write: [value] } }, row => { +const double = System.create({ kernel: true, components: { with: [value] } }, row => { row[value] = row[value] * 2; }); diff --git a/packages/ecs/src/implementation/world/kernels.test.ts b/packages/ecs/src/implementation/world/kernels.test.ts index 7b5a56bb6..0da093e56 100644 --- a/packages/ecs/src/implementation/world/kernels.test.ts +++ b/packages/ecs/src/implementation/world/kernels.test.ts @@ -1,11 +1,9 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; -import { Component } from "../../interface/component/component.js"; -import { Resource } from "../../interface/resource/resource.js"; import { System } from "../../interface/system/system.js"; import { World } from "../../interface/world/world.js"; import { definitionOf } from "../../interface/registry/index.js"; -import { attachKernel } from "./attach-kernel.js"; +import { attachKernel, kernelOf } from "./attach-kernel.js"; import { bindKernel } from "./kernels.js"; import type { KernelDescription } from "./kernel.js"; import { createState } from "./state.js"; @@ -21,23 +19,19 @@ declare module "@adobe/ecs" { [label]: { type: string | null }; } export interface Resources { - [scale]: { type: number }; + [scale]: { type: number; default: 1 }; } } const label = Symbol("kernelsTest/label"); -Component.register(value, { type: { type: "number" } }); -Component.register(other, { type: { type: "number" } }); -Component.register(label, { type: { anyOf: [{ type: "string" }, { type: "null" }] } }); -Resource.register(scale, { type: { type: "number", default: 1 } }); /** Reads `value` and `other`, writes nothing, reads no resources. */ -const system = System.create({ name: "kernelsTest/read", kernel: "cpu", components: { read: [value, other] } }, row => { +const system = System.create({ kernel: true, components: { with: [value, other] } }, row => { if (row[value] > row[other]) return; }); -const spawn = System.create({ name: "kernelsTest/spawn" }, ({ commands }) => { commands.spawn([value, other])({ [value]: 3, [other]: 0 }); }); -const double = System.create({ name: "kernelsTest/double", kernel: "cpu", components: { write: [value] } }, row => { +const spawn = System.create({}, ({ commands }) => { commands.spawn([value, other])({ [value]: 3, [other]: 0 }); }); +const double = System.create({ kernel: true, components: { with: [value] } }, row => { row[value] = row[value] * 2; }); @@ -67,13 +61,22 @@ describe("bindKernel", () => { describe("attachKernel", () => { it("rejects at registration a spawn site naming a component twice", () => { - expect(() => attachKernel(system, describing({ keys: [value, other], spawns: [[1, 0, 1]] }))).toThrow('Kernel "kernelsTest/read": spawns a list naming "kernelsTest/other" twice.'); + expect(() => attachKernel(system, describing({ keys: [value, other], spawns: [[1, 0, 1]] }))).toThrow('spawns a list naming "kernelsTest/other" twice.'); }); it("rejects at registration a tuple or struct given to a component without a linear layout", () => { - expect(() => attachKernel(system, describing({ keys: [value, label], structuredValues: [1] }))).toThrow('Kernel "kernelsTest/read": gives "kernelsTest/label" a tuple or struct, which only a component stored in linear memory takes.'); + expect(() => attachKernel(system, describing({ keys: [value, label], structuredValues: [1] }))).toThrow('gives "kernelsTest/label" a tuple or struct, which only a component stored in linear memory takes.'); expect(() => attachKernel(system, describing({ keys: [value, label], structuredValues: [0] }))).not.toThrow(); }); + it("attaches nothing, and throws nothing, for a kernel the build tried that doesn't match", () => { + const tried = System.create({ components: { with: [value, other] } }, row => { + if (row[value] > row[other]) return; + }); + expect(attachKernel(tried, describing({ keys: [value, label], structuredValues: [1] }), true)).toBe(tried); + expect(kernelOf(tried)).toBeUndefined(); + expect(attachKernel(tried, describing({}), true)).toBe(tried); + expect(kernelOf(tried)).toBeDefined(); + }); it("rejects at registration a spawn site naming a component outside its keys", () => { - expect(() => attachKernel(system, describing({ keys: [value], spawns: [[0, 1]] }))).toThrow('Kernel "kernelsTest/read": spawns a list naming component 1 of its 1 keys.'); + expect(() => attachKernel(system, describing({ keys: [value], spawns: [[0, 1]] }))).toThrow('spawns a list naming component 1 of its 1 keys.'); }); }); diff --git a/packages/ecs/src/implementation/world/kernels.ts b/packages/ecs/src/implementation/world/kernels.ts index b415c4b50..84dba26b9 100644 --- a/packages/ecs/src/implementation/world/kernels.ts +++ b/packages/ecs/src/implementation/world/kernels.ts @@ -10,7 +10,8 @@ import { commandIdOf, idOf, type Key, type State } from './state.js'; import { generateWasm, instantiateWasm, type Type, type WasmColumn, type WasmKernel, type WasmLookup, type WasmRun, type WasmScalar } from './wasm.js'; import { createDirectory, lookupParameters, type Directory } from './directories.js'; import { generateWgsl, type WgslKernel } from './wgsl.js'; -import { labelOf, labelOfItem } from '../../interface/registry/index.js'; +import { labelOf } from '../../interface/registry/index.js'; +import { absentColumn, emptyViews } from './arrays.js'; import type { WorldIndex } from './indexes.js'; import { kernelIndexesOf, refreshSource, writeIndexParameters, type KernelIndex } from './kernel-indexes.js'; import { createIndexReader, createIndexSource, type IndexReaderSetup } from './index-readers.js'; @@ -40,6 +41,8 @@ export type BoundKernel = { /** By lookup through an index: its lookup, how its JS reader reads it, and how many components it watches. */ readonly indexes: readonly { readonly lookup: number; readonly reader: IndexReaderSetup; readonly watched: number }[]; readonly keys: readonly number[]; + readonly labels: readonly string[]; + readonly optional: readonly number[]; readonly starts: readonly unknown[]; /** The component ids of each list the kernel spawns, in the order its spawner takes values. */ readonly spawns: readonly (readonly number[])[]; @@ -68,6 +71,8 @@ export type BoundKernel = { wasm: WasmKernel | null; /** The compiled module and the main thread's instance, once loaded. */ readonly compiled: () => { readonly module: WebAssembly.Module; readonly run: WasmRun } | null; + /** Whether an archetype has every optional component, the only archetypes its WASM runs: JS runs the rest. */ + readonly allPresent: (archetype: Archetype) => boolean; /** For a WASM kernel with lookups: the entity table and directories, passed ahead of the scalars. */ readonly lookupParameters: () => readonly number[]; /** The kernel as a compute shader for the components observed at the last load, or why it can't run on the GPU. */ @@ -87,7 +92,7 @@ const leafType = (layout: Layout, path: readonly (number | string)[]): Type | nu */ export const bindKernel = (state: State, leaf: Leaf, description: KernelDescription, byName: ReadonlyMap = new Map(), since: Since = { value: 0 }): BoundKernel => { const { lookup, components: query } = leaf.declaration; - const fail = (message: string): never => { throw new Error(`Kernel "${labelOfItem(leaf)}": ${message}`); }; + const fail = (message: string): never => { throw new Error(`Kernel "${leaf.name}": ${message}`); }; const booleans: boolean[] = []; // Per column, in elements of its view: entity and tick columns are dense. const strides: number[] = []; @@ -105,7 +110,7 @@ export const bindKernel = (state: State, leaf: Leaf, description: KernelDescript return column; } const id = idOf(state, column.component); - if (column.kind === "changed" || column.kind === "chunks") { + if (column.kind === "changed" || column.kind === "chunks" || column.kind === "blocks" || column.kind === "present") { dense(); return { kind: column.kind, id, leaf: 0 }; } @@ -150,11 +155,14 @@ export const bindKernel = (state: State, leaf: Leaf, description: KernelDescript strides, offsets, lookups: declaredLookups.map((declared): LookupSetup => ({ - read: ids(declared.read), has: ids("has" in declared ? declared.has : undefined), not: ids("not" in declared ? declared.not : undefined), + read: ids(declared.read), has: ids("has" in declared ? declared.has : undefined), optional: ids("optional" in declared ? declared.optional : undefined), not: ids("not" in declared ? declared.not : undefined), changed: "index" in declared || declared.changed === undefined ? null : ids(declared.changed), })), indexes: indexes.flatMap(bound => bound === null ? [] : [{ lookup: bound.lookup, reader: bound.reader, watched: bound.watched.length }]), keys, + labels: keys.map(id => labelOf(state.keys[id])), + // Only those its body uses: one it declares and never touches doesn't keep an archetype lacking it off WASM. + optional: ids(leaf.declaration.components?.optional).filter(id => sources.some(source => source.kind !== "entity" && source.id === id)), starts: keys.map(id => state.starts[id]), spawns, }; @@ -177,16 +185,22 @@ export const bindKernel = (state: State, leaf: Leaf, description: KernelDescript } return value; }; + const optional = new Set(setup.optional); const cache = new Map(); const columnsOf = (archetype: Archetype): readonly TypedArray[] => { const cached = cache.get(archetype); if (cached !== undefined && cached.version === archetype.version) return cached.columns; const columns = sources.map(source => { if (source.kind === "entity") return archetype.entities; + // An optional component's presence: any column that has rows where the archetype has it, empty where it doesn't. + if (source.kind === "present") return archetype.storageOf.has(source.id) ? archetype.entities : emptyViews.f64; const storage = archetype.storageOf.get(source.id); + // An optional component's columns are empty where the archetype lacks it: reads give undefined. + if (storage === undefined && optional.has(source.id)) return absentColumn(state.layouts[source.id], source); if (storage === undefined) return fail(`column of "${labelOf(state.keys[source.id])}" isn't in its components.`); if (source.kind === "changed") return storage.changed; if (source.kind === "chunks") return storage.chunkChanged; + if (source.kind === "blocks") return storage.blockChanged; const layout = state.layouts[source.id]; return layout.kind === "linear" ? storage.views[layout.leaves[source.leaf].view] : fail("a field column isn't linear."); }); @@ -219,7 +233,7 @@ export const bindKernel = (state: State, leaf: Leaf, description: KernelDescript upCache.set(archetype, { version: archetype.version, columns }); return columns; }; - const argsLayout = leaf.declaration.input === undefined ? null : layoutOf(leaf.declaration.input); + const argsLayout = leaf.input === undefined ? null : layoutOf(leaf.input); const scalarType = (scalar: WasmScalar): Type | null => scalar.source === "args" ? argsLayout === null ? null : leafType(argsLayout, scalar.path) : scalar.index < description.resources.length ? leafType(state.layouts[idOf(state, description.resources[scalar.index])], scalar.path) : null; @@ -238,12 +252,12 @@ export const bindKernel = (state: State, leaf: Leaf, description: KernelDescript }, })); /** - * Changed and chunk columns of components no registered system filters on are muted: nothing reads those ticks, - * so the WASM drops their stamps and the JS kernel stamps one dummy element. Chunk columns of components held on - * the GPU stay, since uploads read them. + * Changed, chunk and block columns of components no registered system filters on are muted: nothing reads those + * ticks, so the WASM drops their stamps and the JS kernel stamps one dummy element. Chunk columns of components + * held on the GPU stay, since uploads read them; block columns, which only filters read, go with the row ticks. */ const stamped = (source: Extract): boolean => state.observed.has(source.id) || (source.kind === "chunks" && state.resident.has(source.id)); - const mutedNow = (): boolean[] => sources.map(source => (source.kind === "changed" || source.kind === "chunks") && !stamped(source)); + const mutedNow = (): boolean[] => sources.map(source => (source.kind === "changed" || source.kind === "chunks" || source.kind === "blocks") && !stamped(source)); const upWasmColumns: WasmColumn[] = upSources.map(source => { const layout = state.layouts[source.id]; return { view: source.leaf.view, stride: layout.kind === "linear" ? layout.stride : 0, offset: source.leaf.offset, boolean: source.leaf.scalar === "boolean", component: source.id }; @@ -252,7 +266,8 @@ export const bindKernel = (state: State, leaf: Leaf, description: KernelDescript const filterCount = up !== undefined ? 0 : (query?.added?.length ?? 0) + (query?.changed?.length ?? 0); const generate = (muted: readonly boolean[]): WasmKernel | null => useWasm && description.program !== null ? generateWasm(description.program, wasmColumns.map((column, k) => muted[k] ? { ...column, muted: true } : column), scalarType, state.simd, filterCount, wasmLookups, upWasmColumns, - description.reductions.map(reduction => reduction.operator), indexes.map(bound => bound?.wasm ?? null), (description.probes ?? []).map(probe => probe.lookup)) + description.reductions.map(reduction => reduction.operator), indexes.map(bound => bound?.wasm ?? null), (description.probes ?? []).map(probe => probe.lookup), + description.storagePrecision === true) : null; let muted = mutedNow(); let index = state.kernelIndexes.get(description.url); @@ -275,6 +290,10 @@ export const bindKernel = (state: State, leaf: Leaf, description: KernelDescript }, upColumnsOf, wasm: generate(muted), + allPresent: archetype => { + for (const id of setup.optional) if (!archetype.storageOf.has(id)) return false; + return true; + }, compiled: () => compiled, kernel: () => loaded ?? fail("its module isn't loaded."), stale: () => { @@ -294,7 +313,7 @@ export const bindKernel = (state: State, leaf: Leaf, description: KernelDescript bound.setup = { ...setup, muted, up: upSetup }; loadedFor = muted.join(); const module: KernelModule = await import(/* @vite-ignore */ description.url); - loaded = module.create(booleans, strides.map((stride, k) => muted[k] ? 0 : stride), offsets.map((offset, k) => muted[k] ? 0 : offset), upSetup.booleans, upSetup.strides, upSetup.offsets); + loaded = module.create(booleans, strides.map((stride, k) => muted[k] ? 0 : stride), offsets.map((offset, k) => muted[k] ? 0 : offset), upSetup.booleans, upSetup.strides, upSetup.offsets, setup.labels); compiled = null; if (bound.wasm === null) return; try { diff --git a/packages/ecs/src/implementation/world/layout-directory.browser.test.ts b/packages/ecs/src/implementation/world/layout-directory.browser.test.ts index 9dc793c05..dd6eb39b9 100644 --- a/packages/ecs/src/implementation/world/layout-directory.browser.test.ts +++ b/packages/ecs/src/implementation/world/layout-directory.browser.test.ts @@ -1,12 +1,12 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { afterEach, describe, expect, it } from "vitest"; -import { Component } from "../../interface/component/component.js"; import { Entity } from "../../interface/entity/entity.js"; import { System } from "../../interface/system/system.js"; import type { World } from "../../interface/world/world.js"; import { workers } from "../../interface/resources/workers.js"; import { readEach, select } from "./reads.test-helper.js"; import { createWorld } from "./create.js"; +import type { F32 } from "../../interface/math/index.js"; const value = Symbol("layoutDirectoryTest/value"); const target = Symbol("layoutDirectoryTest/target"); @@ -23,9 +23,9 @@ const tags = [tag0, tag1, tag2, tag3, tag4, tag5, tag6] as const; declare module "@adobe/ecs" { export interface Components { - [value]: { type: number }; + [value]: { type: F32 }; [target]: { type: Entity }; - [seen]: { type: number }; + [seen]: { type: F32 }; [child]: { type: true }; [tag0]: { type: true }; [tag1]: { type: true }; @@ -37,66 +37,54 @@ declare module "@adobe/ecs" { } } -Component.register(value, { type: { type: "number", precision: 1 } }); -Component.register(target, { type: Entity.schema }); -Component.register(seen, { type: { type: "number", precision: 1 } }); -Component.register(child, { type: { const: true } }); -Component.register(tag0, { type: { const: true } }); -Component.register(tag1, { type: { const: true } }); -Component.register(tag2, { type: { const: true } }); -Component.register(tag3, { type: { const: true } }); -Component.register(tag4, { type: { const: true } }); -Component.register(tag5, { type: { const: true } }); -Component.register(tag6, { type: { const: true } }); /** The component lists of 100 archetypes: `value` with each of the first 100 combinations of the tags. */ const lists = Array.from({ length: 100 }, (_, i): (typeof value | (typeof tags)[number])[] => [value, ...tags.filter((_, bit) => (i & (1 << bit)) !== 0)]); /** Spawns `count` entities into each of the 100 archetypes. */ -const fill = System.create({ name: "layoutDirectoryTest/fill", input: { type: "object", properties: { count: { type: "integer" } } } }, ({ input, commands }) => { +const fill = System.create({}, ({ commands }, input: { readonly count: number }) => { for (const list of lists) for (let i = 0; i < input.count; i++) commands.spawn(list)({ [value]: 0 }); }); -const bump = System.create({ name: "layoutDirectoryTest/bump", kernel: "cpu", components: { write: [value] } }, row => { +const bump = System.create({ kernel: true, components: { with: [value] } }, row => { row[value] = row[value] + 1; }); const total = { sum: 0, count: 0 }; /** Sums every value on the world's thread, so it reads what workers wrote. */ -const sum = System.create({ name: "layoutDirectoryTest/sum", components: { read: [value] } }, row => { +const sum = System.create({ components: { with: [value] } }, row => { total.sum += row[value]; total.count++; }); /** Entities whose values the lookups read, recorded as they spawn. */ const spawned: Entity[] = []; -const make = System.create({ name: "layoutDirectoryTest/make" }, ({ commands }) => { +const make = System.create({}, ({ commands }) => { for (let i = 0; i < 64; i++) commands.spawn([target, seen])({ [target]: Entity.none, [seen]: 0 }); commands.spawn([value])({ [value]: -1 }); }); -const spawnMany = System.create({ name: "layoutDirectoryTest/spawnMany", input: { type: "object", properties: { count: { type: "integer" } } } }, ({ input, commands }) => { +const spawnMany = System.create({}, ({ commands }, input: { readonly count: number }) => { for (let i = 0; i < input.count; i++) spawned.push(commands.spawn([value])({ [value]: i })); }); /** Aims each looker at one of the last spawned entities. */ let aimed = 0; -const aim = System.create({ name: "layoutDirectoryTest/aim", components: { write: [target] } }, row => { +const aim = System.create({ components: { with: [target] } }, row => { row[target] = spawned[spawned.length - 1 - aimed++]; }); -const despawnValues = System.create({ name: "layoutDirectoryTest/despawnValues", components: { has: [value], not: [child] } }, (row, { commands }) => { +const despawnValues = System.create({ components: { with: [value], not: [child] } }, (row, { commands }) => { commands.despawn(row.entity); }); /** Lookups from workers. */ const look = System.create({ - name: "layoutDirectoryTest/look", - kernel: "cpu", - components: { read: [target], write: [seen] }, - lookup: { targets: { read: [value] } }, + kernel: true, + components: { with: [target, seen] }, + lookup: { targets: { with: [value] } }, }, (row, { lookup: { targets } }) => { const other = targets(row[target]); row[seen] = other === null ? -1 : other[value]; }); /** Spawns from workers: a child per looker. */ -const breed = System.create({ name: "layoutDirectoryTest/breed", kernel: "cpu", components: { read: [seen] } }, (row, { commands }) => { +const breed = System.create({ kernel: true, components: { with: [seen] } }, (row, { commands }) => { commands.spawn([value, child])({ [value]: row[seen] }); }); @@ -131,7 +119,8 @@ const totals = async (world: World): Promise<{ sum: number; count: number }> => describe("the layout directory", () => { for (const wasm of [true, false]) { it(`lets workers' ${wasm ? "WASM" : "JS"} kernels see every row of 100 archetypes grown across sync points, with no layout message after the one creating them`, async () => { - const world = createWorld({ [workers]: 3 }, { minimumChunk: 16, wasm }); + // Pieces of two rows, so the first round's single rows start the pool and its layout message is before the count. + const world = createWorld({ [workers]: 3 }, { minimumChunk: 2, wasm }); disposals.push(world); await world.run(fill, { count: 1 }); await world.run(bump); @@ -178,7 +167,7 @@ describe("the layout directory", () => { for (const wasm of [true, false]) { it(`lets workers' ${wasm ? "WASM" : "JS"} kernels rebuild their views once memory grows between runs`, async () => { - const world = createWorld({ [workers]: 2 }, { minimumChunk: 16, wasm }); + const world = createWorld({ [workers]: 2 }, { minimumChunk: 2, wasm }); disposals.push(world); await world.run(fill, { count: 1 }); await world.run(bump); diff --git a/packages/ecs/src/implementation/world/layout-directory.fixture.ts b/packages/ecs/src/implementation/world/layout-directory.fixture.ts index a1fbd2feb..d6e3bce8d 100644 --- a/packages/ecs/src/implementation/world/layout-directory.fixture.ts +++ b/packages/ecs/src/implementation/world/layout-directory.fixture.ts @@ -1,5 +1,4 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Component } from "../../interface/component/component.js"; import { Entity } from "../../interface/entity/entity.js"; import { System } from "../../interface/system/system.js"; import type { World } from "../../interface/world/world.js"; @@ -7,6 +6,7 @@ import { workers } from "../../interface/resources/workers.js"; import { readEach, select } from "./reads.test-helper.js"; import { createWorld } from "./create.js"; import { countingWorkers } from "./platforms.test-helper.js"; +import type { F32 } from "../../interface/math/index.js"; /** Scenarios of workers reading layouts from the directory, run built in node and from source in chromium. */ @@ -25,9 +25,9 @@ const tags = [tag0, tag1, tag2, tag3, tag4, tag5, tag6] as const; declare module "@adobe/ecs" { export interface Components { - [value]: { type: number }; + [value]: { type: F32 }; [target]: { type: Entity }; - [seen]: { type: number }; + [seen]: { type: F32 }; [child]: { type: true }; [tag0]: { type: true }; [tag1]: { type: true }; @@ -39,66 +39,54 @@ declare module "@adobe/ecs" { } } -Component.register(value, { type: { type: "number", precision: 1 } }); -Component.register(target, { type: Entity.schema }); -Component.register(seen, { type: { type: "number", precision: 1 } }); -Component.register(child, { type: { const: true } }); -Component.register(tag0, { type: { const: true } }); -Component.register(tag1, { type: { const: true } }); -Component.register(tag2, { type: { const: true } }); -Component.register(tag3, { type: { const: true } }); -Component.register(tag4, { type: { const: true } }); -Component.register(tag5, { type: { const: true } }); -Component.register(tag6, { type: { const: true } }); /** The component lists of 100 archetypes: `value` with each of the first 100 combinations of the tags. */ const lists = Array.from({ length: 100 }, (_, i): (typeof value | (typeof tags)[number])[] => [value, ...tags.filter((_, bit) => (i & (1 << bit)) !== 0)]); /** Spawns `count` entities into each of the 100 archetypes. */ -const fill = System.create({ name: "layoutDirectoryTest/fill", input: { type: "object", properties: { count: { type: "integer" } } } }, ({ input, commands }) => { +const fill = System.create({}, ({ commands }, input: { readonly count: number }) => { for (const list of lists) for (let i = 0; i < input.count; i++) commands.spawn(list)({ [value]: 0 }); }); -const bump = System.create({ name: "layoutDirectoryTest/bump", kernel: "cpu", components: { write: [value] } }, row => { +const bump = System.create({ kernel: true, components: { with: [value] } }, row => { row[value] = row[value] + 1; }); const total = { sum: 0, count: 0 }; /** Sums every value on the world's thread, so it reads what workers wrote. */ -const sum = System.create({ name: "layoutDirectoryTest/sum", components: { read: [value] } }, row => { +const sum = System.create({ components: { with: [value] } }, row => { total.sum += row[value]; total.count++; }); /** Entities whose values the lookups read, recorded as they spawn. */ const spawned: Entity[] = []; -const make = System.create({ name: "layoutDirectoryTest/make" }, ({ commands }) => { +const make = System.create({}, ({ commands }) => { for (let i = 0; i < 64; i++) commands.spawn([target, seen])({ [target]: Entity.none, [seen]: 0 }); commands.spawn([value])({ [value]: -1 }); }); -const spawnMany = System.create({ name: "layoutDirectoryTest/spawnMany", input: { type: "object", properties: { count: { type: "integer" } } } }, ({ input, commands }) => { +const spawnMany = System.create({}, ({ commands }, input: { readonly count: number }) => { for (let i = 0; i < input.count; i++) spawned.push(commands.spawn([value])({ [value]: i })); }); /** Aims each looker at one of the last spawned entities. */ let aimed = 0; -const aim = System.create({ name: "layoutDirectoryTest/aim", components: { write: [target] } }, row => { +const aim = System.create({ components: { with: [target] } }, row => { row[target] = spawned[spawned.length - 1 - aimed++]; }); -const despawnValues = System.create({ name: "layoutDirectoryTest/despawnValues", components: { has: [value], not: [child] } }, (row, { commands }) => { +const despawnValues = System.create({ components: { with: [value], not: [child] } }, (row, { commands }) => { commands.despawn(row.entity); }); /** Lookups from workers. */ const look = System.create({ - name: "layoutDirectoryTest/look", - kernel: "cpu", - components: { read: [target], write: [seen] }, - lookup: { targets: { read: [value] } }, + kernel: true, + components: { with: [target, seen] }, + lookup: { targets: { with: [value] } }, }, (row, { lookup: { targets } }) => { const other = targets(row[target]); row[seen] = other === null ? -1 : other[value]; }); /** Spawns from workers: a child per looker. */ -const breed = System.create({ name: "layoutDirectoryTest/breed", kernel: "cpu", components: { read: [seen] } }, (row, { commands }) => { +const breed = System.create({ kernel: true, components: { with: [seen] } }, (row, { commands }) => { commands.spawn([value, child])({ [value]: row[seen] }); }); @@ -124,7 +112,8 @@ export const rounds: readonly number[] = [16, 32, 64, 128, 256]; /** 100 archetypes grown over `rounds`, each round bumped by workers: layout messages after the first round, and the totals. */ export const grown = async (wasm: boolean): Promise<{ readonly messages: number; readonly totals: { sum: number; count: number } }> => { const counting = countingWorkers(); - return using(createWorld({ [workers]: 3 }, { minimumChunk: 16, wasm, pool: counting.platform }), async world => { + // Pieces of two rows, so the first round's single rows start the pool and its layout message is before the count. + return using(createWorld({ [workers]: 3 }, { minimumChunk: 2, wasm, pool: counting.platform }), async world => { await world.run(fill, { count: 1 }); await world.run(bump); const before = counting.posted("layout"); @@ -165,7 +154,7 @@ export const tableGrown = async (): Promise<{ readonly messages: number; readonl /** Workers' kernels once memory grows between runs: how often it grew, layout messages meanwhile, and the totals. */ export const memoryGrown = async (wasm: boolean): Promise<{ readonly grows: number; readonly messages: number; readonly totals: { sum: number; count: number } }> => { const counting = countingWorkers(); - return using(createWorld({ [workers]: 2 }, { minimumChunk: 16, wasm, pool: counting.platform }), async world => { + return using(createWorld({ [workers]: 2 }, { minimumChunk: 2, wasm, pool: counting.platform }), async world => { await world.run(fill, { count: 1 }); await world.run(bump); let grows = 0; diff --git a/packages/ecs/src/implementation/world/layout-directory.ts b/packages/ecs/src/implementation/world/layout-directory.ts index 47a53f2c9..c4beb2ad4 100644 --- a/packages/ecs/src/implementation/world/layout-directory.ts +++ b/packages/ecs/src/implementation/world/layout-directory.ts @@ -12,8 +12,9 @@ import type { Memory } from './memory.js'; * * Header words: the memory's length in pages (`memory.ts`), the table's offset and length, the structure epoch, then * the entity-table record: its `cursor` offset, which never moves, `free` offset and capacity, `archetype`, `row` and - * `generation` offsets and their capacity, and its version; then the resource entity's index plus one, 0 for none. The - * table's length is `cursor[2]`. + * `generation` offsets and their capacity, and its version; then the resource entity's index plus one, 0 for none; the + * structure epoch's waiters; and how many flushes replaced an existing archetype's arrays. The table's length is + * `cursor[2]`. * * The structure epoch is odd from the start of each sync point that applies anything until its directory writes at the * end, and even otherwise. A thread reading entity locations outside a task reads while it's even and checks it again @@ -35,12 +36,18 @@ export const entitiesVersionField = 7; export const resourceEntityWord = 12; /** Readers waiting on the structure epoch, so the world notifies only when one is. */ export const structureWaitersWord = 13; +/** + * Flushes that wrote an existing archetype's record again, its arrays replaced, so a joined thread checks this word + * once a run rather than each record's version for every row it reads. + */ +export const replacedWord = 14; /** * An archetype record: its capacity, `entities` offset, storage count, version, flags and row count, then per storage - * its component number, flags, column, `added`, `changed`, `chunkAdded` and `chunkChanged` offsets, and chunk count. - * Offsets a storage doesn't have are 0. A record is allocated once, since an archetype's storages are fixed, and never - * moves. Its count is written at flushes while a joined thread may read it, for archetypes whose count changed. + * its component number, flags, column, `added`, `changed`, `chunkAdded` and `chunkChanged` offsets, chunk count, and + * `blockAdded` and `blockChanged` offsets, of `chunks << (chunkShift - blockShift)` blocks. Offsets a storage doesn't + * have are 0. A record is allocated once, since an archetype's storages are fixed, and never moves. Its count is + * written at flushes while a joined thread may read it, for archetypes whose count changed. */ export const recordCapacity = 0; export const recordEntities = 1; @@ -53,7 +60,7 @@ export const recordWords = 6; export const resourceFlag = 1; /** Record flags: it holds reports, which only queries that require a component see. */ export const metaFlag = 2; -export const storageWords = 8; +export const storageWords = 10; export const storageComponent = 0; export const storageFlags = 1; export const storageColumn = 2; @@ -62,6 +69,8 @@ export const storageChanged = 4; export const storageChunkAdded = 5; export const storageChunkChanged = 6; export const storageChunks = 7; +export const storageBlockAdded = 8; +export const storageBlockChanged = 9; /** * Storage flags: it keeps ticks; it has a column in linear memory, a string's of handles included; its objects are a joined thread's, whose number is * the flags from `threadShift` on, and its column holds each row's tag (`move-log.ts`). @@ -148,6 +157,8 @@ const writeArchetype = (directory: LayoutDirectory, archetype: Archetype): void record[base + storageChunkAdded] = storage.ticked ? storage.chunkAdded.byteOffset : 0; record[base + storageChunkChanged] = storage.ticked ? storage.chunkChanged.byteOffset : 0; record[base + storageChunks] = storage.chunkAdded.length; + record[base + storageBlockAdded] = storage.ticked ? storage.blockAdded.byteOffset : 0; + record[base + storageBlockChanged] = storage.ticked ? storage.blockChanged.byteOffset : 0; } Atomics.store(record, recordVersion, archetype.version); // Entered once complete, so a reader that finds it finds a whole record. @@ -170,13 +181,16 @@ export const flushDirectory = (directory: LayoutDirectory | null, bumped: Archet directory.entities = entities.version; } if (bumped.length === 0) return; + let replaced = false; for (let k = 0; k < bumped.length; k++) { const archetype = bumped[k]; archetype.listed = false; + if (directory.records[archetype.id] !== undefined) replaced = true; writeArchetype(directory, archetype); } bumped.length = 0; Atomics.store(directory.header, tableLengthWord, archetypes.length); + if (replaced) Atomics.add(directory.header, replacedWord, 1); }; /** diff --git a/packages/ecs/src/implementation/world/lifetime.fixture.ts b/packages/ecs/src/implementation/world/lifetime.fixture.ts index 53aae275c..85430946c 100644 --- a/packages/ecs/src/implementation/world/lifetime.fixture.ts +++ b/packages/ecs/src/implementation/world/lifetime.fixture.ts @@ -1,7 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Component } from "../../interface/component/component.js"; import { Entity } from "../../interface/entity/entity.js"; -import { Resource } from "../../interface/resource/resource.js"; import { System } from "../../interface/system/system.js"; import { workers } from "../../interface/resources/workers.js"; import { createWorld, type Tables } from "./create.js"; @@ -9,6 +7,7 @@ import { collector, freshKernel, tablesAfterCollecting } from "./lifetime.test-h import { readResource } from "./reads.test-helper.js"; import { fillReports, runOf } from "./reports.test-helper.js"; import { Report } from "../../interface/report/report.js"; +import type { F32 } from "../../interface/math/index.js"; /** Scenarios of what kernels hold across the pool, run built in node and from source in chromium. */ @@ -19,34 +18,28 @@ const total = Symbol("lifetimeFixture/total"); declare module "@adobe/ecs" { export interface Components { - [value]: { type: number }; + [value]: { type: F32 }; [target]: { type: Entity }; - [out]: { type: number }; + [out]: { type: F32 }; } export interface Resources { [total]: { type: number; reduce: "+" }; } } -Component.register(value, { type: { type: "number", precision: 1 } }); -Component.register(target, { type: Entity.schema }); -Component.register(out, { type: { type: "number", precision: 1 } }); -Resource.register(total, { type: { type: "number", default: 0 }, reduce: "+" }); export const rows = 4_096; -const seed = System.create({ name: "lifetimeFixture/seed" }, ({ commands }) => { +const seed = System.create({}, ({ commands }) => { let previous = commands.spawn([value])({ [value]: 0 }); for (let i = 1; i < rows; i++) previous = commands.spawn([value, target, out])({ [value]: i, [target]: previous, [out]: 0 }); }); /** A kernel that holds what kernels can: a lookup's directory, reduction partials and its load on each worker. */ const measure = System.create({ - name: "lifetimeFixture/measure", - kernel: "cpu", - components: { read: [value, target], write: [out] }, - lookup: { targets: { read: [value] } }, - reduce: { [total]: "+" }, + kernel: true, + components: { with: [value, target, out] }, + lookup: { targets: { with: [value] } }, }, (row, { lookup: { targets }, reduce }) => { const other = targets(row[target]); row[out] = other === null ? -1 : row[value] - other[value]; diff --git a/packages/ecs/src/implementation/world/lifetime.test-helper.ts b/packages/ecs/src/implementation/world/lifetime.test-helper.ts index 76d121353..eae89cf87 100644 --- a/packages/ecs/src/implementation/world/lifetime.test-helper.ts +++ b/packages/ecs/src/implementation/world/lifetime.test-helper.ts @@ -73,7 +73,7 @@ export const freshKernel = (system: System): System => { const description = kernelOf(system); if (leaf === undefined || leaf.kind === "composite" || description === undefined) throw new Error("Not a kernel system."); // What `System.create` makes of the same declaration and body, without the id a top-level binding takes. - const copy: Leaf & System = { kind: leaf.kind, name: leaf.name, declaration: leaf.declaration, body: leaf.body }; + const copy: Leaf & System = { kind: leaf.kind, name: leaf.name, declaration: leaf.declaration, input: leaf.input, body: leaf.body }; claimSystem(copy, undefined); return attachKernel(copy, description); }; diff --git a/packages/ecs/src/implementation/world/lifetime.test.ts b/packages/ecs/src/implementation/world/lifetime.test.ts index e505d447d..39f059390 100644 --- a/packages/ecs/src/implementation/world/lifetime.test.ts +++ b/packages/ecs/src/implementation/world/lifetime.test.ts @@ -1,8 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; -import { Component } from "../../interface/component/component.js"; import { Entity } from "../../interface/entity/entity.js"; -import { Resource } from "../../interface/resource/resource.js"; import { System } from "../../interface/system/system.js"; import type { World } from "../../interface/world/world.js"; import { workers } from "../../interface/resources/workers.js"; @@ -20,30 +18,27 @@ declare module "@adobe/ecs" { [parent]: { type: Entity; reference: "owner" }; } interface Resources { - [score]: { type: number }; + [score]: { type: number; default: 7 }; } } -Component.register(count, { type: { type: "number" } }); -Component.register(parent, { type: Entity.schema, reference: "owner" }); -Resource.register(score, { type: { type: "number", default: 7 } }); -const seed = System.create({ name: "lifetimeTest/seed" }, ({ commands }) => { +const seed = System.create({}, ({ commands }) => { const root = commands.spawn([count])({ [count]: 1 }); for (let i = 0; i < 4; i++) commands.spawn([count, parent])({ [count]: i, [parent]: root }); }); /** A module-level system, run between and after the fresh ones, as an app's own systems are. */ -const steady = System.create({ name: "lifetimeTest/steady", components: { read: [count], changed: [count] } }, row => { void row[count]; }); +const steady = System.create({ components: { with: [count], changed: [count] } }, row => { void row[count]; }); let copied = 0; /** Fresh systems of each shape that holds something: a body alone, a resource read, a change filter, and `up`. */ const freshSystems = (k: number): readonly System[] => [ System.create(() => {}), - System.create({ resources: { read: [score] } }, ({ resources }) => { copied = resources[score] + k; }), - System.create({ components: { write: [count], changed: [count] } }, row => { row[count] += 1; }), - System.create({ components: { read: [count] }, up: { through: parent, read: [count] } }, (row, { up }) => { copied = row[count] + (up?.[count] ?? 0); }), + System.create({}, ({ resources }) => { copied = resources[score] + k; }), + System.create({ components: { with: [count], changed: [count] } }, row => { row[count] += 1; }), + System.create({ components: { with: [count] }, up: { through: parent } }, (row, { up }) => { copied = row[count] + (up?.[count] ?? 0); }), ]; const fresh = 10_000; diff --git a/packages/ecs/src/implementation/world/lists.ts b/packages/ecs/src/implementation/world/lists.ts index 4f672b86f..31bec4c1e 100644 --- a/packages/ecs/src/implementation/world/lists.ts +++ b/packages/ecs/src/implementation/world/lists.ts @@ -1,6 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { Entity } from '../../interface/entity/entity.js'; -import { archetypeIndexOf, labelOf } from '../../interface/registry/index.js'; +import { archetypeIndexKey, internArchetype, labelOf, type Interned } from '../../interface/registry/index.js'; import { batchOf } from './batches.js'; import { reserve } from './entities.js'; import { claim, generated, openRow, writerOf, type Column, type Positional, type SpawnBatch, type Writer } from './staging.js'; @@ -37,8 +37,8 @@ export type ListShape = { spawner: Spawner | null; }; -/** A plain list named recently, found by its keys, copied, since it may change. */ -export type Recent = { readonly keys: readonly unknown[]; readonly shape: ListShape }; +/** A plain list named recently, found by its keys, copied once for its shape, since it may change. */ +export type Recent = { keys: readonly unknown[]; shape: ListShape }; const recentCount = 8; @@ -61,17 +61,26 @@ const resolve = (state: State, list: readonly unknown[]): ListShape => { }; /** - * The shape of a list of components: an archetype `Archetype.create` indexed, which never changes, by its index; any - * other list by its keys among the recent plain lists, else resolved and made recent. Lists of the same components in - * any order share a shape. + * The index a list has: the program-wide one the plugin gave it, which never changes; for one made at runtime, the + * one it keeps while its keys are those it was resolved with; else -1. Both are read from one property, so a list of + * either kind is one lookup. + */ +export const listIndexOf = (list: readonly unknown[]): number => { + const index: unknown = Reflect.get(list, archetypeIndexKey); + return typeof index === "number" ? index : isKept(index) && sameKeys(index.keys, list) ? index.index : -1; +}; + +/** + * The shape of a list of components, by its index (`listIndexOf`); for a list made at runtime without one, by its keys + * among the recent ones, else resolved, given an index it keeps and made recent. Lists of the same components in any + * order share a shape. */ export const listShapeOf = (state: State, list: readonly unknown[]): ListShape => { - const index = archetypeIndexOf(list); - // Kept small, so it inlines into the systems spawning and adding: the rest is outlined. + const index = listIndexOf(list); return (index >= 0 ? state.byIndex[index] : undefined) ?? shapeOfList(state, list, index); }; -/** A list's shape, other than an archetype's already resolved. */ +/** A list's shape, other than one already resolved by its index. */ const shapeOfList = (state: State, list: readonly unknown[], index: number): ListShape => { if (index >= 0) { const shape = resolve(state, list); @@ -79,21 +88,40 @@ const shapeOfList = (state: State, list: readonly unknown[], index: number): Lis return shape; } const { recent } = state; - const length = list.length; - for (let r = 0; r < recent.length; r++) { - const { keys } = recent[r]; - if (keys.length !== length) continue; - let same = true; - for (let k = 0; k < length && same; k++) same = keys[k] === list[k]; - if (same) return recent[r].shape; + for (let r = 0; r < recent.length; r++) if (sameKeys(recent[r].keys, list)) return recent[r].shape; + // Lists of these keys made on every call share one index, so a shape seen before allocates nothing here. + const interned = internArchetype(list); + const shape = state.byIndex[interned.index] ?? (state.byIndex[interned.index] = resolve(state, list)); + if (recent.length < recentCount) recent.push({ keys: interned.keys, shape }); + else { + const slot = recent[state.nextRecent = (state.nextRecent + 1) % recentCount]; + slot.keys = interned.keys; + slot.shape = shape; + } + // A list keeps the record of its keys' index, so naming it again is one lookup while it holds them. + const kept: unknown = Reflect.get(list, archetypeIndexKey); + if (kept === undefined && Object.isExtensible(list)) { + record.value = interned; + Object.defineProperty(list, archetypeIndexKey, record); } - const shape = resolve(state, list); - const entry: Recent = { keys: [...list], shape }; - if (recent.length < recentCount) recent.push(entry); - else recent[state.nextRecent = (state.nextRecent + 1) % recentCount] = entry; + else if (isKept(kept)) Reflect.set(list, archetypeIndexKey, interned); return shape; }; +/** The descriptor a list's record is defined by, its value set for each: writable, since the list may change. */ +const record: PropertyDescriptor = { value: null, writable: true }; + +/** Whether an index property holds a list's record: only this module puts an object there, and indexes are numbers. */ +const isKept = (value: unknown): value is Interned => typeof value === "object" && value !== null; + +/** Whether a list holds exactly the keys copied from one before. */ +const sameKeys = (keys: readonly unknown[], list: readonly unknown[]): boolean => { + const length = list.length; + if (keys.length !== length) return false; + for (let k = 0; k < length; k++) if (keys[k] !== list[k]) return false; + return true; +}; + /** Where a shape's spawns go, resolved once. A constant's column holds nothing, so nothing is written or read for it. */ export const writtenOf = (state: State, shape: ListShape): Written => { if (shape.written !== null) return shape.written; diff --git a/packages/ecs/src/implementation/world/lookup-keys.ts b/packages/ecs/src/implementation/world/lookup-keys.ts index e6d1fd9e1..bf26439a8 100644 --- a/packages/ecs/src/implementation/world/lookup-keys.ts +++ b/packages/ecs/src/implementation/world/lookup-keys.ts @@ -1,25 +1,25 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import type { SystemLookup } from '../../interface/system/access.js'; +import type { SystemLookupLists } from '../../interface/system/access.js'; import { indexDefinitionOf, indexOfLookup, watchedBy } from '../../interface/registry/index.js'; -import type { Component } from '../../interface/component/component.js'; +type Key = symbol; /** Every component a lookup names: what it reads, matches on and watches, and for an index lookup the index's own. */ -export const lookupComponentsOf = (lookup: SystemLookup): readonly Component.Key[] => { +export const lookupComponentsOf = (lookup: SystemLookupLists): readonly Key[] => { const index = indexOfLookup(lookup); if (index === undefined) return [...lookup.read ?? [], ..."has" in lookup ? lookup.has ?? [] : [], ..."not" in lookup ? lookup.not ?? [] : []]; const { key, components } = index.declaration; return [...lookup.read ?? [], ...watchedBy(lookup), ...key, ...components?.has ?? [], ...components?.not ?? []]; }; -const none: readonly Component.Key[] = []; +const none: readonly Key[] = []; /** * The components whose changes the indexes of a declaration's lookups follow, which keep ticks and kernels' stamps * while it lives: each index's key parts, and what its lookups watch. */ -export const indexWatchesOf = (lookup: { readonly [name: string]: SystemLookup } | undefined): readonly Component.Key[] => { +export const indexWatchesOf = (lookup: { readonly [name: string]: SystemLookupLists } | undefined): readonly Key[] => { if (lookup === undefined) return none; - const keys: Component.Key[] = []; + const keys: Key[] = []; for (const name in lookup) { const declared = lookup[name]; const index = indexOfLookup(declared); @@ -30,9 +30,9 @@ export const indexWatchesOf = (lookup: { readonly [name: string]: SystemLookup } }; /** The components a declaration's lookups by entity watch: their `changed`, which keep ticks and kernels' stamps while it lives. */ -export const entityWatchesOf = (lookup: { readonly [name: string]: SystemLookup } | undefined): readonly Component.Key[] => { +export const entityWatchesOf = (lookup: { readonly [name: string]: SystemLookupLists } | undefined): readonly Key[] => { if (lookup === undefined) return none; - const keys: Component.Key[] = []; + const keys: Key[] = []; for (const name in lookup) { const declared = lookup[name]; if (indexOfLookup(declared) === undefined) keys.push(...watchedBy(declared)); @@ -41,7 +41,7 @@ export const entityWatchesOf = (lookup: { readonly [name: string]: SystemLookup }; /** Every component the index a declaration orders its rows by names: its key parts, `has` and `not`. */ -export const orderComponentsOf = (declaration: { readonly order?: unknown }): readonly Component.Key[] => { +export const orderComponentsOf = (declaration: { readonly order?: unknown }): readonly Key[] => { const index = indexDefinitionOf(declaration.order); if (index === undefined) return none; const { key, components } = index.declaration; diff --git a/packages/ecs/src/implementation/world/loop.test.ts b/packages/ecs/src/implementation/world/loop.test.ts index e078cc029..f8e37a6ab 100644 --- a/packages/ecs/src/implementation/world/loop.test.ts +++ b/packages/ecs/src/implementation/world/loop.test.ts @@ -1,6 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; -import { Component, System } from "../../index.js"; +import { System } from "../../index.js"; import { requireDefinition, type Composite } from "../../interface/registry/index.js"; import type { Cadence } from "../../interface/resources/cadence.js"; import { backToBack, createLoop, type Clock, type LoopSettings } from "./loop.js"; @@ -15,12 +15,11 @@ declare module "@adobe/ecs" { [count]: { type: number }; } } -Component.register(count, { type: { type: "number" } }); -const increment = requireDefinition(System.create({ name: "loopTest/increment", components: { write: [count] } }, row => { row[count] += 1; })); -const reader = requireDefinition(System.create({ name: "loopTest/reader", components: { read: [count] } }, row => { void row[count]; })); -const ordered = requireDefinition(System.create({ name: "loopTest/ordered", components: { read: [count] }, after: [System.create({ name: "loopTest/phase" }, () => {})] }, row => { void row[count]; })); -const takesInput = requireDefinition(System.create({ name: "loopTest/input", input: { type: "object", properties: { by: { type: "number" } } } }, () => {})); +const increment = requireDefinition(System.create({ components: { with: [count] } }, row => { row[count] += 1; })); +const reader = requireDefinition(System.create({ components: { with: [count] } }, row => { void row[count]; })); +const ordered = requireDefinition(System.create({ components: { with: [count] }, after: [System.create({}, () => {})] }, row => { void row[count]; })); +const takesInput = requireDefinition(System.create({}, (_context, input: { readonly by: number }) => {})); /** A host whose frames and timers run only when the test says, at the times it gives. */ const createHost = (settings: { cadence: Cadence; always: boolean; wake: number }) => { @@ -144,7 +143,7 @@ describe("the loop", () => { const { host } = createHost({ cadence: { kind: "frame" }, always: false, wake: Infinity }); const loop = createLoop(host); loop.add(increment); - expect(() => loop.add(reader)).toThrow('Systems "loopTest/increment" and "loopTest/reader" both access "loopTest/count" and one writes it'); + expect(() => loop.add(reader)).toThrow('Systems "increment" and "reader" both access "loopTest/count" and one writes it'); expect(() => loop.add(takesInput)).toThrow("takes input, which ticks don't pass"); loop.add(increment); expect(() => loop.add(ordered)).toThrow("both access"); @@ -165,7 +164,7 @@ describe("a world's loop platform", () => { it("can be given, so a back-to-back driver ticks as fast as tasks run", async () => { const driver = backToBack(); const world = createWorld({ [always]: true }, { loop: driver }); - world.add(System.create({ name: "loopTest/idle", components: { has: [count] } }, () => {})); + world.add(System.create({ components: { with: [count] } }, () => {})); const start = performance.now(); // At 60 frames a second, a timer's frames would take 1.6 s. await new Promise(resolve => { diff --git a/packages/ecs/src/implementation/world/loop.ts b/packages/ecs/src/implementation/world/loop.ts index 7e1ea679f..c9686b6eb 100644 --- a/packages/ecs/src/implementation/world/loop.ts +++ b/packages/ecs/src/implementation/world/loop.ts @@ -1,7 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { Cadence } from '../../interface/resources/cadence.js'; import type { Composite, Definition } from '../../interface/registry/index.js'; -import { labelOfItem } from '../../interface/registry/index.js'; import { requireDefinition } from '../../interface/registry/program.js'; import { isLeaf, isObserver, scheduleOf } from '../../interface/registry/schedule.js'; import type { Leaf } from '../../interface/system/leaf.js'; @@ -105,7 +104,7 @@ const checkNoInput = (system: Definition, seen: Set = new Set()): vo if (seen.has(system)) return; seen.add(system); if (isLeaf(system)) { - if (system.declaration.input !== undefined) throw new Error(`System "${labelOfItem(system)}" takes input, which ticks don't pass; run it instead.`); + if (system.input !== undefined) throw new Error(`System "${system.name}" takes input, which ticks don't pass; run it instead.`); return; } for (const member of system.declaration.systems) checkNoInput(requireDefinition(member), seen); @@ -248,7 +247,7 @@ export const createLoop = (host: LoopHost): Loop => { } if (systems.includes(system)) return; const next = [...systems, system]; - const candidate: Composite = { kind: "composite", name: "tick", declaration: { name: "tick", systems: next } }; + const candidate: Composite = { kind: "composite", name: "tick", declaration: { systems: next } }; // Throws for members ordered in a cycle, or conflicting with no order between them. scheduleOf(candidate); systems.push(system); @@ -266,7 +265,7 @@ export const createLoop = (host: LoopHost): Loop => { const at = systems.indexOf(system); if (at < 0 || composite === null) return null; const left = systems.filter((_, k) => k !== at); - const next: Composite | null = left.length === 0 ? null : { kind: "composite", name: "tick", declaration: { name: "tick", systems: left } }; + const next: Composite | null = left.length === 0 ? null : { kind: "composite", name: "tick", declaration: { systems: left } }; // Throws for members that conflict with no ordering path left between them. if (next !== null) scheduleOf(next); const previous = composite; diff --git a/packages/ecs/src/implementation/world/mirror.ts b/packages/ecs/src/implementation/world/mirror.ts index 3f7f6c26b..266055f21 100644 --- a/packages/ecs/src/implementation/world/mirror.ts +++ b/packages/ecs/src/implementation/world/mirror.ts @@ -2,7 +2,7 @@ import { indexOf } from './entities.js'; import { cursorField, entitiesWord, recordCount, recordEntities, recordStorages, recordWords, - remoteFlag, storageAdded, storageChanged, storageColumn, storageComponent, storageFlags, storageWords, structureWaitersWord, + remoteFlag, resourceEntityWord, storageAdded, storageChanged, storageColumn, storageComponent, storageFlags, storageWords, structureWaitersWord, structureWord, tableLengthWord, tableOffsetWord, threadShift, tickedFlag, } from './layout-directory.js'; import { @@ -39,6 +39,12 @@ type Staged = { readonly slot: number; readonly entity: number; readonly compone /** Whether replaying is done, waits for a component's key to be named, or found the log lapped or replaced. */ export type Replayed = "current" | "held" | "lapped"; +/** + * Whether rebuilding is done; found a sync point applying before or during it, so it can rebuild once the structure + * epoch is even; or waits for a component's key to be named or a finalizer to be set up. + */ +export type Rebuilt = "rebuilt" | "moved" | "held"; + /** A joined thread's mirror of the world's rows holding its objects. */ export type Mirror = { readonly memory: WebAssembly.Memory; @@ -74,6 +80,8 @@ export type Mirror = { nextSlot: number; /** Times it rebuilt, for tests. */ rebuilds: number; + /** The archetype last found holding the resource entity, or -1. */ + resourceArchetype: number; /** * How many values of components declared `transfer` are staged and not yet settled, so the thread replays to * release those whose commands dropped once it's woken. @@ -97,7 +105,7 @@ export const createMirror = (memory: WebAssembly.Memory, block: number, thread: memory, thread, block, buffer: memory.buffer, words: new Uint32Array(0), signed: new Int32Array(0), header: new Int32Array(0), ring: new Uint32Array(0), ringEntities: new Float64Array(0), version: -1, mask: 0, position: 0, through: 0, tick: 0, keys: new Map(), starts: new Map(), archetypes: [], scratch: new Uint32Array(1024), scratchEntities: new Float64Array(0), - staged: [], head: 0, nextSlot: 0, rebuilds: 0, stagedTransfers: 0, finalize: null, + staged: [], head: 0, nextSlot: 0, rebuilds: 0, resourceArchetype: -1, stagedTransfers: 0, finalize: null, }; refresh(mirror); return mirror; @@ -478,14 +486,14 @@ const previousValue = (old: readonly (Mirrored | null | undefined)[], entity: nu /** * Rebuilds every column from the layout directory, as of the last published sync, under the structure epoch: rows * from each archetype's `entities` and count, values by the tag each row's storage holds, a staged slot's taken, any - * other the thread's own kept by entity, and ticks from the world's. Returns false while a sync point applies, or a - * component it holds has no key named yet, having changed nothing. + * other the thread's own kept by entity, and ticks from the world's. Short of `"rebuilt"`, its columns are as they + * were, though the finalizers of stops through the sync it read may have run first. */ -export const rebuild = (mirror: Mirror): boolean => { +export const rebuild = (mirror: Mirror): Rebuilt => { refresh(mirror); const { words, signed, header, buffer } = mirror; const epoch = Atomics.load(signed, structureWord); - if ((epoch & 1) === 1) return false; + if ((epoch & 1) === 1) return "moved"; const version = Atomics.load(header, logVersionWord); const sync = Atomics.load(header, logSyncWord) >>> 0; const published = Atomics.load(header, logPublishedWord) >>> 0; @@ -500,7 +508,7 @@ export const rebuild = (mirror: Mirror): boolean => { indexed = true; } return previousValue(old, entity, component, start); - })) return false; + })) return "held"; const archetypes: (Mirrored | null)[] = []; /** Staged slots the rebuild takes, settled only once it's complete. */ const taken: number[] = []; @@ -512,7 +520,7 @@ export const rebuild = (mirror: Mirror): boolean => { continue; } const columns = columnsOf(mirror, record); - if (columns === undefined) return false; + if (columns === undefined) return "held"; if (columns === null) { archetypes.push(null); continue; @@ -551,7 +559,7 @@ export const rebuild = (mirror: Mirror): boolean => { archetypes.push(archetype); } // An exchange, so every read above completes before it. - if (Atomics.add(signed, structureWord, 0) !== epoch) return false; + if (Atomics.add(signed, structureWord, 0) !== epoch) return "moved"; for (const slot of taken) { const staged = stagedOf(mirror, slot); if (staged !== undefined) staged.live = false; @@ -564,7 +572,7 @@ export const rebuild = (mirror: Mirror): boolean => { viewRing(mirror); settle(mirror); mirror.rebuilds++; - return true; + return "rebuilt"; }; /** A storage's first word in a record, by its component. */ @@ -576,6 +584,32 @@ const storageOf = (words: Uint32Array, record: number, component: number): numbe return -1; }; +/** + * The value this thread holds for its object resource `component`, as replayed so far, which may be behind the entity + * table; its start while no row holds it. The resource entity is alone in its archetype, so at row 0, and never + * despawned, so its index, which the directory's header holds, names no other entity. + */ +export const resourceValueOf = (mirror: Mirror, component: number, start: unknown): unknown => { + const word = Atomics.load(mirror.words, resourceEntityWord); + if (word === 0) return start; + const index = word - 1; + const { archetypes } = mirror; + let archetype = mirror.resourceArchetype < 0 ? undefined : archetypes[mirror.resourceArchetype]; + if (archetype === null || archetype === undefined || archetype.count === 0 || indexOf(archetype.entities[0]) !== index) { + archetype = undefined; + mirror.resourceArchetype = -1; + for (let id = 0; id < archetypes.length; id++) { + const candidate = archetypes[id]; + if (candidate === null || candidate === undefined || candidate.count === 0 || indexOf(candidate.entities[0]) !== index) continue; + archetype = candidate; + mirror.resourceArchetype = id; + break; + } + } + const column = archetype?.byComponent[component]; + return column === undefined ? start : column.values[0]; +}; + /** A staged slot's value while no sync applied it, else `settled`. */ export const stagedValue = (mirror: Mirror, slot: number, settled: unknown): unknown => { const staged = stagedOf(mirror, slot); diff --git a/packages/ecs/src/implementation/world/move-log.test.ts b/packages/ecs/src/implementation/world/move-log.test.ts index c3131eb7e..469ef580a 100644 --- a/packages/ecs/src/implementation/world/move-log.test.ts +++ b/packages/ecs/src/implementation/world/move-log.test.ts @@ -1,16 +1,21 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; -import { Component } from "../../interface/component/component.js"; import { createBuffer, addCommand, pushCommand, pushComponent, removeCommand, type CommandBuffer } from "./buffer.js"; import { readCell } from "./cells.js"; import { createCommands } from "./commands.js"; import { asEntity, indexOf, locate as locateHere } from "./entities.js"; import { recordCount, structureWord, tableOffsetWord } from "./layout-directory.js"; import { applying, cellOf, createMirror, nameComponent, rebuild, replay, stage, waitStructure, type Mirror } from "./mirror.js"; -import { createMoveLog, ownTag, slotValue, type MoveLog } from "./move-log.js"; +import { createMoveLog, logSyncWord, ownTag, slotValue, type MoveLog } from "./move-log.js"; import { claimThreads } from "./remote.js"; import { createState, defaultOf, idOf, startTicking, type State } from "./state.js"; import { applySync, prepareSync, synchronize } from "./synchronize.js"; +import { createThreadDirectory } from "./thread-directory.js"; +import { createThreadSystems } from "./thread-systems.js"; +import { + completionCapacityWord, completionOffsetWord, payloadCapacityWord, payloadCount, payloadInput, payloadOffsetWord, payloadSequence, payloadSync, ringBaseWord, + ringCapacityWord, taskBlockWords, taskDoneWord, taskPostedWord, +} from "./thread-tasks.js"; // The move log on one thread: a world whose objects of thread "moveLogTest" a mirror fills, against an inline world given // the same commands, compared after every sync point by each entity's id. @@ -29,21 +34,18 @@ declare module "@adobe/ecs" { [uid]: { type: number }; [amount]: { type: number }; [flag]: { type: true }; - [label]: { type: { readonly text: string } | null; thread: "moveLogTest" }; + [label]: { type: Label; thread: "moveLogTest" }; [note]: { type: Note | null; thread: "moveLogTest" }; [own]: { type: string }; } } -Component.register(uid, { type: { type: "number" } }); -Component.register(amount, { type: { type: "number" } }); -Component.register(flag, { type: { const: true } }); /** A value that isn't data. */ type Note = { readonly text: string; readonly read: () => string }; /** The label's start, which a command on another thread than its own gives it by this value. */ -const none = { text: "none" }; -Component.register(label, { type: { anyOf: [{ type: "object", properties: { text: { type: "string" } } }, { type: "null" }], default: none }, thread: "moveLogTest" }); -Component.register(note, { thread: "moveLogTest" }); -Component.register(own, { type: { type: "string" } }); +const none = { text: "none" } as const; +/** A label, which starts as `none`, the value commands on other threads give it by identity. */ +type Label = { readonly text: string } | null; +const Label = { default: none }; const remoteKeys = [label, note] as const; @@ -81,7 +83,7 @@ const inline = (): State => { /** Brings the mirror up to the last sync point, rebuilding when its log lapped it. */ const follow = (mirror: Mirror): void => { const replayed = replay(mirror); - if (replayed === "lapped") expect(rebuild(mirror)).toBe(true); + if (replayed === "lapped") expect(rebuild(mirror)).toBe("rebuilt"); else expect(replayed).toBe("current"); }; @@ -225,6 +227,49 @@ const property = (seed: number, minimum: number, steps: number): { readonly comp return { compared, rebuilds: mirror.rebuilds }; }; +/** + * A thread's systems over a mirror the log lapped, so its next task rebuilds it: `post` posts a task of no systems, + * which runs once the mirror is current, its input by message when `input`; `refreshes` counts its tries. + */ +const lapped = () => { + const { state, mirror, log } = threaded(4); + const { commands, target } = worldCommands(state); + const queue = (): void => { state.queued.push({ buffer: target.buffer, guarded: true }); }; + const entity = commands.spawn([uid, label])({ [uid]: 1 }); + queue(); + synchronize(state); + follow(mirror); + const { memory } = state; + const control = new Int32Array(memory.memory.buffer, memory.allocate(taskBlockWords * 4), taskBlockWords); + const payload = memory.allocate(256); + Atomics.store(control, payloadOffsetWord, payload); + Atomics.store(control, payloadCapacityWord, 256); + Atomics.store(control, completionOffsetWord, memory.allocate(256)); + Atomics.store(control, completionCapacityWord, 256); + Atomics.store(control, ringBaseWord, memory.allocate(1 << 16)); + Atomics.store(control, ringCapacityWord, 1 << 16); + const directory = createThreadDirectory(memory.memory, mirror); + let refreshed = 0; + const counted = { ...directory, refresh: () => { refreshed++; return directory.refresh(); } }; + const systems = createThreadSystems({ + memory: memory.memory, mirror, directory: counted, control, claims: new Set(["moveLogTest"]), + worldWaits: true, post: () => {}, keyNumber: () => 0, numbered: () => 0, + }); + // More entries than the log holds. + for (let k = 0; k < 8; k++) commands.add(entity, amount, k); + queue(); + synchronize(state); + const post = (input: boolean): void => { + const words = new Float64Array(memory.memory.buffer, payload, 32); + words[payloadSequence] = 1; + words[payloadSync] = Atomics.load(log.header, logSyncWord) >>> 0; + words[payloadCount] = 0; + words[payloadInput] = input ? 1 : 0; + Atomics.store(control, taskPostedWord, 1); + }; + return { state, mirror, entity, commands, queue, systems, post, done: () => Atomics.load(control, taskDoneWord) === 1, refreshes: () => refreshed }; +}; + describe("the move log", () => { it("gives a joined thread's columns the inline world's values and ticks after every sync point, replaying its log", () => { for (const seed of [1, 2, 3, 4]) { @@ -324,4 +369,61 @@ describe("the move log", () => { synchronize(state); expect(words[record + recordCount]).toBe(4); }); + it("runs a thread's task once its rebuild failed for a sync point applied during it, with no wake after", async () => { + const { state, mirror, entity, commands, queue, systems, post, done } = lapped(); + // A sync point applies while the thread rebuilds, after it read the structure epoch, and none follows it. + const through = mirror.finalize; + let applied = false; + mirror.finalize = (sync, old) => { + if (!applied) { + applied = true; + commands.add(entity, amount, 9); + queue(); + synchronize(state); + } + return through === null || through(sync, old); + }; + post(false); + expect(systems.process()).toBe(false); + expect(applied).toBe(true); + await new Promise(resolve => setTimeout(resolve, 5)); + expect(done()).toBe(true); + }); + it("runs a thread's task once the sync point its rebuild found applying is applied", async () => { + const { state, mirror, entity, commands, queue, systems, post, done, refreshes } = lapped(); + commands.add(entity, amount, 9); + queue(); + expect(prepareSync(state)).toBe(true); + expect(rebuild(mirror)).toBe("moved"); + post(false); + expect(systems.process()).toBe(false); + const tried = refreshes(); + await new Promise(resolve => setTimeout(resolve, 5)); + expect(done()).toBe(false); + // It waits on the structure epoch, trying nothing until the sync point is applied. + expect(refreshes()).toBe(tried); + applySync(state); + await new Promise(resolve => setTimeout(resolve, 5)); + expect(done()).toBe(true); + }); + it("keeps a thread's event loop while its task waits for its input, after a rebuild outside a task found a sync point applying", async () => { + const { state, mirror, entity, commands, queue, systems, post, done, refreshes } = lapped(); + commands.add(entity, amount, 9); + queue(); + expect(prepareSync(state)).toBe(true); + // Its finalizers replay, and the rebuild finds the sync point applying. + mirror.stagedTransfers = 1; + systems.finalize(); + applySync(state); + // A task whose input is posted after it: nothing runs it until the input arrives. + post(true); + expect(systems.process()).toBe(false); + const before = refreshes(); + for (let k = 0; k < 200; k++) await Promise.resolve(); + const tries = refreshes() - before; + systems.input(1, { given: true }); + expect(done()).toBe(true); + // None while the thread's event loop goes on, so the input's message can arrive. + expect(tries).toBe(0); + }); }); diff --git a/packages/ecs/src/implementation/world/names.test.ts b/packages/ecs/src/implementation/world/names.test.ts index c56cdcd6b..297e0f860 100644 --- a/packages/ecs/src/implementation/world/names.test.ts +++ b/packages/ecs/src/implementation/world/names.test.ts @@ -1,6 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; -import { Component } from "../../interface/component/component.js"; import { Finalizer } from "../../interface/finalizer/finalizer.js"; import { identify } from "../../interface/registry/ids.test-helper.js"; import { workers } from "../../interface/resources/workers.js"; @@ -24,35 +23,36 @@ declare module "@adobe/ecs" { } } -Component.register(value, { type: { type: "number" } }); -Component.register(other, { type: { type: "number" } }); -Component.register(first, { type: { const: true }, derived: true }); -Component.register(second, { type: { const: true }, derived: true }); -// Two libraries, written apart, both name a rule, a finalizer and a system "linked". -Rule.create({ name: "namesTest/linked", components: { has: [value] }, derive: [first] }); -Rule.create({ name: "namesTest/linked", components: { has: [value] }, derive: [second] }); +// Two libraries, written apart, each declare a rule, a finalizer and a system labeled "linked". +const library = (finalizer: (row: { readonly [value]: number }) => void, derived: typeof first | typeof second) => { + Rule.create({ components: { with: [value] }, derive: [derived] }); + // Its body is written in place, so the build reads it, and calls what it's given with the row. + Finalizer.create({ components: { with: [value] } }, row => finalizer(row)); +}; const released: string[] = []; let failing = false; -Finalizer.create({ name: "namesTest/linked", components: { read: [value] } }, row => { released.push(`first ${row[value]}`); }); -Finalizer.create({ name: "namesTest/linked", components: { read: [value] } }, row => { released.push(`second ${row[value]}`); if (failing) throw new Error("second failed"); }); +library(row => { released.push(`first ${row[value]}`); }, first); +library(row => { released.push(`second ${row[value]}`); if (failing) throw new Error("second failed"); }, second); const ran: string[] = []; -const spawn = System.create({ name: "namesTest/spawn" }, ({ commands }) => { commands.spawn([value])({ [value]: 1 }); }); -const despawn = System.create({ name: "namesTest/despawn", components: { has: [value] } }, (row, { commands }) => { commands.despawn(row.entity); }); +const spawn = System.create({}, ({ commands }) => { commands.spawn([value])({ [value]: 1 }); }); +const despawn = System.create({ components: { with: [value] } }, (row, { commands }) => { commands.despawn(row.entity); }); const failOnce = System.create(() => { throw new Error("unnamed"); }); -describe("systems, rules and finalizers of one name", () => { - it("are distinct in schedules, both run, and are told apart in reports by their labels", async () => { - const a = System.create({ name: "namesTest/linked", components: { write: [value] } }, row => { ran.push("a"); row[value] += 1; }); - const b = System.create({ name: "namesTest/linked", components: { write: [value] }, after: [a] }, row => { ran.push("b"); row[value] += 1; throw new Error("b failed"); }); - const c = System.create({ name: "namesTest/linked", components: { read: [value] }, after: [b] }, row => { ran.push(`c${row[value]}`); }); +describe("systems, rules and finalizers of one label", () => { + it("are distinct in schedules, both run, and are told apart in reports by their ids", async () => { + // Its body is written in place, calling what it's given with the row, which the build takes as writing every member. + const linked = (after: readonly System[], body: (row: { [value]: number }) => void): System => System.create({ components: { with: [value] }, after }, row => body(row)); + const a = linked([], row => { ran.push("a"); row[value] += 1; }); + const b = linked([a], row => { ran.push("b"); row[value] += 1; throw new Error("b failed"); }); + const c = linked([b], row => { ran.push(`c${row[value]}`); }); await using world = createWorld({ [workers]: 0 }); await world.run(spawn); - const report = await runReported(world, System.create({ name: "namesTest/frame", systems: [c, b, a] })); + const report = await runReported(world, System.create({ systems: [c, b, a] })); expect(ran).toEqual(["a", "b"]); - expect(report.failures.map(({ kind, name, message }) => ({ kind, name, message }))).toEqual([{ kind: "system", name: "namesTest/linked#6", message: "b failed" }]); - expect(report.skipped).toEqual(["namesTest/linked#7"]); + expect(report.failures.map(({ kind, name, id, message }) => ({ kind, name, id, message }))).toEqual([{ kind: "system", name: "linked", id: null, message: "b failed" }]); + expect(report.skipped).toEqual(["linked"]); }); it("both apply, as rules and as finalizers", async () => { await using world = createWorld({ [workers]: 0 }); @@ -63,19 +63,17 @@ describe("systems, rules and finalizers of one name", () => { const report = await runReported(world, despawn); failing = false; expect(released).toEqual(["first 1", "second 1"]); - expect(report.failures.map(({ kind, name }) => ({ kind, name }))).toEqual([{ kind: "finalizer", name: "namesTest/linked#4" }]); + expect(report.failures.map(({ kind, name }) => ({ kind, name }))).toEqual([{ kind: "finalizer", name: "library" }]); }); - it("are labeled by name and id where the plugin gave ids", async () => { - const write = (id: string) => System.create(identify({ name: "namesTest/library", components: { write: [other] } }, id), row => { row[other] = 1; throw new Error(id); }); - const one = write("one.ts#library"); - const two = write("two.ts#library"); + it("are labeled by the bindings of their ids, which reports give beside them", async () => { + const [one, two] = ["one.ts#library", "two.ts#library"].map(id => System.create(identify({ components: { with: [other] } }, id), row => { row[other] = 1; throw new Error(id); })); await using world = createWorld({ [workers]: 0 }); - await world.run(System.create({ name: "namesTest/spawnOther" }, ({ commands }) => { commands.spawn([other])({ [other]: 0 }); })); - const names = async (system: System) => (await runReported(world, system)).failures.map(failure => failure.name); - expect([await names(one), await names(two)]).toEqual([["namesTest/library (one.ts#library)"], ["namesTest/library (two.ts#library)"]]); + await world.run(System.create({}, ({ commands }) => { commands.spawn([other])({ [other]: 0 }); })); + const labels = async (system: System | undefined) => system === undefined ? [] : (await runReported(world, system)).failures.map(failure => [failure.name, failure.id]); + expect([await labels(one), await labels(two)]).toEqual([[["library", "one.ts#library"]], [["library", "two.ts#library"]]]); }); - it("label a system without a name by its binding and the id the plugin gave it", async () => { + it("label a top-level system by its binding, its id naming its package, version, module and binding", async () => { await using world = createWorld({ [workers]: 0 }); - expect((await runReported(world, failOnce)).failures.map(failure => failure.name)).toEqual(["failOnce (@adobe/ecs/src/implementation/world/names.test.ts#failOnce)"]); + expect((await runReported(world, failOnce)).failures.map(failure => [failure.name, failure.id])).toEqual([["failOnce", expect.stringMatching(/^@adobe\/ecs@[^/]+\/src\/implementation\/world\/names\.test\.ts#failOnce$/)]]); }); }); diff --git a/packages/ecs/src/implementation/world/observe-cases.browser.test.ts b/packages/ecs/src/implementation/world/observe-cases.browser.test.ts index 37aed0d06..8d2bd37ff 100644 --- a/packages/ecs/src/implementation/world/observe-cases.browser.test.ts +++ b/packages/ecs/src/implementation/world/observe-cases.browser.test.ts @@ -1,6 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; -import { Component } from "../../interface/component/component.js"; import type { Entity } from "../../interface/entity/entity.js"; import { System } from "../../interface/system/system.js"; import { createWorld } from "./create.js"; @@ -8,6 +7,7 @@ import { gpuDevice } from "../../interface/resources/gpu-device.js"; import { workers } from "../../interface/resources/workers.js"; import { Observe } from "../../observe/index.js"; import { settle } from "./observe.test-helper.js"; +import type { F32 } from "../../interface/math/index.js"; // The observer factories of `src/observe` over values GPU kernels wrote. @@ -18,24 +18,22 @@ const rate = Symbol("observeCasesGpuTest/rate"); declare module "@adobe/ecs" { export interface Components { - [heat]: { type: number }; - [rate]: { type: number }; + [heat]: { type: F32 }; + [rate]: { type: F32 }; } } -Component.register(heat, { type: { type: "number", precision: 1 } }); -Component.register(rate, { type: { type: "number", precision: 1 } }); let spawned: Entity[] = []; -const spawn = System.create({ name: "observeCasesGpuTest/spawn" }, ({ commands }) => { +const spawn = System.create({}, ({ commands }) => { spawned = []; for (let i = 0; i < 100; i++) spawned.push(commands.spawn([heat, rate])({ [heat]: i, [rate]: 1 })); }); -const warm = System.create({ name: "observeCasesGpuTest/warm", kernel: "gpu", components: { read: [rate], write: [heat] } }, row => { +const warm = System.create({ kernel: "gpu", components: { with: [rate, heat] } }, row => { row[heat] = row[heat] + row[rate] * 100; }); -const idle = System.create({ name: "observeCasesGpuTest/idle" }, () => {}); +const idle = System.create({}, () => {}); const device = async (): Promise => { const adapter = await navigator.gpu.requestAdapter(); diff --git a/packages/ecs/src/implementation/world/observe-cases.test.ts b/packages/ecs/src/implementation/world/observe-cases.test.ts index 0bf0806ff..8c081fdca 100644 --- a/packages/ecs/src/implementation/world/observe-cases.test.ts +++ b/packages/ecs/src/implementation/world/observe-cases.test.ts @@ -1,8 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { afterEach, describe, expect, it, vi } from "vitest"; -import { Component } from "../../interface/component/component.js"; import { Entity } from "../../interface/entity/entity.js"; -import { Resource } from "../../interface/resource/resource.js"; import { workers } from "../../interface/resources/workers.js"; import { System } from "../../interface/system/system.js"; import { createWorld } from "../index.js"; @@ -11,6 +9,7 @@ import { settle } from "./observe.test-helper.js"; import { fillReports, runReported, runThenNext } from "./reports.test-helper.js"; import { retainedRuns } from "./reports.js"; import { Report } from "../../interface/report/report.js"; +import type { F32 } from "../../interface/math/index.js"; // The observer factories of `src/observe`. Each case subscribes, settles, then expects, so it holds whenever a first // value comes. @@ -31,19 +30,15 @@ declare module "@adobe/ecs" { } } -Resource.register(level, { type: { type: "number", default: 0 } }); -Resource.register(other, { type: { type: "number", default: 0 } }); -Resource.register(total, { type: { type: "number", default: 0 }, reduce: "+" }); -Component.register(weight, { type: { type: "number" } }); -const raise = System.create({ name: "observeCasesTest/raise", input: { type: "object", properties: { by: { type: "number" } } }, resources: { write: [level] } }, ({ input, resources }) => { +const raise = System.create({}, ({ resources }, input: { readonly by: number }) => { resources[level] += input.by; resources[level] += input.by; }); -const touchOther = System.create({ name: "observeCasesTest/touchOther", resources: { write: [other] } }, ({ resources }) => { resources[other] += 1; }); -const spawn = System.create({ name: "observeCasesTest/spawn" }, ({ commands }) => { commands.spawn([weight])({ [weight]: 2 }); }); -const sum = System.create({ name: "observeCasesTest/sum", components: { read: [weight] }, reduce: { [total]: "+" } }, (row, { reduce }) => { reduce[total](row[weight]); }); -const keep = System.create({ name: "observeCasesTest/keep", resources: { write: [level] } }, ({ resources }) => { resources[level] = resources[level] + 0; }); +const touchOther = System.create({}, ({ resources }) => { resources[other] += 1; }); +const spawn = System.create({}, ({ commands }) => { commands.spawn([weight])({ [weight]: 2 }); }); +const sum = System.create({ components: { with: [weight] } }, (row, { reduce }) => { reduce[total](row[weight]); }); +const keep = System.create({}, ({ resources }) => { resources[level] = resources[level] + 0; }); /** Errors thrown out of band while `catching` runs, as uncaught errors would be, caught instead. */ const catching = async (run: () => Promise): Promise => { @@ -82,25 +77,21 @@ declare module "@adobe/ecs" { } } -Component.register(heat, { type: { type: "number" } }); -Component.register(tag, { type: { const: true } }); -Component.register(flag, { type: { const: true } }); -Component.register(filler, { type: { type: "number" } }); /** The entity the systems below act on, set by `create`. */ let target = Entity.none; -const create = System.create({ name: "observeCasesTest/create" }, ({ commands }) => { target = commands.spawn([weight, heat])({ [weight]: 1, [heat]: 0 }); }); -const despawn = System.create({ name: "observeCasesTest/despawn" }, ({ commands }) => { commands.despawn(target); }); -const weigh = System.create({ name: "observeCasesTest/weigh", components: { write: [weight], not: [filler] } }, row => { +const create = System.create({}, ({ commands }) => { target = commands.spawn([weight, heat])({ [weight]: 1, [heat]: 0 }); }); +const despawn = System.create({}, ({ commands }) => { commands.despawn(target); }); +const weigh = System.create({ components: { with: [weight], not: [filler] } }, row => { row[weight] += 1; row[weight] += 1; }); -const warm = System.create({ name: "observeCasesTest/warm", components: { write: [heat] } }, row => { row[heat] += 1; }); -const double = System.create({ name: "observeCasesTest/double", kernel: "cpu", components: { write: [heat] } }, row => { row[heat] = row[heat] * 2 + 1; }); -const addTag = System.create({ name: "observeCasesTest/addTag" }, ({ commands }) => { commands.add(target, tag, true); }); -const removeTag = System.create({ name: "observeCasesTest/removeTag" }, ({ commands }) => { commands.remove(target, tag); }); -const removeWeight = System.create({ name: "observeCasesTest/removeWeight" }, ({ commands }) => { commands.remove(target, weight); }); -const addFlag = System.create({ name: "observeCasesTest/addFlag" }, ({ commands }) => { commands.add(target, flag, true); }); +const warm = System.create({ components: { with: [heat] } }, row => { row[heat] += 1; }); +const double = System.create({ kernel: true, components: { with: [heat] } }, row => { row[heat] = row[heat] * 2 + 1; }); +const addTag = System.create({}, ({ commands }) => { commands.add(target, tag, true); }); +const removeTag = System.create({}, ({ commands }) => { commands.remove(target, tag); }); +const removeWeight = System.create({}, ({ commands }) => { commands.remove(target, weight); }); +const addFlag = System.create({}, ({ commands }) => { commands.add(target, flag, true); }); const score = Symbol("observeCasesTest/score"); const benched = Symbol("observeCasesTest/benched"); @@ -114,36 +105,33 @@ declare module "@adobe/ecs" { } } -Component.register(score, { type: { type: "number" } }); -Component.register(benched, { type: { const: true } }); -Component.register(marker, { type: { const: true } }); /** Players the systems below act on, in spawn order, set by `players`. */ let roster: Entity[] = []; -const players = System.create({ name: "observeCasesTest/players" }, ({ commands }) => { +const players = System.create({}, ({ commands }) => { roster = [commands.spawn([score])({ [score]: 1 }), commands.spawn([score])({ [score]: 2 }), commands.spawn([score, benched])({ [score]: 3, [benched]: true })]; }); -const join = System.create({ name: "observeCasesTest/join" }, ({ commands }) => { roster.push(commands.spawn([score])({ [score]: 0 })); }); -const bench = System.create({ name: "observeCasesTest/bench" }, ({ commands }) => { commands.add(roster[0], benched, true); }); +const join = System.create({}, ({ commands }) => { roster.push(commands.spawn([score])({ [score]: 0 })); }); +const bench = System.create({}, ({ commands }) => { commands.add(roster[0], benched, true); }); /** The first player leaves the bench as the last joins it, so as many match as before. */ -const swap = System.create({ name: "observeCasesTest/swap" }, ({ commands }) => { +const swap = System.create({}, ({ commands }) => { commands.add(roster[0], benched, true); commands.remove(roster[2], benched); }); -const mark = System.create({ name: "observeCasesTest/mark" }, ({ commands }) => { commands.add(roster[0], marker, true); }); -const leave = System.create({ name: "observeCasesTest/leave" }, ({ commands }) => { commands.despawn(roster[1]); }); -const score10 = System.create({ name: "observeCasesTest/score10", components: { write: [score] } }, row => { row[score] += 10; }); -const nudge = System.create({ name: "observeCasesTest/nudge", components: { write: [score] } }, row => { row[score] += 0; }); -const invert = System.create({ name: "observeCasesTest/invert", components: { write: [score] } }, row => { row[score] = -row[score]; }); -const invertKernel = System.create({ name: "observeCasesTest/invertKernel", kernel: "cpu", components: { write: [score] } }, row => { row[score] = 0 - row[score]; }); +const mark = System.create({}, ({ commands }) => { commands.add(roster[0], marker, true); }); +const leave = System.create({}, ({ commands }) => { commands.despawn(roster[1]); }); +const score10 = System.create({ components: { with: [score] } }, row => { row[score] += 10; }); +const nudge = System.create({ components: { with: [score] } }, row => { row[score] += 0; }); +const invert = System.create({ components: { with: [score] } }, row => { row[score] = -row[score]; }); +const invertKernel = System.create({ kernel: true, components: { with: [score] } }, row => { row[score] = 0 - row[score]; }); /** Swaps the bench like `swap`, then despawns more entities than the journal holds, so it drops the swap's entries. */ -const swapAndCrowd = System.create({ name: "observeCasesTest/swapAndCrowd" }, ({ commands }) => { +const swapAndCrowd = System.create({}, ({ commands }) => { commands.add(roster[0], benched, true); commands.remove(roster[2], benched); for (const spawned of crowded.splice(0)) commands.despawn(spawned); }); const crowded: Entity[] = []; -const crowdUp = System.create({ name: "observeCasesTest/crowdUp" }, ({ commands }) => { +const crowdUp = System.create({}, ({ commands }) => { for (let i = 0; i < 5000; i++) crowded.push(commands.spawn([filler])({ [filler]: i })); }); @@ -157,22 +145,19 @@ declare module "@adobe/ecs" { } } -Component.register(rank, { type: { type: "number" } }); -Component.register(second, { type: { type: "number" } }); const coarse = Symbol("observeCasesTest/coarse"); declare module "@adobe/ecs" { interface Components { - [coarse]: { type: number }; + [coarse]: { type: F32 }; } } // Kept at 32 bits, so a 64-bit bound may fall between two values it keeps. -Component.register(coarse, { type: { type: "number", precision: 1 } }); let coarseValues: readonly number[] = []; -const spawnCoarse = System.create({ name: "observeCasesTest/spawnCoarse" }, ({ commands }) => { +const spawnCoarse = System.create({}, ({ commands }) => { for (const each of coarseValues) commands.spawn([coarse])({ [coarse]: each }); }); -const shiftCoarse = System.create({ name: "observeCasesTest/shiftCoarse", components: { write: [coarse] } }, row => { row[coarse] = row[coarse] + 0.1; }); +const shiftCoarse = System.create({ components: { with: [coarse] } }, row => { row[coarse] = row[coarse] + 0.1; }); type Condition = number | { readonly "<"?: number; readonly ">"?: number; readonly "<="?: number; readonly ">="?: number; readonly "=="?: number; readonly "!="?: number }; @@ -184,12 +169,12 @@ const passesCondition = (value: number, condition: Condition): boolean => typeof let ranks: readonly (readonly [number, number, boolean])[] = []; /** The entities `rankAll` spawned, in order. */ let ranked: Entity[] = []; -const rankAll = System.create({ name: "observeCasesTest/rankAll" }, ({ commands }) => { +const rankAll = System.create({}, ({ commands }) => { ranked = ranks.map(([value, key, marked]) => commands.spawn(marked ? [rank, second, marker] : [rank, second])({ [rank]: value, [second]: key })); }); /** What `rerank` sets: an index into `ranked` and its new rank. */ let reranks: readonly (readonly [number, number])[] = []; -const rerank = System.create({ name: "observeCasesTest/rerank" }, ({ commands }) => { +const rerank = System.create({}, ({ commands }) => { for (const [index, value] of reranks) commands.add(ranked[index], rank, value); }); @@ -459,11 +444,11 @@ describe("observing entities", () => { await using world = createWorld(); await world.run(create); const seen: unknown[] = []; - const subscribe = System.create({ name: "observeCasesTest/subscribeEntity" }, () => { + const subscribe = System.create({}, () => { entity(world, target, { required: [weight] })(value => seen.push(value)); }); - const later = System.create({ name: "observeCasesTest/laterWeigh", components: { write: [weight] }, after: [subscribe] }, row => { row[weight] += 2; }); - await world.run(System.create({ name: "observeCasesTest/subscribeThenWeigh", systems: [subscribe, later] })); + const later = System.create({ components: { with: [weight] }, after: [subscribe] }, row => { row[weight] += 2; }); + await world.run(System.create({ systems: [subscribe, later] })); await settle(world); await world.run(warm); expect(seen).toEqual([{ [weight]: 3 }]); @@ -592,11 +577,11 @@ describe("observing queries", () => { await using world = createWorld(); await world.run(players); const seen: number[] = []; - const subscribe = System.create({ name: "observeCasesTest/subscribe" }, () => { + const subscribe = System.create({}, () => { count(world, { required: [score], where: { [score]: { "<": 0 } } })(value => seen.push(value)); }); - const later = System.create({ name: "observeCasesTest/later", components: { write: [score] }, after: [subscribe] }, row => { row[score] = -row[score]; }); - await world.run(System.create({ name: "observeCasesTest/subscribeThenWrite", systems: [subscribe, later] })); + const later = System.create({ components: { with: [score] }, after: [subscribe] }, row => { row[score] = -row[score]; }); + await world.run(System.create({ systems: [subscribe, later] })); await settle(world); expect(seen).toEqual([3]); }); @@ -774,8 +759,8 @@ describe("observing a page of a query", () => { describe("the factories' own runs", () => { it("rethrow their systems' failures out of band, from their reports, once a later run of theirs read them", async () => { await using world = createWorld({ [workers]: 0 }); - const fail = System.create({ name: "observeCasesTest/failRun" }, () => { throw new Error("run failed"); }); - const nothing = System.create({ name: "observeCasesTest/nothing" }, () => {}); + const fail = System.create({}, () => { throw new Error("run failed"); }); + const nothing = System.create({}, () => {}); let reports = -1; const caught = await catching(async () => { // Its notifier throws once the failed run's report loses its errors, which a run of the factories' own does diff --git a/packages/ecs/src/implementation/world/observe-factories.test.ts b/packages/ecs/src/implementation/world/observe-factories.test.ts index 4c8c1b702..2c5fde6c7 100644 --- a/packages/ecs/src/implementation/world/observe-factories.test.ts +++ b/packages/ecs/src/implementation/world/observe-factories.test.ts @@ -1,8 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it, vi } from "vitest"; -import { Component } from "../../interface/component/component.js"; import type { Entity } from "../../interface/entity/entity.js"; -import { Resource } from "../../interface/resource/resource.js"; import { workers } from "../../interface/resources/workers.js"; import { System } from "../../interface/system/system.js"; import { Observe } from "../../observe/index.js"; @@ -27,22 +25,19 @@ declare module "@adobe/ecs" { [level]: { type: number }; } } -Component.register(amount, { type: { type: "number" } }); -Component.register(other, { type: { type: "number" } }); -Resource.register(level, { type: { type: "number", default: 0 } }); let spawned: Entity[] = []; -const seed = System.create({ name: "observeFactoriesTest/seed", input: { type: "object", properties: { count: { type: "integer" } } } }, ({ input, commands }) => { +const seed = System.create({}, ({ commands }, input: { readonly count: number }) => { spawned = []; for (let i = 0; i < input.count; i++) spawned.push(commands.spawn([amount])({ [amount]: i })); }); -const grow = System.create({ name: "observeFactoriesTest/grow", components: { write: [amount] } }, row => { row[amount] += 1; }); -const raise = System.create({ name: "observeFactoriesTest/raise", resources: { write: [level] } }, ({ resources }) => { resources[level] += 1; }); -const nothing = System.create({ name: "observeFactoriesTest/nothing" }, () => {}); +const grow = System.create({ components: { with: [amount] } }, row => { row[amount] += 1; }); +const raise = System.create({}, ({ resources }) => { resources[level] += 1; }); +const nothing = System.create({}, () => {}); /** Counts passes: the observer passes after each run's sync point. */ const passes = { count: 0 }; -const counter = System.create({ name: "observeFactoriesTest/counter", observer: true }, () => { passes.count++; }); +const counter = System.create({ observer: true }, () => { passes.count++; }); /** * A world of `count` entities, each amount its index and one more, at level 1: the runs below registered, so runners @@ -120,7 +115,7 @@ describe("observer factories", () => { const stop = Observe.entity(world, spawned[0], { required: [amount] })(value => entities.push(value?.[amount])); await settle(world); await world.run(grow); - await world.run(System.create({ name: "observeFactoriesTest/growOthers", components: { write: [amount] } }, row => { + await world.run(System.create({ components: { with: [amount] } }, row => { if (row.entity !== spawned[0]) row[amount] += 1; })); expect(entities).toEqual([4, 5]); @@ -175,7 +170,7 @@ describe("observer factories", () => { it("read only what they list, so a write elsewhere notifies nothing", async () => { await using world = await seeded(3); - const writeOther = System.create({ name: "observeFactoriesTest/writeOther", components: { write: [other] } }, row => { row[other] = 1; }); + const writeOther = System.create({ components: { with: [other] } }, row => { row[other] = 1; }); let notified = 0; Observe.entity(world, spawned[0], { required: [amount] })(() => notified++); Observe.select(world, { required: [amount], order: { [amount]: false }, page: { offset: 0, limit: 2 } })(() => notified++); @@ -188,7 +183,7 @@ describe("observer factories", () => { it("rethrow only their own systems' failures from their runs, not those of observer systems the world added", async () => { await using world = await seeded(3); - const failing = System.create({ name: "observeFactoriesTest/failing", observer: true }, () => { throw new Error("added failed"); }); + const failing = System.create({ observer: true }, () => { throw new Error("added failed"); }); world.add(failing); const caught: unknown[] = []; const original = globalThis.queueMicrotask; @@ -211,7 +206,7 @@ describe("observer factories", () => { } expect(caught).toEqual([]); // The world's runs still report it. - expect((await runReported(world, nothing)).failures.map(failure => failure.name)).toEqual(["observeFactoriesTest/failing"]); + expect((await runReported(world, nothing)).failures.map(failure => failure.name)).toEqual(["failing"]); }); }); diff --git a/packages/ecs/src/implementation/world/observe-joined-cases.test.ts b/packages/ecs/src/implementation/world/observe-joined-cases.test.ts index f790f2e4a..21c78a66a 100644 --- a/packages/ecs/src/implementation/world/observe-joined-cases.test.ts +++ b/packages/ecs/src/implementation/world/observe-joined-cases.test.ts @@ -1,8 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it, vi } from "vitest"; -import { Component } from "../../interface/component/component.js"; import { Entity } from "../../interface/entity/entity.js"; -import { Resource } from "../../interface/resource/resource.js"; import { workers } from "../../interface/resources/workers.js"; import { System } from "../../interface/system/system.js"; import type { World } from "../../interface/world/world.js"; @@ -31,39 +29,33 @@ declare module "@adobe/ecs" { [label]: { type: string }; [marked]: { type: true }; [place]: { type: number }; - [caption]: { type: { readonly text: string } | null; thread: "observeJoinedTest" }; + [caption]: { type: Caption; thread: "observeJoinedTest" }; [widget]: { type: { readonly draw: () => string } | null; thread: "observeJoinedTest" }; [kept]: { type: string }; } export interface Resources { [total]: { type: number }; - [names]: { type: readonly string[] }; + [names]: { type: readonly string[]; default: [] }; } } -Component.register(point, { type: { type: "array", items: { type: "number" }, minItems: 2, maxItems: 2 } }); -Component.register(label, { type: { type: "string" } }); -Component.register(marked, { type: { const: true } }); -Component.register(place, { type: { type: "number" } }); /** The caption's start, which a command here gives it by this value. */ -const none = { text: "none" }; -Component.register(caption, { type: { anyOf: [{ type: "object", properties: { text: { type: "string" } } }, { type: "null" }], default: none }, thread: "observeJoinedTest" }); -Component.register(widget, { thread: "observeJoinedTest" }); -Component.register(kept, { type: { type: "string" } }); -Resource.register(total, { type: { type: "number", default: 0 } }); -Resource.register(names, { type: { type: "array", items: { type: "string" }, default: [] } }); +const none = { text: "none" } as const; +/** A caption, which starts as `none`, the value commands on other threads give it by identity. */ +type Caption = { readonly text: string } | null; +const Caption = { default: none }; -const add = System.create({ name: "observeJoinedTest/add", input: { type: "object", properties: { amount: { type: "number" } } }, resources: { write: [total] } }, ({ input, resources }) => { +const add = System.create({}, ({ resources }, input: { readonly amount: number }) => { resources[total] = resources[total] * 10 + input.amount; }); -const spawn = System.create({ name: "observeJoinedTest/spawn", input: { type: "object", properties: { x: { type: "number" } } } }, ({ input, commands }) => { +const spawn = System.create({}, ({ commands }, input: { readonly x: number }) => { commands.spawn([point, label, marked])({ [point]: [input.x, -input.x], [label]: `p${input.x}` }); }); -const spawnPlaced = System.create({ name: "observeJoinedTest/spawnPlaced", input: { type: "object", properties: { x: { type: "number" } } } }, ({ input, commands }) => { +const spawnPlaced = System.create({}, ({ commands }, input: { readonly x: number }) => { commands.spawn([place, caption, widget, kept])({ [place]: input.x, [widget]: null, [kept]: `k${input.x}` }); }); -const nullCaptions = System.create({ name: "observeJoinedTest/nullCaptions", components: { has: [caption, place] } }, (row, { commands }) => { commands.add(row.entity, caption, null); }); -const restartCaptions = System.create({ name: "observeJoinedTest/restartCaptions", components: { has: [caption, place] } }, (row, { commands }) => { commands.add(row.entity, caption, none); }); -const dropCaptions = System.create({ name: "observeJoinedTest/dropCaptions", components: { has: [caption] } }, (row, { commands }) => { commands.remove(row.entity, caption); }); +const nullCaptions = System.create({ components: { with: [caption, place] } }, (row, { commands }) => { commands.add(row.entity, caption, null); }); +const restartCaptions = System.create({ components: { with: [caption, place] } }, (row, { commands }) => { commands.add(row.entity, caption, none); }); +const dropCaptions = System.create({ components: { with: [caption] } }, (row, { commands }) => { commands.remove(row.entity, caption); }); /** A world shared with a thread joined through a channel claiming `threads`, and the joined world. */ const joined = (threads: readonly "observeJoinedTest"[] = []): { readonly world: CreatedWorld; readonly remote: World } => { @@ -221,9 +213,11 @@ describe("observer factories on a joined thread", () => { await new Promise(resolve => setTimeout(resolve, 10)); for (const run of runs) for (const failure of (await reportOf(world, run)).failures) reported.push(failure.message); }); - // The entity observer's system, added, fails in a run's report; the selection's scan, run, rejects, rethrown. + // The entity observer's system, added, fails in a run's report, which the factories, whose system it is, rethrow; + // the selection's scan, run, rejects, rethrown. expect([...reported, ...caught.map(String)]).toEqual([ expect.stringMatching(/^System "Observe\.entity\(observeJoinedTest\/caption\).*runs on thread "observeJoinedTest", which a joined thread fills, and has no id/), + expect.stringMatching(/^Error: Observe\.entity\(observeJoinedTest\/caption\): System "Observe\.entity\(observeJoinedTest\/caption\)".*has no id/), expect.stringMatching(/System "Observe\.select\(observeJoinedTest\/place, observeJoinedTest\/caption\).*runs on thread "observeJoinedTest", which a joined thread fills, and has no id/), ]); expect(values).toEqual([]); diff --git a/packages/ecs/src/implementation/world/observe-ticks-cases.test.ts b/packages/ecs/src/implementation/world/observe-ticks-cases.test.ts index 40a9208cb..6db773cf5 100644 --- a/packages/ecs/src/implementation/world/observe-ticks-cases.test.ts +++ b/packages/ecs/src/implementation/world/observe-ticks-cases.test.ts @@ -1,6 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; -import { Component, Resource, System, always, cadence, clock, wake, workers } from "../../index.js"; +import { always, cadence, clock, type F32, System, wake, workers } from "../../index.js"; import type { World } from "../../interface/world/world.js"; import { createWorld } from "./create.js"; import { Observe } from "../../observe/index.js"; @@ -16,52 +16,47 @@ const flag = Symbol("observeTicksCasesTest/flag"); declare module "@adobe/ecs" { interface Resources { [frames]: { type: number }; - [seen]: { type: number }; + [seen]: { type: number; default: -1 }; } interface Components { - [value]: { type: number }; - [watched]: { type: number }; + [value]: { type: F32 }; + [watched]: { type: F32 }; [flag]: { type: true }; } } -Resource.register(frames, { type: { type: "number", default: 0 } }); -Resource.register(seen, { type: { type: "number", default: -1 } }); -Component.register(value, { type: { type: "number", precision: 1 } }); -Component.register(watched, { type: { type: "number", precision: 1 } }); -Component.register(flag, { type: { const: true } }); - -const count = System.create({ name: "observeTicksCasesTest/count", resources: { write: [frames] } }, ({ resources }) => { resources[frames] += 1; }); -const watch = System.create({ name: "observeTicksCasesTest/watch", resources: { read: [clock], write: [seen] } }, ({ resources }) => { resources[seen] = resources[clock].tick; }); -const fail = System.create({ name: "observeTicksCasesTest/fail" }, () => { throw new Error("tick failed"); }); -const nothing = System.create({ name: "observeTicksCasesTest/nothing" }, () => {}); -const idle = System.create({ name: "observeTicksCasesTest/idle", observer: true }, () => {}); -const fast = System.create({ name: "observeTicksCasesTest/fast", resources: { write: [cadence, always] } }, ({ resources }) => { + +const count = System.create({}, ({ resources }) => { resources[frames] += 1; }); +const watch = System.create({}, ({ resources }) => { resources[seen] = resources[clock].tick; }); +const fail = System.create({}, () => { throw new Error("tick failed"); }); +const nothing = System.create({}, () => {}); +const idle = System.create({ observer: true }, () => {}); +const fast = System.create({}, ({ resources }) => { resources[cadence] = { kind: "fixed", step: 1 }; resources[always] = true; }); -const calm = System.create({ name: "observeTicksCasesTest/calm", resources: { write: [always] } }, ({ resources }) => { resources[always] = false; }); -const later = System.create({ name: "observeTicksCasesTest/later", resources: { read: [clock] }, reduce: { [wake]: "min" } }, ({ resources, reduce }) => { +const calm = System.create({}, ({ resources }) => { resources[always] = false; }); +const later = System.create({}, ({ resources, reduce }) => { if (resources[clock].tick === 0) reduce[wake](resources[clock].now + 20); }); -const seed = System.create({ name: "observeTicksCasesTest/seed" }, ({ commands }) => { +const seed = System.create({}, ({ commands }) => { for (let i = 0; i < 3000; i++) commands.spawn([value, watched])({ [value]: i, [watched]: 0 }); }); /** Writes every value: until something observes `value`, its stamps are muted. */ -const bump = System.create({ name: "observeTicksCasesTest/bump", kernel: "cpu", components: { write: [value] } }, row => { +const bump = System.create({ kernel: true, components: { with: [value] } }, row => { row[value] = row[value] + 1; }); -const watchChanged = System.create({ name: "observeTicksCasesTest/watchChanged", kernel: "cpu", components: { write: [watched], changed: [value] }, after: [bump] }, row => { +const watchChanged = System.create({ kernel: true, components: { with: [watched], changed: [value] }, after: [bump] }, row => { row[watched] = row[watched] + 1; }); /** Counts rows whose `value` was added since it last ran. */ -const fresh = System.create({ name: "observeTicksCasesTest/fresh", components: { write: [watched], added: [value] } }, row => { +const fresh = System.create({ components: { with: [watched], added: [value] } }, row => { row[watched] = row[watched] + 1; }); -const flagAll = System.create({ name: "observeTicksCasesTest/flagAll", components: { has: [value] } }, (row, { commands }) => { +const flagAll = System.create({ components: { with: [value] } }, (row, { commands }) => { commands.add(row.entity, flag, true); }); -const spawnOne = System.create({ name: "observeTicksCasesTest/spawnOne" }, ({ commands }) => { +const spawnOne = System.create({}, ({ commands }) => { commands.spawn([value, watched])({ [value]: -1, [watched]: 0 }); }); @@ -146,7 +141,7 @@ describe("ticks", () => { world.add(fail); await world.run(nothing); const last = await reports.until(next => next.failures.length > 0); - expect(last.failures.map(({ kind, name, message }) => [kind, name, message])).toEqual([["system", "observeTicksCasesTest/fail", "tick failed"]]); + expect(last.failures.map(({ kind, name, message }) => [kind, name, message])).toEqual([["system", "fail", "tick failed"]]); }); it("put what an observer throws on a tick's values in a later run's report, once", async () => { diff --git a/packages/ecs/src/implementation/world/observe.test-helper.ts b/packages/ecs/src/implementation/world/observe.test-helper.ts index 4c4e71a50..585c0df34 100644 --- a/packages/ecs/src/implementation/world/observe.test-helper.ts +++ b/packages/ecs/src/implementation/world/observe.test-helper.ts @@ -2,7 +2,7 @@ import { System } from "../../interface/system/system.js"; import type { World } from "../../interface/world/world.js"; -const settled = System.create({ name: "observeTest/settled", observer: true }, () => {}); +const settled = System.create({ observer: true }, () => {}); /** * Resolves once every observer factory subscribed before it had its first value: after the factories' flush, which the diff --git a/packages/ecs/src/implementation/world/observed-workers.fixture.ts b/packages/ecs/src/implementation/world/observed-workers.fixture.ts index f27c2fa45..ed86b7b67 100644 --- a/packages/ecs/src/implementation/world/observed-workers.fixture.ts +++ b/packages/ecs/src/implementation/world/observed-workers.fixture.ts @@ -1,9 +1,9 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Component } from "../../interface/component/component.js"; import { System } from "../../interface/system/system.js"; import { readEach, select } from "./reads.test-helper.js"; import { createWorld } from "./create.js"; import { workers } from "../../interface/resources/workers.js"; +import type { F32 } from "../../interface/math/index.js"; /** Scenarios of tick stamps on workers, run built in node and from source in chromium. */ @@ -12,24 +12,22 @@ const seen = Symbol("observedWorkersTest/seen"); declare module "@adobe/ecs" { export interface Components { - [value]: { type: number }; - [seen]: { type: number }; + [value]: { type: F32 }; + [seen]: { type: F32 }; } } -Component.register(value, { type: { type: "number", precision: 1 } }); -Component.register(seen, { type: { type: "number", precision: 1 } }); -const seed = System.create({ name: "observedWorkersTest/seed" }, ({ commands }) => { +const seed = System.create({}, ({ commands }) => { for (let i = 0; i < 3000; i++) commands.spawn([value, seen])({ [value]: i, [seen]: 0 }); }); /** Writes every value: until something observes `value`, its stamps are muted. */ -const bump = System.create({ name: "observedWorkersTest/bump", kernel: "cpu", components: { write: [value] } }, row => { +const bump = System.create({ kernel: true, components: { with: [value] } }, row => { row[value] = row[value] + 1; }); -const watch = System.create({ name: "observedWorkersTest/watch", kernel: "cpu", components: { write: [seen], changed: [value] }, after: [bump] }, row => { +const watch = System.create({ kernel: true, components: { with: [seen], changed: [value] }, after: [bump] }, row => { row[seen] = row[seen] + 1; }); diff --git a/packages/ecs/src/implementation/world/observer-contained-worker.fixture.ts b/packages/ecs/src/implementation/world/observer-contained-worker.fixture.ts index 0fe9ca948..76728d2a6 100644 --- a/packages/ecs/src/implementation/world/observer-contained-worker.fixture.ts +++ b/packages/ecs/src/implementation/world/observer-contained-worker.fixture.ts @@ -24,7 +24,7 @@ export type FromContainedWorker = const channel = scopeChannel(); let remote: World | null = null; -const placed = System.create({ name: "observerContainedTest/placed", observer: true }, () => {}); +const placed = System.create({ observer: true }, () => {}); channel.onMessage(message => { if ("protocol" in message) return; diff --git a/packages/ecs/src/implementation/world/observer-contained.test-helper.ts b/packages/ecs/src/implementation/world/observer-contained.test-helper.ts index 2456e1fa4..353165adb 100644 --- a/packages/ecs/src/implementation/world/observer-contained.test-helper.ts +++ b/packages/ecs/src/implementation/world/observer-contained.test-helper.ts @@ -1,6 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Resource } from "../../interface/resource/resource.js"; import { System } from "../../interface/system/system.js"; +import type { Schema } from "../../interface/schema/schema.js"; // Keys and systems both threads of the observer-contained fixtures import: a counter the world bumps, and an observer // system reading only resources that a joined thread adds, which can hold its run until the world's thread releases it. @@ -9,13 +9,11 @@ export const tally = Symbol("observerContainedTest/tally"); export const holding = Symbol("observerContainedTest/holding"); declare module "@adobe/ecs" { export interface Resources { - [tally]: { type: number }; + [tally]: { type: Schema.Type<{ type: "integer" }>; default: 0 }; [holding]: { type: number }; } } -Resource.register(tally, { type: { type: "integer", default: 0 } }); /** While negative, each run of the watcher says it reached its value and waits until the world's thread releases it. */ -Resource.register(holding, { type: { type: "number", default: 0 } }); /** Shared words: the value a held run reached, and the value the world's thread released runs through. */ export const reachedWord = 0; @@ -27,14 +25,14 @@ export const gate: { words: Int32Array | null } = { words: null }; /** What the watcher's runs saw, in order. */ export const tallies: number[] = []; -export const bump = System.create({ name: "observerContainedTest/bump", resources: { write: [tally] } }, ({ resources }) => { resources[tally] += 1; }); -export const nothing = System.create({ name: "observerContainedTest/nothing" }, () => {}); -export const setHolding = System.create({ name: "observerContainedTest/setHolding", input: { type: "object", properties: { holding: { type: "number" } } }, resources: { write: [holding] } }, ({ input, resources }) => { +export const bump = System.create({}, ({ resources }) => { resources[tally] += 1; }); +export const nothing = System.create({}, () => {}); +export const setHolding = System.create({}, ({ resources }, input: { readonly holding: number }) => { resources[holding] = input.holding; }); /** After each sync point that changed the tally: notes it, and, while `holding` says so, waits there to be released. */ -export const watchTally = System.create({ name: "observerContainedTest/watchTally", observer: true, resources: { read: [tally, holding], changed: [tally] } }, ({ resources }) => { +export const watchTally = System.create({ observer: true, resources: { changed: [tally] } }, ({ resources }) => { const value = resources[tally]; tallies.push(value); const { words } = gate; diff --git a/packages/ecs/src/implementation/world/observer-contained.test.ts b/packages/ecs/src/implementation/world/observer-contained.test.ts index 082edbd40..a5cf88a51 100644 --- a/packages/ecs/src/implementation/world/observer-contained.test.ts +++ b/packages/ecs/src/implementation/world/observer-contained.test.ts @@ -1,6 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; -import { Resource, System, workers } from "../../index.js"; +import { System, workers } from "../../index.js"; import { createWorld, join } from "./create.js"; // Observer systems reading only resources on a joined thread, whose posts hold copies of what they read, with the world @@ -11,31 +11,28 @@ const paused = Symbol("observerContainedTest/paused"); const steps = Symbol("observerContainedTest/steps"); declare module "@adobe/ecs" { interface Resources { - [wide]: { type: readonly [number, number, number, number, number, number, number, number, number, number, number, number] }; + [wide]: { type: readonly [number, number, number, number, number, number, number, number, number, number, number, number]; default: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] }; [paused]: { type: boolean }; [steps]: { type: number }; } } /** Twelve f64s: more than a post's block holds, so its posts copy them into a snapshot of their own. */ -Resource.register(wide, { type: { type: "array", items: { type: "number" }, minItems: 12, maxItems: 12, default: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] } }); -Resource.register(paused, { type: { type: "boolean", default: false } }); -Resource.register(steps, { type: { type: "number", default: 0 } }); -const widen = System.create({ name: "observerContainedTest/widen", resources: { write: [wide] } }, ({ resources }) => { +const widen = System.create({}, ({ resources }) => { const first = resources[wide][0] + 1; const base = first * 100; resources[wide] = [base, base + 1, base + 2, base + 3, base + 4, base + 5, base + 6, base + 7, base + 8, base + 9, base + 10, base + 11]; }); -const step = System.create({ name: "observerContainedTest/step", resources: { write: [steps] } }, ({ resources }) => { resources[steps] += 1; }); -const pause = System.create({ name: "observerContainedTest/pause", input: { type: "object", properties: { paused: { type: "boolean" } } }, resources: { write: [paused] } }, ({ input, resources }) => { +const step = System.create({}, ({ resources }) => { resources[steps] += 1; }); +const pause = System.create({}, ({ resources }, input: { readonly paused: boolean }) => { resources[paused] = input.paused; }); const seen: { wide: number[][]; steps: number[] } = { wide: [], steps: [] }; -const watchWide = System.create({ name: "observerContainedTest/watchWide", observer: true, resources: { read: [wide], changed: [wide] } }, ({ resources }) => { +const watchWide = System.create({ observer: true, resources: { changed: [wide] } }, ({ resources }) => { seen.wide.push([...resources[wide]]); }); -const watchSteps = System.create({ name: "observerContainedTest/watchSteps", observer: true, resources: { read: [steps], not: [paused], changed: [steps] } }, ({ resources }) => { +const watchSteps = System.create({ observer: true, resources: { not: [paused], changed: [steps] } }, ({ resources }) => { seen.steps.push(resources[steps]); }); @@ -57,7 +54,7 @@ const joined = async () => { if (status === "joined") break; await new Promise(resolve => setTimeout(resolve, 0)); } - await remote.run(System.create({ name: "observerContainedTest/nothing", observer: true }, () => {})); + await remote.run(System.create({ observer: true }, () => {})); return { world, remote, close: async () => { await remote.dispose(); await world.dispose(); } }; }; diff --git a/packages/ecs/src/implementation/world/observer-joined.test-helper.ts b/packages/ecs/src/implementation/world/observer-joined.test-helper.ts index 2929cd364..98ffddcf9 100644 --- a/packages/ecs/src/implementation/world/observer-joined.test-helper.ts +++ b/packages/ecs/src/implementation/world/observer-joined.test-helper.ts @@ -1,9 +1,9 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Component } from "../../interface/component/component.js"; import { Index } from "../../interface/indexes/index-type.js"; -import { Resource } from "../../interface/resource/resource.js"; import { System } from "../../interface/system/system.js"; import type { Entity } from "../../interface/entity/entity.js"; +import type { Schema } from "../../interface/schema/schema.js"; +import type { F32, Vec3 } from "../../interface/math/index.js"; // Keys and systems both threads of the observer-joined fixtures import: world systems writing every row's stamp each // tick, and observer systems a joined thread adds, reading those rows as of one sync point. @@ -20,38 +20,28 @@ export const motto = Symbol("observerJoinedWorkerTest/motto"); export const mottoRound = Symbol("observerJoinedWorkerTest/mottoRound"); declare module "@adobe/ecs" { export interface Components { - [stamp]: { type: number }; - [spot]: { type: readonly [number, number, number] }; - [heat]: { type: number }; - [rate]: { type: number }; + [stamp]: { type: Schema.Type<{ type: "integer" }> }; + [spot]: { type: Vec3 }; + [heat]: { type: F32 }; + [rate]: { type: F32 }; [word]: { type: string }; } export interface Resources { - [counter]: { type: number }; + [counter]: { type: Schema.Type<{ type: "integer" }>; default: 0 }; [stall]: { type: number }; - [phase]: { type: number }; - [motto]: { type: string }; - [mottoRound]: { type: number }; + [phase]: { type: Schema.Type<{ type: "integer" }>; default: 0 }; + [motto]: { type: string; default: "motto 0" }; + [mottoRound]: { type: Schema.Type<{ type: "integer" }>; default: 0 }; } } -Component.register(stamp, { type: { type: "integer" } }); -Component.register(spot, { type: { type: "array", items: { type: "number", precision: 1 }, minItems: 3, maxItems: 3 } }); -Component.register(heat, { type: { type: "number", precision: 1 } }); -Component.register(rate, { type: { type: "number", precision: 1 } }); /** A string the world writes each tick, which lives in shared memory where the observer system reads it. */ -Component.register(word, { type: { type: "string" } }); -Resource.register(counter, { type: { type: "integer", default: 0 } }); /** A string resource the world writes with the round it's of, which an observer system on the thread reads alone. */ -Resource.register(motto, { type: { type: "string", default: "motto 0" } }); -Resource.register(mottoRound, { type: { type: "integer", default: 0 } }); /** Moves on once a joined thread asked the world to run its observer system, so the world's thread knows it did. */ -Resource.register(phase, { type: { type: "integer", default: 0 } }); /** * Milliseconds the observer systems spin partway through each run, so the world's next writer is likely ready before * they finish; negative, they wait there on the gate instead, until the world's thread releases them once a writer * copied. */ -Resource.register(stall, { type: { type: "number", default: 0 } }); const spin = (milliseconds: number): void => { const until = performance.now() + milliseconds; @@ -91,20 +81,20 @@ export const hold = (): void => { export const wordOf = (value: number): string => `${"·".repeat(value % 37)}${value}`; /** On the world's thread: the counter moves on once a tick, then every row's stamp, spot and word take it. */ -export const count = System.create({ name: "observerJoinedWorkerTest/count", resources: { write: [counter] } }, ({ resources }) => { +export const count = System.create({}, ({ resources }) => { resources[counter] += 1; }); -export const advance = System.create({ name: "observerJoinedWorkerTest/advance", components: { write: [stamp, spot, word] }, resources: { read: [counter] }, after: [count] }, (row, { resources }) => { +export const advance = System.create({ components: { with: [stamp, spot, word] }, after: [count] }, (row, { resources }) => { const value = resources[counter]; row[stamp] = value; row[spot] = [value, -value, value]; row[word] = wordOf(value); }); -export const ticked = System.create({ name: "observerJoinedWorkerTest/ticked", systems: [count, advance] }); -export const seed = System.create({ name: "observerJoinedWorkerTest/seed", input: { type: "object", properties: { count: { type: "integer" } } } }, ({ input, commands }) => { +export const ticked = System.create({ systems: [count, advance] }); +export const seed = System.create({}, ({ commands }, input: { readonly count: number }) => { for (let k = 0; k < input.count; k++) commands.spawn([stamp, spot, word])({ [stamp]: 0, [spot]: [0, 0, 0], [word]: wordOf(0) }); }); -export const setStall = System.create({ name: "observerJoinedWorkerTest/setStall", input: { type: "object", properties: { pause: { type: "number" } } }, resources: { write: [stall] } }, ({ input, resources }) => { +export const setStall = System.create({}, ({ resources }, input: { readonly pause: number }) => { resources[stall] = input.pause; }); @@ -113,7 +103,7 @@ export type Seen = { counter: number; rows: number; wrong: number }; export const seen: Seen[] = []; /** Reads every row, which must all be of one tick, pausing at the end of its first chunk while `stall` says so. */ -export const watchRows = System.create({ name: "observerJoinedWorkerTest/watchRows", observer: true, components: { read: [stamp, spot, word] }, resources: { read: [counter, stall] } }, (row, { resources }) => { +export const watchRows = System.create({ observer: true, components: { 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 }); @@ -127,7 +117,7 @@ export const watchRows = System.create({ name: "observerJoinedWorkerTest/watchRo export const looked: Entity[] = []; /** Looks up every row by entity, which must all be of one tick, spinning halfway while `stall` says so. */ -export const lookRows = System.create({ name: "observerJoinedWorkerTest/lookRows", observer: true, resources: { read: [counter, stall] }, lookup: { found: { read: [stamp, spot] } } }, ({ resources, lookup }) => { +export const lookRows = System.create({ observer: true, lookup: { found: { with: [stamp, spot] } } }, ({ resources, lookup }) => { const value = resources[counter]; const run: Seen = { counter: value, rows: 0, wrong: 0 }; seen.push(run); @@ -142,27 +132,27 @@ export const lookRows = System.create({ name: "observerJoinedWorkerTest/lookRows }); /** 100 rows heated 0 to 99, which a GPU kernel warms by 100 each run. */ -export const spawnHeated = System.create({ name: "observerJoinedWorkerTest/spawnHeated" }, ({ commands }) => { +export const spawnHeated = System.create({}, ({ commands }) => { for (let i = 0; i < 100; i++) commands.spawn([heat, rate])({ [heat]: i, [rate]: 1 }); }); -export const warm = System.create({ name: "observerJoinedWorkerTest/warm", kernel: "gpu", components: { read: [rate], write: [heat] } }, row => { +export const warm = System.create({ kernel: "gpu", components: { with: [rate, heat] } }, row => { row[heat] = row[heat] + row[rate] * 100; }); -export const nextPhase = System.create({ name: "observerJoinedWorkerTest/nextPhase", resources: { write: [phase] } }, ({ resources }) => { +export const nextPhase = System.create({}, ({ resources }) => { resources[phase] += 1; }); /** What `countHot` counted, run by run, on the joined thread. */ export const hot: number[] = []; -export const byHeat = Index.create({ name: "observerJoinedWorkerTest/byHeat", key: [heat], sorted: true }); +export const byHeat = Index.create({ key: [heat], sorted: true }); /** Counts the rows at least 150 hot, through the sorted index on what the GPU kernel writes. */ -export const countHot = System.create({ name: "observerJoinedWorkerTest/countHot", observer: true, lookup: { hot: { index: byHeat } } }, ({ lookup }) => { +export const countHot = System.create({ observer: true, lookup: { hot: { index: byHeat } } }, ({ lookup }) => { hot.push(lookup.hot({ ">=": 150 }).length); }); /** On the world's thread: the motto of a round, and the round, every motto of one size. */ export const mottoOf = (round: number): string => `motto ${round % 10}`; -export const setMotto = System.create({ name: "observerJoinedWorkerTest/setMotto", input: { type: "object", properties: { round: { type: "integer" } } }, resources: { write: [motto, mottoRound] } }, ({ input, resources }) => { +export const setMotto = System.create({}, ({ resources }, input: { readonly round: number }) => { resources[motto] = mottoOf(input.round); resources[mottoRound] = input.round; }); @@ -174,7 +164,7 @@ export const mottos: { readonly round: number; readonly motto: string }[] = []; * Reads only resources, a string among them: waits on the gate first, while `stall` says so, its round plus one, then * reads the motto, which must be its round's. */ -export const watchMotto = System.create({ name: "observerJoinedWorkerTest/watchMotto", observer: true, resources: { read: [motto, mottoRound, stall] } }, ({ resources }) => { +export const watchMotto = System.create({ observer: true }, ({ resources }) => { const round = resources[mottoRound]; pauseAt(resources[stall], round + 1); mottos.push({ round, motto: resources[motto] }); diff --git a/packages/ecs/src/implementation/world/observer-joined.test.ts b/packages/ecs/src/implementation/world/observer-joined.test.ts index 14b26e499..58a31926c 100644 --- a/packages/ecs/src/implementation/world/observer-joined.test.ts +++ b/packages/ecs/src/implementation/world/observer-joined.test.ts @@ -1,6 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; -import { Component, Index, Resource, System, workers } from "../../index.js"; +import { Index, System, workers } from "../../index.js"; import type { Entity } from "../../interface/entity/entity.js"; import type { World } from "../../interface/world/world.js"; import { createWorld, join, type CreatedWorld } from "./create.js"; @@ -24,33 +24,30 @@ declare module "@adobe/ecs" { [amount]: { type: number }; [cell]: { type: number }; [tagged]: { type: true }; - [label]: { type: { readonly text: string }; thread: "observerJoinedTest" }; - [name]: { type: string }; + [label]: { type: Label; thread: "observerJoinedTest" }; + [name]: { type: string; default: "" }; } interface Resources { [level]: { type: number }; } } -Component.register(amount, { type: { type: "number" } }); -Component.register(cell, { type: { type: "number" } }); -Component.register(tagged, { type: { const: true } }); /** The label's start, which a command here gives it by this value. */ -const blank = { text: "" }; -Component.register(label, { type: { type: "object", properties: { text: { type: "string" } }, default: blank }, thread: "observerJoinedTest" }); -Component.register(name, { type: { type: "string", default: "" } }); -Resource.register(level, { type: { type: "number", default: 0 } }); +const blank = { text: "" } as const; +/** A label, which starts as `blank`, the value commands on other threads give it by identity. */ +type Label = { readonly text: string }; +const Label = { default: blank }; -const byCell = Index.create({ name: "observerJoinedTest/byCell", key: [cell], components: { has: [amount] } }); -const allTagged = Index.create({ name: "observerJoinedTest/allTagged", key: [], components: { has: [tagged] } }); +const byCell = Index.create({ key: [cell], components: { with: [amount] } }); +const allTagged = Index.create({ key: [], components: { with: [tagged] } }); -const raise = System.create({ name: "observerJoinedTest/raise", resources: { write: [level] } }, ({ resources }) => { resources[level] += 1; }); -const nothing = System.create({ name: "observerJoinedTest/nothing" }, () => {}); -const spawn = System.create({ name: "observerJoinedTest/spawn", input: { type: "object", properties: { at: { type: "number" } } } }, ({ input, commands }) => { +const raise = System.create({}, ({ resources }) => { resources[level] += 1; }); +const nothing = System.create({}, () => {}); +const spawn = System.create({}, ({ commands }, input: { readonly at: number }) => { commands.spawn([amount, cell, tagged])({ [amount]: input.at, [cell]: input.at }); }); -const spawnLabelled = System.create({ name: "observerJoinedTest/spawnLabelled" }, ({ commands }) => { commands.spawn([amount, label])({ [amount]: 4, [label]: blank }); }); -const grow = System.create({ name: "observerJoinedTest/grow", components: { write: [amount] } }, row => { row[amount] += 1; }); -const move = System.create({ name: "observerJoinedTest/move", components: { write: [cell] } }, row => { row[cell] += 10; }); +const spawnLabelled = System.create({}, ({ commands }) => { commands.spawn([amount, label])({ [amount]: 4, [label]: blank }); }); +const grow = System.create({ components: { with: [amount] } }, row => { row[amount] += 1; }); +const move = System.create({ components: { with: [cell] } }, row => { row[cell] += 10; }); /** What the joined thread's observer bodies saw, reset by each test. */ const seen: { levels: number[]; amounts: number[][]; looked: (number | boolean)[][]; tagged: number[]; labels: string[]; calls: number } = { levels: [], amounts: [], looked: [], tagged: [], labels: [], calls: 0 }; @@ -62,32 +59,32 @@ const reset = (): void => { /** The entities the tests look up, by the joined thread's systems. */ const watched: Entity[] = []; -const watchLevel = System.create({ name: "observerJoinedTest/watchLevel", observer: true, resources: { read: [level], changed: [level] } }, ({ resources }) => { +const watchLevel = System.create({ observer: true, resources: { changed: [level] } }, ({ resources }) => { seen.calls++; seen.levels.push(resources[level]); }); -const watchAmounts = System.create({ name: "observerJoinedTest/watchAmounts", observer: true, components: { read: [amount], changed: [amount] } }, row => { +const watchAmounts = System.create({ observer: true, components: { with: [amount], changed: [amount] } }, row => { (seen.amounts[seen.amounts.length - 1] ??= []).push(row[amount]); }); -const startAmounts = System.create({ name: "observerJoinedTest/startAmounts", observer: true }, () => { seen.amounts.push([]); }); +const startAmounts = System.create({ observer: true }, () => { seen.amounts.push([]); }); const amountsPass = System.create([startAmounts, watchAmounts]); -const lookChanged = System.create({ name: "observerJoinedTest/lookChanged", observer: true, lookup: { found: { read: [amount], changed: [amount] } } }, ({ lookup }) => { +const lookChanged = System.create({ observer: true, lookup: { found: { 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({ name: "observerJoinedTest/countTagged", observer: true, lookup: { all: { index: allTagged } } }, ({ lookup }) => { +const countTagged = System.create({ observer: true, lookup: { all: { index: allTagged } } }, ({ lookup }) => { seen.tagged.push(lookup.all().length); }); -const inCell = System.create({ name: "observerJoinedTest/inCell", observer: true, lookup: { found: { index: byCell, read: [amount] } } }, ({ lookup }) => { +const inCell = System.create({ observer: true, lookup: { found: { index: byCell } } }, ({ lookup }) => { const found = lookup.found(11); seen.tagged.push(found.length === 0 ? 0 : found.length * 100 + (found.at(0)?.[amount] ?? -1)); }); -const failing = System.create({ name: "observerJoinedTest/failing", observer: true, resources: { read: [level] } }, ({ resources }) => { +const failing = System.create({ observer: true }, ({ resources }) => { if (resources[level] > 1) throw new Error("observer joined failure"); }); -const readLabels = System.create({ name: "observerJoinedTest/readLabels", observer: true, components: { read: [label, amount] } }, row => { +const readLabels = System.create({ observer: true, components: { with: [label, amount] } }, row => { seen.labels.push(`${row[label].text}${row[amount]}`); }); @@ -211,12 +208,12 @@ describe("observer systems on a joined thread", () => { await world.run(spawn, { at: 1 }); await world.run(spawn, { at: 11 }); // Made in a function, so the build gives them no id. - const made = (): readonly [Index<{ readonly key: readonly []; readonly components: { readonly has: readonly [typeof tagged, typeof cell] } }>, Index<{ readonly key: readonly [typeof cell]; readonly sorted: true; readonly components: { readonly has: readonly [typeof amount] } }>] => [ - Index.create({ name: "observerJoinedTest/madeTagged", key: [], components: { has: [tagged, cell] } }), - Index.create({ name: "observerJoinedTest/madeByCell", key: [cell], sorted: true, components: { has: [amount] } }), + const made = (): readonly [Index<{ readonly key: readonly []; readonly components: { readonly with: readonly [typeof tagged, typeof cell] } }>, Index<{ readonly key: readonly [typeof cell]; readonly sorted: true; readonly components: { readonly with: readonly [typeof amount] } }>] => [ + Index.create({ key: [], components: { with: [tagged, cell] } }), + Index.create({ key: [cell], sorted: true, components: { with: [amount] } }), ]; const [madeTagged, madeByCell] = made(); - const sizes = System.create({ name: "observerJoinedTest/sizes", observer: true, lookup: { all: { index: madeTagged, changed: [cell] }, cells: { index: madeByCell, read: [amount] } } }, ({ lookup }) => { + const sizes = System.create({ observer: true, lookup: { all: { index: madeTagged, changed: [cell] }, cells: { index: madeByCell } } }, ({ lookup }) => { const all = lookup.all(); seen.looked.push([all.changed]); const high = lookup.cells({ ">=": 5 }); @@ -258,7 +255,7 @@ describe("observer systems on a joined thread", () => { await world.run(raise); await turns(); const report = await runReported(world, nothing); - expect(report.failures.map(failure => [failure.kind, failure.name, failure.message])).toEqual([["system", "observerJoinedTest/failing", "observer joined failure"]]); + expect(report.failures.map(failure => [failure.kind, failure.name, failure.message])).toEqual([["system", "failing", "observer joined failure"]]); await close(); }); @@ -312,17 +309,17 @@ describe("observer systems on a joined thread", () => { const { world, close } = await joined(["observerJoinedTest"]); await world.run(spawnLabelled); // Made in a function, so the build gives it no id. - const made = () => System.create({ name: "observerJoinedTest/madeLabels", observer: true, components: { read: [label, amount] } }, row => { + const made = () => System.create({ observer: true, components: { with: [label, amount] } }, row => { seen.labels.push(`${row[label].text}${row[amount]}`); }); const madeLabels = made(); - await expect(world.run(madeLabels)).rejects.toThrow(`System "observerJoinedTest/madeLabels" runs on thread "observerJoinedTest", which a joined thread fills, and has no id, so that thread can't name it`); - // Another of that name, numbered. + await expect(world.run(madeLabels)).rejects.toThrow(`runs on thread "observerJoinedTest", which a joined thread fills, and has no id, so that thread can't name it`); + // Another made there, labeled the same. const added = made(); world.add(added); await turns(); const report = await runReported(world, nothing); - expect(report.failures.map(failure => [failure.kind, failure.name])).toEqual([["system", "observerJoinedTest/madeLabels#2"]]); + expect(report.failures.map(failure => [failure.kind, failure.name])).toEqual([["system", "made"]]); world.remove(added); await turns(); expect(seen.labels).toEqual([]); diff --git a/packages/ecs/src/implementation/world/observer.browser.test.ts b/packages/ecs/src/implementation/world/observer.browser.test.ts index 6ed774f2e..b02a3e94d 100644 --- a/packages/ecs/src/implementation/world/observer.browser.test.ts +++ b/packages/ecs/src/implementation/world/observer.browser.test.ts @@ -1,38 +1,36 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; -import { Component } from "../../interface/component/component.js"; import { Index } from "../../interface/indexes/index-type.js"; import { System } from "../../interface/system/system.js"; import { createWorld } from "./create.js"; import { gpuDevice } from "../../interface/resources/gpu-device.js"; import { workers } from "../../interface/resources/workers.js"; +import type { F32 } from "../../interface/math/index.js"; const heat = Symbol("observerGpuTest/heat"); const rate = Symbol("observerGpuTest/rate"); declare module "@adobe/ecs" { export interface Components { - [heat]: { type: number }; - [rate]: { type: number }; + [heat]: { type: F32 }; + [rate]: { type: F32 }; } } -Component.register(heat, { type: { type: "number", precision: 1 } }); -Component.register(rate, { type: { type: "number", precision: 1 } }); -const spawn = System.create({ name: "observerGpuTest/spawn" }, ({ commands }) => { +const spawn = System.create({}, ({ commands }) => { for (let i = 0; i < 100; i++) commands.spawn([heat, rate])({ [heat]: i, [rate]: 1 }); }); -const warm = System.create({ name: "observerGpuTest/warm", kernel: "gpu", components: { read: [rate], write: [heat] } }, row => { +const warm = System.create({ kernel: "gpu", components: { with: [rate, heat] } }, row => { row[heat] = row[heat] + row[rate] * 100; }); let total = 0; -const sum = System.create({ name: "observerGpuTest/sum", observer: true, components: { read: [heat] } }, row => { total += row[heat]; }); +const sum = System.create({ observer: true, components: { with: [heat] } }, row => { total += row[heat]; }); -const byHeat = Index.create({ name: "observerGpuTest/byHeat", key: [heat], sorted: true }); +const byHeat = Index.create({ key: [heat], sorted: true }); let hot = 0; -const countHot = System.create({ name: "observerGpuTest/countHot", observer: true, lookup: { hot: { index: byHeat } } }, ({ lookup }) => { +const countHot = System.create({ observer: true, lookup: { hot: { index: byHeat } } }, ({ lookup }) => { hot = lookup.hot({ ">=": 150 }).length; }); diff --git a/packages/ecs/src/implementation/world/observer.test.ts b/packages/ecs/src/implementation/world/observer.test.ts index 0aadabbb1..ad312e929 100644 --- a/packages/ecs/src/implementation/world/observer.test.ts +++ b/packages/ecs/src/implementation/world/observer.test.ts @@ -1,6 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; -import { Component, Index, Report, Resource, System, workers } from "../../index.js"; +import { giveAccess } from "../../interface/registry/ids.test-helper.js"; +import { Index, Report, System, workers } from "../../index.js"; import { isObserver, requireDefinition } from "../../interface/registry/index.js"; import type { World } from "../../interface/world/world.js"; import { createWorld } from "./create.js"; @@ -21,11 +22,8 @@ declare module "@adobe/ecs" { [level]: { type: number }; } } -Component.register(amount, { type: { type: "number" } }); -Component.register(cell, { type: { type: "number" } }); -Resource.register(level, { type: { type: "number", default: 0 } }); -const byCell = Index.create({ name: "observerTest/byCell", key: [cell] }); +const byCell = Index.create({ key: [cell] }); /** What observer bodies saw, by system, reset by each test. */ const seen: { levels: number[]; amounts: number[]; counted: number[]; cells: number[]; reports: number[]; removals: number[]; contexts: object[] } = { levels: [], amounts: [], counted: [], cells: [], reports: [], removals: [], contexts: [] }; @@ -33,41 +31,42 @@ const reset = (): void => { for (const list of Object.values(seen)) list.length = 0; }; -const raise = System.create({ name: "observerTest/raise", resources: { write: [level] } }, ({ resources }) => { resources[level] += 1; }); -const spawn = System.create({ name: "observerTest/spawn" }, ({ commands }) => { commands.spawn([amount, cell])({ [amount]: 1, [cell]: 7 }); }); -const grow = System.create({ name: "observerTest/grow", components: { write: [amount] } }, row => { row[amount] += 1; }); -const nothing = System.create({ name: "observerTest/nothing" }, () => {}); +const raise = System.create({}, ({ resources }) => { resources[level] += 1; }); +const spawn = System.create({}, ({ commands }) => { commands.spawn([amount, cell])({ [amount]: 1, [cell]: 7 }); }); +const grow = System.create({ components: { with: [amount] } }, row => { row[amount] += 1; }); +const nothing = System.create({}, () => {}); -const watchLevel = System.create({ name: "observerTest/watchLevel", observer: true, resources: { read: [level] } }, context => { +// It keeps its context, which the build can't follow, so it's given what it reads. +const watchLevel = System.create(giveAccess({ observer: true }, { resources: { read: [level] } }), context => { seen.levels.push(context.resources[level]); seen.contexts.push(context); }); -const watchAmounts = System.create({ name: "observerTest/watchAmounts", observer: true, components: { read: [amount], changed: [amount] } }, row => { seen.amounts.push(row[amount]); }); -const count = System.create({ name: "observerTest/count", observer: true, components: { has: [amount] } }, () => { seen.counted.push(1); }); -const inCell = System.create({ name: "observerTest/inCell", observer: true, lookup: { found: { index: byCell } } }, ({ lookup }) => { +const watchAmounts = System.create({ observer: true, components: { with: [amount], changed: [amount] } }, row => { seen.amounts.push(row[amount]); }); +const count = System.create({ observer: true, components: { with: [amount] } }, () => { seen.counted.push(1); }); +const inCell = System.create({ observer: true, lookup: { found: { index: byCell } } }, ({ lookup }) => { seen.cells.push(lookup.found(7).length); }); -const failing = System.create({ name: "observerTest/failing", observer: true, resources: { read: [level] } }, ({ resources }) => { +const failing = System.create({ observer: true }, ({ resources }) => { if (resources[level] > 0) throw new Error("observer failed"); }); -const watchReport = System.create({ name: "observerTest/watchReport", observer: true, components: { read: [Report.errors], changed: [Report.errors] } }, row => { +const watchReport = System.create({ observer: true, components: { with: [Report.errors], changed: [Report.errors] } }, row => { seen.reports.push(row[Report.errors].failures.length); }); const raiseThenWatch = System.create([raise, watchLevel]); -const watchLevelChanges = System.create({ name: "observerTest/watchLevelChanges", observer: true, resources: { read: [level], changed: [level] } }, ({ resources }) => { +const watchLevelChanges = System.create({ observer: 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({ name: "observerTest/removeLevel", observer: true }, () => { +const removeLevel = System.create({ observer: true }, () => { seen.removals.push(1); current?.remove(watchLevel); }); /** Adds `watchLevel` to the world the test runs, during a run. */ -const addWatch = System.create({ name: "observerTest/addWatch" }, () => { current?.add(watchLevel); }); -const addLevel = System.create({ name: "observerTest/addLevel", observer: true }, () => { +const addWatch = System.create({}, () => { current?.add(watchLevel); }); +const addLevel = System.create({ observer: true }, () => { current?.add(watchLevel); }); @@ -135,7 +134,7 @@ describe("observer systems", () => { expect((await reportOf(world, raised)).failures).toEqual([]); // Read by a run of its own, the next one, whose report holds them. const { failures } = await reportOf(world, raised + 2); - expect(failures.map(({ kind, name, message }) => [kind, name, message])).toEqual([["system", "observerTest/failing", "observer failed"]]); + expect(failures.map(({ kind, name, message }) => [kind, name, message])).toEqual([["system", "failing", "observer failed"]]); }); it("don't make the loop tick, and runs of them alone don't wake it", async () => { @@ -167,7 +166,7 @@ describe("observer systems", () => { expect(counted).toBe(ran + 2); expect(seen.reports).toEqual([1]); expect((await reportOf(world, ran + 1)).failures).toEqual([]); - expect((await reportOf(world, counted)).failures.map(({ name }) => name)).toEqual(["observerTest/failing"]); + expect((await reportOf(world, counted)).failures.map(({ name }) => name)).toEqual(["failing"]); }); it("leave the composite ticks run as it is when one added alone is removed, and release what it held", async () => { diff --git a/packages/ecs/src/implementation/world/observer.ts b/packages/ecs/src/implementation/world/observer.ts index bb0c8fd5a..1f06a1ddd 100644 --- a/packages/ecs/src/implementation/world/observer.ts +++ b/packages/ecs/src/implementation/world/observer.ts @@ -1,7 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { Leaf } from '../../interface/system/leaf.js'; import type { Failure } from '../../interface/report/failure.js'; -import { labelOfItem } from '../../interface/registry/index.js'; import { readAhead, type ExecuteContext } from './execute.js'; import { failureOf } from './failures.js'; import { refreshUses } from './indexes.js'; @@ -133,7 +132,7 @@ const runOne = (leaf: Leaf, runner: Runner, failures: Failure[] | null): Failure const chunk = chunks[c]; // An observer system runs here, as no kernel, so its chunks are settled. if (chunk instanceof Promise || chunk.error === undefined) continue; - (failures ??= []).push(failureOf("system", labelOfItem(leaf), chunk.error)); + (failures ??= []).push(failureOf("system", leaf, chunk.error)); break; } return failures; diff --git a/packages/ecs/src/implementation/world/optional-workers.cases.ts b/packages/ecs/src/implementation/world/optional-workers.cases.ts new file mode 100644 index 000000000..1a90b9315 --- /dev/null +++ b/packages/ecs/src/implementation/world/optional-workers.cases.ts @@ -0,0 +1,19 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { describe, expect, it } from "vitest"; +import type * as Fixture from "./optional-workers.fixture.js"; + +/** Optional components in kernels on workers: the cases, over the fixture built for node. */ +export const optionalWorkersCases = (load: () => Promise) => describe("optional components in kernels on workers", () => { + for (const wasm of [false, true]) { + it(`read undefined in archetypes that lack them, and their values where they don't (${wasm ? "WASM" : "JS"})`, async () => { + const fixture = await load(); + expect(await fixture.movedOnWorkers(wasm)).toEqual({ moved: 200 * 15, still: 200 }); + }); + it(`fail their system where a kernel assigns one an entity lacks (${wasm ? "WASM" : "JS"})`, async () => { + const fixture = await load(); + const failures = await fixture.assignedOnWorkers(wasm); + expect(failures.length).toBeGreaterThan(0); + expect(new Set(failures)).toEqual(new Set([`An entity lacks "optionalWorkersTest/velocity", optional in its system: add it with commands.add rather than assigning it.`])); + }); + } +}); diff --git a/packages/ecs/src/implementation/world/optional-workers.fixture.ts b/packages/ecs/src/implementation/world/optional-workers.fixture.ts new file mode 100644 index 000000000..33c546911 --- /dev/null +++ b/packages/ecs/src/implementation/world/optional-workers.fixture.ts @@ -0,0 +1,55 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { workers } from "../../interface/resources/workers.js"; +import { System } from "../../interface/system/system.js"; +import { createWorld } from "./create.js"; +import { runReported } from "./reports.test-helper.js"; + +/** Kernels with an optional component on workers, over archetypes that have it and one that doesn't. */ + +const position = Symbol("optionalWorkersTest/position"); +const velocity = Symbol("optionalWorkersTest/velocity"); +declare module "@adobe/ecs" { + export interface Components { + [position]: { type: number }; + [velocity]: { type: number }; + } +} + +const rows = 200; +const spawn = System.create(({ commands }) => { + for (let i = 0; i < rows; i++) { + commands.spawn([position])({ [position]: 1 }); + commands.spawn([position, velocity])({ [position]: 10, [velocity]: 5 }); + } +}); +const move = System.create({ kernel: true, components: { with: [position], optional: [velocity] } }, row => { + const speed = row[velocity]; + if (speed !== undefined) row[position] += speed; +}); +const assign = System.create({ kernel: true, components: { with: [position], optional: [velocity] } }, row => { + row[velocity] = row[position]; +}); +const totals = { moved: 0, still: 0 }; +const sum = System.create({ observer: true, components: { with: [position], optional: [velocity] } }, row => { + if (row[velocity] === undefined) totals.still += row[position]; + else totals.moved += row[position]; +}); + +const world = (wasm: boolean) => createWorld({ [workers]: 2 }, { minimumChunk: 2, wasm }); + +/** The positions of rows with a velocity and without, once the kernel moved them on workers. */ +export const movedOnWorkers = async (wasm: boolean): Promise<{ readonly moved: number; readonly still: number }> => { + await using moving = world(wasm); + await moving.run(spawn); + await moving.run(move); + totals.moved = totals.still = 0; + await moving.run(sum); + return { ...totals }; +}; + +/** What the run of a kernel assigning the optional component on workers failed with. */ +export const assignedOnWorkers = async (wasm: boolean): Promise => { + await using assigning = world(wasm); + await assigning.run(spawn); + return (await runReported(assigning, assign)).failures.map(failure => failure.message); +}; diff --git a/packages/ecs/src/implementation/world/optional.test.ts b/packages/ecs/src/implementation/world/optional.test.ts new file mode 100644 index 000000000..be0674aee --- /dev/null +++ b/packages/ecs/src/implementation/world/optional.test.ts @@ -0,0 +1,357 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { describe, expect, it } from "vitest"; +import { Entity, Finalizer, Index, System, workers } from "../../index.js"; +import { kernelOf } from "./attach-kernel.js"; +import { createWorld, join, type CreatedWorld } from "./create.js"; +import { runReported } from "./reports.test-helper.js"; + +const position = Symbol("optionalTest/position"); +const velocity = Symbol("optionalTest/velocity"); +const label = Symbol("optionalTest/label"); +const order = Symbol("optionalTest/order"); +const marker = Symbol("optionalTest/marker"); +declare module "@adobe/ecs" { + interface Threads { + optionalTest: true; + } + interface Components { + [marker]: { type: { readonly text: string } | null; thread: "optionalTest" }; + [position]: { type: number }; + [velocity]: { type: number }; + [label]: { type: string }; + [order]: { type: number }; + } +} + +const spawn = System.create(({ commands }) => { + commands.spawn([position, order])({ [position]: 1, [order]: 2 }); + commands.spawn([position, velocity, order])({ [position]: 10, [velocity]: 5, [order]: 1 }); + commands.spawn([position, velocity, label, order])({ [position]: 100, [velocity]: 50, [label]: "c", [order]: 0 }); + commands.spawn([velocity])({ [velocity]: 7 }); +}); + +// Moves what has a velocity, and leaves the rest where they are. +const move = System.create({ components: { with: [position], optional: [velocity] } }, row => { + const speed = row[velocity]; + if (speed !== undefined) row[position] += speed; +}); + +/** As `move`, asserted a kernel. */ +const moveKernel = System.create({ kernel: true, components: { with: [position], optional: [velocity] } }, row => { + const speed = row[velocity]; + if (speed !== undefined) row[position] += speed; +}); +const writeKernel = System.create({ kernel: true, components: { with: [position], optional: [velocity] } }, row => { + row[velocity] = row[position]; +}); + +/** Rows naming another entity, whose optional velocity a lookup reads. */ +const other = Symbol("optionalTest/other"); +declare module "@adobe/ecs" { + interface Components { + [other]: { type: Entity }; + } +} +let pointed: Entity[] = []; +const spawnPointing = System.create(({ commands }) => { + const still = commands.spawn([position])({ [position]: 1 }); + const moving = commands.spawn([position, velocity])({ [position]: 2, [velocity]: 7 }); + pointed = [still, moving]; + commands.spawn([other, order])({ [other]: still, [order]: 0 }); + commands.spawn([other, order])({ [other]: moving, [order]: 0 }); +}); +const copySpeed = System.create({ + kernel: false, components: { with: [other, order] }, lookup: { target: { with: [position], optional: [velocity] } }, +}, (row, { lookup }) => { + const found = lookup.target(row[other]); + if (found !== null) row[order] = found[velocity] === undefined ? -found[position] : found[velocity]; +}); +const copySpeedKernel = System.create({ + kernel: true, components: { with: [other, order] }, lookup: { target: { with: [position], optional: [velocity] } }, +}, (row, { lookup }) => { + const found = lookup.target(row[other]); + if (found !== null) row[order] = found[velocity] === undefined ? -found[position] : found[velocity]; +}); +const speeds: number[] = []; +const readSpeeds = System.create({ observer: true, components: { with: [other, order] } }, row => { speeds.push(row[order]); }); + +/** What finalizers read of rows that stopped, on the world's thread and on the markers'. */ +const stopped: string[] = []; +const stoppedThere: string[] = []; +export const finalizeHere = Finalizer.create({ components: { with: [position, order], optional: [velocity] } }, row => { + stopped.push(`${row[position]}:${row[velocity] ?? "none"}`); +}); +export const finalizeThere = Finalizer.create({ components: { with: [position, marker], optional: [velocity] } }, row => { + stoppedThere.push(`${row[position]}:${row[velocity] ?? "none"}:${row[marker] === null ? "null" : "marked"}`); +}); +const despawnAll = System.create({ components: { with: [position] } }, (row, { commands }) => { commands.despawn(row.entity); }); + +let seen: (readonly [number, number | undefined, string | undefined])[] = []; +const read = System.create({ observer: true, components: { with: [position], optional: [velocity, label] } }, row => { + seen.push([row[position], row[velocity], row[label]]); +}); +const sorted = () => [...seen].sort((a, b) => a[0] - b[0]); + +const inline = () => createWorld({ [workers]: 0 }); + +const spawnMarked = System.create(({ commands }) => { + commands.spawn([position, marker])({ [position]: 1 }); + commands.spawn([position, velocity, marker])({ [position]: 10, [velocity]: 5 }); +}); +// Placed on the markers' thread by reading them. +const there = System.create({ components: { with: [position, marker], optional: [velocity] } }, row => { + if (row[marker] === null) row[position] = row[velocity] ?? -1; +}); + +// On the markers' thread too, reading the row's own entity through a lookup with an optional component. +const lookedThere = System.create({ components: { with: [position, marker] }, lookup: { self: { with: [position], optional: [velocity] } } }, (row, { lookup }) => { + if (row[marker] === null) row[position] = lookup.self(row.entity)?.[velocity] ?? -1; +}); +// An observer system there reads copies of rows across archetypes, which tell which have the optional component. +let watched: (readonly [number, number | undefined])[] = []; +const watchThere = System.create({ observer: true, components: { with: [position], optional: [velocity] } }, row => { + watched.push([row[position], row[velocity]]); +}); +// A tail there: it writes only its thread's objects, so it runs after the sync point, reading copies of rows. +const labelThere = System.create({ components: { with: [position, marker], optional: [velocity] } }, row => { + row[marker] = { text: `${row[position]}:${row[velocity] ?? "none"}` }; +}); +const labels: string[] = []; +const readLabels = System.create({ components: { with: [marker] } }, row => { labels.push(row[marker]?.text ?? "null"); }); + +/** A world shared with a thread on this one, which claims the thread markers live on. */ +const joined = async (): Promise<{ readonly world: CreatedWorld; readonly remote: ReturnType }> => { + const world = createWorld({ [workers]: 0 }); + const channel = new MessageChannel(); + world.share(channel.port1); + const remote = join(channel.port2, { threads: ["optionalTest"] }); + await new Promise(resolve => { + const stop = world.threads(threads => { + if (threads.at(-1)?.status !== "joined") return; + queueMicrotask(() => stop()); + resolve(); + }); + }); + return { world, remote }; +}; + + +/** Keys of systems whose rows have the same keys but other optional ones, and of stamps under two presence tests. */ +const near = Symbol("optionalTest/near"); +const far = Symbol("optionalTest/far"); +const mark = Symbol("optionalTest/mark"); +declare module "@adobe/ecs" { + interface Components { + [near]: { type: number }; + [far]: { type: number }; + [mark]: { type: number }; + } +} +const spawnApart = System.create(({ commands }) => { + commands.spawn([near])({ [near]: 1 }); + commands.spawn([far])({ [far]: 7 }); +}); +const nearSeen: unknown[] = []; +const farSeen: unknown[] = []; +const fromFar = System.create({ kernel: false, components: { with: [near], optional: [far] } }, row => { + const value = row[far]; + nearSeen.push(value); + row[near] = value ?? -1; +}); +const fromNear = System.create({ kernel: false, components: { with: [far], optional: [near] } }, row => { + const at = row[near]; + farSeen.push([at, row[far]]); + if (at !== undefined) row[near] = row[far]; +}); +const spawnStamped = System.create(({ commands }) => { + commands.spawn([mark, near])({ [mark]: 0, [near]: 1 }); +}); +const markEither = System.create({ kernel: true, components: { with: [mark], optional: [near, far] } }, row => { + if (row[near] !== undefined) row[mark] = 1; + if (row[far] !== undefined) row[mark] = 2; +}); +const marked: number[] = []; +const watchStamped = System.create({ observer: true, components: { with: [mark], changed: [mark] } }, row => { marked.push(row[mark]); }); +const clearNear = System.create({ kernel: false, components: { with: [mark], optional: [near] } }, row => { + if (row[near] !== undefined) row[near] = undefined; +}); + +describe("optional components", () => { + it("never narrow what a system matches, and read undefined where an entity lacks one", async () => { + await using world = inline(); + await world.run(spawn); + seen = []; + await world.run(read); + expect(sorted()).toEqual([[1, undefined, undefined], [10, 5, undefined], [100, 50, "c"]]); + await world.run(move); + seen = []; + await world.run(read); + expect(sorted()).toEqual([[1, undefined, undefined], [15, 5, undefined], [150, 50, "c"]]); + }); + + it("are written where an entity has one, and fail their system where it doesn't", async () => { + const write = System.create({ components: { with: [position], optional: [velocity] } }, row => { + row[velocity] = row[position]; + }); + await using world = inline(); + await world.run(spawn); + const report = await runReported(world, write); + expect(report.failures.map(failure => failure.message)).toEqual([`An entity lacks "optionalTest/velocity", optional in its system: add it with commands.add rather than assigning it.`]); + }); + + it("pass change filters on other components, and are stamped changed when written", async () => { + const changed: number[] = []; + const watch = System.create({ observer: true, components: { with: [velocity], changed: [velocity] } }, row => { changed.push(row[velocity]); }); + const speedUp = System.create({ components: { with: [position], optional: [velocity] } }, row => { + const speed = row[velocity]; + if (speed !== undefined && row[position] > 50) row[velocity] = speed + 1; + }); + await using world = inline(); + await world.run(spawn); + await world.run(watch); + changed.length = 0; + await world.run(speedUp); + await world.run(watch); + expect(changed).toEqual([51]); + }); + + it("run in an index's order across archetypes that have them and don't", async () => { + const byOrder = Index.create({ key: [order], sorted: true }); + const ordered: (number | undefined)[] = []; + const list = System.create({ observer: true, components: { with: [position], optional: [velocity] }, order: byOrder }, row => { + ordered.push(row[velocity]); + }); + await using world = inline(); + await world.run(spawn); + await world.run(list); + expect(ordered).toEqual([50, 5, undefined]); + }); + + it("conflict with systems using them as members do", () => { + const writes = System.create({ components: { with: [position], optional: [velocity] } }, row => { row[velocity] = 0; }); + const reads = System.create({ components: { with: [velocity, position] } }, row => { row[position] = row[velocity]; }); + expect(() => System.create([writes, reads])).toThrow(); + }); + + it("are read on a joined thread, where a system reading the thread's objects runs", async () => { + const { world, remote } = await joined(); + await world.run(spawnMarked); + await world.run(there); + seen = []; + await world.run(read); + expect(sorted()).toEqual([[-1, undefined, undefined], [5, 5, undefined]]); + await remote.dispose(); + await world.dispose(); + }); + + it("are read by lookups on a joined thread, undefined where the entity lacks one", async () => { + const { world, remote } = await joined(); + await world.run(spawnMarked); + await world.run(lookedThere); + seen = []; + await world.run(read); + expect(sorted()).toEqual([[-1, undefined, undefined], [5, 5, undefined]]); + await remote.dispose(); + await world.dispose(); + }); + + it("are read by observer systems on a joined thread, undefined where an archetype lacks one", async () => { + const { world, remote } = await joined(); + await world.run(spawnMarked); + watched = []; + await remote.run(watchThere); + expect([...watched].sort((a, b) => a[0] - b[0])).toEqual([[1, undefined], [10, 5]]); + await remote.dispose(); + await world.dispose(); + }); + + it("are read by tails on a joined thread, undefined where an archetype lacks one", async () => { + const { world, remote } = await joined(); + await world.run(spawnMarked); + const report = await runReported(world, labelThere); + expect(report.warnings).toEqual([]); + labels.length = 0; + await world.run(readLabels); + expect(labels.sort()).toEqual(["10:5", "1:none"]); + await remote.dispose(); + await world.dispose(); + }); + + it("compile to kernels, which read undefined where an archetype lacks one, as a body run as written does", async () => { + expect(kernelOf(move)).toBeDefined(); + expect(kernelOf(moveKernel)).toBeDefined(); + for (const system of [move, moveKernel]) { + await using world = inline(); + await world.run(spawn); + await world.run(system); + seen = []; + await world.run(read); + expect(sorted()).toEqual([[1, undefined, undefined], [15, 5, undefined], [150, 50, "c"]]); + } + }); + + it("are read by finalizers where an entity had them, never deciding a stop", async () => { + await using world = inline(); + await world.run(spawn); + stopped.length = 0; + await world.run(despawnAll); + expect(stopped.sort()).toEqual(["100:50", "10:5", "1:none"]); + }); + + it("are read by finalizers on a joined thread where an entity had them", async () => { + const { world, remote } = await joined(); + await world.run(spawnMarked); + stoppedThere.length = 0; + await world.run(despawnAll); + await remote.run(System.create({ observer: true }, () => {})); + expect(stoppedThere.sort()).toEqual(["10:5:null", "1:none:null"]); + await remote.dispose(); + await world.dispose(); + }); + + it("are read by lookups by entity, undefined where the entity lacks one, as written and in kernels", async () => { + for (const system of [copySpeed, copySpeedKernel]) { + await using world = inline(); + await world.run(spawnPointing); + await world.run(system); + speeds.length = 0; + await world.run(readSpeeds); + expect(speeds.sort((a, b) => a - b)).toEqual([-1, 7]); + } + }); + + it("fail a kernel's system where it assigns one an entity lacks", async () => { + await using world = inline(); + await world.run(spawn); + const report = await runReported(world, writeKernel); + expect(report.failures.map(failure => failure.message)).toEqual([`An entity lacks "optionalTest/velocity", optional in its system: add it with commands.add rather than assigning it.`]); + }); + + it("give systems with the same keys but other optional ones rows of their own", async () => { + await using world = inline(); + await world.run(spawnApart); + const first = await runReported(world, fromFar); + const second = await runReported(world, fromNear); + expect([...first.failures, ...second.failures]).toEqual([]); + expect(nearSeen).toEqual([undefined]); + expect(farSeen).toEqual([[undefined, 7]]); + }); + + it("stamp a chunk changed where tests of two of them write one component", async () => { + await using world = inline(); + await world.run(spawnStamped); + await world.run(watchStamped); + marked.length = 0; + await world.run(markEither); + await world.run(watchStamped); + expect(marked).toEqual([1]); + }); + + it("fail their system when assigned undefined, which removes nothing", async () => { + await using world = inline(); + await world.run(spawnStamped); + const report = await runReported(world, clearNear); + expect(report.failures.map(failure => failure.message)).toEqual([`"optionalTest/near" was assigned undefined, which removes nothing: remove it with commands.remove.`]); + }); + +}); diff --git a/packages/ecs/src/implementation/world/perf.md b/packages/ecs/src/implementation/world/perf.md index ac28b9a16..26b54c0e7 100644 --- a/packages/ecs/src/implementation/world/perf.md +++ b/packages/ecs/src/implementation/world/perf.md @@ -124,6 +124,7 @@ A report for every run and tick, `Report.run` written in place and `Report.error - **About 125 ns a run:** two `performance.now()` calls for `elapsed`, 31 ns each, then locating the report, writing its struct and stamping it. - **`Report.run` is written field by field** through its layout's leaves, not the general struct writer: `run idle` 583 → 500 ns, and `run observer` 375 → 291. - **What a tier ran is worked out once per list of runners:** counting each runner every run took `run 50 idle` to 4.08 µs a run. +- **A world's first 256 runs each spawn their report,** so each has a sync point applying a spawn: 3.2–5.1 µs a run in node against 1.1–1.5 µs once reports are reused, about 1 ms over a world's life. One command buffer spawns every report, 3.8–5.7 → 3.2–5.1 µs. Benchmarks timing a run after another (`after` in `perftest/benchmarks.ts`) warm up past the 256, so they time a world's steady state: the filtered `up` benchmarks timed the spawn's sync point instead, `up one root changed` 0.042 against 0.024 ms, `up filtered after a reparent` 0.028 against 0.018, `up small tree in a big world` 0.020 against 0.011. - **A reused report keeps its errors and takes new ones in place,** so a run with a blocking tier every tick changes no archetype, and its sync point isn't one tails wait for. Strings in shared memory, alternating against `c6bb3c83`, node, five and six rounds, medians: every touched benchmark within noise (`run idle` a mean over 20,000 runs 626–644 against 622–660 ns, `run 50 idle`, `run observer`, `loop tick idle`, `sync point one spawn`, `move 1K`, `main thread 1K`, `spawn bulk`, `despawn bulk`, `despawn one`, `add tag`, `add value`, `remote run`, `joined observer resource`, `joined observer 10K`, `handoff`, `tail prompt`, `tail late snapshot 10K`, `despawn objects 10K` 97.5 against 97.7 ns). @@ -133,6 +134,12 @@ Strings in shared memory, alternating against `c6bb3c83`, node, five and six rou - **Records up to 512 bytes are carved from slabs** by power-of-two size class: `string write 10K joined` 142 → 127 ns against the arena's blocks, whose size class costs a `Math.log2` per record. - **A removed row lets go only of its archetype's string storages,** listed once per archetype: `despawn objects 10K` 104 → 97.7 ns against a check of every storage. +A joined thread's finalizers reading its object resources from its mirror, alternating against `998738a3`, node, three rounds: `despawn objects 10K` 98.2–102 against 99.6–103 ns, within noise. The mirror's archetype holding the resource entity is found once per group of stops that reads one, kept and checked before it's searched for again. + +A joined thread's finalizers reading string resources from copies in the stop list, once per group, alternating against `13eb6d6f`, node, six rounds, medians, on a machine busy elsewhere: all within noise. New `despawn objects 10K string resource`, the despawn benchmark's finalizer reading a string resource too, 95.5 ns a despawn against 99.5 reading the live record (the benchmark run on the old build); `despawn objects 10K` 98.0 against 97.2 ns, `string write 10K joined` 129 against 129 ns, `joined observer strings 10K` 478 against 500 µs a tick. Finalizers reading no string resource copy nothing and the group holds no extra bytes. + +Their linear resources copied into the stop list too, once per group, alternating against `92b823c9`, node, six rounds, medians, on a machine busy elsewhere: all within noise. New `despawn objects 10K linear resource`, the finalizer reading a number and a vec3 resource too, 99.5 ns a despawn against 99.1 reading them where they are (the benchmark run on the old build); `despawn objects 10K` 100.5 against 101.0 ns, `despawn objects 10K string resource` 98.4 against 99.3 ns, `joined observer 10K` 584 against 567 µs a tick, a path this doesn't touch, its rounds spread 500–754 µs. + Sync points waiting only for tails that read the world's memory, against `c21e8e1e`, node: `tail late snapshot 10K` 474–484 → 9.0–9.5 ns a row copied, against 8.9 for `e3c30aa1` measured beside it (its record 8.2), and in chromium 450 → 6.0 ns (its record 5.0, one or two 5 µs timer steps); within noise in both: `tail prompt`, `tail overlap`, `tail overlap waiting`. Since every run spawns its report, until 256 are, almost every sync point has something to apply, and each one settled every tail, so the run waited out the whole body of the benchmark's 4 ms tail ahead of the late one, whose head it had claimed. - **A tail reading only its own thread's objects isn't settled at a sync point,** having nothing there a sync point writes: 474 → 12.8 ns. - **The benchmark times the run alone,** reading its report after: 12.8 → 9.0 ns. Reading it through `runReported` timed a second run as well. @@ -142,6 +149,49 @@ GPU copies uploading only the chunks CPU writes changed, against `a4eef10e`, chr - **An upload writes each run of adjacent chunks stamped since its last upload** or holding rows a sync point appended or disturbed, clamped to the row count; a column with GPU writes the CPU hasn't read back writes only those rows. A whole-column stamp (`columnChanged`) uploads nothing: only GPU kernels make one, and their writes are on the GPU already. - **A WASM kernel's column addresses are kept per load as well as per archetype version:** a kernel loaded again to stamp a component that already kept ticks went on passing the muted column's address, 0, and wrote its stamps over the entity table there. +Render systems with `order`, against `9daaa3f8`, chromium, three runs each: new `gpu ordered render unchanged 50K`, 50K shapes and images, two archetypes interleaved in depth, drawn as one table in a sorted index's order with nothing changed, 0.030–0.036 ms a run, against 0.045–0.049 ms for the same 50K drawn without `order` as two tables, which writes both tables' records each run. New `gpu ordered render one key 50K`, a target system moving one item to another depth and then the ordered draw, 0.268–0.294 ms a run, against 0.042–0.050 ms unordered: listing the 50K entries in order costs 0.167–0.175 ms (3.4 ns an entry) and writing the 200 KB block 0.067–0.070 ms, timed inside the run. +- **The order block is listed and written only when the index's entries changed or moved rows,** by the index's `changed` tick and a `moved` tick its refresh stamps when it relocates an entry with its key unchanged, or when the matches grew; the tables block only when a column's address changed; the record only when one of its four words did. An unchanged run writes nothing to the queue and allocates nothing: the table, its `bind`, the tables list and the context are reused. +- **An entry is one u32:** the matching archetype's slot in the high bits, as few as the matches need and at least one, and its row in the rest, so the shader masks by the record's shift. + +A 2D editor's first tick after loading a document, against `826c45ad`, node, alternating, three rounds: new `editor load tick 50K` 31.4–33.2 → 20.0–20.4 ms, and `up load` 221–223 → 182–186 ms; within noise: `up depth 10`, `up depth 1000`, `up after a spawn`, `up filtered after churn`, `up filtered after a reparent`, `up small tree in a big world`, `index rebuild 1M main`, `order run 100K`, `order filtered 100K 1%`, `children lookup`, `sorted range 1M`, `sorted page 100K`. The benchmark spawns 50,201 nodes depth first, a document, 200 groups, two subgroups in each and 124 shapes and images in each subgroup, after a first tick over the empty world, then times the next tick: two `up` row systems with `changed` filters writing a position and an opacity, a layout looking up each group's children through a hash index on the parent, and a paint system run in a sorted index's paint order. Before, each system's part of the tick, run alone after the load: the position `up` 13.1 ms, the opacity `up` 10.4, the layout 2.45 (its index's build), the paint 5.43 (its index's build and 50K ordered rows). Of the opacity `up`'s profile, building the tree 22%, marking every row as passing the filters (`changedRows`, `mark`, clearing the marks) 25%, building the order 19%, listing the marked rows from it 10%, and the body 20%. After: 6.4, 4.4, 2.15 and 4.4 ms. +- **A filtered run after its tree was built again runs the whole order,** as an unfiltered one does, since every row passes then: the tick 32 → 25.1 ms, the position `up` 13.1 → 8.5 and the opacity `up` 10.4 → 7.1. Marking 50K rows, each in a bucket of its depth, and scanning the order for the marks cost as much as the run. A later sparse run is cold for it: `up one root changed` 28 → 33 µs a run over the benchmark's 100 rounds, the marking path no longer warmed by a first full pass, and 14 µs either way after 1,000 rounds. +- **The tree's build reads each archetype's parent column once,** rather than finding the archetype, column and layout for every entity: the opacity `up` 5.35 → 4.5 ms. +- **The build sets every level in one walk per entity,** up to a root or an entity already set and back down, rather than settling the entities as journaled changes do, which walk each one's children again: the opacity `up` 4.77 → 4.49 ms. +- **An order build's scratch is shared by every `up` of a thread:** the second system reuses the first one's arrays rather than allocating its own, 13 of them 50K entries each: the tick 20.6 → 20.0 ms, medians of five rounds. +- **A sorted index's rebuild skips the sort when its entries are in key order already,** as a load in document order lists them, checked in one pass that stops at the first pair out of order: the paint 5.6–5.8 → 5.1–5.3 ms. +- **A main-thread system run in an index's order binds its row in place when the archetype changes,** rather than looking the shared row up again, which happens at every row when archetypes interleave in the order: the paint 4.85 → 4.5 ms. + +Long hash buckets left unsorted until read by position, against `1ff6c206`, node, alternating, medians of five: new `bucket move 1K/10K/100K length`, one entity moved between the two buckets of an index on a boolean and a run reading both lengths, as toggling a todo is, 9.0 → 5.7, 63.9 → 5.5 and 418 → 5.6 µs a move (two runs' fixed cost included); new `bucket move … in order`, the run reading each bucket's first ten rows, 9.4 → 6.8, 65.2 → 12.7 and 416 → 68.9 µs. Within noise: `index refresh 1M 1%` 159 → 152 ns, `unique lookup 1M`, `children lookup`, `grid neighbors 100K main` and `workers`, `sorted range 1M`, `sorted page 100K`, `order run 100K`, `order filtered 100K 1%`, `index idle`, the keyless and `lookup 1K entities` benchmarks, `index rebuild 1M main` and `workers` (7.74 → 8.19 ms, runs spread 7.2–8.4 both); `rekey 1M` 13.6 → 14.4 ms (+6%), its 10K buckets of 100 taking about six moves each a frame, which a batched merge did in one pass. +- **A long bucket's change leaves a hole or joins a tail,** found by binary search over the part in entity order or by the tail position kept per entity, so moving an entity costs no shift of the bucket; its record's length counts entries, so `length` and `changed` read no order. +- **Sorting is lazy where it can be, eager where it must be:** a list's `at` on the world's thread sorts its bucket; `writeIndexParameters` sorts every unsorted bucket before kernels, workers and joined threads read the arrays, since they read entries by position and can't write them. +- **A sort moves 8-byte words:** shared memory copies an element at a time, a 150K-word `copyWithin` 193 µs as `Int32Array` against 25 as `Float64Array`; a shift by an odd number of words goes through a scratch, 58 µs, once it's at least 1,024 words, the two views of it it makes costing tens of nanoseconds against a microsecond's copy; a shorter one copies a word at a time and allocates nothing. `bucket move 100K in order` 222 → 68 µs; every odd shift a word at a time, 180 µs, and `bucket move 10K in order` 12.7 → 23 µs. +- **A full unsorted bucket with holes sorts in place rather than moving its segment, and one that grows gets half its length more spare slots:** `rekey 1M` 15.5 → 14.4 ms, from relocated segments, buffer growth and compactions. + +Block ticks, alternating against `998738a3`, node, medians of five rounds for the filter benchmarks and three for the rest; chromium, medians of three. New benchmarks (`perftest/filters.ts`), 100K rows of one archetype after a write of one row, ten rows 10,000 apart, or every row, then eight main-thread systems or four WASM kernels filtering on `changed`, timed a run of them: + +| Benchmark | Before | Now | +|---|---|---| +| `changed one 100K`, eight systems | 34.9 µs | 5.04 µs | +| `changed few 100K` | 421 µs | 11.5 µs | +| `changed all 100K` | 8.49 ms | 4.69 ms | +| `changed one 100K two filters`, the other component unwritten | 37.6 µs | 8.42 µs | +| `changed one 100K kernels`, four WASM kernels | 5.21 µs | 4.42 µs | +| `changed few 100K kernels` | 15.2 µs | 7.33 µs | +| `changed all 100K kernels` | 504 µs | 420 µs | +| `sparse changed`, 1M, 1% scattered | 354 µs | 317 µs | +| `sparse changed clustered` | 29.3 µs | 28.0 µs | + +In chromium: `sparse changed main` 295 → 260 µs, `sparse changed workers` 70 → 60 µs, `changed few 100K` 115 → 5 µs, `changed one 100K two filters` 40 → 5 µs (timers step by 5 µs), `changed all 100K kernels` 485 → 395 µs. Readers of key and parent ticks: `up one root changed` 27.8 → 26.4 µs, `up filtered after churn` 54.5 → 51.4 µs, `rekey 1M` 13.6 → 12.4 ms, `read run changed` 14.5 → 13.3 µs. Writes within noise in node (`create`, `create flat`, `move`, `churn`, `retag`, `emit`, `emit main`, `reduce`, `chain`, `follow`, `spawn bulk`, `spawn bulk kernel`, `add tag`, `add value`, `add replace`, `despawn bulk`, `index refresh 1M 1%`, `sync point one spawn`, `move 1K`) and chromium (`move`, `chain`, `emit` and `spawn bulk kernel`, main and workers), and so are `run idle`, `run observer`, `index idle`, `observer idle 100`, `joined observer 10K` and `observe gpu entity factory`. Memory: a ticked storage has two block arrays, one for a constant, each 128 bytes per chunk of its capacity, so at least 128 bytes: 1.6% of its row ticks once its capacity is 1024 rows or more, and as much as them at the initial capacity of 16 rows. Each storage's layout record takes two more words. + +- **Every row tick's writer raises its block's tick,** one store beside the chunk's: row writes on any thread, kernels (once per call for a block every row of the call reaches, in JS and WASM), the sync point's writes, spawns and moves, swaps into removed rows, a GPU kernel's column stamp lifted onto a joined thread's rows, and joined threads' own ticks. The arrays live in the arena beside the chunk ticks, and the layout directory gives workers and joined threads their offsets. +- **Main-thread, worker JS and joined-thread filters skip chunks, then blocks, then rows, each in a loop over one array,** and a passing range runs on by row ticks alone until a row doesn't pass; the scan was about 5 ns a row, with a branch per row on the filter count, and is now under 1 ns: `changed all 100K` 8.49 → 4.69 ms. +- **WASM kernels find runs of passing blocks in an outer loop and run their rows in an inner one,** a chunk's passing blocks read into a mask once and each run found with `ctz`, a chunk with more than half its blocks passing taken as one run; every row pays one comparison against the run's end, where it paid a test of whether it started a chunk: `changed all 100K kernels` 504 → 420 µs. +- **Index refreshes and `up` find changed key-part and filter rows by blocks too.** + +Joined threads' index lookups checking archetypes once a run, alternating against `13eb6d6f`, node, on a machine loaded by other work, medians of 9 and 11 rounds: +- **A row a lookup gave stays bound while one count stays the same,** as on the world's thread: each flush that writes an existing archetype's record again adds one to a header word, and a joined thread's directory moves its count on once a run when the word, its mirror's archetypes (a rebuild) or its views (memory grown, keys named) changed. Before, every row read loaded its archetype's view, the mirror's entry and the record's version. `joined observer 10K` 485 → 438 µs and 480 → 417 µs a tick; `joined select 10K factory` 524 → 498 µs. Within noise: `joined observer numbers 10K`, `joined observer strings 10K` (their rows bind once per archetype a run either way), `joined observer resource` 4.7 against 4.6 µs, `unique lookup 1M` 79 against 71 ns (its generated site now checks the count, not the archetype's version), `grid neighbors 100K main` 5.94 against 5.99 µs a row. +- **A rebuilt mirror's rows are bound again:** rows compare the view they were bound to, not only its version, so a row a lookup gave before the thread's log lapped no longer reads the objects of the mirror's old columns. + ## Where the gains came from Step figures marked (vitest) are from the node vitest lane: read them as relative. @@ -150,10 +200,14 @@ Step figures marked (vitest) are from the node vitest lane: read them as relativ - Commands as parallel typed arrays, a reusable typed plan, and cached archetype edges: create 198 → 68 ms, churn 22 → 10 ms, retag 24 → 11.7 ms, emit 7.3 → 3.1 ms (vitest). - `Object.keys` instead of `for…in`, and buffers reused across runs: small gains. - Allocation-free kernel commands (`begin`/`value`/`component`/`spawn`): emit 3.2 → 1.98 ms (vitest). +- Kernel commands write the buffer themselves, not through `pushCommand` and `pushComponent`: once V8 reoptimized a + kernel, the second call stayed a call and boxed each entity, a double past 2^32. `despawn bulk kernel` 4.60 → 4.12 ms; + `despawn bulk` compiled 4.55 ms against 4.32 as written before, 4.10 after. - Inline value writes instead of closures, an 8-shape spawn cache, edges indexed by component id, archetypes grown once per sync point: create 38 → 27.6 ms. **Change filters.** - Chunk ticks, the newest tick per 1024 rows, so filters skip unchanged chunks: clustered sparse to ~0.09 ms. +- Block ticks, the newest tick per 64 rows, so filters skip a changed chunk's unchanged blocks: eight systems finding one changed row of 100K 34.9 → 5.04 µs (above). - Filters inside WASM kernels, one call per archetype instead of one per passing range: uniform sparse 3.5 → 1.15 ms, clustered to 0.02 ms. **Lookups.** @@ -192,7 +246,7 @@ Step figures marked (vitest) are from the node vitest lane: read them as relativ **Tails.** A tail's snapshot is laid out for the rows it found passing and copied a run of consecutive rows at a time, its entities and each linear read one `set` each, its positions, archetypes and rows written in place, and tags marked only for a tail that writes objects: `tail late snapshot 10K` 29.8 → 6.7 ns a row in node, from a row at a time through a scratch list, whose tag pass alone cost 3.3 ns a row for a tail that writes nothing. The copy is what lets the world's work after its writer overlap a late tail: `tail overlap` 2.53 ms a tick against 4.70 for `tail overlap waiting` in node (below). **Indexes.** Node numbers, `pnpm perf`. -- Keeping one current costs nothing while its entities rest: a refresh checks its journal position and its key parts' last writes, which systems' writes and sync points' values mark per component, `index idle` 9 ns an index. Moving 1% of 1M entities to other keys, `index refresh 1M 1%`, 4.3 → 1.56 ms: rows listed with their archetype and row, without marks when one key part's scan is all a refresh reads, short buckets' operations applied as they come and long ones merged once, ordered by a counting sort by bucket; a refresh whose changed chunks' rows pass an eighth counts the rows' own ticks before building again, so a few writes spread over every chunk patch: `rekey 1M`, 1M particles 6% of which cross into another cell a frame, 46.9 → 14.1 ms, against 4.8 ms for the same frame without the index. +- Keeping one current costs nothing while its entities rest: a refresh checks its journal position and its key parts' last writes, which systems' writes and sync points' values mark per component, `index idle` 9 ns an index. Moving 1% of 1M entities to other keys, `index refresh 1M 1%`, 4.3 → 1.56 ms: rows listed with their archetype and row, without marks when one key part's scan is all a refresh reads, short buckets' operations applied as they come and long ones left unsorted; a refresh whose changed chunks' rows pass an eighth counts the rows' own ticks before building again, so a few writes spread over every chunk patch: `rekey 1M`, 1M particles 6% of which cross into another cell a frame, 46.9 → 14.1 ms, against 4.8 ms for the same frame without the index. - Lookups: a call site's encoding, hash and probe generated for its shape and the index's layout, buckets as records of hash, segment and key beside two-word slots of hash and bucket, and a row bound only when its archetype changed: `unique lookup 1M` 86 → 45–50 ns. A microbenchmark of the same probes over the same table reads 29 ns sequential and 85 random a lookup; an identity hash for integer keys is under "Tried and not kept". `children lookup` 32 ns a child, `grid neighbors 100K main` 10.5 µs a row of 27 probes. - Sorted ranges: a search over a compact array of each block's last key and entity, comparing words only for keys of words, and `at` walking blocks through a cursor: `sorted range 1M` 1.08 → 0.76 µs a range of ten; `sorted page 100K` 22 µs a change against 1.12 ms for `select page 100K`. - Kernels: probes in straight-line WASM over the index's arrays, `grid neighbors 100K workers` 179 ns a row across nine workers. @@ -210,9 +264,11 @@ Step figures marked (vitest) are from the node vitest lane: read them as relativ **Main thread.** Row getters chosen once, and tuple reads and writes in one loop: 50 systems 3.2 → 1.63 ms (vitest). - Accessor rows each get their own prototype. Built from `{}`, every row after the first that defines an accessor with another getter went to V8 dictionary properties. One row per set of reads and writes, shared across systems and archetypes and rebound per pass, so a body sees one shape; f32-lane tuples read as array literals, and scalars and f32 lanes written directly. Resource cells in a context are resolved once per run. On node: 50 systems 1.1 → 0.45 ms, churn 55 → 32 ms, retag 74 → 59 ms; 50 distinct bodies over three row shapes 1.75 → 0.53 ms; two writing systems over 100K 19.5 → 1.9 ms; 20 systems reading a resource per row 1.26 → 0.22 ms. +**f64 lanes for kernels computing at the body's precision.** A kernel compiled without `kernel: true` computes in f64, as its body does, so it can't use f32 lanes. Across rows it runs f64x2 lanes instead: each f32 pair loaded with `load64_zero` and promoted, each operation the f64 one, demoted only on store, so results are the body's bit for bit (`wasm.test.ts`, `precision.test.ts`). Medians of five, node, against one row at a time in f64 and against `kernel: true`'s f32 lanes: `move` 0.399 → 0.177 ms (f32 lanes 0.090), `chain` 6.48 → 3.70 ms (1.55), `move 10K` 16.2 → 7.7 µs (4.5). Filtered kernels and reductions run a row at a time in both, so `sparse changed` and `reduce` are unchanged; a padded vec3's elements aren't whole vectors, so it does too. + ## GPU particles -The sample (`pnpm sample`, which opens `/particles/`) draws the visible and enabled quarter of the perf particles as points, wrapping positions in the vertex shader, and moves them with the perf `move` kernel: on the GPU (`?mode=gpu`, positions never come back), on workers with the positions uploaded for drawing each frame (`?mode=cpu`), or not at all (`?mode=static`). Headless chromium on Apple silicon at 60 fps, 10 s per sample after warmup, CPU time per frame by process from `ps`, main-thread task time from the DevTools protocol. Four rounds at 1M (250K drawn), two at 4M (1M drawn): +The sample (`pnpm dev`, the GPU Particles tab, or `/particles/` alone) draws the visible and enabled quarter of the perf particles as points, wrapping positions in the vertex shader, and moves them with the perf `move` kernel: on the GPU (`?mode=gpu`, positions never come back), on workers with the positions uploaded for drawing each frame (`?mode=cpu`), or not at all (`?mode=static`). Headless chromium on Apple silicon at 60 fps, 10 s per sample after warmup, CPU time per frame by process from `ps`, main-thread task time from the DevTools protocol. Four rounds at 1M (250K drawn), two at 4M (1M drawn): | Per frame, ms | draw only | CPU move + upload | GPU move | |---|---|---|---| @@ -250,6 +306,12 @@ move, chromium, by worker count and entity count (a quarter of the entities move ## Tried and not kept +- **Lists made at runtime finding their shape through a `WeakMap` by list, or through a record of their own beside the + index property:** with `Archetype.create` gone, `spawn sixteen archetypes`, whose lists are built by `Array.from`, + took 9.8 ms and 6.9 ms against 3.96 with them indexed by hand; a microbenchmark gave 43 ns a lookup for the second + property against 5.4 for an index. The record now lives in the index property itself, 12 ns checked: 4.37 ms, the + rest the check of the list's keys that a list made at runtime needs, since it may change. Worth revisiting if a way + to know a runtime list is unchanged without reading its keys appears. - **Observer posts without allocating per post** (a reused list of chosen posts, no closure for posting, the block's cleared words a constant list, settling over reused scratch rather than a copy of the posts): `joined observer resource` 7.2–7.6 µs a tick against 6.4–8.3 without, `joined observer 10K` 0.52–0.69 ms against 0.51–0.76, within noise. Worth revisiting once posting shows in a profile of a world with many observer systems on joined threads. - **Observer factories: a count or selection with `where` tracked through an index without a key,** every row read through `at` and tested after any change: `count 1M changed factory` 23.2 ms; a sorted index on the `where` key, each entry looking up its range, takes 42 µs. Kept for selections with a page and no `order`, which stop at the page's end in entity order. - **Observer factories: descending pages finding each run of equal keys by galloping back from its last row,** three reads of most rows: `select page 100K factory` 23 µs a change; reading each row once, holding a run until its key changes, 16 µs. @@ -285,6 +347,10 @@ move, chromium, by worker count and entity count (a quarter of the entities move - **Copying drained columns into fresh, exactly sized arrays:** planning 2.60 → 2.45 ms in chromium, but the copies cost more than that. - **The main thread claiming pieces too:** no gain; workers that wake late are slow at first, so the main thread does about what it would alone. - **A loop per main-thread system** (eval or emitted by the plugin): 5–10% on top of fast-property rows; a micro-benchmark of 50 systems with one loop literal each measured 16%. The tuple each `row[component]` read allocates stays, and it costs more than the shared call site. Worth revisiting with compiled main-thread bodies, where reads become column loads. +- **A WASM kernel testing block starts in its one row loop,** a block's tick checked at each row that starts one, inside which a chunk's start checks the chunk's: `changed all 100K kernels` 504 → 576 µs, from a branch taken every 64 rows that the predictor misses, where the chunk test it replaced was taken every 1024. Checking blocks only at the eight-row SIMD checks, every eighth of them, still cost 7% in a microbenchmark of one kernel over 100K rows that all changed. The outer loop over runs of blocks takes it to 420 µs. +- **Every run of passing blocks as its own run in WASM,** however many of a chunk's blocks pass: 1% of 100K rows changed, scattered, one filter, in a microbenchmark, 31 µs against 24 µs for chunks alone, since a run's end costs more than the eight-row checks spend on a block between two runs. Taking a chunk with more than half its blocks passing as one run measured 23.9 µs, more than a quarter 23.9 µs and more than three quarters 32 µs; one changed row 0.21–0.25 µs against 0.33 for chunks alone at each. +- **Blocks of 32 rows:** against 64, eight systems finding one row of 100K 4.75 against 4.92 µs and ten rows 10.2 against 11.5 µs, but `sparse changed` 421 against 294 µs, a WASM kernel's chunk of 32 blocks with 1% of rows changed having fewer than half passing, so its many runs each pay their end; and twice the memory. Worth revisiting if one-row changes dominate real filters on main-thread systems and WASM finds runs another way. +- **Several filters scanned filter by filter,** each array's next newer tick found in a loop of its own and the earliest taken, in a microbenchmark of three filters: one change of 10K rows 0.40 against 0.39 µs, 1% of 10K 32.3 against 28.8 µs, 100K rows with nothing changed 0.18 against 0.38 µs. Mixed, so the filters are tested together at each index. Worth revisiting if systems filtering on several components over large archetypes that rarely change show in a profile. - **Removing a sync point's rows after all its entries, each archetype's from the highest,** so every row that stays moves at most once: churn 1M +13%, retag +6%. Sorting, the map of rows and a second pass over scattered rows cost more than the copies saved, and copying cells is only about 12% of retag; planning, the system's body and per-entry work make up the rest. Worth revisiting only with column-major moves per pair of archetypes, where removals are batched too. - **Unrolled f32 lane writers for staged spawns:** create 1M −5% for 30 more lines. Profiles attribute the caller's tuple allocations to the writer, which inflates it. Worth revisiting if spawns stop allocating tuples. - **Faster value-object spawns** (`create flat dynamic`): `Object.keys` and the caller's own spreads dominate, on a path the plugin already avoids for literal keys. Worth revisiting only if spawns from data objects matter to real workloads. @@ -337,11 +403,18 @@ move, chromium, by worker count and entity count (a quarter of the entities move - **Reading a patch's keys, hashes and buckets ahead in batches of 64,** so their misses overlap: `index refresh 1M 1%` 165 → 173 ns and `rekey 1M` 14.7 → 23.7 ms. A move in cache costs the same, so there was little latency to hide. - **A list's pool in three banks, its first, last and middle positions,** for hash lists too: `grid neighbors 100K main` 3.63 → 3.84 µs a row from choosing the bank in `at`. Only sorted lists keep a second pool for their last positions. - **Making a pool's rows for every position a list holds:** a 100K-row sorted list made 100K rows for a page of 20; rows are made ahead for the first and last 256 positions only. +- **The tree's build linking parents over locals,** rather than through `link`: the opacity `up` 4.44–4.65 against 4.55–4.76 ms, about 2%, within noise. +- **An index rebuild listing its entities in one loop over locals,** rather than a closure per entity: `index rebuild 1M main` 28.3–30 against 28.7–34.7 ms, and the editor's layout 2.33–2.84 against 2.35–2.56 ms, no gain. +- **Binding a row without making a slot object per component:** the editor's paint 5.02–5.06 against 4.99–5.07 ms, no gain; V8 removes the allocation. +- **Unsorted buckets sorted once their holes and tail passed a quarter of their entries,** rather than when full: `rekey 1M` 15.5 ms against 14.4, sorting buckets of 100 every few frames that nothing reads by position. A bucket at least 256 long before it's left unsorted, shorter ones shifted: 14.8 ms, no better. Merging a long tail an entry at a time rather than by a binary search and a move per tail entry: no measurable difference at `rekey 1M`, 15.4–16.7 ms against 15.4–15.8 then; worth revisiting where tails of thousands are sorted. +- **Moves of 4-byte words over shared memory,** `copyWithin` on the entries' `Int32Array`: `bucket move 100K in order` 222 µs against 68 with 8-byte words; closing holes by scanning the whole bucket rather than stopping at the last, 245 µs. +- **Presizing a hash index's table for a rebuild,** measured rather than tried: the editor's 601 parents rehash and place their buckets in 0.2 of 34 ms of profile, since a table is sized for the buckets it last held. Worth revisiting for a first build of an index with as many keys as entities. ## Next - `grid neighbors 100K main`, 3.66 µs a row against about 1.6 µs for the kernel on one worker: each neighbor costs about 10 ns in loads of the list's state, the pool and the row's slots; rows whose values carry their index and views as their own fields would save a few of them. - Index refreshes, 141 ns a moved entity: two ordered bucket edits and a probe, in cache. +- `editor load tick 50K`, 20 ms: each `up` system builds its own tree and order, about 1.3 ms each over 50K rows, though systems with the same `through` and matches build the same ones; sharing them would need each system's own journal position, touched rows and order patches. Of the rest, the `up` bodies through accessor rows take about 2 ms a system, the 50K ordered paint rows about 2 ms, and each index's rebuild about 1.5 ms, 28 ns an entity listing and encoding its key. - `loop tick add remove`, a new system each tick: about 2 µs more a tick since indexes (above), in `System.create` and `add`, as more garbage; the paths a run of an existing system takes measure as before. - Index rebuilds across the pool: `index rebuild 1M workers` 6.7 ms, nine workers 4.3× one, every phase scattering or gathering by partition; an estimated 2–3 ms if the scatters reached memory bandwidth. Rebuilds are rare, a refresh moving more than an eighth of an index. @@ -351,6 +424,6 @@ move, chromium, by worker count and entity count (a quarter of the entities move - Kernels taking `%` of an f64: a conditional on `resources[clock].tick % 2` costs about 33 ns a row in node, 10K rows 333 µs against 8.5 µs without it. - Replaying the move log: `log replay` takes 24 ns a move in node and 36.5 ns in chromium, two entries each, against about 1 ns to move the bytes; the cost is the per-entry dispatch and the scattered rows a swap reads. - Ordered selections recomputed every tick: `joined observer 10K` spends most of its 0.4–0.5 ms a tick sorting 10K entities. -- `joined observer 10K`, back to back: lookups read the rows in place, so the world's writer waits for each run the thread claimed, about 1.07 ms, in 1,451 of 3,000 ticks; the thread's run is about 60% the benchmark's own sort, and of the rest `at` through the index without a key (14%) and a load of the archetype's version per row (12%), which a check of versions once a post would save. +- `joined observer 10K`, back to back: lookups read the rows in place, so the world's writer waits for each run the thread claimed, about 1.07 ms, in 1,451 of 3,000 ticks; the thread's run is mostly the benchmark's own sort, and of the rest `at` through the index without a key. - A tail's body reading from a snapshot ran slower in one case: a loop counted down from an unbounded integer resource (an f64) took 13.6 ms against 11.2 in place over 10,000 rows of 200 steps, in node, while counted as an i32 it took 10.8 against 11.2; deopts didn't account for it. - Main-thread spawns reserving in blocks too: `spawn bulk` still takes one atomic add per spawn. Finalizers spawn during the sync point, after it materializes the run's reservations, so a world's block would have to end before each materialization. diff --git a/packages/ecs/src/implementation/world/pool-size.cases.ts b/packages/ecs/src/implementation/world/pool-size.cases.ts index 6de8781be..3db1e80e5 100644 --- a/packages/ecs/src/implementation/world/pool-size.cases.ts +++ b/packages/ecs/src/implementation/world/pool-size.cases.ts @@ -14,6 +14,23 @@ export const poolSizeCases = (load: () => Promise) => describe(" const fixture = await load(); expect((await fixture.resized(1, [null])).started).toEqual([1, 1 + fixture.platformCount()]); }); + it("starts the pool only once a kernel has rows enough for workers", async () => { + const fixture = await load(); + const { started, values } = await fixture.whenWarranted(); + expect(started).toEqual([0, fixture.platformCount()]); + expect(values).toEqual([0, 4, 8, 12]); + }); + it("starts no pool for rows no kernel runs over", async () => { + expect(await (await load()).unmatched()).toBe(0); + }); + it("reports a kernel that fails to load on workers as the pool starts, and runs it here from then on", async () => { + const fixture = await load(); + const { failures, loads, values } = await fixture.failedLoad(); + expect(failures[0]).toEqual([expect.stringMatching(/^thread: .*no kernel here/)]); + expect(failures[1]).toEqual([]); + expect(loads[1]).toBe(loads[0]); + expect(values).toEqual([0, 4, 8, 12]); + }); it("sizes the kernel pool at the next sync point, with the same results", async () => { const fixture = await load(); const { started, values } = await fixture.resized(2, [3, 0]); diff --git a/packages/ecs/src/implementation/world/pool-size.fixture.ts b/packages/ecs/src/implementation/world/pool-size.fixture.ts index efa934697..2504531c1 100644 --- a/packages/ecs/src/implementation/world/pool-size.fixture.ts +++ b/packages/ecs/src/implementation/world/pool-size.fixture.ts @@ -1,27 +1,41 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Component } from "../../interface/component/component.js"; import { workers } from "../../interface/resources/workers.js"; import { System } from "../../interface/system/system.js"; import { readEach, select } from "./reads.test-helper.js"; import { createWorld } from "./create.js"; import { Observe } from "../../observe/index.js"; import { countingWorkers } from "./platforms.test-helper.js"; +import { reportOf } from "./reports.test-helper.js"; +import type { WorkerPlatform } from "./worker-platform.js"; +import type { F32 } from "../../interface/math/index.js"; /** Scenarios of the `workers` resource sizing the pool, run built in node and from source in chromium. */ const value = Symbol("poolSizeTest/value"); declare module "@adobe/ecs" { export interface Components { - [value]: { type: number }; + [value]: { type: F32 }; } } -Component.register(value, { type: { type: "number", precision: 1 } }); -const seed = System.create({ name: "poolSizeTest/seed" }, ({ commands }) => { +const other = Symbol("poolSizeTest/other"); +declare module "@adobe/ecs" { + export interface Components { + [other]: { type: F32 }; + } +} +const others = System.create({}, ({ commands }) => { + for (let i = 0; i < 4096; i++) commands.spawn([other])({ [other]: i }); +}); + +const seed = System.create({}, ({ commands }) => { for (let i = 0; i < 4096; i++) commands.spawn([value])({ [value]: i }); }); -const double = System.create({ name: "poolSizeTest/double", kernel: "cpu", components: { write: [value] } }, row => { row[value] = row[value] * 2; }); -const resize = System.create({ name: "poolSizeTest/resize", input: { type: "object", properties: { size: { anyOf: [{ type: "integer" }, { type: "null" }] } } }, resources: { write: [workers] } }, ({ input, resources }) => { +const few = System.create({}, ({ commands }) => { + for (let i = 0; i < 16; i++) commands.spawn([value])({ [value]: i }); +}); +const double = System.create({ kernel: true, components: { with: [value] } }, row => { row[value] = row[value] * 2; }); +const resize = System.create({}, ({ resources }, input: { readonly size: number | null }) => { resources[workers] = input.size; }); @@ -65,3 +79,77 @@ export const resized = async (initial: number, sizes: readonly (number | null)[] await world.dispose(); } }; + +/** + * Workers started after a kernel runs over fewer rows than a piece for a worker, then after it runs over enough, and + * the first values it doubled. + */ +export const whenWarranted = async (): Promise<{ readonly started: readonly number[]; readonly values: readonly (number | null)[] }> => { + const counting = countingWorkers(); + const world = createWorld({}, { minimumChunk: 64, pool: counting.platform }); + try { + await world.run(few); + await world.run(double); + const started = [counting.started()]; + await world.run(seed); + await world.run(double); + started.push(counting.started()); + return { started, values: (await readEach(world, await select(world, { required: [value] }), value)).slice(0, 4) }; + } + finally { + await world.dispose(); + } +}; + +/** The platform's workers, failing every kernel load: each kernel's module is one that throws. */ +const failingLoads = (): { readonly platform: WorkerPlatform; readonly loads: () => number } => { + const counting = countingWorkers(); + const broken = "data:text/javascript,throw new Error('no kernel here')"; + const start = () => { + const port = counting.platform.start(); + if (port === null) return null; + return { ...port, post: ((message, transfer) => port.post(message.kind === "kernels" ? { ...message, kernels: message.kernels.map(kernel => ({ ...kernel, url: broken })) } : message, transfer)) satisfies typeof port.post }; + }; + return { platform: { count: counting.platform.count, start }, loads: () => counting.posted("kernels") }; +}; + +/** + * A kernel whose load fails on workers when the pool starts: the failures of that run's report and of the next, the + * kernel loads posted after each, and the values its runs doubled, here. + */ +export const failedLoad = async (): Promise<{ readonly failures: readonly (readonly string[])[]; readonly loads: readonly number[]; readonly values: readonly (number | null)[] }> => { + const failing = failingLoads(); + const world = createWorld({ [workers]: 2 }, { minimumChunk: 64, pool: failing.platform }); + try { + // Registered while no archetype warrants workers, so the pool starts in a later run. + await world.run(double); + await world.run(seed); + const failures: string[][] = []; + const loads: number[] = []; + for (let i = 0; i < 2; i++) { + const run = await world.run(double); + failures.push((await reportOf(world, run)).failures.map(failure => `${failure.kind}: ${failure.message}`)); + loads.push(failing.loads()); + } + return { failures, loads, values: (await readEach(world, await select(world, { required: [value] }), value)).slice(0, 4) }; + } + finally { + await world.dispose(); + } +}; + +/** Workers started after a kernel runs over few rows in a world whose largest archetype no kernel matches. */ +export const unmatched = async (): Promise => { + const counting = countingWorkers(); + const world = createWorld({}, { minimumChunk: 64, pool: counting.platform }); + try { + await world.run(others); + await world.run(few); + await world.run(double); + await world.run(double); + return counting.started(); + } + finally { + await world.dispose(); + } +}; diff --git a/packages/ecs/src/implementation/world/port.test.ts b/packages/ecs/src/implementation/world/port.test.ts new file mode 100644 index 000000000..2127c5441 --- /dev/null +++ b/packages/ecs/src/implementation/world/port.test.ts @@ -0,0 +1,45 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { describe, expect, it } from "vitest"; +import { lentPort, serveLent } from "./port.js"; + +describe("a lent worker's owner", () => { + it("reports a worker it ends as terminated by its owner, though the worker's own end is heard of first", async () => { + let died: ((error: Error) => void) | null = null; + // As a node worker's `exit` can be, before its `terminate` resolves. + const worker = { + onDeath: (handler: (error: Error) => void) => { died = handler; }, + terminate: async () => { died?.(new Error("A world worker exited with code 1.")); }, + }; + const { port1, port2 } = new MessageChannel(); + const reported = new Promise(resolve => { + port2.addEventListener("message", event => resolve(event.data)); + port2.start(); + }); + await serveLent(worker, port1).terminate(); + expect(await reported).toEqual({ kind: "death", message: "A world worker was terminated by its owner." }); + port1.close(); + port2.close(); + }); +}); + +describe("a lent worker", () => { + it("is reported terminated by its owner when its owner ends it, though its port's close is heard of first", async () => { + const data = new MessageChannel(); + const control = new MessageChannel(); + const lent = lentPort({ port: data.port2, owner: control.port2 }); + const reported = new Promise(resolve => lent.onDeath(error => resolve(error.message))); + const portClosed = new Promise(resolve => data.port2.addEventListener("close", () => resolve())); + // Ending the worker closes its port, and the thread it's lent to hears of that before `terminate` resolves. + const worker = { + onDeath: () => {}, + terminate: async () => { + data.port1.close(); + await portClosed; + }, + }; + await serveLent(worker, control.port1).terminate(); + expect(await reported).toBe("A world worker was terminated by its owner."); + control.port1.close(); + control.port2.close(); + }); +}); diff --git a/packages/ecs/src/implementation/world/port.ts b/packages/ecs/src/implementation/world/port.ts index ce5acf70b..05c9fde16 100644 --- a/packages/ecs/src/implementation/world/port.ts +++ b/packages/ecs/src/implementation/world/port.ts @@ -35,9 +35,12 @@ export type Endpoint = { */ export type Lent

= { readonly port: P; readonly owner: P }; -/** Between a lent worker's owner and the thread it's lent to. */ -type ToOwner = { readonly kind: "terminate" }; -type FromOwner = { readonly kind: "death"; readonly message: string } | { readonly kind: "terminated" }; +/** + * Between a lent worker's owner and the thread it's lent to. `closed` tells the owner the worker's port closed; the + * owner answers it in order with what it posts, so a death it reported first arrives first. + */ +type ToOwner = { readonly kind: "terminate" } | { readonly kind: "closed" }; +type FromOwner = { readonly kind: "death"; readonly message: string } | { readonly kind: "terminated" } | { readonly kind: "closed" }; /** A death to report once: the first error wins, and a handler set after it still hears it. */ export const createDeath = (): { readonly die: (error: Error) => void; readonly onDeath: (handler: (error: Error) => void) => void } => { @@ -81,8 +84,9 @@ export const scopeChannel = (): Channel => { /** * The thread a lent worker runs on, as the thread it's lent to sees it: its death is what its owner reports, or either - * port's `close` where the platform has it; `terminate` asks the owner, and closes both ports once it has, or once the - * owner's port closed. + * port's `close` where the platform has it. The worker's port closing is reported once its owner answers that it + * reported nothing before, so an owner's report wins over the close its ending causes. `terminate` asks the owner, and + * closes both ports once it has, or once the owner's port closed. */ export const lentPort = ({ port, owner }: Lent): Port => { const channel = endpointChannel(port); @@ -91,9 +95,10 @@ export const lentPort = ({ port, owner }: Lent): Port void) | null = null; control.onMessage(message => { if (message.kind === "death") death.die(new Error(message.message)); + else if (message.kind === "closed") death.die(new Error("A world worker's port closed.")); else terminated?.(); }); - port.addEventListener("close", () => death.die(new Error("A world worker's port closed."))); + port.addEventListener("close", () => control.post({ kind: "closed" })); // An owner whose thread is gone can't answer; where the platform reports that, its worker is gone with it. owner.addEventListener("close", () => { death.die(new Error("A world worker's owner is gone.")); @@ -116,7 +121,7 @@ export const lentPort = ({ port, owner }: Lent): Port, "onDeath" | "terminate">, owner: Endpoint): { readonly terminate: () => Promise } => { const control = endpointChannel(owner); @@ -127,12 +132,13 @@ export const serveLent = (worker: Pick, "onDeath" | "term control.post({ kind: "death", message: error.message }); }); const end = async (): Promise => { - await worker.terminate(); - if (gone) return; + // Reported before it's ended, so it comes before the worker's end it causes, here and as its port's close there. + if (!gone) control.post({ kind: "death", message: "A world worker was terminated by its owner." }); gone = true; - control.post({ kind: "death", message: "A world worker was terminated by its owner." }); + await worker.terminate(); }; - control.onMessage(async () => { + control.onMessage(async message => { + if (message.kind === "closed") return control.post({ kind: "closed" }); gone = true; await worker.terminate(); control.post({ kind: "terminated" }); diff --git a/packages/ecs/src/implementation/world/ports.cases.ts b/packages/ecs/src/implementation/world/ports.cases.ts index 4a5ecc229..2c7d8e3ef 100644 --- a/packages/ecs/src/implementation/world/ports.cases.ts +++ b/packages/ecs/src/implementation/world/ports.cases.ts @@ -10,8 +10,7 @@ export const portsCases = (load: () => Promise) => describe("thr it(`fail the run whose ${wasm ? "WASM" : "JS"} task's worker is killed from outside mid-task, by ${by === "exit" ? "ending it" : "its owner"}, and every later task on it`, async () => { const fixture = await load(); const { first, second, disposed } = await fixture.killedMidTask(by, wasm); - // A lent worker's death is what its owner reports, or its port's close where the platform has it, whichever comes first. - const death = by === "exit" ? /exited/ : /terminated by its owner|port closed/; + const death = by === "exit" ? /exited/ : /terminated by its owner/; expect(first.length).toBeGreaterThan(0); for (const message of first) expect(message).toMatch(death); expect(second.length).toBeGreaterThan(0); diff --git a/packages/ecs/src/implementation/world/ports.fixture.ts b/packages/ecs/src/implementation/world/ports.fixture.ts index 733e9ae32..a485d8b7d 100644 --- a/packages/ecs/src/implementation/world/ports.fixture.ts +++ b/packages/ecs/src/implementation/world/ports.fixture.ts @@ -1,6 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. /// -import { Component } from "../../interface/component/component.js"; import { workers } from "../../interface/resources/workers.js"; import { System } from "../../interface/system/system.js"; import { createWorld } from "./create.js"; @@ -17,14 +16,13 @@ declare module "@adobe/ecs" { [value]: { type: number }; } } -Component.register(value, { type: { type: "number" } }); -const seed = System.create({ name: "portsTest/seed" }, ({ commands }) => { +const seed = System.create({}, ({ commands }) => { for (let i = 0; i < 100_000; i++) commands.spawn([value])({ [value]: i }); }); /** Long enough per row that a task over 100,000 rows is still running when its worker is killed. */ -const heavy = System.create({ name: "portsTest/heavy", kernel: "cpu", components: { write: [value] } }, row => { +const heavy = System.create({ kernel: true, components: { with: [value] } }, row => { let total = row[value]; for (let k = 0; k < 2000; k++) total = total * 0.5 + k; row[value] = total; diff --git a/packages/ecs/src/implementation/world/precision.test.ts b/packages/ecs/src/implementation/world/precision.test.ts new file mode 100644 index 000000000..3ff824f9d --- /dev/null +++ b/packages/ecs/src/implementation/world/precision.test.ts @@ -0,0 +1,115 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { expect, it } from "vitest"; +import { System } from "../../interface/system/system.js"; +import { workers } from "../../interface/resources/workers.js"; +import type { F32, Vec3 } from "../../interface/math/index.js"; +import { kernelOf } from "./attach-kernel.js"; +import { createWorld } from "./create.js"; + +const a = Symbol("precisionTest/a"); +const c = Symbol("precisionTest/c"); +const difference = Symbol("precisionTest/difference"); +const position = Symbol("precisionTest/position"); +const velocity = Symbol("precisionTest/velocity"); + +declare module "@adobe/ecs" { + export interface Components { + [a]: { type: F32 }; + [c]: { type: F32 }; + [difference]: { type: F32 }; + [position]: { type: Vec3 }; + [velocity]: { type: Vec3 }; + } +} + +// a * a is 1 + 2⁻²² + 2⁻⁴⁶, which f32 rounds to c, so only arithmetic at f64 keeps the 2⁻⁴⁶. +const seed = System.create(({ commands }) => { + for (let i = 0; i < 64; i++) commands.spawn([a, c, difference])({ [a]: 1 + 2 ** -23, [c]: 1 + 2 ** -22, [difference]: 0 }); +}); +const tried = System.create({ components: { with: [a, c, difference] } }, row => { + row[difference] = row[a] * row[a] - row[c]; +}); +const written = System.create({ kernel: false, components: { with: [a, c, difference] } }, row => { + row[difference] = row[a] * row[a] - row[c]; +}); +const seen: number[] = []; +const read = System.create({ kernel: false, components: { with: [difference] } }, row => { + seen.push(row[difference]); +}); + +const differenceAfter = async (system: typeof tried, wasm: boolean): Promise => { + const world = createWorld({ [workers]: 0 }, { wasm }); + try { + await world.run(seed); + await world.run(system); + seen.length = 0; + await world.run(read); + return seen[0]; + } + finally { + await world.dispose(); + } +}; + +it("computes an unflagged kernel at the precision the body as written does, in JS and WASM", async () => { + expect(kernelOf(tried)).toBeDefined(); + const expected = await differenceAfter(written, true); + expect(expected).toBe(Math.fround(2 ** -46)); + expect(await differenceAfter(tried, true)).toBe(expected); + expect(await differenceAfter(tried, false)).toBe(expected); +}); + +/** Values where f32 arithmetic loses what f64 keeps: cancellation, rounding at each step, NaN, infinities and -0. */ +const hard = (i: number): number => [1 + 2 ** -23, 1 + 2 ** -22, 3.4e38, -0, NaN, Infinity, 1e-45, 0.1, -7.25, 16777217][i % 10] * (1 + (i % 7) * 2 ** -20); +// Rows that leave a partial group of lanes at the end. +const rows = 1027; +const seedMany = System.create(({ commands }) => { + for (let i = 0; i < rows; i++) commands.spawn([a, c, difference, position, velocity])({ [a]: hard(i), [c]: hard(i + 1), [difference]: 0, [position]: [hard(i + 2), hard(i + 3), hard(i + 4)], [velocity]: [hard(i + 5), hard(i + 6), hard(i)] }); +}); +const touch = System.create({ kernel: false, components: { with: [c] } }, row => { + if (row.entity % 3 === 0) row[c] = row[c] * 3; +}); +const pairs = [ + [ + System.create({ components: { with: [a, c, difference] } }, row => { row[difference] = Math.sqrt(Math.abs(row[a])) / row[c] + Math.min(row[a], 0.75) * Math.fround(row[c]) - Math.floor(row[a]); }), + System.create({ kernel: false, components: { with: [a, c, difference] } }, row => { row[difference] = Math.sqrt(Math.abs(row[a])) / row[c] + Math.min(row[a], 0.75) * Math.fround(row[c]) - Math.floor(row[a]); }), + ], + [ + System.create({ components: { with: [velocity, position] } }, row => { row[position] = [row[position][0] * row[velocity][0] - row[velocity][0] * 0.75, row[position][1] * row[velocity][1] - row[velocity][1] * 0.75, row[position][2] * row[velocity][2] - row[velocity][2] * 0.75]; }), + System.create({ kernel: false, components: { with: [velocity, position] } }, row => { row[position] = [row[position][0] * row[velocity][0] - row[velocity][0] * 0.75, row[position][1] * row[velocity][1] - row[velocity][1] * 0.75, row[position][2] * row[velocity][2] - row[velocity][2] * 0.75]; }), + ], + [ + System.create({ components: { with: [a, c, difference], changed: [c] } }, row => { row[difference] = row[a] * row[a] - row[c]; }), + System.create({ kernel: false, components: { with: [a, c, difference], changed: [c] } }, row => { row[difference] = row[a] * row[a] - row[c]; }), + ], +] as const; +const values: number[] = []; +const readAll = System.create({ kernel: false, components: { with: [difference, position] } }, row => { + values.push(row[difference], ...row[position]); +}); + +/** Every value after a seed, a change to some rows and `system`, as bits, so NaN and -0 compare exactly. */ +const bitsAfter = async (system: (typeof pairs)[number][number], wasm: boolean): Promise => { + const world = createWorld({ [workers]: 0 }, { wasm }); + try { + await world.run(seedMany); + await world.run(system); + await world.run(touch); + await world.run(system); + values.length = 0; + await world.run(readAll); + return [...new Uint32Array(new Float32Array(values).buffer)]; + } + finally { + await world.dispose(); + } +}; + +it("gives an unflagged kernel's results bit for bit as written, for dense columns, whole elements and change filters", async () => { + for (const [index, [tried, written]] of pairs.entries()) { + expect([index, kernelOf(tried) !== undefined]).toEqual([index, true]); + const expected = await bitsAfter(written, true); + expect([index, "wasm", await bitsAfter(tried, true)]).toEqual([index, "wasm", expected]); + expect([index, "js", await bitsAfter(tried, false)]).toEqual([index, "js", expected]); + } +}); diff --git a/packages/ecs/src/implementation/world/prepared-spawn.ts b/packages/ecs/src/implementation/world/prepared-spawn.ts index 1447de3ca..0800c321d 100644 --- a/packages/ecs/src/implementation/world/prepared-spawn.ts +++ b/packages/ecs/src/implementation/world/prepared-spawn.ts @@ -4,9 +4,8 @@ import type { Essential } from '../../interface/component/value.js'; import type { Entity } from '../../interface/entity/entity.js'; import { addCommand, pushCommand, pushComponent, type CommandBuffer } from './buffer.js'; import { commandsTarget, type CommandsTarget } from './commands.js'; -import { listShapeOf, positionalOf, type ListShape, type Positional } from './lists.js'; +import { listIndexOf, listShapeOf, positionalOf, type ListShape, type Positional } from './lists.js'; import type { Key, State } from './state.js'; -import { archetypeIndexOf } from '../../interface/registry/index.js'; type TargetedCommands = Commands & { readonly [commandsTarget]: CommandsTarget }; @@ -22,17 +21,17 @@ const valuesOf = (keys: readonly Key[], values: readonly unknown[]): { [key: str /** What a site resolved for a world and shape. */ type Planned = { readonly state: State; readonly shape: ListShape }; -/** A site's plans: by archetype index, for the world that last used it, and by shape, which belongs to one world and is held only while it lives. */ -type Plans

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

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

(): Plans

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

(): Plans

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

(plans: Plans

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

(plans: Plans

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

(plans: Plans

, state: State, list: readonly unknown[], site: readonly Key[], make: (state: State, shape: ListShape, site: readonly Key[]) => P, index: number): P => { const shape = listShapeOf(state, list); - let plan = plans.byShape.get(shape); - if (plan === undefined) { + let plan = plans.byShape[shape.index]; + if (plan === undefined || plan.shape !== shape) { plan = make(state, shape, site); - plans.byShape.set(shape, plan); + plans.byShape[shape.index] = plan; } - if (index >= 0) plans.byIndex[index] = plan; + // A list made at runtime has an index once resolved. + const kept = index >= 0 ? index : listIndexOf(list); + if (kept >= 0) plans.byIndex[kept] = plan; return plan; }; diff --git a/packages/ecs/src/implementation/world/protocol.ts b/packages/ecs/src/implementation/world/protocol.ts index f2013070a..86453e8da 100644 --- a/packages/ecs/src/implementation/world/protocol.ts +++ b/packages/ecs/src/implementation/world/protocol.ts @@ -5,10 +5,10 @@ import type { WasmScalar } from './wasm.js'; import type { IndexReaderSetup } from './index-readers.js'; /** Where a kernel column comes from in any archetype it matches. */ -export type Source = { readonly kind: "entity" } | { readonly kind: "field" | "changed" | "chunks"; readonly id: number; readonly leaf: number }; +export type Source = { readonly kind: "entity" } | { readonly kind: "field" | "changed" | "chunks" | "blocks" | "present"; readonly id: number; readonly leaf: number }; /** Component ids of one lookup, and for a lookup by entity with `changed`, those it watches. */ -export type LookupSetup = { readonly read: readonly number[]; readonly has: readonly number[]; readonly not: readonly number[]; readonly changed: readonly number[] | null }; +export type LookupSetup = { readonly read: readonly number[]; readonly has: readonly number[]; readonly optional: readonly number[]; readonly not: readonly number[]; readonly changed: readonly number[] | null }; export type KernelSetup = { readonly index: number; @@ -24,6 +24,10 @@ export type KernelSetup = { readonly indexes: readonly { readonly lookup: number; readonly reader: IndexReaderSetup; readonly watched: number }[]; /** Component ids, used as `keys` so command values name components by id. */ readonly keys: readonly number[]; + /** By key index, each component's label, which a failing write of an optional component names. */ + readonly labels: readonly string[]; + /** Optional components' ids: an archetype without one gives its columns empty. */ + readonly optional: readonly number[]; /** By key index: what each component stored in linear memory starts at, for a spawn leaving it out; null for the rest, whose start the world resolves. */ readonly starts: readonly unknown[]; /** The component ids of each list the kernel spawns, in the order its spawner takes values. */ diff --git a/packages/ecs/src/implementation/world/ranges.test.ts b/packages/ecs/src/implementation/world/ranges.test.ts new file mode 100644 index 000000000..03cd34024 --- /dev/null +++ b/packages/ecs/src/implementation/world/ranges.test.ts @@ -0,0 +1,66 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { describe, expect, it } from "vitest"; +import { blockShift, chunkShift } from "./kernel.js"; +import { forEachNewerRow, forEachPassingRange, type Filter } from "./ranges.js"; + +const rows = 5000; + +/** A filter over `rows` rows at tick 1, with `changed` rows at tick 9, their chunks and blocks raised. */ +const filterOf = (changed: readonly number[]): { readonly ticks: Float64Array; readonly chunks: Float64Array; readonly blocks: Float64Array } => { + const ticks = new Float64Array(rows).fill(1); + const chunks = new Float64Array((rows >> chunkShift) + 1).fill(1); + const blocks = new Float64Array(chunks.length << (chunkShift - blockShift)).fill(1); + for (const row of changed) { + ticks[row] = chunks[row >> chunkShift] = blocks[row >> blockShift] = 9; + } + return { ticks, chunks, blocks }; +}; + +const ranges = (filters: readonly Filter[], lastRun: number, start = 0, end = rows): (readonly [number, number])[] => { + const found: (readonly [number, number])[] = []; + forEachPassingRange(filters, lastRun, start, end, (first, last) => found.push([first, last])); + return found; +}; + +describe("forEachPassingRange", () => { + it("gives each maximal run of rows with a newer tick, across blocks and chunks", () => { + const filter = filterOf([3, 4, 5, 63, 64, 1023, 1024, 4999]); + expect(ranges([filter], 5)).toEqual([[3, 6], [63, 65], [1023, 1025], [4999, 5000]]); + expect(ranges([filter], 5, 4, 1024)).toEqual([[4, 6], [63, 65], [1023, 1024]]); + expect(ranges([filter], 9)).toEqual([]); + }); + + it("skips a chunk or a block whose tick isn't newer without reading its rows", () => { + const filter = filterOf([2000]); + filter.ticks[2100] = 9; + filter.ticks[3100] = 9; + // Row 2100's block and row 3100's chunk say nothing in them changed. + expect(ranges([filter], 5)).toEqual([[2000, 2001]]); + }); + + it("passes a row any of several filters passes", () => { + const first = filterOf([10, 3000]); + const second = filterOf([11, 4000]); + expect(ranges([first, second], 5)).toEqual([[10, 12], [3000, 3001], [4000, 4001]]); + expect(ranges([first, second], 5, 11, 3001)).toEqual([[11, 12], [3000, 3001]]); + }); + + it("passes every row with no filters or a lastRun below 0", () => { + const filter = filterOf([]); + expect(ranges([], 5, 7, 70)).toEqual([[7, 70]]); + expect(ranges([filter], -1, 7, 70)).toEqual([[7, 70]]); + }); +}); + +describe("forEachNewerRow", () => { + it("gives each row with a newer tick, skipping chunks and blocks whose tick isn't", () => { + const { ticks, chunks, blocks } = filterOf([0, 64, 1500, 4999]); + ticks[1600] = 9; + const found: number[] = []; + forEachNewerRow(ticks, chunks, blocks, rows, 5, row => found.push(row)); + expect(found).toEqual([0, 64, 1500, 4999]); + found.length = 0; + forEachNewerRow(ticks, chunks, blocks, 1501, 5, row => found.push(row)); + expect(found).toEqual([0, 64, 1500]); + }); +}); diff --git a/packages/ecs/src/implementation/world/ranges.ts b/packages/ecs/src/implementation/world/ranges.ts index b1f2dd2df..4653599e6 100644 --- a/packages/ecs/src/implementation/world/ranges.ts +++ b/packages/ecs/src/implementation/world/ranges.ts @@ -1,45 +1,110 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { chunkShift } from './kernel.js'; +import { blockShift, chunkShift } from './kernel.js'; -/** One `added` or `changed` filter: per-row ticks, and the newest tick of each chunk of rows. */ -export type Filter = { readonly ticks: Float64Array; readonly chunks: Float64Array }; +/** + * One `added` or `changed` filter: per-row ticks, the newest tick of each chunk of rows, and the newest tick of each + * block of rows within a chunk. + */ +export type Filter = { readonly ticks: Float64Array; readonly chunks: Float64Array; readonly blocks: Float64Array }; + +/** Whether any filter's chunk tick of `chunk` is newer than `lastRun`. */ +const chunkNewer = (filters: readonly Filter[], chunk: number, lastRun: number): boolean => { + for (let i = 0; i < filters.length; i++) if (filters[i].chunks[chunk] > lastRun) return true; + return false; +}; + +const blockNewer = (filters: readonly Filter[], block: number, lastRun: number): boolean => { + for (let i = 0; i < filters.length; i++) if (filters[i].blocks[block] > lastRun) return true; + return false; +}; + +const rowNewer = (filters: readonly Filter[], row: number, lastRun: number): boolean => { + for (let i = 0; i < filters.length; i++) if (filters[i].ticks[row] > lastRun) return true; + return false; +}; + +/** + * Calls `each` with every row below `count` whose tick in `ticks` is newer than `since`, skipping chunks, then blocks, + * whose ticks aren't. + */ +export const forEachNewerRow = (ticks: Float64Array, chunks: Float64Array, blocks: Float64Array, count: number, since: number, each: (row: number) => void): void => { + for (let chunk = 0, last = (count - 1) >> chunkShift; chunk <= last; chunk++) { + if (!(chunks[chunk] > since)) continue; + for (let block = chunk << (chunkShift - blockShift), end = Math.min((chunk + 1) << (chunkShift - blockShift), ((count - 1) >> blockShift) + 1); block < end; block++) { + if (!(blocks[block] > since)) continue; + for (let row = block << blockShift, rowEnd = Math.min(count, (block + 1) << blockShift); row < rowEnd; row++) if (ticks[row] > since) each(row); + } + } +}; + +/** `forEachPassingRange` for one filter, whose loops each read one array. */ +const forEachPassingRangeOf = (filter: Filter, lastRun: number, start: number, end: number, call: (start: number, end: number) => void): void => { + const { ticks, chunks, blocks } = filter; + const lastChunk = (end - 1) >> chunkShift; + let row = start; + while (row < end) { + let chunk = row >> chunkShift; + if (!(chunks[chunk] > lastRun)) { + do chunk++; while (chunk <= lastChunk && !(chunks[chunk] > lastRun)); + if (chunk > lastChunk) return; + row = chunk << chunkShift; + } + const chunkEnd = Math.min(end, (chunk + 1) << chunkShift); + const lastBlock = (chunkEnd - 1) >> blockShift; + let block = row >> blockShift; + if (!(blocks[block] > lastRun)) { + do block++; while (block <= lastBlock && !(blocks[block] > lastRun)); + if (block > lastBlock) { + row = chunkEnd; + continue; + } + row = block << blockShift; + } + const blockEnd = Math.min(chunkEnd, (block + 1) << blockShift); + while (row < blockEnd && !(ticks[row] > lastRun)) row++; + if (row === blockEnd) continue; + // A passing range runs on by row ticks alone, past blocks and chunks, until a row doesn't pass. + const first = row; + do row++; while (row < end && ticks[row] > lastRun); + call(first, row); + } +}; /** * Calls `call` for each maximal range of rows in `start` to `end` that passes the change filters: any tick newer - * than `lastRun`. Chunks with no newer tick are skipped without reading their rows. With no filters, or `lastRun` - * below 0, every row passes. + * than `lastRun`. Chunks, then blocks, with no newer tick are skipped without reading their rows, so finding the rows + * costs a check per chunk, one per block of a chunk that changed, and one per row of a block that changed. With no + * filters, or `lastRun` below 0, every row passes. */ export const forEachPassingRange = (filters: readonly Filter[], lastRun: number, start: number, end: number, call: (start: number, end: number) => void): void => { if (lastRun < 0 || filters.length === 0) return call(start, end); - let first = -1; - const single = filters.length === 1 ? filters[0].ticks : null; - for (let chunk = start >> chunkShift, last = (end - 1) >> chunkShift; chunk <= last; chunk++) { - const chunkStart = Math.max(start, chunk << chunkShift); - const chunkEnd = Math.min(end, (chunk + 1) << chunkShift); - let candidate = false; - for (let i = 0; i < filters.length && !candidate; i++) candidate = filters[i].chunks[chunk] > lastRun; - if (!candidate) { - if (first >= 0) { - call(first, chunkStart); - first = -1; - } - continue; + if (start >= end) return; + if (filters.length === 1) return forEachPassingRangeOf(filters[0], lastRun, start, end, call); + const lastChunk = (end - 1) >> chunkShift; + let row = start; + while (row < end) { + let chunk = row >> chunkShift; + if (!chunkNewer(filters, chunk, lastRun)) { + do chunk++; while (chunk <= lastChunk && !chunkNewer(filters, chunk, lastRun)); + if (chunk > lastChunk) return; + row = chunk << chunkShift; } - for (let row = chunkStart; row < chunkEnd; row++) { - let passes: boolean; - if (single !== null) passes = single[row] > lastRun; - else { - passes = false; - for (let i = 0; i < filters.length && !passes; i++) passes = filters[i].ticks[row] > lastRun; - } - if (passes) { - if (first < 0) first = row; - } - else if (first >= 0) { - call(first, row); - first = -1; + const chunkEnd = Math.min(end, (chunk + 1) << chunkShift); + const lastBlock = (chunkEnd - 1) >> blockShift; + let block = row >> blockShift; + if (!blockNewer(filters, block, lastRun)) { + do block++; while (block <= lastBlock && !blockNewer(filters, block, lastRun)); + if (block > lastBlock) { + row = chunkEnd; + continue; } + row = block << blockShift; } + const blockEnd = Math.min(chunkEnd, (block + 1) << blockShift); + while (row < blockEnd && !rowNewer(filters, row, lastRun)) row++; + if (row === blockEnd) continue; + const first = row; + do row++; while (row < end && rowNewer(filters, row, lastRun)); + call(first, row); } - if (first >= 0) call(first, end); }; diff --git a/packages/ecs/src/implementation/world/reductions.fixture.ts b/packages/ecs/src/implementation/world/reductions.fixture.ts index 565b225de..37459ebfa 100644 --- a/packages/ecs/src/implementation/world/reductions.fixture.ts +++ b/packages/ecs/src/implementation/world/reductions.fixture.ts @@ -1,10 +1,9 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Component } from "../../interface/component/component.js"; -import { Resource } from "../../interface/resource/resource.js"; import { System } from "../../interface/system/system.js"; import { readResource } from "./reads.test-helper.js"; import { createWorld } from "./create.js"; import { workers } from "../../interface/resources/workers.js"; +import type { F32 } from "../../interface/math/index.js"; /** Scenarios of reductions across workers, run built in node and from source in chromium. */ @@ -16,7 +15,7 @@ const largest = Symbol("reductionsBrowserTest/largest"); declare module "@adobe/ecs" { export interface Components { - [weight]: { type: number }; + [weight]: { type: F32 }; [sized]: { type: true }; } export interface Resources { @@ -26,23 +25,18 @@ declare module "@adobe/ecs" { } } -Component.register(weight, { type: { type: "number", precision: 1 } }); -Component.register(sized, { type: { const: true } }); -Resource.register(count, { type: { type: "number", default: 0 }, reduce: "+" }); -Resource.register(sum, { type: { type: "number", default: 0 }, reduce: "+" }); -Resource.register(largest, { type: { type: "number", default: 0 }, reduce: "max" }); export const rows = 100_000; export const weightAt = (i: number): number => Math.fround(Math.sin(i * 0.37) * 1000); -const spawn = System.create({ name: "reductionsBrowserTest/spawn" }, ({ commands }) => { +const spawn = System.create({}, ({ commands }) => { for (let i = 0; i < rows; i++) { if (i % 4 === 0) commands.spawn([weight, sized])({ [weight]: weightAt(i), [sized]: true }); else commands.spawn([weight])({ [weight]: weightAt(i) }); } }); -const reduce = System.create({ name: "reductionsBrowserTest/reduce", kernel: "cpu", components: { read: [weight] }, reduce: { [count]: "+", [sum]: "+", [largest]: "max" } }, (row, { reduce }) => { +const reduce = System.create({ kernel: true, components: { with: [weight] } }, (row, { reduce }) => { reduce[count](1); reduce[sum](row[weight]); reduce[largest](row[weight]); diff --git a/packages/ecs/src/implementation/world/reductions.test.ts b/packages/ecs/src/implementation/world/reductions.test.ts index f83d79a7a..672ac165c 100644 --- a/packages/ecs/src/implementation/world/reductions.test.ts +++ b/packages/ecs/src/implementation/world/reductions.test.ts @@ -1,8 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; -import { Component } from "../../interface/component/component.js"; -import { Vec3 } from "../../interface/math/index.js"; -import { Resource } from "../../interface/resource/resource.js"; +import { type I32, type U32, Vec3 } from "../../interface/math/index.js"; +import { registerResource } from "../../interface/registry/declarations.js"; import { System } from "../../interface/system/system.js"; import { createWorld } from "./create.js"; import { readResource } from "./reads.test-helper.js"; @@ -10,6 +9,7 @@ import { definitionOf } from "../../interface/registry/index.js"; import type { Name } from "../../interface/system/name.js"; import { createRegistrar } from "./register.js"; import { createState } from "./state.js"; +import { giveAccess } from "../../interface/registry/ids.test-helper.js"; import { workers } from "../../interface/resources/workers.js"; import { runReported } from "./reports.test-helper.js"; @@ -29,47 +29,34 @@ const summed = Symbol("reductionsTest/summed"); declare module "@adobe/ecs" { export interface Components { [position]: { type: Vec3 }; - [mass]: { type: number }; + [mass]: { type: U32 }; [heavy]: { type: true }; } export interface Resources { [total]: { type: number; reduce: "+" }; - [low]: { type: readonly [number, number, number]; reduce: "min" }; - [bits]: { type: number; reduce: "|" }; - [least]: { type: number; reduce: "min" }; + [low]: { type: Vec3; reduce: "min" }; + [bits]: { type: U32; reduce: "|" }; + [least]: { type: U32; reduce: "min" }; [any]: { type: boolean; reduce: "|" }; [level]: { type: number }; [seen]: { type: number }; - [wrapped]: { type: number; reduce: "+" }; - [summed]: { type: boolean; reduce: "|" }; + [wrapped]: { type: I32; default: 5; reduce: "+" }; } } -Component.register(position, { type: Vec3.schema }); -Component.register(mass, { type: { type: "integer", minimum: 0, maximum: 0xFFFFFFFF } }); -Component.register(heavy, { type: { const: true } }); -Resource.register(total, { type: { type: "number", default: 0 }, reduce: "+" }); -Resource.register(low, { type: { ...Vec3.schema, default: [0, 0, 0] }, reduce: "min" }); -Resource.register(bits, { type: { type: "integer", minimum: 0, maximum: 0xFFFFFFFF, default: 0 }, reduce: "|" }); -Resource.register(least, { type: { type: "integer", minimum: 0, maximum: 0xFFFFFFFF, default: 0 }, reduce: "min" }); -Resource.register(any, { type: { type: "boolean", default: false }, reduce: "|" }); -Resource.register(level, { type: { type: "number", default: 0 } }); -Resource.register(seen, { type: { type: "number", default: 0 } }); -Resource.register(wrapped, { type: { type: "integer", minimum: -(2 ** 31), maximum: 2 ** 31 - 1, default: 5 }, reduce: "+" }); - const rows = 3000; const positionAt = (i: number): Vec3 => [Math.sin(i) * 100, (i % 97) - 50, i / 7]; const massAt = (i: number): number => (i * 2654435761) >>> 20; /** Rows in two archetypes, so a run folds more than one call's partials. */ -const spawn = System.create({ name: "reductionsTest/spawn" }, ({ commands }) => { +const spawn = System.create({}, ({ commands }) => { for (let i = 0; i < rows; i++) { if (i % 3 === 0) commands.spawn([position, mass, heavy])({ [position]: positionAt(i), [mass]: massAt(i), [heavy]: true }); else commands.spawn([position, mass])({ [position]: positionAt(i), [mass]: massAt(i) }); } }); -const kernel = System.create({ name: "reductionsTest/kernel", kernel: "cpu", components: { read: [position, mass] }, reduce: { [total]: "+", [low]: "min", [bits]: "|", [least]: "min" } }, (row, { reduce }) => { +const kernel = System.create({ kernel: true, components: { with: [position, mass] } }, (row, { reduce }) => { const [x, y, z] = row[position]; reduce[total](row[mass]); reduce[total](0.5); @@ -79,7 +66,7 @@ const kernel = System.create({ name: "reductionsTest/kernel", kernel: "cpu", com }); /** The same reductions, run as written on the main thread. */ -const body = System.create({ name: "reductionsTest/body", components: { read: [position, mass] }, reduce: { [total]: "+", [low]: "min", [bits]: "|", [least]: "min" } }, (row, { reduce }) => { +const body = System.create({ components: { with: [position, mass] } }, (row, { reduce }) => { const [x, y, z] = row[position]; reduce[total](row[mass]); reduce[total](0.5); @@ -88,29 +75,29 @@ const body = System.create({ name: "reductionsTest/body", components: { read: [p reduce[least](row[mass]); }); -const filtered = System.create({ name: "reductionsTest/filtered", kernel: "cpu", components: { read: [mass], has: [heavy] }, resources: { changed: [level] }, reduce: { [total]: "+", [least]: "min" } }, (row, { reduce }) => { +const filtered = System.create({ kernel: true, components: { with: [mass, heavy] }, resources: { changed: [level] } }, (row, { reduce }) => { reduce[total](row[mass]); reduce[least](row[mass]); }); -const flags = System.create({ name: "reductionsTest/flags", kernel: "cpu", components: { read: [mass] }, reduce: { [any]: "|" } }, (row, { reduce }) => { +const flags = System.create({ kernel: true, components: { with: [mass] } }, (row, { reduce }) => { reduce[any](row[mass] > 4000); }); /** Two contributors to `total`, the heavy rows and the light ones, and a reader of the finished fold. */ -const heavyMass = System.create({ name: "reductionsTest/heavy", kernel: "cpu", components: { read: [mass], has: [heavy] }, reduce: { [total]: "+" } }, (row, { reduce }) => { +const heavyMass = System.create({ kernel: true, components: { with: [mass, heavy] } }, (row, { reduce }) => { reduce[total](row[mass]); }); -const lightMass = System.create({ name: "reductionsTest/light", components: { read: [mass], not: [heavy] }, reduce: { [total]: "+" } }, (row, { reduce }) => { +const lightMass = System.create({ components: { with: [mass], not: [heavy] } }, (row, { reduce }) => { reduce[total](row[mass]); }); -const reader = System.create({ name: "reductionsTest/reader", resources: { read: [total], write: [seen] } }, ({ resources }) => { +const reader = System.create({}, ({ resources }) => { resources[seen] = resources[total]; }); -const frame = System.create({ name: "reductionsTest/frame", systems: [reader, heavyMass, lightMass] }); +const frame = System.create({ systems: [reader, heavyMass, lightMass] }); -const weigh = System.create({ name: "reductionsTest/weigh", components: { write: [mass], has: [heavy] } }, row => { row[mass] = 1; }); -const kernelChanged = System.create({ name: "reductionsTest/changed", kernel: "cpu", components: { read: [mass], changed: [mass] }, reduce: { [total]: "+" } }, (row, { reduce }) => { +const weigh = System.create({ components: { with: [mass, heavy] } }, row => { row[mass] = 1; }); +const kernelChanged = System.create({ kernel: true, components: { with: [mass], changed: [mass] } }, (row, { reduce }) => { reduce[total](row[mass]); }); @@ -171,7 +158,7 @@ describe("reductions", () => { it("keep the previous value when a contributor fails, or when every contributor skips the run", async () => { let fail = false; - const fails = System.create({ name: "reductionsTest/fails", components: { read: [mass], has: [heavy] }, reduce: { [total]: "+" } }, (row, { reduce }) => { + const fails = System.create({ components: { with: [mass, heavy] } }, (row, { reduce }) => { reduce[total](row[mass]); if (fail) throw new Error("contributor failed"); }); @@ -214,7 +201,7 @@ describe("reductions", () => { it("stamp their resources changed only when the fold differs", async () => { let ran = 0; - const watcher = System.create({ name: "reductionsTest/reader/2", components: { has: [mass] }, resources: { changed: [total] } }, () => { ran++; }); + const watcher = System.create({ components: { with: [mass] }, resources: { changed: [total] } }, () => { ran++; }); const world = createWorld({ [workers]: 0 }); await world.run(spawn); await world.run(kernel); @@ -228,7 +215,7 @@ describe("reductions", () => { }); it("store an infinite sum as any write of it, and only an empty min or max as the integer type's extreme", async () => { - const infinite = System.create({ name: "reductionsTest/infinite", components: { has: [mass] }, reduce: { [wrapped]: "+" } }, (_, { reduce }) => { + const infinite = System.create({ components: { with: [mass] } }, (_, { reduce }) => { reduce[wrapped](Infinity); }); const world = createWorld({ [workers]: 0 }); @@ -247,26 +234,25 @@ describe("reductions", () => { }); it("reject what can't be reduced", async () => { - // @ts-expect-error booleans reduce bitwise - expect(() => Resource.register(summed, { type: { type: "boolean", default: false }, reduce: "+" })).toThrow(`booleans reduce with "&", "|" or "^"`); - // @ts-expect-error a contributor repeats its resource's operator - expect(() => System.create({ name: "reductionsTest/other", components: { has: [mass] }, reduce: { [total]: "max" } }, (_, { reduce }) => { reduce[total](1); })).toThrow(`reduces "reductionsTest/total" with "max", but it's declared reduce: "+"`); + // Booleans reduce bitwise. + expect(() => registerResource(summed, { type: { type: "boolean" }, default: false, reduce: "+" })).toThrow(`booleans reduce with "&", "|" or "^"`); // @ts-expect-error only a resource with a reduce trait is reduced - expect(() => System.create({ name: "reductionsTest/other/2", components: { has: [mass] }, reduce: { [level]: "+" } }, (_, { reduce }) => { reduce[level](1); })).toThrow(`reduces "reductionsTest/level", which has no reduce trait`); + expect(() => System.create({ components: { with: [mass] } }, (_, { reduce }) => { reduce[level](1); })).toThrow(`reduces "reductionsTest/level", which has no reduce trait`); // @ts-expect-error a reduced resource is never written - expect(() => System.create({ name: "reductionsTest/other/3", resources: { write: [total] } }, ({ resources }) => { resources[total] = 1; })).toThrow(`writes "reductionsTest/total", which is reduced`); - // @ts-expect-error a contributor doesn't read what it reduces into - expect(() => System.create({ name: "reductionsTest/other/4", components: { has: [mass] }, resources: { read: [total] }, reduce: { [total]: "+" } }, (_, { resources, reduce }) => { reduce[total](resources[total]); })).toThrow(`reduces "reductionsTest/total", so it can't also read, write or filter on it`); - // @ts-expect-error nor filters on it - expect(() => System.create({ name: "reductionsTest/other/5", components: { has: [mass] }, resources: { changed: [total] }, reduce: { [total]: "+" } }, (_, { reduce }) => { reduce[total](1); })).toThrow(`reduces "reductionsTest/total", so it can't also read, write or filter on it`); + expect(() => System.create({}, ({ resources }) => { resources[total] = 1; })).toThrow(`writes "reductionsTest/total", which is reduced`); + // A contributor doesn't read what it reduces into. + expect(() => System.create({ components: { with: [mass] } }, (_, { resources, reduce }) => { reduce[total](resources[total]); })).toThrow(`reduces "reductionsTest/total", so it can't also read, write or filter on it`); + // Nor filters on it. + expect(() => System.create({ components: { with: [mass] }, resources: { changed: [total] } }, (_, { reduce }) => { reduce[total](1); })).toThrow(`reduces "reductionsTest/total", so it can't also read, write or filter on it`); }); it("leave nothing subscribed to sync points from a registration that failed", () => { const state = createState(true); const registrar = createRegistrar(state); - // A declaration the build doesn't see is a kernel it didn't compile, which only a world's registration finds. - const uncompiled = { name: "reductionsTest/other/6", kernel: "cpu", components: { read: [mass] } } as const; - const both = System.create({ name: "reductionsTest/both", systems: [kernel, System.create(uncompiled, row => { void row[mass]; })] }); + // A call the build doesn't see is a kernel it didn't compile, which only a world's registration finds. + const unseen = System.create; + const uncompiled = giveAccess({ kernel: true, components: { with: [mass] } } as const, { read: [mass] }); + const both = System.create({ systems: [kernel, unseen(uncompiled, row => { void row[mass]; })] }); const definition = (system: System) => { const found = definitionOf(system); if (found === undefined) throw new Error("not a system"); diff --git a/packages/ecs/src/implementation/world/reductions.ts b/packages/ecs/src/implementation/world/reductions.ts index 5c29a1c47..5aaea5b43 100644 --- a/packages/ecs/src/implementation/world/reductions.ts +++ b/packages/ecs/src/implementation/world/reductions.ts @@ -1,13 +1,13 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { Reduction } from '../../interface/resource/reduction.js'; -import type { Declaration } from '../../interface/system/declaration.js'; +import type { Resolved } from '../../interface/system/declaration.js'; import { isReduction } from '../../interface/registry/reduction.js'; import type { Layout, Path, View } from '../../interface/registry/layout.js'; import { stampChanged } from './archetype.js'; import { readCell, writeCell } from './cells.js'; import { resourceCell } from './resources.js'; import { idOf, type Key, type State } from './state.js'; -import { labelOf } from '../../interface/registry/index.js'; +import { declarationOf, labelOf } from '../../interface/registry/index.js'; /** A resource a system reduces into: its operator, and the scalar leaves of its value. */ export type ReductionTarget = { @@ -20,18 +20,19 @@ const bitwise: ReadonlySet = new Set(["&", "|", "^"]); /** - * The resources a declaration reduces into, with their layouts' leaves. Creating the system checked each against its - * `reduce` trait, which registering the resource checked against its schema, so a throw here is an invariant broken. + * The resources a system reduces into, with their operators, their `reduce` traits, and their layouts' leaves. + * Creating the system checked each has a trait, which registering the resource checked against its schema, so a throw + * here is an invariant broken. */ -export const reductionTargets = (state: State, declaration: Pick): readonly ReductionTarget[] => - reductionTargetsOf(key => state.layouts[idOf(state, key)], declaration); +export const reductionTargets = (state: State, name: string, declaration: Resolved): readonly ReductionTarget[] => + reductionTargetsOf(key => state.layouts[idOf(state, key)], name, declaration); -/** The resources a declaration reduces into, by `layoutFor`, each resource's layout. */ -export const reductionTargetsOf = (layoutFor: (key: Key) => Layout | undefined, { name, reduce }: Pick): readonly ReductionTarget[] => { +/** The resources a system `name` reduces into, by `layoutFor`, each resource's layout. */ +export const reductionTargetsOf = (layoutFor: (key: Key) => Layout | undefined, name: string, declaration: Resolved): readonly ReductionTarget[] => { + const reduce = declaration.resources?.reduce; if (reduce === undefined) return []; - return Object.getOwnPropertySymbols(reduce).flatMap((key): ReductionTarget[] => { - const operator: unknown = Reflect.get(reduce, key); - if (operator === undefined) return []; + return reduce.map((key): ReductionTarget => { + const operator: unknown = declarationOf(key)?.reduce; if (!isReduction(operator)) throw new Error(`System "${name}" reduces "${labelOf(key)}" with "${labelOf(operator)}", which isn't an operator.`); const layout = layoutFor(key); if (layout?.kind !== "linear") throw new Error(`System "${name}" reduces "${labelOf(key)}", which isn't stored in linear memory.`); @@ -39,7 +40,7 @@ export const reductionTargetsOf = (layoutFor: (key: Key) => Layout | undefined, if (leaves.some(leaf => leaf.boolean) && !bitwise.has(operator)) { throw new Error(`System "${name}" reduces "${labelOf(key)}", which has boolean values, with "${operator}"; booleans reduce with "&", "|" or "^".`); } - return [{ key, operator, leaves }]; + return { key, operator, leaves }; }); }; diff --git a/packages/ecs/src/implementation/world/reevaluation.test-helper.ts b/packages/ecs/src/implementation/world/reevaluation.test-helper.ts index dadb9bf72..169593aa3 100644 --- a/packages/ecs/src/implementation/world/reevaluation.test-helper.ts +++ b/packages/ecs/src/implementation/world/reevaluation.test-helper.ts @@ -1,5 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Component, System } from "../../index.js"; +import { System } from "../../index.js"; // A module the identity example evaluates twice, as a dev server does after an edit. export const score = Symbol("reevaluation/score"); @@ -10,8 +10,7 @@ declare module "@adobe/ecs" { } } -Component.register(score, { type: { type: "number" } }); -export const bump = System.create({ name: "reevaluation/bump", components: { write: [score] } }, row => { +export const bump = System.create({ components: { with: [score] } }, row => { row[score] += 1; }); diff --git a/packages/ecs/src/implementation/world/reevaluation.test.ts b/packages/ecs/src/implementation/world/reevaluation.test.ts index 394be7114..d7fe6e736 100644 --- a/packages/ecs/src/implementation/world/reevaluation.test.ts +++ b/packages/ecs/src/implementation/world/reevaluation.test.ts @@ -6,17 +6,17 @@ import { bump, score } from "./reevaluation.test-helper.js"; import { createWorld } from "../index.js"; import { runReported } from "./reports.test-helper.js"; -const spawn = System.create({ name: "reevaluation/spawn" }, ({ commands }) => { commands.spawn([score])({ [score]: 0 }); }); +const spawn = System.create({}, ({ commands }) => { commands.spawn([score])({ [score]: 0 }); }); -// A name is a label, so other code may use it too: this isn't 20-score.ts's `bump`, and reports label the two apart. -const broken = System.create({ name: "reevaluation/bump" }, () => { +// A label isn't an identity: this `bump` isn't the helper's, and reports tell the two apart by their ids. +const bump2 = System.create({}, () => { throw new Error("offline"); }); -it("labels systems that share a name by their ids", async () => { +it("gives each system's id beside its label", async () => { await using world = createWorld(); - const { failures } = await runReported(world, broken); - expect(failures.map(({ name }) => name)).toEqual(["reevaluation/bump (@adobe/ecs/src/implementation/world/reevaluation.test.ts#broken)"]); + const { failures } = await runReported(world, bump2); + expect(failures.map(({ name, id }) => [name, id])).toEqual([["bump2", expect.stringMatching(/^@adobe\/ecs@[^/]+\/src\/implementation\/world\/reevaluation\.test\.ts#bump2$/)]]); }); it("matches the keys and systems of a module evaluated again by id", async () => { @@ -27,7 +27,7 @@ it("matches the keys and systems of a module evaluated again by id", async () => const path = "./reevaluation.test-helper.js?again"; const again: typeof import("./reevaluation.test-helper.js") = await import(/* @vite-ignore */ path); // Its key is the same component, and its system the same system, so a composite holds it once. - await world.run(System.create({ name: "reevaluation/both", systems: [bump, again.bump] })); + await world.run(System.create({ systems: [bump, again.bump] })); const [entity] = await Observe.toPromise(Observe.select(world, { required: [again.score] })); expect((await Observe.toPromise(Observe.entity(world, entity, { required: [again.score] })))?.[again.score]).toBe(2); }); diff --git a/packages/ecs/src/implementation/world/register.ts b/packages/ecs/src/implementation/world/register.ts index fa1c3eff9..bccb76aa5 100644 --- a/packages/ecs/src/implementation/world/register.ts +++ b/packages/ecs/src/implementation/world/register.ts @@ -6,7 +6,7 @@ import { createJoinedObserverRunner, type ObserverTarget } from './joined-observ import { createRunner, releaseHoldings, removedRunner, stopHoldings, type Holdings, type Runner } from './runner.js'; import { isLeaf, reducedBy, scheduleOf, type Schedule } from '../../interface/registry/schedule.js'; import { idOf, observe, unobserve, type Key, type State } from './state.js'; -import { labelOfItem, orderKeysOf } from '../../interface/registry/index.js'; +import { orderKeysOf } from '../../interface/registry/index.js'; import { entityWatchesOf, indexWatchesOf, lookupComponentsOf, orderComponentsOf } from './lookup-keys.js'; /** What a registration still has to do asynchronously: add resources, then load kernels; `rollback` undoes it if that fails. */ @@ -378,7 +378,7 @@ export const createRegistrar = (state: State): Registrar => { if (problem !== null) return; const thread = threadOf(leaf.declaration, "System", leaf.name); if (thread === null || !threads.includes(thread)) return; - const label = labelOfItem(leaf); + const label = leaf.name; const id = idOfItem(leaf); if (id === undefined) problem = `System "${label}" runs on thread "${thread}" and has no id, so a joined thread can't run it; the build gives top-level systems one.`; else if (!imported.has(id)) problem = `System "${label}" runs on thread "${thread}", which the joined thread claiming it never imported.`; diff --git a/packages/ecs/src/implementation/world/remote-finalizers-worker.fixture.ts b/packages/ecs/src/implementation/world/remote-finalizers-worker.fixture.ts index c5b7c0574..2e42569c8 100644 --- a/packages/ecs/src/implementation/world/remote-finalizers-worker.fixture.ts +++ b/packages/ecs/src/implementation/world/remote-finalizers-worker.fixture.ts @@ -2,11 +2,11 @@ import type { MessagePort as NodeMessagePort } from "node:worker_threads"; import { join } from "./create.js"; import { scopeChannel } from "./port.js"; -import { entitled, released, type Released } from "./remote-finalizers.test-helper.js"; +import { entitled, gauged, headlined, headlinedBoth, released, surveyed, type Released, type Surveyed } from "./remote-finalizers.test-helper.js"; /** * A thread joining a world through the port it's sent, claiming "remoteFinalizersTest", whose finalizer it imports: it - * tells what its finalizers saw when asked, or spins, without yielding, for a while or for good. + * tells what its finalizers saw when asked, blocks until a shared flag is set, or spins, without yielding, for good. */ type Port = MessagePort | NodeMessagePort; @@ -14,12 +14,25 @@ export type ToRemoteFinalizersWorker = | { readonly kind: "join"; readonly port: Port } | { readonly kind: "released" } | { readonly kind: "entitled" } - | { readonly kind: "pause"; readonly milliseconds: number } + | { readonly kind: "headlined" } + | { readonly kind: "gauged" } + | { readonly kind: "hold"; readonly flag: Int32Array } | { readonly kind: "spin" }; export type FromRemoteFinalizersWorker = | { readonly kind: "joined" } | { readonly kind: "released"; readonly released: readonly Released[] } | { readonly kind: "entitled"; readonly entitled: readonly { readonly serial: number; readonly title: string }[] } + | { + readonly kind: "headlined"; + readonly headlined: readonly { readonly serial: number; readonly heading: string }[]; + readonly both: readonly { readonly serial: number; readonly heading: string; readonly subheading: string }[]; + } + | { + readonly kind: "gauged"; + readonly gauged: readonly { readonly serial: number; readonly level: number; readonly origin: readonly number[] }[]; + readonly surveyed: readonly Surveyed[]; + } + | { readonly kind: "held" } | { readonly kind: "spinning" }; const channel = scopeChannel(); @@ -31,11 +44,12 @@ channel.onMessage(message => { } else if (message.kind === "released") channel.post({ kind: "released", released }); else if (message.kind === "entitled") channel.post({ kind: "entitled", entitled }); - else if (message.kind === "pause") { - channel.post({ kind: "spinning" }); - // Runs nothing meanwhile, finalizers included, then goes on. - const until = performance.now() + message.milliseconds; - while (performance.now() < until) { /* busy */ } + else if (message.kind === "gauged") channel.post({ kind: "gauged", gauged, surveyed }); + else if (message.kind === "headlined") channel.post({ kind: "headlined", headlined, both: headlinedBoth }); + else if (message.kind === "hold") { + channel.post({ kind: "held" }); + // Runs nothing, finalizers included, until the flag over shared memory is set. + Atomics.wait(message.flag, 0, 0); } else { channel.post({ kind: "spinning" }); diff --git a/packages/ecs/src/implementation/world/remote-finalizers.cases.ts b/packages/ecs/src/implementation/world/remote-finalizers.cases.ts index dbeb61eae..cbec95a81 100644 --- a/packages/ecs/src/implementation/world/remote-finalizers.cases.ts +++ b/packages/ecs/src/implementation/world/remote-finalizers.cases.ts @@ -1,6 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; import type * as Fixture from "./remote-finalizers.fixture.js"; +import { surveyOf } from "./remote-finalizers.test-helper.js"; /** Finalizers on a joined worker: the cases, over the fixture built for node or from source. */ export const remoteFinalizersCases = (load: () => Promise) => describe("finalizers on a joined worker", () => { @@ -14,7 +15,7 @@ export const remoteFinalizersCases = (load: () => Promise) => de const fixture = await load(); expect(await fixture.diesHolding()).toEqual({ held: 3, whileHeld: 0, - failures: [["thread", "remoteFinalizersTest/release", 'Thread "remoteFinalizersTest" is gone, so 3 stops of finalizer "remoteFinalizersTest/release" didn\'t run there.']], + failures: [["thread", "release", 'Thread "remoteFinalizersTest" is gone, so 3 stops of finalizer "release" didn\'t run there.']], after: 3, }); }, 30000); @@ -22,4 +23,20 @@ export const remoteFinalizersCases = (load: () => Promise) => de const fixture = await load(); expect(await fixture.titledStops()).toEqual(Array.from({ length: 50 }, (_, k) => ({ serial: k, title: `t${k}` }))); }, 30000); + it("read one string resource, or two, as they were before their stops' sync point applied, after the world wrote them again and freed their records", async () => { + const fixture = await load(); + const heading = (k: number): string => k < 10 ? "h0" : "h6"; + expect(await fixture.headedStops()).toEqual({ + one: Array.from({ length: 20 }, (_, k) => ({ serial: k, heading: heading(k) })), + two: Array.from({ length: 20 }, (_, k) => ({ serial: k, heading: heading(k), subheading: `s${heading(k)}` })), + }); + }, 30000); + it("read linear resources of every width, and strings beside them, as they were before their stops' sync point applied, after the world wrote them again", async () => { + const fixture = await load(); + const level = (k: number): number => k < 10 ? 0 : 6; + expect(await fixture.gaugedStops()).toEqual({ + linear: Array.from({ length: 20 }, (_, k) => ({ serial: k, level: level(k), origin: [level(k), level(k) + 10] })), + mixed: Array.from({ length: 20 }, (_, k) => ({ serial: k, ...surveyOf(level(k)) })), + }); + }, 30000); }); diff --git a/packages/ecs/src/implementation/world/remote-finalizers.fixture.ts b/packages/ecs/src/implementation/world/remote-finalizers.fixture.ts index 6f22b5a24..bc9c27736 100644 --- a/packages/ecs/src/implementation/world/remote-finalizers.fixture.ts +++ b/packages/ecs/src/implementation/world/remote-finalizers.fixture.ts @@ -6,7 +6,7 @@ import { workers } from "../../interface/resources/workers.js"; import { createWorld, type CreatedWorld } from "./create.js"; import { indexOf } from "./entities.js"; import type { FromRemoteFinalizersWorker, ToRemoteFinalizersWorker } from "./remote-finalizers-worker.fixture.js"; -import { despawn, released, retitle, scenario, serial, sorted, spawn, spawnTitled, type Released } from "./remote-finalizers.test-helper.js"; +import { despawn, regauge, rehead, released, retitle, scenario, serial, sorted, spawn, spawnHeaded, spawnTitled, type Released, type Surveyed } from "./remote-finalizers.test-helper.js"; import { fillReports, runReported } from "./reports.test-helper.js"; /** @@ -66,6 +66,16 @@ const joined = async (world: CreatedWorld): Promise<{ readonly worker: Worker }> return { worker }; }; +/** Holds the worker, which runs nothing until the returned function lets it go on. */ +const hold = async (worker: Worker): Promise<() => void> => { + const flag = new Int32Array(new SharedArrayBuffer(4)); + await worker.ask({ kind: "hold", flag }); + return () => { + Atomics.store(flag, 0, 1); + Atomics.notify(flag, 0); + }; +}; + /** What the worker's finalizer saw. */ const releasedThere = async (worker: Worker): Promise => { const reply = await worker.ask({ kind: "released" }); @@ -118,7 +128,7 @@ export const diesHolding = async () => { }; /** - * Titled rows despawned while the worker runs nothing, then the world's thread rewriting the titles of others six + * Titled rows despawned while the worker is held, then the world's thread rewriting the titles of others six * times, so the records the despawned rows' titles were in are freed and reused before the worker finalizes them: * the titles its finalizer read. */ @@ -126,10 +136,15 @@ export const titledStops = async () => { const world = createWorld({ [workers]: 0 }); const { worker } = await joined(world); await world.run(spawnTitled, { from: 0, count: 50 }); - await worker.ask({ kind: "pause", milliseconds: 300 }); - await world.run(despawn, { serials: Array.from({ length: 50 }, (_, k) => k) }); - await world.run(spawnTitled, { from: 100, count: 50 }); - for (let round = 0; round < 6; round++) await world.run(retitle, { round }); + const release = await hold(worker); + try { + await world.run(despawn, { serials: Array.from({ length: 50 }, (_, k) => k) }); + await world.run(spawnTitled, { from: 100, count: 50 }); + for (let round = 0; round < 6; round++) await world.run(retitle, { round }); + } + finally { + release(); + } let seen: readonly { readonly serial: number; readonly title: string }[] = []; for (let k = 0; k < 200 && seen.length < 50; k++) { const reply = await worker.ask({ kind: "entitled" }); @@ -139,3 +154,64 @@ export const titledStops = async () => { await world.dispose(); return [...seen].sort((a, b) => a.serial - b.serial); }; + +/** + * Headed rows despawned with the heading "h0" while the worker is held, then the world's thread writing the heading + * and subheading six times, so the records of "h0" and "sh0" are freed and reused before the worker finalizes them, + * and the rest despawned once it goes on: what its finalizers of one and two string resources read. + */ +export const headedStops = async () => { + const world = createWorld({ [workers]: 0 }); + const { worker } = await joined(world); + await world.run(rehead, { heading: "h0" }); + await world.run(spawnHeaded, { from: 0, count: 20 }); + const release = await hold(worker); + try { + await world.run(despawn, { serials: Array.from({ length: 10 }, (_, k) => k) }); + for (let round = 1; round <= 6; round++) await world.run(rehead, { heading: `h${round}` }); + } + finally { + release(); + } + await world.run(despawn, { serials: Array.from({ length: 10 }, (_, k) => 10 + k) }); + let seen: readonly { readonly serial: number; readonly heading: string }[] = []; + let both: readonly { readonly serial: number; readonly heading: string; readonly subheading: string }[] = []; + for (let k = 0; k < 200 && (seen.length < 20 || both.length < 20); k++) { + const reply = await worker.ask({ kind: "headlined" }); + seen = reply.kind === "headlined" ? reply.headlined : []; + both = reply.kind === "headlined" ? reply.both : []; + } + await worker.terminate(); + await world.dispose(); + return { one: [...seen].sort((a, b) => a.serial - b.serial), two: [...both].sort((a, b) => a.serial - b.serial) }; +}; + +/** + * Headed rows despawned at level 0 while the worker is held, then the world's thread writing the level and origin six + * times, with resources of mixed widths, and the rest despawned once it goes on: what its finalizers read. + */ +export const gaugedStops = async () => { + const world = createWorld({ [workers]: 0 }); + const { worker } = await joined(world); + await world.run(regauge, { level: 0 }); + await world.run(spawnHeaded, { from: 0, count: 20 }); + const release = await hold(worker); + try { + await world.run(despawn, { serials: Array.from({ length: 10 }, (_, k) => k) }); + for (let round = 1; round <= 6; round++) await world.run(regauge, { level: round }); + } + finally { + release(); + } + await world.run(despawn, { serials: Array.from({ length: 10 }, (_, k) => 10 + k) }); + let seen: readonly { readonly serial: number; readonly level: number; readonly origin: readonly number[] }[] = []; + let mixed: readonly Surveyed[] = []; + for (let k = 0; k < 200 && (seen.length < 20 || mixed.length < 20); k++) { + const reply = await worker.ask({ kind: "gauged" }); + seen = reply.kind === "gauged" ? reply.gauged : []; + mixed = reply.kind === "gauged" ? reply.surveyed : []; + } + await worker.terminate(); + await world.dispose(); + return { linear: [...seen].sort((a, b) => a.serial - b.serial), mixed: [...mixed].sort((a, b) => a.serial - b.serial) }; +}; diff --git a/packages/ecs/src/implementation/world/remote-finalizers.test-helper.ts b/packages/ecs/src/implementation/world/remote-finalizers.test-helper.ts index 7ebe04943..e662f69e8 100644 --- a/packages/ecs/src/implementation/world/remote-finalizers.test-helper.ts +++ b/packages/ecs/src/implementation/world/remote-finalizers.test-helper.ts @@ -1,10 +1,10 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Component } from "../../interface/component/component.js"; import type { Entity } from "../../interface/entity/entity.js"; import { Finalizer } from "../../interface/finalizer/finalizer.js"; -import { Resource } from "../../interface/resource/resource.js"; import { System } from "../../interface/system/system.js"; import type { World } from "../../interface/world/world.js"; +import type { Schema } from "../../interface/schema/schema.js"; +import type { Vec3 } from "../../interface/math/index.js"; // Keys, systems and a finalizer both threads of the remote finalizers tests import: a finalizer on thread // "remoteFinalizersTest", which a joined thread fills, reading structs, a constant, that thread's objects and its @@ -18,35 +18,54 @@ export const widget = Symbol("remoteFinalizersTest/widget"); export const label = Symbol("remoteFinalizersTest/label"); export const tone = Symbol("remoteFinalizersTest/tone"); export const title = Symbol("remoteFinalizersTest/title"); +export const headed = Symbol("remoteFinalizersTest/headed"); +export const heading = Symbol("remoteFinalizersTest/heading"); +export const subheading = Symbol("remoteFinalizersTest/subheading"); +export const level = Symbol("remoteFinalizersTest/level"); +export const origin = Symbol("remoteFinalizersTest/origin"); +export const caption = Symbol("remoteFinalizersTest/caption"); +export const lit = Symbol("remoteFinalizersTest/lit"); +export const tint = Symbol("remoteFinalizersTest/tint"); +export const sample = Symbol("remoteFinalizersTest/sample"); +export const material = Symbol("remoteFinalizersTest/material"); declare module "@adobe/ecs" { export interface Threads { remoteFinalizersTest: true; } export interface Components { - [serial]: { type: number }; + [serial]: { type: Schema.Type<{ type: "integer" }> }; [place]: { type: readonly [number, number] }; [marked]: { type: true }; [flagged]: { type: true }; [widget]: { type: Widget | null; thread: "remoteFinalizersTest" }; - [label]: { type: { readonly text: string } | null; thread: "remoteFinalizersTest" }; + [label]: { type: { readonly text: string } | null; thread: "remoteFinalizersTest"; default: { text: "none" } }; [title]: { type: string }; + [headed]: { type: true }; } export interface Resources { - [tone]: { type: { readonly text: string }; thread: "remoteFinalizersTest" }; + [tone]: { type: { readonly text: string }; thread: "remoteFinalizersTest"; default: { text: "quiet" } }; + [heading]: { type: string; default: "h" }; + [subheading]: { type: string; default: "s" }; + [level]: { type: number; default: -1 }; + [origin]: { type: readonly [number, number]; default: [-1, -1] }; + [caption]: { type: string; default: "c" }; + [lit]: { type: boolean }; + [tint]: { type: Vec3; default: [-1, -1, -1] }; + [sample]: { type: Schema.Type<{ type: "object"; properties: { on: { type: "boolean" }; count: { type: "integer"; minimum: 0; maximum: 4294967295 }; weight: { type: "number" }; ratio: { type: "number"; precision: 1 } } }>; default: { on: false; count: 0; weight: -1; ratio: -1 } }; + [material]: { type: Schema.Type<{ type: "object"; properties: { alpha: { type: "number"; precision: 1 }; color: { type: "array"; items: { type: "number"; precision: 1 }; minItems: 3; maxItems: 3 }; glossy: { type: "boolean" } }; layout: "storage" }>; default: { alpha: -1; color: [-1, -1, -1]; glossy: false } }; } } /** A value that isn't data, which only its thread holds. */ type Widget = { readonly tag: string; readonly draw: () => string }; -Component.register(serial, { type: { type: "integer" } }); -Component.register(place, { type: { type: "array", items: { type: "number" }, minItems: 2, maxItems: 2 } }); -Component.register(marked, { type: { const: true } }); -Component.register(flagged, { type: { const: true } }); -Component.register(widget, { thread: "remoteFinalizersTest" }); const text = { type: "object", properties: { text: { type: "string" } } } as const; -Component.register(label, { type: { anyOf: [text, { type: "null" }], default: { text: "none" } }, thread: "remoteFinalizersTest" }); -Resource.register(tone, { type: { ...text, default: { text: "quiet" } }, thread: "remoteFinalizersTest" }); /** A string, in shared memory, which the world's thread writes and the finalizer on the thread reads. */ -Component.register(title, { type: { type: "string" } }); +/** A string resource, which the world's thread writes and the finalizer on the thread reads. */ +/** Linear resources, a number and a struct, which the world's thread writes and the finalizer on the thread reads. */ +/** Resources of mixed widths: a string, a boolean (1 byte), an f32 vec3 (12), a padded CPU struct (24) and a storage struct (32). */ +type Sample = { readonly on: boolean; readonly count: number; readonly weight: number; readonly ratio: number }; +type Material = { readonly alpha: number; readonly color: readonly [number, number, number]; readonly glossy: boolean }; +const f32 = { type: "number", precision: 1 } as const; +const vec3 = { type: "array", items: f32, minItems: 3, maxItems: 3 } as const; /** A stop as the finalizer saw it, on the thread it ran on. */ export type Released = { readonly serial: number; readonly place: readonly number[]; readonly marked: boolean; readonly widget: string | null; readonly label: string | null; readonly tone: string }; @@ -58,9 +77,9 @@ export const finalized: Entity[] = []; export const throwing = new Set(); /** On the finalizers' thread: what each stop held there, its structs before the sync point applied. */ -export const release = Finalizer.create({ name: "remoteFinalizersTest/release", components: { read: [serial, place, marked, widget, label] }, resources: { read: [tone] } }, (row, { resources }) => { +export const release = Finalizer.create({ components: { with: [serial, place, marked, widget, label] } }, (row, { resources }) => { finalized.push(row.entity); - released.push({ serial: row[serial], place: [...row[place]], marked: row[marked], widget: row[widget]?.tag ?? null, label: row[label]?.text ?? null, tone: resources[tone]?.text }); + released.push({ serial: row[serial], place: [...row[place]], marked: row[marked], widget: row[widget]?.tag ?? null, label: row[label]?.text ?? null, tone: resources[tone].text }); if (throwing.has(row[serial])) throw new Error(`remoteFinalizersTest: ${row[serial]} throws`); }); @@ -68,54 +87,126 @@ export const release = Finalizer.create({ name: "remoteFinalizersTest/release", export const entitled: { readonly serial: number; readonly title: string }[] = []; /** On the finalizers' thread: what each titled stop's title was before its sync point applied. */ -export const entitle = Finalizer.create({ name: "remoteFinalizersTest/entitle", components: { read: [serial, title, widget] } }, row => { +export const entitle = Finalizer.create({ components: { with: [serial, title, widget] } }, row => { + // Reading its widget places it on the widget's thread. + void row[widget]; entitled.push({ serial: row[serial], title: row[title] }); }); +/** Every headed stop finalized on this thread, in order, with the heading it read. */ +export const headlined: { readonly serial: number; readonly heading: string }[] = []; + +/** On the finalizers' thread: the heading as it was before each headed stop's sync point applied. */ +export const headline = Finalizer.create({ components: { with: [serial, widget, headed] } }, (row, { resources }) => { + void row[widget]; + headlined.push({ serial: row[serial], heading: resources[heading] }); +}); + +/** Every headed stop finalized on this thread, in order, with the heading and subheading it read. */ +export const headlinedBoth: { readonly serial: number; readonly heading: string; readonly subheading: string }[] = []; + +/** On the finalizers' thread: two string resources as they were before each headed stop's sync point applied. */ +export const headlines = Finalizer.create({ components: { with: [serial, widget, headed] } }, (row, { resources }) => { + void row[widget]; + headlinedBoth.push({ serial: row[serial], heading: resources[heading], subheading: resources[subheading] }); +}); + +/** Every headed stop finalized on this thread, in order, with the linear resources it read. */ +export const gauged: { readonly serial: number; readonly level: number; readonly origin: readonly number[] }[] = []; + +/** On the finalizers' thread: a number and a struct resource as they were before each headed stop's sync point applied. */ +export const gauge = Finalizer.create({ components: { with: [serial, widget, headed] } }, (row, { resources }) => { + void row[widget]; + gauged.push({ serial: row[serial], level: resources[level], origin: [...resources[origin]] }); +}); + +/** Every headed stop finalized on this thread, in order, with the resources of mixed widths it read. */ +export type Surveyed = { readonly serial: number; readonly caption: string; readonly lit: boolean; readonly tint: readonly number[]; readonly sample: Sample; readonly material: Material }; +export const surveyed: Surveyed[] = []; + +/** On the finalizers' thread: resources of mixed widths as they were before each headed stop's sync point applied. */ +export const survey = Finalizer.create({ components: { with: [serial, widget, headed] } }, (row, { resources }) => { + void row[widget]; + const { alpha, color, glossy } = resources[material]; + surveyed.push({ + serial: row[serial], caption: resources[caption], lit: resources[lit], tint: [...resources[tint]], + sample: { ...resources[sample] }, material: { alpha, color: [...color], glossy }, + }); +}); + +/** What `regauge` writes to the resources of mixed widths at a level. */ +export const surveyOf = (at: number): Omit => ({ + caption: `c${at}`, lit: at > 3, tint: [at, at + 0.5, at * 2], + sample: { on: at > 3, count: at * 1000 + 1, weight: at + 0.25, ratio: at * 0.5 + 1 }, + material: { alpha: at * 0.25 + 1, color: [at + 1, at + 2, at + 3], glossy: at > 3 }, +}); + +/** From the world's thread: the level, the origin at it, and the resources of mixed widths at it. */ +export const regauge = System.create({}, ({ resources }, input: { readonly level: number }) => { + resources[level] = input.level; + resources[origin] = [input.level, input.level + 10]; + const values = surveyOf(input.level); + resources[caption] = values.caption; + resources[lit] = values.lit; + resources[tint] = [values.tint[0], values.tint[1], values.tint[2]]; + resources[sample] = values.sample; + resources[material] = values.material; +}); + +/** Spawns headed rows from the world's thread, with the thread's widget at null. */ +export const spawnHeaded = System.create({}, ({ commands }, input: { readonly from: number; readonly count: number }) => { + for (let k = input.from; k < input.from + input.count; k++) commands.spawn([serial, headed, widget])({ [serial]: k, [widget]: null }); +}); +/** From the world's thread: the heading and subheading. */ +export const rehead = System.create({}, ({ resources }, input: { readonly heading: string }) => { + resources[heading] = input.heading; + resources[subheading] = `s${input.heading}`; +}); + /** Spawns titled rows from the world's thread, with the thread's widget at null. */ -export const spawnTitled = System.create({ name: "remoteFinalizersTest/spawnTitled", input: { type: "object", properties: { from: { type: "integer" }, count: { type: "integer" } } } }, ({ input, commands }) => { +export const spawnTitled = System.create({}, ({ commands }, input: { readonly from: number; readonly count: number }) => { for (let k = input.from; k < input.from + input.count; k++) commands.spawn([serial, title, widget])({ [serial]: k, [title]: `t${k}`, [widget]: null }); }); /** From the world's thread: every title as another of the same size, so the records of titles let go are reused. */ -export const retitle = System.create({ name: "remoteFinalizersTest/retitle", input: { type: "object", properties: { round: { type: "integer" } } }, components: { write: [title] } }, (row, { input }) => { +export const retitle = System.create({ components: { with: [title] } }, (row, _context, input: { readonly round: number }) => { row[title] = `r${input.round}`; }); /** Spawns from the world's thread: structs, the thread's objects at null and their start. */ -export const spawn = System.create({ name: "remoteFinalizersTest/spawn", input: { type: "object", properties: { from: { type: "integer" }, count: { type: "integer" } } } }, ({ input, commands }) => { +export const spawn = System.create({}, ({ commands }, input: { readonly from: number; readonly count: number }) => { for (let k = input.from; k < input.from + input.count; k++) commands.spawn([serial, place, marked, widget, label])({ [serial]: k, [place]: [k, -k], [widget]: null }); }); /** On the thread: widgets written in place. */ -export const attach = System.create({ name: "remoteFinalizersTest/attach", components: { read: [serial], write: [widget] } }, row => { +export const attach = System.create({ components: { with: [serial, widget] } }, row => { const tag = `w${row[serial]}`; row[widget] = { tag, draw: () => tag }; }); /** On the thread: labels given by command, staged there, for odd serials. */ -export const relabel = System.create({ name: "remoteFinalizersTest/relabel", components: { read: [serial, label] } }, (row, { commands }) => { +export const relabel = System.create({ components: { with: [serial, label] } }, (row, { commands }) => { const relabelled = { text: `l${row[serial]}${row[label]?.text === "none" ? "" : "+"}` }; if (row[serial] % 2 === 1) commands.add(row.entity, label, relabelled); }); /** On the thread: its object resource. */ -export const retone = System.create({ name: "remoteFinalizersTest/retone", input: { type: "object", properties: { tone: { type: "string" } } }, resources: { write: [tone] } }, ({ input, resources }) => { +export const retone = System.create({}, ({ resources }, input: { readonly tone: string }) => { resources[tone] = { text: input.tone }; }); /** From the world's thread: places move, so a stop's values are those before the sync point applies. */ -export const shift = System.create({ name: "remoteFinalizersTest/shift", components: { write: [place] } }, row => { +export const shift = System.create({ components: { with: [place] } }, row => { row[place] = [row[place][0] + 100, row[place][1]]; }); /** From the world's thread: despawns these serials. */ -export const despawn = System.create({ name: "remoteFinalizersTest/despawn", input: { type: "object", properties: { serials: { type: "array", items: { type: "integer" } } } }, components: { read: [serial] } }, (row, { input, commands }) => { +export const despawn = System.create({ components: { with: [serial] } }, (row, { commands }, input: { readonly serials: readonly number[] }) => { if (input.serials.includes(row[serial])) commands.despawn(row.entity); }); /** From the world's thread: removes widgets of these serials, a stop that despawns nothing. */ -export const strip = System.create({ name: "remoteFinalizersTest/strip", input: { type: "object", properties: { serials: { type: "array", items: { type: "integer" } } } }, components: { read: [serial], has: [widget] } }, (row, { input, commands }) => { +export const strip = System.create({ components: { with: [serial, widget] } }, (row, { commands }, input: { readonly serials: readonly number[] }) => { if (input.serials.includes(row[serial])) commands.remove(row.entity, widget); }); /** From the world's thread: every row moves archetype, flagged on one run and not on the next. */ -export const flag = System.create({ name: "remoteFinalizersTest/flag", components: { has: [serial], not: [flagged] } }, (row, { commands }) => { +export const flag = System.create({ components: { with: [serial], not: [flagged] } }, (row, { commands }) => { commands.add(row.entity, flagged); }); -export const unflag = System.create({ name: "remoteFinalizersTest/unflag", components: { has: [serial, flagged] } }, (row, { commands }) => { +export const unflag = System.create({ components: { with: [serial, flagged] } }, (row, { commands }) => { commands.remove(row.entity, flagged); }); diff --git a/packages/ecs/src/implementation/world/remote-finalizers.test.ts b/packages/ecs/src/implementation/world/remote-finalizers.test.ts index 95aff7c84..31e371669 100644 --- a/packages/ecs/src/implementation/world/remote-finalizers.test.ts +++ b/packages/ecs/src/implementation/world/remote-finalizers.test.ts @@ -12,6 +12,27 @@ import { indexOf } from "./entities.js"; import { attach, despawn, eventually, finalized, marked, place, released, retone, scenario, serial, sorted, spawn, throwing, tone, widget } from "./remote-finalizers.test-helper.js"; import { runReported } from "./reports.test-helper.js"; +/** Resources of the world's thread, each added once the thread joined, so the resource entity moves to another archetype. */ +const later = Symbol("remoteFinalizersTest/later"); +const second = Symbol("remoteFinalizersTest/second"); +const third = Symbol("remoteFinalizersTest/third"); +declare module "@adobe/ecs" { + export interface Resources { + [later]: { type: number }; + [second]: { type: number }; + [third]: { type: number }; + } +} +const bump = System.create({}, ({ resources }) => { + resources[later] += 1; +}); +const bumpSecond = System.create({}, ({ resources }) => { + resources[second] += 1; +}); +const bumpThird = System.create({}, ({ resources }) => { + resources[third] += 1; +}); + // Finalizers on a joined thread, with the world and the thread on this one, joined through a MessageChannel. /** The first value an observer gives that `accept` takes. */ @@ -50,7 +71,7 @@ describe("finalizers on a joined thread", () => { reset(); const seen: (readonly symbol[] | null)[][] = []; let watching = false; - Finalizer.changes({ name: "remoteFinalizersTest/everything" }, changes => { + Finalizer.changes({}, changes => { if (watching) for (const group of changes.groups) seen.push([group.to, group.added]); }); const { world, remote } = await joined(); @@ -89,7 +110,7 @@ describe("finalizers on a joined thread", () => { let reusedEarly = 0; let reused = 0; // Spawns check each entity they reserve: an index whose last entity stopped is reused only once it was finalized. - const check = System.create(identify({ name: "remoteFinalizersTest/check", input: { type: "object", properties: { serial: { type: "integer" } } } }, "remoteFinalizersTest#check"), ({ input, commands }) => { + const check = System.create(identify({}, "remoteFinalizersTest#check"), ({ commands }, input: { readonly serial: number }) => { const entity = commands.spawn([serial, place, marked, widget])({ [serial]: input.serial, [place]: [0, 0], [widget]: null }); const before = stopped.get(indexOf(entity)); if (before === undefined) return; @@ -124,7 +145,7 @@ describe("finalizers on a joined thread", () => { await new Promise(resolve => setTimeout(resolve, 1)); report = await runReported(world, despawn, { serials: [] }); } - expect(report.failures.map(failure => [failure.kind, failure.name, failure.message])).toEqual([["finalizer", "remoteFinalizersTest/release", "remoteFinalizersTest: 1 throws"]]); + expect(report.failures.map(failure => [failure.kind, failure.name, failure.message])).toEqual([["finalizer", "release", "remoteFinalizersTest: 1 throws"]]); expect((await runReported(world, despawn, { serials: [] })).failures).toEqual([]); await remote.dispose(); await world.dispose(); @@ -156,17 +177,63 @@ describe("finalizers on a joined thread", () => { await world.dispose(); expect(released.map(stop => stop.serial)).toEqual([0, 1, 2]); }); + it("read their thread's object resource as the thread holds it, from the first sync point after joining and once the resource entity moved before they ran", async () => { + reset(); + const { world, remote } = await joined(); + // Stops the thread runs as it first rebuilds, before it holds any row. + await world.run(spawn, { from: 0, count: 3 }); + await world.run(despawn, { serials: [0] }); + expect(await eventually(() => released.length === 1)).toBe(true); + await world.run(retone, { tone: "soft" }); + // Not awaited: the resource entity moves to another archetype before the thread replays the stop's sync. + await Promise.all([world.run(despawn, { serials: [1] }), world.run(bump)]); + expect(await eventually(() => released.length === 2)).toBe(true); + expect(released.map(stop => [stop.serial, stop.tone])).toEqual([[0, "quiet"], [1, "soft"]]); + await remote.dispose(); + await world.dispose(); + }); + it("read their thread's object resource where the resource entity moved since it was last found, before a later write applies", async () => { + reset(); + const { world, remote } = await joined(); + await world.run(spawn, { from: 0, count: 3 }); + await world.run(despawn, { serials: [0] }); + expect(await eventually(() => released.length === 1)).toBe(true); + // Replayed, not rebuilt: the resource entity is found in the mirror. + await world.run(despawn, { serials: [1] }); + expect(await eventually(() => released.length === 2)).toBe(true); + await world.run(bump); + await world.run(retone, { tone: "soft" }); + // The write after the stop's sync is a task the thread runs once it replayed that sync. + await Promise.all([world.run(despawn, { serials: [2] }), world.run(retone, { tone: "loud" })]); + expect(await eventually(() => released.length === 3)).toBe(true); + expect(released.map(stop => [stop.serial, stop.tone])).toEqual([[0, "quiet"], [1, "quiet"], [2, "soft"]]); + await remote.dispose(); + await world.dispose(); + }); + it("read their thread's object resource once the resource entity moved twice before the thread replayed", async () => { + reset(); + const { world, remote } = await joined(); + await world.run(spawn, { from: 0, count: 2 }); + await world.run(despawn, { serials: [0] }); + expect(await eventually(() => released.length === 1)).toBe(true); + await world.run(retone, { tone: "soft" }); + await Promise.all([world.run(despawn, { serials: [1] }), world.run(bumpSecond), world.run(bumpThird)]); + expect(await eventually(() => released.length === 2)).toBe(true); + expect(released.map(stop => [stop.serial, stop.tone])).toEqual([[0, "quiet"], [1, "soft"]]); + await remote.dispose(); + await world.dispose(); + }); // Last: the finalizers it creates stay in the program. it("created after the claim run there; one the thread can't name doesn't activate, reported once", async () => { reset(); const { world, remote } = await joined(); await world.run(spawn, { from: 0, count: 2 }); const late: string[] = []; - Finalizer.create(identify({ name: "remoteFinalizersTest/late", components: { read: [widget, serial] } }, "remoteFinalizersTest#late"), row => { late.push(`${row[serial]}:${String(row[widget])}`); }); + Finalizer.create(identify({ components: { with: [widget, serial] } }, "remoteFinalizersTest#late"), row => { late.push(`${row[serial]}:${String(row[widget])}`); }); // Declared in a function, so the build gives it no id. - Finalizer.create({ name: "remoteFinalizersTest/unnamed", components: { read: [widget] } }, () => {}); + Finalizer.create({ components: { with: [widget] } }, row => { void row[widget]; }); const first = await runReported(world, despawn, { serials: [0] }); - expect(first.failures.map(failure => [failure.kind, failure.name, failure.message])).toEqual([["finalizer", "remoteFinalizersTest/unnamed", 'Finalizer "remoteFinalizersTest/unnamed" runs on thread "remoteFinalizersTest", which a joined thread fills, and has no id, so that thread can\'t name it; the build gives top-level finalizers one.']]); + expect(first.failures.map(failure => [failure.kind, failure.name, failure.message])).toEqual([["finalizer", expect.stringMatching(/^finalizer #\d+$/), expect.stringMatching(/^Finalizer "finalizer #\d+" runs on thread "remoteFinalizersTest", which a joined thread fills, and has no id, so that thread can't name it; the build gives top-level finalizers one\.$/)]]); expect(await eventually(() => late.length === 1)).toBe(true); expect((await runReported(world, despawn, { serials: [1] })).failures).toEqual([]); expect(await eventually(() => late.length === 2)).toBe(true); @@ -174,8 +241,8 @@ describe("finalizers on a joined thread", () => { // Placed there by the thread's object resource alone: stops of rows holding none of its objects, which its log // has no entries for, run there too. const heard: string[] = []; - Finalizer.create(identify({ name: "remoteFinalizersTest/heard", components: { read: [serial], not: [widget] }, resources: { read: [tone] } }, "remoteFinalizersTest#heard"), (row, { resources }) => { heard.push(`${row[serial]}:${resources[tone].text}`); }); - const plain = System.create({ name: "remoteFinalizersTest/plain" }, ({ commands }) => { + Finalizer.create(identify({ components: { with: [serial], not: [widget] } }, "remoteFinalizersTest#heard"), (row, { resources }) => { heard.push(`${row[serial]}:${resources[tone].text}`); }); + const plain = System.create({}, ({ commands }) => { for (let k = 0; k < 3; k++) commands.spawn([serial, place])({ [serial]: 50 + k, [place]: [0, 0] }); }); await world.run(plain); diff --git a/packages/ecs/src/implementation/world/remote-objects.test-helper.ts b/packages/ecs/src/implementation/world/remote-objects.test-helper.ts index 1d952d719..9ae6a5619 100644 --- a/packages/ecs/src/implementation/world/remote-objects.test-helper.ts +++ b/packages/ecs/src/implementation/world/remote-objects.test-helper.ts @@ -1,5 +1,4 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Component } from "../../interface/component/component.js"; import type { Entity } from "../../interface/entity/entity.js"; import { System } from "../../interface/system/system.js"; import type { World } from "../../interface/world/world.js"; @@ -20,21 +19,20 @@ declare module "@adobe/ecs" { [serial]: { type: number }; [phase]: { type: number }; [flag]: { type: true }; - [caption]: { type: { readonly text: string } | null; thread: "remoteObjects" }; + [caption]: { type: Caption; thread: "remoteObjects" }; } } -Component.register(serial, { type: { type: "number" } }); -Component.register(phase, { type: { type: "number" } }); -Component.register(flag, { type: { const: true } }); /** The caption's start, which a command here gives it by this value. */ -const none = { text: "none" }; -Component.register(caption, { type: { anyOf: [{ type: "object", properties: { text: { type: "string" } } }, { type: "null" }], default: none }, thread: "remoteObjects" }); +const none = { text: "none" } as const; +/** A caption, which starts as `none`, the value commands on other threads give it by identity. */ +type Caption = { readonly text: string } | null; +const Caption = { default: none }; /** A caption's text, null for none, as the watchers record it. */ const textOf = (value: { readonly text: string } | null): string | null => value === null ? null : value.text; /** The watched entity, serial 0. */ -export const seed = System.create({ name: "remoteObjectsTest/seed" }, ({ commands }) => { +export const seed = System.create({}, ({ commands }) => { commands.spawn([serial, phase, caption])({ [serial]: 0, [phase]: 0 }); }); @@ -43,7 +41,7 @@ export const seed = System.create({ name: "remoteObjectsTest/seed" }, ({ command * flagged when it's odd, so it moves archetype every tick; the other entities despawn, and four more spawn into its * archetypes, so rows swap into the holes they leave. */ -export const churn = System.create({ name: "remoteObjectsTest/churn", components: { read: [serial], write: [phase] } }, (row, { commands }) => { +export const churn = System.create({ components: { with: [serial, phase] } }, (row, { commands }) => { if (row[serial] !== 0) { commands.despawn(row.entity); return; @@ -74,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({ name: "remoteObjectsTest/observerWatch", observer: true, lookup: { values: { read: [phase, caption] }, flagged: { has: [flag] } } }, ({ lookup }) => { +export const observerWatch = System.create({ observer: true, lookup: { values: { with: [phase, caption] }, flagged: { with: [flag] } } }, ({ lookup }) => { if (observer.entity === null) return; const row = lookup.values(observer.entity); if (row === null) return; diff --git a/packages/ecs/src/implementation/world/remote-systems.ts b/packages/ecs/src/implementation/world/remote-systems.ts index eddcd11cc..92cebfb17 100644 --- a/packages/ecs/src/implementation/world/remote-systems.ts +++ b/packages/ecs/src/implementation/world/remote-systems.ts @@ -3,11 +3,10 @@ import type { Leaf } from '../../interface/system/leaf.js'; import { entityWatchesOf, lookupComponentsOf } from './lookup-keys.js'; import type { Leaf as FinalizerLeaf } from '../../interface/finalizer/created.js'; import type { Warning } from '../../interface/report/failure.js'; -import { declarationOf, idOfItem, idOfKey, indexOfLookup, labelOf, labelOfItem, threadOf, watchedBy, watchesEntries } from '../../interface/registry/index.js'; +import { declarationOf, idOfItem, idOfKey, idOrNull, indexOfLookup, labelOf, threadOf, watchedBy, watchesEntries } from '../../interface/registry/index.js'; import { unsharedKeyOf } from './index-keys.js'; import { layoutOf } from '../../interface/registry/layout.js'; -import type { Archetype } from './archetype.js'; -import { chunkShift } from './kernel.js'; +import { raiseRange, type Archetype } from './archetype.js'; import type { LocalIds, Segment } from './command-ring.js'; import type { ToJoined } from './joining.js'; import { lineWords, type WorkerError } from './protocol.js'; @@ -21,6 +20,7 @@ import { logBoundedWord, logBoundWord, logSyncWord, type MoveLog } from './move- import { createStopWriter, freeStops, type StopWriter } from './stop-lists.js'; import { freeTail, stampWritten, type Tail } from './tails.js'; import { isHeld } from './transfers.js'; +import { requiredOf } from './required.js'; import { completionCapacityWord, completionMessage, completionOffsetWord, completionSystems, completionWords, executedFlag, failedFlag, payloadCapacityWord, payloadCount, payloadInput, payloadOffsetWord, payloadSequence, payloadSync, payloadSystems, payloadWords, ranFlag, @@ -44,6 +44,8 @@ export type RemoteSystem = { readonly channel: TaskChannel; readonly index: number; readonly label: string; + /** The id the plugin gave it, which reports give beside its label. */ + readonly id: string | null; /** Its warning, listed in each run's report its tier blocks. */ readonly warning: Warning; readonly targets: readonly ReductionTarget[]; @@ -347,11 +349,11 @@ export const createTaskChannel = (state: State, log: MoveLog, post: (message: To /** An item's setup there: its id, and the world's numbers for the keys it names, by their ids. Throws without them. */ const setupOf = (state: State, item: Leaf | FinalizerLeaf, what: string, named: readonly symbol[]): { readonly id: string; readonly keys: readonly (readonly [number, string])[] } => { const id = idOfItem(item); - if (id === undefined) throw new Error(`${what} "${labelOfItem(item)}" runs on thread "${label}", which a joined thread fills, and has no id, so that thread can't name it; the build gives top-level ${what === "System" ? "systems" : "finalizers"} one.`); + if (id === undefined) throw new Error(`${what} "${item.name}" runs on thread "${label}", which a joined thread fills, and has no id, so that thread can't name it; the build gives top-level ${what === "System" ? "systems" : "finalizers"} one.`); const keys: (readonly [number, string])[] = []; for (const key of named) { const keyId = idOfKey(key); - if (keyId === undefined) throw new Error(`${what} "${labelOfItem(item)}" names "${labelOf(key)}", which has no id, so thread "${label}" can't name it; the build gives top-level keys one.`); + if (keyId === undefined) throw new Error(`${what} "${item.name}" names "${labelOf(key)}", which has no id, so thread "${label}" can't name it; the build gives top-level keys one.`); keys.push([idOf(state, key), keyId]); } return { id, keys }; @@ -472,13 +474,12 @@ export const createTaskChannel = (state: State, log: MoveLog, post: (message: To /** Every key a leaf names in its declaration, so the thread finds the world's numbers for them. */ export const keysOf = (leaf: Leaf): readonly symbol[] => { - const { components, lookup, resources, reduce } = leaf.declaration; + const { components, lookup, resources } = leaf.declaration; const keys = new Set([ ...components?.read ?? [], ...components?.write ?? [], ...components?.has ?? [], ...components?.not ?? [], ...components?.added ?? [], ...components?.changed ?? [], - ...resources?.read ?? [], ...resources?.write ?? [], ...resources?.changed ?? [], ...resources?.is ?? [], ...resources?.not ?? [], + ...resources?.read ?? [], ...resources?.write ?? [], ...resources?.changed ?? [], ...resources?.is ?? [], ...resources?.not ?? [], ...resources?.reduce ?? [], ]); for (const name in lookup) for (const key of lookupComponentsOf(lookup[name])) keys.add(key); - if (reduce !== undefined) for (const key of Object.getOwnPropertySymbols(reduce)) keys.add(key); return [...keys].filter((key): key is symbol => typeof key === "symbol"); }; @@ -495,22 +496,22 @@ export const finalizerKeysOf = (finalizer: FinalizerLeaf): readonly symbol[] => */ /** Why a leaf with an index as a change filter can't run on a joined thread. */ export const entriesOnJoined = (leaf: Leaf, thread: string, index: string): string => - `System "${labelOfItem(leaf)}" runs on thread "${thread}", which a joined thread fills, and when index "${index}" changed, a lookup's changed being true, which runs only on the world's thread.`; + `System "${leaf.name}" runs on thread "${thread}", which a joined thread fills, and when index "${index}" changed, a lookup's changed being true, which runs only on the world's thread.`; export const unplaceable = (leaf: Leaf, thread: string): string | null => { - if (leaf.declaration.up !== undefined) return `System "${labelOfItem(leaf)}" runs on thread "${thread}", which a joined thread fills, and declares up, which runs only on the world's thread.`; - if (leaf.declaration.order !== undefined) return `System "${labelOfItem(leaf)}" runs on thread "${thread}", which a joined thread fills, and orders its rows by an index, which runs only on the world's thread.`; - if (leaf.declaration.target !== undefined) return `System "${labelOfItem(leaf)}" runs on thread "${thread}", which a joined thread fills, and on one entity, which runs only on the world's thread.`; + if (leaf.declaration.up !== undefined) return `System "${leaf.name}" runs on thread "${thread}", which a joined thread fills, and declares up, which runs only on the world's thread.`; + if (leaf.declaration.order !== undefined) return `System "${leaf.name}" runs on thread "${thread}", which a joined thread fills, and orders its rows by an index, which runs only on the world's thread.`; + if (leaf.declaration.target !== undefined) return `System "${leaf.name}" runs on thread "${thread}", which a joined thread fills, and on one entity, which runs only on the world's thread.`; const { components, resources } = leaf.declaration; const string = [...components?.write ?? [], ...resources?.write ?? []].find(key => layoutOf(declarationOf(key)?.type).kind === "string"); - if (string !== undefined) return `System "${labelOfItem(leaf)}" runs on thread "${thread}", which a joined thread fills, and writes "${labelOf(string)}", a string, which only the world's thread writes.`; + if (string !== undefined) return `System "${leaf.name}" runs on thread "${thread}", which a joined thread fills, and writes "${labelOf(string)}", a string, which only the world's thread writes.`; for (const lookup of Object.values(leaf.declaration.lookup ?? {})) { const index = indexOfLookup(lookup); const object = index === undefined ? undefined : unsharedKeyOf(index.declaration.key); if (index !== undefined && object !== undefined) { - return `System "${labelOfItem(leaf)}" runs on thread "${thread}", which a joined thread fills, and looks up through index "${labelOfItem(index)}", whose key part "${labelOf(object.key)}" is ${object.what}, so it runs only on the world's thread.`; + return `System "${leaf.name}" runs on thread "${thread}", which a joined thread fills, and looks up through index "${index.name}", whose key part "${labelOf(object.key)}" is ${object.what}, so it runs only on the world's thread.`; } - if (index !== undefined && watchesEntries(lookup)) return entriesOnJoined(leaf, thread, labelOfItem(index)); + if (index !== undefined && watchesEntries(lookup)) return entriesOnJoined(leaf, thread, index.name); } return null; }; @@ -546,17 +547,17 @@ export const createRemoteRunner = (state: State, leaf: Leaf, channel: TaskChanne const { declaration } = leaf; const { components: query } = declaration; const thread = threadOf(declaration, "System", leaf.name) ?? "world"; - const label = labelOfItem(leaf); + const label = leaf.name; const problem = unplaceable(leaf, thread); if (problem !== null) throw new Error(problem); const lookups = remoteIndexesOf(state, declaration.lookup, acquireLookups(state, declaration.lookup)); const index = channel.place(state, leaf, lookups.named); - const required = [...new Set([...query?.read ?? [], ...query?.write ?? [], ...query?.has ?? [], ...query?.added ?? [], ...query?.changed ?? []])].map(key => idOf(state, key)); + const required = requiredOf(query).map(key => idOf(state, key)); const excluded = (query?.not ?? []).map(key => idOf(state, key)); const touches = [...new Set([...query?.read ?? [], ...query?.write ?? []])].map(key => idOf(state, key)); const writes = (query?.write ?? []).map(key => idOf(state, key)); const looks = [...new Set(Object.values(declaration.lookup ?? {}).flatMap(lookup => lookup.read ?? []))].map(key => idOf(state, key)); - const targets = reductionTargets(state, declaration); + const targets = reductionTargets(state, label, declaration); const folds = targets.map(target => new Float64Array(target.leaves.length)); const matches: Archetype[] = []; let checked = 0; @@ -568,14 +569,14 @@ export const createRemoteRunner = (state: State, leaf: Leaf, channel: TaskChanne return matches; }; const remote: RemoteSystem = { - channel, index, label, - warning: { kind: "blocking", name: label, message: `System "${label}" runs on thread "${thread}", which a joined thread fills, so its tier waits for that thread.` }, + channel, index, label, id: idOrNull(leaf), + warning: { kind: "blocking", name: label, id: idOrNull(leaf), message: `System "${label}" runs on thread "${thread}", which a joined thread fills, so its tier waits for that thread.` }, targets, folds, foldCount: folds.reduce((sum, leaves) => sum + leaves.length, 0), changed: (query?.changed ?? []).map(key => idOf(state, key)), watched: entityWatchesOf(declaration.lookup).map(key => idOf(state, key)), archetypes: state.archetypes, writes, - declaresInput: declaration.input !== undefined, + declaresInput: leaf.input !== undefined, lastRun: since, result: { flags: 0, segment: undefined, error: undefined }, tail: null, @@ -623,6 +624,6 @@ export const liftColumns = (archetype: Archetype, ids: readonly number[], since: if (storage === undefined || !storage.ticked || storage.columnChanged <= since) continue; const stamp = storage.columnChanged; for (let row = 0; row < archetype.count; row++) if (storage.changed[row] < stamp) storage.changed[row] = stamp; - for (let chunk = 0; chunk <= (archetype.count - 1) >> chunkShift; chunk++) if (storage.chunkChanged[chunk] < stamp) storage.chunkChanged[chunk] = stamp; + raiseRange(storage.chunkChanged, storage.blockChanged, 0, archetype.count, stamp); } }; diff --git a/packages/ecs/src/implementation/world/remote.test.ts b/packages/ecs/src/implementation/world/remote.test.ts index 71553f4a2..92eadac13 100644 --- a/packages/ecs/src/implementation/world/remote.test.ts +++ b/packages/ecs/src/implementation/world/remote.test.ts @@ -1,6 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; -import { Component } from "../../interface/component/component.js"; import type { Entity } from "../../interface/entity/entity.js"; import { Finalizer } from "../../interface/finalizer/finalizer.js"; import { Observe } from "../../observe/index.js"; @@ -25,7 +24,7 @@ declare module "@adobe/ecs" { } export interface Components { [point]: { type: number }; - [caption]: { type: { readonly text: string } | null; thread: "remoteTest" }; + [caption]: { type: Caption; thread: "remoteTest" }; [widget]: { type: Widget | null; thread: "remoteTest" }; [kept]: { type: string }; [guarded]: { type: Widget | null; thread: "remoteTestFinalized" }; @@ -34,48 +33,45 @@ declare module "@adobe/ecs" { } /** A value that isn't data. */ type Widget = { readonly draw: () => string }; -Component.register(point, { type: { type: "number" } }); /** The caption's start, which a command here gives it by this value. */ -const none = { text: "none" }; -Component.register(caption, { type: { anyOf: [{ type: "object", properties: { text: { type: "string" } } }, { type: "null" }], default: none }, thread: "remoteTest" }); -Component.register(widget, { thread: "remoteTest" }); -Component.register(kept, { type: { type: "string" } }); +const none = { text: "none" } as const; +/** A caption, which starts as `none`, the value commands on other threads give it by identity. */ +type Caption = { readonly text: string } | null; +const Caption = { default: none }; /** A widget, or the number a kernel gives one, which only the world's thread could hold. */ -Component.register(handle, { thread: "remoteTest" }); -Component.register(guarded, { thread: "remoteTestFinalized" }); -const spawn = System.create({ name: "remoteTest/spawn", input: { type: "object", properties: { x: { type: "number" } } } }, ({ input, commands }) => { +const spawn = System.create({}, ({ commands }, input: { readonly x: number }) => { commands.spawn([point, caption, widget, kept])({ [point]: input.x, [kept]: `k${input.x}`, [widget]: null }); }); -const nullCaptions = System.create({ name: "remoteTest/nullCaptions", components: { has: [caption, point] } }, (row, { commands }) => { +const nullCaptions = System.create({ components: { with: [caption, point] } }, (row, { commands }) => { commands.add(row.entity, caption, null); }); -const restartCaptions = System.create({ name: "remoteTest/restartCaptions", components: { has: [caption, point] } }, (row, { commands }) => { +const restartCaptions = System.create({ components: { with: [caption, point] } }, (row, { commands }) => { commands.add(row.entity, caption, none); }); -const dropCaptions = System.create({ name: "remoteTest/dropCaptions", components: { has: [caption] } }, (row, { commands }) => { +const dropCaptions = System.create({ components: { with: [caption] } }, (row, { commands }) => { commands.remove(row.entity, caption); }); -const giveCaption = System.create({ name: "remoteTest/giveCaption", components: { has: [point] } }, (row, { commands }) => { +const giveCaption = System.create({ components: { with: [point] } }, (row, { commands }) => { commands.add(row.entity, caption, { text: "a caption only the thread gives" }); }); -const spawnCaptioned = System.create({ name: "remoteTest/spawnCaptioned" }, ({ commands }) => { +const spawnCaptioned = System.create({}, ({ commands }) => { commands.spawn([point, caption])({ [point]: 1, [caption]: { text: "a caption only the thread gives" } }); }); /** A kernel giving the thread's objects null, which they take, and a number, which they don't. */ -const nullCaptionsKernel = System.create({ name: "remoteTest/nullCaptionsKernel", kernel: "cpu", components: { has: [caption], read: [point] } }, (row, { commands }) => { +const nullCaptionsKernel = System.create({ kernel: true, components: { with: [point, caption] } }, (row, { commands }) => { if (row[point] > 0) commands.add(row.entity, caption, null); }); -const numberHandlesKernel = System.create({ name: "remoteTest/numberHandlesKernel", kernel: "cpu", components: { read: [point] } }, (row, { commands }) => { +const numberHandlesKernel = System.create({ kernel: true, components: { with: [point] } }, (row, { commands }) => { commands.add(row.entity, handle, row[point]); }); /** Reaches its commands, so it isn't terminal: its tier blocks rather than running as a tail. */ -const readCaptions = System.create({ name: "remoteTest/readCaptions", components: { read: [caption] } }, (row, { commands }) => { +const readCaptions = System.create({ components: { with: [caption] } }, (row, { commands }) => { if (row[caption]?.text === "never") commands.despawn(row.entity); }); -const idle = System.create({ name: "remoteTest/idle" }, () => {}); -const after = System.create({ name: "remoteTest/after" }, () => {}); -Finalizer.create({ name: "remoteTest/releaseGuarded", components: { read: [guarded] } }, () => {}); +const idle = System.create({}, () => {}); +const after = System.create({}, () => {}); +Finalizer.create({ components: { with: [guarded] } }, row => { void row[guarded]; }); /** A world shared with a thread on this one, which claims `threads`. */ const joined = (threads: readonly ("remoteTest" | "remoteTestFinalized")[] = ["remoteTest"]): { readonly world: CreatedWorld; readonly remote: World } => { @@ -130,7 +126,7 @@ describe("objects a joined thread fills", () => { expect((await runReported(world, nullCaptionsKernel)).failures).toEqual([]); expect(await until(listed, value => value?.[caption] === null)).toEqual({ [caption]: null, [widget]: null }); const report = await runReported(world, numberHandlesKernel); - expect(report.failures.map(failure => [failure.name, failure.message])).toEqual([["remoteTest/numberHandlesKernel", '"remoteTest/handle" lives on thread "remoteTest", which a joined thread fills, so a command here gives it only its start or null.']]); + expect(report.failures.map(failure => [failure.name, failure.message])).toEqual([["numberHandlesKernel", '"remoteTest/handle" lives on thread "remoteTest", which a joined thread fills, so a command here gives it only its start or null.']]); expect(report.dropped).toBe(1); await remote.dispose(); await world.dispose(); @@ -141,7 +137,7 @@ describe("objects a joined thread fills", () => { await spawnOne(world, 3); expect(await runReported(world, readCaptions)).toEqual({ failures: [], skipped: [], dropped: 0, - warnings: [{ kind: "blocking", name: "remoteTest/readCaptions", message: 'System "remoteTest/readCaptions" runs on thread "remoteTest", which a joined thread fills, so its tier waits for that thread.' }], + warnings: [{ kind: "blocking", name: "readCaptions", id: "@adobe/ecs@0.1.1/src/implementation/world/remote.test.ts#readCaptions", message: 'System "readCaptions" runs on thread "remoteTest", which a joined thread fills, so its tier waits for that thread.' }], }); await remote.dispose(); await world.dispose(); @@ -165,21 +161,21 @@ describe("objects a joined thread fills", () => { await world.run(readCaptions); const taken = joined(); await statusOf(taken.world, "joined"); - expect((await runReported(taken.world, readCaptions)).warnings.map(warning => warning.name)).toEqual(["remoteTest/readCaptions"]); + expect((await runReported(taken.world, readCaptions)).warnings.map(warning => warning.name)).toEqual(["readCaptions"]); await taken.remote.dispose(); await taken.world.dispose(); // Declared in a function, so the build gives it no id, and another thread can't name it. - const unnamed = System.create({ name: "remoteTest/unnamed", components: { read: [caption] } }, row => { void row[caption]; }); + const unnamed = System.create({ components: { with: [caption] } }, row => { void row[caption]; }); await world.run(unnamed); const channel = new MessageChannel(); world.share(channel.port1); const remote = join(channel.port2, { threads: ["remoteTest"] }); - await expect(remote.run(idle)).rejects.toThrow('System "remoteTest/unnamed" runs on thread "remoteTest" and has no id, so a joined thread can\'t run it'); + await expect(remote.run(idle)).rejects.toThrow('System "unnamed" runs on thread "remoteTest" and has no id, so a joined thread can\'t run it'); await statusOf(world, "gone"); await remote.dispose(); const other = joined(["remoteTestFinalized"]); // Declared without a binding, so the build gives it no id. - await expect(other.remote.run(idle)).rejects.toThrow('Finalizer "remoteTest/releaseGuarded" runs on thread "remoteTestFinalized" and has no id, so a joined thread can\'t run it'); + await expect(other.remote.run(idle)).rejects.toThrow(/Finalizer "finalizer #\d+" runs on thread "remoteTestFinalized" and has no id, so a joined thread can't run it/); await other.remote.dispose(); await other.world.dispose(); await world.dispose(); diff --git a/packages/ecs/src/implementation/world/remove.test-helper.ts b/packages/ecs/src/implementation/world/remove.test-helper.ts index 8185405f7..2e4da6c24 100644 --- a/packages/ecs/src/implementation/world/remove.test-helper.ts +++ b/packages/ecs/src/implementation/world/remove.test-helper.ts @@ -1,7 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Resource } from "../../interface/resource/resource.js"; import { System } from "../../interface/system/system.js"; import type { World } from "../../interface/world/world.js"; +import type { Schema } from "../../interface/schema/schema.js"; // Keys and systems both threads of the removal tests import: systems on thread "removeTest", which a joined thread // fills, the first of each pair removing the second from the task they run in. @@ -14,17 +14,14 @@ declare module "@adobe/ecs" { removeTest: true; } export interface Resources { - [mark]: { type: { readonly text: string }; thread: "removeTest" }; + [mark]: { type: { readonly text: string }; thread: "removeTest"; default: { text: "m" } }; [pause]: { type: number }; - [secondRuns]: { type: number }; + [secondRuns]: { type: Schema.Type<{ type: "integer" }>; default: 0 }; } } /** An object resource of the thread, which places the systems reading it there. */ -Resource.register(mark, { type: { type: "object", properties: { text: { type: "string" } }, default: { text: "m" } }, thread: "removeTest" }); /** How long, in milliseconds, `first` and `firstTail` spin before they return. */ -Resource.register(pause, { type: { type: "number", default: 0 } }); /** How many times `second` ran, wherever it ran. */ -Resource.register(secondRuns, { type: { type: "integer", default: 0 } }); /** How many times each system's body was called on this thread. */ export const calls = { first: 0, second: 0, firstTail: 0, secondTail: 0 }; @@ -41,35 +38,35 @@ const spin = (milliseconds: number): void => { }; /** Blocking, since its body reaches `commands`: a tier on the thread, and with `second`, one task. */ -export const first = System.create({ name: "removeTest/first", resources: { read: [mark, pause] } }, ({ resources, commands }) => { +export const first = System.create({}, ({ resources, commands }) => { void commands; calls.first += resources[mark].text.length; spin(resources[pause]); if (removal.armed) removal.world?.remove(second); }); -export const second = System.create({ name: "removeTest/second", resources: { read: [mark], write: [secondRuns] }, after: [first] }, ({ resources, commands }) => { +export const second = System.create({ after: [first] }, ({ resources, commands }) => { void commands; calls.second += resources[mark].text.length; resources[secondRuns] += 1; }); /** Terminal, so a tail, run with `secondTail` as one task after the tick's sync point. */ -export const firstTail = System.create({ name: "removeTest/firstTail", resources: { read: [mark, pause] } }, ({ resources }) => { +export const firstTail = System.create({}, ({ resources }) => { calls.firstTail += resources[mark].text.length; spin(resources[pause]); if (removal.armed) removal.world?.remove(secondTail); }); /** Terminal, so a tail: removes `removal.target`, once, when it's set. */ -export const removerTail = System.create({ name: "removeTest/removerTail", resources: { read: [mark] } }, ({ resources }) => { +export const removerTail = System.create({}, ({ resources }) => { void resources[mark]; const { target } = removal; removal.target = null; if (target !== null) removal.world?.remove(target); }); -export const secondTail = System.create({ name: "removeTest/secondTail", resources: { read: [mark] }, after: [firstTail] }, ({ resources }) => { +export const secondTail = System.create({ after: [firstTail] }, ({ resources }) => { calls.secondTail += resources[mark].text.length; }); @@ -77,9 +74,9 @@ export const secondTail = System.create({ name: "removeTest/secondTail", resourc * On the world's thread, added while the others come and go, so ticks go on; after `second`, so the tiers of `first` and * `second` are one task there. */ -export const ticking = System.create({ name: "removeTest/ticking", after: [second] }, () => {}); +export const ticking = System.create({ after: [second] }, () => {}); /** Sets how long `first` and `firstTail` spin. */ -export const setPause = System.create({ name: "removeTest/setPause", input: { type: "object", properties: { milliseconds: { type: "number" } } }, resources: { write: [pause] } }, ({ input, resources }) => { +export const setPause = System.create({}, ({ resources }, input: { readonly milliseconds: number }) => { resources[pause] = input.milliseconds; }); diff --git a/packages/ecs/src/implementation/world/remove.test.ts b/packages/ecs/src/implementation/world/remove.test.ts index 945d3157d..d5d7b66b9 100644 --- a/packages/ecs/src/implementation/world/remove.test.ts +++ b/packages/ecs/src/implementation/world/remove.test.ts @@ -1,6 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; -import { Component } from "../../interface/component/component.js"; import { Entity } from "../../interface/entity/entity.js"; import { Observe } from "../../observe/index.js"; import { always } from "../../interface/resources/always.js"; @@ -24,9 +23,6 @@ declare module "@adobe/ecs" { } } -Component.register(value, { type: { type: "number" } }); -Component.register(parent, { type: Entity.schema, reference: "owner" }); -Component.register(shared, { type: { type: "number" } }); /** How many times each system's body was called. */ const calls = { kept: 0, held: 0, alone: 0, other: 0, late: 0, self: 0, writer: 0, reader: 0 }; @@ -35,41 +31,41 @@ let current: World | null = null; /** What `selfRemoving` removes, once its body has been called this many times. */ let removeAfter = Infinity; -const seed = System.create({ name: "removeTest/seed" }, ({ commands }) => { +const seed = System.create({}, ({ commands }) => { const root = commands.spawn([value, shared])({ [value]: 0, [shared]: 0 }); for (let i = 1; i < 10; i++) commands.spawn([value, parent, shared])({ [value]: i, [parent]: root, [shared]: 0 }); }); /** Run before tables are taken, so `value` and `parent` keep ticks then, as they do once any system filters on them. */ -const ticksValue = System.create({ name: "removeTest/ticksValue", components: { added: [value, parent] } }, () => {}); +const ticksValue = System.create({ components: { added: [value, parent] } }, () => {}); /** Added for the whole of each test, so ticks go on. */ -const kept = System.create({ name: "removeTest/kept" }, () => { calls.kept++; }); +const kept = System.create({}, () => { calls.kept++; }); /** Holds what systems can on the world's thread: an `up` order, a change filter's count, command buffers. */ -const held = System.create({ name: "removeTest/held", components: { read: [value], changed: [value] }, up: { through: parent, read: [value] } }, (row, { up }) => { +const held = System.create({ components: { with: [value], changed: [value] }, up: { through: parent } }, (row, { up }) => { calls.held++; void (row[value] + (up?.[value] ?? 0)); }); -const alone = System.create({ name: "removeTest/alone" }, () => { calls.alone++; }); -const other = System.create({ name: "removeTest/other" }, () => { calls.other++; }); -const pair = System.create({ name: "removeTest/pair", systems: [alone, other] }); +const alone = System.create({}, () => { calls.alone++; }); +const other = System.create({}, () => { calls.other++; }); +const pair = System.create({ systems: [alone, other] }); /** In a later tier than `remover`, which removes it during the tick. */ -const late = System.create({ name: "removeTest/late", components: { read: [value] } }, row => { calls.late += row[value] >= 0 ? 1 : 0; }); -const remover = System.create({ name: "removeTest/remover", before: [late] }, () => { current?.remove(late); }); +const late = System.create({ components: { with: [value] } }, row => { calls.late += row[value] >= 0 ? 1 : 0; }); +const remover = System.create({ before: [late] }, () => { current?.remove(late); }); /** Removes itself partway through its rows. */ -const selfRemoving = System.create({ name: "removeTest/selfRemoving", components: { read: [value] } }, row => { +const selfRemoving = System.create({ components: { with: [value] } }, row => { calls.self += row[value] >= 0 ? 1 : 0; if (calls.self === removeAfter) current?.remove(selfRemoving); }); /** Ordered only through `phase`: removing `phase` would leave them conflicting with no order between them. */ -const phase = System.create({ name: "removeTest/phase" }, () => {}); -const writer = System.create({ name: "removeTest/writer", components: { write: [shared] }, before: [phase] }, row => { calls.writer++; row[shared] += 1; }); -const reader = System.create({ name: "removeTest/reader", components: { read: [shared] }, after: [phase] }, row => { calls.reader++; void row[shared]; }); +const phase = System.create({}, () => {}); +const writer = System.create({ components: { with: [shared] }, before: [phase] }, row => { calls.writer++; row[shared] += 1; }); +const reader = System.create({ components: { with: [shared] }, after: [phase] }, row => { calls.reader++; void row[shared]; }); /** A world ticking back to back once something is added, with `kept` added. */ const ticking = async (): Promise<{ readonly world: CreatedWorld; readonly ticks: (count: number) => Promise; readonly close: () => Promise }> => { diff --git a/packages/ecs/src/implementation/world/render-order.browser.test.ts b/packages/ecs/src/implementation/world/render-order.browser.test.ts new file mode 100644 index 000000000..4a00f7e8d --- /dev/null +++ b/packages/ecs/src/implementation/world/render-order.browser.test.ts @@ -0,0 +1,208 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { describe, expect, it } from "vitest"; +import type { Entity } from "../../interface/entity/entity.js"; +import { Gpu } from "../../interface/gpu/gpu.js"; +import { Index } from "../../interface/indexes/index-type.js"; +import { gpuDevice } from "../../interface/resources/gpu-device.js"; +import { System } from "../../interface/system/system.js"; +import { createWorld } from "./create.js"; +import { runReported } from "./reports.test-helper.js"; +import type { F32, U32, Vec3 } from "../../interface/math/index.js"; + +const depth = Symbol("renderOrderTest/depth"); +const code = Symbol("renderOrderTest/code"); +const shape = Symbol("renderOrderTest/shape"); +const image = Symbol("renderOrderTest/image"); +const size = Symbol("renderOrderTest/size"); +const visible = Symbol("renderOrderTest/visible"); +const hidden = Symbol("renderOrderTest/hidden"); + +declare module "@adobe/ecs" { + export interface Components { + [depth]: { type: F32 }; + [code]: { type: U32 }; + [shape]: { type: true }; + [image]: { type: true }; + [size]: { type: Vec3 }; + [visible]: { type: true }; + [hidden]: { type: true }; + } +} + + +const byDepth = Index.create({ key: [depth], sorted: true }); + +const device = async (): Promise => { + const adapter = await navigator.gpu.requestAdapter(); + if (adapter === null) throw new Error("No WebGPU adapter."); + return adapter.requestDevice(); +}; + +const count = 2000; +const spawned: Entity[] = []; + +/** + * Shapes and images interleaved in depth, spawned in reverse depth order, so neither spawn nor row order is paint + * order. Every tenth is hidden or not visible, which the render system filters out, and plain depths match nothing. + */ +const spawn = System.create({}, ({ commands }) => { + for (let i = count - 1; i >= 0; i--) { + if (i % 10 === 3) spawned[i] = commands.spawn([depth, code, shape, visible, hidden])({ [depth]: i, [code]: i, [shape]: true, [visible]: true, [hidden]: true }); + else if (i % 10 === 7) spawned[i] = commands.spawn([depth, code, image])({ [depth]: i, [code]: i, [image]: true }); + else if (i % 2 === 0) spawned[i] = commands.spawn([depth, code, shape, visible])({ [depth]: i, [code]: i, [shape]: true, [visible]: true }); + else spawned[i] = commands.spawn([depth, code, image, size, visible])({ [depth]: i, [code]: i, [image]: true, [size]: [i, i, i], [visible]: true }); + } + commands.spawn([depth])({ [depth]: -1 }); +}); + +/** The codes a frame should draw, in paint order. */ +const drawn = (codes: Iterable): number[] => [...codes].filter(i => i % 10 !== 3 && i % 10 !== 7).sort((a, b) => a - b); + +describe("render systems with an order", () => { + it("draw every matching archetype as one table, in the index's order, through the same accessors", async () => { + const own = await device(); + const output = own.createBuffer({ size: (count + 16) * 8, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC | GPUBufferUsage.COPY_DST }); + const tableCounts: number[][] = []; + const draw = System.render({ + components: { with: [visible], not: [hidden] }, + order: byDepth, + shader: { accessors: { paintDepth: depth, paintCode: code } }, + }, ({ encoder, tables }) => { + tableCounts.push(tables.map(table => table.count)); + encoder.clearBuffer(output); + const pass = encoder.beginComputePass(); + pass.setPipeline(pipeline); + pass.setBindGroup(1, outputGroup); + for (const table of tables) { + table.bind(pass); + pass.dispatchWorkgroups(Math.ceil(table.count / 64)); + } + pass.end(); + }); + const outputLayout = own.createBindGroupLayout({ entries: [{ binding: 0, visibility: GPUShaderStage.COMPUTE, buffer: { type: "storage" } }] }); + const outputGroup = own.createBindGroup({ layout: outputLayout, entries: [{ binding: 0, resource: { buffer: output } }] }); + const module = own.createShaderModule({ + code: `${Gpu.wgsl(draw)} +@group(1) @binding(0) var output: array; +@compute @workgroup_size(64) fn main(@builtin(global_invocation_id) id: vec3u) { + if (id.x >= ecs_count()) { return; } + output[id.x] = vec2u(u32(paintDepth(id.x)), paintCode(id.x)); +}`, + }); + expect((await module.getCompilationInfo()).messages.filter(message => message.type === "error")).toEqual([]); + const pipeline = own.createComputePipeline({ layout: own.createPipelineLayout({ bindGroupLayouts: [Gpu.layout(own), outputLayout] }), compute: { module, entryPoint: "main" } }); + + /** What the last frame drew: depth and code per instance. */ + const read = async (): Promise => { + const staging = own.createBuffer({ size: output.size, usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST }); + const encoder = own.createCommandEncoder(); + encoder.copyBufferToBuffer(output, 0, staging, 0, output.size); + own.queue.submit([encoder.finish()]); + await staging.mapAsync(GPUMapMode.READ); + const words = new Uint32Array(staging.getMappedRange().slice(0)); + staging.destroy(); + const instances = tableCounts.at(-1)?.[0] ?? 0; + return Array.from({ length: instances }, (_, i) => [words[2 * i], words[2 * i + 1]]); + }; + const frame = async (): Promise => expect((await runReported(world, draw)).failures).toEqual([]); + + const world = createWorld({ [gpuDevice]: own }); + await world.run(spawn); + await frame(); + const codes = new Set(Array.from({ length: count }, (_, i) => i)); + let expected = drawn(codes); + expect(tableCounts.at(-1)).toEqual([expected.length]); + expect(await read()).toEqual(expected.map(i => [i, i])); + + // A despawn moves another shape into its row, a spawn adds an image between two others, and a key moves a shape + // to the back; a frame after each draws the new order. + const despawn = System.create({}, ({ commands }) => commands.despawn(spawned[count - 2])); + await world.run(despawn); + codes.delete(count - 2); + await frame(); + expected = drawn(codes); + expect(await read()).toEqual(expected.map(i => [i, i])); + + const addImage = System.create({}, ({ commands }) => { + commands.spawn([depth, code, image, size, visible])({ [depth]: 500.5, [code]: 5001, [image]: true, [size]: [0, 0, 0], [visible]: true }); + }); + await world.run(addImage); + await frame(); + const withImage = expected.flatMap(i => i === 500 ? [[i, i], [500, 5001]] : [[i, i]]); + expect(await read()).toEqual(withImage); + + const toBack = System.create({ components: { with: [code, depth] } }, row => { + if (row[code] === 10) row[depth] = count + 5; + }); + await world.run(toBack); + await frame(); + const atBack = [...withImage.filter(([, c]) => c !== 10), [count + 5, 10]]; + expect(await read()).toEqual(atBack); + + // Showing a hidden shape adds it to the one table, in its place. + const show = System.create({}, ({ commands }) => commands.remove(spawned[13], hidden)); + await world.run(show); + await frame(); + const shown = atBack.flatMap(([d, c]) => d === 12 ? [[d, c], [13, 13]] : [[d, c]]); + expect(await read()).toEqual(shown); + expect(tableCounts.every(counts => counts.length === 1)).toBe(true); + await world.dispose(); + }); + + it("write their order only when the index's entries change or move", async () => { + const own = await device(); + const draw = System.render({ components: { with: [visible], not: [hidden] }, order: byDepth, shader: { accessors: { paintDepth: depth } } }, () => {}); + const world = createWorld({ [gpuDevice]: own }); + await world.run(spawn); + await world.run(draw); + const instances = drawn(Array.from({ length: count }, (_, i) => i)).length; + // Row uploads and block writes pass a size; record writes don't. + const sizes: number[] = []; + const { queue } = own; + const write = queue.writeBuffer.bind(queue); + let records = 0; + queue.writeBuffer = (buffer, offset, data, dataOffset, size) => { + if (size === undefined) records++; + else sizes.push(size); + write(buffer, offset, data, dataOffset, size); + }; + await world.run(draw); + expect([sizes, records]).toEqual([[], 0]); + // A new key writes its chunk of the column and the order, in elements; the record stays. + const toBack = System.create({ components: { with: [code, depth] } }, row => { + if (row[code] === 10) row[depth] = count + 5; + }); + await world.run(toBack); + await world.run(draw); + expect(sizes.slice(1)).toEqual([instances]); + expect(records).toBe(0); + sizes.length = 0; + await world.run(draw); + expect(sizes).toEqual([]); + await world.dispose(); + }); + + it("give a shader one table when nothing matches", async () => { + const own = await device(); + let tables: readonly System.RenderTable[] = []; + const draw = System.render({ components: { with: [hidden, image] }, order: byDepth, shader: { accessors: { paintDepth: depth } } }, context => { tables = context.tables; }); + const world = createWorld({ [gpuDevice]: own }); + await world.run(spawn); + expect((await runReported(world, draw)).failures).toEqual([]); + expect(tables.map(table => table.count)).toEqual([0]); + await world.dispose(); + }); + + it("declare their ordered accessors, which compile", async () => { + const own = await device(); + const draw = System.render({ order: byDepth, shader: { group: 2, accessors: { paintDepth: depth, paintSize: size } } }, () => {}); + const code = Gpu.wgsl(draw); + expect(code).toContain("fn paintDepth(row: u32) -> f32"); + expect(code).toContain("fn paintSize(row: u32) -> vec3"); + expect(code).toContain("@group(2) @binding(1) var ecs_table: EcsTable;"); + // An instance is its position in the order, which the order block maps to a table and a row. + expect(code).toContain("struct EcsTable { ecs_rows: u32, ecs_order: u32, ecs_tables: u32, ecs_shift: u32 }"); + const module = own.createShaderModule({ code: `${code}\n@compute @workgroup_size(1) fn main() { _ = paintDepth(0u); _ = paintSize(0u); _ = ecs_count(); }` }); + expect((await module.getCompilationInfo()).messages.filter(message => message.type === "error")).toEqual([]); + }); +}); diff --git a/packages/ecs/src/implementation/world/render-order.ts b/packages/ecs/src/implementation/world/render-order.ts new file mode 100644 index 000000000..607755d9c --- /dev/null +++ b/packages/ecs/src/implementation/world/render-order.ts @@ -0,0 +1,150 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { RenderContext, RenderTable } from '../../interface/system/render.js'; +import type { Archetype } from './archetype.js'; +import type { GpuEngine } from './gpu.js'; +import type { Ordered } from './index-order.js'; +import { blockBase } from './index-sorted.js'; +import type { WorldIndex } from './indexes.js'; + +// A render system with `order` draws one table: instance `i` is the i-th matching entity in its sorted index's order. +// Its record is the instance count, the word addresses of its order block and tables block, and the bits a slot is +// shifted by. The order block holds, by position, a matched archetype's slot above `shift` bits and its row below +// them; the tables block holds, by slot, the word address of each accessor's column. The order block is built and +// written only when the index's entries changed or moved, or the matches grew; the tables block only when a column's +// address changed; the record only when one of its words did. + +/** The table of a render system's run and its context, both reused. */ +type MutableTable = { count: number; bind: (pass: GPURenderPassEncoder | GPUComputePassEncoder) => void }; +type MutableContext = { device: GPUDevice; encoder: GPUCommandEncoder; tables: readonly RenderTable[] }; + +export type OrderedRender = { + /** + * Uploads what the run's one table reads; returns null, or the reason it can't be drawn: more matched archetypes + * and rows than an entry's 32 bits hold. + */ + readonly prepare: (gpu: GpuEngine, matches: readonly Archetype[], tick: number) => string | null; + /** The body's context, once the work recorded before it is submitted. */ + readonly context: (gpu: GpuEngine) => RenderContext; +}; + +/** + * `columns` are the accessors' component ids, in accessor order; `group` the bind group the shader declares; `key` + * the prefix of the runner's GPU records. + */ +export const createOrderedRender = (index: WorldIndex, ordered: Ordered, columns: readonly number[], group: number, key: string): OrderedRender => { + const width = columns.length; + const orderKey = `${key}order`; + const tablesKey = `${key}tables`; + const recordKey = `${key}ordered`; + let entries = new Int32Array(64); + let instances = 0; + let tables = new Int32Array(Math.max(1, width)); + /** What the order block was last built from: the index's ticks, the matches, and the GPU it was written to. */ + let builtChanged = -1; + let builtMoved = -1; + let builtMatches = -1; + let builtGpu: GpuEngine | null = null; + let shift = 31; + const words = new Uint32Array(4); + let recorded = false; + const offsets = [0]; + let bound: GpuEngine | null = null; + const table: MutableTable = { + count: 0, + bind: pass => { + if (bound !== null) pass.setBindGroup(group, bound.renderGroup(), offsets); + }, + }; + const list: readonly RenderTable[] = [table]; + let context: MutableContext | null = null; + + /** Lists the index's entries of matched archetypes, in its order, as slot and row. */ + const build = (matches: readonly Archetype[]): void => { + const sorted = index.sorted; + instances = 0; + if (sorted === null) return; + if (entries.length < sorted.size) entries = new Int32Array(Math.max(sorted.size, 2 * entries.length)); + const owners = ordered.owners(matches); + const known = owners.length; + const slotShift = shift; + const { order, lengths, stride, words: keyWords, blocks } = sorted; + const sortedEntries = sorted.entries; + for (let k = 0; k < blocks; k++) { + const block = order[k]; + const base = blockBase(sorted, block) + keyWords; + for (let at = base, end = base + lengths[block] * stride; at < end; at += stride) { + const archetype = sortedEntries[at + 1]; + const owner = archetype < known ? owners[archetype] : -1; + if (owner < 0) continue; + entries[instances++] = (owner << slotShift) | sortedEntries[at + 2]; + } + } + }; + + const prepare = (gpu: GpuEngine, matches: readonly Archetype[], tick: number): string | null => { + const slots = matches.length; + if (gpu !== builtGpu) { + builtGpu = gpu; + builtChanged = -1; + recorded = false; + } + // The tables block: every column's address, uploading rows the CPU changed first. + if (tables.length < slots * width) { + const grown = new Int32Array(Math.max(slots * width, 2 * tables.length)); + grown.set(tables); + tables = grown; + } + let addressed = false; + let most = 0; + for (let slot = 0; slot < slots; slot++) { + const archetype = matches[slot]; + const { count } = archetype; + if (count > most) most = count; + for (let k = 0; k < width; k++) { + const storage = count === 0 ? undefined : archetype.storageById[columns[k]]; + const address = storage === undefined ? 0 : gpu.upload(archetype, storage, tick); + const at = slot * width + k; + if (tables[at] !== address) { + tables[at] = address; + addressed = true; + } + } + } + if (slots !== builtMatches) { + // At least one bit of slot, so a row's mask is never 32 bits. + const slotBits = Math.max(1, Math.ceil(Math.log2(slots))); + shift = 32 - slotBits; + builtMatches = slots; + builtChanged = -1; + } + if (most > 2 ** shift) return `with ${slots} matching archetypes an ordered table names at most ${2 ** shift} rows of each, and one holds ${most}`; + const rebuilt = index.changed !== builtChanged || index.moved !== builtMoved; + if (rebuilt) { + build(matches); + builtChanged = index.changed; + builtMoved = index.moved; + } + const orderAddress = gpu.uploadIndexes(orderKey, entries, instances, rebuilt); + const tablesAddress = gpu.uploadIndexes(tablesKey, tables, slots * width, addressed); + if (!recorded || words[0] !== instances || words[1] !== orderAddress || words[2] !== tablesAddress || words[3] !== shift) { + words[0] = instances; + words[1] = orderAddress; + words[2] = tablesAddress; + words[3] = shift; + offsets[0] = gpu.record(recordKey, words); + recorded = true; + } + bound = gpu; + table.count = instances; + return null; + }; + + const contextOf = (gpu: GpuEngine): RenderContext => { + if (context === null) return context = { device: gpu.device, encoder: gpu.encoder(), tables: list }; + context.device = gpu.device; + context.encoder = gpu.encoder(); + return context; + }; + + return { prepare, context: contextOf }; +}; diff --git a/packages/ecs/src/implementation/world/reports.test-helper.ts b/packages/ecs/src/implementation/world/reports.test-helper.ts index 593d9ab67..ac8544fac 100644 --- a/packages/ecs/src/implementation/world/reports.test-helper.ts +++ b/packages/ecs/src/implementation/world/reports.test-helper.ts @@ -23,7 +23,7 @@ const readerOf = (world: World): Reader => { const known = readers.get(world); if (known !== undefined) return known; const asked: Reader["asked"] = []; - const system = System.create({ name: "test/readReport", observer: true, components: { read: [Report.run] }, lookup: { errorsOf: { read: [Report.errors] } } }, (row, { lookup }) => { + const system = System.create({ observer: true, components: { with: [Report.run] }, lookup: { errorsOf: { with: [Report.errors] } } }, (row, { lookup }) => { const read = asked[0]; const values = row[Report.run]; if (read === undefined || (read.run >= 0 ? values.number !== read.run : values.number <= (read.values?.number ?? -1))) return; @@ -90,7 +90,7 @@ export const followReports = (world: World) => { type Seen = { readonly run: number; readonly failures: readonly Report.Failure[] }; const seen: Seen[] = []; const waiting: (() => void)[] = []; - const system = System.create({ name: "test/followReports", observer: true, components: { read: [Report.run, Report.errors], changed: [Report.errors] } }, row => { + const system = System.create({ observer: true, components: { with: [Report.run, Report.errors], changed: [Report.errors] } }, row => { seen.push({ run: row[Report.run].number, failures: row[Report.errors].failures }); for (const wake of waiting.splice(0)) wake(); }); @@ -106,11 +106,11 @@ export const followReports = (world: World) => { }; /** Despawns every report, for tests that compare entity order with a world that has none: later runs spawn them again. */ -export const dropReports = System.create({ name: "test/dropReports", components: { has: [Report.run] } }, (row, { commands }) => { +export const dropReports = System.create({ components: { with: [Report.run] } }, (row, { commands }) => { commands.despawn(row.entity); }); -const nothing = System.create({ name: "test/nothing" }, () => {}); +const nothing = System.create({}, () => {}); /** Runs a world until every report it keeps is spawned, so later runs reuse their entities and spawn none. */ export const fillReports = async (world: World): Promise => { diff --git a/packages/ecs/src/implementation/world/reports.test.ts b/packages/ecs/src/implementation/world/reports.test.ts index db328c337..5a17fb229 100644 --- a/packages/ecs/src/implementation/world/reports.test.ts +++ b/packages/ecs/src/implementation/world/reports.test.ts @@ -1,6 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; -import { Component, Index, Report, Resource, System, clock, workers } from "../../index.js"; +import { Index, Report, System, clock, workers } from "../../index.js"; import { createWorld } from "./create.js"; import { retainedRuns } from "./reports.js"; import { reportOf, runOf } from "./reports.test-helper.js"; @@ -15,34 +15,32 @@ declare module "@adobe/ecs" { [level]: { type: number }; } } -Component.register(amount, { type: { type: "number" } }); -Resource.register(level, { type: { type: "number", default: 0 } }); -const nothing = System.create({ name: "reportsTest/nothing" }, () => {}); -const fail = System.create({ name: "reportsTest/fail" }, () => { throw new Error("run failed"); }); -const raise = System.create({ name: "reportsTest/raise", resources: { write: [level] } }, ({ resources }) => { resources[level] += 1; }); -const idle = System.create({ name: "reportsTest/idle", observer: true }, () => {}); -const spawn = System.create({ name: "reportsTest/spawn" }, ({ commands }) => { commands.spawn([amount])({ [amount]: 1 }); }); -const despawnAll = System.create({ name: "reportsTest/despawnAll", components: { has: [amount] } }, (row, { commands }) => { +const nothing = System.create({}, () => {}); +const fail = System.create({}, () => { throw new Error("run failed"); }); +const raise = System.create({}, ({ resources }) => { resources[level] += 1; }); +const idle = System.create({ observer: true }, () => {}); +const spawn = System.create({}, ({ commands }) => { commands.spawn([amount])({ [amount]: 1 }); }); +const despawnAll = System.create({ components: { with: [amount] } }, (row, { commands }) => { commands.despawn(row.entity); commands.despawn(row.entity); }); -const failingObserver = System.create({ name: "reportsTest/failingObserver", observer: true, resources: { read: [level], changed: [level] } }, ({ resources }) => { +const failingObserver = System.create({ observer: true, resources: { changed: [level] } }, ({ resources }) => { if (resources[level] > 0) throw new Error("observer failed"); }); /** What a logger following reports saw, by pass: each changed report's run and failures' messages. */ const logged: (readonly [number, readonly string[]])[] = []; -const logger = System.create({ name: "reportsTest/logger", observer: true, components: { read: [Report.run, Report.errors], changed: [Report.errors] } }, row => { +const logger = System.create({ observer: true, components: { with: [Report.run, Report.errors], changed: [Report.errors] } }, row => { logged.push([row[Report.run].number, row[Report.errors].failures.map(failure => failure.message)]); }); /** The run numbers of every report, in entity order, and of those with errors. */ let numbers: number[] = []; let withErrors: number[] = []; -const allReports = Index.create({ name: "reportsTest/allReports", key: [], components: { has: [Report.run] } }); -const erredReports = Index.create({ name: "reportsTest/erredReports", key: [], components: { has: [Report.run, Report.errors] } }); -const listReports = System.create({ name: "reportsTest/listReports", observer: true, lookup: { all: { index: allReports, read: [Report.run] }, erred: { index: erredReports, read: [Report.run] } } }, ({ lookup }) => { +const allReports = Index.create({ key: [], components: { with: [Report.run] } }); +const erredReports = Index.create({ key: [], components: { with: [Report.run, Report.errors] } }); +const listReports = System.create({ observer: true, lookup: { all: { index: allReports }, erred: { index: erredReports } } }, ({ lookup }) => { const all = lookup.all(); numbers = []; for (let i = 0; i < all.length; i++) numbers.push(all.at(i)[Report.run].number); @@ -83,7 +81,7 @@ describe("reports", () => { const failed = await world.run(fail); expect(logged).toEqual([[failed, ["run failed"]]]); const { failures } = await reportOf(world, failed); - expect(failures.map(({ kind, name, message }) => [kind, name, message])).toEqual([["system", "reportsTest/fail", "run failed"]]); + expect(failures.map(({ kind, name, message }) => [kind, name, message])).toEqual([["system", "fail", "run failed"]]); await world.run(spawn); const dropping = await world.run(despawnAll); expect(await reportOf(world, dropping)).toEqual({ failures: [], skipped: [], dropped: 1, warnings: [] }); @@ -103,7 +101,7 @@ describe("reports", () => { expect((await reportOf(world, raised)).failures).toEqual([]); // The reader's run, the next, holds them, and the logger saw them in its pass. const { failures } = await reportOf(world, raised + 1); - expect(failures.map(({ name, message }) => [name, message])).toEqual([["reportsTest/failingObserver", "observer failed"]]); + expect(failures.map(({ name, message }) => [name, message])).toEqual([["failingObserver", "observer failed"]]); expect(logged).toEqual([[raised + 1, ["observer failed"]]]); // A failed run's report holds its own failures; its pass's are in the next. const both = System.create([fail, raise]); @@ -133,7 +131,7 @@ describe("reports", () => { await using world = inline(); logged.length = 0; world.add(logger); - const failWith = System.create({ name: "reportsTest/failWith", input: { type: "object", properties: { k: { type: "integer" } } } }, ({ input }) => { throw new Error(`run ${input.k}`); }); + const failWith = System.create({}, (_context, input: { readonly k: number }) => { throw new Error(`run ${input.k}`); }); for (let k = 0; k < retainedRuns; k++) await world.run(failWith, { k }); logged.length = 0; const reused = await world.run(failWith, { k: retainedRuns }); @@ -152,7 +150,7 @@ describe("reports", () => { let ticked = (): void => {}; // Its first pass passes its filter; only a tick writes the clock after. let passes = 0; - const watchTicks = System.create({ name: "reportsTest/watchTicks", observer: true, resources: { changed: [clock] } }, () => { + const watchTicks = System.create({ observer: true, resources: { changed: [clock] } }, () => { if (++passes > 1) ticked(); }); world.add(watchTicks); diff --git a/packages/ecs/src/implementation/world/reports.ts b/packages/ecs/src/implementation/world/reports.ts index 8613ad5cd..66c7bc501 100644 --- a/packages/ecs/src/implementation/world/reports.ts +++ b/packages/ecs/src/implementation/world/reports.ts @@ -2,7 +2,7 @@ import { Report } from '../../interface/report/report.js'; import type { Failure, Warning } from '../../interface/report/failure.js'; import { stampChanged } from './archetype.js'; -import { addCommand, createBuffer, pushCommand, pushComponent, removeCommand, spawnCommand, type CommandBuffer } from './buffer.js'; +import { addCommand, clearBuffer, createBuffer, pushCommand, pushComponent, removeCommand, spawnCommand, type CommandBuffer } from './buffer.js'; import { writeCell } from './cells.js'; import type { Views } from '../../interface/registry/layout.js'; import { pushValues } from './commands.js'; @@ -79,6 +79,8 @@ export const createReports = (state: State, applyAlone: (buffer: CommandBuffer) let oldest = 0; /** The slot the last run took. */ let last: Slot | null = null; + /** The buffer that spawns reports. */ + const spawns = createBuffer(1); /** The storage and row of a slot's component `id`, while its entity holds it. */ const cellOf = (slot: Slot, id: number) => { @@ -130,7 +132,10 @@ export const createReports = (state: State, applyAlone: (buffer: CommandBuffer) slot.errors = null; } slot.entity = reserve(state.entities); - const buffer = createBuffer(); + // One buffer spawns every report, as each sync point is done with it before the next run takes one; a new one + // only while it's still queued. + const buffer = state.queued.some(queued => queued.buffer === spawns) ? createBuffer(1) : spawns; + clearBuffer(buffer); pushValues(state, buffer, spawnCommand, slot.entity, { [Report.run]: { number: run, elapsed: 0, systems: 0, workers: 0, joined: 0, main: false, gpu: false, dropped: 0 } satisfies RunValues, }); diff --git a/packages/ecs/src/implementation/world/required.ts b/packages/ecs/src/implementation/world/required.ts new file mode 100644 index 000000000..3f9f07d80 --- /dev/null +++ b/packages/ecs/src/implementation/world/required.ts @@ -0,0 +1,10 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { ComponentLists } from '../../interface/system/access.js'; + +/** The components every entity a system's lists match has: what it reads, writes, has and filters on, but its optional ones. */ +export const requiredOf = (lists: ComponentLists | undefined): readonly symbol[] => { + if (lists === undefined) return []; + const { optional } = lists; + const all = new Set([...lists.read ?? [], ...lists.write ?? [], ...lists.has ?? [], ...lists.added ?? [], ...lists.changed ?? []]); + return optional === undefined ? [...all] : [...all].filter(key => !optional.includes(key)); +}; diff --git a/packages/ecs/src/implementation/world/resources.ts b/packages/ecs/src/implementation/world/resources.ts index 49ba73351..e6823a9c5 100644 --- a/packages/ecs/src/implementation/world/resources.ts +++ b/packages/ecs/src/implementation/world/resources.ts @@ -1,7 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { Storage } from './archetype.js'; import { indexOf, reserve } from './entities.js'; -import { defaultOf, idOf, type Key, type State } from './state.js'; +import { idOf, type Key, type State } from './state.js'; import { synchronizeAlone } from './synchronize.js'; import { addCommand, createBuffer, spawnCommand } from './buffer.js'; import { pushValues } from './commands.js'; @@ -10,8 +10,11 @@ import { labelOf } from '../../interface/registry/index.js'; /** Resources are components on one entity, so they share storage, ticks and sync with every other component. */ export type ResourceCell = { readonly storage: Storage; readonly row: number }; -/** A resource's value before anything writes it: the one given at the world's creation, or its default. */ -export const initialOf = (state: State, key: Key): unknown => state.initial.has(key) ? state.initial.get(key) : defaultOf(key); +/** + * A resource's value before anything writes it: the one given at the world's creation, or the start the world made + * for it, so a fresh `Map` or `Set` is one value however often it's read. + */ +export const initialOf = (state: State, key: Key): unknown => state.initial.has(key) ? state.initial.get(key) : state.starts[idOf(state, key)]; /** * Adds any missing resources with their initial values, as their own sync point, between runs; commands that are diff --git a/packages/ecs/src/implementation/world/rows.test.ts b/packages/ecs/src/implementation/world/rows.test.ts index 4269f9fdc..1d48eb4c8 100644 --- a/packages/ecs/src/implementation/world/rows.test.ts +++ b/packages/ecs/src/implementation/world/rows.test.ts @@ -1,12 +1,12 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; -import { Component } from "../../interface/component/component.js"; import type { Entity } from "../../interface/entity/entity.js"; import type { Schema } from "../../interface/schema/schema.js"; import { System } from "../../interface/system/system.js"; import { readEach, select } from "./reads.test-helper.js"; import { createWorld } from "./create.js"; import { workers } from "../../interface/resources/workers.js"; +import type { U32 } from "../../interface/math/index.js"; type Vec3 = readonly [number, number, number]; const vec3 = { type: "array", items: { type: "number", precision: 1 }, minItems: 3, maxItems: 3 } as const satisfies Schema; @@ -17,19 +17,15 @@ const tag = Symbol("rowsTest/tag"); declare module "@adobe/ecs" { export interface Components { - [position]: { type: readonly [number, number, number] }; - [count]: { type: number }; + [position]: { type: Schema.Type<{ type: "array"; items: { type: "number"; precision: 1 }; minItems: 3; maxItems: 3 }> }; + [count]: { type: U32 }; [flag]: { type: boolean }; [tag]: { type: true }; } } -Component.register(position, { type: vec3 }); -Component.register(count, { type: { type: "integer", minimum: 0, maximum: 1000 } }); -Component.register(flag, { type: { type: "boolean" } }); -Component.register(tag, { type: { const: true } }); -const seed = System.create({ name: "rowsTest/seed" }, ({ commands }) => { +const seed = System.create({}, ({ commands }) => { for (let i = 0; i < 4; i++) commands.spawn(i < 2 ? [position, count, flag, tag] : [position, count, flag])({ [position]: [i, i + 0.5, -i], [count]: i, [flag]: i % 2 === 0 }); }); @@ -38,17 +34,17 @@ describe("accessor rows", () => { const seen: { entity: Entity; position: Vec3; count: number; flag: boolean }[] = []; const kept: Vec3[] = []; // Both run over two archetypes (with and without the tag), with a system using a different row between them. - const first = System.create({ name: "rowsTest/first", components: { read: [position, flag], write: [count] } }, row => { + const first = System.create({ components: { with: [position, flag, count] } }, row => { const value = row[position]; kept.push(value); seen.push({ entity: row.entity, position: value, count: row[count], flag: row[flag] }); row[count] = row[count] + 10; }); - const other = System.create({ name: "rowsTest/other", components: { write: [position] }, after: [first] }, row => { + const other = System.create({ components: { with: [position] }, after: [first] }, row => { const [x, y, z] = row[position]; row[position] = [x + 100, y, z]; }); - const second = System.create({ name: "rowsTest/second", components: { read: [position, flag], write: [count] }, after: [other] }, row => { + const second = System.create({ components: { with: [position, flag, count] }, after: [other] }, row => { seen.push({ entity: row.entity, position: row[position], count: row[count], flag: row[flag] }); row[count] = row[count] + 1; }); diff --git a/packages/ecs/src/implementation/world/rows.ts b/packages/ecs/src/implementation/world/rows.ts index 4d9f194ee..474ac2091 100644 --- a/packages/ecs/src/implementation/world/rows.ts +++ b/packages/ecs/src/implementation/world/rows.ts @@ -1,7 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { Entity } from '../../interface/entity/entity.js'; -import type { Declaration } from '../../interface/system/declaration.js'; -import type { SystemLookup } from '../../interface/system/access.js'; +import type { Resolved } from '../../interface/system/declaration.js'; +import type { SystemLookupLists } from '../../interface/system/access.js'; import type { Commands } from '../../interface/system/commands.js'; import type { Layout, TypedArray } from '../../interface/registry/layout.js'; import { stampChanged, writeF32Lanes, writeGeneral, writeOf, type Archetype, type Storage } from './archetype.js'; @@ -10,6 +10,7 @@ import { readString, writeString } from './strings.js'; import { indexOf, locate } from './entities.js'; import { ownTag } from './move-log.js'; import { resourceCell } from './resources.js'; +import { labelOf } from '../../interface/registry/names.js'; import { idOf, type Key, type State } from './state.js'; import { combine, contributed, identityOf, type ReductionTarget } from './reductions.js'; import { createIndexLookup, type Since } from './index-lookups.js'; @@ -19,7 +20,7 @@ import type { WorldIndex } from './indexes.js'; * What an accessor row reads and writes: a storage, or a joined thread's view of one. A joined thread's own objects * have `tags`, the world's tags of their rows, which a write marks as the thread's own value. */ -export type RowStorage = Pick; +export type RowStorage = Pick; /** What a row binds to: an archetype, or a joined thread's view of one. */ export type RowArchetype = { readonly id: number; readonly entities: Float64Array; readonly storageById: readonly (RowStorage | undefined)[] }; @@ -126,8 +127,25 @@ const slotOf = (archetype: RowArchetype, id: number): Slot => { return { storage, view: viewOf(storage) }; }; -export const createRow = (host: RowHost, archetype: RowArchetype, keys: readonly Key[], ids: readonly number[], writes: readonly Key[]): Row => { - const slots = ids.map(id => slotOf(archetype, id)); +/** An optional component's getter where the archetype lacks it, and its setter, which fails its system. */ +const absentGetter = (): undefined => undefined; +const absentSetterOf = (key: Key): (value: unknown) => void => () => { + throw new Error(`An entity lacks "${labelOf(key)}", optional in its system: add it with commands.add rather than assigning it.`); +}; + +/** A present optional component's setter, which fails its system for `undefined`: that removes nothing. */ +const presentSetterOf = (key: Key, set: (value: unknown) => void): (value: unknown) => void => value => { + if (value === undefined) throw new Error(`"${labelOf(key)}" was assigned undefined, which removes nothing: remove it with commands.remove.`); + set(value); +}; + +/** + * A row of an archetype's `keys`. `absent` is undefined for a member and, for an optional component, whether the + * archetype lacks it: one it lacks reads as `undefined` and assigning it fails its system, and one it has fails its + * system when assigned `undefined`. + */ +export const createRow = (host: RowHost, archetype: RowArchetype, keys: readonly Key[], ids: readonly number[], writes: readonly Key[], absent: readonly (boolean | undefined)[] = []): Row => { + const slots = ids.map((id, i): Slot | undefined => absent[i] === true ? undefined : slotOf(archetype, id)); // Its own prototype gives the row its own root map. Rows built from `{}` share one, and defining an accessor with a // different getter than an existing transition's makes V8 switch the object to slow dictionary properties. const value: object = Object.create({}); @@ -138,18 +156,25 @@ export const createRow = (host: RowHost, archetype: RowArchetype, keys: readonly bind: next => { row.entities = next.entities; for (let i = 0; i < ids.length; i++) { + const slot = slots[i]; + if (slot === undefined) continue; const { storage, view } = slotOf(next, ids[i]); - slots[i].storage = storage; - slots[i].view = view; + slot.storage = storage; + slot.view = view; } }, }; Object.defineProperty(value, "entity", { get: () => row.entities[row.index], enumerable: true }); keys.forEach((key, i) => { const layout = host.layouts[ids[i]]; - Object.defineProperty(value, key, { - get: getterOf(host, layout, slots[i], row), - set: writes.includes(key) ? setterOf(host, layout, slots[i], row) : undefined, + const slot = slots[i]; + Object.defineProperty(value, key, slot === undefined ? { + get: absentGetter, + set: writes.includes(key) ? absentSetterOf(key) : undefined, + enumerable: true, + } : { + get: getterOf(host, layout, slot, row), + set: !writes.includes(key) ? undefined : absent[i] === false ? presentSetterOf(key, setterOf(host, layout, slot, row)) : setterOf(host, layout, slot, row), enumerable: true, }); }); @@ -262,11 +287,13 @@ export const parentBinder = (state: State, reads: readonly Key[]): (archetype: A * declares the same ones, so a body sees one row shape whichever system or archetype it runs over. A host's systems * run one at a time, so a binding lasts for one system's pass over one archetype. `idOfKey` gives each key's component. */ -export const rowBinderOf = (host: RowHost, idOfKey: (key: Key) => number, reads: readonly Key[], writes: readonly Key[]): (archetype: A) => Row => { +export const rowBinderOf = (host: RowHost, idOfKey: (key: Key) => number, reads: readonly Key[], writes: readonly Key[], optional: readonly Key[] = []): (archetype: A) => Row => { const keys = [...new Set([...reads, ...writes])]; const ids = keys.map(idOfKey); const signature = keys.map((key, i) => writes.includes(key) ? `${ids[i]}w` : `${ids[i]}`).join(","); - return archetype => { + /** The positions in `keys` of optional components, whose presence in an archetype picks its row. */ + const optionals = keys.flatMap((key, i) => optional.includes(key) ? [i] : []); + if (optionals.length === 0) return archetype => { let row = host.rows.get(signature); if (row === undefined) { row = createRow(host, archetype, keys, ids, writes); @@ -275,21 +302,47 @@ export const rowBinderOf = (host: RowHost, idOfKey: (key else row.bind(archetype); return row; }; + // Each pattern of which optional components an archetype has is a row of its own, so no read checks per row. The + // pattern marks every key, `-` for a member and `0` or `1` for an optional one absent or present, so systems with + // the same keys but other optional ones never share a row. + const absent: (boolean | undefined)[] = keys.map(() => undefined); + return archetype => { + let pattern = signature + "|"; + for (let i = 0, next = 0; i < keys.length; i++) { + if (optionals[next] !== i) { + pattern += "-"; + continue; + } + next++; + absent[i] = archetype.storageById[ids[i]] === undefined; + pattern += absent[i] ? "0" : "1"; + } + let row = host.rows.get(pattern); + if (row === undefined) { + row = createRow(host, archetype, keys, ids, writes, [...absent]); + host.rows.set(pattern, row); + } + else row.bind(archetype); + return row; + }; }; /** The accessor row binder for main-thread systems of a world. */ -export const rowBinder = (state: State, reads: readonly Key[], writes: readonly Key[]): (archetype: Archetype) => Row => - rowBinderOf(state, key => idOf(state, key), reads, writes); +export const rowBinder = (state: State, reads: readonly Key[], writes: readonly Key[], optional: readonly Key[] = []): (archetype: Archetype) => Row => + rowBinderOf(state, key => idOf(state, key), reads, writes, optional); /** * A lookup's matcher, with its component ids resolved once. `find` returns the row of a live entity that matches, or -1, * leaving its archetype in `archetype`. */ -const createMatcher = (state: State, lookup: SystemLookup) => { +const createMatcher = (state: State, lookup: SystemLookupLists) => { const ids = (keys: readonly Key[] | undefined): number[] => (keys ?? []).map(key => idOf(state, key)); const read = ids(lookup.read); const has = ids("has" in lookup ? lookup.has : undefined); const not = ids("not" in lookup ? lookup.not : undefined); + // What it reads but its optional components, which an entity it finds may lack. + const optional = ids("optional" in lookup ? lookup.optional : undefined); + const required = read.filter(id => !optional.includes(id)); const matcher = { read, archetype: null as Archetype | null, @@ -299,7 +352,7 @@ const createMatcher = (state: State, lookup: SystemLookup) => { const archetype = state.archetypes[state.entities.archetype[index]]; if (archetype.resource) return -1; const { storageById } = archetype; - for (let i = 0; i < read.length; i++) if (storageById[read[i]] === undefined) return -1; + for (let i = 0; i < required.length; i++) if (storageById[required[i]] === undefined) return -1; for (let i = 0; i < has.length; i++) if (storageById[has[i]] === undefined) return -1; for (let i = 0; i < not.length; i++) if (storageById[not[i]] !== undefined) return -1; const row = state.entities.row[index]; @@ -348,7 +401,7 @@ const changedAt = (archetype: Archetype, row: number, ids: readonly number[], si * A lookup returning `read` values keyed by component, for main-thread systems: a new object each call, the shared * empty one for a lookup reading nothing, and with `changed` one row reused, its `changed` as of `since`. */ -export const createObjectLookup = (state: State, lookup: SystemLookup, since: Since) => { +export const createObjectLookup = (state: State, lookup: SystemLookupLists, since: Since) => { const matcher = createMatcher(state, lookup); const keys = lookup.read ?? []; const watched = "index" in lookup ? undefined : lookup.changed; @@ -379,12 +432,12 @@ export const createObjectLookup = (state: State, lookup: SystemLookup, since: Si * A lookup returning `read` values in declared order, for kernels: a new array each call, and with `changed` one array * reused, whether a component it watches may have changed after `since` last. */ -export const createArrayLookup = (state: State, lookup: SystemLookup, since: Since) => { +export const createArrayLookup = (state: State, lookup: SystemLookupLists, since: Since) => { const watched = "index" in lookup ? undefined : lookup.changed; return watched === undefined ? createPlainArrayLookup(state, lookup) : createChangedArrayLookup(state, lookup, watched.map(key => idOf(state, key)), since); }; -const createChangedArrayLookup = (state: State, lookup: SystemLookup, ids: readonly number[], since: Since) => { +const createChangedArrayLookup = (state: State, lookup: SystemLookupLists, ids: readonly number[], since: Since) => { const matcher = createMatcher(state, lookup); const { read } = matcher; const values = new Array(read.length + 1); @@ -398,7 +451,7 @@ const createChangedArrayLookup = (state: State, lookup: SystemLookup, ids: reado }; }; -const createPlainArrayLookup = (state: State, lookup: SystemLookup) => { +const createPlainArrayLookup = (state: State, lookup: SystemLookupLists) => { const matcher = createMatcher(state, lookup); const { read } = matcher; return (entity: Entity): readonly unknown[] | null => { @@ -412,11 +465,11 @@ const createPlainArrayLookup = (state: State, lookup: SystemLookup) => { }; /** - * A body's context: its resources, lookups, reductions, input and commands, each under its own key and present only + * A body's context: its resources, lookups, reductions and commands, each under its own key and present only * when declared. `bind` resolves the resources' cells for a run; `reduced[t]` holds the leaves of the fold into its * `t`th reduction target so far this run. */ -export type Context = { readonly value: { input?: unknown; up?: unknown }; readonly bind: () => void; readonly reduced: readonly Float64Array[] }; +export type Context = { readonly value: { up?: unknown }; readonly bind: () => void; readonly reduced: readonly Float64Array[] }; /** * What a context holds beyond the declaration's resources and lookups, and for lookups through indexes, the index of @@ -425,7 +478,6 @@ export type Context = { readonly value: { input?: unknown; up?: unknown }; reado type Extras = { readonly targets?: readonly ReductionTarget[]; readonly commands?: Commands; - readonly input?: boolean; readonly indexes?: ReadonlyMap; readonly since?: Since; }; @@ -437,8 +489,8 @@ const never: Since = { value: -1 }; const own = (): object => Object.create({}); /** Resource cells are resolved once per run: they move only at sync points. An unresolved resource throws on access. */ -export const createContext = (state: State, declaration: Pick, { targets = [], commands, input = false, indexes, since = never }: Extras = {}): Context => { - const value: { input?: unknown; up?: unknown } = own(); +export const createContext = (state: State, declaration: Pick, { targets = [], commands, indexes, since = never }: Extras = {}): Context => { + const value: { up?: unknown } = own(); const { lookup, resources } = declaration; const names = Object.keys(lookup ?? {}); if (names.length > 0) { @@ -501,7 +553,6 @@ export const createContext = (state: State, declaration: Pick { if (keys.length > 0) { diff --git a/packages/ecs/src/implementation/world/rules.test.ts b/packages/ecs/src/implementation/world/rules.test.ts index 8bd5f49fc..5ae31eb5e 100644 --- a/packages/ecs/src/implementation/world/rules.test.ts +++ b/packages/ecs/src/implementation/world/rules.test.ts @@ -8,7 +8,8 @@ import { createWorld } from "./create.js"; import { count, read } from "./reads.test-helper.js"; import { workers } from "../../interface/resources/workers.js"; import { identify } from "../../interface/registry/ids.test-helper.js"; -import { labelOfItem, programRules } from "../../interface/registry/index.js"; +import { registerComponent } from "../../interface/registry/declarations.js"; +import { programRules } from "../../interface/registry/index.js"; const source = Symbol("rulesTest/source"); const hidden = Symbol("rulesTest/hidden"); @@ -20,6 +21,7 @@ const glow = Symbol("rulesTest/glow"); const owned = Symbol("rulesTest/owned"); const follows = Symbol("rulesTest/follows"); const mark = Symbol("rulesTest/mark"); +// Within each other, so never declared: the second to register closes a cycle. const left = Symbol("rulesTest/left"); const right = Symbol("rulesTest/right"); const late = Symbol("rulesTest/late"); @@ -31,43 +33,27 @@ declare module "@adobe/ecs" { [source]: { type: number }; [hidden]: { type: true }; [sprite]: { type: true }; - [slot]: { type: number; derived: true }; + [slot]: { type: number; derived: true; default: 7 }; [visible]: { type: true; derived: true }; [ready]: { type: number; within: typeof slot }; [glow]: { type: true; within: typeof ready }; [owned]: { type: Entity; reference: "owner" }; [follows]: { type: Entity; reference: "weak" }; [mark]: { type: number }; - [left]: { type: true; within: typeof right }; - [right]: { type: true; within: typeof left }; [late]: { type: true; derived: true }; [tagged]: { type: true }; [tag]: { type: true; derived: true }; } } -Component.register(source, { type: { type: "number" } }); -Component.register(hidden, { type: { const: true } }); -Component.register(sprite, { type: { const: true } }); -Component.register(slot, { type: { type: "number", default: 7 }, derived: true }); -Component.register(visible, { type: { const: true }, derived: true }); -Component.register(ready, { type: { type: "number" }, within: slot }); -Component.register(glow, { type: { const: true }, within: ready }); -Component.register(owned, { type: Entity.schema, reference: "owner" }); -Component.register(follows, { type: Entity.schema, reference: "weak" }); -Component.register(mark, { type: { type: "number" } }); -Component.register(left, { type: { const: true }, within: right }); -Component.register(late, { type: { const: true }, derived: true }); -Component.register(tagged, { type: { const: true } }); -Component.register(tag, { type: { const: true }, derived: true }); -Rule.create(identify({ name: "rulesTest/slots", components: { has: [source], not: [hidden] }, derive: [slot] }, "rulesTest/rules.ts#slots")); -Rule.create({ name: "rulesTest/visible", components: { has: [slot] }, derive: [visible] }); -Rule.create({ name: "rulesTest/sprites", components: { has: [sprite] }, derive: [visible] }); -Rule.create({ name: "rulesTest/tag", components: { has: [tagged] }, derive: [tag] }); +Rule.create(identify({ components: { with: [source], not: [hidden] }, derive: [slot] }, "rulesTest/rules.ts#slots")); +Rule.create({ components: { with: [slot] }, derive: [visible] }); +Rule.create({ components: { with: [sprite] }, derive: [visible] }); +Rule.create({ components: { with: [tagged] }, derive: [tag] }); /** Runs `edit` with commands once, as its own run. */ -const edit = System.create({ name: "rulesTest/edit", input: { type: "object", properties: { step: { type: "integer" } } } }, ({ input, commands }) => { +const edit = System.create({}, ({ commands }, input: { readonly step: number }) => { for (const step of steps[input.step] ?? []) step(commands); }); let steps: ((commands: System.Commands) => void)[][] = []; @@ -85,11 +71,11 @@ const components = async (world: ReturnType, entity: Entity) describe("rules", () => { it("accept a rule declared again the same under its id, as a module evaluated again declares it, and reject a different one", () => { const rules = programRules().length; - Rule.create(identify({ name: "rulesTest/slots", components: { has: [source], not: [hidden] }, derive: [slot] }, "rulesTest/rules.ts#slots")); - expect(() => Rule.create(identify({ name: "rulesTest/slots", components: { has: [source] }, derive: [slot] }, "rulesTest/rules.ts#slots"))).toThrow("reload the page"); + Rule.create(identify({ components: { with: [source], not: [hidden] }, derive: [slot] }, "rulesTest/rules.ts#slots")); + expect(() => Rule.create(identify({ components: { with: [source] }, derive: [slot] }, "rulesTest/rules.ts#slots"))).toThrow("reload the page"); expect(programRules()).toHaveLength(rules); }); - it("derive components where they match, from the schema default, and remove them where they stop", async () => { + it("derive components where they match, from their start, and remove them where they stop", async () => { const world = createWorld({ [workers]: 0 }); const step = at(world); let a = Entity.none, b = Entity.none, c = Entity.none; @@ -110,7 +96,7 @@ describe("rules", () => { }); it("write the default again where a derived component starts over, on a reused row", async () => { - const write = System.create({ name: "rulesTest/write", components: { write: [slot] } }, row => { row[slot] = 99; }); + const write = System.create({ components: { with: [slot] } }, row => { row[slot] = 99; }); const world = createWorld({ [workers]: 0 }); const step = at(world); let a = Entity.none; @@ -124,7 +110,7 @@ describe("rules", () => { }); it("apply in a world from the first sync point after it meets their components, deriving on the first spawn", async () => { - const spawn = System.create({ name: "rulesTest/tagged" }, ({ commands }) => { commands.spawn([tagged])({ [tagged]: true }); }); + const spawn = System.create({}, ({ commands }) => { commands.spawn([tagged])({ [tagged]: true }); }); const world = createWorld({ [workers]: 0 }); await world.run(edit, { step: -1 }); expect(await count(world, { required: [tag] })).toBe(0); @@ -135,13 +121,13 @@ describe("rules", () => { it("created after a world has entities they match, derive for them at the next run, and for batch spawns", async () => { let added = 0; - const observe = System.create({ name: "rulesTest/added", components: { added: [late] } }, () => { added++; }); - const spawn = System.create({ name: "rulesTest/spawn" }, ({ commands }) => { for (let i = 0; i < 3; i++) commands.spawn([mark])({ [mark]: i }); }); + const observe = System.create({ components: { added: [late] } }, () => { added++; }); + const spawn = System.create({}, ({ commands }) => { for (let i = 0; i < 3; i++) commands.spawn([mark])({ [mark]: i }); }); const world = createWorld({ [workers]: 0 }); await world.run(spawn); await world.run(observe); expect(await count(world, { required: [late] })).toBe(0); - Rule.create({ name: "rulesTest/late", components: { has: [mark] }, derive: [late] }); + Rule.create({ components: { with: [mark] }, derive: [late] }); await world.run(spawn); expect(await count(world, { required: [late] })).toBe(6); await world.run(observe); @@ -151,15 +137,13 @@ describe("rules", () => { it("are rejected when they can't hold", async () => { // @ts-expect-error a rule derives only derived components - expect(() => Rule.create({ name: "rulesTest/bad", components: { has: [source] }, derive: [mark] })).toThrow(`derives "rulesTest/mark", which isn't declared derived`); - expect(() => Rule.create({ name: "rulesTest/bad", components: { has: [source], not: [visible] }, derive: [slot] })).toThrow("cycle through a negation, which could oscillate"); - // A rejected rule records nothing, so its name labels only the next; a system may share a rule's name. - expect(labelOfItem(Rule.create({ name: "rulesTest/bad", components: { has: [tagged] }, derive: [late] }))).toBe("rulesTest/bad"); - expect(labelOfItem(System.create({ name: "rulesTest/visible" }, () => {}))).toBe("rulesTest/visible#2"); + expect(() => Rule.create({ components: { with: [source] }, derive: [mark] })).toThrow(`derives "rulesTest/mark", which isn't declared derived`); + expect(() => Rule.create({ components: { with: [source], not: [visible] }, derive: [slot] })).toThrow("cycle through a negation, which could oscillate"); // @ts-expect-error a reference changes only through commands - expect(() => System.create({ name: "rulesTest/writeReference", components: { write: [owned] } }, row => { row[owned] = Entity.none; })).toThrow("a reference, which changes only through commands"); + expect(() => System.create({ components: { with: [owned] } }, row => { row[owned] = Entity.none; })).toThrow("a reference, which changes only through commands"); // The component closing a cycle of within declarations is rejected when it's registered. - expect(() => Component.register(right, { type: { const: true }, within: left })).toThrow(`Cannot register component "rulesTest/right": components are within each other in a cycle: rulesTest/left, rulesTest/right.`); + registerComponent(left, { type: { const: true }, within: right }); + expect(() => registerComponent(right, { type: { const: true }, within: left })).toThrow(`Cannot register component "rulesTest/right": components are within each other in a cycle: rulesTest/left, rulesTest/right.`); }); }); diff --git a/packages/ecs/src/implementation/world/runner.ts b/packages/ecs/src/implementation/world/runner.ts index 4dc78bc2a..5851b980e 100644 --- a/packages/ecs/src/implementation/world/runner.ts +++ b/packages/ecs/src/implementation/world/runner.ts @@ -14,6 +14,7 @@ import { createBlock, endBlock, indexOf, locate, reserveFrom } from './entities. import { forEachPassingRange, type Filter } from './ranges.js'; import { resourceCell } from './resources.js'; import { createContext, parentBinder, rowBinder } from './rows.js'; +import { requiredOf } from './required.js'; import { scalarValue, scalarValues, type WasmKernel } from './wasm.js'; import { idOf, type State } from './state.js'; import { accessorsOf } from '../../interface/gpu/accessors.js'; @@ -24,7 +25,7 @@ import { combine, identityOf, reductionTargets, type ReductionTarget } from './r import { samePath, type TypedArray } from '../../interface/registry/layout.js'; import { inputFor } from './input.js'; import { createUp, type Run } from './up.js'; -import { labelOf, labelOfItem } from '../../interface/registry/index.js'; +import { labelOf } from '../../interface/registry/index.js'; import type { RemoteSystem } from './remote-systems.js'; import type { JoinedObserver } from './joined-observer.js'; import { releaseDirectory, type Directory } from './directories.js'; @@ -33,6 +34,7 @@ import { acquireLookups, type IndexLookups, type IndexUse } from './indexes.js'; import { createOrdered, type Ordered, type Passing } from './index-order.js'; import { blockBase, type SortedTable } from './index-sorted.js'; import type { Since } from './index-lookups.js'; +import { createOrderedRender } from './render-order.js'; /** * One unit of a system's work for a run: the commands it issued, or for a worker's chunk its segment of the worker's @@ -168,7 +170,7 @@ export const releaseHoldings = (state: State, holdings: Holdings): void => { /** The resources a declaration writes or reduces into, by id. */ export const resourceWritesOf = (state: State, declaration: Leaf["declaration"]): readonly number[] => - [...new Set([...declaration.resources?.write ?? [], ...declaration.reduce === undefined ? [] : Object.getOwnPropertySymbols(declaration.reduce)].map(key => idOf(state, key)))]; + [...new Set([...declaration.resources?.write ?? [], ...declaration.resources?.reduce ?? []].map(key => idOf(state, key)))]; /** A number for each leaf, so the GPU records of every runner made for it are its own, whatever its name. */ const leafNumbers = new WeakMap(); @@ -183,11 +185,11 @@ const numberOf = (leaf: Leaf): number => { export const createRunner = (state: State, leaf: Leaf, since = 0): Runner => { const { declaration } = leaf; const leafNumber = numberOf(leaf); - const label = labelOfItem(leaf); + const label = leaf.name; // Method parameters are bivariant, so the runtime can see any leaf's body with erased parameter types. - const system: { body(subject: unknown, context?: unknown): void } = leaf; + const system: { body(subject: unknown, context?: unknown, input?: unknown): void } = leaf; const { components: query, resources } = declaration; - const required = [...new Set([...query?.read ?? [], ...query?.write ?? [], ...query?.has ?? [], ...query?.added ?? [], ...query?.changed ?? []])].map(key => idOf(state, key)); + const required = requiredOf(query).map(key => idOf(state, key)); const excluded = (query?.not ?? []).map(key => idOf(state, key)); const addedIds = (query?.added ?? []).map(key => idOf(state, key)); const changedIds = (query?.changed ?? []).map(key => idOf(state, key)); @@ -200,12 +202,12 @@ export const createRunner = (state: State, leaf: Leaf, since = 0): Runner => { /** Its `lastRun` while it runs, which its lookups' lists and rows compare with. */ const lookupSince: Since = { value: since }; let kernel: BoundKernel | null = null; - if (declaration.kernel !== undefined) { - const description = kernelOf(leaf); - if (description === undefined) { - indexLookups.release(); - throw new Error(`System "${label}" is a kernel but wasn't compiled; add kernelPlugin() to the Vite config.`); - } + const description = kernelOf(leaf); + if (description === undefined && (declaration.kernel === true || declaration.kernel === "gpu")) { + indexLookups.release(); + throw new Error(`System "${label}" is a kernel but wasn't compiled; add Compiler.plugin() to the Vite config.`); + } + if (description !== undefined) { try { kernel = bindKernel(state, leaf, description, indexLookups.byName, lookupSince); } @@ -223,7 +225,7 @@ export const createRunner = (state: State, leaf: Leaf, since = 0): Runner => { const up = declaration.up === undefined ? null : createUp(state, declaration.up, filtered); /** Its rows in its `order` index's order, run by run. */ const ordered = indexLookups.ordered === null ? null : createOrdered(state.memory); - const targets = reductionTargets(state, declaration); + const targets = reductionTargets(state, label, declaration); /** For each partial of the kernel, the target and leaf it folds into. */ const partialLeaves = (kernel?.description.reductions ?? []).map(reduced => { const index = targets.findIndex(target => target.key === reduced.resource); @@ -232,6 +234,9 @@ export const createRunner = (state: State, leaf: Leaf, since = 0): Runner => { }); const accessors = declaration.shader === undefined ? [] : accessorsOf(declaration.shader); if (typeof accessors === "string") return failPlacement(`can't render: ${accessors}`); + /** A render system's one table in its `order` index's order. */ + const orderedRender = leaf.kind !== "render" || indexLookups.ordered === null || ordered === null ? null + : createOrderedRender(indexLookups.ordered, ordered, accessors.map(accessor => idOf(state, accessor.component)), declaration.shader?.group ?? 0, `${leafNumber}:`); const touches = [...new Set([...query?.read ?? [], ...query?.write ?? []])].map(key => idOf(state, key)); const writes = (query?.write ?? []).map(key => idOf(state, key)); const looks = [...new Set([...Object.values(declaration.lookup ?? {}).flatMap(lookup => lookup.read ?? []), ...declaration.up?.read ?? []])].map(key => idOf(state, key)); @@ -309,10 +314,11 @@ export const createRunner = (state: State, leaf: Leaf, since = 0): Runner => { // A kernel's reductions fold in its partials, so only a body that runs as written gets reducer functions. Render // bodies, GPU kernels and observer systems issue no commands. const ownInput = inputFor(leaf); + /** The current run's input, which the body takes after its context; kept until the next run, as its context is. */ + let runInput: unknown; const context = createContext(state, declaration, { targets: kernel === null ? targets : [], commands: leaf.kind === "render" || declaration.kernel === "gpu" || declaration.observer === true ? undefined : commands, - input: declaration.input !== undefined, indexes: indexLookups.byName, since: lookupSince, }); @@ -355,7 +361,9 @@ export const createRunner = (state: State, leaf: Leaf, since = 0): Runner => { const partialAddress = (slot: number): number => partialCount === 0 ? -1 : slots.byteOffset + slot * partialCount * 8; const matches: Archetype[] = []; let checked = 0; - const bindRow = rowBinder(state, query?.read ?? [], query?.write ?? []); + const bindRow = rowBinder(state, query?.read ?? [], query?.write ?? [], query?.optional); + /** Whether its rows have optional components, so which an archetype has picks its row. */ + const optional = (query?.optional?.length ?? 0) > 0; const bindParent = parentBinder(state, declaration.up?.read ?? []); let lastRun = since; @@ -374,7 +382,7 @@ export const createRunner = (state: State, leaf: Leaf, since = 0): Runner => { }; /** - * By archetype: its change filters, `added` then `changed`, rows and chunks, kept until its arrays are replaced; + * By archetype: its change filters, `added` then `changed`, rows, chunks and blocks, kept until its arrays are replaced; * and the WASM addresses of its columns and filters, for the WASM kernel `addressed` they were taken for, since a * kernel loaded again may stamp a tick column the last one muted. */ @@ -387,11 +395,11 @@ export const createRunner = (state: State, leaf: Leaf, since = 0): Runner => { const filters = addedIds.length + changedIds.length === 0 ? noFilters : [ ...addedIds.flatMap(id => { const storage = archetype.storageById[id]; - return storage === undefined ? [] : [{ ticks: storage.added, chunks: storage.chunkAdded }]; + return storage === undefined ? [] : [{ ticks: storage.added, chunks: storage.chunkAdded, blocks: storage.blockAdded }]; }), ...changedIds.flatMap(id => { const storage = archetype.storageById[id]; - return storage === undefined ? [] : [{ ticks: storage.changed, chunks: storage.chunkChanged }]; + return storage === undefined ? [] : [{ ticks: storage.changed, chunks: storage.chunkChanged, blocks: storage.blockChanged }]; }), ]; const entry: Filtered = { version: archetype.version, filters, addresses: noAddresses, addressed: null }; @@ -411,27 +419,28 @@ export const createRunner = (state: State, leaf: Leaf, since = 0): Runner => { if (archetype === undefined || !matches.includes(archetype)) return; const row = bindRow(archetype); row.index = state.entities.row[index]; - system.body(row.value, context.value); + system.body(row.value, context.value, runInput); }; const runBody = (archetype: Archetype, since: number): void => { const row = bindRow(archetype); const { value } = row; const values = context.value; + const input = runInput; const filters = filtersOf(archetype); // Every row passes, so nothing is filtered. // A body may remove its own system, after which no row of it runs. if (since < 0 || filters.length === 0) { for (let index = 0, count = archetype.count; index < count && !holdings.removed; index++) { row.index = index; - system.body(value, values); + system.body(value, values, input); } return; } forEachPassingRange(filters, since, 0, archetype.count, (start, end) => { for (let index = start; index < end && !holdings.removed; index++) { row.index = index; - system.body(value, values); + system.body(value, values, input); } }); }; @@ -450,6 +459,7 @@ export const createRunner = (state: State, leaf: Leaf, since = 0): Runner => { ...bound.columnsOf(archetype).map(column => column.byteOffset), ...entry.filters.map(filter => filter.ticks.byteOffset), ...entry.filters.map(filter => filter.chunks.byteOffset), + ...entry.filters.map(filter => filter.blocks.byteOffset), ]; entry.addressed = bound.wasm; } @@ -473,7 +483,7 @@ export const createRunner = (state: State, leaf: Leaf, since = 0): Runner => { const runKernelLocally = (bound: BoundKernel, archetype: Archetype, since: number, values: readonly unknown[], args: unknown, tick: number): void => { const slot = partialCount === 0 ? 0 : takeSlots(1); - if (bound.compiled() !== null && bound.wasm !== null) return runWasm(bound, archetype, 0, archetype.count, since, values, args, tick, partialAddress(slot)); + if (bound.compiled() !== null && bound.wasm !== null && bound.allPresent(archetype)) return runWasm(bound, archetype, 0, archetype.count, since, values, args, tick, partialAddress(slot)); const columns = bound.columnsOf(archetype); const filters = filtersOf(archetype); const run = bound.kernel(); @@ -511,11 +521,12 @@ export const createRunner = (state: State, leaf: Leaf, since = 0): Runner => { // The parent's row is bound after the system's own, for the archetype both may share. const parentRow = parentArchetype < 0 ? null : bindParent(state.archetypes[parentArchetype]); const upContext = context.value; + const input = runInput; upContext.up = parentRow === null ? null : parentRow.value; for (let n = start; n < end && !holdings.removed; n++) { row.index = rows[n]; if (parentRow !== null) parentRow.index = parents[n]; - system.body(row.value, upContext); + system.body(row.value, upContext, input); } }; @@ -535,6 +546,7 @@ export const createRunner = (state: State, leaf: Leaf, since = 0): Runner => { const runs = order.runs(sorted, matches, since < 0 ? null : orderPassing); const slot = partialCount === 0 ? 0 : takeSlots(1); const contextValue = context.value; + const input = runInput; for (let k = 0; k < runs.length && !holdings.removed; k++) { const run = runs[k]; const archetype = matches[run.owner]; @@ -546,7 +558,7 @@ export const createRunner = (state: State, leaf: Leaf, since = 0): Runner => { const { value } = row; for (let n = run.start; n < run.end && !holdings.removed; n++) { row.index = run.rows[n]; - system.body(value, contextValue); + system.body(value, contextValue, input); } } }; @@ -556,6 +568,7 @@ export const createRunner = (state: State, leaf: Leaf, since = 0): Runner => { const owners = order.owners(matches); const { entries, stride, words, lengths, blocks } = sorted; const contextValue = context.value; + const input = runInput; let current = -1; let row: ReturnType | null = null; let value: object | null = null; @@ -568,12 +581,15 @@ export const createRunner = (state: State, leaf: Leaf, since = 0): Runner => { const owner = archetype < owners.length ? owners[archetype] : -1; if (owner < 0) continue; if (owner !== current || row === null || value === null) { - row = bindRow(matches[owner]); + // The row is found once; after, it's bound in place, as often as every row when archetypes interleave, + // unless which optional components an archetype has picks another row. + if (row === null || optional) row = bindRow(matches[owner]); + else row.bind(matches[owner]); value = row.value; current = owner; } row.index = entries[at + 2]; - system.body(value, contextValue); + system.body(value, contextValue, input); } } }; @@ -607,28 +623,9 @@ export const createRunner = (state: State, leaf: Leaf, since = 0): Runner => { const runUp = (since: number, values: readonly unknown[], args: unknown, tick: number, workers: Workers | null, index: number): Promise | null => { if (up === null) return null; const slot = partialCount === 0 ? 0 : takeSlots(1); - let next: () => readonly Run[] | null; - let clear = (): void => {}; - if (since < 0) { - const order = up.levels(matches); - let depth = 0; - next = () => { - if (depth >= order.levels) return null; - const runs: Run[] = []; - for (let group = order.depthGroups[depth]; group < order.depthGroups[depth + 1]; group++) { - if (order.groupEnds[group] === order.groupStarts[group]) continue; - runs.push({ owner: order.groupOwners[group], parent: order.groupParents[group], rows: order.rows, parents: order.parents, start: order.groupStarts[group], end: order.groupEnds[group] }); - } - depth++; - return runs; - }; - } - else { - const pass = up.filtered(matches, addedIds, changedIds, since); - if (pass === null) return null; - next = pass.next; - clear = pass.clear; - } + const pass = since < 0 ? up.wholePass(up.levels(matches)) : up.filtered(matches, addedIds, changedIds, since); + if (pass === null) return null; + const { next, clear } = pass; const runLocally = (run: Run): void => runUpRows(matches[run.owner], run.parent, run.rows, run.parents, run.start, run.end, values, args, tick, slot); const parallel = workers !== null && kernel !== null && kernel.wasm !== null && kernel.compiled() !== null && partialCount === 0 && kernel.lookupParameters().length + indexParameters.length + 3 + (kernel.description.upColumns?.length ?? 0) + kernel.wasm.scalars.length <= maximumScalars; @@ -684,7 +681,7 @@ export const createRunner = (state: State, leaf: Leaf, since = 0): Runner => { const { pool } = workers; const bound = kernel; const scalars = bound !== null && bound.wasm !== null ? [...bound.lookupParameters(), ...indexParameters, ...scalarValues(bound.wasm.scalars, values, args)] : []; - const fast = isFast(bound); + const fast = isFast(bound) && bound !== null && bound.allPresent(archetype); const participants = Math.min(pool.size, Math.ceil(count / minimumFor(workers))); const aligned = (rows: number): number => Math.ceil(rows / chunkAlignment) * chunkAlignment; // WASM chunks go through shared memory. The main thread doesn't take one: measured, it finishes later than an @@ -831,6 +828,7 @@ export const createRunner = (state: State, leaf: Leaf, since = 0): Runner => { if (gpu === null) return failPlacement("needs a world with a gpu device"); if (typeof accessors === "string") return; holdings.gpu = gpu; + if (orderedRender !== null) return runOrderedRender(gpu, tick, orderedRender); const group = declaration.shader?.group ?? 0; const tables = matches.filter(archetype => archetype.count > 0).map(archetype => { words[0] = archetype.count; @@ -852,6 +850,20 @@ export const createRunner = (state: State, leaf: Leaf, since = 0): Runner => { } }; + /** A render system with `order`: one table, nothing allocated while nothing it reads changed. */ + const runOrderedRender = (gpu: GpuEngine, tick: number, render: NonNullable): void => { + const failure = render.prepare(gpu, matches, tick); + if (failure !== null) return failPlacement(`can't render: ${failure}`); + gpu.submit(); + try { + system.body(render.context(gpu), context.value); + } + catch (error) { + gpu.discard(); + throw error; + } + }; + const holdings: Holdings = { removed: false, released: false, partials: null, up: up ?? ordered, directories: kernel?.directories ?? [], indexes: indexLookups, gpu: null, prefix: `${leafNumber}:`, afterSync: partialCount > 0 ? new WeakRef(sizeSlots) : null, remote: null, @@ -924,7 +936,7 @@ export const createRunner = (state: State, leaf: Leaf, since = 0): Runner => { /** -1 passes every row. */ const since = !filtered || resourcesChanged ? -1 : lastRun; context.bind(); - if (declaration.input !== undefined) context.value.input = args; + runInput = args; if (indexed && kernel !== null) indexParameters = kernel.refreshIndexes(lastRun); if (placement === "render") { refreshMatches(); @@ -933,7 +945,7 @@ export const createRunner = (state: State, leaf: Leaf, since = 0): Runner => { } if (query === undefined) { ran = since < 0; - if (ran) system.body(context.value); + if (ran) system.body(context.value, args); return chunks; } // Only resource and index filters, none of which changed: no row passes, unless its parent changed. @@ -954,6 +966,7 @@ export const createRunner = (state: State, leaf: Leaf, since = 0): Runner => { return chunks; } if (up !== null) { + if (workers === null && kernel !== null) for (const archetype of matches) if (archetype.count >= state.poolThreshold) state.poolWanted = true; const parallel = runUp(since, values, args, tick, workers, index); if (parallel !== null) chunks.push(parallel); return chunks; @@ -970,7 +983,10 @@ export const createRunner = (state: State, leaf: Leaf, since = 0): Runner => { chunks.push(...postChunks(workers, index, archetype, from, values, args, tick)); chunks.push(nextChunk()); } - else runKernelLocally(kernel, archetype, from, values, args, tick); + else { + if (archetype.count >= state.poolThreshold) state.poolWanted = true; + runKernelLocally(kernel, archetype, from, values, args, tick); + } } return chunks; } diff --git a/packages/ecs/src/implementation/world/schedule.test.ts b/packages/ecs/src/implementation/world/schedule.test.ts index 1251fa95b..831fec52a 100644 --- a/packages/ecs/src/implementation/world/schedule.test.ts +++ b/packages/ecs/src/implementation/world/schedule.test.ts @@ -1,8 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; -import { Component } from "../../interface/component/component.js"; import type { Composite } from "../../interface/system/composite.js"; -import type { Declaration } from "../../interface/system/declaration.js"; +import type { Resolved } from "../../interface/system/declaration.js"; import type { Definition } from "../../interface/registry/index.js"; import type { Leaf } from "../../interface/system/leaf.js"; import { scheduleOf } from "../../interface/registry/schedule.js"; @@ -17,109 +16,107 @@ declare module "@adobe/ecs" { } } -Component.register(value, { type: { type: "number" } }); -Component.register(other, { type: { type: "number" } }); /** Systems built as values, not registered, so each test's are its own. */ -const leaf = (declaration: Declaration & { readonly name: string }): Leaf => ({ kind: "system", name: declaration.name, declaration, body: () => {} }); +const leaf = (declaration: Resolved & { readonly name: string }): Leaf => ({ kind: "system", name: declaration.name, declaration, input: undefined, body: () => {} }); const composite = (declaration: Composite["declaration"] & { readonly name: string }): Composite => ({ kind: "composite", name: declaration.name, declaration }); const names = (system: Definition): string[][] => scheduleOf(system).tiers.map(tier => tier.map(leaf => leaf.name)); describe("scheduleOf", () => { it("tiers by longest ordering path, keeping flatten order within a tier", () => { - const a = leaf({ name: "scheduleTest/a" }); - const b = leaf({ name: "scheduleTest/b", after: [a] }); - const c = leaf({ name: "scheduleTest/c", before: [b] }); - const d = leaf({ name: "scheduleTest/d" }); - const root = composite({ name: "scheduleTest/root", systems: [b, d, c, a] }); - expect(names(root)).toEqual([["scheduleTest/d", "scheduleTest/c", "scheduleTest/a"], ["scheduleTest/b"]]); + const a = leaf({ name: "a" }); + const b = leaf({ name: "b", after: [a] }); + const c = leaf({ name: "c", before: [b] }); + const d = leaf({ name: "d" }); + const root = composite({ name: "root", systems: [b, d, c, a] }); + expect(names(root)).toEqual([["d", "c", "a"], ["b"]]); }); it("applies a composite's constraints to every member, and resolves composites to their members", () => { - const a = leaf({ name: "scheduleTest/a" }); - const b = leaf({ name: "scheduleTest/b" }); - const d = leaf({ name: "scheduleTest/d" }); - const group = composite({ name: "scheduleTest/group", systems: [a, b], after: [d] }); - const c = leaf({ name: "scheduleTest/c", after: [group] }); - expect(names(composite({ name: "scheduleTest/root", systems: [c, group, d] }))) - .toEqual([["scheduleTest/d"], ["scheduleTest/a", "scheduleTest/b"], ["scheduleTest/c"]]); + const a = leaf({ name: "a" }); + const b = leaf({ name: "b" }); + const d = leaf({ name: "d" }); + const group = composite({ name: "group", systems: [a, b], after: [d] }); + const c = leaf({ name: "c", after: [group] }); + expect(names(composite({ name: "root", systems: [c, group, d] }))) + .toEqual([["d"], ["a", "b"], ["c"]]); }); it("pulls during pairs into one tier unless an ordering path separates them", () => { - const a = leaf({ name: "scheduleTest/a" }); - const b = leaf({ name: "scheduleTest/b", after: [a] }); - const c = leaf({ name: "scheduleTest/c", during: [b] }); - const d = leaf({ name: "scheduleTest/d", after: [c] }); - expect(names(composite({ name: "scheduleTest/root", systems: [a, b, c, d] }))) - .toEqual([["scheduleTest/a"], ["scheduleTest/b", "scheduleTest/c"], ["scheduleTest/d"]]); + const a = leaf({ name: "a" }); + const b = leaf({ name: "b", after: [a] }); + const c = leaf({ name: "c", during: [b] }); + const d = leaf({ name: "d", after: [c] }); + expect(names(composite({ name: "root", systems: [a, b, c, d] }))) + .toEqual([["a"], ["b", "c"], ["d"]]); }); it("orders a system during another between what that system follows and what follows it", () => { - const update = leaf({ name: "scheduleTest/update" }); - const render = leaf({ name: "scheduleTest/render", after: [update] }); - const writer = leaf({ name: "scheduleTest/writer", components: { write: [value] }, during: [update] }); - const reader = leaf({ name: "scheduleTest/reader", components: { read: [value] }, during: [render] }); - expect(names(composite({ name: "scheduleTest/root", systems: [reader, writer, update, render] }))) - .toEqual([["scheduleTest/writer", "scheduleTest/update"], ["scheduleTest/reader", "scheduleTest/render"]]); + const update = leaf({ name: "update" }); + const render = leaf({ name: "render", after: [update] }); + const writer = leaf({ name: "writer", components: { write: [value] }, during: [update] }); + const reader = leaf({ name: "reader", components: { read: [value] }, during: [render] }); + expect(names(composite({ name: "root", systems: [reader, writer, update, render] }))) + .toEqual([["writer", "update"], ["reader", "render"]]); }); it("takes the order of a system during a system that is itself during another", () => { - const update = leaf({ name: "scheduleTest/update" }); - const render = leaf({ name: "scheduleTest/render", after: [update] }); - const physics = leaf({ name: "scheduleTest/physics", during: [update] }); - const writer = leaf({ name: "scheduleTest/writer", components: { write: [value] }, during: [physics] }); - const reader = leaf({ name: "scheduleTest/reader", components: { read: [value] }, during: [render] }); - expect(names(composite({ name: "scheduleTest/root", systems: [reader, writer, physics, update, render] }))) - .toEqual([["scheduleTest/writer", "scheduleTest/physics", "scheduleTest/update"], ["scheduleTest/reader", "scheduleTest/render"]]); + const update = leaf({ name: "update" }); + const render = leaf({ name: "render", after: [update] }); + const physics = leaf({ name: "physics", during: [update] }); + const writer = leaf({ name: "writer", components: { write: [value] }, during: [physics] }); + const reader = leaf({ name: "reader", components: { read: [value] }, during: [render] }); + expect(names(composite({ name: "root", systems: [reader, writer, physics, update, render] }))) + .toEqual([["writer", "physics", "update"], ["reader", "render"]]); }); it("orders a system during a composite around the composite, not between its members", () => { - const start = leaf({ name: "scheduleTest/start", components: { write: [other] } }); - const a = leaf({ name: "scheduleTest/a" }); - const b = leaf({ name: "scheduleTest/b", after: [a] }); - const group = composite({ name: "scheduleTest/group", systems: [a, b], after: [start] }); - const end = leaf({ name: "scheduleTest/end", components: { read: [value] }, after: [group] }); - const c = leaf({ name: "scheduleTest/c", components: { read: [other], write: [value] }, during: [group] }); - expect(names(composite({ name: "scheduleTest/root", systems: [c, start, group, end] }))) - .toEqual([["scheduleTest/start"], ["scheduleTest/c", "scheduleTest/a"], ["scheduleTest/b"], ["scheduleTest/end"]]); + const start = leaf({ name: "start", components: { write: [other] } }); + const a = leaf({ name: "a" }); + const b = leaf({ name: "b", after: [a] }); + const group = composite({ name: "group", systems: [a, b], after: [start] }); + const end = leaf({ name: "end", components: { read: [value] }, after: [group] }); + const c = leaf({ name: "c", components: { read: [other], write: [value] }, during: [group] }); + expect(names(composite({ name: "root", systems: [c, start, group, end] }))) + .toEqual([["start"], ["c", "a"], ["b"], ["end"]]); }); it("still throws on conflicting systems during one system, and on a during that contradicts before or after", () => { - const update = leaf({ name: "scheduleTest/update" }); - const render = leaf({ name: "scheduleTest/render", after: [update] }); - const a = leaf({ name: "scheduleTest/a", components: { write: [value] }, during: [update] }); - const b = leaf({ name: "scheduleTest/b", components: { write: [value] }, during: [update] }); - expect(() => scheduleOf(composite({ name: "scheduleTest/root", systems: [update, render, a, b] }))).toThrow('"scheduleTest/a" and "scheduleTest/b"'); - const late = leaf({ name: "scheduleTest/late", during: [update], after: [render] }); - expect(() => scheduleOf(composite({ name: "scheduleTest/root", systems: [update, render, late] }))).toThrow("Ordering cycle"); + const update = leaf({ name: "update" }); + const render = leaf({ name: "render", after: [update] }); + const a = leaf({ name: "a", components: { write: [value] }, during: [update] }); + const b = leaf({ name: "b", components: { write: [value] }, during: [update] }); + expect(() => scheduleOf(composite({ name: "root", systems: [update, render, a, b] }))).toThrow('"a" and "b"'); + const late = leaf({ name: "late", during: [update], after: [render] }); + expect(() => scheduleOf(composite({ name: "root", systems: [update, render, late] }))).toThrow("Ordering cycle"); }); it("ignores constraints naming systems outside the one being scheduled", () => { - const a = leaf({ name: "scheduleTest/a", after: [leaf({ name: "scheduleTest/d" })] }); - expect(names(composite({ name: "scheduleTest/root", systems: [a] }))).toEqual([["scheduleTest/a"]]); + const a = leaf({ name: "a", after: [leaf({ name: "d" })] }); + expect(names(composite({ name: "root", systems: [a] }))).toEqual([["a"]]); }); it("throws on a cycle", () => { - const a = leaf({ name: "scheduleTest/a" }); - const b = leaf({ name: "scheduleTest/b", after: [a], before: [a] }); - expect(() => scheduleOf(composite({ name: "scheduleTest/root", systems: [a, b] }))).toThrow("Ordering cycle"); + const a = leaf({ name: "a" }); + const b = leaf({ name: "b", after: [a], before: [a] }); + expect(() => scheduleOf(composite({ name: "root", systems: [a, b] }))).toThrow("Ordering cycle"); }); it("throws on an unordered write conflict, but not when ordered or disjoint", () => { - const a = leaf({ name: "scheduleTest/a", components: { write: [value] } }); - const b = leaf({ name: "scheduleTest/b", components: { read: [value] } }); - const c = leaf({ name: "scheduleTest/c", components: { write: [other] }, during: [a] }); - expect(() => scheduleOf(composite({ name: "scheduleTest/root", systems: [a, b] }))).toThrow('"scheduleTest/a" and "scheduleTest/b"'); + const a = leaf({ name: "a", components: { write: [value] } }); + const b = leaf({ name: "b", components: { read: [value] } }); + const c = leaf({ name: "c", components: { write: [other] }, during: [a] }); + expect(() => scheduleOf(composite({ name: "root", systems: [a, b] }))).toThrow('"a" and "b"'); const ordered = leaf({ ...b.declaration, name: b.name, after: [a] }); - expect(names(composite({ name: "scheduleTest/root", systems: [a, ordered, c] }))) - .toEqual([["scheduleTest/a", "scheduleTest/c"], ["scheduleTest/b"]]); + expect(names(composite({ name: "root", systems: [a, ordered, c] }))) + .toEqual([["a", "c"], ["b"]]); }); it("schedules two systems of one name as two, and one listed twice as one", () => { - const a = leaf({ name: "scheduleTest/a", components: { write: [value] } }); - const copy = leaf({ name: "scheduleTest/a", components: { write: [value] }, after: [a] }); - expect(scheduleOf(composite({ name: "scheduleTest/root", systems: [copy, a, a] })).tiers).toEqual([[a], [copy]]); - expect(() => scheduleOf(composite({ name: "scheduleTest/root", systems: [a, leaf({ name: "scheduleTest/a", components: { write: [value] } })] }))).toThrow('Systems "scheduleTest/a" and "scheduleTest/a" both access'); + const a = leaf({ name: "a", components: { write: [value] } }); + const copy = leaf({ name: "a", components: { write: [value] }, after: [a] }); + expect(scheduleOf(composite({ name: "root", systems: [copy, a, a] })).tiers).toEqual([[a], [copy]]); + expect(() => scheduleOf(composite({ name: "root", systems: [a, leaf({ name: "a", components: { write: [value] } })] }))).toThrow('Systems "a" and "a" both access'); }); }); diff --git a/packages/ecs/src/implementation/world/semantics.test.ts b/packages/ecs/src/implementation/world/semantics.test.ts index 73fae368c..ef53b1bc5 100644 --- a/packages/ecs/src/implementation/world/semantics.test.ts +++ b/packages/ecs/src/implementation/world/semantics.test.ts @@ -1,6 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; -import { Component } from "../../interface/component/component.js"; import { Entity } from "../../interface/entity/entity.js"; import type { Schema } from "../../interface/schema/schema.js"; import { System } from "../../interface/system/system.js"; @@ -17,28 +16,23 @@ const score = Symbol("semanticsTest/score"); const tag = Symbol("semanticsTest/tag"); declare module "@adobe/ecs" { export interface Components { - [position]: { type: readonly [number, number] }; + [position]: { type: Schema.Type<{ type: "array"; items: { type: "number"; precision: 1 }; minItems: 2; maxItems: 2 }> }; [target]: { type: Entity }; [score]: { type: number }; [tag]: { type: true }; } } -Component.register(position, { type: vec2 }); -Component.register(target, { type: Entity.schema }); -Component.register(score, { type: { type: "number" } }); -Component.register(tag, { type: { const: true } }); -const seed = System.create({ name: "semanticsTest/seed" }, ({ commands }) => { +const seed = System.create({}, ({ commands }) => { // @ts-expect-error untyped callers can store any number as an entity commands.spawn([position, target, score])({ [position]: [5, 6], [target]: -1, [score]: 0 }); // @ts-expect-error untyped callers can store any number as an entity commands.spawn([position, target, score])({ [position]: [7, 8], [target]: Number.NaN, [score]: 0 }); }); const chase = System.create({ - name: "semanticsTest/chase", - kernel: "cpu", - components: { read: [target], write: [score] }, - lookup: { targets: { read: [position] } }, + kernel: true, + components: { with: [target, score] }, + lookup: { targets: { with: [position] } }, }, (row, { lookup: { targets } }) => { const other = targets(row[target]); if (other === null) { @@ -61,7 +55,7 @@ const run = async (wasm: boolean) => { describe("semantics", () => { it("a throwing sync point doesn't poison the next one or another world", async () => { let poison = false; - const spawn = System.create({ name: "semanticsTest/seed/2" }, ({ commands }) => { + const spawn = System.create({}, ({ commands }) => { commands.spawn([score])({ [score]: 1 }); commands.spawn([score])({ [score]: poison ? Symbol("x") as unknown as number : 2 }); }); @@ -87,9 +81,9 @@ describe("semantics", () => { it("re-adding an existing tag keeps its added tick", async () => { const seen: number[] = []; let entity = Entity.none; - const spawn = System.create({ name: "semanticsTest/seed/3" }, ({ commands }) => { entity = commands.spawn([tag, score])({ [tag]: true, [score]: 0 }); }); - const retag = System.create({ name: "semanticsTest/retag" }, ({ commands }) => { commands.add(entity, tag, true); }); - const watch = System.create({ name: "semanticsTest/watch", components: { has: [score], added: [tag] } }, row => { seen.push(row.entity); }); + const spawn = System.create({}, ({ commands }) => { entity = commands.spawn([tag, score])({ [tag]: true, [score]: 0 }); }); + const retag = System.create({}, ({ commands }) => { commands.add(entity, tag, true); }); + const watch = System.create({ components: { with: [score], added: [tag] } }, row => { seen.push(row.entity); }); const world = createWorld({ [workers]: 0 }); await world.run(spawn); await world.run(watch); diff --git a/packages/ecs/src/implementation/world/sharing.ts b/packages/ecs/src/implementation/world/sharing.ts index 59fb1ea82..e1ebe1126 100644 --- a/packages/ecs/src/implementation/world/sharing.ts +++ b/packages/ecs/src/implementation/world/sharing.ts @@ -1,7 +1,10 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. +import { createResolved } from '../../interface/system/create-resolved.js'; +import type { Resolved } from '../../interface/system/declaration.js'; +import type { SystemLookupLists } from '../../interface/system/access.js'; import type { World } from '../../interface/world/world.js'; import type { Failure } from '../../interface/report/failure.js'; -import { definitionOf, idOfKey, indexDefinitionOf, itemOfId, keyOfId, labelOfItem, requireDefinition, type Definition } from '../../interface/registry/index.js'; +import { definitionOf, idOfKey, indexDefinitionOf, itemOfId, keyOfId, requireDefinition, type Definition } from '../../interface/registry/index.js'; import type { Views } from '../../interface/registry/layout.js'; import { failureOf } from './failures.js'; import { @@ -12,7 +15,6 @@ import { import { createObserverTarget, droppedObserver, endTarget, joinedRun, type ObserverTarget } from './joined-observer.js'; import type { Leaf } from '../../interface/system/leaf.js'; import { Index } from '../../interface/indexes/index-type.js'; -import { System } from '../../interface/system/system.js'; import { createMoveLog, freeMoveLog, type MoveLog } from './move-log.js'; import { lineWords } from './protocol.js'; import { claimThreads, releaseThreads } from './remote.js'; @@ -240,7 +242,7 @@ export const createSharing = (host: SharingHost) => { released(holder, thread, sequence); continue; } - const layout = bytes >= 0 && definition.kind !== "composite" ? inputLayoutOf(definition, definition.declaration.input) : null; + const layout = bytes >= 0 && definition.kind !== "composite" ? inputLayoutOf(definition, definition.input) : null; const held = holder !== null && holder.tasks !== null ? heldInput(thread.number, sequence, holder.tasks) : null; const value = own ? joinedRun(sequence) : held ?? (layout !== null && layout.kind === "linear" ? layout.read(thread.views[slot], 0) : input); let ran: Promise; @@ -436,7 +438,7 @@ export const createSharing = (host: SharingHost) => { if (tasks === null) return; for (const [index, error] of message.errors) { const finalizer = tasks.finalizerAt(index); - if (finalizer !== undefined) host.defer(failureOf("finalizer", labelOfItem(finalizer), Object.assign(new Error(error.message), { name: error.name, stack: error.stack }))); + if (finalizer !== undefined) host.defer(failureOf("finalizer", finalizer, Object.assign(new Error(error.message), { name: error.name, stack: error.stack }))); } return; } @@ -472,7 +474,7 @@ export const createSharing = (host: SharingHost) => { case "observer failed": { for (const [index, error] of message.errors) { const placed = thread.observer?.placed[index]; - if (placed !== undefined) host.defer(failureOf("system", placed.label, Object.assign(new Error(error.message), { name: error.name, stack: error.stack }))); + if (placed !== undefined) host.defer(failureOf("system", { name: placed.label, id: placed.id }, Object.assign(new Error(error.message), { name: error.name, stack: error.stack }))); } return; } @@ -520,7 +522,7 @@ export const createSharing = (host: SharingHost) => { let index = madeIndexes.get(identity); if (index === undefined) { // `Index.create` checks the declaration as the joined thread's was checked. - index = indexDefinitionOf(Reflect.apply(Index.create, undefined, [{ name: declared.name, key, sorted: declared.sorted, unique: declared.unique, components: { has, not } }])); + index = indexDefinitionOf(Reflect.apply(Index.create, undefined, [{ name: declared.name, key, sorted: declared.sorted, unique: declared.unique, components: { with: has, not } }])); madeIndexes.set(identity, index); } return index; @@ -538,10 +540,10 @@ export const createSharing = (host: SharingHost) => { for (const [field, numbers] of entries) if (numbers !== null && (numbers.length > 0 || field === "changed")) result[field] = keys(numbers); return result; }; - const lookup: { [name: string]: object } = {}; + const lookup: { [name: string]: SystemLookupLists } = {}; for (const each of declared.lookup) { if (each.index === null) { - lookup[each.name] = listed([["read", each.read], ["has", each.has], ["not", each.not], ["changed", each.changed]]); + lookup[each.name] = listed([["read", each.read], ["has", each.has], ["optional", each.optional], ["not", each.not], ["changed", each.changed]]); continue; } const index = typeof each.index === "string" ? indexDefinitionOf(itemOfId(each.index)) : madeIndex(thread, each.index); @@ -550,19 +552,19 @@ export const createSharing = (host: SharingHost) => { } const { components, resources } = declared; // Unnamed, so other systems' labels stay as they are; its own is the thread's. - const declaration = { + const declaration: Resolved = { observer: true, - ...components === null ? {} : { components: listed([["read", components.read], ["has", components.has], ["not", components.not], ["added", components.added], ["changed", components.changed]]) }, + ...components === null ? {} : { components: listed([["read", components.read], ["has", components.has], ["optional", components.optional], ["not", components.not], ["added", components.added], ["changed", components.changed]]) }, ...resources === null ? {} : { resources: listed([["read", resources.read], ["changed", resources.changed], ["is", resources.is], ["not", resources.not]]) }, ...declared.lookup.length === 0 ? {} : { lookup }, }; - // `System.create` checks the declaration as the joined thread's was checked; its body runs only there. - const created = requireDefinition(Reflect.apply(System.create, undefined, [declaration, () => {}])); + // Checked as the joined thread's was; its body runs only there. + const created = requireDefinition(createResolved(declaration, () => {})); if (created.kind === "composite") return; proxy = created; } catch (error) { - host.defer(failureOf("system", name, error)); + host.defer(failureOf("system", { name, id: null }, error)); // Its runs reject. thread.names.set(number, { id: `#observer:${number}`, value: null }); drain(thread); diff --git a/packages/ecs/src/implementation/world/state.ts b/packages/ecs/src/implementation/world/state.ts index 5f97809e9..88ef8ac1a 100644 --- a/packages/ecs/src/implementation/world/state.ts +++ b/packages/ecs/src/implementation/world/state.ts @@ -1,7 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { createHolds, type Holds } from './stop-lists.js'; import type { Entity } from '../../interface/entity/entity.js'; -import { declarationOf, firstKeyOf, kindOf, labelOf } from '../../interface/registry/index.js'; +import { declarationOf, firstKeyOf, kindOf, labelOf, whyUnregistered } from '../../interface/registry/index.js'; import { createArchetype, startTicks, type Archetype, type Storage } from './archetype.js'; import type { SpawnBatch } from './staging.js'; import type { CommandBuffer } from './buffer.js'; @@ -15,6 +15,7 @@ import type { FinalizerRunner, Thrown } from './finalizers.js'; import { createPlan, type Plan } from './plan.js'; import { createStructure, owner, restratify, weak, type Structure } from './structure.js'; import { layoutOf, objectLayout, type Layout } from '../../interface/registry/layout.js'; +import { numberConstants } from '../../interface/registry/schemas.js'; import { createMemory, type Memory } from './memory.js'; import { createStrings, type StringStore } from './strings.js'; import { createLayoutDirectory, type LayoutDirectory } from './layout-directory.js'; @@ -138,8 +139,9 @@ export type State = { readonly batches: SpawnBatch[]; readonly batchByKey: Map; /** - * Lists of components commands named, by their sorted ids; archetypes by their program-wide index, and the last - * eight plain lists, which find them first. + * Lists of components commands named, by their sorted ids; lists the build gave indexes, and those made at runtime + * that keep one while their keys stay the same, by their index; and the last eight made at runtime, by their keys, + * which find copies of them before they keep their own (`lists.ts`). */ readonly lists: Map; readonly byIndex: ListShape[]; @@ -165,6 +167,12 @@ export type State = { /** Whether kernels with a program run as WASM, and as SIMD where they can; tests turn these off to compare with JS. */ readonly wasm: boolean; readonly simd: boolean; + /** + * Rows of an archetype a kernel runs here at which it wants the pool, while none is started: infinite with a pool, + * or without workers to start. A kernel reaching it sets `poolWanted`, and the next run starts the pool. + */ + poolThreshold: number; + poolWanted: boolean; /** The sync point's plan, kept from planning to applying. */ readonly plan: Plan; /** A sync point between planning and applying: its queued buffers, reservations and tick. */ @@ -298,6 +306,8 @@ export const createState = (shared?: boolean, wasm = true, simd = true, gpu = fa rows: new Map(), wasm, simd, + poolThreshold: Infinity, + poolWanted: false, plan: createPlan(), syncing: null, carried: null, @@ -367,7 +377,7 @@ export const idOf = (state: State, key: Key): number => { return id; } const declaration = declarationOf(key); - if (declaration === undefined) throw new Error(`"${labelOf(key)}" has no registered schema; import the module that registers it.`); + if (declaration === undefined) throw new Error(`"${labelOf(key)}" has no registered schema: ${whyUnregistered(key)}.`); const { structure } = state; // A host gets its id first, so stratifying with the new edge can name it; a cycle throws before anything is // recorded. @@ -413,15 +423,19 @@ export const idOf = (state: State, key: Key): number => { }; /** - * What a resource or derived component starts at: its declaration's default or its schema's, or `null` when its type - * allows leaving it out. + * What a resource or derived component starts at: a new empty `Map` or `Set` when it starts fresh, so each world has + * its own; its declaration's default, which the build gives; or `null` when its type allows leaving it out. */ export const defaultOf = (key: Key): unknown => { const declaration = declarationOf(key); if (declaration === undefined) return null; + if (declaration.fresh === "Map") return new Map(); + if (declaration.fresh === "Set") return new Set(); if ("default" in declaration) return declaration.default; const { type } = declaration; - return type !== undefined && "default" in type ? type.default : null; + if (type === undefined) return null; + // A union of number constants starts at its first, so its value is always one of them. + return numberConstants(type)?.[0] ?? null; }; /** diff --git a/packages/ecs/src/implementation/world/stop-lists.ts b/packages/ecs/src/implementation/world/stop-lists.ts index 8a5390f6e..2f03faacc 100644 --- a/packages/ecs/src/implementation/world/stop-lists.ts +++ b/packages/ecs/src/implementation/world/stop-lists.ts @@ -1,5 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { Finalizer } from '../../interface/finalizer/created.js'; +import { viewsOf, type Layout } from '../../interface/registry/layout.js'; import type { Memory } from './memory.js'; import type { StringStore } from './strings.js'; import { logFinalizedWord, logStopsWord, type MoveLog } from './move-log.js'; @@ -12,7 +13,9 @@ import { logFinalizedWord, logStopsWord, type MoveLog } from './move-log.js'; // its bytes. Then a group per finalizer with stops, in the world's finalizer order: its index on the thread, its stops, // its bytes; then as columns, each on 8 bytes, every stop's entity (f64), its archetype and row before apply (u32), the // tag of each object it reads (u32), and the value of each linear struct it reads in that component's layout, or for a -// string, the handle of a copy of its record, freed with the block, since the thread may read it sync points later. +// string, the handle of a copy of its record; then its resources as they were before apply: the handle of a copy of +// each string resource it reads (u32), then each linear resource it reads in its layout, each on 8 bytes. Copies of +// records are freed with the block, since the thread may read them sync points later. export const blockSyncWord = 0; export const blockNextWord = 1; @@ -37,11 +40,29 @@ export const valuesAt = (group: number, count: number, objects: number): number /** Bytes of one linear read's column of `count` values of `stride` bytes. */ export const columnBytes = (count: number, stride: number): number => align(count * stride); -/** Bytes of a group of `count` stops of a finalizer reading `objects` objects and linear values of `strides` bytes. */ -const groupBytes = (count: number, objects: number, strides: readonly number[]): number => { +/** + * Where each linear resource's copy starts past a group's columns, after the handles of `strings` string resources, + * for linear resources of `strides` bytes; then the bytes of them all. + */ +export const resourceOffsetsOf = (strings: number, strides: readonly number[]): number[] => { + const offsets: number[] = []; + let at = align(4 * strings); + for (const stride of strides) { + offsets.push(at); + at += align(stride); + } + offsets.push(at); + return offsets; +}; + +/** + * Bytes of a group of `count` stops of a finalizer reading `objects` objects, linear values of `strides` bytes and + * `resources` bytes of resources. + */ +const groupBytes = (count: number, objects: number, strides: readonly number[], resources: number): number => { let bytes = valuesAt(0, count, objects); for (const stride of strides) bytes += columnBytes(count, stride); - return bytes; + return bytes + resources; }; /** One of a joined thread's finalizers, as the world writes its stops. */ @@ -55,6 +76,18 @@ export type StopsOf = { readonly strides: readonly number[]; /** By linear read: whether it's a string's handles, whose records the block holds copies of. */ readonly copies: readonly boolean[]; + /** + * The string resources it reads, then its linear resources with their strides, in its context's order, which each + * group holds copies of; and where each linear one's copy starts past the group's columns, then their bytes. + */ + readonly stringResources: readonly number[]; + readonly linearResources: readonly number[]; + readonly resourceStrides: readonly number[]; + readonly resourceOffsets: readonly number[]; + /** What its string and linear resources start at, with the linear ones' layouts, for one the world doesn't hold. */ + readonly stringStarts: readonly unknown[]; + readonly linearStarts: readonly unknown[]; + readonly resourceLayouts: readonly Layout[]; /** This sync point's stops, as plan entries, reused by every sync point. */ readonly entries: number[]; }; @@ -128,7 +161,10 @@ const copyStrings = (strings: StringStore, copied: number[], u32: Uint32Array, a /** A storage as stops are copied from it: its tags, or its linear values. */ type Copied = { readonly tags: Uint32Array; readonly views: { readonly u8: Uint8Array; readonly u32: Uint32Array } }; -/** What a sync point's stops come from: entries' entities and archetypes before apply, and the entity table's rows. */ +/** + * What a sync point's stops come from: entries' entities and archetypes before apply, the entity table's rows, and the + * resource entity's archetype and row, -1 without one. + */ export type StopSource = { readonly entity: Float64Array; readonly from: Int32Array; @@ -136,6 +172,8 @@ export type StopSource = { readonly archetypes: readonly { readonly storageById: readonly (Copied | undefined)[] }[]; readonly row: Uint32Array; readonly holds: Holds; + readonly resourceArchetype: number; + readonly resourceRow: number; }; /** @@ -147,7 +185,7 @@ export const writeStops = (writer: StopWriter, sync: number, source: StopSource) const { staged, memory } = writer; if (staged.length === 0) return; let bytes = blockHeaderBytes; - for (const stops of staged) bytes += groupBytes(stops.entries.length, stops.objects.length, stops.strides); + for (const stops of staged) bytes += groupBytes(stops.entries.length, stops.objects.length, stops.strides, stops.resourceOffsets[stops.linearResources.length]); const offset = memory.allocate(bytes); const buffer = memory.memory.buffer; const u8 = new Uint8Array(buffer); @@ -163,9 +201,9 @@ export const writeStops = (writer: StopWriter, sync: number, source: StopSource) let group = offset + blockHeaderBytes; const { entity, from, despawned, archetypes, row: rows, holds } = source; for (const stops of staged) { - const { entries, objects, linear, strides, copies } = stops; + const { entries, objects, linear, strides, copies, stringResources, linearResources, resourceStrides, resourceOffsets } = stops; const count = entries.length; - const size = groupBytes(count, objects.length, strides); + const size = groupBytes(count, objects.length, strides, resourceOffsets[linearResources.length]); u32[(group >> 2) + groupIndexWord] = stops.index; u32[(group >> 2) + groupCountWord] = count; u32[(group >> 2) + groupBytesWord] = size; @@ -229,6 +267,36 @@ export const writeStops = (writer: StopWriter, sync: number, source: StopSource) } column += columnBytes(count, stride); } + // As before apply, as its components are: the thread may read them after the resources are written again. + if (stringResources.length > 0 || linearResources.length > 0) { + const { strings } = writer; + const { resourceRow } = source; + const storageById = source.resourceArchetype < 0 ? undefined : archetypes[source.resourceArchetype].storageById; + for (let k = 0; k < stringResources.length; k++) { + const handles = storageById?.[stringResources[k]]?.views.u32; + // Without the resource, unreachable while a finalizer activates only once its resources exist: its start. + const start = stops.stringStarts[k]; + const copy = strings === null ? 0 : handles !== undefined ? strings.copy(handles[resourceRow]) : typeof start === "string" ? strings.write(start) : 0; + if (copy !== 0) copied.push(copy); + u32[(column >> 2) + k] = copy; + } + for (let k = 0; k < linearResources.length; k++) { + const values = storageById?.[linearResources[k]]?.views; + const stride = resourceStrides[k]; + const target = column + resourceOffsets[k]; + if (values === undefined) { + // Unreachable while a finalizer activates only once its resources exist: its start. + const layout = stops.resourceLayouts[k]; + if (layout.kind === "linear") layout.write(viewsOf(buffer, target, stride), 0, stops.linearStarts[k]); + continue; + } + if ((stride & 3) === 0) { + const words = stride >> 2; + for (let w = 0; w < words; w++) u32[(target >> 2) + w] = values.u32[resourceRow * words + w]; + } + else for (let b = 0; b < stride; b++) u8[target + b] = values.u8[resourceRow * stride + b]; + } + } stopped.push([stops.finalizer, count]); group += size; entries.length = 0; diff --git a/packages/ecs/src/implementation/world/strings.test.ts b/packages/ecs/src/implementation/world/strings.test.ts index 13608233e..d2e8d880d 100644 --- a/packages/ecs/src/implementation/world/strings.test.ts +++ b/packages/ecs/src/implementation/world/strings.test.ts @@ -1,8 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { afterEach, describe, expect, it, vi } from "vitest"; -import { Component } from "../../interface/component/component.js"; import type { Entity } from "../../interface/entity/entity.js"; -import { Resource } from "../../interface/resource/resource.js"; import { workers } from "../../interface/resources/workers.js"; import { System } from "../../interface/system/system.js"; import { createWorld, join } from "./create.js"; @@ -24,60 +22,54 @@ declare module "@adobe/ecs" { [note]: { type: string | null }; } export interface Resources { - [mode]: { type: "edit" | "view" }; + [mode]: { type: "edit" | "view"; default: "view" }; [title]: { type: string }; } } -Component.register(label, { type: { type: "string" } }); -Component.register(order, { type: { type: "number" } }); -Component.register(tagged, { type: { const: true } }); -Component.register(note, { type: { anyOf: [{ type: "string" }, { type: "null" }] } }); -Resource.register(mode, { type: { anyOf: [{ const: "edit" }, { const: "view" }], default: "view" } }); -Resource.register(title, { type: { type: "string", default: "" } }); /** Text of every kind a string holds: none, letters, astral characters as surrogate pairs, and a long run. */ const textOf = (n: number): string => n % 4 === 0 ? "" : n % 4 === 1 ? `item ${n}` : n % 4 === 2 ? `✓ ${n} 😀 naïve` : `${"long ".repeat(40)}${n}`; -const spawn = System.create({ name: "stringsTest/spawn", input: { type: "object", properties: { count: { type: "integer" }, from: { type: "integer" } } } }, ({ input, commands }) => { +const spawn = System.create({}, ({ commands }, input: { readonly count: number; readonly from: number }) => { for (let i = 0; i < input.count; i++) commands.spawn([label, order])({ [label]: textOf(input.from + i), [order]: input.from + i }); }); /** `count` rows with labels of 400 code units, whose records are 1024-byte blocks of the arena. */ -const spawnLong = System.create({ name: "stringsTest/spawnLong", input: { type: "object", properties: { count: { type: "integer" } } } }, ({ input, commands }) => { +const spawnLong = System.create({}, ({ commands }, input: { readonly count: number }) => { for (let i = 0; i < input.count; i++) commands.spawn([label, order])({ [label]: `${i}`.padStart(400, "·"), [order]: i }); }); /** A run over every row that changes nothing, for its sync point. */ -const pass = System.create({ name: "stringsTest/pass", components: { read: [order] } }, row => { void row[order]; }); +const pass = System.create({ components: { with: [order] } }, row => { void row[order]; }); -const rename = System.create({ name: "stringsTest/rename", input: { type: "object", properties: { suffix: { type: "string" } } }, components: { read: [order], write: [label] } }, (row, { input }) => { +const rename = System.create({ components: { with: [order, label] } }, (row, _context, input: { readonly suffix: string }) => { row[label] = `${textOf(row[order])}${input.suffix}`; }); -const relabel = System.create({ name: "stringsTest/relabel", input: { type: "object", properties: { suffix: { type: "string" } } }, components: { read: [order], has: [label] } }, (row, { input, commands }) => { +const relabel = System.create({ components: { with: [order, label] } }, (row, { commands }, input: { readonly suffix: string }) => { commands.add(row.entity, label, `${textOf(row[order])}${input.suffix}`); }); -const tag = System.create({ name: "stringsTest/tag", components: { has: [label] } }, (row, { commands }) => commands.add(row.entity, tagged)); -const untag = System.create({ name: "stringsTest/untag", components: { has: [tagged] } }, (row, { commands }) => commands.remove(row.entity, tagged)); -const unlabel = System.create({ name: "stringsTest/unlabel", components: { has: [label] } }, (row, { commands }) => commands.remove(row.entity, label)); -const despawn = System.create({ name: "stringsTest/despawn", components: { has: [order] } }, (row, { commands }) => commands.despawn(row.entity)); -const annotate = System.create({ name: "stringsTest/annotate", components: { read: [order], write: [note] } }, row => { +const tag = System.create({ components: { with: [label] } }, (row, { commands }) => commands.add(row.entity, tagged)); +const untag = System.create({ components: { with: [tagged] } }, (row, { commands }) => commands.remove(row.entity, tagged)); +const unlabel = System.create({ components: { with: [label] } }, (row, { commands }) => commands.remove(row.entity, label)); +const despawn = System.create({ components: { with: [order] } }, (row, { commands }) => commands.despawn(row.entity)); +const annotate = System.create({ components: { with: [order, note] } }, row => { row[note] = row[order] % 3 === 0 ? null : `note ${row[order]}`; }); -const unannotate = System.create({ name: "stringsTest/unannotate", components: { write: [note] } }, row => { +const unannotate = System.create({ components: { with: [note] } }, row => { row[note] = null; }); -const noted = System.create({ name: "stringsTest/noted", components: { has: [order], not: [note] } }, (row, { commands }) => commands.add(row.entity, note, null)); -const despawnEven = System.create({ name: "stringsTest/despawnEven", components: { read: [order] } }, (row, { commands }) => { +const noted = System.create({ components: { with: [order], not: [note] } }, (row, { commands }) => commands.add(row.entity, note, null)); +const despawnEven = System.create({ components: { with: [order] } }, (row, { commands }) => { if (row[order] % 2 === 0) commands.despawn(row.entity); }); -const labelAgain = System.create({ name: "stringsTest/labelAgain", input: { type: "object", properties: { suffix: { type: "string" } } }, components: { read: [order], not: [label] } }, (row, { input, commands }) => { +const labelAgain = System.create({ components: { with: [order], not: [label] } }, (row, { commands }, input: { readonly suffix: string }) => { commands.add(row.entity, label, `${textOf(row[order])}${input.suffix}`); }); -const edit = System.create({ name: "stringsTest/edit", input: { type: "object", properties: { title: { type: "string" } } }, resources: { write: [mode, title] } }, ({ input, resources }) => { +const edit = System.create({}, ({ resources }, input: { readonly title: string }) => { resources[mode] = "edit"; resources[title] = input.title; }); diff --git a/packages/ecs/src/implementation/world/synchronize.ts b/packages/ecs/src/implementation/world/synchronize.ts index 6777bc463..c38c3ad49 100644 --- a/packages/ecs/src/implementation/world/synchronize.ts +++ b/packages/ecs/src/implementation/world/synchronize.ts @@ -1,7 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { Views } from '../../interface/registry/layout.js'; import { copyValue, ensureCapacity, swapRemove, writeF32, writeF32Lanes, writeF64, writeI32, writeU32, type Archetype, type Storage } from './archetype.js'; -import { chunkShift } from './kernel.js'; +import { blockShift, chunkShift } from './kernel.js'; import { addCommand, clearBuffer, despawnCommand, endOf, referenceAt, removeCommand, spawnCommand, type CommandBuffer } from './buffer.js'; import { readCell, writeCell } from './cells.js'; import { commitBatches, commitStaged } from './batches.js'; @@ -17,7 +17,7 @@ import { settleStops } from './stop-lists.js'; import { closureOf, derives, gainsOf } from './structure.js'; import type { Plan } from './plan.js'; import type { MoveLog } from './move-log.js'; -import { archetypeOf, defaultOf, movesOf, withComponent, withoutComponent, type State } from './state.js'; +import { archetypeOf, movesOf, withComponent, withoutComponent, type State } from './state.js'; import { releaseValue } from './transfers.js'; /** Buffers queued for the next sync point, in merge order. Commands in guarded buffers can't touch the resource entity. */ @@ -281,10 +281,12 @@ const writeRange = (buffer: CommandBuffer, start: number, end: number, to: Arche // A constant's `changed` ticks are its `added` ticks, and its value can't change: only a new start stamps it. if (storage.changed !== storage.added || starts) { storage.changed[toRow] = tick; + storage.blockChanged[toRow >> blockShift] = tick; storage.chunkChanged[toRow >> chunkShift] = tick; } if (starts) { storage.added[toRow] = tick; + storage.blockAdded[toRow >> blockShift] = tick; storage.chunkAdded[toRow >> chunkShift] = tick; } } @@ -293,10 +295,12 @@ const writeRange = (buffer: CommandBuffer, start: number, end: number, to: Arche /** Writes a derived component's default into a row it starts in, stamped as added. */ export const writeDefault = (state: State, storage: Storage, row: number, tick: number): void => { // A joined thread's object starts there, as the row's move or append says. - if (storage.remote === null) writeCell(storage, row, defaultOf(state.keys[storage.component])); + // One start per world, never one per row: values are replaced whole, so a shared start is never changed. + if (storage.remote === null) writeCell(storage, row, state.starts[storage.component]); else storage.tags[row] = startTag; if (!storage.ticked) return; storage.added[row] = storage.changed[row] = tick; + storage.blockAdded[row >> blockShift] = storage.blockChanged[row >> blockShift] = tick; storage.chunkAdded[row >> chunkShift] = storage.chunkChanged[row >> chunkShift] = tick; }; @@ -416,7 +420,10 @@ const copyColumn = (from: Storage, to: Storage, plan: Plan, order: Int32Array, f const changed = from.changed[fromRow]; to.added[toRow] = added; to.changed[toRow] = changed; - // A chunk's tick is the newest of its rows, so a row moving in can raise it. + // A block's and a chunk's tick are the newest of their rows, so a row moving in can raise them. + const block = toRow >> blockShift; + if (added > to.blockAdded[block]) to.blockAdded[block] = added; + if (changed > to.blockChanged[block]) to.blockChanged[block] = changed; const chunk = toRow >> chunkShift; if (added > to.chunkAdded[chunk]) to.chunkAdded[chunk] = added; if (changed > to.chunkChanged[chunk]) to.chunkChanged[chunk] = changed; diff --git a/packages/ecs/src/implementation/world/tail-blocks.ts b/packages/ecs/src/implementation/world/tail-blocks.ts index 08b80c8b2..867e3ffc5 100644 --- a/packages/ecs/src/implementation/world/tail-blocks.ts +++ b/packages/ecs/src/implementation/world/tail-blocks.ts @@ -6,10 +6,11 @@ import { lineWords } from './protocol.js'; // // A tail's work is numbered: position 0 is its head, which checks its conditions and resolves its resources, and // positions from 1 are the rows of its matched archetypes in order. The tail claims positions in chunks by adding to -// its progress word; the world exchanges that word for `snapshotted` once it copied the passing rows from the position -// it read, so a claim that returns `snapshotted` or more reads the rest from the copy. +// its progress word; the world adds `snapshotted` to that word once it copied the passing rows from the position it +// read, so a claim that returns `snapshotted` or more reads the rest from the copy, and the word still says whether a +// claim came before the copy. -/** The positions the tail claimed, which it adds to and the world exchanges; and the end of its work, 1 plus its rows. */ +/** The positions the tail claimed, which it and the world add to; and the end of its work, 1 plus its rows. */ export const progressWord = 0; export const endWord = 1; /** The position the tail finished reading through, which it stores after each chunk; whether the world waits for it. */ @@ -28,7 +29,10 @@ export const snapshotCapacityWord = 2 * lineWords + 6; export const tailBlockWords = 3 * lineWords; export const tailBlockBytes = tailBlockWords * 4; -/** What the progress word holds once the world copied the rest: claims add to it and stay past it. */ +/** + * What the world adds to the progress word once it copied the rest, `snapshotted` itself when no claim came first: + * claims add to it and stay past it. + */ export const snapshotted = 0x40000000; /** Positions a claim takes: the most the world waits for once a writer is ready. */ export const tailChunk = 64; diff --git a/packages/ecs/src/implementation/world/tails.cases.ts b/packages/ecs/src/implementation/world/tails.cases.ts index a0a316655..cde8dab73 100644 --- a/packages/ecs/src/implementation/world/tails.cases.ts +++ b/packages/ecs/src/implementation/world/tails.cases.ts @@ -7,7 +7,7 @@ export const tailsCases = (load: () => Promise) => describe("tai it("see one tick's values in every run while the world ticks on: in place, from a snapshot when late, and both across a switch at a chunk", async () => { const fixture = await load(); const seen = await fixture.continuous(); - expect({ wrong: seen.wrong, labels: seen.labels, fewest: seen.fewest, most: seen.most }).toEqual({ wrong: 0, labels: 0, fewest: 1000, most: 1000 }); + expect({ wrong: seen.wrong, labels: seen.labels, fewest: seen.fewest, most: seen.most, lacking: seen.lacking }).toEqual({ wrong: 0, labels: 0, fewest: 1000, most: 1000, lacking: true }); expect(seen.late).toBeGreaterThan(0); expect(seen.switched).toBeGreaterThan(0); }, 30000); @@ -39,7 +39,7 @@ export const tailsCases = (load: () => Promise) => describe("tai }, 30000); it("are skipped in every run queued when their thread left, and nothing waits for a tail on a thread that's gone", async () => { const fixture = await load(); - const skipped = ["tailsTest/lead", "tailsTest/watch"]; + const skipped = ["lead", "watch"]; expect(await fixture.leaveQueued()).toEqual({ skipped: [skipped, skipped], after: 0 }); }, 30000); }); diff --git a/packages/ecs/src/implementation/world/tails.fixture.ts b/packages/ecs/src/implementation/world/tails.fixture.ts index 76ada07b4..873ab255e 100644 --- a/packages/ecs/src/implementation/world/tails.fixture.ts +++ b/packages/ecs/src/implementation/world/tails.fixture.ts @@ -81,6 +81,8 @@ const summary = async (worker: Worker) => { runs: seen.length, wrong: seen.reduce((sum, run) => sum + run.wrong, 0), labels: seen.reduce((sum, run) => sum + run.labels, 0), + // Rows of the archetype without `extra`, which every run reads as undefined. + lacking: seen.every(run => run.lacking * 2 === run.rows), fewest: Math.min(...seen.map(run => run.rows)), most: Math.max(...seen.map(run => run.rows)), late: reply.late, diff --git a/packages/ecs/src/implementation/world/tails.test-helper.ts b/packages/ecs/src/implementation/world/tails.test-helper.ts index 807365023..b77edaa3c 100644 --- a/packages/ecs/src/implementation/world/tails.test-helper.ts +++ b/packages/ecs/src/implementation/world/tails.test-helper.ts @@ -1,7 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Component } from "../../interface/component/component.js"; -import { Resource } from "../../interface/resource/resource.js"; import { System } from "../../interface/system/system.js"; +import type { Schema } from "../../interface/schema/schema.js"; +import type { Vec3 } from "../../interface/math/index.js"; // Keys and systems both threads of the tails fixtures import: a world system writing every row's stamp each tick, and // terminal systems on thread "tailsTest", which a joined thread fills, running there as tails. @@ -15,43 +15,35 @@ export const spawning = Symbol("tailsTest/spawning"); export const failAt = Symbol("tailsTest/failAt"); export const canvas = Symbol("tailsTest/canvas"); export const word = Symbol("tailsTest/word"); +/** On every other row: rows without it are in an archetype of their own, so a tail's optional read of it tells them apart. */ +export const extra = Symbol("tailsTest/extra"); declare module "@adobe/ecs" { export interface Threads { tailsTest: true; } export interface Components { - [stamp]: { type: number }; - [spot]: { type: readonly [number, number, number] }; - [label]: { type: { readonly text: string } | null; thread: "tailsTest" }; + [stamp]: { type: Schema.Type<{ type: "integer" }> }; + [spot]: { type: Vec3 }; + [label]: { type: { readonly text: string } | null; thread: "tailsTest"; default: null }; [word]: { type: string }; + [extra]: { type: Schema.Type<{ type: "integer" }> }; } export interface Resources { - [counter]: { type: number }; - [stall]: { type: { readonly mode: number; readonly pause: number } }; - [spawning]: { type: number }; - [failAt]: { type: number }; - [canvas]: { type: { readonly text: string }; thread: "tailsTest" }; + [counter]: { type: Schema.Type<{ type: "integer" }>; default: 0 }; + [stall]: { type: Schema.Type<{ type: "object"; properties: { mode: { type: "integer" }; pause: { type: "number" } } }>; default: { mode: 0; pause: 0 } }; + [spawning]: { type: Schema.Type<{ type: "integer" }>; default: 0 }; + [failAt]: { type: Schema.Type<{ type: "integer" }>; default: -1 }; + [canvas]: { type: { readonly text: string }; thread: "tailsTest"; default: { text: "canvas" } }; } } -Component.register(stamp, { type: { type: "integer" } }); -Component.register(spot, { type: { type: "array", items: { type: "number", precision: 1 }, minItems: 3, maxItems: 3 } }); /** An object of the tail's thread, which the tail writes. */ const text = { type: "object", properties: { text: { type: "string" } } } as const; -Component.register(label, { type: { anyOf: [text, { type: "null" }], default: null }, thread: "tailsTest" }); /** A string the world writes each tick, which lives in shared memory where the tail reads it. */ -Component.register(word, { type: { type: "string" } }); -Resource.register(counter, { type: { type: "integer", default: 0 } }); /** * How the tails stall, for milliseconds `pause`: `"none"`, `"chunk"` in the watcher at the end of its first chunk, so * a writer is ready mid-way, or `"before"` in a tail ahead of it, so the watcher is late. */ -Resource.register(stall, { - type: { type: "object", properties: { mode: { type: "integer" }, pause: { type: "number" } }, required: ["mode", "pause"], default: { mode: 0, pause: 0 } }, -}); -Resource.register(spawning, { type: { type: "integer", default: 0 } }); -Resource.register(failAt, { type: { type: "integer", default: -1 } }); /** An object resource of the tail's thread, which places the systems reading it there. */ -Resource.register(canvas, { type: { ...text, default: { text: "canvas" } }, thread: "tailsTest" }); export const noStall = 0; export const stallAtChunk = 1; @@ -63,49 +55,56 @@ const spin = (milliseconds: number): void => { }; /** On the world's thread: the counter moves on once a tick. */ -export const count = System.create({ name: "tailsTest/count", resources: { write: [counter] } }, ({ resources }) => { +export const count = System.create({}, ({ resources }) => { resources[counter] += 1; }); /** A tick's word, of a length that varies with it, so records of every size are freed and reused. */ export const wordOf = (value: number): string => `${"·".repeat(value % 37)}${value}`; /** On the world's thread: every row's stamp, spot and word take the counter. */ -export const advance = System.create({ name: "tailsTest/advance", components: { write: [stamp, spot, word] }, resources: { read: [counter] }, after: [count] }, (row, { resources }) => { +export const advance = System.create({ components: { with: [stamp, spot, word], optional: [extra] }, after: [count] }, (row, { resources }) => { const value = resources[counter]; row[stamp] = value; + if (row[extra] !== undefined) row[extra] = value; row[spot] = [value, -value, value]; row[word] = wordOf(value); }); /** On the world's thread: every row's word as another, of the size of the first ticks', so their records are reused. */ -export const reword = System.create({ name: "tailsTest/reword", input: { type: "object", properties: { round: { type: "integer" } } }, components: { write: [word] } }, (row, { input }) => { +export const reword = System.create({ components: { with: [word] } }, (row, _context, input: { readonly round: number }) => { row[word] = `r${input.round}`; }); /** On the world's thread: spawns rows stamped with this tick's counter, `spawning` of them, then no more. */ -export const grow = System.create({ name: "tailsTest/grow", resources: { read: [counter], write: [spawning] }, after: [count] }, ({ resources, commands }) => { +export const grow = System.create({ after: [count] }, ({ resources, commands }) => { const value = resources[counter]; - for (let k = 0; k < resources[spawning]; k++) commands.spawn([stamp, spot, label, word])({ [stamp]: value, [spot]: [value, -value, value], [word]: wordOf(value) }); + for (let k = 0; k < resources[spawning]; k++) { + if (k % 2 === 0) commands.spawn([stamp, spot, label, word, extra])({ [stamp]: value, [spot]: [value, -value, value], [word]: wordOf(value), [extra]: value }); + else commands.spawn([stamp, spot, label, word])({ [stamp]: value, [spot]: [value, -value, value], [word]: wordOf(value) }); + } resources[spawning] = 0; }); /** Spawns `count` rows. */ -export const seed = System.create({ name: "tailsTest/seed", input: { type: "object", properties: { count: { type: "integer" } } } }, ({ input, commands }) => { - for (let k = 0; k < input.count; k++) commands.spawn([stamp, spot, label, word])({ [stamp]: 0, [spot]: [0, 0, 0], [word]: wordOf(0) }); +export const seed = System.create({}, ({ commands }, input: { readonly count: number }) => { + for (let k = 0; k < input.count; k++) { + if (k % 2 === 0) commands.spawn([stamp, spot, label, word, extra])({ [stamp]: 0, [spot]: [0, 0, 0], [word]: wordOf(0), [extra]: 0 }); + else commands.spawn([stamp, spot, label, word])({ [stamp]: 0, [spot]: [0, 0, 0], [word]: wordOf(0) }); + } }); -export const setStall = System.create({ name: "tailsTest/setStall", input: { type: "object", properties: { mode: { type: "integer" }, pause: { type: "number" } } }, resources: { write: [stall] } }, ({ input, resources }) => { +export const setStall = System.create({}, ({ resources }, input: { readonly mode: number; readonly pause: number }) => { resources[stall] = { mode: input.mode, pause: input.pause }; }); -export const setSpawning = System.create({ name: "tailsTest/setSpawning", input: { type: "object", properties: { count: { type: "integer" } } }, resources: { write: [spawning] } }, ({ input, resources }) => { +export const setSpawning = System.create({}, ({ resources }, input: { readonly count: number }) => { resources[spawning] = input.count; }); -export const setFailAt = System.create({ name: "tailsTest/setFailAt", input: { type: "object", properties: { at: { type: "integer" } } }, resources: { write: [failAt] } }, ({ input, resources }) => { +export const setFailAt = System.create({}, ({ resources }, input: { readonly at: number }) => { resources[failAt] = input.at; }); /** What each run of the watcher saw, by the counter it read: rows, and those whose structs, word or label weren't that tick's. */ -export type Seen = { counter: number; rows: number; wrong: number; labels: number }; +export type Seen = { counter: number; rows: number; wrong: number; labels: number; lacking: number }; export const seen: Seen[] = []; /** On the tail's thread, ahead of the watcher: stalls it while `stall` says so, so it's late. */ -export const lead = System.create({ name: "tailsTest/lead", resources: { read: [stall, canvas] } }, ({ resources }) => { +export const lead = System.create({}, ({ resources }) => { if (resources[stall].mode === stallBefore && resources[canvas].text === "canvas") spin(resources[stall].pause); }); @@ -114,11 +113,14 @@ export const lead = System.create({ name: "tailsTest/lead", resources: { read: [ * must be the last tick's where it wrote one. */ export const watch = System.create({ - name: "tailsTest/watch", components: { read: [stamp, spot, word], write: [label] }, resources: { read: [counter, stall, failAt] }, after: [advance, grow, lead], + components: { with: [stamp, spot, word, label], optional: [extra] }, after: [advance, grow, lead], }, (row, { resources }) => { const value = resources[counter]; let last = seen.at(-1); - if (last === undefined || last.counter !== value) seen.push(last = { counter: value, rows: 0, wrong: 0, labels: 0 }); + if (last === undefined || last.counter !== value) seen.push(last = { counter: value, rows: 0, wrong: 0, labels: 0, lacking: 0 }); + const more = row[extra]; + if (more === undefined) last.lacking++; + else if (more !== value) last.wrong++; if (resources[failAt] === value) throw new Error(`tailsTest failure at ${value}`); const [x, y, z] = row[spot]; if (row[stamp] !== value || x !== value || y !== -value || z !== value || row[word] !== wordOf(value)) last.wrong++; @@ -130,7 +132,7 @@ export const watch = System.create({ }); /** Not terminal: it reaches its commands, so its tier blocks. */ -export const blocking = System.create({ name: "tailsTest/blocking", components: { read: [label] } }, (row, { commands }) => { +export const blocking = System.create({ components: { with: [label] } }, (row, { commands }) => { if (row[label]?.text === "never") commands.despawn(row.entity); }); @@ -140,7 +142,7 @@ export const changed = new Map(); * On the tail's thread, ahead of the watcher, reaching its commands, so its tier blocks: counts the labels the watcher * wrote since it last ran, which it sees once the world stamped them, from the watcher's snapshot too. */ -export const tally = System.create({ name: "tailsTest/tally", components: { read: [label], changed: [label] }, resources: { read: [counter] }, after: [count], before: [watch] }, (row, { resources, commands }) => { +export const tally = System.create({ components: { with: [label], changed: [label] }, after: [count], before: [watch] }, (row, { resources, commands }) => { if (row[label]?.text === "never") commands.despawn(row.entity); changed.set(resources[counter], (changed.get(resources[counter]) ?? 0) + 1); }); @@ -149,19 +151,19 @@ export const tally = System.create({ name: "tailsTest/tally", components: { read export const gate: { words: Int32Array | null } = { words: null }; /** On the tail's thread, terminal, reading only that thread's objects: held in its body until the world opens the gate. */ -export const held = System.create({ name: "tailsTest/held", resources: { read: [canvas] } }, ({ resources }) => { +export const held = System.create({}, ({ resources }) => { const { words } = gate; if (words === null || resources[canvas].text !== "canvas") return; Atomics.store(words, 1, 1); Atomics.notify(words, 1); Atomics.wait(words, 0, 0); }); -export const heldTick = System.create({ name: "tailsTest/heldTick", systems: [held] }); +export const heldTick = System.create({ systems: [held] }); /** Every tick: the world's systems, then the tails. */ -export const ticked = System.create({ name: "tailsTest/ticked", systems: [count, advance, grow, lead, watch] }); +export const ticked = System.create({ systems: [count, advance, grow, lead, watch] }); /** The same, with `tally` counting what the watcher wrote. */ -export const tallied = System.create({ name: "tailsTest/tallied", systems: [count, advance, grow, lead, tally, watch] }); +export const tallied = System.create({ systems: [count, advance, grow, lead, tally, watch] }); /** Every system the joined thread runs, which it imports so the world can place them there. */ export const tailSystems: readonly System[] = [ticked, tallied, blocking, heldTick]; diff --git a/packages/ecs/src/implementation/world/tails.test.ts b/packages/ecs/src/implementation/world/tails.test.ts index 08936f553..0fce36746 100644 --- a/packages/ecs/src/implementation/world/tails.test.ts +++ b/packages/ecs/src/implementation/world/tails.test.ts @@ -48,7 +48,7 @@ describe("tails", () => { }); it("block in their tier where a body reaches its commands, which isn't terminal", async () => { const { world, remote } = await joined(); - expect((await runReported(world, blocking)).warnings.map(warning => warning.name)).toEqual(["tailsTest/blocking"]); + expect((await runReported(world, blocking)).warnings.map(warning => warning.name)).toEqual(["blocking"]); await remote.dispose(); await world.dispose(); }); @@ -59,12 +59,12 @@ describe("tails", () => { await world.run(seed, { count: 10 }); await world.run(setFailAt, { at: 1 }); const ran = await runReported(world, ticked); - expect(ran.failures.map(failure => [failure.kind, failure.name, failure.message])).toEqual([["system", "tailsTest/watch", "tailsTest failure at 1"]]); + expect(ran.failures.map(failure => [failure.kind, failure.name, failure.message])).toEqual([["system", "watch", "tailsTest failure at 1"]]); const failing = await world.run(setFailAt, { at: 6 }); const reports = followReports(world); world.add(ticked); const failed = await reports.until(value => value.run > failing && value.failures.length > 0); - expect(failed.failures.map(failure => [failure.kind, failure.name, failure.message])).toEqual([["system", "tailsTest/watch", "tailsTest failure at 6"]]); + expect(failed.failures.map(failure => [failure.kind, failure.name, failure.message])).toEqual([["system", "watch", "tailsTest failure at 6"]]); await remote.dispose(); await world.dispose(); driver.close(); @@ -74,7 +74,7 @@ describe("tails", () => { const { world, remote } = await joined({ loop: driver }, { [always]: true }); await world.run(seed, { count: 10 }); const counters: number[] = []; - const values = System.create({ observer: true, resources: { read: [counter] } }, ({ resources }) => { counters.push(resources[counter]); }); + const values = System.create({ observer: true }, ({ resources }) => { counters.push(resources[counter]); }); world.add(ticked); world.add(values); await until(Observe.resource(world, counter), value => value >= 5); @@ -83,7 +83,7 @@ describe("tails", () => { const first = counters.findIndex(value => value > 0); expect(counters.slice(first, first + 4)).toEqual([0, 1, 2, 3].map(k => counters[first] + k)); const reports: (readonly [number, readonly string[]])[] = []; - const reading = System.create({ observer: true, components: { read: [Report.errors], changed: [Report.errors] }, resources: { read: [counter] } }, (row, { resources }) => { + const reading = System.create({ observer: true, components: { with: [Report.errors], changed: [Report.errors] } }, (row, { resources }) => { reports.push([resources[counter], row[Report.errors].failures.map(failure => failure.message)]); }); world.add(reading); @@ -104,8 +104,8 @@ describe("tails", () => { const leaving = remote.dispose(); const report = await runReported(world, ticked); await leaving; - expect(report.failures.map(failure => [failure.kind, failure.name])).toEqual([["thread", "tailsTest/lead"], ["thread", "tailsTest/watch"]]); - expect(report.skipped).toEqual(["tailsTest/lead", "tailsTest/watch"]); + expect(report.failures.map(failure => [failure.kind, failure.name])).toEqual([["thread", "lead"], ["thread", "watch"]]); + expect(report.skipped).toEqual(["lead", "watch"]); expect(await runReported(world, ticked)).toEqual({ failures: [], skipped: [], dropped: 0, warnings: [] }); expect(seen.map(({ counter, rows, wrong }) => [counter, rows, wrong])).toEqual([[1, 10, 0], [3, 10, 0]]); // A registration waits for tails in flight, of which a thread that's gone has none. diff --git a/packages/ecs/src/implementation/world/tails.ts b/packages/ecs/src/implementation/world/tails.ts index 012667d87..1931267e2 100644 --- a/packages/ecs/src/implementation/world/tails.ts +++ b/packages/ecs/src/implementation/world/tails.ts @@ -1,11 +1,12 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { Leaf } from '../../interface/system/leaf.js'; import type { Failure } from '../../interface/report/failure.js'; -import { declarationOf, firstKeyOf, labelOfItem } from '../../interface/registry/index.js'; +import { declarationOf, firstKeyOf } from '../../interface/registry/index.js'; import { layoutOf, type Layout } from '../../interface/registry/layout.js'; import { accessOf, type Schedule } from '../../interface/registry/schedule.js'; import { stampChanged, type Archetype } from './archetype.js'; import { readCell } from './cells.js'; +import { kernelOf } from './attach-kernel.js'; import { indexOf, locate } from './entities.js'; import { failureOf } from './failures.js'; import { ownTag } from './move-log.js'; @@ -38,9 +39,9 @@ export const isTerminal = (leaf: Leaf): boolean => { const known = terminalLeaves.get(leaf); if (known !== undefined) return known; const { declaration } = leaf; - const terminal = leaf.kind === "system" && declaration.kernel === undefined && declaration.up === undefined && provenTerminal(leaf.body) + const terminal = leaf.kind === "system" && kernelOf(leaf) === undefined && declaration.up === undefined && provenTerminal(leaf.body) && Object.keys(declaration.lookup ?? {}).length === 0 - && (declaration.reduce === undefined || Object.getOwnPropertySymbols(declaration.reduce).length === 0) + && (declaration.resources?.reduce?.length ?? 0) === 0 && objectsOnly(declaration.components?.write) && objectsOnly(declaration.resources?.write); terminalLeaves.set(leaf, terminal); return terminal; @@ -130,7 +131,7 @@ const isStruct = (state: State, id: number): boolean => state.layouts[id].kind ! /** A runner's tail, made the first time it runs as one; its block is the channel's, freed with it. */ export const tailOf = (state: State, runner: Runner, leaf: Leaf): Tail => { const remote = runner.remote; - if (remote === null) throw new Error(`System "${labelOfItem(leaf)}" runs as a tail only on a joined thread.`); + if (remote === null) throw new Error(`System "${leaf.name}" runs as a tail only on a joined thread.`); if (remote.tail !== null) return remote.tail; const { components, resources } = leaf.declaration; const ids = (keys: readonly Key[] | undefined): number[] => [...new Set((keys ?? []).map(key => idOf(state, key)))]; @@ -222,10 +223,10 @@ const outcomeOf = async (tasks: readonly Promise<{ readonly done: boolean; reado for (const { done, systems } of await Promise.all(tasks)) { for (const system of systems) { if (!done) { - (failures ??= []).push(failureOf("thread", system.label, system.channel.goneError())); + (failures ??= []).push(failureOf("thread", { name: system.label, id: system.id }, system.channel.goneError())); (skipped ??= []).push(system.label); } - else if (system.result.error !== undefined) (failures ??= []).push(failureOf("system", system.label, system.result.error)); + else if (system.result.error !== undefined) (failures ??= []).push(failureOf("system", { name: system.label, id: system.id }, system.result.error)); } } return { failures, skipped }; @@ -287,11 +288,11 @@ const filtersOf = (tail: Copied, archetype: Archetype): Filter[] => { const filters: Filter[] = []; for (const id of tail.added) { const storage = archetype.storageById[id]; - if (storage !== undefined) filters.push({ ticks: storage.added, chunks: storage.chunkAdded }); + if (storage !== undefined) filters.push({ ticks: storage.added, chunks: storage.chunkAdded, blocks: storage.blockAdded }); } for (const id of tail.changed) { const storage = archetype.storageById[id]; - if (storage !== undefined) filters.push({ ticks: storage.changed, chunks: storage.chunkChanged }); + if (storage !== undefined) filters.push({ ticks: storage.changed, chunks: storage.chunkChanged, blocks: storage.blockChanged }); } return filters; }; @@ -399,7 +400,7 @@ const markOwn = (state: State, tail: Copied, claimed: number): void => { /** * Before a writer of what a tail reads: unless it read everything, copies the rest from the position it reached, - * exchanges its progress for `snapshotted`, and waits for the chunk it claimed. Null when nothing is left to wait for. + * adds `snapshotted` to its progress, and waits for the chunk it claimed. Null when nothing is left to wait for. */ const resolve = (state: State, tail: Tail): Promise | null => { settledTail(state, tail); @@ -407,8 +408,8 @@ const resolve = (state: State, tail: Tail): Promise | null => { }; /** - * Unless the reader read everything, copies the rest of what it reads from the position it reached, exchanges its - * progress for `snapshotted`, and waits for the chunk it claimed. Null when nothing is left to wait for. + * Unless the reader read everything, copies the rest of what it reads from the position it reached, adds `snapshotted` + * to its progress, and waits for the chunk it claimed. Null when nothing is left to wait for. */ export const copyRest = (state: State, tail: Copied): Promise | null => { const { control, end } = tail; @@ -417,7 +418,10 @@ export const copyRest = (state: State, tail: Copied): Promise | null => { const from = Atomics.load(control, progressWord); runs.length = 0; if (from < end) writeSnapshot(state, tail, from); - const claimed = Atomics.exchange(control, progressWord, snapshotted); + // Added rather than exchanged, so a claim before the copy stays in the word: a post a claim reached is never taken + // for one none did (`cancel` in joined-observer.ts). Claims past it, a chunk at a time, keep the word below + // `cancelled` there, `snapshotted` plus at most the end and a chunk, as ends are far below their difference. + const claimed = Atomics.add(control, progressWord, snapshotted); tailWriters.copies++; // Rows the tail claimed while this copied it reads where they are. if (claimed < end) markOwn(state, tail, claimed); diff --git a/packages/ecs/src/implementation/world/target.test.ts b/packages/ecs/src/implementation/world/target.test.ts index a8364fe8a..8de7cf417 100644 --- a/packages/ecs/src/implementation/world/target.test.ts +++ b/packages/ecs/src/implementation/world/target.test.ts @@ -1,6 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; -import { Component, Entity, System, workers } from "../../index.js"; +import { Entity, System, workers } from "../../index.js"; import { createWorld } from "./create.js"; const amount = Symbol("targetTest/amount"); @@ -11,25 +11,21 @@ declare module "@adobe/ecs" { [tagged]: { type: true }; } } -Component.register(amount, { type: { type: "number" } }); -Component.register(tagged, { type: { const: true } }); - -const one = { type: "object", properties: { entity: Entity.schema } } as const; let spawned: Entity[] = []; -const spawn = System.create({ name: "targetTest/spawn" }, ({ commands }) => { +const spawn = System.create({}, ({ commands }) => { spawned = [commands.spawn([amount, tagged])({ [amount]: 1 }), commands.spawn([amount, tagged])({ [amount]: 2 }), commands.spawn([amount])({ [amount]: 3 })]; }); -const bump = System.create({ name: "targetTest/bump", input: one, target: "entity", components: { write: [amount], has: [tagged] } }, row => { +const bump = System.create({ target: "entity", components: { with: [amount, tagged] } }, (row, _context, input: { readonly entity: Entity }) => { row[amount] += 10; }); -const remove = System.create({ name: "targetTest/remove", input: one, target: "entity", components: { has: [tagged] } }, (row, { commands }) => { +const remove = System.create({ target: "entity", components: { with: [tagged] } }, (row, { commands }, _input: { readonly entity: Entity }) => { commands.despawn(row.entity); }); let amounts: number[] = []; let changed: number[] = []; -const readAmounts = System.create({ name: "targetTest/readAmounts", observer: true, components: { read: [amount] } }, row => { amounts.push(row[amount]); }); -const watchChanged = System.create({ name: "targetTest/watchChanged", observer: true, components: { read: [amount], changed: [amount] } }, row => { changed.push(row[amount]); }); +const readAmounts = System.create({ observer: true, components: { with: [amount] } }, row => { amounts.push(row[amount]); }); +const watchChanged = System.create({ observer: true, components: { with: [amount], changed: [amount] } }, row => { changed.push(row[amount]); }); const inline = () => createWorld({ [workers]: 0 }); const amountsOf = async (world: ReturnType): Promise => { @@ -66,9 +62,9 @@ describe("systems on one entity", () => { it("take their entity from an entity field of their input, and have components, no kernel, up, order or reduce", () => { // @ts-expect-error its target isn't an entity field - expect(() => System.create({ name: "targetTest/notEntity", input: { type: "object", properties: { count: { type: "number" } } }, target: "count", components: { has: [amount] } }, () => {})) - .toThrow('System "targetTest/notEntity" runs on one entity, so it takes it from an input field "count" holding an entity.'); + expect(() => System.create({ target: "count", components: { with: [amount] } }, (_row, _context, input: { readonly count: number }) => {})) + .toThrow('runs on one entity, so it takes it from an input field "count" holding an entity.'); // @ts-expect-error it needs components - expect(() => System.create({ name: "targetTest/noComponents", input: one, target: "entity" }, () => {})).toThrow('System "targetTest/noComponents" runs on one entity, so it needs components.'); + expect(() => System.create({ target: "entity" }, (_context, input: { readonly entity: Entity }) => {})).toThrow('runs on one entity, so it needs components.'); }); }); diff --git a/packages/ecs/src/implementation/world/thread-directory.ts b/packages/ecs/src/implementation/world/thread-directory.ts index 220fc0a15..950087720 100644 --- a/packages/ecs/src/implementation/world/thread-directory.ts +++ b/packages/ecs/src/implementation/world/thread-directory.ts @@ -6,10 +6,11 @@ import { emptyViews } from './arrays.js'; import { tableLength } from './entities.js'; import { archetypeField, capacityField, cursorField, entitiesVersionField, entitiesWord, freeCapacityField, freeField, generationField, linearFlag, - recordCapacity, recordCount, recordEntities, recordFlags, recordStorages, recordVersion, recordWords, metaFlag, remoteFlag, resourceEntityWord, resourceFlag, - rowField, storageAdded, storageChanged, storageChunkAdded, storageChunkChanged, storageChunks, storageColumn, storageComponent, storageFlags, - storageWords, tableLengthWord, tableOffsetWord, threadShift, tickedFlag, + recordCapacity, recordCount, recordEntities, recordFlags, recordStorages, recordVersion, recordWords, metaFlag, remoteFlag, replacedWord, resourceEntityWord, resourceFlag, + rowField, storageAdded, storageBlockAdded, storageBlockChanged, storageChanged, storageChunkAdded, storageChunkChanged, storageChunks, storageColumn, + storageComponent, storageFlags, storageWords, tableLengthWord, tableOffsetWord, threadShift, tickedFlag, } from './layout-directory.js'; +import { blockShift, chunkShift } from './kernel.js'; import { coveringBuffer, pageBytes, pagesWord } from './memory.js'; import { createStringReader } from './strings.js'; import type { Mirror, Mirrored } from './mirror.js'; @@ -66,12 +67,38 @@ export const createThreadDirectory = (memory: WebAssembly.Memory, mirror: Mirror const thread = mirror?.thread ?? -1; /** The records strings' columns name, which this thread reads. */ const strings = createStringReader(memory); + /** + * Moved on whenever a view `archetypeAt` gave a row may have been made again: the world replaced an archetype's + * arrays, the views were dropped, or the mirror rebuilt. Index lookups' rows stay bound while it stays the same, so + * they check it rather than each archetype for every row. A mirror that begins holding an archetype needs no move: + * it does from the first entry naming it, replayed before any run reads a row there. + */ + const replaced = { count: 0 }; + /** The directory's `replacedWord`, and the mirror's archetypes, as this thread last saw them. */ + let replacedSeen = 0; + let mirroredSeen: readonly (Mirrored | null | undefined)[] = noMirrored; + + /** + * Moves `replaced` on if any archetype's view may have changed since it last looked: once a run, before it reads + * rows, since the world replaces arrays only at a sync point or a move outside one, which wait for a run in progress. + */ + const settle = (): void => { + const word = Atomics.load(viewed.words, replacedWord); + const mirrored = mirror?.archetypes ?? noMirrored; + if (word === replacedSeen && mirrored === mirroredSeen) return; + replacedSeen = word; + mirroredSeen = mirrored; + replaced.count++; + }; - /** Views of the memory again, once it grew; every archetype's views with them. Returns its buffer. */ + /** Views of the memory again, once it grew, every archetype's with them, and `replaced` settled. Returns its buffer. */ const refresh = (): ArrayBufferLike => { if (viewed.words.length === 0) viewed.words = new Uint32Array(memory.buffer); const current = Atomics.load(viewed.words, pagesWord); - if (current === pages) return viewed.buffer; + if (current === pages) { + settle(); + return viewed.buffer; + } const latest = coveringBuffer(memory, current * pageBytes); if (latest.byteLength < current * pageBytes) throw new Error(`This thread's memory is ${latest.byteLength} bytes, short of the ${current * pageBytes} the world stored.`); viewed.buffer = latest; @@ -79,7 +106,10 @@ export const createThreadDirectory = (memory: WebAssembly.Memory, mirror: Mirror viewed.views = viewsOf(latest, 0, latest.byteLength); pages = current; archetypes.length = 0; + // Defensive: rows bound to the old views still read the same shared memory, and arrays that moved bumped `replacedWord`. + replaced.count++; entitiesVersion = -1; + settle(); return latest; }; @@ -133,6 +163,8 @@ export const createThreadDirectory = (memory: WebAssembly.Memory, mirror: Mirror changed: layout.kind === "constant" ? added : changed, chunkAdded: ticked ? new Float64Array(buffer, words[base + storageChunkAdded], chunks) : noTicks, chunkChanged: ticked ? new Float64Array(buffer, words[layout.kind === "constant" ? base + storageChunkAdded : base + storageChunkChanged], chunks) : noTicks, + blockAdded: ticked ? new Float64Array(buffer, words[base + storageBlockAdded], chunks << (chunkShift - blockShift)) : noTicks, + blockChanged: ticked ? new Float64Array(buffer, words[layout.kind === "constant" ? base + storageBlockAdded : base + storageBlockChanged], chunks << (chunkShift - blockShift)) : noTicks, }; }; @@ -191,6 +223,8 @@ export const createThreadDirectory = (memory: WebAssembly.Memory, mirror: Mirror layouts[number] = layoutOf(declarationOf(key)?.type); // Views hold storages only of components with layouts here, so they're made again. archetypes.length = 0; + // Defensive: a lookup's read keys are named before its runner and pool exist, so no bound row lacks a storage. + replaced.count++; } } }; @@ -202,7 +236,7 @@ export const createThreadDirectory = (memory: WebAssembly.Memory, mirror: Mirror }; return { - viewed, rows, keys, layouts, numbers, mirror, strings, + viewed, rows, keys, layouts, numbers, mirror, strings, replaced, refresh, refreshEntities, archetypeAt, countOf, resourceRow, tableCount, name, numberOf, layoutOf: (key: Key): Layout | undefined => layouts[numbers.get(firstKeyOf(key)) ?? -1], }; diff --git a/packages/ecs/src/implementation/world/thread-finalizers.ts b/packages/ecs/src/implementation/world/thread-finalizers.ts index 0c42a704f..d92702300 100644 --- a/packages/ecs/src/implementation/world/thread-finalizers.ts +++ b/packages/ecs/src/implementation/world/thread-finalizers.ts @@ -6,12 +6,12 @@ import { writeOf } from './archetype.js'; import { emptyViews } from './arrays.js'; import { readCell } from './cells.js'; import { logFinalizedWord, logStopsWord, nullTag, ownTag, slotTags, startTag } from './move-log.js'; -import { stagedValue, type Mirror, type Previously } from './mirror.js'; +import { resourceValueOf, stagedValue, type Mirror, type Previously } from './mirror.js'; import type { WorkerError } from './protocol.js'; import { rowBinderOf, type Row, type RowArchetype, type RowHost, type RowStorage } from './rows.js'; import { archetypesAt, blockGroupsWord, blockHeaderBytes, blockNextWord, blockSyncWord, columnBytes, entitiesAt, groupBytesWord, groupCountWord, - groupIndexWord, rowsAt, tagsAt, valuesAt, + groupIndexWord, resourceOffsetsOf, rowsAt, tagsAt, valuesAt, } from './stop-lists.js'; import type { Key } from './state.js'; import type { StringStore } from './strings.js'; @@ -62,15 +62,20 @@ type ThreadFinalizer = { readonly constants: readonly number[]; readonly constantLayouts: readonly Layout[]; readonly bind: (archetype: RowArchetype) => Row; + /** Its optional components, which a stop's row has only where the entity's archetype did. */ + readonly optional: readonly number[]; readonly context: object; - /** Resolves its resources' cells, as of now. */ - readonly cells: () => void; + /** + * Resolves its resources: its thread's objects as it holds them, and the rest from the copies a group holds from + * byte `copies`, as before its stops' sync point applied, or with -1, once the world is disposed, where they are. + */ + readonly cells: (copies: number) => void; readonly body: (row: object, context: object) => void; }; /** A storage a stop list's column holds, for a row over its stops. */ const columnStorage = (component: number, layout: Layout, views: RowStorage["views"], values: unknown[], strings: StringStore | null): FinalizedStorage => ({ - component, layout, views, strings: layout.kind === "string" ? strings : null, values, ...writeOf(layout), ticked: false, added: noTicks, changed: noTicks, chunkAdded: noTicks, chunkChanged: noTicks, tags: noTags, + component, layout, views, strings: layout.kind === "string" ? strings : null, values, ...writeOf(layout), ticked: false, added: noTicks, changed: noTicks, chunkAdded: noTicks, chunkChanged: noTicks, blockAdded: noTicks, blockChanged: noTicks, tags: noTags, }); /** @@ -128,11 +133,44 @@ export const createThreadFinalizers = (host: Threa const keys = [...new Set(resources?.read ?? [])]; const numbers = keys.map(host.numberOf); const resolved: { storage: FinalizedStorage | undefined; row: number }[] = keys.map(() => ({ storage: undefined, row: 0 })); + // Its thread's object resources are read from the mirror, which may be behind the entity table as it replays. + const own = keys.map(key => host.layoutOf(key)?.kind === "object"); + const resourceStarts = numbers.map(number => mirror.starts.get(number) ?? null); + const ownValues: unknown[] = keys.map(() => undefined); + const shared = own.includes(false); + // The world copies the rest for each group, in this order: by slot, its strings' handles, then its linear + // values, each with its key's index and layout. A constant needs no copy. + const stringKeys: number[] = []; + const stringLayouts: Layout[] = []; + const linearKeys: number[] = []; + const linearLayouts: Layout[] = []; + const linearStrides: number[] = []; + const constantKeys: number[] = []; + const constantStorages: FinalizedStorage[] = []; + keys.forEach((key, k) => { + const layout = host.layoutOf(key); + if (layout === undefined || layout.kind === "object") return; + if (layout.kind === "string") { + stringKeys.push(k); + stringLayouts.push(layout); + } + else if (layout.kind === "linear") { + linearKeys.push(k); + linearLayouts.push(layout); + linearStrides.push(layout.stride); + } + else { + constantKeys.push(k); + constantStorages.push(columnStorage(numbers[k], layout, emptyViews, [], null)); + } + }); + const resourceOffsets = resourceOffsetsOf(stringKeys.length, linearStrides); if (keys.length > 0) { const values: object = Object.create({}); keys.forEach((key, k) => { Object.defineProperty(values, key, { get: () => { + if (own[k]) return ownValues[k]; const { storage, row } = resolved[k]; if (storage === undefined) throw new Error(`Resource "${labelOf(key)}" isn't in this world.`); return readCell(storage, row); @@ -146,15 +184,41 @@ export const createThreadFinalizers = (host: Threa const untyped: { body(row: object, context: object): void } = leaf; return { leaf, objects, starts, linear, layouts, constants, constantLayouts, - required: [...new Set([...reads, ...components.has ?? []])].map(host.numberOf), + required: [...new Set([...reads, ...components.has ?? []])].filter(key => !(components.optional ?? []).includes(key)).map(host.numberOf), excluded: (components.not ?? []).map(host.numberOf), - bind: rowBinderOf(host.rows, host.numberOf, reads, []), + optional: (components.optional ?? []).map(host.numberOf), + bind: rowBinderOf(host.rows, host.numberOf, reads, [], components.optional), context: value, - cells: () => { - const found = keys.length === 0 ? null : host.resourceRow(); - for (let k = 0; k < keys.length; k++) { - resolved[k].storage = found?.archetype.storageById[numbers[k]]; - resolved[k].row = found?.row ?? 0; + cells: copies => { + // Before a rebuild too, its old rows are the mirror's, which hold what the rebuild keeps. + for (let k = 0; k < keys.length; k++) if (own[k]) ownValues[k] = resourceValueOf(mirror, numbers[k], resourceStarts[k]); + if (!shared) return; + if (copies < 0) { + const found = host.resourceRow(); + for (let k = 0; k < keys.length; k++) { + if (own[k]) continue; + resolved[k].storage = found?.archetype.storageById[numbers[k]]; + resolved[k].row = found?.row ?? 0; + } + return; + } + if (stringKeys.length > 0) { + // One view over every handle, a slot each. + const views = viewsOf(buffer, copies, 4 * stringKeys.length); + for (let slot = 0; slot < stringKeys.length; slot++) { + const k = stringKeys[slot]; + resolved[k].storage = columnStorage(numbers[k], stringLayouts[slot], views, [], host.strings); + resolved[k].row = slot; + } + } + for (let slot = 0; slot < linearKeys.length; slot++) { + const k = linearKeys[slot]; + resolved[k].storage = columnStorage(numbers[k], linearLayouts[slot], viewsOf(buffer, copies + resourceOffsets[slot], linearStrides[slot]), [], null); + resolved[k].row = 0; + } + for (let slot = 0; slot < constantKeys.length; slot++) { + resolved[constantKeys[slot]].storage = constantStorages[slot]; + resolved[constantKeys[slot]].row = 0; } }, body: (row, context) => untyped.body(row, context), @@ -235,11 +299,38 @@ export const createThreadFinalizers = (host: Threa column += columnBytes(count, stride); } for (let k = 0; k < finalizer.constants.length; k++) storageById[finalizer.constants[k]] = columnStorage(finalizer.constants[k], finalizer.constantLayouts[k], emptyViews, [], null); - const row = finalizer.bind({ id: -1, entities: new Float64Array(buffer, entitiesAt(group), count), storageById }); - finalizer.cells(); - for (let i = 0; i < count; i++) { - row.index = i; - guarded(index, finalizer, row); + const entities = new Float64Array(buffer, entitiesAt(group), count); + finalizer.cells(column); + if (finalizer.optional.length === 0) { + const row = finalizer.bind({ id: -1, entities, storageById }); + for (let i = 0; i < count; i++) { + row.index = i; + guarded(index, finalizer, row); + } + } + else { + // A stop's row has an optional component only where its archetype did: rows run a run of one + // archetype's stops at a time, and archetypes with the same ones share their copy of the columns. + const byPattern = new Map(); + for (let i = 0; i < count;) { + const id = u32[archetypeWords + i]; + let end = i + 1; + while (end < count && u32[archetypeWords + end] === id) end++; + const components = host.archetypeAt(id)?.components ?? []; + let pattern = 0; + finalizer.optional.forEach((component, k) => { if (components.includes(component)) pattern |= 1 << k; }); + let archetype = byPattern.get(pattern); + if (archetype === undefined) { + const storages: (FinalizedStorage | undefined)[] = [...storageById]; + finalizer.optional.forEach((component, k) => { if ((pattern & (1 << k)) === 0) storages[component] = undefined; }); + byPattern.set(pattern, archetype = { id: -1, entities, storageById: storages }); + } + const row = finalizer.bind(archetype); + for (; i < end; i++) { + row.index = i; + guarded(index, finalizer, row); + } + } } group += bytes; } @@ -297,7 +388,7 @@ export const createThreadFinalizers = (host: Threa for (let index = 0; index < finalizers.length; index++) { const finalizer = finalizers[index]; if (finalizer === null || finalizer === undefined) continue; - finalizer.cells(); + finalizer.cells(-1); for (let id = 0; id < host.tableCount(); id++) { const archetype = host.archetypeAt(id); if (archetype === undefined || archetype.resource || (archetype.meta && finalizer.required.length === 0)) continue; diff --git a/packages/ecs/src/implementation/world/thread-indexes.ts b/packages/ecs/src/implementation/world/thread-indexes.ts index 27c224a4c..96dc2a058 100644 --- a/packages/ecs/src/implementation/world/thread-indexes.ts +++ b/packages/ecs/src/implementation/world/thread-indexes.ts @@ -39,19 +39,20 @@ const u32From = (buffer: ArrayBufferLike, at: number): Uint32Array => new Uint32 const i32From = (buffer: ArrayBufferLike, at: number): Int32Array => new Int32Array(buffer, at); const f64From = (buffer: ArrayBufferLike, at: number): Float64Array => new Float64Array(buffer, at); -export const createThreadIndex = (rows: RowHost, archetypeAt: (id: number) => LookupArchetype | undefined, setup: ThreadIndexSetup): ThreadIndex => { +export const createThreadIndex = (rows: RowHost, archetypeAt: (id: number) => LookupArchetype | undefined, replaced: { readonly count: number }, setup: ThreadIndexSetup): ThreadIndex => { const { parts, words } = keyPartsOf(setup.layouts); const watchedPositions = Array.from({ length: setup.watched }, (_, k) => k); const hashWatched = new Map(); const sortedWatched = new Map(); - const hash: HashView | null = setup.sorted ? null : { words, stride: recordKey + words, slots: none32, records: none32, ticks: none64, cleared: 0, entries: noneI32, watched: hashWatched }; + // The world sorts a hash index's buckets before writing its record, so this view has none unsorted. + const hash: HashView | null = setup.sorted ? null : { words, stride: recordKey + words, slots: none32, records: none32, ticks: none64, cleared: 0, entries: noneI32, unsorted: 0, watched: hashWatched }; const sorted: SortedView | null = !setup.sorted ? null : { words, stride: sortedStride(words), parts, objects: false, entries: none32, lengths: noneI32, order: noneI32, blocks: 0, counts: noneI32, tree: none64, leaves: 1, fences: none32, size: 0, cleared: 0, watched: sortedWatched, }; const index: IndexView = { parts, words, unique: setup.unique, hash, sorted }; const since: Since = { value: 0 }; - const lookup = lookupFunction({ rows, archetypeAt, index, unique: setup.unique, reads: setup.reads, readIds: setup.readIds, watched: watchedPositions, since, replaced: null }); + const lookup = lookupFunction({ rows, archetypeAt, index, unique: setup.unique, reads: setup.reads, readIds: setup.readIds, watched: watchedPositions, since, replaced }); const count = recordWordsOf(setup.sorted, setup.watched); let record: Float64Array = none64; const refresh = (buffer: ArrayBufferLike): void => { diff --git a/packages/ecs/src/implementation/world/thread-observer.ts b/packages/ecs/src/implementation/world/thread-observer.ts index ea6ecb2fd..8bd5a39da 100644 --- a/packages/ecs/src/implementation/world/thread-observer.ts +++ b/packages/ecs/src/implementation/world/thread-observer.ts @@ -1,7 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { Commands } from '../../interface/system/commands.js'; import type { Leaf } from '../../interface/system/leaf.js'; -import { definitionOf, isLeaf, itemOfId, labelOfItem } from '../../interface/registry/index.js'; +import { definitionOf, idOrNull, isLeaf, itemOfId } from '../../interface/registry/index.js'; import { cancelled, notRun, opening, outcomeWord, postedWord, removedWord, runsWord, observerBlockWords, syncWord, tickSlot } from './joined-observer.js'; import { protocolName, type ObserverSetup, type ToWorld } from './joining.js'; import type { Mirror } from './mirror.js'; @@ -50,6 +50,8 @@ type Block = { readonly control: Int32Array; readonly ticks: Float64Array; handl type Placed = { readonly index: number; readonly label: string; + /** The id the plugin gave it, which reports give beside its label. */ + readonly id: string | null; readonly blocks: readonly Block[]; /** Whether its posts read only their blocks, never this thread's objects. */ readonly contained: boolean; @@ -73,7 +75,7 @@ export const createThreadObserver = (host: ThreadObserverHost) => { const runnerHost: RunnerHost = { rows: directory.rows, archetypeAt: directory.archetypeAt, countOf: directory.countOf, tableCount: directory.tableCount, resourceRow: directory.resourceRow, refreshEntities: directory.refreshEntities, commands: noCommands, - numberOf: directory.numberOf, layoutOf: directory.layoutOf, mirror, refresh: directory.refresh, strings: directory.strings, + numberOf: directory.numberOf, layoutOf: directory.layoutOf, mirror, refresh: directory.refresh, replaced: directory.replaced, strings: directory.strings, }; const placed: (Placed | undefined)[] = []; @@ -92,13 +94,13 @@ export const createThreadObserver = (host: ThreadObserverHost) => { if (own === undefined) continue; own.runner = createThreadRunner(runnerHost, own.leaf, records); own.placed = true; - placed[index] = { index, label: labelOfItem(own.leaf), blocks, contained, runner: null, local: new WeakRef(own), stopped: false }; + placed[index] = { index, label: own.leaf.name, id: idOrNull(own.leaf), blocks, contained, runner: null, local: new WeakRef(own), stopped: false }; continue; } const item = id === null ? undefined : itemOfId(id); const definition = item === undefined ? undefined : definitionOf(item); const leaf = definition !== undefined && isLeaf(definition) ? definition : null; - placed[index] = { index, label: leaf === null ? String(id) : labelOfItem(leaf), blocks, contained, runner: leaf === null ? null : createThreadRunner(runnerHost, leaf, records), local: null, stopped: false }; + placed[index] = { index, label: leaf === null ? String(id) : leaf.name, id: leaf === null ? null : idOrNull(leaf), blocks, contained, runner: leaf === null ? null : createThreadRunner(runnerHost, leaf, records), local: null, stopped: false }; } }; diff --git a/packages/ecs/src/implementation/world/thread-systems.cases.ts b/packages/ecs/src/implementation/world/thread-systems.cases.ts index 79f1f7ffc..8aaca95ee 100644 --- a/packages/ecs/src/implementation/world/thread-systems.cases.ts +++ b/packages/ecs/src/implementation/world/thread-systems.cases.ts @@ -14,13 +14,13 @@ export const threadSystemsCases = (load: () => Promise) => descr it("are skipped with a \"thread\" failure when the worker dies mid-task, and run on the world's thread once it's gone", async () => { const fixture = await load(); expect(await fixture.diesMidTask()).toEqual({ - report: { failures: [["thread", "threadSystemsTest/stall", 'Thread "threadSystemsTest" is gone, so its systems don\'t run.']], skipped: ["threadSystemsTest/stall"] }, + report: { failures: [["thread", "stall", 'Thread "threadSystemsTest" is gone, so its systems don\'t run.']], skipped: ["stall"] }, after: 0, }); }, 30000); it("can't join claiming the thread of a system the joined thread never imported", async () => { const fixture = await load(); - expect(await fixture.unimported()).toBe('System "threadSystemsTest/onlyHere" runs on thread "threadSystemsTest", which the joined thread claiming it never imported.'); + expect(await fixture.unimported()).toBe('System "onlyHere" runs on thread "threadSystemsTest", which the joined thread claiming it never imported.'); }, 30000); it("don't stall ticks that don't touch their thread when it never yields", async () => { const fixture = await load(); diff --git a/packages/ecs/src/implementation/world/thread-systems.fixture.ts b/packages/ecs/src/implementation/world/thread-systems.fixture.ts index b5a6e4dd0..9b10af649 100644 --- a/packages/ecs/src/implementation/world/thread-systems.fixture.ts +++ b/packages/ecs/src/implementation/world/thread-systems.fixture.ts @@ -16,7 +16,7 @@ import { reportOf, runReported } from "./reports.test-helper.js"; */ /** A system on the worker's thread, which only this module declares, so the worker never imported it. */ -const onlyHere = System.create({ name: "threadSystemsTest/onlyHere", components: { read: [caption] } }, row => { void row[caption]; }); +const onlyHere = System.create({ components: { with: [caption] } }, row => { void row[caption]; }); type Worker = { readonly ask: (message: ToThreadSystemsWorker, transfer?: readonly unknown[]) => Promise; diff --git a/packages/ecs/src/implementation/world/thread-systems.test-helper.ts b/packages/ecs/src/implementation/world/thread-systems.test-helper.ts index 93451c1a8..8948c663e 100644 --- a/packages/ecs/src/implementation/world/thread-systems.test-helper.ts +++ b/packages/ecs/src/implementation/world/thread-systems.test-helper.ts @@ -1,11 +1,10 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Component } from "../../interface/component/component.js"; import type { Entity } from "../../interface/entity/entity.js"; import { Observe } from "../../observe/index.js"; -import { Resource } from "../../interface/resource/resource.js"; import { System } from "../../interface/system/system.js"; import type { World } from "../../interface/world/world.js"; import { runReported, type Reported } from "./reports.test-helper.js"; +import type { Schema } from "../../interface/schema/schema.js"; // Keys and systems both threads of the thread systems fixtures import: systems on thread "threadSystemsTest", which a // joined thread fills, beside systems on the world's thread, and what each world holds after the same runs. @@ -28,46 +27,33 @@ declare module "@adobe/ecs" { threadSystemsTest: true; } export interface Components { - [uid]: { type: number }; + [uid]: { type: Schema.Type<{ type: "integer" }> }; [position]: { type: readonly [number, number] }; [velocity]: { type: number }; [flagged]: { type: true }; - [caption]: { type: { readonly text: string } | null; thread: "threadSystemsTest" }; + [caption]: { type: { readonly text: string } | null; thread: "threadSystemsTest"; default: { text: "none" } }; [note]: { type: Note | null; thread: "threadSystemsTest" }; [kept]: { type: string }; } export interface Resources { [total]: { type: number; reduce: "+" }; - [changes]: { type: number }; - [paint]: { type: { readonly text: string }; thread: "threadSystemsTest" }; - [painted]: { type: number }; - [enabled]: { type: boolean }; - [nextUid]: { type: number }; + [changes]: { type: Schema.Type<{ type: "integer" }>; default: 0 }; + [paint]: { type: { readonly text: string }; thread: "threadSystemsTest"; default: { text: "plain" } }; + [painted]: { type: Schema.Type<{ type: "integer" }>; default: 0 }; + [enabled]: { type: boolean; default: true }; + [nextUid]: { type: Schema.Type<{ type: "integer" }>; default: 1 }; } } /** A value that isn't data, which only its thread holds. */ type Note = { readonly text: string; readonly read: () => string }; -Component.register(uid, { type: { type: "integer" } }); -Component.register(position, { type: { type: "array", items: { type: "number" }, minItems: 2, maxItems: 2 } }); -Component.register(velocity, { type: { type: "number" } }); -Component.register(flagged, { type: { const: true } }); const text = { type: "object", properties: { text: { type: "string" } } } as const; -Component.register(caption, { type: { anyOf: [text, { type: "null" }], default: { text: "none" } }, thread: "threadSystemsTest" }); -Component.register(note, { thread: "threadSystemsTest" }); /** An object of the world's thread. */ -Component.register(kept, { type: { type: "string" } }); -Resource.register(total, { type: { type: "number", default: 0 }, reduce: "+" }); -Resource.register(changes, { type: { type: "integer", default: 0 } }); /** An object resource of the thread, which only systems there read and write. */ -Resource.register(paint, { type: { ...text, default: { text: "plain" } }, thread: "threadSystemsTest" }); -Resource.register(painted, { type: { type: "integer", default: 0 } }); -Resource.register(enabled, { type: { type: "boolean", default: true } }); -Resource.register(nextUid, { type: { type: "integer", default: 1 } }); const makeNote = (text: string): Note => ({ text, read: () => text }); /** Spawns from the world's thread: structs and its own object, the thread's objects at their starts. */ -export const seed = System.create({ name: "threadSystemsTest/seed", input: { type: "object", properties: { count: { type: "integer" } } }, resources: { write: [nextUid] } }, ({ input, resources, commands }) => { +export const seed = System.create({}, ({ resources, commands }, input: { readonly count: number }) => { for (let k = 0; k < input.count; k++) { const id = resources[nextUid]++; commands.spawn([uid, position, velocity, caption, kept])({ [uid]: id, [position]: [id, -id], [velocity]: id % 3, [kept]: `k${id}` }); @@ -75,18 +61,18 @@ export const seed = System.create({ name: "threadSystemsTest/seed", input: { typ }); /** On the world's thread: positions move by their velocity. */ -export const move = System.create({ name: "threadSystemsTest/move", components: { read: [velocity], write: [position] } }, row => { +export const move = System.create({ components: { with: [velocity, position] } }, row => { const [x, y] = row[position]; row[position] = [x + row[velocity], y - row[velocity]]; }); /** On the thread: captions written in place, from the structs it reads. */ -export const describe = System.create({ name: "threadSystemsTest/describe", components: { read: [position, velocity], write: [caption] }, after: [move] }, row => { +export const describe = System.create({ components: { with: [position, velocity, caption] }, after: [move] }, row => { if (row[velocity] !== 0) row[caption] = { text: `at ${row[position][0]}` }; }); /** On the thread: commands giving its objects values it stages, struct values and tags, and despawns. */ -export const relabel = System.create({ name: "threadSystemsTest/relabel", components: { read: [uid, caption, position], has: [velocity] }, after: [describe] }, (row, { commands }) => { +export const relabel = System.create({ components: { with: [uid, caption, position, velocity] }, after: [describe] }, (row, { commands }) => { const id = row[uid]; if (row[position][0] > 40) { commands.despawn(row.entity); @@ -99,58 +85,58 @@ export const relabel = System.create({ name: "threadSystemsTest/relabel", compon }); /** On the thread, reading its object resource: spawns giving its objects values, with a struct resource's next uid. */ -export const breed = System.create({ name: "threadSystemsTest/breed", resources: { read: [paint], write: [nextUid] }, after: [relabel] }, ({ resources, commands }) => { +export const breed = System.create({ after: [relabel] }, ({ resources, commands }) => { const id = resources[nextUid]++; commands.spawn([uid, position, velocity, caption, note])({ [uid]: id, [position]: [0, id], [velocity]: 1, [caption]: { text: `${resources[paint].text} ${id}` }, [note]: makeNote(`bred ${id}`) }); }); /** On the thread: a reduction over its objects and structs. */ -export const tally = System.create({ name: "threadSystemsTest/tally", components: { read: [caption, position] }, reduce: { [total]: "+" }, after: [breed] }, (row, { reduce }) => { +export const tally = System.create({ components: { with: [caption, position] }, after: [breed] }, (row, { reduce }) => { reduce[total]((row[caption]?.text.length ?? 0) + row[position][1]); }); /** On the thread: a change filter on its own objects, writing a struct resource. */ -export const watchCaptions = System.create({ name: "threadSystemsTest/watchCaptions", components: { changed: [caption] }, resources: { write: [changes] }, after: [tally] }, (_row, { resources }) => { +export const watchCaptions = System.create({ components: { changed: [caption] }, after: [tally] }, (_row, { resources }) => { resources[changes] += 1; }); /** On the thread, while `enabled` holds: writes its object resource, and a struct resource from it. */ -export const repaint = System.create({ name: "threadSystemsTest/repaint", resources: { write: [paint, painted], is: [enabled] }, after: [watchCaptions] }, ({ resources }) => { +export const repaint = System.create({ resources: { is: [enabled] }, after: [watchCaptions] }, ({ resources }) => { resources[paint] = { text: `${resources[paint].text}+` }; resources[painted] = resources[paint].text.length; }); /** On the thread: a lookup reading an entity's structs and this thread's objects. */ -export const peek = System.create({ name: "threadSystemsTest/peek", components: { has: [uid, note] }, lookup: { self: { read: [position, caption] } }, after: [repaint] }, (row, { lookup, commands }) => { +export const peek = System.create({ components: { with: [uid, note] }, lookup: { self: { with: [position, caption] } }, after: [repaint] }, (row, { lookup, commands }) => { const self = lookup.self(row.entity); if (self !== null && ((self[caption]?.text.length ?? 0) + self[position][1]) % 2 === 1) commands.add(row.entity, flagged); }); /** On the world's thread, beside `tally`: a world object from the structs. */ -export const keep = System.create({ name: "threadSystemsTest/keep", components: { read: [uid], write: [kept], added: [kept] }, after: [breed], before: [watchCaptions] }, row => { +export const keep = System.create({ components: { with: [uid, kept], added: [kept] }, after: [breed], before: [watchCaptions] }, row => { row[kept] = `${row[kept]}.${row[uid] % 10}`; }); /** On the thread, failing after its commands: later tiers are skipped, and its commands still apply. */ -export const fail = System.create({ name: "threadSystemsTest/fail", components: { read: [uid, caption] } }, (row, { commands }) => { +export const fail = System.create({ components: { with: [uid, caption] } }, (row, { commands }) => { commands.add(row.entity, flagged); if (row[uid] === 3 && row[caption] !== undefined) throw new Error("threadSystemsTest failure"); }); -export const afterFail = System.create({ name: "threadSystemsTest/afterFail", components: { read: [caption] }, after: [fail] }, (row, { commands }) => { +export const afterFail = System.create({ components: { with: [caption] }, after: [fail] }, (row, { commands }) => { if (row[caption] !== undefined) commands.despawn(row.entity); }); -export const failing = System.create({ name: "threadSystemsTest/failing", systems: [fail, afterFail] }); +export const failing = System.create({ systems: [fail, afterFail] }); /** A tick's systems, on both threads, in tiers some of which run on the joined thread alone, one after another. */ -export const tick = System.create({ name: "threadSystemsTest/tick", systems: [move, describe, relabel, breed, tally, keep, watchCaptions, repaint, peek] }); +export const tick = System.create({ systems: [move, describe, relabel, breed, tally, keep, watchCaptions, repaint, peek] }); /** On the thread, with input, which comes to it by message. */ -export const stamp = System.create({ name: "threadSystemsTest/stamp", input: { type: "object", properties: { text: { type: "string" } } }, components: { read: [uid], write: [caption] } }, (row, { input }) => { +export const stamp = System.create({ components: { with: [uid, caption] } }, (row, _context, input: { readonly text: string }) => { if (row[uid] === 1) row[caption] = { text: input.text }; }); /** Disables `repaint`, from the world's thread. */ -export const disable = System.create({ name: "threadSystemsTest/disable", resources: { write: [enabled] } }, ({ resources }) => { +export const disable = System.create({}, ({ resources }) => { resources[enabled] = false; }); @@ -186,9 +172,8 @@ const reading: { entities: readonly Entity[]; objects: readonly { readonly [name /** Each entity's objects of the thread, as data, a note by its text: none for an entity that's dead or has no uid. */ const readObjects = System.create({ - name: "threadSystemsTest/readObjects", observer: true, - lookup: { ids: { has: [uid] }, captions: { read: [caption] }, notes: { read: [note] } }, + lookup: { ids: { with: [uid] }, captions: { with: [caption] }, notes: { with: [note] } }, }, ({ lookup }) => { reading.objects = reading.entities.map(entity => { const objects: { [name: string]: unknown } = {}; @@ -256,40 +241,40 @@ export const scenario = async (world: World, objects: Objects = objectsThere(wor }; /** Spawns entities with captions, from the world's thread. */ -export const crowd = System.create({ name: "threadSystemsTest/crowd", input: { type: "object", properties: { count: { type: "integer" } } } }, ({ input, commands }) => { +export const crowd = System.create({}, ({ commands }, input: { readonly count: number }) => { for (let k = 0; k < input.count; k++) commands.spawn([uid, caption])({ [uid]: 1000 + k }); }); /** On the world's thread: every crowd entity but uid 1000 moves archetype, flagged on one run and not on the next. */ -export const flagCrowd = System.create({ name: "threadSystemsTest/flagCrowd", components: { read: [uid], has: [caption], not: [flagged] } }, (row, { commands }) => { +export const flagCrowd = System.create({ components: { with: [uid, caption], not: [flagged] } }, (row, { commands }) => { if (row[uid] !== 1000) commands.add(row.entity, flagged); }); -export const unflagCrowd = System.create({ name: "threadSystemsTest/unflagCrowd", components: { has: [caption, flagged] } }, (row, { commands }) => { +export const unflagCrowd = System.create({ components: { with: [caption, flagged] } }, (row, { commands }) => { commands.remove(row.entity, flagged); }); /** On the thread: writes uid 1000's caption in place. */ -export const write = System.create({ name: "threadSystemsTest/write", components: { read: [uid], write: [caption] } }, row => { +export const write = System.create({ components: { with: [uid, caption] } }, row => { if (row[uid] === 1000) row[caption] = { text: "written" }; }); /** On the thread: counts captions it holds that the thread wrote, into a struct resource. */ -export const countWritten = System.create({ name: "threadSystemsTest/countWritten", components: { read: [caption] }, resources: { write: [changes] } }, (row, { resources }) => { +export const countWritten = System.create({ components: { with: [caption] } }, (row, { resources }) => { if (row[caption]?.text === "written") resources[changes] += 1; }); /** On the thread: gives an object of the world's thread a value, which can't cross, so its command drops and it fails. */ -export const giveKept = System.create({ name: "threadSystemsTest/giveKept", components: { read: [caption] } }, (row, { commands }) => { +export const giveKept = System.create({ components: { with: [caption] } }, (row, { commands }) => { if (row[caption] !== undefined) commands.add(row.entity, kept, "given there"); }); /** On the thread: spawns giving an object of the world's thread a value, which throws when it's issued. */ -export const spawnKept = System.create({ name: "threadSystemsTest/spawnKept", resources: { read: [paint] } }, ({ resources, commands }) => { +export const spawnKept = System.create({}, ({ resources, commands }) => { commands.spawn([uid, kept])({ [uid]: resources[paint].text.length, [kept]: "spawned there" }); }); /** On the thread: runs until its thread ends, so its task never completes. */ -export const stall = System.create({ name: "threadSystemsTest/stall", resources: { read: [paint] } }, ({ resources }) => { +export const stall = System.create({}, ({ resources }) => { while (resources[paint].text !== "never") { /* until the thread ends */ } }); /** On the world's thread: counts its runs, ticking with nothing on the thread. */ -export const count = System.create({ name: "threadSystemsTest/count", resources: { write: [changes] } }, ({ resources }) => { +export const count = System.create({}, ({ resources }) => { resources[changes] += 1; }); /** @@ -309,12 +294,12 @@ const setQueued = (task: () => void): void => { ranHere.atDue = -1; }; /** On the thread: counts its runs, so the world's ticks post it a task each. */ -export const countThere = System.create({ name: "threadSystemsTest/countThere", resources: { read: [paint], write: [changes] } }, ({ resources }) => { +export const countThere = System.create({}, ({ resources }) => { if (resources[paint].text !== "never") resources[changes] += 1; }); /** On the thread: counts its runs there in `ranHere`; it reaches its commands, so it blocks its tier, a task a tick. */ -export const counted = System.create({ name: "threadSystemsTest/counted", resources: { read: [paint] } }, ({ resources, commands }) => { +export const counted = System.create({}, ({ resources, commands }) => { void resources[paint]; void commands; ranHere.runs++; diff --git a/packages/ecs/src/implementation/world/thread-systems.test.ts b/packages/ecs/src/implementation/world/thread-systems.test.ts index 96a7c5475..afd10d2e3 100644 --- a/packages/ecs/src/implementation/world/thread-systems.test.ts +++ b/packages/ecs/src/implementation/world/thread-systems.test.ts @@ -46,7 +46,7 @@ describe("systems on a joined thread", () => { expect(actual.reports).toEqual(expected.reports); expect(expected.warnings.every(names => names.length === 0)).toBe(true); // Every tick waits for the joined thread's tiers. - expect(actual.warnings[1]).toEqual(["threadSystemsTest/describe", "threadSystemsTest/relabel", "threadSystemsTest/breed", "threadSystemsTest/tally", "threadSystemsTest/watchCaptions", "threadSystemsTest/repaint", "threadSystemsTest/peek"]); + expect(actual.warnings[1]).toEqual(["describe", "relabel", "breed", "tally", "watchCaptions", "repaint", "peek"]); await remote.dispose(); await world.dispose(); }); @@ -65,10 +65,10 @@ describe("systems on a joined thread", () => { const { world, remote } = await joined(); await world.run(crowd, { count: 1 }); const given = await runReported(world, giveKept); - expect(given.failures.map(failure => [failure.name, failure.message])).toEqual([["threadSystemsTest/giveKept", '"threadSystemsTest/kept" is a string, which only the world\'s thread writes, so a command on another thread gives it only its start.']]); + expect(given.failures.map(failure => [failure.name, failure.message])).toEqual([["giveKept", '"threadSystemsTest/kept" is a string, which only the world\'s thread writes, so a command on another thread gives it only its start.']]); expect(given.dropped).toBe(1); const spawned = await runReported(world, spawnKept); - expect(spawned.failures.map(failure => [failure.name, failure.message])).toEqual([["threadSystemsTest/spawnKept", '"threadSystemsTest/kept" is a string, which only the world\'s thread writes, so a command on another thread gives it only its start.']]); + expect(spawned.failures.map(failure => [failure.name, failure.message])).toEqual([["spawnKept", '"threadSystemsTest/kept" is a string, which only the world\'s thread writes, so a command on another thread gives it only its start.']]); expect(await Observe.toPromise(Observe.count(world, { required: [kept] }))).toBe(0); await remote.dispose(); await world.dispose(); @@ -80,8 +80,8 @@ describe("systems on a joined thread", () => { const leaving = remote.dispose(); const report = await runReported(world, write); await leaving; - expect(report.failures.map(failure => [failure.kind, failure.name, failure.message])).toEqual([["thread", "threadSystemsTest/write", 'Thread "threadSystemsTest" is gone, so its systems don\'t run.']]); - expect(report.skipped).toEqual(["threadSystemsTest/write"]); + expect(report.failures.map(failure => [failure.kind, failure.name, failure.message])).toEqual([["thread", "write", 'Thread "threadSystemsTest" is gone, so its systems don\'t run.']]); + expect(report.skipped).toEqual(["write"]); const [entity] = await Observe.toPromise(Observe.select(world, { required: [uid] })); expect(await runReported(world, write)).toEqual({ failures: [], skipped: [], dropped: 0, warnings: [] }); expect(await Observe.toPromise(Observe.entity(world, entity, { required: [caption] }))).toEqual({ [caption]: { text: "written" } }); @@ -111,7 +111,7 @@ describe("systems on a joined thread", () => { return ran; }; const failuresOf = async (ran: number | null) => ran === null ? null : (await reportOf(world, ran)).failures.map(failure => [failure.kind, failure.name, failure.message]); - const failed = [["system", "threadSystemsTest/write", `This thread's memory is ${pages * pageBytes} bytes, short of the ${(pages + 1) * pageBytes} the world stored.`]]; + const failed = [["system", "write", `This thread's memory is ${pages * pageBytes} bytes, short of the ${(pages + 1) * pageBytes} the world stored.`]]; // Read once every run ran, as the thread saw them. const runs = [await short(), await world.run(write), await short(), await world.run(write)]; expect(await Promise.all(runs.map(failuresOf))).toEqual([failed, [], failed, []]); diff --git a/packages/ecs/src/implementation/world/thread-systems.ts b/packages/ecs/src/implementation/world/thread-systems.ts index bcb38ccee..da8447aaa 100644 --- a/packages/ecs/src/implementation/world/thread-systems.ts +++ b/packages/ecs/src/implementation/world/thread-systems.ts @@ -2,8 +2,8 @@ import type { Entity } from '../../interface/entity/entity.js'; import type { Commands } from '../../interface/system/commands.js'; import type { Leaf } from '../../interface/system/leaf.js'; -import type { SystemLookup } from '../../interface/system/access.js'; -import { declarationOf, definitionOf, firstKeyOf, indexOfLookup, isLeaf, itemOfId, kindOf, labelOf, programFinalizers, threadOfDeclaration, watchedBy } from '../../interface/registry/index.js'; +import type { SystemLookupLists } from '../../interface/system/access.js'; +import { declarationOf, definitionOf, firstKeyOf, indexOfLookup, isLeaf, itemOfId, kindOf, labelOf, programFinalizers, threadOfDeclaration, watchedBy, whyUnregistered } from '../../interface/registry/index.js'; import { layoutOf, objectLayout, viewsOf, type Layout, type Views } from '../../interface/registry/layout.js'; import type { StringStore } from './strings.js'; import { stampChanged, writeOf } from './archetype.js'; @@ -24,13 +24,14 @@ import type { WorkerError } from './protocol.js'; import { forEachPassingRange, type Filter } from './ranges.js'; import { combine, contributed, identityOf, reductionTargetsOf, type ReductionTarget } from './reductions.js'; import { changedRowOf, emptyRow, rowBinderOf, type RowHost } from './rows.js'; +import { requiredOf } from './required.js'; import type { Since } from './index-lookups.js'; import { createBatch, positionalSpawner, type Positional, type SpawnBatch } from './staging.js'; import { createThreadFinalizers } from './thread-finalizers.js'; import { createThreadIndex, type ThreadIndex } from './thread-indexes.js'; import type { EntityViews, ThreadArchetype, ThreadDirectory, ThreadStorage } from './thread-directory.js'; import { keptSorted } from './index-keys.js'; -import { chunkShift } from './kernel.js'; +import { blockShift, chunkShift } from './kernel.js'; import { doneWord, endWord, headExecuted, headRan, progressWord, snapshotFlagsWord, snapshotLayout, snapshotOffsetWord, snapshotResourceArchetypeWord, snapshotResourceRowWord, snapshotRowsWord, snapshotCapacityWord, snapshotted, tailBlockWords, tailChunk, waitingWord, @@ -99,6 +100,9 @@ type CommandKey = { /** The system that ran a task's commands, for their checks' errors. */ type Running = { error: unknown }; +/** Whether a task's mirror is current, or why not (`bringCurrent`). */ +type Readiness = "current" | "moved" | "held"; + /** * Runs the systems the world places on this thread, as tasks it posts in the task block: each task's systems in order, * after replaying the mirror through the task's sync, writing each system's commands and spawns as a segment of the @@ -144,7 +148,7 @@ export const createThreadSystems = (host: ThreadSystemsHost) => { return known; } const declaration = declarationOf(key); - if (declaration === undefined || kindOf(key) !== "component") throw new Error(kindOf(key) === "resource" ? `"${labelOf(key)}" is a resource, not a component.` : `"${labelOf(key)}" has no registered schema; import the module that registers it.`); + if (declaration === undefined || kindOf(key) !== "component") throw new Error(kindOf(key) === "resource" ? `"${labelOf(key)}" is a resource, not a component.` : `"${labelOf(key)}" has no registered schema: ${whyUnregistered(key)}.`); if (declaration.derived === true) throw new Error(`"${labelOf(key)}" is derived, so only rules add and remove it.`); const layout = layoutOf(declaration.type); const own = ownObject(key); @@ -283,7 +287,7 @@ export const createThreadSystems = (host: ThreadSystemsHost) => { const runnerHost: RunnerHost = { rows: rowHost, archetypeAt, countOf, tableCount, resourceRow, refreshEntities, commands, mirror, - refresh: directory.refresh, + refresh: directory.refresh, replaced: directory.replaced, numberOf: directory.numberOf, layoutOf: directory.layoutOf, strings: directory.strings, @@ -359,18 +363,25 @@ export const createThreadSystems = (host: ThreadSystemsHost) => { })); }; - /** Brings the mirror up to the last published sync, which runs the finalizers of its stops; false while it can't. */ - const current = (): boolean => { + /** + * Brings the mirror up to the last published sync, which runs the finalizers of its stops: `"moved"` when its + * rebuild found a sync point applying before or during it, so it can rebuild once that's applied, and `"held"` + * while it waits for something else. + */ + const bringCurrent = (): Readiness => { const replayed = replay(mirror); - if (replayed === "held") return false; - return replayed !== "lapped" || rebuild(mirror); + if (replayed === "held") return "held"; + if (replayed !== "lapped") return "current"; + const rebuilt = rebuild(mirror); + return rebuilt === "rebuilt" ? "current" : rebuilt; }; + const current = (): boolean => bringCurrent() === "current"; /** Whether a task can run: its systems known here, its input arrived, and the mirror through its sync. */ - const ready = (payload: Float64Array, count: number): boolean => { - for (let k = 0; k < count; k++) if (runners[payload[payloadSystems + k * systemFields + systemIndex]] === undefined) return false; - if (payload[payloadInput] !== 0 && !inputs.has(payload[payloadSequence])) return false; - return current(); + const ready = (payload: Float64Array, count: number): Readiness => { + for (let k = 0; k < count; k++) if (runners[payload[payloadSystems + k * systemFields + systemIndex]] === undefined) return "held"; + if (payload[payloadInput] !== 0 && !inputs.has(payload[payloadSequence])) return "held"; + return bringCurrent(); }; /** Views of the payload and completion, kept while the world keeps them where they are. */ @@ -428,15 +439,26 @@ export const createThreadSystems = (host: ThreadSystemsHost) => { return control; }; - /** A task waiting for a sync point to finish applying, to rebuild its mirror, runs once it has. */ + /** + * A task waiting for a sync point to finish applying, to rebuild its mirror, runs once it has. One whose rebuild + * found a sync point applied during it runs again after a turn of the event loop: nothing else may wake this thread + * before the task is done, and sync points overlapping every rebuild mustn't keep it from its event loop. + */ let structureWaited = false; - const retryAfterStructure = (): void => { - if (structureWaited || !applying(mirror)) return; - structureWaited = true; - void waitStructure(mirror).then(() => { - structureWaited = false; - process(); - }); + const retried = (): void => { + structureWaited = false; + process(); + }; + const retryAfterStructure = (moved: boolean): void => { + if (structureWaited) return; + if (applying(mirror)) { + structureWaited = true; + void waitStructure(mirror).then(retried); + } + else if (moved) { + structureWaited = true; + setTimeout(retried, 0); + } }; const runTask = (posted: number): void => { @@ -445,8 +467,9 @@ export const createThreadSystems = (host: ThreadSystemsHost) => { const count = payload[payloadCount]; const sequence = payload[payloadSequence]; pending = sequence; - if (!ready(payload, count)) { - retryAfterStructure(); + const readiness = ready(payload, count); + if (readiness !== "current") { + retryAfterStructure(readiness === "moved"); return; } pending = -1; @@ -574,8 +597,10 @@ export type RunnerHost = { readonly layoutOf: (key: Key) => Layout | undefined; /** This thread's objects, null where it fills none. */ readonly mirror: Mirror | null; - /** Views of the memory again, once it grew: its buffer. */ + /** Views of the memory again, once it grew, and `replaced` moved on if a view may have: its buffer. */ readonly refresh: () => ArrayBufferLike; + /** Moved on by `refresh` once any archetype's view may have changed, which index lookups' rows check (`thread-directory.ts`). */ + readonly replaced: { readonly count: number }; /** The records strings' columns name. */ readonly strings: StringStore; }; @@ -607,16 +632,16 @@ type ThreadCell = { storage: ThreadStorage | undefined; row: number }; export const createThreadRunner = (host: RunnerHost, leaf: Leaf, records: ReadonlyMap = noRecords): ThreadRunner => { const { declaration } = leaf; // Method parameters are bivariant, so the runtime can see any leaf's body with erased parameter types. - const system: { body(subject: unknown, context?: unknown): void } = leaf; + const system: { body(subject: unknown, context?: unknown, input?: unknown): void } = leaf; const { components: query, resources } = declaration; const numberOf = host.numberOf; - const required = [...new Set([...query?.read ?? [], ...query?.write ?? [], ...query?.has ?? [], ...query?.added ?? [], ...query?.changed ?? []])].map(numberOf); + const required = requiredOf(query).map(numberOf); const excluded = (query?.not ?? []).map(numberOf); const addedIds = (query?.added ?? []).map(numberOf); const changedIds = (query?.changed ?? []).map(numberOf); const resourceChanged = (resources?.changed ?? []).map(numberOf); const filtered = addedIds.length + changedIds.length + resourceChanged.length > 0; - const bindRow = rowBinderOf(host.rows, numberOf, query?.read ?? [], query?.write ?? []); + const bindRow = rowBinderOf(host.rows, numberOf, query?.read ?? [], query?.write ?? [], query?.optional); const matches: number[] = []; let checked = 0; const refreshMatches = (): void => { @@ -628,7 +653,7 @@ export const createThreadRunner = (host: RunnerHost, leaf: Leaf, records: Readon } }; const ownInput = inputFor(leaf); - const targets: readonly ReductionTarget[] = reductionTargetsOf(host.layoutOf, declaration); + const targets: readonly ReductionTarget[] = reductionTargetsOf(host.layoutOf, leaf.name, declaration); const reduced = targets.map(target => new Float64Array(target.leaves.length)); // The context: resources, lookups, reductions, input and commands, each under its own key when declared. @@ -678,10 +703,10 @@ export const createThreadRunner = (host: RunnerHost, leaf: Leaf, records: Readon if (index === undefined || record === undefined) found = entityLookupOf(host, declared, lookupSince); else { const reads = declared.read ?? []; - const threadIndex = createThreadIndex(host.rows, host.archetypeAt, { + const threadIndex = createThreadIndex(host.rows, host.archetypeAt, host.replaced, { layouts: index.declaration.key.map(key => { const layout = host.layoutOf(key); - if (layout === undefined) throw new Error(`"${labelOf(key)}" has no registered schema; import the module that registers it.`); + if (layout === undefined) throw new Error(`"${labelOf(key)}" has no registered schema: ${whyUnregistered(key)}.`); return layout; }), sorted: keptSorted(index.declaration), @@ -712,14 +737,14 @@ export const createThreadRunner = (host: RunnerHost, leaf: Leaf, records: Readon }); Object.defineProperty(value, "reduce", { value: reducers, enumerable: true }); } - if (declaration.input !== undefined) value.input = undefined; // A tail is terminal, so its context has no commands. - const tailValue: { input?: unknown } = own(); + const tailValue: object = own(); for (const name of Object.getOwnPropertyNames(value)) { const descriptor = Object.getOwnPropertyDescriptor(value, name); - if (descriptor !== undefined && name !== "input") Object.defineProperty(tailValue, name, descriptor); + if (descriptor !== undefined) Object.defineProperty(tailValue, name, descriptor); } - if (declaration.input !== undefined) tailValue.input = undefined; + /** The current run's input, which the body takes after its context; kept until the next run, as its context is. */ + let runInput: unknown; Object.defineProperty(value, "commands", { value: host.commands, enumerable: true }); /** Resolves resource cells for a run: they move only at sync points. */ @@ -764,11 +789,11 @@ export const createThreadRunner = (host: RunnerHost, leaf: Leaf, records: Readon const filters: Filter[] = []; for (const id of addedIds) { const storage = archetype.storageById[id]; - if (storage !== undefined) filters.push({ ticks: storage.added, chunks: storage.chunkAdded }); + if (storage !== undefined) filters.push({ ticks: storage.added, chunks: storage.chunkAdded, blocks: storage.blockAdded }); } for (const id of changedIds) { const storage = archetype.storageById[id]; - if (storage !== undefined) filters.push({ ticks: storage.changed, chunks: storage.chunkChanged }); + if (storage !== undefined) filters.push({ ticks: storage.changed, chunks: storage.chunkChanged, blocks: storage.blockChanged }); } filterCache.set(archetype.id, { archetype, filters }); return filters; @@ -777,18 +802,19 @@ export const createThreadRunner = (host: RunnerHost, leaf: Leaf, records: Readon const runBody = (archetype: ThreadArchetype, count: number, since: number): void => { const row = bindRow(archetype); const subject = row.value; + const input = runInput; const filters = filtersOf(archetype); if (since < 0 || filters.length === 0) { for (let index = 0; index < count; index++) { row.index = index; - system.body(subject, value); + system.body(subject, value, input); } return; } forEachPassingRange(filters, since, 0, count, (start, end) => { for (let index = start; index < end; index++) { row.index = index; - system.body(subject, value); + system.body(subject, value, input); } }); }; @@ -809,13 +835,14 @@ export const createThreadRunner = (host: RunnerHost, leaf: Leaf, records: Readon const since = !filtered || resourcesChanged ? -1 : lastRun; bind(); if (threadIndexes.length !== 0) { + // Settles the count after `ready()` or `current()` may have rebuilt the mirror, since `runTask` settles before them. const buffer = host.refresh(); for (let k = 0; k < threadIndexes.length; k++) threadIndexes[k].refresh(buffer); } - if (declaration.input !== undefined) value.input = ownInput(input); + runInput = ownInput(input); if (query === undefined) { outcome.ran = since < 0; - if (outcome.ran) system.body(value); + if (outcome.ran) system.body(value, runInput); return outcome; } if (since >= 0 && addedIds.length + changedIds.length === 0) return outcome; @@ -838,6 +865,8 @@ export const createThreadRunner = (host: RunnerHost, leaf: Leaf, records: Readon // thread's, which it reads from its mirror at the snapshot's rows. const readKeys = query?.read ?? []; const writeKeys = query?.write ?? []; + /** Its optional components, which rows from a snapshot have only where their archetype does. */ + const optionalNumbers = (query?.optional ?? []).map(numberOf); const linearReads: { readonly number: number; readonly layout: Columned }[] = []; const constantReads: { readonly number: number; readonly layout: Layout }[] = []; for (const key of readKeys) { @@ -867,18 +896,19 @@ export const createThreadRunner = (host: RunnerHost, leaf: Leaf, records: Readon const runRows = (archetype: ThreadArchetype, start: number, end: number, since: number): void => { const row = bindRow(archetype); const subject = row.value; + const input = runInput; const filters = filtersOf(archetype); if (since < 0 || filters.length === 0) { for (let index = start; index < end && !halted; index++) { row.index = index; - system.body(subject, tailValue); + system.body(subject, tailValue, input); } return; } forEachPassingRange(filters, since, start, end, (first, last) => { for (let index = first; index < last && !halted; index++) { row.index = index; - system.body(subject, tailValue); + system.body(subject, tailValue, input); } }); }; @@ -954,10 +984,10 @@ export const createThreadRunner = (host: RunnerHost, leaf: Leaf, records: Readon if ((flags & headExecuted) === 0) return; outcome.executed = true; outcome.ran = (flags & headRan) !== 0; - if (declaration.input !== undefined) tailValue.input = ownInput(input); + runInput = ownInput(input); if (!outcome.ran) return; if (query === undefined) { - system.body(tailValue); + system.body(tailValue, runInput); writeBackResources(ownResources, resourceArchetype, resourceRow, tick, at, stamps); return; } @@ -988,12 +1018,42 @@ export const createThreadRunner = (host: RunnerHost, leaf: Leaf, records: Readon } let first = 0; while (first < rows && positions[first] < position) first++; - const row = bindRow({ id: -1, entities, storageById, record: -1, version: -1, mirrored: null, components: [], resource: false, meta: false }); - const subject = row.value; + const copied = (storages: readonly (ThreadStorage | undefined)[]): ThreadArchetype => + ({ id: -1, entities, storageById: storages, record: -1, version: -1, mirrored: null, components: [], resource: false, meta: false }); + const ownedInput = runInput; try { - for (let index = first; index < rows && !halted; index++) { - row.index = index; - system.body(subject, tailValue); + if (optionalNumbers.length === 0) { + const row = bindRow(copied(storageById)); + const subject = row.value; + for (let index = first; index < rows && !halted; index++) { + row.index = index; + system.body(subject, tailValue, ownedInput); + } + } + else { + // Rows of archetypes that lack optional components run in a row without them, a run of one archetype's rows + // at a time, and every archetype with the same ones shares its copy of the snapshot's columns. + const byPattern = new Map(); + for (let index = first; index < rows && !halted;) { + const id = archetypeIds[index]; + let end = index + 1; + while (end < rows && archetypeIds[end] === id) end++; + const components = host.archetypeAt(id)?.components ?? []; + let pattern = 0; + for (let k = 0; k < optionalNumbers.length; k++) if (components.includes(optionalNumbers[k])) pattern |= 1 << k; + let archetype = byPattern.get(pattern); + if (archetype === undefined) { + const storages: (ThreadStorage | undefined)[] = [...storageById]; + optionalNumbers.forEach((number, k) => { if ((pattern & (1 << k)) === 0) storages[number] = undefined; }); + byPattern.set(pattern, archetype = copied(storages)); + } + const row = bindRow(archetype); + const subject = row.value; + for (; index < end && !halted; index++) { + row.index = index; + system.body(subject, tailValue, ownedInput); + } + } } } finally { @@ -1052,10 +1112,10 @@ export const createThreadRunner = (host: RunnerHost, leaf: Leaf, records: Readon const resourcesChanged = resourceChanged.length > 0 && resourcesChangedSince(lastRun); const since = !filtered || resourcesChanged ? -1 : lastRun; bind(); - if (declaration.input !== undefined) tailValue.input = ownInput(input); + runInput = ownInput(input); if (query === undefined) { outcome.ran = since < 0; - if (outcome.ran) system.body(tailValue); + if (outcome.ran) system.body(tailValue, runInput); return outcome; } if (since >= 0 && addedIds.length + changedIds.length === 0) return outcome; @@ -1106,16 +1166,25 @@ type Columned = Extract; /** A storage over a snapshot's column, or of values only this thread holds, with ticks of its own for `ticked` rows. */ const localStorage = (component: number, layout: Layout, views: Views, values: unknown[], ticked: number, strings: StringStore): ThreadStorage => { const chunks = ticked === 0 ? 0 : (ticked >> chunkShift) + 1; + const blocks = ticked === 0 ? 0 : (ticked >> blockShift) + 1; return { component, layout, views, strings: layout.kind === "string" ? strings : null, values, ...writeOf(layout), tags: noTags, ticked: ticked > 0, added: ticked === 0 ? noTicks : new Float64Array(ticked), changed: ticked === 0 ? noTicks : new Float64Array(ticked), chunkAdded: chunks === 0 ? noTicks : new Float64Array(chunks), chunkChanged: chunks === 0 ? noTicks : new Float64Array(chunks), + blockAdded: blocks === 0 ? noTicks : new Float64Array(blocks), blockChanged: blocks === 0 ? noTicks : new Float64Array(blocks), }; }; +/** What a lookup reads and requires: its reads but its optional components, which an entity it finds may lack. */ +const requiredReads = (host: RunnerHost, lookup: SystemLookupLists): readonly number[] => { + const optional = "optional" in lookup ? lookup.optional ?? [] : []; + return (lookup.read ?? []).filter(key => !optional.includes(key)).map(host.numberOf); +}; + /** A lookup returning `read` values keyed by component, from this thread's views. */ -const lookupOf = (host: RunnerHost, lookup: SystemLookup) => { +const lookupOf = (host: RunnerHost, lookup: SystemLookupLists) => { const read = (lookup.read ?? []).map(host.numberOf); + const required = requiredReads(host, lookup); const has = ("has" in lookup ? lookup.has ?? [] : []).map(host.numberOf); const not = ("not" in lookup ? lookup.not ?? [] : []).map(host.numberOf); const keys = lookup.read ?? []; @@ -1126,7 +1195,7 @@ const lookupOf = (host: RunnerHost, lookup: SystemLookup) => { const archetype = host.archetypeAt(table.archetype[index]); if (archetype === undefined || archetype.resource) return null; const { components } = archetype; - for (const id of read) if (!components.includes(id)) return null; + for (const id of required) if (!components.includes(id)) return null; for (const id of has) if (!components.includes(id)) return null; for (const id of not) if (components.includes(id)) return null; const row = table.row[index]; @@ -1140,7 +1209,7 @@ const lookupOf = (host: RunnerHost, lookup: SystemLookup) => { }; /** A lookup reading nothing, from this thread's views: the shared empty row for every entity it finds. */ -const emptyLookupOf = (host: RunnerHost, lookup: SystemLookup) => { +const emptyLookupOf = (host: RunnerHost, lookup: SystemLookupLists) => { const has = ("has" in lookup ? lookup.has ?? [] : []).map(host.numberOf); const not = ("not" in lookup ? lookup.not ?? [] : []).map(host.numberOf); return (entity: Entity): object | null => { @@ -1161,8 +1230,9 @@ const emptyLookupOf = (host: RunnerHost, lookup: SystemLookup) => { * a component `watched` names may have changed in it after `since`. A GPU kernel's column stamps reach this thread as * row ticks; a component keeping no ticks counts as changed. */ -const changedLookupOf = (host: RunnerHost, lookup: SystemLookup, watched: readonly Key[], since: Since) => { +const changedLookupOf = (host: RunnerHost, lookup: SystemLookupLists, watched: readonly Key[], since: Since) => { const read = (lookup.read ?? []).map(host.numberOf); + const required = requiredReads(host, lookup); const has = ("has" in lookup ? lookup.has ?? [] : []).map(host.numberOf); const not = ("not" in lookup ? lookup.not ?? [] : []).map(host.numberOf); const ids = watched.map(host.numberOf); @@ -1175,7 +1245,7 @@ const changedLookupOf = (host: RunnerHost, lookup: SystemLookup, watched: readon const archetype = host.archetypeAt(table.archetype[index]); if (archetype === undefined || archetype.resource) return null; const { components, storageById } = archetype; - for (const id of read) if (!components.includes(id)) return null; + for (const id of required) if (!components.includes(id)) return null; for (const id of has) if (!components.includes(id)) return null; for (const id of not) if (components.includes(id)) return null; const row = table.row[index]; @@ -1194,7 +1264,7 @@ const changedLookupOf = (host: RunnerHost, lookup: SystemLookup, watched: readon }; /** The lookup by entity a declaration chooses, once: with `changed`, reading nothing, or reading values. */ -const entityLookupOf = (host: RunnerHost, lookup: SystemLookup, since: Since) => { +const entityLookupOf = (host: RunnerHost, lookup: SystemLookupLists, since: Since) => { const watched = "index" in lookup ? undefined : lookup.changed; if (watched !== undefined) return changedLookupOf(host, lookup, watched, since); return (lookup.read ?? []).length === 0 ? emptyLookupOf(host, lookup) : lookupOf(host, lookup); diff --git a/packages/ecs/src/implementation/world/threads.browser.test.ts b/packages/ecs/src/implementation/world/threads.browser.test.ts index a7c89510f..04e8181ca 100644 --- a/packages/ecs/src/implementation/world/threads.browser.test.ts +++ b/packages/ecs/src/implementation/world/threads.browser.test.ts @@ -6,6 +6,7 @@ import { poolSizeCases } from "./pool-size.cases.js"; import { dispatchCases } from "./dispatch.cases.js"; import { reductionsCases } from "./reductions.cases.js"; import { observedWorkersCases } from "./observed-workers.cases.js"; +import { changeBlocksCases } from "./change-blocks.cases.js"; import { upWorkersCases } from "./up-workers.cases.js"; import { portsCases } from "./ports.cases.js"; import { commandRingsCases } from "./command-rings.cases.js"; @@ -30,6 +31,7 @@ poolSizeCases(() => import("./pool-size.fixture.js")); dispatchCases(() => import("./dispatch.fixture.js")); reductionsCases(() => import("./reductions.fixture.js")); observedWorkersCases(() => import("./observed-workers.fixture.js")); +changeBlocksCases(() => import("./change-blocks.fixture.js")); upWorkersCases(() => import("./up-workers.fixture.js")); portsCases(() => import("./ports.fixture.js")); commandRingsCases(() => import("./command-rings.fixture.js")); diff --git a/packages/ecs/src/implementation/world/threads.test.ts b/packages/ecs/src/implementation/world/threads.test.ts index c1ccb4fad..4faaa131c 100644 --- a/packages/ecs/src/implementation/world/threads.test.ts +++ b/packages/ecs/src/implementation/world/threads.test.ts @@ -14,6 +14,8 @@ import type * as Reductions from "./reductions.fixture.js"; import { reductionsCases } from "./reductions.cases.js"; import type * as ObservedWorkers from "./observed-workers.fixture.js"; import { observedWorkersCases } from "./observed-workers.cases.js"; +import type * as ChangeBlocks from "./change-blocks.fixture.js"; +import { changeBlocksCases } from "./change-blocks.cases.js"; import type * as UpWorkers from "./up-workers.fixture.js"; import { upWorkersCases } from "./up-workers.cases.js"; import type * as Ports from "./ports.fixture.js"; @@ -46,6 +48,8 @@ import type * as ObserverJoined from "./observer-joined.fixture.js"; import { observerJoinedCases } from "./observer-joined.cases.js"; import type * as ObserverContained from "./observer-contained.fixture.js"; import { observerContainedCases } from "./observer-contained.cases.js"; +import type * as OptionalWorkers from "./optional-workers.fixture.js"; +import { optionalWorkersCases } from "./optional-workers.cases.js"; // Real worker threads in node: the cases over fixtures `vitest.fixtures.ts` built, each typed as its source, which it's // built from. @@ -56,6 +60,7 @@ poolSizeCases((): Promise => builtFixture("pool-size")); dispatchCases((): Promise => builtFixture("dispatch")); reductionsCases((): Promise => builtFixture("reductions")); observedWorkersCases((): Promise => builtFixture("observed-workers")); +changeBlocksCases((): Promise => builtFixture("change-blocks")); upWorkersCases((): Promise => builtFixture("up-workers")); portsCases((): Promise => builtFixture("ports")); commandRingsCases((): Promise => builtFixture("command-rings")); @@ -72,3 +77,4 @@ indexBuildCases((): Promise => builtFixture("index-build")); entityLookupWorkersCases((): Promise => builtFixture("entity-lookup-workers")); observerJoinedCases((): Promise => builtFixture("observer-joined")); observerContainedCases((): Promise => builtFixture("observer-contained")); +optionalWorkersCases((): Promise => builtFixture("optional-workers")); diff --git a/packages/ecs/src/implementation/world/ticks.browser.test.ts b/packages/ecs/src/implementation/world/ticks.browser.test.ts index 196170362..d5b250a21 100644 --- a/packages/ecs/src/implementation/world/ticks.browser.test.ts +++ b/packages/ecs/src/implementation/world/ticks.browser.test.ts @@ -1,6 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; -import { Resource } from "../../interface/resource/resource.js"; import { always } from "../../interface/resources/always.js"; import { clock } from "../../interface/resources/clock.js"; import { System } from "../../interface/system/system.js"; @@ -14,10 +13,9 @@ declare module "@adobe/ecs" { [frames]: { type: number }; } } -Resource.register(frames, { type: { type: "number", default: 0 } }); -const count = System.create({ name: "ticksBrowserTest/count", resources: { write: [frames] } }, ({ resources }) => { resources[frames] += 1; }); -const start = System.create({ name: "ticksBrowserTest/start", resources: { write: [always] } }, ({ resources }) => { resources[always] = true; }); +const count = System.create({}, ({ resources }) => { resources[frames] += 1; }); +const start = System.create({}, ({ resources }) => { resources[always] = true; }); describe("a frame cadence", () => { it("ticks on animation frames, with their time", async () => { diff --git a/packages/ecs/src/implementation/world/ticks.test.ts b/packages/ecs/src/implementation/world/ticks.test.ts index 7b2cad3a1..de67bde26 100644 --- a/packages/ecs/src/implementation/world/ticks.test.ts +++ b/packages/ecs/src/implementation/world/ticks.test.ts @@ -1,6 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; -import { Resource, System, clock, workers } from "../../index.js"; +import { System, clock, workers } from "../../index.js"; import { createWorld } from "./create.js"; // The cases of ticks that read through observers are in `observe-ticks-cases.test.ts`. @@ -11,14 +11,13 @@ declare module "@adobe/ecs" { [frames]: { type: number }; } } -Resource.register(frames, { type: { type: "number", default: 0 } }); -const count = System.create({ name: "ticksTest/count", resources: { write: [frames] } }, ({ resources }) => { resources[frames] += 1; }); +const count = System.create({}, ({ resources }) => { resources[frames] += 1; }); describe("ticks", () => { it("reject a system that conflicts with one added with no order between them, and one with input", async () => { - const other = System.create({ name: "ticksTest/other", resources: { write: [frames] } }, ({ resources }) => { resources[frames] = 0; }); - const withInput = System.create({ name: "ticksTest/withInput", input: { type: "object", properties: { by: { type: "number" } } } }, () => {}); + const other = System.create({}, ({ resources }) => { resources[frames] = 0; }); + const withInput = System.create({}, (_context, input: { readonly by: number }) => {}); await using world = createWorld({ [workers]: 0 }); world.add(count); expect(() => world.add(other)).toThrow('both access "ticksTest/frames"'); diff --git a/packages/ecs/src/implementation/world/traits.test.ts b/packages/ecs/src/implementation/world/traits.test.ts index 49e2d1a47..0bfcce459 100644 --- a/packages/ecs/src/implementation/world/traits.test.ts +++ b/packages/ecs/src/implementation/world/traits.test.ts @@ -1,6 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; -import { Component } from "../../interface/component/component.js"; import { Entity } from "../../interface/entity/entity.js"; import { System } from "../../interface/system/system.js"; import { count, read } from "./reads.test-helper.js"; @@ -21,15 +20,11 @@ declare module "@adobe/ecs" { } } -Component.register(score, { type: { type: "number" } }); -Component.register(slot, { type: { type: "number", default: 0 }, derived: true }); -Component.register(ready, { type: { const: true }, within: slot }); -Component.register(parent, { type: Entity.schema, reference: "owner" }); describe("traits", () => { it("keep derived components out of commands, for untyped callers too", async () => { const errors: string[] = []; - const derived = System.create({ name: "traitsTest/derived" }, ({ commands }) => { + const derived = System.create({}, ({ commands }) => { const entity = commands.spawn([score])({ [score]: 1 }); const attempts = [ // @ts-expect-error derived components are added only by rules @@ -54,8 +49,8 @@ describe("traits", () => { it("leave index 0 to Entity.none, which no lookup finds", async () => { let first = Entity.none; let found: unknown = undefined; - const spawn = System.create({ name: "traitsTest/spawn" }, ({ commands }) => { first = commands.spawn([score])({ [score]: 1 }); }); - const lookup = System.create({ name: "traitsTest/lookup", lookup: { scores: { read: [score] } } }, ({ lookup: { scores } }) => { found = scores(Entity.none); }); + const spawn = System.create({}, ({ commands }) => { first = commands.spawn([score])({ [score]: 1 }); }); + const lookup = System.create({ lookup: { scores: { with: [score] } } }, ({ lookup: { scores } }) => { found = scores(Entity.none); }); const world = createWorld({ [workers]: 0 }); await world.run(spawn); await world.run(lookup); diff --git a/packages/ecs/src/implementation/world/transfers.test-helper.ts b/packages/ecs/src/implementation/world/transfers.test-helper.ts index ba9fad9b0..c5b7b8b5b 100644 --- a/packages/ecs/src/implementation/world/transfers.test-helper.ts +++ b/packages/ecs/src/implementation/world/transfers.test-helper.ts @@ -1,8 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Component } from "../../interface/component/component.js"; import { Entity } from "../../interface/entity/entity.js"; -import { Resource } from "../../interface/resource/resource.js"; import { System } from "../../interface/system/system.js"; +import type { Schema } from "../../interface/schema/schema.js"; // Keys and systems every thread of the transfer tests imports: systems on thread "transfersTest", which a joined thread // fills, and on the world's, taking Transferable values in their input, and `transfer` components holding them. @@ -29,20 +28,12 @@ declare module "@adobe/ecs" { [depth]: { type: number }; } export interface Resources { - [anchor]: { type: { readonly text: string }; thread: "transfersTest" }; - [order]: { type: number }; + [anchor]: { type: { readonly text: string }; thread: "transfersTest"; default: { text: "anchor" } }; + [order]: { type: Schema.Type<{ type: "integer" }>; default: 0 }; } } /** An object resource of "transfersTest", which places the systems reading it there. */ -Resource.register(anchor, { type: { type: "object", properties: { text: { type: "string" } }, default: { text: "anchor" } }, thread: "transfersTest" }); /** Counts the runs that read it, in the order they ran. */ -Resource.register(order, { type: { type: "integer", default: 0 } }); -Component.register(port, { thread: "transfersTest", transfer: true }); -Component.register(bitmap, { thread: "transfersTest", transfer: true }); -Component.register(portHere, { transfer: true }); -Component.register(bitmapHere, { transfer: true }); -Component.register(parent, { type: Entity.schema, reference: "owner" }); -Component.register(depth, { type: { type: "number" } }); const any = { allOf: [] } as const; @@ -52,46 +43,44 @@ export const stored: Stored[] = []; /** The values systems gave components, on the thread they ran on. */ export const kept: unknown[] = []; -const store = (value: unknown, resources: { [order]: number }): void => { +const store = (value: unknown, at: number): void => { const bytes = value instanceof ArrayBuffer ? value.byteLength : -1; - stored.push({ bytes, first: bytes > 0 && value instanceof ArrayBuffer ? new Uint8Array(value)[0] : -1, order: resources[order]++, value }); + stored.push({ bytes, first: bytes > 0 && value instanceof ArrayBuffer ? new Uint8Array(value)[0] : -1, order: at, value }); }; /** On "transfersTest": records the buffer it's given. */ -export const storeThere = System.create({ name: "transfersTest/storeThere", input: { type: "object", properties: { buffer: any } }, resources: { read: [anchor], write: [order] } }, ({ input, resources }) => { - if (resources[anchor].text === "anchor") store(input.buffer, resources); +export const storeThere = System.create({}, ({ resources }, input: { readonly buffer: unknown }) => { + if (resources[anchor].text === "anchor") store(input.buffer, resources[order]++); }); /** On the world's thread: records the buffer it's given. */ -export const storeHere = System.create({ name: "transfersTest/storeHere", input: { type: "object", properties: { buffer: any } }, resources: { write: [order] } }, ({ input, resources }) => { - store(input.buffer, resources); +export const storeHere = System.create({}, ({ resources }, input: { readonly buffer: unknown }) => { + store(input.buffer, resources[order]++); }); /** On the world's thread: what a run from a joined thread saw of the order, by its value. */ export const marks: { readonly value: number; readonly order: number }[] = []; -export const mark = System.create({ name: "transfersTest/mark", input: { type: "object", properties: { value: { type: "number" } } }, resources: { write: [order] } }, ({ input, resources }) => { +export const mark = System.create({}, ({ resources }, input: { readonly value: number }) => { marks.push({ value: input.value, order: resources[order]++ }); }); /** On "transfersTest": gives an entity the port or bitmap it's given. */ -export const attachThere = System.create({ - name: "transfersTest/attachThere", input: { type: "object", properties: { entity: Entity.schema, value: any } }, resources: { read: [anchor] }, -}, ({ input, resources, commands }) => { +export const attachThere = System.create({}, ({ resources, commands }, input: { readonly entity: Entity; readonly value: unknown }) => { if (resources[anchor].text !== "anchor") return; kept.push(input.value); if (input.value instanceof MessagePort) commands.add(input.entity, port, input.value); else if (typeof ImageBitmap === "function" && input.value instanceof ImageBitmap) commands.add(input.entity, bitmap, input.value); }); /** On the world's thread: gives an entity the port or bitmap it's given. */ -export const attachHere = System.create({ name: "transfersTest/attachHere", input: { type: "object", properties: { entity: Entity.schema, value: any } } }, ({ input, commands }) => { +export const attachHere = System.create({}, ({ commands }, input: { readonly entity: Entity; readonly value: unknown }) => { kept.push(input.value); if (input.value instanceof MessagePort) commands.add(input.entity, portHere, input.value); else if (typeof ImageBitmap === "function" && input.value instanceof ImageBitmap) commands.add(input.entity, bitmapHere, input.value); }); /** On "transfersTest": says hello through the port it's given, which only moving it there, not copying it, gives. */ -export const greetThere = System.create({ name: "transfersTest/greetThere", input: { type: "object", properties: { port: any } }, resources: { read: [anchor] } }, ({ input, resources }) => { +export const greetThere = System.create({}, ({ resources }, input: { readonly port: unknown }) => { if (input.port instanceof MessagePort) input.port.postMessage(resources[anchor].text); }); /** On the world's thread: spawns an entity holding the port it's given. */ -export const spawnHere = System.create({ name: "transfersTest/spawnHere", input: { type: "object", properties: { value: any } } }, ({ input, commands }) => { +export const spawnHere = System.create({}, ({ commands }, input: { readonly value: unknown }) => { if (input.value instanceof MessagePort) commands.spawn([portHere])({ [portHere]: input.value }); }); @@ -100,13 +89,13 @@ export const spawnHere = System.create({ name: "transfersTest/spawnHere", input: * "transfersTest", it fails to register, so a run of it never runs. */ export const unregistered = System.create({ - name: "transfersTest/unregistered", input: { type: "object", properties: { value: any } }, components: { read: [port], write: [depth] }, up: { through: parent, read: [depth] }, -}, (row, { input, up }) => { + components: { with: [port, depth] }, up: { through: parent }, +}, (row, { up }, input: { readonly value: unknown }) => { row[depth] = (up === null ? 0 : up[depth]) + (row[port] === input.value ? 1 : 0); }); /** On the world's thread: nothing, a run of its own. */ -export const idle = System.create({ name: "transfersTest/idle" }, () => {}); +export const idle = System.create({}, () => {}); /** Every system the joined threads run or place, which they import so the world can place them there. */ export const transferSystems: readonly { readonly name: string }[] = [storeThere, storeHere, mark, attachThere, attachHere, spawnHere, greetThere, unregistered]; diff --git a/packages/ecs/src/implementation/world/unidentified-finalizers.test.ts b/packages/ecs/src/implementation/world/unidentified-finalizers.test.ts new file mode 100644 index 000000000..02403c7c0 --- /dev/null +++ b/packages/ecs/src/implementation/world/unidentified-finalizers.test.ts @@ -0,0 +1,59 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { describe, expect, it } from "vitest"; +import { Finalizer, System, workers } from "../../index.js"; +import { createWorld, join } from "./create.js"; +import { runReported } from "./reports.test-helper.js"; + +const held = Symbol("unidentifiedFinalizers/held"); +declare module "@adobe/ecs" { + interface Threads { unidentifiedFinalizers: true } + interface Components { [held]: { type: { readonly text: string } | null; thread: "unidentifiedFinalizers" } } +} + +const nothing = System.create(() => {}); +const spawn = System.create(({ commands }) => { commands.spawn([held])({ [held]: null }); }); +const seen: unknown[] = []; +/** A finalizer reading the thread's objects, made where the build gives it no id. */ +const unidentified = (): void => { Finalizer.create({ components: { with: [held] } }, row => { seen.push(row[held]); }); }; + +/** A world shared with a thread on this one claiming the thread, and its status once settled: joined or gone. */ +const shared = async () => { + const world = createWorld({ [workers]: 0 }); + const channel = new MessageChannel(); + let status = ""; + const settled = new Promise(resolve => { + world.threads(threads => { + status = threads.at(-1)?.status ?? ""; + if (status === "joined" || status === "gone") resolve(); + }); + }); + world.share(channel.port1); + const remote = join(channel.port2, { threads: ["unidentifiedFinalizers"] }); + await settled; + return { world, remote, status: () => status }; +}; + +describe("a finalizer without an id on a thread a joined thread fills", () => { + // Created after the claim, so the claim is made; the next run reports it once and the world goes on without it. + it("fails once, in a report, and leaves the world running", async () => { + const { world, remote, status } = await shared(); + expect(status()).toBe("joined"); + await remote.run(spawn); + unidentified(); + const first = await runReported(world, nothing); + expect(first.failures.map(failure => [failure.kind, failure.message.includes("has no id")])).toEqual([["finalizer", true]]); + expect((await runReported(world, nothing)).failures).toEqual([]); + await remote.run(nothing); + await world.dispose(); + }, 10_000); + + // Finalizers are program-wide, so the one the test above made is present when this thread claims: the claim is + // refused, and the joined thread's runs say why. + it("refuses a later claim of its thread, saying why", async () => { + const { world, remote, status } = await shared(); + expect(status()).toBe("gone"); + await expect(remote.run(nothing)).rejects.toThrow(/has no id, so a joined thread can't run it/); + await world.run(nothing); + await world.dispose(); + }, 10_000); +}); diff --git a/packages/ecs/src/implementation/world/up-tree.test.ts b/packages/ecs/src/implementation/world/up-tree.test.ts index 591ae1757..4d061e0c1 100644 --- a/packages/ecs/src/implementation/world/up-tree.test.ts +++ b/packages/ecs/src/implementation/world/up-tree.test.ts @@ -1,6 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { expect, it } from "vitest"; -import { Component } from "../../interface/component/component.js"; import { Entity } from "../../interface/entity/entity.js"; import { System } from "../../interface/system/system.js"; import type { World } from "../../interface/world/world.js"; @@ -24,25 +23,18 @@ declare module "@adobe/ecs" { } } -Component.register(local, { type: { type: "number" } }); -Component.register(full, { type: { type: "number" } }); -Component.register(filtered, { type: { type: "number" } }); -Component.register(parent, { type: Entity.schema, reference: "weak" }); -Component.register(hidden, { type: { const: true } }); const propagateFull = System.create({ - name: "upTreeTest/propagateFull", - kernel: "cpu", - components: { read: [local], write: [full], not: [hidden] }, - up: { through: parent, read: [full] }, + kernel: true, + components: { with: [local, full], not: [hidden] }, + up: { through: parent }, }, (row, { up }) => { row[full] = (up === null ? 0 : up[full]) + row[local]; }); const propagateFiltered = System.create({ - name: "upTreeTest/propagateFiltered", - kernel: "cpu", - components: { read: [local], write: [filtered], changed: [local], not: [hidden] }, - up: { through: parent, read: [filtered] }, + kernel: true, + components: { with: [local, filtered], changed: [local], not: [hidden] }, + up: { through: parent }, }, (row, { up }) => { row[filtered] = (up === null ? 0 : up[filtered]) + row[local]; }); @@ -53,7 +45,7 @@ type Operation = | { readonly kind: "reparent"; readonly entity: Entity; readonly to: Entity } | { readonly kind: "set"; readonly entity: Entity; readonly local: number }; const operations: Operation[] = []; -const apply = System.create({ name: "upTreeTest/apply" }, ({ commands }) => { +const apply = System.create({}, ({ commands }) => { for (const operation of operations.splice(0)) { switch (operation.kind) { case "spawn": diff --git a/packages/ecs/src/implementation/world/up-workers.fixture.ts b/packages/ecs/src/implementation/world/up-workers.fixture.ts index c38b777c9..5c7d5f590 100644 --- a/packages/ecs/src/implementation/world/up-workers.fixture.ts +++ b/packages/ecs/src/implementation/world/up-workers.fixture.ts @@ -1,5 +1,4 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Component } from "../../interface/component/component.js"; import { Entity } from "../../interface/entity/entity.js"; import { System } from "../../interface/system/system.js"; import { workers } from "../../interface/resources/workers.js"; @@ -21,20 +20,16 @@ declare module "@adobe/ecs" { } } -Component.register(local, { type: { type: "number" } }); -Component.register(world, { type: { type: "number" } }); -Component.register(parent, { type: Entity.schema, reference: "owner" }); const propagate = System.create({ - name: "upWorkersTest/propagate", - kernel: "cpu", - components: { read: [local], write: [world], changed: [local] }, - up: { through: parent, read: [world] }, + kernel: true, + components: { with: [local, world], changed: [local] }, + up: { through: parent }, }, (row, { up }) => { row[world] = (up === null ? 0 : up[world]) + row[local] * 2; }); /** 100 trees, each a root with 40 levels of two children, ordered so storage order isn't depth order. */ -const grow = System.create({ name: "upWorkersTest/grow" }, ({ commands }) => { +const grow = System.create({}, ({ commands }) => { for (let tree = 0; tree < 100; tree++) { let level = [commands.spawn([local, world])({ [local]: tree, [world]: 0 })]; for (let depth = 1; depth < 40; depth++) { @@ -43,7 +38,7 @@ const grow = System.create({ name: "upWorkersTest/grow" }, ({ commands }) => { } } }); -const nudge = System.create({ name: "upWorkersTest/nudge", components: { write: [local] } }, row => { +const nudge = System.create({ components: { with: [local] } }, row => { if (row[local] === 7) row[local] = 70; }); diff --git a/packages/ecs/src/implementation/world/up.browser.test.ts b/packages/ecs/src/implementation/world/up.browser.test.ts index c081bee47..2361fb588 100644 --- a/packages/ecs/src/implementation/world/up.browser.test.ts +++ b/packages/ecs/src/implementation/world/up.browser.test.ts @@ -1,6 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { afterEach, describe, expect, it } from "vitest"; -import { Component } from "../../interface/component/component.js"; import { Entity } from "../../interface/entity/entity.js"; import { System } from "../../interface/system/system.js"; import type { World } from "../../interface/world/world.js"; @@ -15,29 +14,25 @@ const marked = Symbol("upBrowserTest/marked"); declare module "@adobe/ecs" { interface Components { [parent]: { type: Entity; reference: "owner" }; - [offset]: { type: number }; - [placed]: { type: number }; + [offset]: { type: F32 }; + [placed]: { type: F32 }; [marked]: { type: true }; } } -Component.register(parent, { type: Entity.schema, reference: "owner" }); -Component.register(offset, { type: { type: "number", precision: 1 } }); -Component.register(placed, { type: { type: "number", precision: 1 } }); -Component.register(marked, { type: { const: true } }); import { gpuDevice } from "../../interface/resources/gpu-device.js"; import { workers } from "../../interface/resources/workers.js"; +import type { F32 } from "../../interface/math/index.js"; const place = System.create({ - name: "upBrowserTest/place", kernel: "gpu", - components: { read: [offset], write: [placed] }, - up: { through: parent, read: [placed] }, + components: { with: [offset, placed] }, + up: { through: parent }, }, (row, { up }) => { row[placed] = up === null ? row[offset] : up[placed] + row[offset]; }); /** Trees whose children are in two archetypes, so a depth's rows have parents in several. */ -const plant = System.create({ name: "upBrowserTest/plant" }, ({ commands }) => { +const plant = System.create({}, ({ commands }) => { for (let tree = 0; tree < 50; tree++) { let level = [commands.spawn([offset, placed])({ [offset]: tree, [placed]: 0 })]; for (let depth = 1; depth < 12; depth++) { diff --git a/packages/ecs/src/implementation/world/up.test.ts b/packages/ecs/src/implementation/world/up.test.ts index e021ed25c..054307334 100644 --- a/packages/ecs/src/implementation/world/up.test.ts +++ b/packages/ecs/src/implementation/world/up.test.ts @@ -1,6 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; -import { Component } from "../../interface/component/component.js"; import { Entity } from "../../interface/entity/entity.js"; import { System } from "../../interface/system/system.js"; import { definitionOf } from "../../interface/registry/index.js"; @@ -31,92 +30,85 @@ declare module "@adobe/ecs" { } } -Component.register(local, { type: { type: "number" } }); -Component.register(world, { type: { type: "number" } }); -Component.register(parent, { type: Entity.schema, reference: "owner" }); -Component.register(label, { type: { type: "string" } }); -Component.register(visible, { type: { type: "boolean" } }); -Component.register(shown, { type: { type: "boolean" } }); -Component.register(anchor, { type: { const: true } }); const ran: string[] = []; const propagate = System.create({ - name: "upTest/propagate", - components: { read: [local, label], write: [world] }, - up: { through: parent, read: [world] }, + components: { with: [local, label, world] }, + up: { through: parent }, }, (row, { up }) => { ran.push(row[label]); row[world] = (up === null ? 0 : up[world]) + row[local]; }); const propagateChanged = System.create({ - name: "upTest/propagateChanged", - components: { read: [local, label], write: [world], changed: [local] }, - up: { through: parent, read: [world] }, + components: { with: [local, label, world], changed: [local] }, + up: { through: parent }, }, (row, { up }) => { ran.push(row[label]); row[world] = (up === null ? 0 : up[world]) + row[local]; }); const propagateAdded = System.create({ - name: "upTest/propagateAdded", - components: { read: [local, label], write: [world], added: [local] }, - up: { through: parent, read: [world] }, + components: { with: [local, label, world], added: [local] }, + up: { through: parent }, }, (row, { up }) => { ran.push(row[label]); row[world] = (up === null ? 0 : up[world]) + row[local]; }); const propagateChangedKernel = System.create({ - name: "upTest/propagateChangedKernel", - kernel: "cpu", - components: { read: [local], write: [world], changed: [local] }, - up: { through: parent, read: [world] }, + kernel: true, + components: { with: [local, world], changed: [local] }, + up: { through: parent }, }, (row, { up }) => { row[world] = (up === null ? 0 : up[world]) + row[local]; }); const propagateKernel = System.create({ - name: "upTest/propagateKernel", - kernel: "cpu", - components: { read: [local], write: [world] }, - up: { through: parent, read: [world] }, + kernel: true, + components: { with: [local, world] }, + up: { through: parent }, }, (row, { up }) => { row[world] = (up === null ? 0 : up[world]) + row[local]; }); /** Spawns a root with two trees under it, the grandchild stored before its parent so storage order isn't depth order. */ const entities: { [name: string]: Entity } = {}; -const spawn = System.create({ name: "upTest/spawn" }, ({ commands }) => { +const spawn = System.create({}, ({ commands }) => { entities.root = commands.spawn([label, local, world])({ [label]: "root", [local]: 1, [world]: 0 }); entities.grandchild = commands.spawn([label, local, world, parent])({ [label]: "grandchild", [local]: 100, [world]: 0, [parent]: entities.root }); entities.child = commands.spawn([label, local, world, parent])({ [label]: "child", [local]: 10, [world]: 0, [parent]: entities.root }); entities.other = commands.spawn([label, local, world])({ [label]: "other", [local]: 1000, [world]: 0 }); }); -const nest = System.create({ name: "upTest/nest" }, ({ commands }) => { commands.add(entities.grandchild, parent, entities.child); }); -const bump = System.create({ name: "upTest/bump", input: { type: "object", properties: { entity: Entity.schema } } }, ({ input, commands }) => { +const nest = System.create({}, ({ commands }) => { commands.add(entities.grandchild, parent, entities.child); }); +const bump = System.create({}, ({ commands }, input: { readonly entity: Entity }) => { commands.add(input.entity, local, 2); }); -const loop = System.create({ name: "upTest/loop" }, ({ commands }) => { +const loop = System.create({}, ({ commands }) => { commands.add(entities.root, parent, entities.grandchild); commands.spawn([label, local, world, parent])({ [label]: "under", [local]: 5, [world]: 0, [parent]: entities.child }); }); /** Puts the root under the grandchild, two levels below it, then under `other`. */ -const close = System.create({ name: "upTest/close" }, ({ commands }) => { commands.add(entities.root, parent, entities.grandchild); }); -const open = System.create({ name: "upTest/open" }, ({ commands }) => { commands.add(entities.root, parent, entities.other); }); -const orphan = System.create({ name: "upTest/orphan" }, ({ commands }) => { commands.remove(entities.grandchild, parent); }); +const close = System.create({}, ({ commands }) => { commands.add(entities.root, parent, entities.grandchild); }); +const open = System.create({}, ({ commands }) => { commands.add(entities.root, parent, entities.other); }); +const orphan = System.create({}, ({ commands }) => { commands.remove(entities.grandchild, parent); }); +/** Spawns `count` leaves under the grandchild, more than the journal keeps for a world this small. */ +const bulk: Entity[] = []; +const bulkUnder = System.create({}, ({ commands }, input: { readonly count: number }) => { + bulk.length = 0; + for (let i = 0; i < input.count; i++) bulk.push(commands.spawn([label, local, world, parent])({ [label]: "bulk", [local]: 1, [world]: 0, [parent]: entities.grandchild })); +}); const worlds = async (value: (entity: Entity) => Promise): Promise<{ [name: string]: number | null }> => Object.fromEntries(await Promise.all(Object.entries(entities).map(async ([name, entity]) => [name, await value(entity)]))); const show = System.create({ - name: "upTest/show", - kernel: "cpu", - components: { read: [visible], write: [shown] }, - up: { through: parent, read: [shown] }, + kernel: true, + components: { with: [visible, shown] }, + up: { through: parent }, }, (row, { up }) => { row[shown] = up === null ? row[visible] : up[shown] && row[visible]; }); /** Children of one archetype whose parents are in three: a matched one, one the query doesn't match, and one without `world`. */ const mixed: { [name: string]: Entity } = {}; -const spawnMixed = System.create({ name: "upTest/spawnMixed" }, ({ commands }) => { +const spawnMixed = System.create({}, ({ commands }) => { mixed.root = commands.spawn([local, world])({ [local]: 1, [world]: 0 }); mixed.anchored = commands.spawn([local, world, anchor])({ [local]: 2, [world]: 0, [anchor]: true }); mixed.unmatched = commands.spawn([world])({ [world]: 50 }); @@ -126,10 +118,10 @@ const spawnMixed = System.create({ name: "upTest/spawnMixed" }, ({ commands }) = mixed.underAnchored = commands.spawn([local, world, parent])({ [local]: 10, [world]: 0, [parent]: mixed.anchored }); mixed.underBare = commands.spawn([local, world, parent])({ [local]: 10, [world]: 0, [parent]: mixed.bare }); }); -const growParents = System.create({ name: "upTest/growParents" }, ({ commands }) => { +const growParents = System.create({}, ({ commands }) => { for (let i = 0; i < 100; i++) commands.spawn([local, world, anchor])({ [local]: 0, [world]: 0, [anchor]: true }); }); -const shift = System.create({ name: "upTest/shift", components: { write: [local], has: [anchor] } }, row => { row[local] += 1; }); +const shift = System.create({ components: { with: [local, anchor] } }, row => { row[local] += 1; }); describe("up", () => { for (const [kind, system] of [["main-thread", propagate], ["kernel", propagateKernel]] as const) { @@ -198,6 +190,27 @@ describe("up", () => { expect(await read(ecs, entities.grandchild, world)).toBe(100); }); + it("runs every row after a spawn outruns the journal, with change filters, then only the rows that changed", async () => { + await using ecs = createWorld({ [workers]: 0 }); + await ecs.run(spawn); + await ecs.run(nest); + await ecs.run(propagateChanged); + await ecs.run(bulkUnder, { count: 5000 }); + ran.length = 0; + await ecs.run(propagateChanged); + expect(ran.length).toBe(4 + 5000); + expect(ran.slice(0, 4).sort()).toEqual(["child", "grandchild", "other", "root"]); + expect(await readEach(ecs, bulk.slice(0, 3), world)).toEqual([112, 112, 112]); + ran.length = 0; + await ecs.run(bump, { entity: entities.grandchild }); + await ecs.run(propagateChanged); + expect(ran.length).toBe(1 + 5000); + expect(await readEach(ecs, [entities.grandchild, bulk[4999]], world)).toEqual([13, 14]); + ran.length = 0; + await ecs.run(propagateChanged); + expect(ran).toEqual([]); + }); + it("finds a cycle a reparent closes after the tree was built, and runs its rows again once it opens", async () => { await using ecs = createWorld({ [workers]: 0 }); await ecs.run(spawn); @@ -243,7 +256,7 @@ describe("up", () => { await using ecs = createWorld({ [workers]: 0 }); await ecs.run(spawnMixed); await ecs.run(propagateKernel); - await ecs.run(System.create({ name: "upTest/moveUnmatched" }, ({ commands }) => { commands.add(mixed.unmatched, [anchor, world], { [anchor]: true, [world]: 80 }); })); + await ecs.run(System.create({}, ({ commands }) => { commands.add(mixed.unmatched, [anchor, world], { [anchor]: true, [world]: 80 }); })); await ecs.run(propagateKernel); expect(await read(ecs, mixed.underUnmatched, world)).toBe(90); }); @@ -251,7 +264,7 @@ describe("up", () => { it("reads booleans through up", async () => { await using ecs = createWorld({ [workers]: 0 }); const tree: Entity[] = []; - await ecs.run(System.create({ name: "upTest/spawnShown" }, ({ commands }) => { + await ecs.run(System.create({}, ({ commands }) => { const top = commands.spawn([visible, shown])({ [visible]: false, [shown]: true }); tree.push(top, commands.spawn([visible, shown, parent])({ [visible]: true, [shown]: true, [parent]: top })); const other = commands.spawn([visible, shown])({ [visible]: true, [shown]: false }); @@ -280,6 +293,6 @@ describe("up", () => { it("rejects a through that isn't a reference, for untyped declarations", async () => { // @ts-expect-error through is a reference component - expect(() => System.create({ name: "upTest/untyped", components: { write: [world] }, up: { through: local, read: [world] } }, row => { row[world] = 0; })).toThrow('System "upTest/untyped": up.through "upTest/local" isn\'t a reference component.'); + expect(() => System.create({ components: { with: [world] }, up: { through: local } }, row => { row[world] = 0; })).toThrow('up.through "upTest/local" isn\'t a reference component.'); }); }); diff --git a/packages/ecs/src/implementation/world/up.ts b/packages/ecs/src/implementation/world/up.ts index 369a0221d..14706db93 100644 --- a/packages/ecs/src/implementation/world/up.ts +++ b/packages/ecs/src/implementation/world/up.ts @@ -1,9 +1,9 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import type { Up } from '../../interface/system/access.js'; +import type { UpLists } from '../../interface/system/access.js'; import type { Archetype, Storage } from './archetype.js'; import { generationOf, indexOf } from './entities.js'; import { journalEnd } from './journal.js'; -import { chunkShift } from './kernel.js'; +import { forEachNewerRow } from './ranges.js'; import { idOf, type Key, type State } from './state.js'; import { allocateArray, freeArray } from './arrays.js'; import type { Memory } from './memory.js'; @@ -44,6 +44,29 @@ const extend = (array: Int32Array, length: number, value: number): return grown; }; +// Scratch of an order build, shared by every `up` of a thread, since a build runs start to end without yielding: a +// second system reuses the first one's arrays rather than allocating its own. + +/** By index among the matched rows: its entity index, depth, archetype, parent row, and parent's archetype id when readable or -1. */ +let rowEntities = new Int32Array(0); +let depths = new Int32Array(0); +let rowParents = new Int32Array(0); +let keys = new Int32Array(0); +let owners = new Int32Array(0); +/** The matched rows' indexes, by depth, and where each depth ends. */ +let sortedRows = new Int32Array(0); +let counts = new Int32Array(0); +/** By index in `matches`: the index of its first row among the matched rows. */ +let bases = new Int32Array(0); +/** The groups of a build, laid out one after another, before the order lays them out with spare slots. */ +let builtRows = new Int32Array(0); +let builtParents = new Int32Array(0); +let builtEntities = new Int32Array(0); +let groupEnds = new Int32Array(0); +let groupDepths = new Int32Array(0); +let groupOwners = new Int32Array(0); +let groupParents = new Int32Array(0); + /** * Orders an `up` system's rows by depth through its `through` reference. A row's depth is one more than its parent's * when its parent is among the matched rows, and 0 otherwise, so rows of one depth never read each other's writes. @@ -53,7 +76,7 @@ const extend = (array: Int32Array, length: number, value: number): * of every row, patched for the entities whose row, level or parent changed. A filtered run marks the rows that may * pass, by depth, and lists only those. */ -export const createUp = (state: State, up: Up, isFiltered: boolean) => { +export const createUp = (state: State, up: UpLists, isFiltered: boolean) => { const through = idOf(state, up.through); const readKeys: readonly Key[] = up.read ?? []; const read = readKeys.map(key => idOf(state, key)); @@ -254,15 +277,67 @@ export const createUp = (state: State, up: Up, isFiltered: boolean) => { for (const archetype of matches) count += archetype.count; const all = new Int32Array(count); let at = 0; + const levels = levelOf; for (const archetype of matches) { - for (let row = 0; row < archetype.count; row++) { - const index = archetype.entities[row] >>> 0; - levelOf[index] = 1; + const rows = archetype.entities; + for (let row = 0, end = archetype.count; row < end; row++) { + const index = rows[row] >>> 0; + levels[index] = 1; all[at++] = index; } } - for (let k = 0; k < count; k++) link(all[k], parentAt(all[k])); - settle(all, count); + // Each archetype's parent column is found once, as `parentAt` finds it per entity. + const { generation, archetype: archetypeOf, length: entityCount } = state.entities; + const parents = parentOf; + at = 0; + for (const archetype of matches) { + const storage: Storage | undefined = archetype.storageById[through]; + const layout = storage?.layout; + const leaf = layout?.kind === "linear" ? layout.leaves[0] : undefined; + if (storage === undefined || leaf === undefined) { + for (let row = 0; row < archetype.count; row++) parents[all[at++]] = -1; + continue; + } + const view = storage.views[leaf.view]; + const stride = leaf.unitStride, offset = leaf.unitOffset; + for (let row = 0, end = archetype.count; row < end; row++) { + const index = all[at++]; + const parent = view[row * stride + offset]; + const target = parent >>> 0; + link(index, parent > 0 && target < entityCount && archetypeOf[target] >= 0 && generation[target] === generationOf(parent) ? target : -1); + } + } + settleAll(all, count); + }; + + /** + * Sets the level of every matched entity in `all`, the whole tree: each walks up to a root or to an entity already + * set, then sets the levels on its way back down, so each entity is walked once. A walk meeting itself is a cycle, + * and it and everything below it on the walk is cyclic. + */ + const settleAll = (all: Int32Array, count: number): void => { + const walking = ++pass; + const done = ++pass; + stack = extend(stack, count, 0); + const levels = levelOf, parents = parentOf, seen = stamps, path = stack; + for (let k = 0; k < count; k++) { + let current = all[k]; + if (seen[current] === done) continue; + let height = 0; + for (; current >= 0 && levels[current] !== unmatched; current = parents[current]) { + const stamp = seen[current]; + if (stamp === done || stamp === walking) break; + seen[current] = walking; + path[height++] = current; + } + let level = current < 0 || levels[current] === unmatched ? 0 : seen[current] === done ? levels[current] : cyclic; + for (let h = height - 1; h >= 0; h--) { + const node = path[h]; + if (level !== cyclic) level++; + levels[node] = level; + seen[node] = done; + } + } }; /** Brings the tree up to date with the journal. */ @@ -326,26 +401,6 @@ export const createUp = (state: State, up: Up, isFiltered: boolean) => { settle(pending, pending.length); }; - /** By index among the matched rows: its entity index, depth, archetype, parent row, and parent's archetype id when readable or -1. */ - let rowEntities = new Int32Array(0); - let depths = new Int32Array(0); - let rowParents = new Int32Array(0); - let keys = new Int32Array(0); - let owners = new Int32Array(0); - /** The matched rows' indexes, by depth, and where each depth ends. */ - let sortedRows = new Int32Array(0); - let counts = new Int32Array(0); - /** By index in `matches`: the index of its first row among the matched rows. */ - let bases = new Int32Array(0); - /** The groups of a build, laid out one after another, before the order lays them out with spare slots. */ - let builtRows = new Int32Array(0); - let builtParents = new Int32Array(0); - let builtEntities = new Int32Array(0); - let groupEnds = new Int32Array(0); - let groupDepths = new Int32Array(0); - let groupOwners = new Int32Array(0); - let groupParents = new Int32Array(0); - /** The matched rows in depth order from the roots. Rows on or under a parent cycle are in none. */ const levels = (matches: readonly Archetype[]): Order => { update(matches); @@ -548,15 +603,12 @@ export const createUp = (state: State, up: Up, isFiltered: boolean) => { const changedRows = (archetype: Archetype, id: number, added: boolean, since: number, each: (row: number) => void): void => { const storage = archetype.storageById[id]; if (storage === undefined) return; - const whole = storage.columnChanged > since; - const ticks = added ? storage.added : storage.changed; - const chunks = added ? storage.chunkAdded : storage.chunkChanged; - for (let chunk = 0, count = Math.ceil(archetype.count / 2 ** chunkShift); chunk < count; chunk++) { - if (!whole && !(chunks[chunk] > since)) continue; - for (let row = chunk << chunkShift, end = Math.min(archetype.count, (chunk + 1) << chunkShift); row < end; row++) { - if (whole || ticks[row] > since) each(row); - } + if (storage.columnChanged > since) { + for (let row = 0; row < archetype.count; row++) each(row); + return; } + if (added) forEachNewerRow(storage.added, storage.chunkAdded, storage.blockAdded, archetype.count, since, each); + else forEachNewerRow(storage.changed, storage.chunkChanged, storage.blockChanged, archetype.count, since, each); }; /** @@ -566,6 +618,13 @@ export const createUp = (state: State, up: Up, isFiltered: boolean) => { */ const filtered = (matches: readonly Archetype[], added: readonly number[], changed: readonly number[], since: number): Pass | null => { update(matches); + // Every row passes after a rebuild, so the pass is the whole order, with nothing to mark. + if (rebuilt) { + touched = []; + rebuilt = false; + const ordered = levels(matches); + return ordered.levels === 0 ? null : wholePass(ordered); + } const length = state.entities.length; if (dirty.length < length) dirty = new Uint8Array(Math.max(length, 2 * dirty.length)); for (const bucket of buckets) if (bucket !== undefined) bucket.length = 0; @@ -672,6 +731,22 @@ export const createUp = (state: State, up: Up, isFiltered: boolean) => { return { next, clear }; }; + /** Every row of `ordered`, a depth at a time. */ + const wholePass = (ordered: Order): Pass => { + let depth = 0; + const next = (): readonly Run[] | null => { + if (depth >= ordered.levels) return null; + const runs: Run[] = []; + for (let group = ordered.depthGroups[depth]; group < ordered.depthGroups[depth + 1]; group++) { + if (ordered.groupEnds[group] === ordered.groupStarts[group]) continue; + runs.push({ owner: ordered.groupOwners[group], parent: ordered.groupParents[group], rows: ordered.rows, parents: ordered.parents, start: ordered.groupStarts[group], end: ordered.groupEnds[group] }); + } + depth++; + return runs; + }; + return { next, clear: noClear }; + }; + /** Frees what it holds in the arena and stops its reading of the journal, once nothing runs it again. */ const release = (): void => { state.journal.readers--; @@ -681,5 +756,7 @@ export const createUp = (state: State, up: Up, isFiltered: boolean) => { list = parentList = new Int32Array(0); }; - return { levels, filtered, release }; + return { levels, filtered, wholePass, release }; }; + +const noClear = (): void => {}; diff --git a/packages/ecs/src/implementation/world/wasm-kernels.test.ts b/packages/ecs/src/implementation/world/wasm-kernels.test.ts index 6216563bc..930dd4fd4 100644 --- a/packages/ecs/src/implementation/world/wasm-kernels.test.ts +++ b/packages/ecs/src/implementation/world/wasm-kernels.test.ts @@ -2,7 +2,6 @@ import { describe, expect, it } from "vitest"; import { Component } from "../../interface/component/component.js"; import { Entity } from "../../interface/entity/entity.js"; -import { Resource } from "../../interface/resource/resource.js"; import type { Schema } from "../../interface/schema/schema.js"; import { System } from "../../interface/system/system.js"; import type { World } from "../../interface/world/world.js"; @@ -13,6 +12,7 @@ import { createWorld } from "./create.js"; import { bindKernel } from "./kernels.js"; import { createState } from "./state.js"; import { workers } from "../../interface/resources/workers.js"; +import type { F32, I32 } from "../../interface/math/index.js"; type Vec2 = readonly [number, number]; const vec2 = { type: "array", items: { type: "number", precision: 1 }, minItems: 2, maxItems: 2 } as const satisfies Schema; @@ -28,29 +28,21 @@ const target = Symbol("wasmTest/target"); declare module "@adobe/ecs" { export interface Components { - [position]: { type: readonly [number, number] }; - [velocity]: { type: readonly [number, number] }; - [bounces]: { type: number }; + [position]: { type: Schema.Type<{ type: "array"; items: { type: "number"; precision: 1 }; minItems: 2; maxItems: 2; layout: "storage" }> }; + [velocity]: { type: Schema.Type<{ type: "array"; items: { type: "number"; precision: 1 }; minItems: 2; maxItems: 2 }> }; + [bounces]: { type: I32 }; [flipped]: { type: boolean }; [score]: { type: number }; [target]: { type: Entity }; } export interface Resources { - [seconds]: { type: number }; - [floor]: { type: number }; + [seconds]: { type: number; default: 0.016666666666666666 }; + [floor]: { type: F32; default: -2.5 }; } } -Component.register(position, { type: { ...vec2, layout: "storage" } }); -Component.register(velocity, { type: vec2 }); -Component.register(bounces, { type: { type: "integer", minimum: -2147483648, maximum: 2147483647 } }); -Component.register(flipped, { type: { type: "boolean" } }); -Component.register(score, { type: { type: "number" } }); -Component.register(target, { type: Entity.schema }); -Resource.register(seconds, { type: { type: "number", default: 1 / 60 } }); -Resource.register(floor, { type: { type: "number", precision: 1, default: -2.5 } }); -const seed = System.create({ name: "wasmTest/seed", input: { type: "object", properties: { count: { type: "integer" } } } }, ({ input, commands }) => { +const seed = System.create({}, ({ commands }, input: { readonly count: number }) => { let previous = Entity.none; for (let i = 0; i < input.count; i++) { previous = commands.spawn([position, velocity, bounces, flipped, score, target])({ [position]: [i * 0.37, (i % 13) * 0.71], [velocity]: [Math.sin(i) * 3, Math.cos(i) * 5], [bounces]: 0, [flipped]: false, [score]: 0, [target]: previous }); @@ -58,7 +50,7 @@ const seed = System.create({ name: "wasmTest/seed", input: { type: "object", pro }); /** Despawns every seventh entity and spawns replacements, so some targets go stale and their indexes are reused. */ -const cull = System.create({ name: "wasmTest/cull", input: { type: "object", properties: { frame: { type: "integer" } } }, components: { read: [position], has: [bounces] } }, (row, { input, commands }) => { +const cull = System.create({ components: { with: [position, bounces] } }, (row, { commands }, input: { readonly frame: number }) => { if ((row.entity >>> 0) % 7 !== input.frame % 7) return; commands.despawn(row.entity); commands.spawn([position, velocity, bounces, flipped, score, target])({ [position]: row[position], [velocity]: [1, 1], [bounces]: 0, [flipped]: true, [score]: 0, [target]: row.entity }); @@ -67,10 +59,8 @@ const cull = System.create({ name: "wasmTest/cull", input: { type: "object", pro const halve = (value: number) => value * 0.5; const integrate = System.create({ - name: "wasmTest/integrate", - kernel: "cpu", - components: { write: [position, velocity] }, - resources: { read: [seconds] }, + kernel: true, + components: { with: [position, velocity] }, }, (row, { resources }) => { const [x, y] = row[position]; const [vx, vy] = row[velocity]; @@ -80,10 +70,8 @@ const integrate = System.create({ }); const bounce = System.create({ - name: "wasmTest/bounce", - kernel: "cpu", - components: { write: [position, velocity, bounces, flipped] }, - resources: { read: [floor] }, + kernel: true, + components: { with: [position, velocity, bounces, flipped] }, after: [integrate], }, (row, { resources }) => { const [x, y] = row[position]; @@ -100,9 +88,8 @@ const bounce = System.create({ }); const tally = System.create({ - name: "wasmTest/tally", - kernel: "cpu", - components: { read: [bounces, flipped, position], write: [score] }, + kernel: true, + components: { with: [bounces, flipped, position, score] }, after: [bounce], }, row => { let total = 0; @@ -116,20 +103,16 @@ const tally = System.create({ }); const scale = System.create({ - name: "wasmTest/scale", - kernel: "cpu", - input: { type: "object", properties: { factor: { type: "number" } } }, - components: { write: [score] }, -}, (row, { input }) => { + kernel: true, + components: { with: [score] }, +}, (row, _context, input: { readonly factor: number }) => { row[score] = row[score] * input.factor; }); /** Component-wise vector math: one f32x4 operation per row. */ const drift = System.create({ - name: "wasmTest/drift", - kernel: "cpu", - components: { read: [velocity], write: [position] }, - resources: { read: [seconds] }, + kernel: true, + components: { with: [velocity, position] }, after: [tally], }, (row, { resources }) => { const [x, y] = row[position]; @@ -140,9 +123,8 @@ const drift = System.create({ /** Runs only for rows whose bounces changed since it last ran. */ const react = System.create({ - name: "wasmTest/react", - kernel: "cpu", - components: { write: [score], changed: [bounces] }, + kernel: true, + components: { with: [score], changed: [bounces] }, after: [tally], }, row => { row[score] = row[score] + 1000; @@ -150,10 +132,9 @@ const react = System.create({ /** A lookup per row: found targets pull, missing or stale ones cost a point. */ const chase = System.create({ - name: "wasmTest/chase", - kernel: "cpu", - components: { read: [target], write: [score] }, - lookup: { targets: { read: [position, flipped] } }, + kernel: true, + components: { with: [target, score] }, + lookup: { targets: { with: [position, flipped] } }, after: [react, drift], }, (row, { lookup: { targets } }) => { const other = targets(row[target]); @@ -165,7 +146,7 @@ const chase = System.create({ row[score] = row[score] + (other[flipped] ? x : y); }); -const main = System.create({ name: "wasmTest/main", systems: [integrate, bounce, tally, drift, react, chase] }); +const main = System.create({ systems: [integrate, bounce, tally, drift, react, chase] }); const snapshotted = [position, velocity, bounces, flipped, score] as const; @@ -191,21 +172,21 @@ const simulate = async (wasm: boolean, simd = false): Promise => { }; /** Counts the runs of rows whose score changed. */ -const recount = System.create({ name: "wasmTest/recount", kernel: "cpu", components: { write: [bounces], changed: [score] } }, row => { +const recount = System.create({ kernel: true, components: { with: [bounces], changed: [score] } }, row => { row[bounces] += 1; }); const chosen = new Set(); -const rescore = System.create({ name: "wasmTest/rescore", components: { write: [score] } }, row => { +const rescore = System.create({ components: { with: [score] } }, row => { if (chosen.has(row.entity)) row[score] += 1; }); /** Keeps ticks of `score` without observing its changes, so kernels writing it mute their stamps. */ -const scoreAdded = System.create({ name: "wasmTest/scoreAdded", components: { added: [score] } }, () => {}); -const raise = System.create({ name: "wasmTest/raise", kernel: "cpu", components: { write: [score] } }, row => { +const scoreAdded = System.create({ components: { added: [score] } }, () => {}); +const raise = System.create({ kernel: true, components: { with: [score] } }, row => { row[score] += 1; }); let raised = 0; -const countRaised = System.create({ name: "wasmTest/countRaised", components: { changed: [score] } }, () => { raised++; }); +const countRaised = System.create({ components: { changed: [score] } }, () => { raised++; }); describe("WASM kernels", () => { it("stamp what they write once a change filter observes a component that already kept ticks", async () => { diff --git a/packages/ecs/src/implementation/world/wasm.test.ts b/packages/ecs/src/implementation/world/wasm.test.ts index d2b72a00e..a0fc85d89 100644 --- a/packages/ecs/src/implementation/world/wasm.test.ts +++ b/packages/ecs/src/implementation/world/wasm.test.ts @@ -138,12 +138,14 @@ describe("generateWasm with SIMD", () => { const floats = columns.map((_, k) => new Float32Array(memory.buffer, bases[k], 512)); const ticks = new Float64Array(memory.buffer, 4096 * 8, 64); const chunks = new Float64Array(memory.buffer, 4096 * 9, 4); + const blocks = new Float64Array(memory.buffer, 4096 * 10, 4); fill(floats, ticks); const runKernel: unknown = new WebAssembly.Instance(new WebAssembly.Module(kernel.bytes), { env: { memory }, math: importsOf(kernel.imports) }).exports.run; if (typeof runKernel !== "function") throw new Error("no run export"); - // f64 columns are ticks, then their chunks. - runKernel(0, rows, tick, -1, ...bases.map((base, k) => columns[k].view !== "f64" ? base : columns[k].component === -2 ? 4096 * 9 : 4096 * 8)); - return { kernel, floats, ticks, chunks }; + // f64 columns are ticks, then their chunks, then their blocks. + const tickBase = (component: number): number => component === -3 ? 4096 * 10 : component === -2 ? 4096 * 9 : 4096 * 8; + runKernel(0, rows, tick, -1, ...bases.map((base, k) => columns[k].view !== "f64" ? base : tickBase(columns[k].component))); + return { kernel, floats, ticks, chunks, blocks }; }; const column = (component: number, stride: number, offset: number): WasmColumn => ({ view: "f32", stride, offset, boolean: false, component }); const read = (k: number): Expression => ({ kind: "column", column: k }); @@ -201,7 +203,7 @@ describe("generateWasm with SIMD", () => { column: leaf, value: add(add(local(leaf), { kind: "binary", operator: "*", left: local(leaves + leaf), right: { kind: "args", path: [] } }), { kind: "number", value: leaf + 1 }), })), - { kind: "stamp", column: 2 * leaves, chunks: 2 * leaves + 1 }, + { kind: "stamp", column: 2 * leaves, chunks: 2 * leaves + 1, blocks: 2 * leaves + 2 }, ], }; const columns: WasmColumn[] = [ @@ -209,6 +211,7 @@ describe("generateWasm with SIMD", () => { ...Array.from({ length: leaves }, (_, leaf) => column(1, stride, 4 * leaf)), { view: "f64", stride: 8, offset: 0, boolean: false, component: -1 }, { view: "f64", stride: 8, offset: 0, boolean: false, component: -2 }, + { view: "f64", stride: 8, offset: 0, boolean: false, component: -3 }, ]; const kernel = generateWasm(program, columns, () => "number"); if (kernel === null) throw new Error("expected a kernel"); @@ -221,7 +224,7 @@ describe("generateWasm with SIMD", () => { const runKernel: unknown = new WebAssembly.Instance(new WebAssembly.Module(kernel.bytes), { env: { memory }, math: importsOf(kernel.imports) }).exports.run; if (typeof runKernel !== "function") throw new Error("no run export"); const [start, end] = [3, 22]; - runKernel(start, end, 7, -1, ...columns.slice(0, leaves).map(() => 4096), ...columns.slice(0, leaves).map(() => 8192), 16384, 20480, 0.5); + runKernel(start, end, 7, -1, ...columns.slice(0, leaves).map(() => 4096), ...columns.slice(0, leaves).map(() => 8192), 16384, 20480, 24576, 0.5); const row = (index: number): number => Math.floor(index / leaves); expect([...p]).toEqual([...p].map((_, i) => row(i) >= start && row(i) < end ? i + (2 * i + 100) * 0.5 + (i % leaves) + 1 : i)); expect([...ticks]).toEqual([...ticks].map((_, i) => i >= start && i < end ? 7 : 1)); @@ -230,11 +233,12 @@ describe("generateWasm with SIMD", () => { it("blends stores and tick stamps under an if, per lane", () => { const positive: Expression = { kind: "binary", operator: ">", left: read(0), right: { kind: "number", value: 0 } }; - const program: Program = { locals: 0, body: [{ kind: "if", test: positive, consequent: [{ kind: "store", column: 1, value: read(0) }, { kind: "stamp", column: 2, chunks: 3 }], alternate: [] }] }; + const program: Program = { locals: 0, body: [{ kind: "if", test: positive, consequent: [{ kind: "store", column: 1, value: read(0) }, { kind: "stamp", column: 2, chunks: 3, blocks: 4 }], alternate: [] }] }; const ticksColumn: WasmColumn = { view: "f64", stride: 8, offset: 0, boolean: false, component: -1 }; const chunksColumn: WasmColumn = { view: "f64", stride: 8, offset: 0, boolean: false, component: -2 }; + const blocksColumn: WasmColumn = { view: "f64", stride: 8, offset: 0, boolean: false, component: -3 }; const inputs = [1, -1, 2, -2, 0, 3, NaN, 4, -5, 6]; - const { kernel, floats, ticks, chunks } = runVector(program, [column(0, 4, 0), column(1, 4, 0), ticksColumn, chunksColumn], ([input, out], tickValues) => { + const { kernel, floats, ticks, chunks, blocks } = runVector(program, [column(0, 4, 0), column(1, 4, 0), ticksColumn, chunksColumn, blocksColumn], ([input, out], tickValues) => { input.set(inputs); out.fill(-7); tickValues.fill(1); @@ -243,6 +247,32 @@ describe("generateWasm with SIMD", () => { expect([...floats[1].subarray(0, 10)]).toEqual(inputs.map(x => x > 0 ? x : -7)); expect([...ticks.subarray(0, 10)]).toEqual(inputs.map(x => x > 0 ? 7 : 1)); expect(chunks[0]).toBe(7); + expect(blocks[0]).toBe(7); + }); + + /** Runs `out = in` with its stamps where `in > 0`, rows `start` to `end` of 2048, four at a time; the blocks then stamped. */ + const stampedFourAtATime = (start: number, end: number, positive: readonly number[]): number[] => { + const test: Expression = { kind: "binary", operator: ">", left: read(0), right: { kind: "number", value: 0 } }; + const program: Program = { locals: 0, body: [{ kind: "if", test, consequent: [{ kind: "store", column: 1, value: read(0) }, { kind: "stamp", column: 2, chunks: 3, blocks: 4 }], alternate: [] }] }; + const dense = (component: number): WasmColumn => ({ view: "f64", stride: 8, offset: 0, boolean: false, component }); + const kernel = generateWasm(program, [column(0, 4, 0), column(1, 4, 0), dense(-1), dense(-2), dense(-3)], () => "number"); + if (kernel === null) throw new Error("expected a kernel"); + expect(kernel.vectorized).toBe(true); + const memory = new WebAssembly.Memory({ initial: 2, maximum: 65536, shared: true }); + const input = new Float32Array(memory.buffer, 0, 2048).fill(-1); + for (const row of positive) input[row] = 1; + const blocks = new Float64Array(memory.buffer, 65536 + 1024, 32); + const runKernel = new WebAssembly.Instance(new WebAssembly.Module(kernel.bytes), { env: { memory }, math: importsOf(kernel.imports) }).exports.run; + if (typeof runKernel !== "function") throw new Error("no run export"); + runKernel(start, end, 7, -1, 0, 8192, 16384, 65536, 65536 + 1024); + return [...blocks].flatMap((tick, block) => tick === 7 ? [block] : []); + }; + + it("stamps the blocks of rows a conditional write reaches four rows at a time", () => { + // 300 rows are 75 whole groups, so no row runs one at a time. + expect(stampedFourAtATime(0, 300, [200])).toContain(200 >> 6); + // From row 1 the last group, rows 61 to 64, straddles blocks 0 and 1, and only its last row writes. + expect(stampedFourAtATime(1, 65, [64])).toEqual([0, 1]); }); it("stays scalar for operations without an exact f32x4 equivalent", () => { @@ -253,7 +283,7 @@ describe("generateWasm with SIMD", () => { }); describe("generateWasm with change filters", () => { - it("runs only rows with a newer tick, skipping chunks whose tick isn't newer", () => { + it("runs only rows with a newer tick, skipping chunks and blocks whose tick isn't newer", () => { for (const simd of [false, true]) { // out = in, for rows whose filter tick is newer than lastRun 5. const columns: WasmColumn[] = [{ view: "f32", stride: 4, offset: 0, boolean: false, component: 0 }, { view: "f32", stride: 4, offset: 0, boolean: false, component: 1 }]; @@ -266,46 +296,107 @@ describe("generateWasm with change filters", () => { const output = new Float32Array(memory.buffer, 16384, rows).fill(-1); const ticks = new Float64Array(memory.buffer, 32768, rows); const chunks = new Float64Array(memory.buffer, 65536, 3); - // Chunk 0's chunk tick is stale-new (8) but its rows are old; chunk 1 has three new rows; chunk 2 is old throughout. + const blocks = new Float64Array(memory.buffer, 66560, 48); + // Chunk 0's chunk and block ticks are stale-new (8) but its rows are old; chunk 1 has three new rows in + // blocks marked new; chunk 2 is old throughout. chunks.set([8, 9, 3]); - for (const row of [1030, 1500, 2047]) ticks[row] = 9; + blocks.fill(8, 0, 16).fill(3, 16); + for (const row of [1030, 1500, 2047]) { + ticks[row] = 9; + blocks[row >> 6] = 9; + } + ticks[1100] = 9; ticks[2050] = 9; const run = new WebAssembly.Instance(new WebAssembly.Module(kernel.bytes), { env: { memory }, math: importsOf(kernel.imports) }).exports.run; if (typeof run !== "function") throw new Error("no run export"); - run(0, rows, 10, 5, 0, 16384, 32768, 65536); + run(0, rows, 10, 5, 0, 16384, 32768, 65536, 66560); const written = [...output].flatMap((value, row) => value === -1 ? [] : [row]); - // Row 2050 has a new tick, but its chunk says nothing in it changed, so it's skipped by design. + // Rows 1100 and 2050 have new ticks, but their block or chunk says nothing in it changed, so they're skipped by design. expect([simd, kernel.vectorized, written]).toEqual([simd, false, [1030, 1500, 2047]]); + // From a start inside a block, that block's tick is still checked. + output.fill(-1); + run(1029, 1040, 10, 5, 0, 16384, 32768, 65536, 66560); + expect([...output].flatMap((value, row) => value === -1 ? [] : [row])).toEqual([1030]); // lastRun below 0 passes everything. output.fill(-1); - run(0, rows, 10, -1, 0, 16384, 32768, 65536); + run(0, rows, 10, -1, 0, 16384, 32768, 65536, 66560); expect(output.every((value, row) => value === row)).toBe(true); } }); }); -describe("generateWasm chunk stamps", () => { +describe("generateWasm with up", () => { + it("refuses change filters, which its runner applies by listing only passing rows", () => { + const columns: WasmColumn[] = [{ view: "f32", stride: 4, offset: 0, boolean: false, component: 0 }]; + const program: Program = { locals: 0, up: true, body: [{ kind: "store", column: 0, value: { kind: "number", value: 1 } }] }; + expect(() => generateWasm(program, columns, () => "number", true, 1)).toThrow("takes no change filters"); + expect(generateWasm(program, columns, () => "number", true, 0)).not.toBeNull(); + }); +}); + +describe("generateWasm with several change filters", () => { + it("runs rows either filter passes, a chunk with most blocks passing as one run", () => { + for (const simd of [false, true]) { + const columns: WasmColumn[] = [{ view: "f32", stride: 4, offset: 0, boolean: false, component: 0 }, { view: "f32", stride: 4, offset: 0, boolean: false, component: 1 }]; + const kernel = generateWasm({ locals: 0, body: [{ kind: "store", column: 1, value: { kind: "column", column: 0 } }] }, columns, () => "number", simd, 2); + if (kernel === null) throw new Error("expected a kernel"); + const rows = 2100; + const memory = new WebAssembly.Memory({ initial: 4, maximum: 65536, shared: true }); + const input = new Float32Array(memory.buffer, 0, rows); + for (let row = 0; row < rows; row++) input[row] = row; + const output = new Float32Array(memory.buffer, 16384, rows).fill(-1); + // Each filter's rows, chunks and blocks. + const ticks = [new Float64Array(memory.buffer, 32768, rows).fill(1), new Float64Array(memory.buffer, 65536 + 4096, rows).fill(1)]; + const chunks = [new Float64Array(memory.buffer, 65536, 3).fill(1), new Float64Array(memory.buffer, 65536 + 64, 3).fill(1)]; + const blocks = [new Float64Array(memory.buffer, 65536 + 1024, 48).fill(1), new Float64Array(memory.buffer, 65536 + 2048, 48).fill(1)]; + const mark = (filter: number, row: number): void => { + ticks[filter][row] = chunks[filter][row >> 10] = blocks[filter][row >> 6] = 9; + }; + // Chunk 0: the first filter marks ten blocks of sixteen, so the chunk runs whole, and a row in an + // unmarked block with a new tick runs too. Chunk 1: each filter marks a row; a new tick in an unmarked + // block is skipped. + for (let block = 0; block < 10; block++) mark(0, block * 64 + 1); + ticks[1][900] = 9; + chunks[1][0] = 9; + mark(0, 1030); + mark(1, 1500); + ticks[1][1700] = 9; + const run = new WebAssembly.Instance(new WebAssembly.Module(kernel.bytes), { env: { memory }, math: importsOf(kernel.imports) }).exports.run; + if (typeof run !== "function") throw new Error("no run export"); + run(0, rows, 10, 5, 0, 16384, 32768, 65536 + 4096, 65536, 65536 + 64, 65536 + 1024, 65536 + 2048); + const written = [...output].flatMap((value, row) => value === -1 ? [] : [row]); + expect([simd, written]).toEqual([simd, [...Array.from({ length: 10 }, (_, block) => block * 64 + 1), 900, 1030, 1500]]); + } + }); +}); + +describe("generateWasm chunk and block stamps", () => { const columns: WasmColumn[] = [ { view: "f32", stride: 4, offset: 0, boolean: false, component: 0 }, { view: "f64", stride: 8, offset: 0, boolean: false, component: -1 }, { view: "f64", stride: 8, offset: 0, boolean: false, component: -2 }, + { view: "f64", stride: 8, offset: 0, boolean: false, component: -3 }, ]; - const run = (program: Program, start: number, end: number): number[] => { + /** The blocks whose tick is 7. */ + const stampedBlocks = (blocks: Float64Array): number[] => [...blocks].flatMap((tick, block) => tick === 7 ? [block] : []); + const run = (program: Program, start: number, end: number): { readonly chunks: number[]; readonly blocks: number[] } => { const kernel = generateWasm(program, columns, () => "number"); if (kernel === null) throw new Error("expected a kernel"); const memory = new WebAssembly.Memory({ initial: 4, maximum: 65536, shared: true }); new Float32Array(memory.buffer, 0, 4096).fill(1); const chunks = new Float64Array(memory.buffer, 65536, 4); + const blocks = new Float64Array(memory.buffer, 66560, 64); const runKernel = new WebAssembly.Instance(new WebAssembly.Module(kernel.bytes), { env: { memory }, math: importsOf(kernel.imports) }).exports.run; if (typeof runKernel !== "function") throw new Error("no run export"); - runKernel(start, end, 7, -1, 0, 32768, 65536); - return [...chunks]; + runKernel(start, end, 7, -1, 0, 32768, 65536, 66560); + return { chunks: [...chunks], blocks: stampedBlocks(blocks) }; }; - const stamp: Program.Statement = { kind: "stamp", column: 1, chunks: 2 }; + const stamp: Program.Statement = { kind: "stamp", column: 1, chunks: 2, blocks: 3 }; + const range = (first: number, last: number): number[] => Array.from({ length: last - first + 1 }, (_, k) => first + k); - it("stamps every chunk of the range once when every row stamps", () => { - expect(run({ locals: 0, body: [stamp] }, 1000, 2100)).toEqual([7, 7, 7, 0]); - expect(run({ locals: 0, body: [stamp] }, 5, 5)).toEqual([0, 0, 0, 0]); + it("stamps every chunk and block of the range once when every row stamps", () => { + expect(run({ locals: 0, body: [stamp] }, 1000, 2100)).toEqual({ chunks: [7, 7, 7, 0], blocks: range(1000 >> 6, 2099 >> 6) }); + expect(run({ locals: 0, body: [stamp] }, 5, 5)).toEqual({ chunks: [0, 0, 0, 0], blocks: [] }); }); it("stamps per row when a row can end before its stamp", () => { @@ -323,10 +414,12 @@ describe("generateWasm chunk stamps", () => { const values = new Float32Array(memory.buffer, 0, 4096).fill(1); values.fill(2, 1024, 2048); const chunks = new Float64Array(memory.buffer, 65536, 4); + const blocks = new Float64Array(memory.buffer, 66560, 64); const runKernel = new WebAssembly.Instance(new WebAssembly.Module(kernel.bytes), { env: { memory }, math: importsOf(kernel.imports) }).exports.run; if (typeof runKernel !== "function") throw new Error("no run export"); - runKernel(0, 3000, 7, -1, 0, 32768, 65536); + runKernel(0, 3000, 7, -1, 0, 32768, 65536, 66560); expect([...chunks]).toEqual([7, 0, 7, 0]); + expect(stampedBlocks(blocks)).toEqual([...range(0, 15), ...range(32, 2999 >> 6)]); }); }); @@ -380,3 +473,87 @@ describe("generateWasm reductions", () => { } }); }); + +describe("generateWasm with f64 lanes, as kernels computing at the body's precision run", () => { + const column = (component: number, stride: number, offset: number): WasmColumn => ({ view: "f32", stride, offset, boolean: false, component }); + const ticks = (component: number): WasmColumn => ({ view: "f64", stride: 8, offset: 0, boolean: false, component }); + const read = (k: number): Expression => ({ kind: "column", column: k }); + const number = (value: number): Expression => ({ kind: "number", value }); + const binary = (operator: "+" | "-" | "*" | "/" | ">" | "<=", left: Expression, right: Expression): Expression => ({ kind: "binary", operator, left, right }); + const math = (name: string, ...parameters: Expression[]): Expression => ({ kind: "math", name, arguments: parameters }); + + /** + * Runs `program` over rows `start` to `end` with f64 lanes, and as scalar WASM, which is JS's semantics; returns + * whether the lanes ran, and each run's memory as bits, so NaN and -0 compare exactly. + */ + const both = (program: Program, columns: readonly WasmColumn[], fill: (floats: readonly Float32Array[]) => void, start: number, end: number) => { + const results = [true, false].map(simd => { + const kernel = generateWasm(program, columns, () => "number", simd, 0, [], [], [], [], [], false); + if (kernel === null) throw new Error("expected a kernel"); + const memory = new WebAssembly.Memory({ initial: 1, maximum: 65536, shared: true }); + // f32 components at 4096 × (component + 1); ticks, then their chunks, then their blocks, after them. + const baseOf = (info: WasmColumn): number => info.view === "f64" ? 4096 * (8 - info.component) : 4096 * (info.component + 1); + const floats = [0, 1, 2].map(component => new Float32Array(memory.buffer, 4096 * (component + 1), 1024)); + new Float64Array(memory.buffer, 4096 * 9, 1536).fill(1); + fill(floats); + const run: unknown = new WebAssembly.Instance(new WebAssembly.Module(kernel.bytes), { env: { memory }, math: importsOf(kernel.imports) }).exports.run; + if (typeof run !== "function") throw new Error("no run export"); + run(start, end, 7, -1, ...columns.map(baseOf), 0.75); + return { vectorized: kernel.vectorized, bits: [...new Uint32Array(memory.buffer, 4096, 4096 * 3)] }; + }); + return { vectorized: results[0].vectorized, same: results[0].bits.every((bits, i) => bits === results[1].bits[i]) }; + }; + /** Values where f32 arithmetic loses what f64 keeps: cancellation, rounding at each step, NaN, infinities and -0. */ + const hard = (i: number): number => [1 + 2 ** -23, 1 + 2 ** -22, 3.4e38, -0, NaN, Infinity, 1e-45, 0.1, -7.25, 16777217][i % 10] * (1 + (i % 7) * 2 ** -20); + + it("computes dense f32 columns two rows at a time in f64, as the body does, through the tail", () => { + // out = a * a - b, which cancels in f32; and out2 = sqrt(|a|) / b + min(a, args) * fround(b) - floor(a). + const program: Program = { + locals: 0, + body: [ + { kind: "store", column: 2, value: binary("-", binary("*", read(0), read(0)), read(1)) }, + { kind: "store", column: 3, value: binary("-", binary("+", binary("/", math("sqrt", math("abs", read(0))), read(1)), binary("*", math("min", read(0), { kind: "args", path: [] }), math("fround", read(1)))), math("floor", read(0))) }, + ], + }; + const columns = [column(0, 4, 0), column(1, 4, 0), column(2, 4, 0), column(2, 4, 2048)]; + for (const [start, end] of [[0, 300], [3, 22], [1, 2]] as const) { + expect([start, end, both(program, columns, ([a, b]) => { for (let i = 0; i < 1024; i++) { a[i] = hard(i); b[i] = hard(i + 1); } }, start, end)]).toEqual([start, end, { vectorized: true, same: true }]); + } + }); + + it("blends stores and stamps under an if per lane, in f64", () => { + const program: Program = { + locals: 1, + body: [ + { kind: "set", local: 0, value: binary("*", read(0), read(0)) }, + { kind: "if", test: binary(">", { kind: "local", local: 0 }, read(1)), consequent: [{ kind: "store", column: 2, value: binary("-", { kind: "local", local: 0 }, read(1)) }, { kind: "stamp", column: 3, chunks: 4, blocks: 5 }], alternate: [{ kind: "store", column: 2, value: { kind: "conditional", test: binary("<=", read(0), number(0)), consequent: number(-1), alternate: read(1) } }] }, + ], + }; + const columns = [column(0, 4, 0), column(1, 4, 0), column(2, 4, 0), ticks(-1), ticks(-2), ticks(-3)]; + expect(both(program, columns, ([a, b]) => { for (let i = 0; i < 1024; i++) { a[i] = hard(i); b[i] = hard(i + 3); } }, 5, 133)).toEqual({ vectorized: true, same: true }); + }); + + // A padded vec3's elements aren't whole vectors, and f64 lanes don't fuse within a row, so it runs a row at a time. + for (const [name, stride, leaves, vectorized] of [["vec2", 8, 2, true], ["tight vec3", 12, 3, true], ["padded vec3", 16, 3, false]] as const) { + it(`computes ${name} elements ${vectorized ? "in f64 lanes" : "a row at a time"}, as the body does`, () => { + // p = p * v - v * args, leaf by leaf, with p.x and v.x reading cancelling values. + const all = Array.from({ length: leaves }, (_, leaf) => leaf); + const program: Program = { locals: 0, body: all.map(leaf => ({ kind: "store", column: leaf, value: binary("-", binary("*", read(leaf), read(leaves + leaf)), binary("*", read(leaves + leaf), { kind: "args", path: [] })) })) }; + const columns = [...all.map(leaf => column(0, stride, 4 * leaf)), ...all.map(leaf => column(1, stride, 4 * leaf))]; + expect(both(program, columns, ([p, v]) => { for (let i = 0; i < 1024; i++) { p[i] = hard(i); v[i] = hard(i + 1); } }, 1, 63)).toEqual({ vectorized, same: true }); + }); + } + + it("leaves explicit kernels' f32 lanes, which round each step", () => { + const program: Program = { locals: 0, body: [{ kind: "store", column: 2, value: binary("-", binary("*", read(0), read(0)), read(1)) }] }; + const columns = [column(0, 4, 0), column(1, 4, 0), column(2, 4, 0)]; + const kernel = generateWasm(program, columns, () => "number", true, 0, [], [], [], [], [], true); + const memory = new WebAssembly.Memory({ initial: 1, maximum: 65536, shared: true }); + new Float32Array(memory.buffer, 4096, 4).fill(1 + 2 ** -23); + new Float32Array(memory.buffer, 8192, 4).fill(1 + 2 ** -22); + const run: unknown = kernel === null ? null : new WebAssembly.Instance(new WebAssembly.Module(kernel.bytes), { env: { memory }, math: importsOf(kernel.imports) }).exports.run; + if (typeof run !== "function") throw new Error("no run export"); + run(0, 4, 7, -1, 4096, 8192, 12288); + expect([kernel?.vectorized, new Float32Array(memory.buffer, 12288, 1)[0]]).toEqual([true, 0]); + }); +}); diff --git a/packages/ecs/src/implementation/world/wasm.ts b/packages/ecs/src/implementation/world/wasm.ts index fe6846f86..fc2fcb837 100644 --- a/packages/ecs/src/implementation/world/wasm.ts +++ b/packages/ecs/src/implementation/world/wasm.ts @@ -1,5 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { chunkShift, type Program } from './kernel.js'; +import { blockShift, chunkShift, type Program } from './kernel.js'; import type { Reduction } from '../../interface/resource/reduction.js'; import type { View } from '../../interface/registry/layout.js'; import { withoutIntrinsics } from './intrinsics.js'; @@ -78,7 +78,7 @@ export type WasmKernel = { readonly scalars: readonly WasmScalar[]; /** The JS functions it imports, by name. */ readonly imports: readonly string[]; - /** Whether it runs four rows at a time with SIMD, at storage precision. */ + /** Whether it runs rows several at a time with SIMD: in f32 at storage precision, or in f64 as the body computes. */ readonly vectorized: boolean; }; @@ -133,7 +133,7 @@ const op = { i32Const: 0x41, f64Const: 0x44, i32ReinterpretF32: 0xbc, i64ReinterpretF64: 0xbd, i32LtU: 0x49, i32GtU: 0x4b, i32LeU: 0x4d, i32GtS: 0x4a, f64Load2: 0x2b, i32Eqz: 0x45, i32GeS: 0x4e, f64Eq: 0x61, f64Ne: 0x62, f64Lt: 0x63, f64Gt: 0x64, f64Le: 0x65, f64Ge: 0x66, - i32Eq: 0x46, i32LtS: 0x48, i32LeS: 0x4c, i32Clz: 0x67, i32Add: 0x6a, i32Sub: 0x6b, i32Mul: 0x6c, i32And: 0x71, i32Or: 0x72, i32Xor: 0x73, i32Shl: 0x74, i32ShrS: 0x75, i32ShrU: 0x76, + i32Eq: 0x46, i32LtS: 0x48, i32LeS: 0x4c, i32Clz: 0x67, i32Ctz: 0x68, i32Popcnt: 0x69, i32Add: 0x6a, i32Sub: 0x6b, i32Mul: 0x6c, i32And: 0x71, i32Or: 0x72, i32Xor: 0x73, i32Shl: 0x74, i32ShrS: 0x75, i32ShrU: 0x76, f64Abs: 0x99, f64Neg: 0x9a, f64Ceil: 0x9b, f64Floor: 0x9c, f64Trunc: 0x9d, f64Sqrt: 0x9f, f64Add: 0xa0, f64Sub: 0xa1, f64Mul: 0xa2, f64Div: 0xa3, f64Min: 0xa4, f64Max: 0xa5, i32WrapI64: 0xa7, i32GeU: 0x4f, i32Ne: 0x47, i64Const: 0x42, i64ShrU: 0x88, i64RemS: 0x81, f64ConvertI64S: 0xb9, f64Copysign: 0xa6, f32DemoteF64: 0xb6, f64ConvertI32S: 0xb7, f64ConvertI32U: 0xb8, f64PromoteF32: 0xbb, @@ -210,6 +210,11 @@ export const generateWasm = ( indexLookups: readonly (WasmIndexLookup | null)[] = [], /** By probe, its lookup: `KernelDescription.probes[k].lookup`. */ probeLookups: readonly number[] = [], + /** + * Whether SIMD lanes may compute at storage precision, as only an explicit kernel's may: f32 rounds each step. + * Without it lanes are f64, two values a vector, promoted from f32 and demoted only on store, as the body computes. + */ + storagePrecision = true, ): WasmKernel | null => { const program = withoutIntrinsics(source); const scalars: WasmScalar[] = []; @@ -352,18 +357,23 @@ export const generateWasm = ( }; while (inferIntegral(program.body)); - // Parameters: start, end, tick, lastRun, column addresses, filter row tick addresses, filter chunk tick addresses, scalars, and with reductions the partials address. + // Parameters: start, end, tick, lastRun, column addresses, filter row, chunk and block tick addresses, scalars, and with reductions the partials address. const columnParameter = (column: number): number => 4 + column; const filterRowParameter = (filter: number): number => 4 + columns.length + filter; const filterChunkParameter = (filter: number): number => 4 + columns.length + filters + filter; + const filterBlockParameter = (filter: number): number => 4 + columns.length + 2 * filters + filter; + /** Parameters before the lookups': the fixed four, the columns' and the filters'. */ + const beforeLookups = 4 + columns.length + 3 * filters; /** * With lookups: the entity table (archetype, row and generation addresses, then its length), then each lookup's * directory address and record count, all as f64 so workers pass them like scalars. */ const lookupParameters = lookups.length === 0 ? 0 : 4 + 2 * lookups.length + (watching ? 1 : 0); - const lookupParameter = (index: number): number => 4 + columns.length + 2 * filters + index; + const lookupParameter = (index: number): number => beforeLookups + index; /** For `up`: the rows' and their parents' rows' addresses, whether there's a parent, then each parent column's address. */ const upMode = program.up === true; + // Its runner passes an `up` kernel only the rows that pass, and the filter loops step the row, not its list slot. + if (upMode && filters > 0) throw new Error("A WASM kernel reading `up` takes no change filters: its runner lists only the rows that pass."); const upParameters = upMode ? 3 + upColumns.length : 0; /** Each lookup through an index's parameters, after the lookup parameters: `indexParameterCount` of them, as f64. */ const indexOffsets: number[] = []; @@ -373,9 +383,9 @@ export const generateWasm = ( const index = indexLookups[lookup]; if (index !== null && index !== undefined) indexParameters += indexParameterCount(index); } - const indexParameter = (lookup: number, index: number): number => 4 + columns.length + 2 * filters + lookupParameters + indexOffsets[lookup] + index; - const upParameter = (index: number): number => 4 + columns.length + 2 * filters + lookupParameters + indexParameters + index; - const scalarParameter = (index: number): number => 4 + columns.length + 2 * filters + lookupParameters + indexParameters + upParameters + index; + const indexParameter = (lookup: number, index: number): number => beforeLookups + lookupParameters + indexOffsets[lookup] + index; + const upParameter = (index: number): number => beforeLookups + lookupParameters + indexParameters + index; + const scalarParameter = (index: number): number => beforeLookups + lookupParameters + indexParameters + upParameters + index; const code: number[] = []; const extraLocals: number[] = []; let localBase = 0; @@ -611,31 +621,34 @@ export const generateWasm = ( }; /** - * Chunk columns stamped by every row: top-level stamps with no `next` before them, in an unfiltered kernel. - * Their chunks are stamped once per call, for the whole range, instead of on each row. + * Chunk and block columns stamped by every row, by the shift from a row to its element: top-level stamps with no + * `next` before them, in an unfiltered kernel. Their chunks and blocks are stamped once per call, for the whole + * range, instead of on each row. */ - const everyRow = new Set(); - // Listed rows needn't be consecutive, so each stamps its own chunk. + const everyRow = new Map(); + // Listed rows needn't be consecutive, so each stamps its own chunk and block. if (filters === 0 && !upMode) { const ends = (statement: Statement): boolean => statement.kind === "next" || (statement.kind === "if" && (statement.consequent.some(ends) || statement.alternate.some(ends))) || ((statement.kind === "block" || statement.kind === "loop") && statement.body.some(ends)); for (const statement of program.body) { if (ends(statement)) break; - if (statement.kind === "stamp" && columns[statement.chunks].muted !== true) everyRow.add(statement.chunks); + if (statement.kind !== "stamp") continue; + if (columns[statement.blocks].muted !== true) everyRow.set(statement.blocks, blockShift); + if (columns[statement.chunks].muted !== true) everyRow.set(statement.chunks, chunkShift); } } - /** Stamps every chunk from `start`'s to `end - 1`'s, for the chunk columns every row stamps. */ + /** Stamps every chunk or block from `start`'s to `end - 1`'s, for the chunk and block columns every row stamps. */ const stampRangeChunks = (): void => { - for (const chunks of everyRow) { + for (const [dense, shift] of everyRow) { const chunk = scratch(i32Type); const last = scratch(i32Type); - // if (start < end) for (chunk = start >> shift; chunk <= (end - 1) >> shift; chunk++) chunks[chunk] = tick + // if (start < end) for (chunk = start >> shift; chunk <= (end - 1) >> shift; chunk++) dense[chunk] = tick code.push(op.localGet, 0, op.localGet, 1, op.i32LtS, op.if, 0x40); - code.push(op.localGet, 0, op.i32Const, ...signed(chunkShift), op.i32ShrU, op.localSet, ...unsigned(chunk)); - code.push(op.localGet, 1, op.i32Const, 1, op.i32Sub, op.i32Const, ...signed(chunkShift), op.i32ShrU, op.localSet, ...unsigned(last)); + code.push(op.localGet, 0, op.i32Const, ...signed(shift), op.i32ShrU, op.localSet, ...unsigned(chunk)); + code.push(op.localGet, 1, op.i32Const, 1, op.i32Sub, op.i32Const, ...signed(shift), op.i32ShrU, op.localSet, ...unsigned(last)); code.push(op.loop, 0x40); - code.push(op.localGet, ...unsigned(columnParameter(chunks)), op.localGet, ...unsigned(chunk), op.i32Const, 3, op.i32Shl, op.i32Add, op.localGet, 2, op.f64Store, 3, 0); + code.push(op.localGet, ...unsigned(columnParameter(dense)), op.localGet, ...unsigned(chunk), op.i32Const, 3, op.i32Shl, op.i32Add, op.localGet, 2, op.f64Store, 3, 0); code.push(op.localGet, ...unsigned(chunk), op.i32Const, 1, op.i32Add, op.localTee, ...unsigned(chunk), op.localGet, ...unsigned(last), op.i32LeS, op.brIf, 0); code.push(op.end, op.end); } @@ -1235,12 +1248,15 @@ export const generateWasm = ( emitRecordLeaf(row, 1, { view: "f64", stride: 8, offset: 0, boolean: false }); }; - /** Sets the chunk tick of the row `rowOffset` rows past the current one, unless every row's chunk is stamped up front. */ - const stampChunk = (chunks: number, rowOffset: number): void => { - if (everyRow.has(chunks)) return; - code.push(op.localGet, ...unsigned(columnParameter(chunks)), op.localGet, ...unsigned(rowLocal)); + /** + * Sets the chunk or block tick, by `shift`, of the row `rowOffset` rows past the current one, unless it's muted + * or every row's is stamped up front. + */ + const stampDense = (dense: number, shift: number, rowOffset: number): void => { + if (columns[dense].muted === true || everyRow.has(dense)) return; + code.push(op.localGet, ...unsigned(columnParameter(dense)), op.localGet, ...unsigned(rowLocal)); if (rowOffset !== 0) code.push(op.i32Const, ...signed(rowOffset), op.i32Add); - code.push(op.i32Const, ...signed(chunkShift), op.i32ShrU, op.i32Const, 3, op.i32Shl, op.i32Add, op.localGet, 2, op.f64Store, 3, 0); + code.push(op.i32Const, ...signed(shift), op.i32ShrU, op.i32Const, 3, op.i32Shl, op.i32Add, op.localGet, 2, op.f64Store, 3, 0); }; const statement = (current: Statement): void => { @@ -1250,8 +1266,9 @@ export const generateWasm = ( // A chunk column stays when its row column is muted, for a component held on the GPU. case "stamp": if (columns[current.column].muted !== true) store(current.column, "tick"); - if (columns[current.chunks].muted === true) return; - return stampChunk(current.chunks, 0); + stampDense(current.blocks, blockShift, 0); + stampDense(current.chunks, chunkShift, 0); + return; case "if": emit(current.test); emitTruthy(typeOf(current.test)); @@ -1285,7 +1302,7 @@ export const generateWasm = ( } }; - // ---- SIMD. Lanes compute in f32, at storage precision, so results may differ from the scalar path in the last bit. ---- + // ---- SIMD. f32 lanes compute at storage precision, so results may differ from the scalar path; f64 lanes don't. ---- const v128Type = 0x7b; const simd = (operation: number): number[] => [0xfd, ...unsigned(operation)]; @@ -1294,39 +1311,70 @@ export const generateWasm = ( f32Eq: 65, f32Ne: 66, f32Lt: 67, f32Gt: 68, f32Le: 69, f32Ge: 70, f64Gt: 74, not: 77, and: 78, andNot: 79, or: 80, bitselect: 82, anyTrue: 83, store32Lane: 90, store64Lane: 91, load32Zero: 92, load64Zero: 93, - f32Ceil: 103, f32Floor: 104, f32Trunc: 105, i64ExtendLowS: 199, i64ExtendHighS: 200, + f64Eq: 71, f64Ne: 72, f64Lt: 73, f64Le: 75, f64Ge: 76, f32DemoteF64Zero: 94, f64PromoteLowF32: 95, + f32Ceil: 103, f32Floor: 104, f32Trunc: 105, f64Ceil: 116, f64Floor: 117, f64Trunc: 122, i64ExtendLowS: 199, i64ExtendHighS: 200, f32Abs: 224, f32Neg: 225, f32Sqrt: 227, f32Add: 228, f32Sub: 229, f32Mul: 230, f32Div: 231, f32Min: 232, f32Max: 233, + f64Abs: 236, f64Neg: 237, f64Sqrt: 239, f64Add: 240, f64Sub: 241, f64Mul: 242, f64Div: 243, f64Min: 244, f64Max: 245, } as const; + /** f64 lanes: each vector holds two values, each operation the f64 one, so lanes compute as the scalar path does. */ + const wide = !storagePrecision; + /** Bytes of f32 storage one vector covers: four lanes, or two promoted to f64. */ + const laneBytes = wide ? 8 : 16; const float32 = (value: number): number[] => [...new Uint8Array(new Float32Array([value]).buffer)]; - const vectorConstant = (values: readonly number[]): number[] => [...simd(vop.const), ...[0, 1, 2, 3].flatMap(lane => float32(values[lane] ?? 0))]; + const vectorConstant = (values: readonly number[]): number[] => wide + ? [...simd(vop.const), ...[0, 1].flatMap(lane => float64(values[lane] ?? 0))] + : [...simd(vop.const), ...[0, 1, 2, 3].flatMap(lane => float32(values[lane] ?? 0))]; const splatConstant = (value: number): number[] => vectorConstant([value, value, value, value]); - const arithmeticVector = { "+": vop.f32Add, "-": vop.f32Sub, "*": vop.f32Mul, "/": vop.f32Div } as const; - const unaryVector: { readonly [name: string]: number | undefined } = { abs: vop.f32Abs, sqrt: vop.f32Sqrt, floor: vop.f32Floor, ceil: vop.f32Ceil, trunc: vop.f32Trunc }; + const arithmeticVector = wide + ? { "+": vop.f64Add, "-": vop.f64Sub, "*": vop.f64Mul, "/": vop.f64Div } + : { "+": vop.f32Add, "-": vop.f32Sub, "*": vop.f32Mul, "/": vop.f32Div }; + const unaryVector: { readonly [name: string]: number | undefined } = wide + ? { abs: vop.f64Abs, sqrt: vop.f64Sqrt, floor: vop.f64Floor, ceil: vop.f64Ceil, trunc: vop.f64Trunc } + : { abs: vop.f32Abs, sqrt: vop.f32Sqrt, floor: vop.f32Floor, ceil: vop.f32Ceil, trunc: vop.f32Trunc }; + const negateVector = wide ? vop.f64Neg : vop.f32Neg; + const comparisonVector: { readonly [operator: string]: number | undefined } = wide + ? { "<": vop.f64Lt, ">": vop.f64Gt, "<=": vop.f64Le, ">=": vop.f64Ge, "==": vop.f64Eq, "!=": vop.f64Ne, "===": vop.f64Eq, "!==": vop.f64Ne } + : { "<": vop.f32Lt, ">": vop.f32Gt, "<=": vop.f32Le, ">=": vop.f32Ge, "==": vop.f32Eq, "!=": vop.f32Ne, "===": vop.f32Eq, "!==": vop.f32Ne }; + /** Loads the vector of f32 storage at the address on the stack plus `offset`, as lanes. */ + const loadLanes = (offset: number): void => { + if (wide) code.push(...simd(vop.load64Zero), 2, ...unsigned(offset), ...simd(vop.f64PromoteLowF32)); + else code.push(...simd(vop.load), 2, ...unsigned(offset)); + }; + /** Stores lanes, on the stack after the address, to f32 storage at that address plus `offset`. */ + const storeLanes = (offset: number): void => { + if (wide) code.push(...simd(vop.f32DemoteF64Zero), ...simd(vop.store64Lane), 2, ...unsigned(offset), 0); + else code.push(...simd(vop.store), 2, ...unsigned(offset)); + }; const isArithmetic = (operator: string): operator is keyof typeof arithmeticVector => operator in arithmeticVector; - /** A value that's the same for every lane, splatted: demoted to f32, as a lane computes. */ + /** A value that's the same for every lane, splatted: demoted to f32 for f32 lanes, as a lane computes. */ const emitSplat = (expression: Expression): void => { emit(expression); - code.push(op.f32DemoteF64, ...simd(vop.f32Splat)); + if (wide) code.push(...simd(vop.f64Splat)); + else code.push(op.f32DemoteF64, ...simd(vop.f32Splat)); }; - /** `Math` functions with an f32x4 equivalent; `fround` is the identity in f32. */ + /** `Math` functions with a vector equivalent; `fround` is the identity in f32 and a demotion and promotion in f64. */ const emitVectorMath = (functionName: string, count: number): void => { if (functionName === "min" || functionName === "max") { - for (let i = 1; i < count; i++) code.push(...simd(functionName === "min" ? vop.f32Min : vop.f32Max)); + const operation = functionName === "min" ? (wide ? vop.f64Min : vop.f32Min) : (wide ? vop.f64Max : vop.f32Max); + for (let i = 1; i < count; i++) code.push(...simd(operation)); return; } + if (functionName === "fround" && wide) return void code.push(...simd(vop.f32DemoteF64Zero), ...simd(vop.f64PromoteLowF32)); const unary = unaryVector[functionName]; if (unary !== undefined) code.push(...simd(unary)); }; const vectorMath = (functionName: string, count: number): boolean => ((functionName === "min" || functionName === "max") && count > 0) || ((unaryVector[functionName] !== undefined || functionName === "fround") && count === 1); - // ---- Across rows: four rows at a time, for straight-line programs whose columns are all dense f32. ---- + // ---- Across rows: four rows at a time in f32 lanes, two in f64, for programs whose columns are all dense f32. ---- /** The mask of the enclosing `if`s, or null outside any. */ let mask: number | null = null; const rowsLocals: number[] = []; const rowsLocal = (index: number): number => rowsLocals[index] ??= scratch(v128Type); + /** Rows a pass of the across-rows loops runs at once, which stamps cover. */ + let groupRows = 4; const denseAddress = (column: number): void => { const { view, stride, boolean, component } = columns[column]; @@ -1336,12 +1384,12 @@ export const generateWasm = ( const vectorTruthy = (type: Type): void => { if (type === "boolean") { - code.push(...splatConstant(0), ...simd(vop.f32Ne)); + code.push(...splatConstant(0), ...simd(comparisonVector["!="] ?? vop.f32Ne)); return; } const value = scratch(v128Type); - code.push(op.localTee, ...unsigned(value), ...splatConstant(0), ...simd(vop.f32Ne)); - code.push(op.localGet, ...unsigned(value), op.localGet, ...unsigned(value), ...simd(vop.f32Eq), ...simd(vop.and)); + code.push(op.localTee, ...unsigned(value), ...splatConstant(0), ...simd(comparisonVector["!="] ?? vop.f32Ne)); + code.push(op.localGet, ...unsigned(value), op.localGet, ...unsigned(value), ...simd(comparisonVector["=="] ?? vop.f32Eq), ...simd(vop.and)); }; /** A lane mask as a value: 1 where set, 0 elsewhere. */ const maskValue = (): void => { @@ -1356,11 +1404,11 @@ export const generateWasm = ( case "resource": case "args": return emitSplat(expression); case "column": denseAddress(expression.column); - code.push(...simd(vop.load), 2, ...unsigned(columns[expression.column].offset)); + loadLanes(columns[expression.column].offset); return; case "unary": rowsEmit(expression.operand); - if (expression.operator === "-") code.push(...simd(vop.f32Neg)); + if (expression.operator === "-") code.push(...simd(negateVector)); else if (expression.operator === "!") { vectorTruthy(typeOf(expression.operand)); code.push(...simd(vop.not)); @@ -1379,7 +1427,7 @@ export const generateWasm = ( return; } } - const comparison = { "<": vop.f32Lt, ">": vop.f32Gt, "<=": vop.f32Le, ">=": vop.f32Ge, "==": vop.f32Eq, "!=": vop.f32Ne, "===": vop.f32Eq, "!==": vop.f32Ne }[operator as string]; + const comparison = comparisonVector[operator]; if (!isArithmetic(operator) && comparison === undefined) throw notVectorizable; rowsEmit(left); rowsEmit(right); @@ -1451,28 +1499,33 @@ export const generateWasm = ( } code.push(op.localSet, ...unsigned(value)); denseAddress(current.column); - code.push(op.localGet, ...unsigned(value), ...simd(vop.store), 2, ...unsigned(columns[current.column].offset)); + code.push(op.localGet, ...unsigned(value)); + storeLanes(columns[current.column].offset); return; } case "stamp": { - // Ticks are dense f64: four rows are two f64x2 stores; a chunk column stays when its row column is muted. + // Ticks are dense f64: each two rows are an f64x2 store; a chunk column stays when its row column is muted. + // f64 lanes' masks are already a lane per row; f32 lanes' are extended, two rows a store. if (columns[current.column].muted !== true) { - for (let half = 0; half < 2; half++) { + for (let half = 0; half < groupRows / 2; half++) { const address = (): void => { code.push(op.localGet, ...unsigned(columnParameter(current.column)), op.localGet, ...unsigned(rowLocal), op.i32Const, 3, op.i32Shl, op.i32Add); }; address(); code.push(op.localGet, 2, ...simd(vop.f64Splat)); if (mask !== null) { address(); code.push(...simd(vop.load), 3, ...unsigned(16 * half)); - code.push(op.localGet, ...unsigned(mask), ...simd(half === 0 ? vop.i64ExtendLowS : vop.i64ExtendHighS), ...simd(vop.bitselect)); + code.push(op.localGet, ...unsigned(mask)); + if (!wide) code.push(...simd(half === 0 ? vop.i64ExtendLowS : vop.i64ExtendHighS)); + code.push(...simd(vop.bitselect)); } code.push(...simd(vop.store), 3, ...unsigned(16 * half)); } } - if (columns[current.chunks].muted === true) return; - // The group may straddle two chunks. Under a mask this can stamp a chunk no lane changed, which only costs a scan. - stampChunk(current.chunks, 0); - stampChunk(current.chunks, 3); + // The group may straddle two chunks or blocks. Under a mask this can stamp one no lane changed, which only costs a scan. + stampDense(current.blocks, blockShift, 0); + stampDense(current.blocks, blockShift, groupRows - 1); + stampDense(current.chunks, chunkShift, 0); + stampDense(current.chunks, chunkShift, groupRows - 1); return; } case "if": { @@ -1504,15 +1557,16 @@ export const generateWasm = ( } }; - /** Rows from `start` in groups of four, up to the last whole group; leaves the row local at the first row not done. */ + /** Rows from `start` in groups of a vector's lanes, up to the last whole group; leaves the row local at the first row not done. */ const rowsLoop = (): void => { + groupRows = laneBytes / 4; const vectorEnd = scratch(i32Type); - code.push(op.localGet, 1, op.localGet, 0, op.i32Sub, op.i32Const, ...signed(-4), op.i32And, op.localGet, 0, op.i32Add, op.localSet, ...unsigned(vectorEnd)); + code.push(op.localGet, 1, op.localGet, 0, op.i32Sub, op.i32Const, ...signed(-groupRows), op.i32And, op.localGet, 0, op.i32Add, op.localSet, ...unsigned(vectorEnd)); code.push(op.block, 0x40, op.loop, 0x40); code.push(op.localGet, ...unsigned(rowLocal), op.localGet, ...unsigned(vectorEnd), op.i32GeS, op.brIf, 1); mask = null; for (const current of program.body) rowsStatement(current); - code.push(op.localGet, ...unsigned(rowLocal), op.i32Const, 4, op.i32Add, op.localSet, ...unsigned(rowLocal), op.br, 0); + code.push(op.localGet, ...unsigned(rowLocal), op.i32Const, ...signed(groupRows), op.i32Add, op.localSet, ...unsigned(rowLocal), op.br, 0); code.push(op.end, op.end); }; @@ -1756,7 +1810,7 @@ export const generateWasm = ( return; case "negate": emitVectorExpression(expression.operand); - code.push(...simd(vop.f32Neg)); + code.push(...simd(negateVector)); return; case "math": for (const parameter of expression.arguments) emitVectorExpression(parameter); @@ -1790,7 +1844,7 @@ export const generateWasm = ( return groups.size > 0; }; - // ---- Across rows for interleaved elements: four rows of a `stride`-byte element are `stride / 4` whole vectors. ---- + // ---- Across rows for interleaved elements: four rows of a `stride`-byte element are `4 * stride / laneBytes` whole vectors. ---- type LanePlan = ReturnType; /** Whether a value is the same for every row. */ @@ -1848,9 +1902,9 @@ export const generateWasm = ( } case "load": elementAddress(expression.column); - code.push(...simd(vop.load), 2, ...unsigned(16 * vector)); + loadLanes(laneBytes * vector); return; - case "constant": code.push(...vectorConstant([0, 1, 2, 3].map(lane => expression.values[((16 * vector + 4 * lane) % stride) / 4]))); return; + case "constant": code.push(...vectorConstant([0, 1, 2, 3].map(lane => expression.values[((laneBytes * vector + 4 * lane) % stride) / 4] ?? 0))); return; case "splat": return emitSplat(expression.expression); case "binary": emitElementExpression(expression.left, vector, stride, packVectors); @@ -1859,7 +1913,7 @@ export const generateWasm = ( return; case "negate": emitElementExpression(expression.operand, vector, stride, packVectors); - code.push(...simd(vop.f32Neg)); + code.push(...simd(negateVector)); return; case "math": for (const parameter of expression.arguments) emitElementExpression(parameter, vector, stride, packVectors); @@ -1874,6 +1928,8 @@ export const generateWasm = ( * and each vector's store only touches bytes no other vector reads. */ const elementsLoop = ({ groups, packs }: LanePlan): void => { + groupRows = 4; + const vectors = (stride: number): number => 4 * stride / laneBytes; const vectorEnd = scratch(i32Type); code.push(op.localGet, 1, op.localGet, 0, op.i32Sub, op.i32Const, ...signed(-4), op.i32And, op.localGet, 0, op.i32Add, op.localSet, ...unsigned(vectorEnd)); code.push(op.block, 0x40, op.loop, 0x40); @@ -1884,23 +1940,25 @@ export const generateWasm = ( for (let index = 0; index < body.length; index++) { const pack = packs.get(index); if (pack?.used === true) { - const vectors = Array.from({ length: columns[pack.column].stride / 4 }, (_, vector) => { + const locals = Array.from({ length: vectors(columns[pack.column].stride) }, (_, vector) => { const local = scratch(v128Type); elementAddress(pack.column); - code.push(...simd(vop.load), 2, ...unsigned(16 * vector), op.localSet, ...unsigned(local)); + loadLanes(laneBytes * vector); + code.push(op.localSet, ...unsigned(local)); return local; }); - packVectors.set(pack, vectors); + packVectors.set(pack, locals); } const group = groups.get(index); if (group !== undefined) { const { stride } = columns[group.column]; - for (let vector = 0; vector < stride / 4; vector++) { + for (let vector = 0; vector < vectors(stride); vector++) { const value = scratch(v128Type); emitElementExpression(group.value, vector, stride, packVectors); code.push(op.localSet, ...unsigned(value)); elementAddress(group.column); - code.push(op.localGet, ...unsigned(value), ...simd(vop.store), 2, ...unsigned(16 * vector)); + code.push(op.localGet, ...unsigned(value)); + storeLanes(laneBytes * vector); } index += group.length - 1; } @@ -1911,7 +1969,7 @@ export const generateWasm = ( }; try { - const parameterCount = 4 + columns.length + 2 * filters + lookupParameters + indexParameters + upParameters; + const parameterCount = beforeLookups + lookupParameters + indexParameters + upParameters; collectFinds(program.body); /** Leaves 1 if any filter's tick at `(row >> shift) * 8` in its `parameter` array is newer than `lastRun`. */ const anyNewer = (parameter: (filter: number) => number, shift: number): void => { @@ -1922,36 +1980,88 @@ export const generateWasm = ( if (filter > 0) code.push(op.i32Or); } }; - /** At a chunk's first row, skips the whole chunk unless a filter's chunk tick is newer. */ - const skipChunks = (): void => { - const next = scratch(i32Type); - code.push(op.localGet, ...unsigned(rowLocal), op.localGet, 0, op.i32Eq, op.localGet, ...unsigned(rowLocal), op.i32Const, ...signed(2 ** chunkShift - 1), op.i32And, op.i32Eqz, op.i32Or); - code.push(op.if, 0x40); + /** + * i32 locals: the row after the current chunk; the row after the current run of passing blocks, both `start` + * before the first; the current chunk's blocks that pass, a bit each; and two temporaries. + */ + let chunkEnd = 0; + let blockEnd = 0; + let passingBlocks = 0; + let ahead = 0; + let skipped = 0; + const startChunksAndBlocks = (): void => { + chunkEnd = scratch(i32Type); + blockEnd = scratch(i32Type); + passingBlocks = scratch(i32Type); + ahead = scratch(i32Type); + skipped = scratch(i32Type); + code.push(op.localGet, 0, op.localTee, ...unsigned(chunkEnd), op.localSet, ...unsigned(blockEnd)); + }; + const get = (local: number): number[] => [op.localGet, ...unsigned(local)]; + const set = (local: number): number[] => [op.localSet, ...unsigned(local)]; + /** Leaves `min(end, ((row >> shift) + count) << shift)`, with `count` on the stack first. */ + const runEnd = (shift: number, target: number): void => { + code.push(...get(rowLocal), op.i32Const, ...signed(shift), op.i32ShrU, op.i32Add, op.i32Const, ...signed(shift), op.i32Shl, ...set(target)); + code.push(...get(target), op.localGet, 1, ...get(target), op.localGet, 1, op.i32LtS, op.select, ...set(target)); + }; + const blocksPerChunk = 2 ** (chunkShift - blockShift); + /** + * In the outer row loop, at `row`, the first row after the last run of passing blocks: past the current chunk, + * skips chunks no filter's chunk tick says changed, and notes which blocks of the next one pass, a bit each; + * then skips the blocks that don't pass, and sets `blockEnd` to the end of the run of blocks that do, which the + * inner loop runs. A chunk with more than half its blocks passing is one run: ending a run costs more than the + * eight-row checks spend on a block between runs. + */ + const findRun = (): void => { + code.push(...get(rowLocal), ...get(chunkEnd), op.i32GeS, op.if, 0x40); + code.push(op.i32Const, 1); + runEnd(chunkShift, chunkEnd); anyNewer(filterChunkParameter, chunkShift); - code.push(op.i32Eqz, op.if, 0x40); - // row = min(end, start of the next chunk) - code.push(op.localGet, ...unsigned(rowLocal), op.i32Const, ...signed(chunkShift), op.i32ShrU, op.i32Const, 1, op.i32Add, op.i32Const, ...signed(chunkShift), op.i32Shl, op.localSet, ...unsigned(next)); - code.push(op.localGet, ...unsigned(next), op.localGet, 1, op.localGet, ...unsigned(next), op.localGet, 1, op.i32LtS, op.select, op.localSet, ...unsigned(rowLocal)); - code.push(op.br, 2, op.end, op.end); + code.push(op.i32Eqz, op.if, 0x40, ...get(chunkEnd), ...set(rowLocal), op.br, 2, op.end); + // The chunk's blocks that pass, from its first block, `(row >> chunkShift) << (chunkShift - blockShift)`. + for (let block = 0; block < blocksPerChunk; block++) { + for (let filter = 0; filter < filters; filter++) { + code.push(...get(filterBlockParameter(filter)), ...get(rowLocal), op.i32Const, ...signed(chunkShift), op.i32ShrU, op.i32Const, ...signed(chunkShift - blockShift), op.i32Shl); + code.push(op.i32Const, ...signed(block), op.i32Add, op.i32Const, 3, op.i32Shl, op.i32Add, op.f64Load, 3, 0, op.localGet, 3, op.f64Gt); + if (filter > 0) code.push(op.i32Or); + } + if (block > 0) code.push(op.i32Const, ...signed(block), op.i32Shl, ...get(passingBlocks), op.i32Or); + code.push(...set(passingBlocks)); + } + code.push(...get(passingBlocks), op.i32Popcnt, op.i32Const, ...signed(blocksPerChunk / 2), op.i32GtS, op.if, 0x40); + code.push(op.i32Const, ...signed((2 ** blocksPerChunk - 1) | 0), ...set(passingBlocks), op.end); + code.push(op.end); + // ahead = passingBlocks >> (row's block in its chunk); none ahead skips the rest of the chunk. + code.push(...get(passingBlocks), ...get(rowLocal), op.i32Const, ...signed(blockShift), op.i32ShrU, op.i32Const, ...signed(blocksPerChunk - 1), op.i32And, op.i32ShrU, op.localTee, ...unsigned(ahead)); + code.push(op.i32Eqz, op.if, 0x40, ...get(chunkEnd), ...set(rowLocal), op.br, 1, op.end); + // Skips to the next passing block, then runs to the end of the passing blocks after it. + code.push(...get(ahead), op.i32Ctz, op.localTee, ...unsigned(skipped), op.if, 0x40); + code.push(...get(skipped), ...get(rowLocal), op.i32Const, ...signed(blockShift), op.i32ShrU, op.i32Add, op.i32Const, ...signed(blockShift), op.i32Shl, ...set(rowLocal)); + code.push(op.end); + code.push(...get(ahead), ...get(skipped), op.i32ShrU, op.i32Const, ...signed(-1), op.i32Xor, op.i32Ctz); + runEnd(blockShift, blockEnd); + code.push(...get(blockEnd), ...get(chunkEnd), ...get(blockEnd), ...get(chunkEnd), op.i32LtS, op.select, ...set(blockEnd)); + // A row moved to a later block goes round the outer loop, which checks it against `end`. + code.push(...get(skipped), op.brIf, 0); }; - /** Rows a block skip checks at once: four f64x2 loads of each filter's row ticks. */ - const blockRows = 8; + /** Rows an eight-row skip checks at once: four f64x2 loads of each filter's row ticks. */ + const eightRows = 8; /** - * With SIMD, at a row that's a multiple of `blockRows`, skips that many rows when none passes a filter, testing - * two ticks per comparison. Only aligned rows check, so rows that do pass pay one check per block. + * With SIMD, at a row that's a multiple of `eightRows`, skips that many rows when none passes a filter, testing + * two ticks per comparison. Only aligned rows check, so rows that do pass pay one check per eight. */ - const skipBlocks = (): void => { - code.push(op.localGet, ...unsigned(rowLocal), op.i32Const, blockRows - 1, op.i32And, op.i32Eqz); - code.push(op.localGet, ...unsigned(rowLocal), op.i32Const, blockRows, op.i32Add, op.localGet, 1, op.i32LeS, op.i32And, op.if, 0x40); + const skipEights = (): void => { + code.push(op.localGet, ...unsigned(rowLocal), op.i32Const, eightRows - 1, op.i32And, op.i32Eqz); + code.push(op.localGet, ...unsigned(rowLocal), op.i32Const, eightRows, op.i32Add, op.localGet, 1, op.i32LeS, op.i32And, op.if, 0x40); for (let filter = 0; filter < filters; filter++) { - for (let pair = 0; pair < blockRows / 2; pair++) { + for (let pair = 0; pair < eightRows / 2; pair++) { code.push(op.localGet, ...unsigned(filterRowParameter(filter)), op.localGet, ...unsigned(rowLocal), op.i32Const, 3, op.i32Shl, op.i32Add); code.push(...simd(vop.load), 3, ...unsigned(16 * pair), op.localGet, 3, ...simd(vop.f64Splat), ...simd(vop.f64Gt)); if (filter > 0 || pair > 0) code.push(...simd(vop.or)); } } code.push(...simd(vop.anyTrue), op.i32Eqz, op.if, 0x40); - code.push(op.localGet, ...unsigned(rowLocal), op.i32Const, blockRows, op.i32Add, op.localSet, ...unsigned(rowLocal)); + code.push(op.localGet, ...unsigned(rowLocal), op.i32Const, eightRows, op.i32Add, op.localSet, ...unsigned(rowLocal)); code.push(op.br, 2, op.end, op.end); }; // Scalars are discovered while generating, so code is generated first with locals placed after a provisional scalar count. @@ -1986,10 +2096,13 @@ export const generateWasm = ( if (lookupParameters > 0) convertLookupParameters(); if (indexParameters > 0) convertIndexParameters(); stampRangeChunks(); + if (filters > 0) startChunksAndBlocks(); if (mode === "rows") rowsLoop(); + let across = false; if (mode === "lanes") { const plan = planLanes(); - if (acrossElements(plan)) elementsLoop(plan); + across = acrossElements(plan); + if (across) elementsLoop(plan); } // for (; row < end; row++) { block next { body } }, where with `up` the row and its parent's are rows[n] and parents[n]. const slot = rowLocal; @@ -1999,12 +2112,16 @@ export const generateWasm = ( } code.push(op.block, 0x40, op.loop, 0x40); code.push(op.localGet, ...unsigned(slot), op.localGet, 1, op.i32GeS, op.brIf, 1); + // With change filters, an outer loop finds each run of passing blocks and an inner one runs its rows. + if (filters > 0) { + findRun(); + code.push(op.block, 0x40, op.loop, 0x40, ...get(rowLocal), ...get(blockEnd), op.i32GeS, op.brIf, 1); + } if (upMode) { code.push(op.localGet, ...unsigned(upParameter(0)), op.localGet, ...unsigned(slot), op.i32Const, 2, op.i32Shl, op.i32Add, op.i32Load, 2, 0, op.localSet, ...unsigned(rowLocal)); code.push(op.localGet, ...unsigned(upParameter(1)), op.localGet, ...unsigned(slot), op.i32Const, 2, op.i32Shl, op.i32Add, op.i32Load, 2, 0, op.localSet, ...unsigned(parentLocal)); } - if (filters > 0) skipChunks(); - if (filters > 0 && simdEnabled && !upMode) skipBlocks(); + if (filters > 0 && simdEnabled && !upMode) skipEights(); code.push(op.block, 0x40); // A row that no filter passes goes to the next. if (filters > 0) { @@ -2013,8 +2130,12 @@ export const generateWasm = ( } labels.length = 0; labels.push("next"); - if (mode === "lanes") fused = emitLaneBody(); - else for (const current of program.body) statement(current); + // f64 lanes fuse only across rows: within a row the rest run as scalars, as the body computes. + if (mode === "lanes" && !wide) fused = emitLaneBody(); + else { + if (mode === "lanes") fused = across; + for (const current of program.body) statement(current); + } labels.pop(); code.push(op.end); accumulators.forEach((accumulator, k) => { @@ -2028,6 +2149,7 @@ export const generateWasm = ( }); code.push(op.localGet, ...unsigned(slot), op.i32Const, 1, op.i32Add, op.localSet, ...unsigned(slot), op.br, 0); code.push(op.end, op.end); + if (filters > 0) code.push(op.br, 0, op.end, op.end); rowLocal = slot; accumulators.forEach((accumulator, k) => { const extra = lanes[k]; @@ -2046,7 +2168,9 @@ export const generateWasm = ( // Across-rows mode doesn't filter rows, so filtered kernels fuse within rows instead. // Accumulators carry from row to row, which neither SIMD mode keeps. // Listed rows aren't consecutive, so `up` programs run one row at a time. - let mode: "rows" | "lanes" | "scalar" = !simdEnabled || accumulators.length > 0 || upMode ? "scalar" : filters > 0 ? "lanes" : "rows"; + // Within a row, f64 lanes would split a component's leaves across vectors, so filtered kernels at the body's + // precision run one row at a time. + let mode: "rows" | "lanes" | "scalar" = !simdEnabled || accumulators.length > 0 || upMode || (wide && filters > 0) ? "scalar" : filters > 0 ? "lanes" : "rows"; try { generate(0, mode); } @@ -2064,7 +2188,7 @@ export const generateWasm = ( if (scalars.length !== discovered) throw unsupported; const vectorized = mode !== "scalar"; - const parameterTypes = [i32Type, i32Type, f64Type, f64Type, ...columns.map(() => i32Type), ...new Array(2 * filters).fill(i32Type), ...new Array(lookupParameters).fill(f64Type), ...new Array(indexParameters).fill(f64Type), ...new Array(upParameters).fill(i32Type), ...scalars.map(() => f64Type), ...accumulators.length > 0 ? [i32Type] : []]; + const parameterTypes = [i32Type, i32Type, f64Type, f64Type, ...columns.map(() => i32Type), ...new Array(3 * filters).fill(i32Type), ...new Array(lookupParameters).fill(f64Type), ...new Array(indexParameters).fill(f64Type), ...new Array(upParameters).fill(i32Type), ...scalars.map(() => f64Type), ...accumulators.length > 0 ? [i32Type] : []]; const localTypesList = [...new Array(program.locals).fill(f64Type)]; // Locals in order: program locals, then extras in creation order; the row index was the first extra. const declared = [...localTypesList.map(type => [1, type]), ...extraLocals.map(type => [1, type])]; diff --git a/packages/ecs/src/implementation/world/worker-window.test.ts b/packages/ecs/src/implementation/world/worker-window.test.ts new file mode 100644 index 000000000..d0590739e --- /dev/null +++ b/packages/ecs/src/implementation/world/worker-window.test.ts @@ -0,0 +1,43 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { describe, expect, it } from "vitest"; +import { controlWords, done, epoch, posted, ringSize, taskWords } from "./protocol.js"; +import { runWindow } from "./worker-window.js"; + +describe("a pool worker's window", () => { + it("runs a task the pool posted just before closing it, after the worker last looked for tasks", () => { + const control = new Int32Array(new SharedArrayBuffer(controlWords * 4)); + const statuses = new Int32Array(new SharedArrayBuffer(ringSize * 4)); + const payloads = new Float64Array(new SharedArrayBuffer(ringSize * taskWords * 8)); + const windowEpoch = 2; + control[epoch] = windowEpoch; + // The pool, between the worker's look at `posted` and its look at the epoch: it posts a task of this window, + // then closes it. + const load = Atomics.load; + let lookedForTasks = false; + let played = false; + const playPool = (array: Int32Array, index: number): number => { + const value = load(array, index); + if (array !== control) return value; + if (index === posted) lookedForTasks = true; + else if (index === epoch && lookedForTasks && !played) { + played = true; + payloads[6] = windowEpoch; + Atomics.store(control, posted, 1); + return Atomics.add(control, epoch, 1) + 1; + } + return value; + }; + const original = Object.getOwnPropertyDescriptor(Atomics, "load"); + let ran = 0; + try { + Object.defineProperty(Atomics, "load", { value: playPool, configurable: true, writable: true }); + runWindow(control, statuses, payloads, windowEpoch, () => { ran++; }); + } + finally { + if (original !== undefined) Object.defineProperty(Atomics, "load", original); + } + expect(played).toBe(true); + expect(ran).toBe(1); + expect(Atomics.load(control, done)).toBe(1); + }); +}); diff --git a/packages/ecs/src/implementation/world/worker-window.ts b/packages/ecs/src/implementation/world/worker-window.ts new file mode 100644 index 000000000..5d3755b6c --- /dev/null +++ b/packages/ecs/src/implementation/world/worker-window.ts @@ -0,0 +1,43 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { done, epoch, posted, ringSize, signal, taskWords } from './protocol.js'; + +/** + * A pool worker's window (`pool.ts`): runs the tasks of its ring, each by `runTask` with its payload's base, which + * throws when the task fails, blocking between them, until the pool closes the window or posts a task of a later one; + * then it returns, so the worker handles its messages. `control`, `statuses` and `payloads` are the worker's views of + * the pool's state; `windowEpoch` is the epoch the window opened in. + */ +export const runWindow = (control: Int32Array, statuses: Int32Array, payloads: Float64Array, windowEpoch: number, runTask: (base: number) => void): void => { + let next = Atomics.load(control, done); + for (;;) { + const seen = Atomics.load(control, signal); + if (next < Atomics.load(control, posted)) { + const ring = next % ringSize; + const base = ring * taskWords; + // A task from a later window waits until this worker has handled the messages sent before it. + if (payloads[base + 6] !== windowEpoch) return; + let status = next + 1; + try { + runTask(base); + } + catch { + status = -status; + } + Atomics.store(statuses, ring, status); + next++; + Atomics.store(control, done, next); + Atomics.notify(control, done); + continue; + } + // Closed, it leaves once it ran this window's tasks: the pool posts a task before it closes the window, so a task + // posted since the check above is seen now, and runs above if it's this window's. + if (Atomics.load(control, epoch) !== windowEpoch) { + if (next < Atomics.load(control, posted)) continue; + return; + } + // Tiers follow each other closely, so spin briefly before paying for a blocking wait's wake-up. + const deadline = performance.now() + 0.05; + while (Atomics.load(control, signal) === seen && performance.now() < deadline); + if (Atomics.load(control, signal) === seen) Atomics.wait(control, signal, seen); + } +}; diff --git a/packages/ecs/src/implementation/world/worker.ts b/packages/ecs/src/implementation/world/worker.ts index b0ed19286..ab4a7c8ff 100644 --- a/packages/ecs/src/implementation/world/worker.ts +++ b/packages/ecs/src/implementation/world/worker.ts @@ -3,11 +3,12 @@ import { buildKernel, buildViews, phases, readPlan, runBuildStep } from './index import type { Entity } from '../../interface/entity/entity.js'; import { createBlock, endBlock, locate, reserveFrom, tableLength, type Block } from './entities.js'; import { createBatch, positionalSpawner, type Positional, type SpawnBatch } from './staging.js'; -import type { Kernel, KernelCommands, KernelModule } from './kernel.js'; +import { blockShift, chunkShift, type Kernel, type KernelCommands, type KernelModule } from './kernel.js'; import { layoutOf, viewsOf, type Layout, type TypedArray, type Views } from '../../interface/registry/layout.js'; -import { emptyViews } from './arrays.js'; +import { absentColumn, emptyViews } from './arrays.js'; import { claimPiece } from './claims.js'; -import { controlWords, done, epoch, idleKernel, lineWords, posted, ringBase, ringCapacity, ringCursor, ringNeeded, ringSize, signal, taskFields, taskWords, type FromWorker, type KernelSetup, type LookupSetup, type ToWorker, type WorkerError } from './protocol.js'; +import { controlWords, idleKernel, lineWords, ringBase, ringCapacity, ringCursor, ringNeeded, ringSize, taskFields, taskWords, type FromWorker, type KernelSetup, type LookupSetup, type ToWorker, type WorkerError } from './protocol.js'; +import { runWindow } from './worker-window.js'; import { beginCommand, createRingWriter, encoderOf, endSegment, noExtent, objectStart, putComponent, putStart, putValue, startSegment, type Encoder, type Extent } from './command-ring.js'; import { forEachPassingRange, type Filter } from './ranges.js'; import { indexParameterCount, instantiateWasm, scalarValues, type WasmRun } from './wasm.js'; @@ -16,7 +17,7 @@ import { coveringBuffer, pageBytes, pagesWord } from './memory.js'; import { endpointChannel, scopeChannel } from './port.js'; import { archetypeField, capacityField, cursorField, entitiesVersionField, entitiesWord, freeCapacityField, freeField, generationField, linearFlag, - recordCapacity, recordEntities, recordStorages, recordVersion, recordWords, rowField, storageAdded, storageChanged, storageChunkAdded, + recordCapacity, recordEntities, recordStorages, recordVersion, recordWords, rowField, storageAdded, storageBlockAdded, storageBlockChanged, storageChanged, storageChunkAdded, storageChunkChanged, storageChunks, storageColumn, storageComponent, storageFlags, storageWords, tableLengthWord, tableOffsetWord, tickedFlag, } from './layout-directory.js'; @@ -27,6 +28,8 @@ type StorageViews = { readonly changed: Float64Array; readonly chunkAdded: Float64Array; readonly chunkChanged: Float64Array; + readonly blockAdded: Float64Array; + readonly blockChanged: Float64Array; }; /** A worker's views of one archetype's columns, as of its record's `version`. */ @@ -161,6 +164,8 @@ const viewArchetype = (record: number, version: number): ArchetypeViews => { changed: ticked ? new Float64Array(memoryBuffer, words[base + storageChanged], capacity) : noTicks, chunkAdded: ticked ? new Float64Array(memoryBuffer, words[base + storageChunkAdded], words[base + storageChunks]) : noTicks, chunkChanged: ticked ? new Float64Array(memoryBuffer, words[base + storageChunkChanged], words[base + storageChunks]) : noTicks, + blockAdded: ticked ? new Float64Array(memoryBuffer, words[base + storageBlockAdded], words[base + storageChunks] << (chunkShift - blockShift)) : noTicks, + blockChanged: ticked ? new Float64Array(memoryBuffer, words[base + storageBlockChanged], words[base + storageChunks] << (chunkShift - blockShift)) : noTicks, }); } return { version, entities: new Float64Array(memoryBuffer, words[record + recordEntities], capacity), storages }; @@ -202,7 +207,7 @@ const createChangedLookup = (lookup: LookupSetup, watched: readonly number[], si if (index < 0) return null; const archetype = archetypeAt(table.archetype[index]); if (archetype === undefined) return null; - for (const id of lookup.read) if (!archetype.storages.has(id)) return null; + for (const id of lookup.read) if (!archetype.storages.has(id) && !lookup.optional.includes(id)) return null; for (const id of lookup.has) if (!archetype.storages.has(id)) return null; for (const id of lookup.not) if (archetype.storages.has(id)) return null; const row = table.row[index]; @@ -210,7 +215,7 @@ const createChangedLookup = (lookup: LookupSetup, watched: readonly number[], si for (let i = 0; i < read.length; i++) { const layout = layouts[read[i]]; const storage = archetype.storages.get(read[i]); - values[i] = layout.kind === "linear" && storage !== undefined ? layout.read(storage.views, row) : layout.kind === "constant" ? layout.value : undefined; + values[i] = storage === undefined && !archetype.storages.has(read[i]) ? undefined : layout.kind === "linear" && storage !== undefined ? layout.read(storage.views, row) : layout.kind === "constant" ? layout.value : undefined; } let changed = since.value < 0; for (let i = 0; i < watched.length && !changed; i++) { @@ -228,14 +233,15 @@ const createLookup = (lookup: LookupSetup) => (entity: Entity): readonly unknown if (index < 0) return null; const archetype = archetypeAt(table.archetype[index]); if (archetype === undefined) return null; - for (const id of lookup.read) if (!archetype.storages.has(id)) return null; + for (const id of lookup.read) if (!archetype.storages.has(id) && !lookup.optional.includes(id)) return null; for (const id of lookup.has) if (!archetype.storages.has(id)) return null; for (const id of lookup.not) if (archetype.storages.has(id)) return null; const row = table.row[index]; return lookup.read.map(id => { const layout = layouts[id]; const storage = archetype.storages.get(id); - return layout.kind === "linear" && storage !== undefined ? layout.read(storage.views, row) : layout.kind === "constant" ? layout.value : undefined; + // An optional component the entity lacks reads undefined. + return storage === undefined && !archetype.storages.has(id) ? undefined : layout.kind === "linear" && storage !== undefined ? layout.read(storage.views, row) : layout.kind === "constant" ? layout.value : undefined; }); }; @@ -248,15 +254,15 @@ const readerArchetype = (id: number) => { /** The one element a muted tick column's stores land on. */ const mutedColumn = new Float64Array(1); -/** A kernel's change filters in an archetype: `added` then `changed`, rows and chunks. */ +/** A kernel's change filters in an archetype: `added` then `changed`, rows, chunks and blocks. */ const filtersOf = (loaded: LoadedKernel, archetype: ArchetypeViews): Filter[] => [ ...loaded.setup.added.flatMap(id => { const storage = archetype.storages.get(id); - return storage === undefined ? [] : [{ ticks: storage.added, chunks: storage.chunkAdded }]; + return storage === undefined ? [] : [{ ticks: storage.added, chunks: storage.chunkAdded, blocks: storage.blockAdded }]; }), ...loaded.setup.changed.flatMap(id => { const storage = archetype.storages.get(id); - return storage === undefined ? [] : [{ ticks: storage.changed, chunks: storage.chunkChanged }]; + return storage === undefined ? [] : [{ ticks: storage.changed, chunks: storage.chunkChanged, blocks: storage.blockChanged }]; }), ]; @@ -264,16 +270,20 @@ const bind = (loaded: LoadedKernel, archetypeId: number, archetype: ArchetypeVie const columns = loaded.setup.sources.map((source, k) => { if (loaded.setup.muted[k]) return mutedColumn; if (source.kind === "entity") return archetype.entities; + if (source.kind === "present") return archetype.storages.has(source.id) ? archetype.entities : emptyViews.f64; const storage = archetype.storages.get(source.id); + // An optional component's columns are empty where the archetype lacks it: reads give undefined. + if (storage === undefined && loaded.setup.optional.includes(source.id)) return absentColumn(layouts[source.id], source); if (storage === undefined) throw new Error(`Archetype ${archetypeId} lacks component ${source.id}.`); if (source.kind === "changed") return storage.changed; if (source.kind === "chunks") return storage.chunkChanged; + if (source.kind === "blocks") return storage.blockChanged; const layout = layouts[source.id]; if (layout.kind !== "linear") throw new Error(`Component ${source.id} isn't linear.`); return storage.views[layout.leaves[source.leaf].view]; }); const filters = filtersOf(loaded, archetype); - const addresses = [...columns.map(column => column.byteOffset), ...filters.map(filter => filter.ticks.byteOffset), ...filters.map(filter => filter.chunks.byteOffset)]; + const addresses = [...columns.map(column => column.byteOffset), ...filters.map(filter => filter.ticks.byteOffset), ...filters.map(filter => filter.chunks.byteOffset), ...filters.map(filter => filter.blocks.byteOffset)]; return { views: archetype, columns, filters, addresses }; }; @@ -337,7 +347,8 @@ const run = (message: RunMessage): void => { const { columns, filters, addresses } = boundOf(loaded, message.archetype); const { commands } = stagingOf(loaded); const { wasm } = loaded; - if (wasm !== null && loaded.setup.wasm !== null) { + // WASM runs only archetypes with every optional component; the JS kernel runs the rest. + if (wasm !== null && loaded.setup.wasm !== null && loaded.setup.optional.every(id => boundOf(loaded, message.archetype).views.storages.has(id))) { wasm(message.start, message.end, message.tick, message.lastRun, ...addresses, ...message.lookups, ...message.indexes, ...scalarValues(loaded.setup.wasm.scalars, message.resources, message.args), ...message.partials < 0 ? [] : [message.partials]); @@ -374,7 +385,7 @@ const load = async (setups: readonly KernelSetup[]): Promise => { const since: Since | null = setup.lookups.some(lookup => lookup.changed !== null) ? { value: 0 } : null; const lookups = setup.lookups.map((lookup, k): unknown => readers.find(found => found.lookup === k)?.reader ?? (lookup.changed === null || since === null ? createLookup(lookup) : createChangedLookup(lookup, lookup.changed, since))); - kernels.set(setup.index, { since, setup, kernel: loaded[i].module.create(setup.booleans, setup.strides.map((stride, k) => setup.muted[k] ? 0 : stride), setup.offsets.map((offset, k) => setup.muted[k] ? 0 : offset), setup.up.booleans, setup.up.strides, setup.up.offsets), wasm: loaded[i].wasm, lookups, readers, staging: null, bound: [] }); + kernels.set(setup.index, { since, setup, kernel: loaded[i].module.create(setup.booleans, setup.strides.map((stride, k) => setup.muted[k] ? 0 : stride), setup.offsets.map((offset, k) => setup.muted[k] ? 0 : offset), setup.up.booleans, setup.up.strides, setup.up.offsets, setup.labels), wasm: loaded[i].wasm, lookups, readers, staging: null, bound: [] }); }); }; @@ -424,54 +435,30 @@ const runBuildTask = (payloads: Float64Array, base: number, buffer: ArrayBufferL * Runs tasks from this worker's ring in shared memory, blocking between them, until the main thread asks it to leave; * then it returns to handling messages. */ -const runWindow = (message: Extract): void => { +const openWindow = (message: Extract): void => { const buffer = required(memory, "memory").buffer; // The pool's control state never moves, so these views last the window. const control = new Int32Array(buffer, message.control + message.worker * controlWords * 4, controlWords); const statuses = new Int32Array(buffer, message.statuses + message.worker * ringSize * 4, ringSize); const payloads = new Float64Array(buffer, message.payloads + message.worker * ringSize * taskWords * 8, ringSize * taskWords); - let next = Atomics.load(control, done); - for (;;) { - const seen = Atomics.load(control, signal); - if (next < Atomics.load(control, posted)) { - const ring = next % ringSize; - const base = ring * taskWords; - // A task from a later window waits until this worker has handled the messages sent before it. - if (payloads[base + 6] !== message.epoch) return; - let status = next + 1; - try { - refreshMemory(); - const kernel = payloads[base]; - if (kernel === buildKernel) runBuildTask(payloads, base, buffer, message); - else if (kernel !== idleKernel) { - const loaded = kernels.get(kernel); - if (loaded === undefined) throw new Error("Worker can't run this task."); - const line = payloads[base + 8]; - const participants = payloads[base + 11]; - const scalarCount = payloads[base + 7]; - const partials = loaded.setup.reduces ? payloads[base + taskFields + scalarCount] : -1; - const partialBytes = loaded.setup.reduces ? payloads[base + taskFields + scalarCount + 1] : 0; - runWasm(loaded, payloads[base + 1], payloads[base + 2], payloads[base + 3], payloads[base + 4], payloads[base + 5], - Array.from(payloads.subarray(base + taskFields, base + taskFields + scalarCount)), - line < 0 ? null : { words: new Int32Array(buffer, message.claims + line * lineWords * 4, participants * lineWords), self: payloads[base + 10], participants }, - payloads[base + 9], partials ?? -1, partialBytes ?? 0); - } - } - catch { - status = -status; - } - Atomics.store(statuses, ring, status); - next++; - Atomics.store(control, done, next); - Atomics.notify(control, done); - continue; + runWindow(control, statuses, payloads, message.epoch, base => { + refreshMemory(); + const kernel = payloads[base]; + if (kernel === buildKernel) runBuildTask(payloads, base, buffer, message); + else if (kernel !== idleKernel) { + const loaded = kernels.get(kernel); + if (loaded === undefined) throw new Error("Worker can't run this task."); + const line = payloads[base + 8]; + const participants = payloads[base + 11]; + const scalarCount = payloads[base + 7]; + const partials = loaded.setup.reduces ? payloads[base + taskFields + scalarCount] : -1; + const partialBytes = loaded.setup.reduces ? payloads[base + taskFields + scalarCount + 1] : 0; + runWasm(loaded, payloads[base + 1], payloads[base + 2], payloads[base + 3], payloads[base + 4], payloads[base + 5], + Array.from(payloads.subarray(base + taskFields, base + taskFields + scalarCount)), + line < 0 ? null : { words: new Int32Array(buffer, message.claims + line * lineWords * 4, participants * lineWords), self: payloads[base + 10], participants }, + payloads[base + 9], partials ?? -1, partialBytes ?? 0); } - if (Atomics.load(control, epoch) !== message.epoch) return; - // Tiers follow each other closely, so spin briefly before paying for a blocking wait's wake-up. - const deadline = performance.now() + 0.05; - while (Atomics.load(control, signal) === seen && performance.now() < deadline); - if (Atomics.load(control, signal) === seen) Atomics.wait(control, signal, seen); - } + }); }; /** Where messages come from and replies go: this worker's own scope, until a lent worker's owner gives it a port. */ @@ -493,7 +480,7 @@ const handle = async (message: ToWorker): Promise => { return; } if (message.kind === "window") { - runWindow(message); + openWindow(message); return; } if (message.kind === "layout") { diff --git a/packages/ecs/src/implementation/world/workers.fixture.ts b/packages/ecs/src/implementation/world/workers.fixture.ts index 0bceffef7..a34af434e 100644 --- a/packages/ecs/src/implementation/world/workers.fixture.ts +++ b/packages/ecs/src/implementation/world/workers.fixture.ts @@ -1,7 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Component } from "../../interface/component/component.js"; import { Entity } from "../../interface/entity/entity.js"; -import { Resource } from "../../interface/resource/resource.js"; import type { Schema } from "../../interface/schema/schema.js"; import { System } from "../../interface/system/system.js"; import type { World } from "../../interface/world/world.js"; @@ -10,6 +8,7 @@ import { createWorld } from "./create.js"; import { workers } from "../../interface/resources/workers.js"; import { countingWorkers } from "./platforms.test-helper.js"; import type { WorkerPlatform } from "./worker-platform.js"; +import type { F32 } from "../../interface/math/index.js"; /** Scenarios of kernels on workers against the main thread alone, run built in node and from source in chromium. */ @@ -35,10 +34,10 @@ const seconds = Symbol("workersTest/seconds"); declare module "@adobe/ecs" { export interface Components { - [position]: { type: readonly [number, number] }; - [velocity]: { type: readonly [number, number] }; + [position]: { type: Schema.Type<{ type: "array"; items: { type: "number"; precision: 1 }; minItems: 2; maxItems: 2 }> }; + [velocity]: { type: Schema.Type<{ type: "array"; items: { type: "number"; precision: 1 }; minItems: 2; maxItems: 2 }> }; [target]: { type: Entity }; - [offset]: { type: number }; + [offset]: { type: F32 }; [spawned]: { type: true }; [tagA]: { type: true }; [tagB]: { type: true }; @@ -51,37 +50,22 @@ declare module "@adobe/ecs" { [tagI]: { type: true }; } export interface Resources { - [seconds]: { type: number }; + [seconds]: { type: number; default: 0.25 }; } } -Component.register(position, { type: vec2 }); -Component.register(velocity, { type: vec2 }); -Component.register(target, { type: Entity.schema }); -Component.register(offset, { type: { type: "number", precision: 1 } }); -Component.register(spawned, { type: { const: true } }); -Component.register(tagA, { type: { const: true } }); -Component.register(tagB, { type: { const: true } }); -Component.register(tagC, { type: { const: true } }); -Component.register(tagD, { type: { const: true } }); -Component.register(tagE, { type: { const: true } }); -Component.register(tagF, { type: { const: true } }); -Component.register(tagG, { type: { const: true } }); -Component.register(tagH, { type: { const: true } }); -Component.register(tagI, { type: { const: true } }); /** 64 entities in each of the 512 archetypes the tags combine into. */ -const scatter = System.create({ name: "workersTest/scatter" }, ({ commands }) => { +const scatter = System.create({}, ({ commands }) => { for (let i = 0; i < 512 * 64; i++) { const components: (typeof position | typeof velocity | (typeof tags)[number])[] = [position, velocity]; tags.forEach((tag, bit) => { if (((i >> 6) & (1 << bit)) !== 0) components.push(tag); }); commands.spawn(components)({ [position]: [i, 0], [velocity]: [1, i % 5] }); } }); -Resource.register(seconds, { type: { type: "number", default: 0.25 } }); /** Spawns `count` moving entities, each following the one before it. */ -const seed = System.create({ name: "workersTest/seed", input: { type: "object", properties: { count: { type: "integer" } } } }, ({ input, commands }) => { +const seed = System.create({}, ({ commands }, input: { readonly count: number }) => { let previous = commands.spawn([position, velocity])({ [position]: [0, 0], [velocity]: [1, 0] }); for (let i = 1; i < input.count; i++) { previous = commands.spawn([position, velocity, target])({ [position]: [i, 0], [velocity]: [1, i % 7], [target]: previous }); @@ -89,10 +73,8 @@ const seed = System.create({ name: "workersTest/seed", input: { type: "object", }); const integrate = System.create({ - name: "workersTest/integrate", - kernel: "cpu", - components: { read: [velocity], write: [position] }, - resources: { read: [seconds] }, + kernel: true, + components: { with: [velocity, position] }, }, (row, { resources }) => { const [x, y] = row[position]; const [vx, vy] = row[velocity]; @@ -101,9 +83,8 @@ const integrate = System.create({ /** Structural commands from workers: rows past a threshold spawn a marker and lose their velocity. */ const split = System.create({ - name: "workersTest/split", - kernel: "cpu", - components: { read: [position], has: [velocity] }, + kernel: true, + components: { with: [position, velocity] }, after: [integrate], }, (row, { commands }) => { const [x, y] = row[position]; @@ -115,10 +96,9 @@ const split = System.create({ /** Lookups from workers: offset by the distance to the target. */ const follow = System.create({ - name: "workersTest/follow", - kernel: "cpu", - components: { read: [position, target], write: [offset] }, - lookup: { targets: { read: [position] } }, + kernel: true, + components: { with: [position, target, offset] }, + lookup: { targets: { with: [position] } }, after: [split], }, (row, { lookup: { targets } }) => { const other = targets(row[target]); @@ -126,31 +106,28 @@ const follow = System.create({ row[offset] = other === null ? -1 : x - other[position][0]; }, ); -const main = System.create({ name: "workersTest/main", systems: [integrate, split, follow] }); +const main = System.create({ systems: [integrate, split, follow] }); /** Every row writes the same entity, so only merge order decides which value survives. */ const stamp = System.create({ - name: "workersTest/stamp", - kernel: "cpu", - input: { type: "object", properties: { entity: Entity.schema } }, - components: { read: [position] }, -}, (row, { input, commands }) => { + kernel: true, + components: { with: [position] }, +}, (row, { commands }, input: { readonly entity: Entity }) => { const [x, y] = row[position]; commands.add(input.entity, offset, x * 1000 + y); }); /** Despawns entity 1, the resource entity, since index 0 is never an entity and registration reserves the next first. */ const unlink = System.create({ - name: "workersTest/unlink", - kernel: "cpu", - components: { has: [target] }, + kernel: true, + components: { with: [target] }, }, (_, { commands }) => { // @ts-expect-error an untyped caller can pass any number commands.despawn(1); }); /** Archetypes in this order: 5 rows the main thread runs, 1,000 that workers run, then 10 more on the main thread. */ -const sources = System.create({ name: "workersTest/sources" }, ({ commands }) => { +const sources = System.create({}, ({ commands }) => { for (let i = 0; i < 5; i++) commands.spawn([offset, tagD])({ [offset]: i }); for (let i = 5; i < 1_005; i++) commands.spawn([offset, tagA])({ [offset]: i }); for (let i = 1_005; i < 1_015; i++) commands.spawn([offset, tagB])({ [offset]: i }); @@ -158,11 +135,9 @@ const sources = System.create({ name: "workersTest/sources" }, ({ commands }) => /** A spawn per row, from wherever its chunk runs; with `edit`, each spawn is also added to and removed from. */ const emit = System.create({ - name: "workersTest/emit", - kernel: "cpu", - input: { type: "object", properties: { edit: { type: "boolean" } } }, - components: { read: [offset], not: [spawned] }, -}, (row, { input, commands }) => { + kernel: true, + components: { with: [offset], not: [spawned] }, +}, (row, { commands }, input: { readonly edit: boolean }) => { const entity = commands.spawn([position, spawned, tagC])({ [position]: [row[offset], -row[offset]] }); if (input.edit) { commands.add(entity, velocity, [row[offset], 1]); @@ -171,11 +146,11 @@ const emit = System.create({ }); const emitted = { seen: 0 }; -const countEmitted = System.create({ name: "workersTest/countEmitted", components: { added: [position], has: [spawned] } }, () => { emitted.seen++; }); +const countEmitted = System.create({ components: { added: [position], with: [spawned] } }, () => { emitted.seen++; }); const gathered: Entity[] = []; -const gatherPositioned = System.create({ name: "workersTest/gatherPositioned", observer: true, components: { has: [position] } }, row => { gathered.push(row.entity); }); -const gatherSpawned = System.create({ name: "workersTest/gatherSpawned", observer: true, components: { has: [spawned] } }, row => { gathered.push(row.entity); }); +const gatherPositioned = System.create({ observer: true, components: { with: [position] } }, row => { gathered.push(row.entity); }); +const gatherSpawned = System.create({ observer: true, components: { with: [spawned] } }, row => { gathered.push(row.entity); }); /** The entities an observer row system passes, in storage order. */ const storageOrder = async (world: World, gather: System): Promise => { @@ -253,8 +228,9 @@ export const unlinked = (): Promise => using(createWorld({ [workers]: 2 /** Workers ended by `dispose`, and what a run after it rejects with. */ export const disposed = async (): Promise<{ readonly started: number; readonly terminated: number; readonly after: string }> => { const counting = countingWorkers(); - const world = createWorld({ [workers]: 2 }, { pool: counting.platform }); - // Workers start with the first kernel. + const world = createWorld({ [workers]: 2 }, { minimumChunk: 64, pool: counting.platform }); + // Workers start with the first kernel run over rows enough for them. + await world.run(seed, { count: 64 }); await world.run(main); await world.dispose(); const after = await world.run(main).then(() => "resolved", (error: unknown) => String(error)); diff --git a/packages/ecs/src/index.ts b/packages/ecs/src/index.ts index f146c518f..cb678fef5 100644 --- a/packages/ecs/src/index.ts +++ b/packages/ecs/src/index.ts @@ -1,4 +1,8 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. +/** + * A high-performance, multithreaded Entity Component System. + * @module @adobe/ecs + */ import { create, join } from './implementation/index.js'; import type { Create } from './interface/world/create.js'; import type { Join } from './interface/world/join.js'; diff --git a/packages/ecs/src/interface/archetype/archetype.ts b/packages/ecs/src/interface/archetype/archetype.ts deleted file mode 100644 index 850526668..000000000 --- a/packages/ecs/src/interface/archetype/archetype.ts +++ /dev/null @@ -1,4 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -export type Archetype = never; - -export * as Archetype from './index.js'; diff --git a/packages/ecs/src/interface/archetype/archetype.type-test.ts b/packages/ecs/src/interface/archetype/archetype.type-test.ts deleted file mode 100644 index c89bbf297..000000000 --- a/packages/ecs/src/interface/archetype/archetype.type-test.ts +++ /dev/null @@ -1,38 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Resource } from '../resource/resource.js'; -import { Component } from '../component/component.js'; -import { Archetype, System } from "@adobe/ecs"; - -const position = Symbol("archetypeTypeTest/position"); -const velocity = Symbol("archetypeTypeTest/velocity"); -const scale = Symbol("archetypeTypeTest/scale"); -declare module "@adobe/ecs" { - interface Components { - [position]: { type: number }; - [velocity]: { type: number }; - } - interface Resources { - [scale]: { type: number }; - } -} -Component.register(position, { type: { type: "number" } }); -Component.register(velocity, { type: { type: "number" } }); -Resource.register(scale, { type: { type: "number", default: 0 } }); - -type Equal = (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) ? true : false; -const expectType = (): T | undefined => undefined; - -// `create` returns its argument, typed as the tuple it was given. -export const particle = Archetype.create([position, velocity]); -expectType>(); -export const one: readonly Component.Key[] = particle; -// A plain readonly list of components is an archetype too. -export const plain = [position] as const; -export const moveOver = System.create({ name: "archetypeTypeTest/moveOver", components: { write: plain } }, row => { row[position] += 1; }); -// @ts-expect-error a resource is never in an archetype -Archetype.create([position, scale]); -// @ts-expect-error nor is a string -Archetype.create(["position"]); -// Archetypes go wherever component lists do. -export const move = System.create({ name: "archetypeTypeTest/move", components: { write: particle } }, row => { row[position] += row[velocity]; }); -export const read = System.create({ name: "archetypeTypeTest/read", observer: true, components: { read: particle } }, row => { void row[velocity]; }); diff --git a/packages/ecs/src/interface/archetype/create.test.ts b/packages/ecs/src/interface/archetype/create.test.ts deleted file mode 100644 index c45e116cf..000000000 --- a/packages/ecs/src/interface/archetype/create.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { describe, expect, it } from "vitest"; -import { Component } from "../component/component.js"; -import { archetypeIndexOf } from "../registry/index.js"; -import { Archetype } from "./archetype.js"; - -const position = Symbol("archetypeCreateTest/position"); -const velocity = Symbol("archetypeCreateTest/velocity"); -declare module "@adobe/ecs" { - interface Components { - [position]: { type: number }; - [velocity]: { type: number }; - } -} -Component.register(position, { type: { type: "number" } }); -Component.register(velocity, { type: { type: "number" } }); - -describe("Archetype.create", () => { - it("returns its argument, with a program-wide index of its own", () => { - const list = [position, velocity] as const; - const particle = Archetype.create(list); - const other = Archetype.create([velocity]); - expect(particle).toBe(list); - expect(particle).toEqual([position, velocity]); - expect(archetypeIndexOf(particle)).toBeGreaterThanOrEqual(0); - expect(archetypeIndexOf(other)).toBeGreaterThan(archetypeIndexOf(particle)); - expect(Object.keys(particle)).toEqual(["0", "1"]); - }); - it("keeps the index of a list given again", () => { - const particle = Archetype.create([position]); - const index = archetypeIndexOf(particle); - expect(Archetype.create(particle)).toBe(particle); - expect(archetypeIndexOf(particle)).toBe(index); - }); - it("gives a plain list no index", () => { - expect(archetypeIndexOf([position])).toBe(-1); - }); - it("throws for a component listed twice", () => { - expect(() => Archetype.create([position, velocity, position])).toThrow(`An archetype lists "archetypeCreateTest/position" twice.`); - }); -}); diff --git a/packages/ecs/src/interface/archetype/create.ts b/packages/ecs/src/interface/archetype/create.ts deleted file mode 100644 index 238645fca..000000000 --- a/packages/ecs/src/interface/archetype/create.ts +++ /dev/null @@ -1,13 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { Component } from '../component/component.js'; -import { indexArchetype } from '../registry/archetypes.js'; - -/** - * Declares an archetype, the components an entity has, listed once each: returns `components`, typed as the tuple - * given, with a program-wide index the world finds its storage by. It goes wherever a list of components does, as any - * readonly list of components does. Throws for a component listed twice. - */ -export const create = (components: C): C => { - indexArchetype(components); - return components; -}; diff --git a/packages/ecs/src/interface/component/declaration.ts b/packages/ecs/src/interface/component/declaration.ts deleted file mode 100644 index 021251142..000000000 --- a/packages/ecs/src/interface/component/declaration.ts +++ /dev/null @@ -1,68 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { IsData } from "../schema/is-data.js"; -import type { Schema } from "../schema/schema.js"; -import type { Key as TraitKey } from "../trait/key.js"; -import type { Required } from "../trait/required.js"; -import type { Traits } from "../trait/types.js"; -import type { SymbolKeys } from "../trait/symbol-keys.js"; -import type { Key } from "./key.js"; -import type { Components } from "./types.js"; - -/** A value starts at `default`, which a type that allows `null` may leave out: it's `null` then. */ -export type Starting = null extends T ? { readonly default?: T } : { readonly default: T }; - -type Same = (() => X extends A ? 1 : 2) extends (() => X extends B ? 1 : 2) ? true : false; - -/** How deep `Flattened` goes: deeper values are compared as they are, so recursive types stay finite. */ -type Depth = [never, 0, 1, 2, 3, 4, 5, 6, 7]; - -/** `T` with every object, at any depth up to eight, as one object type, so `A & B` compares equal to the object it is. */ -type Flattened = - [D] extends [never] ? T : - T extends string | number | boolean | bigint | symbol | null | undefined | ((...parameters: never[]) => unknown) ? T : - T extends ReadonlyMap ? (T extends Map ? T : ReadonlyMap, Flattened>) : - T extends ReadonlySet ? (T extends Set ? T : ReadonlySet>) : - { [K in keyof T]: Flattened }; - -/** Whether `A` and `B` are the same type, flattened only when they aren't identical. */ -type Equivalent = Same extends true ? true : Same, Flattened>; - -/** - * Nothing when schema `S` describes exactly the declared type `T` and its `default`, if any, is a `T`; else a required - * property naming the problem, so registration fails to compile and says why. A check rather than `type`'s own type, - * so `type` is inferred from the schema alone. - */ -export type Describing = - ((S extends Schema ? Equivalent, T> : false) extends true ? {} : { readonly "the schema describes another type than the declared one": T }) - & (S extends { readonly default: infer V } ? ([V] extends [T] ? {} : { readonly "the schema's default isn't of the declared type": T }) : {}); - -/** A derived component starts at its schema's `default`, at its `const`, or at `null` when its type allows it. */ -type DerivedStart = - S extends { readonly const: unknown } | { readonly default: unknown } ? {} : - null extends T ? {} : - { readonly "a derived component's schema has a default, which it starts at": T }; - -/** - * The value type's part of a declaration: `type`, schema `S`, describing exactly the declared type `T` when it's data. - * A type that isn't data has no `type`, and a derived one starts at the declaration's `default`. - */ -type TypePart = - IsData extends true - ? [S] extends [never] ? { readonly type: Schema } : Describing & { readonly default?: never } & (Derived extends true ? DerivedStart : {}) - : { readonly type?: never } & (Derived extends true ? Starting : { readonly default?: never }); - -/** - * What `register(K, ...)` takes: exactly the traits `Components[K]` declares, `type` as schema `S` for the declared - * type. A trait whose declared value its definition doesn't allow on `K`, or one a value of its type must declare and - * doesn't, becomes `never`, so registration fails to compile, as every registration does while a component, resource - * or trait is keyed by a string (`SymbolKeys`). - */ -export type Declaration = - { readonly type?: S } - & TypePart - & { readonly [T in Exclude]: - T extends TraitKey ? (Components[K][T] extends Traits[T] ? Components[K][T] : never) : - never } - & { readonly [T in Exclude]?: never } - & Required - & SymbolKeys; diff --git a/packages/ecs/src/interface/component/declaration.type-test.ts b/packages/ecs/src/interface/component/declaration.type-test.ts deleted file mode 100644 index c2cbee4fc..000000000 --- a/packages/ecs/src/interface/component/declaration.type-test.ts +++ /dev/null @@ -1,88 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Entity } from '../entity/entity.js'; -import { Vec3 } from '../math/index.js'; -import { Component } from './component.js'; - -type Equal = (() => T extends X ? 1 : 2) extends (() => T extends Y ? 1 : 2) ? true : false; -const check = <_T extends true>() => { }; - -const position = Symbol("declarationTypeTest/position"); -const score = Symbol("declarationTypeTest/score"); -const parent = Symbol("declarationTypeTest/parent"); -const follows = Symbol("declarationTypeTest/follows"); -const tags = Symbol("declarationTypeTest/tags"); -const weights = Symbol("declarationTypeTest/weights"); -const element = Symbol("declarationTypeTest/element"); -const handle = Symbol("declarationTypeTest/handle"); -const cache = Symbol("declarationTypeTest/cache"); -const level = Symbol("declarationTypeTest/level"); - -// The declared traits are the source of truth; registration must give exactly them. -declare module '@adobe/ecs' { - export interface Components { - [position]: { type: Vec3 }; - [score]: { type: number }; - [parent]: { type: Entity; reference: "owner" }; - [follows]: { type: Entity; reference: "weak" }; - [tags]: { type: ReadonlySet }; - [weights]: { type: ReadonlyMap }; - [element]: { type: HTMLElement; thread: "main" }; - [handle]: { type: { readonly release: () => void } }; - [cache]: { type: Map }; - [level]: { type: number; derived: true }; - } -} - -Component.register(position, { type: Vec3.schema }); -Component.register(score, { type: { type: "integer" } }); -Component.register(parent, { type: Entity.schema, reference: "owner" }); -Component.register(follows, { type: Entity.schema, reference: "weak" }); -Component.register(tags, { type: { type: "set", items: { type: "string" } } }); -Component.register(weights, { type: { type: "map", keys: Entity.schema, values: { type: "number" } } }); -Component.register(level, { type: { type: "number", default: 0 }, derived: true }); - -// A schema describes exactly the declared type. -// @ts-expect-error a variable-length array isn't a Vec3 -Component.register(position, { type: { type: "array", items: { type: "number" } } }); -// @ts-expect-error a string isn't a number -Component.register(score, { type: { type: "string" } }); -// @ts-expect-error a number's schema isn't an entity's -Component.register(parent, { type: { type: "integer" }, reference: "owner" }); -// @ts-expect-error a set of numbers isn't a set of strings -Component.register(tags, { type: { type: "set", items: { type: "number" } } }); -// @ts-expect-error an array isn't a set -Component.register(tags, { type: { type: "array", items: { type: "string" } } }); -// @ts-expect-error a map's keys are entities -Component.register(weights, { type: { type: "map", keys: { type: "string" }, values: { type: "number" } } }); - -// Every declared trait is registered, with its declared value, and nothing undeclared is. -// @ts-expect-error a declared trait is missing -Component.register(parent, { type: Entity.schema }); -// @ts-expect-error an undeclared trait is given -Component.register(score, { type: { type: "number" }, thread: "main" }); -// @ts-expect-error an undeclared trait is given -Component.register(score, { type: { type: "number" }, derived: true }); -// @ts-expect-error a trait's value differs from the declared one -Component.register(follows, { type: Entity.schema, reference: "owner" }); -// A declaration held in a constant gets no excess property check, so the types name each undeclared trait. -const placed = { type: { type: "number" }, thread: "main" } as const; -// @ts-expect-error an undeclared trait is given -Component.register(score, placed); - -// A type that's data is registered with its schema; one that isn't data has none, and is stored as it is. -// @ts-expect-error a number is data, so it has a schema -Component.register(score, {}); -// @ts-expect-error a set of strings is data too -Component.register(tags, {}); -Component.register(element, { thread: "main" }); -Component.register(handle, {}); -// A Set or Map changes in place, so it isn't data. -Component.register(cache, {}); -// @ts-expect-error a value that isn't data has no schema -Component.register(handle, { type: { type: "object", properties: {} } }); - -// Value types are the declared ones. -check, Vec3>>(); -check, ReadonlySet>>(); -check, ReadonlyMap>>(); -check, HTMLElement>>(); diff --git a/packages/ecs/src/interface/component/index.ts b/packages/ecs/src/interface/component/index.ts index ac4e91828..4c6a6b0aa 100644 --- a/packages/ecs/src/interface/component/index.ts +++ b/packages/ecs/src/interface/component/index.ts @@ -1,4 +1,3 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -export * from './register.js'; export type { Key } from './key.js'; export type { Value, With } from './value.js'; diff --git a/packages/ecs/src/interface/component/key.type-test.ts b/packages/ecs/src/interface/component/key.type-test.ts index a5582d817..06644e209 100644 --- a/packages/ecs/src/interface/component/key.type-test.ts +++ b/packages/ecs/src/interface/component/key.type-test.ts @@ -16,8 +16,6 @@ declare module "@adobe/ecs" { readonly [mark]: true; } } -Component.register(position, { type: { type: "number" } }); -Resource.register(scale, { type: { type: "number", default: 0 } }); // Components, resources and traits are named by keys; systems, rules and finalizers by names. export const component: Component.Key = position; diff --git a/packages/ecs/src/interface/component/register.test.ts b/packages/ecs/src/interface/component/register.test.ts index c2666f77b..83a76e378 100644 --- a/packages/ecs/src/interface/component/register.test.ts +++ b/packages/ecs/src/interface/component/register.test.ts @@ -1,9 +1,9 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; import { Entity } from "../entity/entity.js"; +import { registerComponent } from "../registry/declarations.js"; +import { addTrait } from "../registry/traits.js"; import { declarationOf, kindOf, threadOfDeclaration } from "../registry/index.js"; -import { Trait } from "../trait/trait.js"; -import { Component } from "./component.js"; const parent = Symbol("typesComponentTest/parent"); const count = Symbol("typesComponentTest/count"); @@ -28,32 +28,17 @@ declare module "@adobe/ecs" { [position]: { type: readonly [number, number] }; [bitmap]: { type: ImageBitmap; transfer: true }; [marked]: { type: true; [mark]: true }; - [slot]: { type: number; derived: true }; + [slot]: { type: number; derived: true; default: 0 }; } export interface Traits { readonly [mark]: true; - readonly [late]: true; } } -Trait.register(mark); -Component.register(parent, { type: Entity.schema, reference: "owner" }); -Component.register(count, { type: { type: "number" } }); -Component.register(label, { type: { type: "string" } }); -Component.register(placed, { type: { type: "array", items: { type: "string" } }, thread: "typesComponentTest" }); -Component.register(position, { type: { type: "array", items: { type: "number" }, minItems: 2, maxItems: 2 } }); -Component.register(bitmap, { transfer: true }); -Component.register(marked, { type: { const: true }, [mark]: true }); -Component.register(slot, { type: { type: "number", default: 0 }, derived: true }); +/** Registers a declaration as the build does, which a test may write wrong. */ +const register = (key: unknown, declaration: object): void => registerComponent(key, declaration); -const untyped: (...parameters: never[]) => unknown = Component.register; - -/** Registers as an untyped caller would, bypassing the types' checks. */ -const register = (key: unknown, declaration: object): void => { - Reflect.apply(untyped, undefined, [key, declaration]); -}; - -describe("Component.register", () => { +describe("registering a component", () => { it("records the declaration, and its thread: none for a struct or a string, else its own or \"world\"", () => { expect(declarationOf(parent)).toEqual({ type: Entity.schema, reference: "owner" }); expect(kindOf(parent)).toBe("component"); @@ -68,31 +53,37 @@ describe("Component.register", () => { expect(threadOfDeclaration({ type: { anyOf: [{ type: "string" }, { type: "number" }] } })).toBe("world"); }); it("accepts the same declaration again, as a module evaluated again makes it, and rejects a different one or a key of another kind", () => { - expect(() => Component.register(count, { type: { type: "number" } })).not.toThrow(); + expect(() => register(count, { type: { type: "number" } })).not.toThrow(); expect(() => register(count, { type: { type: "integer" } })).toThrow(`Cannot register component "typesComponentTest/count": already registered as a component, with another declaration.`); expect(declarationOf(count)).toEqual({ type: { type: "number" } }); const claimed = Symbol("typesComponentTest/claimed"); register(claimed, { type: { const: true } }); - expect(() => Reflect.apply(Trait.register, undefined, [claimed])).toThrow(`Cannot register trait "typesComponentTest/claimed": already registered as a component.`); + expect(() => addTrait(claimed)).toThrow(`Cannot register trait "typesComponentTest/claimed": already registered as a component.`); expect(kindOf(count)).toBe("component"); }); it("takes only plain symbols as keys, so built-in traits' string names are no component's", () => { - // @ts-expect-error a key is a symbol, and derived is a built-in trait's name - expect(() => Component.register("derived", { type: { const: true } })).toThrow(`Cannot register component "derived": a key is a plain Symbol(...), not a string.`); - expect(() => register(Symbol.for("typesComponentTest/shared"), { type: { const: true } })).toThrow("a Symbol.for key is shared across the program"); + expect(() => register("derived", { type: { const: true } })).toThrow(`Cannot register component "derived": a key is a plain Symbol(...), not a string.`); + expect(() => register(Symbol.for("shared"), { type: { const: true } })).toThrow("a Symbol.for key is shared across the program"); expect(() => register(Symbol.asyncIterator, { type: { const: true } })).toThrow("Symbol.asyncIterator is a well-known symbol."); expect(kindOf("derived")).toBe(undefined); }); - it("rejects what types reject, for untyped callers, recording nothing", () => { + it("rejects a declaration whose traits can't hold, recording nothing", () => { expect(() => register(Symbol("typesComponentTest/strayTrait"), { type: { type: "number" }, unknown: true })).toThrow("unknown isn't a registered trait"); expect(() => register(Symbol("typesComponentTest/badReference"), { type: { type: "number" }, reference: "weak" })).toThrow("a reference's type is Entity.schema"); - expect(() => register(Symbol("typesComponentTest/badSlot"), { type: { type: "number" }, derived: true })).toThrow("a derived component's type has a default"); + expect(() => register(Symbol("typesComponentTest/badSlot"), { type: { type: "number" }, derived: true })).toThrow("a derived component has a default"); expect(() => register(Symbol("typesComponentTest/reduced"), { type: { type: "number" }, reduce: "+" })).toThrow("only a resource is reduced"); + expect(() => register(Symbol("typesComponentTest/derivedWithin"), { type: { type: "number" }, default: 0, derived: true, within: count })).toThrow("a derived component's rule covers within"); + expect(() => register(Symbol("typesComponentTest/derivedReference"), { type: { ...Entity.schema }, default: Entity.none, derived: true, reference: "owner" })).toThrow("a rule can't set a derived component's target"); const badReference = Symbol("typesComponentTest/badReference"); expect(() => register(badReference, { type: { type: "number" }, reference: "weak" })).toThrow("a reference's type is Entity.schema"); expect(declarationOf(badReference)).toBe(undefined); expect(kindOf(badReference)).toBe(undefined); }); + it("rejects a default that isn't one of its type's values, however it's given", () => { + expect(() => register(Symbol("typesComponentTest/tallHeight"), { type: { type: "number", precision: 1 }, default: "tall" })).toThrow("its default, tall, isn't one of its type's values"); + expect(() => register(Symbol("typesComponentTest/lowLevel"), { type: { type: "integer", minimum: 1, maximum: 9 }, default: 0, derived: true })).toThrow("isn't one of its type's values"); + expect(() => register(Symbol("typesComponentTest/floor"), { type: { type: "number", precision: 1 }, default: -Infinity })).not.toThrow(); + }); it("rejects a schema whose GPU layout can't hold it", () => { expect(() => register(Symbol("typesComponentTest/storageText"), { type: { type: "array", items: { type: "string" }, layout: "storage" } })).toThrow("layout needs numbers"); }); @@ -108,18 +99,19 @@ describe("Component.register", () => { it("records no type for a value that isn't data", () => { expect(declarationOf(bitmap)).toEqual({ transfer: true }); expect(Reflect.has(declarationOf(bitmap) ?? {}, "type")).toBe(false); - expect(() => Component.register(bitmap, { transfer: true })).not.toThrow(); + expect(() => register(bitmap, { transfer: true })).not.toThrow(); }); - it("rejects a default beside a component's schema, which holds it", () => { - expect(() => register(Symbol("typesComponentTest/defaulted"), { type: { type: "number" }, default: 0 })).toThrow("its schema holds its default"); + it("takes a component's default beside its schema, never in it", () => { + expect(() => register(Symbol("typesComponentTest/defaulted"), { type: { type: "number" }, default: 0 })).not.toThrow(); + expect(() => register(Symbol("typesComponentTest/inSchema"), { type: { type: "number", default: 0 } })).toThrow("its schema holds a default, which a schema never does"); }); it("takes traits other packages register, in the namespace components share", () => { const early = Symbol("typesComponentTest/early"); expect(() => register(early, { type: { const: true }, [late]: true })).toThrow(`typesComponentTest/late isn't a registered trait`); - Trait.register(late); + addTrait(late); expect(() => register(early, { type: { const: true }, [late]: true })).not.toThrow(); expect(declarationOf(marked)).toEqual({ type: { const: true }, [mark]: true }); - expect(() => Trait.register(mark)).not.toThrow(); - expect(declarationOf(slot)).toEqual({ type: { type: "number", default: 0 }, derived: true }); + expect(() => addTrait(mark)).not.toThrow(); + expect(declarationOf(slot)).toEqual({ type: { type: "number" }, default: 0, derived: true }); }); }); diff --git a/packages/ecs/src/interface/component/register.ts b/packages/ecs/src/interface/component/register.ts deleted file mode 100644 index 99afedc9e..000000000 --- a/packages/ecs/src/interface/component/register.ts +++ /dev/null @@ -1,29 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { createRegistry, type AnyDeclaration } from "../registry/declarations.js"; -import type { Resource } from "../resource/resource.js"; -import type { Schema } from "../schema/schema.js"; -import type { Key as TraitKey } from "../trait/key.js"; -import type { Declaration } from "./declaration.js"; -import type { Key } from "./key.js"; - -const registry = createRegistry("component"); - -/** - * Registers the component `key`, a plain `Symbol(...)` whose traits `Components` declares, with exactly those traits: - * `type` as a schema that describes the declared type, left out only for a type that isn't data, which is stored as - * it is. - * ```ts - * declare module "@adobe/ecs" { interface Components { [position]: { type: Vec3 } } } - * Component.register(position, { type: Vec3.schema }); - * ``` - * The declaration is fixed from then on. The same declaration again is accepted, and so is a new symbol with the id - * of a component, as a module evaluated again makes it, which is then that key. A key also declared as a resource or - * a trait can't be registered: names are unique across the three. Throws for a key that isn't a plain symbol (a - * `Symbol.for` or well-known symbol isn't), one registered, or whose id is registered, as another kind or with another - * declaration, a trait that isn't registered, a trait's value that can't hold for the declaration, or a schema asking - * for a GPU layout it can't meet. - */ -export function register & symbol, const S extends Schema & { readonly default?: unknown } = never>(key: K, declaration: Declaration): void; -export function register(key: unknown, declaration: AnyDeclaration): void { - registry(key, declaration); -} diff --git a/packages/ecs/src/interface/component/types.ts b/packages/ecs/src/interface/component/types.ts index 0fdecb25e..803554442 100644 --- a/packages/ecs/src/interface/component/types.ts +++ b/packages/ecs/src/interface/component/types.ts @@ -1,7 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. /** - * Each component's traits, `type` included, by augmentation: `[position]: { type: Vec3 }`, - * `[slot]: { type: Slot; derived: true }`. Registration must give exactly these. + * Each component's traits, `type` and its `default` included, by augmentation in the module declaring its key: + * `[position]: { type: Vec3 }`, `[slot]: { type: Slot; derived: true; default: 0 }`. The build registers each. */ export interface Components { } diff --git a/packages/ecs/src/interface/entity/index.ts b/packages/ecs/src/interface/entity/index.ts index da4641d4b..acfdaed37 100644 --- a/packages/ecs/src/interface/entity/index.ts +++ b/packages/ecs/src/interface/entity/index.ts @@ -7,3 +7,6 @@ export const schema = { type: "integer", format: "entity" } as const satisfies S /** No entity: index 0 is never one, so zeroed memory and an entity-valued default mean none, and a lookup of it returns null. */ export const none = 0 as Entity; + +/** What an entity-typed value starts at: none. */ +export { none as default }; diff --git a/packages/ecs/src/interface/finalizer/body.ts b/packages/ecs/src/interface/finalizer/body.ts index 752acab74..53b4b5813 100644 --- a/packages/ecs/src/interface/finalizer/body.ts +++ b/packages/ecs/src/interface/finalizer/body.ts @@ -1,28 +1,28 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { Component } from '../component/component.js'; import type { Entity } from '../entity/entity.js'; -import type { Resource } from '../resource/resource.js'; import type { Commands } from '../system/commands.js'; import type { Listed, Property } from '../system/property.js'; -import type { LookupFunction, ResourceValues, Values } from '../system/values.js'; +import type { LookupFunction } from '../system/values.js'; +import type { WriteResources } from '../system/body.js'; import type { ChangesDeclaration, Declaration } from './declaration.js'; import type { Essential, ReadonlyValue } from '../component/value.js'; import type { Elsewhere, ThreadsOf } from '../system/threads.js'; -/** The entity that stopped matching, with its `read` values as they are before the sync point applies. */ +/** + * The entity that stopped matching, with the values of its `with` as they are before the sync point applies, and of + * its `optional` where it had them, `undefined` where it didn't. + */ export type Row = - { readonly entity: Entity } & { readonly [K in Listed, "read"> & Component.Key]: Component.Value }; - -type Resources = Listed, "read"> | Listed, "write">; + { readonly entity: Entity } & { readonly [K in Listed, "with"> & Component.Key]: Component.Value } + & { readonly [K in Exclude, "optional">, Listed, "with">> & Component.Key]: Component.Value | undefined }; /** - * Resources and lookups, each present only when declared, and the commands it issues for the next sync point: none on - * another thread than the world's, which runs after the sync point. + * Every resource, its lookups when declared, and the commands it issues for the next sync point: none on another + * thread than the world's, which runs after the sync point and writes no resources. */ export type Context = - ([Resources] extends [never] ? {} : { - readonly resources: Values, "read">, Listed, "write">, Resource.Key, ResourceValues>; - }) + { readonly resources: WriteResources } & ([keyof Property] extends [never] ? {} : { readonly lookup: { readonly [K in keyof Property]: LookupFunction[K]> }; }) diff --git a/packages/ecs/src/interface/finalizer/create.test.ts b/packages/ecs/src/interface/finalizer/create.test.ts index cba1ad24f..c03b1428e 100644 --- a/packages/ecs/src/interface/finalizer/create.test.ts +++ b/packages/ecs/src/interface/finalizer/create.test.ts @@ -1,9 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; -import { Component } from "../component/component.js"; -import { identify } from "../registry/ids.test-helper.js"; -import { labelOfItem, programFinalizers, programRules } from "../registry/index.js"; -import { Resource } from "../resource/resource.js"; +import { giveAccess, identify } from "../registry/ids.test-helper.js"; +import { programFinalizers, programRules } from "../registry/index.js"; import { Rule } from "../rule/rule.js"; import { clock } from "../resources/index.js"; import { Finalizer } from "./finalizer.js"; @@ -34,116 +32,112 @@ declare module "@adobe/ecs" { } } -Component.register(node, { thread: "main" }); -Component.register(label, { type: { type: "string" } }); -Component.register(tags, { type: { type: "array", items: { type: "string" } } }); -Component.register(slot, { type: { type: "number", default: 0 }, derived: true }); -Resource.register(total, { type: { type: "number", default: 0 }, reduce: "+" }); -Resource.register(time, { type: { type: "number", default: 0 } }); -Resource.register(page, { thread: "main" }); -Component.register(mark, { type: { const: true }, derived: true }); -Component.register(other, { type: { const: true }, derived: true }); -/** Declares as an untyped caller would, bypassing the types' checks. */ -const untyped = (constructor: (...parameters: never[]) => unknown, declaration: object): unknown => Reflect.apply(constructor, undefined, [declaration, () => {}]); +/** Declares as an untyped caller would, bypassing the types' checks, its body using what `uses` says, as the build would infer. */ +const untyped = (constructor: (...parameters: never[]) => unknown, declaration: object, uses: Parameters[1] = {}): unknown => + Reflect.apply(constructor, undefined, [giveAccess(declaration, uses), () => {}]); describe("Finalizer.create and Finalizer.changes", () => { it("record finalizers in creation order, keep the place of one declared again under its id with its new body, and reject its id with another declaration", () => { - const release = Finalizer.create(identify({ name: "typesFinalizerTest/release", components: { read: [node] } }, "f.ts#release"), () => {}); - const save = Finalizer.changes({ name: "typesFinalizerTest/save", watch: [label] }, () => {}); + const release = Finalizer.create(identify({ components: { with: [node] } }, "f.ts#release"), () => {}); + const save = Finalizer.changes({ watch: [label] }, () => {}); expect(programFinalizers().map(finalizer => finalizer.name)).toEqual([release.name, save.name]); const body = (): void => {}; - expect(Finalizer.create(identify({ name: "typesFinalizerTest/release", components: { read: [node] } }, "f.ts#release"), body)).toBe(release); + // A body the build doesn't see is given what it uses. + expect(Finalizer.create(giveAccess(identify({ components: { with: [node] } }, "f.ts#release")), body)).toBe(release); expect(programFinalizers()[0]?.kind === "finalizer" ? programFinalizers()[0]?.body : null).toBe(body); - expect(() => Finalizer.create(identify({ name: "typesFinalizerTest/release", components: { read: [label] } }, "f.ts#release"), () => {})).toThrow("reload the page"); - expect(() => Finalizer.changes(identify({ name: "typesFinalizerTest/release" }, "f.ts#release"), () => {})).toThrow("its id is a finalizer's"); + expect(() => Finalizer.create(identify({ components: { with: [label] } }, "f.ts#release"), () => {})).toThrow("reload the page"); + expect(() => Finalizer.changes(identify({}, "f.ts#release"), () => {})).toThrow("its id is a finalizer's"); expect(programFinalizers()).toHaveLength(2); }); - it("keep finalizers of one name apart, labeled apart", () => { - const first = Finalizer.changes({ name: "typesFinalizerTest/twin", watch: [label] }, () => {}); - const second = Finalizer.changes({ name: "typesFinalizerTest/twin", watch: [label] }, () => {}); + it("reject a body writing its row, which holds the values from before", () => { + expect(() => untyped(Finalizer.create, { components: { with: [label] } }, { write: [label] })).toThrow("which is read-only"); + }); + it("keep finalizers of one label apart", () => { + const twin = (): Finalizer => Finalizer.changes({ watch: [label] }, () => {}); + const [first, second] = [twin(), twin()]; expect(second).not.toBe(first); - expect([first, second].map(labelOfItem)).toEqual(["typesFinalizerTest/twin", "typesFinalizerTest/twin#2"]); - const third = Finalizer.changes({ name: "typesFinalizerTest/ordered", watch: [label], after: [second], before: [first] }, () => {}); + expect([first.name, second.name]).toEqual(["twin", "twin"]); + const third = Finalizer.changes({ watch: [label], after: [second], before: [first] }, () => {}); expect(programFinalizers().slice(-3)).toEqual([second, third, first]); }); it("order finalizers by before and after, and reject a cycle or a resource conflict with no order", () => { - const late = Finalizer.create({ name: "typesFinalizerTest/late", components: { read: [label] }, resources: { read: [time] } }, () => {}); - const early = Finalizer.create({ name: "typesFinalizerTest/early", components: { read: [label] }, resources: { write: [time] }, before: [late] }, () => {}); + const late = Finalizer.create({ components: { with: [label] } }, (_, { resources }) => { void resources[time]; }); + const early = Finalizer.create({ components: { with: [label] }, before: [late] }, (_, { resources }) => { resources[time] = 1; }); expect(programFinalizers().map(finalizer => finalizer.name).filter(name => name === early.name || name === late.name)).toEqual([early.name, late.name]); - expect(() => Finalizer.create({ name: "typesFinalizerTest/conflict", components: { read: [label] }, resources: { read: [time] } }, () => {})).toThrow('both access "typesFinalizerTest/time", one writing it'); - expect(() => Finalizer.create({ name: "typesFinalizerTest/cycle", components: { read: [label] }, before: [early], after: [late] }, () => {})).toThrow("ordered in a cycle"); - expect(() => untyped(Finalizer.create, { name: "typesFinalizerTest/clock", components: {}, resources: { write: [clock] } })).toThrow(`writes "clock", which only the world writes`); + expect(() => Finalizer.create({ components: { with: [label] } }, (_, { resources }) => { void resources[time]; })).toThrow('both access "typesFinalizerTest/time", one writing it'); + expect(() => Finalizer.create({ components: { with: [label] }, before: [early], after: [late] }, () => {})).toThrow("ordered in a cycle"); + expect(() => untyped(Finalizer.create, { components: {} }, { resources: { write: [clock] } })).toThrow(`writes "clock", which only the world writes`); expect(programFinalizers()).toHaveLength(7); }); it("reject what types reject, for untyped callers", () => { - expect(() => untyped(Finalizer.create, { name: "typesFinalizerTest/both", components: {}, resources: { read: [time], write: [time] } })).toThrow("in both resources read and write"); - expect(() => untyped(Finalizer.create, { name: "typesFinalizerTest/reduced", components: {}, resources: { write: [total] } })).toThrow("which is reduced"); - expect(() => untyped(Finalizer.create, { name: "typesFinalizerTest/span", components: { read: [node, tags] } })).toThrow('uses "typesFinalizerTest/node" on "main" and "typesFinalizerTest/tags" on "world"'); - expect(() => untyped(Finalizer.changes, { name: "typesFinalizerTest/derived", watch: [slot] })).toThrow("which is derived"); - expect(() => untyped(Finalizer.changes, { name: "typesFinalizerTest/resource", watch: [time] })).toThrow("which isn't a registered component"); - expect(() => untyped(Finalizer.create, { name: "typesFinalizerTest/hasResource", components: { has: [time] } })).toThrow(`Finalizer "typesFinalizerTest/hasResource" uses component "typesFinalizerTest/time", which isn't a registered component`); - expect(() => untyped(Finalizer.create, { name: "typesFinalizerTest/lookupNot", components: { read: [label] }, lookup: { others: { not: [Symbol("typesFinalizerTest/unregistered")] } } })).toThrow("which isn't a registered component"); + expect(() => untyped(Finalizer.create, { components: {} }, { resources: { write: [total] } })).toThrow("which is reduced"); + expect(() => untyped(Finalizer.create, { components: {} }, { resources: { reduce: [total] } })).toThrow("only systems contribute to a reduction"); + expect(() => untyped(Finalizer.create, { components: { with: [node, tags] } }, { read: [node, tags] })).toThrow('uses "typesFinalizerTest/node" on "main" and "typesFinalizerTest/tags" on "world"'); + expect(() => untyped(Finalizer.changes, { name: "derived", watch: [slot] })).toThrow("which is derived"); + expect(() => untyped(Finalizer.changes, { name: "resource", watch: [time] })).toThrow("which isn't a registered component"); + expect(() => untyped(Finalizer.create, { components: { with: [time] } })).toThrow(`uses component "typesFinalizerTest/time", which isn't a registered component`); + expect(() => untyped(Finalizer.create, { components: { with: [label] }, lookup: { others: { not: [Symbol("typesFinalizerTest/unregistered")] } } }, { read: [label] })).toThrow("which isn't a registered component"); expect(programFinalizers()).toHaveLength(7); }); it("reject resource writes and lookups in a finalizer on another thread than the world's, and a changes finalizer watching or reading another thread's values", () => { - expect(() => untyped(Finalizer.create, { name: "typesFinalizerTest/detachWriting", components: { read: [node] }, resources: { write: [time] } })) - .toThrow(`Finalizer "typesFinalizerTest/detachWriting" runs on thread "main" and writes resources; one on another thread than the world's runs after the sync point, so it writes none.`); - expect(() => untyped(Finalizer.create, { name: "typesFinalizerTest/pageWriting", components: { read: [tags] }, resources: { write: [page] } })) + expect(() => untyped(Finalizer.create, { components: { with: [node] } }, { read: [node], resources: { write: [time] } })) + .toThrow(`runs on thread "main" and writes resources; one on another thread than the world's runs after the sync point, so it writes none.`); + expect(() => untyped(Finalizer.create, { components: { with: [tags] } }, { read: [tags], resources: { write: [page] } })) .toThrow('uses "typesFinalizerTest/tags" on "world" and "typesFinalizerTest/page" on "main"'); - expect(() => untyped(Finalizer.create, { name: "typesFinalizerTest/detachLooking", components: { read: [node] }, lookup: { labels: { read: [mark] } } })) - .toThrow(`Finalizer "typesFinalizerTest/detachLooking" runs on thread "main" and looks up other entities; one on another thread than the world's runs after the sync point, so it looks up none.`); - expect(() => untyped(Finalizer.changes, { name: "typesFinalizerTest/saveNodes", watch: [node] })) - .toThrow(`Finalizer "typesFinalizerTest/saveNodes" watches or reads values on thread "main"; a changes finalizer runs on the world's thread, in the sync point.`); - expect(() => untyped(Finalizer.changes, { name: "typesFinalizerTest/savePage", watch: [tags], resources: { read: [page] } })) + expect(() => untyped(Finalizer.create, { components: { with: [node] }, lookup: { labels: { with: [mark] } } }, { read: [node], lookup: { labels: [mark] } })) + .toThrow(`runs on thread "main" and looks up other entities; one on another thread than the world's runs after the sync point, so it looks up none.`); + expect(() => untyped(Finalizer.changes, { name: "saveNodes", watch: [node] })) + .toThrow(`watches or reads values on thread "main"; a changes finalizer runs on the world's thread, in the sync point.`); + expect(() => untyped(Finalizer.changes, { watch: [tags] }, { resources: { read: [page] } })) .toThrow('uses "typesFinalizerTest/tags" on "world" and "typesFinalizerTest/page" on "main"'); - expect(() => untyped(Finalizer.changes, { name: "typesFinalizerTest/savePageOnly", resources: { read: [page] } })) - .toThrow(`Finalizer "typesFinalizerTest/savePageOnly" watches or reads values on thread "main"; a changes finalizer runs on the world's thread, in the sync point.`); + expect(() => untyped(Finalizer.changes, {}, { resources: { read: [page] } })) + .toThrow(`watches or reads values on thread "main"; a changes finalizer runs on the world's thread, in the sync point.`); // Reading another thread's values, without writing or looking up, it's created. - const detach = Finalizer.create({ name: "typesFinalizerTest/detach", components: { read: [node] }, resources: { read: [page] } }, () => {}); + const detach = Finalizer.create({ components: { with: [node] } }, row => { void row[node]; }); expect(programFinalizers()).toHaveLength(8); // Finalizers on different threads run at different times, so neither is ordered before the other. - const late = programFinalizers().find(finalizer => finalizer.name === "typesFinalizerTest/late"); - expect(() => untyped(Finalizer.create, { name: "typesFinalizerTest/afterDetach", components: { read: [label] }, after: [detach] })) - .toThrow(`Finalizer "typesFinalizerTest/afterDetach" runs on thread "world" and is ordered with "typesFinalizerTest/detach", which runs on thread "main"; finalizers on different threads run at different times, so neither is ordered before the other.`); - expect(() => untyped(Finalizer.create, { name: "typesFinalizerTest/beforeLate", components: { read: [node] }, before: [late] })) - .toThrow(`Finalizer "typesFinalizerTest/beforeLate" runs on thread "main" and is ordered with "typesFinalizerTest/late", which runs on thread "world"`); - expect(() => untyped(Finalizer.changes, { name: "typesFinalizerTest/saveAfterDetach", watch: [label], after: [detach] })) - .toThrow(`Finalizer "typesFinalizerTest/saveAfterDetach" runs on thread "world" and is ordered with "typesFinalizerTest/detach"`); - Finalizer.create({ name: "typesFinalizerTest/detachAgain", components: { read: [node] }, after: [detach] }, () => {}); + const late = programFinalizers().find(finalizer => finalizer.name === "late"); + expect(() => untyped(Finalizer.create, { components: { with: [label] }, after: [detach] }, { read: [label] })) + .toThrow(`runs on thread "world" and is ordered with "detach", which runs on thread "main"; finalizers on different threads run at different times, so neither is ordered before the other.`); + expect(() => untyped(Finalizer.create, { components: { with: [node] }, before: [late] }, { read: [node] })) + .toThrow(`runs on thread "main" and is ordered with "late", which runs on thread "world"`); + expect(() => untyped(Finalizer.changes, { name: "saveAfterDetach", watch: [label], after: [detach] })) + .toThrow(`runs on thread "world" and is ordered with "detach"`); + Finalizer.create({ components: { with: [node] }, after: [detach] }, row => { void row[node]; }); expect(programFinalizers()).toHaveLength(9); }); }); describe("Rule.create", () => { it("records a rule and returns it, the one already recorded when declared again under its id, and rejects its id with another declaration", () => { - const slots = Rule.create(identify({ name: "typesFinalizerTest/slots", components: { has: [label] }, derive: [slot] }, "r.ts#slots")); + const slots = Rule.create(identify({ components: { with: [label] }, derive: [slot] }, "r.ts#slots")); expect(programRules()).toEqual([slots]); - expect(Rule.create(identify({ name: "typesFinalizerTest/slots", components: { has: [label] }, derive: [slot] }, "r.ts#slots"))).toBe(slots); - expect(() => Rule.create(identify({ name: "typesFinalizerTest/slots", components: { has: [node] }, derive: [slot] }, "r.ts#slots"))).toThrow("reload the page"); - const twin = Rule.create({ name: "typesFinalizerTest/slots", components: { has: [node] }, derive: [slot] }); + expect(Rule.create(identify({ components: { with: [label] }, derive: [slot] }, "r.ts#slots"))).toBe(slots); + expect(() => Rule.create(identify({ components: { with: [node] }, derive: [slot] }, "r.ts#slots"))).toThrow("reload the page"); + const twin = Rule.create({ components: { with: [node] }, derive: [slot] }); expect(programRules()).toEqual([slots, twin]); - expect([slots, twin].map(labelOfItem)).toEqual(["typesFinalizerTest/slots (r.ts#slots)", "typesFinalizerTest/slots#2"]); + expect([slots, twin].map(rule => rule.name)).toEqual(["slots", "twin"]); }); it("rejects rules that derive in a cycle through a negation", () => { - Rule.create({ name: "typesFinalizerTest/marks", components: { not: [mark] }, derive: [other] }); - expect(() => Rule.create({ name: "typesFinalizerTest/others", components: { has: [other] }, derive: [mark] })).toThrow("cycle through a negation"); + Rule.create({ components: { not: [mark] }, derive: [other] }); + expect(() => Rule.create({ components: { with: [other] }, derive: [mark] })).toThrow("cycle through a negation"); expect(programRules()).toHaveLength(3); }); it("rejects a rule deriving a component that isn't derived, or matching on a resource", () => { const create = (declaration: object): unknown => Reflect.apply(Rule.create, undefined, [declaration]); - expect(() => create({ name: "typesFinalizerTest/essential", components: {}, derive: [label] })).toThrow("which isn't declared derived"); - expect(() => create({ name: "typesFinalizerTest/onResource", components: { has: [time] }, derive: [slot] })).toThrow("which isn't a registered component"); + expect(() => create({ components: {}, derive: [label] })).toThrow("which isn't declared derived"); + expect(() => create({ components: { with: [time] }, derive: [slot] })).toThrow("which isn't a registered component"); expect(programRules()).toHaveLength(3); }); }); -describe("rules and finalizers without a name", () => { - it("are named by their binding and id, or else by their kind numbered", () => { - const rule = Rule.create(identify({ components: { has: [node] }, derive: [slot] }, "rules.ts#slotted")); - const release = Finalizer.create(identify({ components: { read: [node] } }, "finalizers.ts#release"), () => {}); - expect([rule, release].map(labelOfItem)).toEqual(["slotted (rules.ts#slotted)", "release (finalizers.ts#release)"]); - const labels = [Rule.create({ components: { has: [label] }, derive: [slot] }), Finalizer.changes({ watch: [label] }, () => {})].map(labelOfItem); - expect(labels.map(found => found.replace(/#\d+$/, "#n"))).toEqual(["rule #n", "finalizer #n"]); +describe("rules' and finalizers' labels", () => { + it("are the bindings of their ids, or else their kinds numbered", () => { + const [rule, release] = [Rule.create(identify({ components: { with: [node] }, derive: [slot] }, "rules.ts#slotted")), Finalizer.create(identify({ components: { with: [node] } }, "finalizers.ts#release"), () => {})]; + expect([rule?.name, release?.name]).toEqual(["slotted", "release"]); + // Made in an expression no binding names, so the build labels them by nothing. + const [rule2, finalizer2] = [Rule.create({ components: { with: [label] }, derive: [slot] }), Finalizer.changes({ watch: [label] }, () => {})]; + expect([rule2, finalizer2].map(item => item.name.replace(/#\d+$/, "#n"))).toEqual(["rule #n", "finalizer #n"]); }); }); diff --git a/packages/ecs/src/interface/finalizer/create.ts b/packages/ecs/src/interface/finalizer/create.ts index 170a79c6d..02e486d71 100644 --- a/packages/ecs/src/interface/finalizer/create.ts +++ b/packages/ecs/src/interface/finalizer/create.ts @@ -1,33 +1,28 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { declarationOf, kindOf, labelOf } from '../registry/names.js'; +import { declarationOf, kindOf, labelOf, whyUnregistered } from '../registry/names.js'; import { takeIdOfDeclaration } from '../registry/ids.js'; -import { firstItemOf, labelOfItem, nameOf } from '../registry/items.js'; +import { firstItemOf, nameOf } from '../registry/items.js'; import { addFinalizer, programFinalizers } from '../registry/program.js'; import { requireRegistered, threadOf } from '../registry/threads.js'; import { checkWorldWritten } from '../registry/written.js'; import { checkLookup } from '../registry/indexes.js'; -import type { Resource } from '../resource/resource.js'; import type { Listed, Property } from '../system/property.js'; import type { ThreadOfComponent } from '../component/value.js'; import type { Component } from '../component/component.js'; import type { Elsewhere, OneThread, ThreadedBy, ThreadsOf } from '../system/threads.js'; import type { ChangeSet, Context, Row, Watched } from './body.js'; import type { Changes, Leaf } from './created.js'; -import type { ChangesDeclaration, Declaration } from './declaration.js'; +import type { ChangesDeclaration, Declaration, Resolved, ResolvedChanges } from './declaration.js'; +import { accessOf, compact, lookupsLists, resourceUses, type Access } from '../system/resolve.js'; import type { Finalizer } from './finalizer.js'; import type { Finalizer as Registered } from './created.js'; -/** A resource in `write` is readable too, so it isn't also in `read`. */ -type Validate = - { readonly resources?: { readonly read?: readonly Exclude, "write">>[] } }; - /** - * A finalizer on another thread than the world's runs there after the sync point, which doesn't wait for it: it writes - * no resources and looks up nothing. + * A finalizer on another thread than the world's runs there after the sync point, which doesn't wait for it: it looks + * up nothing, and, which registration checks, writes no resources. */ type Remote = true extends Elsewhere> - ? ([Listed, "write">] extends [never] ? {} : { readonly "error: a finalizer on another thread than the world's writes no resources": true }) - & ([keyof Property] extends [never] ? {} : { readonly "error: a finalizer on another thread than the world's looks up nothing": true }) + ? ([keyof Property] extends [never] ? {} : { readonly "error: a finalizer on another thread than the world's looks up nothing": true }) : {}; /** A changes finalizer runs on the world's thread, in the sync point: it watches and reads no other thread's objects. */ @@ -37,6 +32,32 @@ type OnWorld = true extends Elsewhere | ThreadOfComponent { + const uses = resourceUses(what, access, undefined); + if (uses.reduce.length > 0) throw new Error(`${what} reduces into resources; only systems contribute to a reduction.`); + const lookup = lookupsLists(declared.lookup, access); + return { + ...lookup === undefined ? {} : { lookup }, + resources: compact({ read: uses.read, write: uses.write }), + ...declared.before === undefined ? {} : { before: declared.before }, + ...declared.after === undefined ? {} : { after: declared.after }, + }; +}; + +/** A finalizer's row lists: what its body reads, the rest of `with` only required. Throws for a body writing its row, which holds values from before. */ +const componentReads = (what: string, components: Declaration["components"], access: Access): Resolved["components"] => { + const written = access.components?.write[0]; + if (written !== undefined) throw new Error(`${what} writes "${labelOf(written)}" of its row, which is read-only: it holds the values from before the entity stopped matching.`); + // Optional components are read where the entity had them, and never decide a stop. + const optional = (components.optional ?? []).filter(key => !(components.with ?? []).includes(key)); + const read = access.components?.read ?? [...components.with ?? [], ...optional]; + return compact({ read, has: (components.with ?? []).filter(key => !read.includes(key)), optional, not: components.not }); +}; + /** Throws if a resource is in both `read` and `write`, or a reduced one is written. */ const checkResources = (name: string, resources: { readonly read?: readonly Key[]; readonly write?: readonly Key[] } | undefined): void => { const writes = new Set(resources?.write); @@ -59,7 +80,7 @@ const checkOrder = (name: string, thread: string, declaration: { readonly before if (found === undefined) continue; const theirs = runsOn(found); if (theirs !== thread) { - throw new Error(`Finalizer "${name}" runs on thread "${thread}" and is ordered with "${labelOfItem(found)}", which runs on thread "${theirs}"; finalizers on different threads run at different times, so neither is ordered before the other.`); + throw new Error(`Finalizer "${name}" runs on thread "${thread}" and is ordered with "${found.name}", which runs on thread "${theirs}"; finalizers on different threads run at different times, so neither is ordered before the other.`); } } }; @@ -73,12 +94,18 @@ const checkOrder = (name: string, thread: string, declaration: { readonly before * or with a resource conflict. */ export const create = ( - declaration: D & Validate & OneThread, ThreadedBy> & Remote, + declared: D & Inferred & { readonly components: { readonly read?: never; readonly has?: never } } & OneThread, ThreadedBy> & Remote, body: (row: Row, context: Context) => void, ): Finalizer => { - const id = takeIdOfDeclaration(declaration); - const name = nameOf(declaration.name, id, "finalizer"); - const finalizer: Leaf = { kind: "finalizer", name, declaration, body }; + const id = takeIdOfDeclaration(declared); + const name = nameOf(declared, id, "finalizer"); + const what = `Finalizer "${name}"`; + const access = accessOf(what, declared); + const declaration: Resolved = { + ...resolveShared(what, declared, access), + components: componentReads(what, declared.components, access), + }; + const finalizer: Leaf = { kind: "finalizer", name, declaration, body }; checkResources(name, declaration.resources); const thread = threadOf(declaration, "Finalizer", name); if (thread !== null && thread !== "world") { @@ -104,12 +131,14 @@ export const create = ( * one watching or reading another thread's values. */ export const changes = ( - declaration: D & Validate & OnWorld, + declared: D & Inferred & OnWorld, body: (changes: ChangeSet>, context: Context) => void, ): Finalizer => { - const id = takeIdOfDeclaration(declaration); - const name = nameOf(declaration.name, id, "finalizer"); - const finalizer: Changes = { kind: "changes", name, declaration, body }; + const id = takeIdOfDeclaration(declared); + const name = nameOf(declared, id, "finalizer"); + const what = `Finalizer "${name}"`; + const declaration: ResolvedChanges = { ...resolveShared(what, declared, accessOf(what, declared)), ...declared.watch === undefined ? {} : { watch: declared.watch } }; + const finalizer: Changes = { kind: "changes", name, declaration, body }; checkResources(name, declaration.resources); requireRegistered("Finalizer", name, "resource", declaration.resources?.read); requireRegistered("Finalizer", name, "resource", declaration.resources?.write); @@ -119,7 +148,7 @@ export const changes = ( for (const keys of [lookup.read, "has" in lookup ? lookup.has : undefined, "not" in lookup ? lookup.not : undefined]) requireRegistered("Finalizer", name, "component", keys); } for (const key of declaration.watch ?? []) { - if (kindOf(key) !== "component") throw new Error(`Finalizer "${name}" watches "${labelOf(key)}", which isn't a registered component.`); + if (kindOf(key) !== "component") throw new Error(`Finalizer "${name}" watches "${labelOf(key)}", which isn't a registered component: ${whyUnregistered(key)}.`); if (declarationOf(key)?.derived === true) throw new Error(`Finalizer "${name}" watches "${labelOf(key)}", which is derived; it sees only components user code adds and removes.`); } const thread = threadOf({ components: { read: declaration.watch }, resources: declaration.resources, lookup: declaration.lookup }, "Finalizer", name); diff --git a/packages/ecs/src/interface/finalizer/created.ts b/packages/ecs/src/interface/finalizer/created.ts index 4e3f68f2c..a9f87503e 100644 --- a/packages/ecs/src/interface/finalizer/created.ts +++ b/packages/ecs/src/interface/finalizer/created.ts @@ -1,21 +1,21 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import type { ChangesDeclaration, Declaration } from './declaration.js'; +import type { Resolved, ResolvedChanges } from './declaration.js'; // Bodies' parameters are checked where they're created, so a finalizer holds any body. /** Runs, inside a sync point, for each entity that stops matching its components. */ -export type Leaf = { +export type Leaf = { readonly kind: "finalizer"; readonly name: string; - readonly declaration: D; + readonly declaration: Resolved; body: (...parameters: never[]) => void; }; /** Runs, inside a sync point, with all its structural changes to the components it watches. */ -export type Changes = { +export type Changes = { readonly kind: "changes"; readonly name: string; - readonly declaration: D; + readonly declaration: ResolvedChanges; body: (...parameters: never[]) => void; }; diff --git a/packages/ecs/src/interface/finalizer/declaration.ts b/packages/ecs/src/interface/finalizer/declaration.ts index ded9fea99..54ebdd064 100644 --- a/packages/ecs/src/interface/finalizer/declaration.ts +++ b/packages/ecs/src/interface/finalizer/declaration.ts @@ -1,26 +1,27 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { Component } from '../component/component.js'; -import type { Resource } from '../resource/resource.js'; +import type { SystemLookupLists, ResourceLists } from '../system/access.js'; import type { Lookup } from '../system/access.js'; import type { Finalizer } from './finalizer.js'; -import type { Name } from './name.js'; import type { Essential } from '../component/value.js'; -import type { Reduced } from '../resource/reduction.js'; -import type { WorldWritten } from '../resource/written.js'; +/** + * What every finalizer declares. What its body reads of its row and lookups, and reads and writes of resources, the + * build infers; resource writes take the sync point's tick, so each system sees them once. + */ type Shared = { - readonly name?: Name; /** Read-only access to other entities, as they are before the sync point applies. */ readonly lookup?: { readonly [name: string]: Lookup }; - /** Writes take the sync point's tick, so each system sees them once. A resource in `write` is also readable. */ - readonly resources?: { readonly read?: readonly Resource.Key[]; readonly write?: readonly Exclude[] }; readonly before?: readonly Finalizer[]; readonly after?: readonly Finalizer[]; }; -/** A finalizer that runs for each entity that stops matching its components. */ +/** + * A finalizer that runs for each entity that stops matching its components: those with every one in `with` and none in + * `not`. Its `optional` components, which never decide a stop, its row reads where the entity had them. + */ export type Declaration = Shared & { - readonly components: { readonly read?: readonly Component.Key[]; readonly has?: readonly Component.Key[]; readonly not?: readonly Component.Key[] }; + readonly components: { readonly with?: readonly Component.Key[]; readonly optional?: readonly Component.Key[]; readonly not?: readonly Component.Key[] }; }; /** @@ -28,3 +29,20 @@ export type Declaration = Shared & { * isn't derived or an object a joined thread fills. */ export type ChangesDeclaration = Shared & { readonly watch?: readonly Essential[] }; + +type Key = symbol; + +/** What every finalizer's declaration holds as the world takes it, with what the build inferred its body uses. */ +type ResolvedShared = { + readonly lookup?: { readonly [name: string]: SystemLookupLists }; + readonly resources?: Pick; + readonly before?: readonly Finalizer[]; + readonly after?: readonly Finalizer[]; +}; + +/** A finalizer's declaration as the world takes it: what its body reads of its row in `read`, the rest of `with` in `has`. */ +export type Resolved = ResolvedShared & { + readonly components: { readonly read?: readonly Key[]; readonly has?: readonly Key[]; readonly optional?: readonly Key[]; readonly not?: readonly Key[] }; +}; + +export type ResolvedChanges = ResolvedShared & { readonly watch?: readonly Key[] }; diff --git a/packages/ecs/src/interface/finalizer/finalizer.ts b/packages/ecs/src/interface/finalizer/finalizer.ts index 642ca3559..da8250bf2 100644 --- a/packages/ecs/src/interface/finalizer/finalizer.ts +++ b/packages/ecs/src/interface/finalizer/finalizer.ts @@ -1,7 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. /** - * A finalizer, the value `Finalizer.create` or `Finalizer.changes` returned: identified by that value, labeled by its - * name, and listed in other finalizers' `before` and `after`. + * A finalizer, the value `Finalizer.create` or `Finalizer.changes` returned: identified by that value, labeled by + * `name`, the binding it's declared at, and listed in other finalizers' `before` and `after`. */ export type Finalizer = { readonly name: string }; diff --git a/packages/ecs/src/interface/finalizer/name.ts b/packages/ecs/src/interface/finalizer/name.ts deleted file mode 100644 index ef87e3b53..000000000 --- a/packages/ecs/src/interface/finalizer/name.ts +++ /dev/null @@ -1,7 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -/** - * The label reports and errors give it, which needn't be unique: the value its constructor returns is its identity, so - * separately written code can't collide by name. Namespacing it (`"physics/integrate"`) keeps labels short. Without - * one, it's labeled by its binding and the id the plugin gave it, or else by its kind numbered. - */ -export type Name = string; diff --git a/packages/ecs/src/interface/gpu/accessors.ts b/packages/ecs/src/interface/gpu/accessors.ts index d628fad30..9d40716d3 100644 --- a/packages/ecs/src/interface/gpu/accessors.ts +++ b/packages/ecs/src/interface/gpu/accessors.ts @@ -28,14 +28,15 @@ const reserved = new Set(("alias break case const const_assert continue continui /** * Accessors for a render system's shader, in `accessors` order, or the reason one can't be read on the GPU: scalars - * and tuples of two to four scalars of one type, laid out in 4-byte words. + * and tuples of two to four scalars of one type, laid out in 4-byte words. An `ordered` system's accessors take a + * row's position in its order, which `ecs_address` resolves to a table's column. */ -export const accessorsOf = (shader: Shader): readonly Accessor[] | string => { +export const accessorsOf = (shader: Shader, ordered = false): readonly Accessor[] | string => { const accessors: Accessor[] = []; const entries = Object.entries(shader.accessors); // A table record is a row count, then a word address per accessor. if (entries.length > 63) return "it names more than 63 accessors"; - for (const [name, key] of entries) { + for (const [column, [name, key]] of entries.entries()) { const component = labelOf(key); if (!/^[A-Za-z][A-Za-z0-9_]*$/.test(name) || name.startsWith("ecs_") || reserved.has(name)) return `"${name}" isn't usable as a WGSL function name`; const declaration = declarationOf(key); @@ -48,26 +49,41 @@ export const accessorsOf = (shader: Shader): readonly Accessor[] | string => { return `"${component}" isn't laid out in 4-byte words of f32, i32, u32 or booleans; give it a "storage" or "vertex" layout or 4-byte scalars`; } const words = stride / 4; - const word = (leaf: Leaf): string => `ecs_table.ecs_${name} + row * ${words}u + ${leaf.offset / 4}u`; + const word = ordered + ? (leaf: Leaf): string => `ecs_base + ${leaf.offset / 4}u` + : (leaf: Leaf): string => `ecs_table.ecs_${name} + row * ${words}u + ${leaf.offset / 4}u`; + const base = ordered ? `let ecs_base = ecs_address(row, ${column}u, ${words}u); ` : ""; const [first] = leaves; if (leaves.length === 1 && first.path.length === 0) { - accessors.push({ name, component: key, code: `fn ${name}(row: u32) -> ${types[0]} { return ${readOf(first, word(first))}; }` }); + accessors.push({ name, component: key, code: `fn ${name}(row: u32) -> ${types[0]} { ${base}return ${readOf(first, word(first))}; }` }); continue; } const tuple = leaves.length >= 2 && leaves.length <= 4 && leaves.every((leaf, i) => leaf.path.length === 1 && leaf.path[0] === i && types[i] === types[0]); if (!tuple) return `"${component}" isn't a scalar or a tuple of two to four scalars of one type`; const items = leaves.map(leaf => readOf(leaf, word(leaf))).join(", "); - accessors.push({ name, component: key, code: `fn ${name}(row: u32) -> vec${leaves.length}<${types[0]}> { return vec${leaves.length}<${types[0]}>(${items}); }` }); + accessors.push({ name, component: key, code: `fn ${name}(row: u32) -> vec${leaves.length}<${types[0]}> { ${base}return vec${leaves.length}<${types[0]}>(${items}); }` }); } return accessors; }; /** The WGSL declarations of a render system's bind group and accessors. */ -export const declarationsOf = (group: number, accessors: readonly Accessor[]): string => [ +export const declarationsOf = (group: number, accessors: readonly Accessor[], ordered = false): string => [ `@group(${group}) @binding(0) var ecs_heap: array;`, - `struct EcsTable { ecs_rows: u32, ${accessors.map(accessor => `ecs_${accessor.name}: u32`).join(", ")} }`, + ordered + ? "struct EcsTable { ecs_rows: u32, ecs_order: u32, ecs_tables: u32, ecs_shift: u32 }" + : `struct EcsTable { ecs_rows: u32, ${accessors.map(accessor => `ecs_${accessor.name}: u32`).join(", ")} }`, `@group(${group}) @binding(1) var ecs_table: EcsTable;`, `fn ecs_count() -> u32 { return ecs_table.ecs_rows; }`, + ...ordered ? [orderedAddress(accessors.length)] : [], ...accessors.map(accessor => accessor.code), "", ].join("\n"); + +/** + * The word address of a column's value at a position of an ordered system's order. The order block holds, by position, + * a table's slot above `ecs_shift` bits and its row below them; the tables block holds, by slot, a word address per + * accessor. + */ +const orderedAddress = (columns: number): string => + "fn ecs_address(row: u32, column: u32, words: u32) -> u32 { let ecs_entry = ecs_heap[ecs_table.ecs_order + row]; " + + `return ecs_heap[ecs_table.ecs_tables + (ecs_entry >> ecs_table.ecs_shift) * ${columns}u + column] + (ecs_entry & ((1u << ecs_table.ecs_shift) - 1u)) * words; }`; diff --git a/packages/ecs/src/interface/gpu/gpu.type-test.ts b/packages/ecs/src/interface/gpu/gpu.type-test.ts index 090e07113..088dcaa22 100644 --- a/packages/ecs/src/interface/gpu/gpu.type-test.ts +++ b/packages/ecs/src/interface/gpu/gpu.type-test.ts @@ -12,7 +12,6 @@ declare module '@adobe/ecs' { [tint]: { type: number }; } } -Component.register(tint, { type: { type: "number" } }); declare const wgsl: typeof Gpu.wgsl; declare const layout: typeof Gpu.layout; @@ -22,7 +21,7 @@ export type Wgsl = Gpu.Wgsl; export type Layout = Gpu.Layout; check) => string>>(); declare const device: GPUDevice; -const draw = System.render({ name: "gpuTypeTest/draw", shader: { accessors: { tint } } }, () => {}); +const draw = System.render({ shader: { accessors: { tint } } }, () => {}); const code: string = wgsl(draw); check GPUBindGroupLayout>>(); export const used = [code, layout(device)]; diff --git a/packages/ecs/src/interface/gpu/wgsl.ts b/packages/ecs/src/interface/gpu/wgsl.ts index b9f7a39c1..3b13c9397 100644 --- a/packages/ecs/src/interface/gpu/wgsl.ts +++ b/packages/ecs/src/interface/gpu/wgsl.ts @@ -1,21 +1,23 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { definitionOf } from '../registry/index.js'; -import type { Name } from '../system/name.js'; import type { System } from '../system/system.js'; import { accessorsOf, declarationsOf } from './accessors.js'; /** * The WGSL a render system's shaders include: its bind group at `shader.group` (the heap and its table record), and a * function per accessor returning that component of a row, such as `fn position(row: u32) -> vec3`, plus - * `ecs_count()`. Depends only on registered schemas, so pipelines can be built before any world exists. + * `ecs_count()`. For a system with `order`, `row` is an instance's position in its order and `ecs_count()` the number + * of instances. Depends only on registered schemas, so pipelines can be built before any world exists. * Throws if the system isn't a render system or a component can't be read on the GPU. */ export const wgsl = (system: System): string => { const definition = definitionOf(system); - const shader = definition?.kind === "render" ? definition.declaration.shader : undefined; - if (shader === undefined) throw new Error(`System "${system.name}" isn't a render system.`); + const declaration = definition?.kind === "render" ? definition.declaration : undefined; + const shader = declaration?.shader; + if (declaration === undefined || shader === undefined) throw new Error(`System "${system.name}" isn't a render system.`); const { name } = system; - const accessors = accessorsOf(shader); + const ordered = declaration.order !== undefined; + const accessors = accessorsOf(shader, ordered); if (typeof accessors === "string") throw new Error(`Render system "${name}": ${accessors}.`); - return declarationsOf(shader.group ?? 0, accessors); + return declarationsOf(shader.group ?? 0, accessors, ordered); }; diff --git a/packages/ecs/src/interface/index.ts b/packages/ecs/src/interface/index.ts index 1afca4f3d..a60a0fbcf 100644 --- a/packages/ecs/src/interface/index.ts +++ b/packages/ecs/src/interface/index.ts @@ -1,5 +1,4 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -export { Archetype } from './archetype/archetype.js'; export { Component } from './component/component.js'; export { Entity } from './entity/entity.js'; export { Finalizer } from './finalizer/finalizer.js'; @@ -12,7 +11,6 @@ export { Resource } from './resource/resource.js'; export { Rule } from './rule/rule.js'; export type { Schema } from './schema/schema.js'; export { System } from './system/system.js'; -export { Trait } from './trait/trait.js'; export type { World } from './world/world.js'; export type { Components } from './component/types.js'; export type { Resources } from './resource/types.js'; diff --git a/packages/ecs/src/interface/index.type-test.ts b/packages/ecs/src/interface/index.type-test.ts index fc0fbaee5..63e2159a5 100644 --- a/packages/ecs/src/interface/index.type-test.ts +++ b/packages/ecs/src/interface/index.type-test.ts @@ -18,21 +18,17 @@ declare module "@adobe/ecs" { [scale]: { type: number }; } } -Component.register(position, { type: { type: "number" } }); -Component.register(slot, { type: { type: "number", default: 0 }, derived: true }); -Component.register(element, { thread: "main" }); -Resource.register(scale, { type: { type: "number", default: 1 } }); -export const move = System.create({ name: "indexTypeTest/move", components: { write: [position] }, resources: { read: [scale] } }, (row, { resources }) => { row[position] += resources[scale]; }); -export const show = System.create({ name: "indexTypeTest/show", components: { read: [position], write: [element] } }, row => { row[element].textContent = String(row[position]); }); -export const all = System.create({ name: "indexTypeTest/all", systems: [move, show] }); -export const draw = System.render({ name: "indexTypeTest/draw", shader: { accessors: { place: position } } }, () => {}); -export const release = Finalizer.create({ name: "indexTypeTest/release", components: { read: [element] } }, row => { row[element].remove(); }); -Rule.create({ name: "indexTypeTest/slots", components: { has: [position] }, derive: [slot] }); +export const move = System.create({ components: { with: [position] } }, (row, { resources }) => { row[position] += resources[scale]; }); +export const show = System.create({ components: { with: [position, element] } }, row => { row[element].textContent = String(row[position]); }); +export const all = System.create({ systems: [move, show] }); +export const draw = System.render({ shader: { accessors: { place: position } } }, () => {}); +export const release = Finalizer.create({ components: { with: [element] } }, row => { row[element].remove(); }); +Rule.create({ components: { with: [position] }, derive: [slot] }); declare const create: Create; declare const wgsl: typeof Gpu.wgsl; export const world = create({ [workers]: 4, [gpuDevice]: null }); -export const read = System.create({ name: "indexTypeTest/read", observer: true, components: { read: [position, slot] }, resources: { read: [gpuDevice, workers] } }, () => {}); -export const readOne = System.create({ observer: true, lookup: { at: { read: [position], has: [slot] } } }, () => {}); +export const read = System.create({ observer: true, components: { with: [position, slot] } }, () => {}); +export const readOne = System.create({ observer: true, lookup: { at: { with: [position, slot] } } }, () => {}); export const report = world.run(all); export const shader = wgsl(draw); export const added = world.add(read); @@ -43,8 +39,8 @@ export type NoThread = Ecs.Thread; // @ts-expect-error the cadence's value is Resource.Value export type NoCadence = Ecs.Cadence; // @ts-expect-error a rule is the value Rule.create returns: ReturnType -export const noRule: Rule = Rule.create({ components: { has: [position] }, derive: [slot] }); -// @ts-expect-error an archetype is a readonly list of components +export const noRule: Rule = Rule.create({ components: { with: [position] }, derive: [slot] }); +// @ts-expect-error an archetype is a list of components declared as const export const noArchetype: Ecs.Archetype = [position]; // @ts-expect-error a trait's key is keyof Traits export type NoTraitKey = Ecs.Trait.Key; diff --git a/packages/ecs/src/interface/indexes/create.test.ts b/packages/ecs/src/interface/indexes/create.test.ts index c72b27c0c..b80264d87 100644 --- a/packages/ecs/src/interface/indexes/create.test.ts +++ b/packages/ecs/src/interface/indexes/create.test.ts @@ -1,14 +1,16 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; -import { Component } from "../component/component.js"; +import { createResolved } from "../system/create-resolved.js"; +import { giveAccess } from "../registry/ids.test-helper.js"; import { Entity } from "../entity/entity.js"; import { Finalizer } from "../finalizer/finalizer.js"; import { identify } from "../registry/ids.test-helper.js"; -import { isLeaf, labelOfItem, requireDefinition } from "../registry/index.js"; +import { isLeaf, requireDefinition } from "../registry/index.js"; import { accessOf } from "../registry/schedule.js"; import { threadOf } from "../registry/threads.js"; import { System } from "../system/system.js"; import { Index } from "./index-type.js"; +import type { Vec2 } from "../math/index.js"; const cell = Symbol("indexCreateTest/cell"); const point = Symbol("indexCreateTest/point"); @@ -24,7 +26,7 @@ const view = Symbol("indexCreateTest/view"); declare module "@adobe/ecs" { export interface Components { - [cell]: { type: readonly [number, number] }; + [cell]: { type: Vec2.I32 }; [point]: { type: { readonly x: number; readonly y: number } }; [amount]: { type: number }; [name]: { type: string }; @@ -36,38 +38,28 @@ declare module "@adobe/ecs" { [view]: { type: { readonly label: string }; thread: "main" }; } } -Component.register(cell, { type: { type: "array", items: { type: "integer", minimum: -1000, maximum: 1000 }, minItems: 2, maxItems: 2 } }); -Component.register(point, { type: { type: "object", properties: { x: { type: "number" }, y: { type: "number" } }, required: ["x", "y"] } }); -Component.register(amount, { type: { type: "number" } }); -Component.register(name, { type: { type: "string" } }); -Component.register(node, { type: { type: "object", properties: { label: { type: "string" } } } }); -Component.register(tags, { type: { type: "set", items: { type: "string" } } }); -Component.register(shown, { type: { const: true } }); -Component.register(parent, { type: Entity.schema, reference: "owner" }); -Component.register(handle, {}); -Component.register(view, { type: { type: "object", properties: { label: { type: "string" } } }, thread: "main" }); /** Declares as an untyped caller would, bypassing the types' checks. */ const untyped = (declaration: object): unknown => Reflect.apply(Index.create, undefined, [declaration]); describe("Index.create", () => { it("returns an index labeled by its name, its binding and id, or its kind numbered", () => { - const named = Index.create({ name: "indexCreateTest/named", key: [amount] }); - expect(named.name).toBe("indexCreateTest/named"); + const named = Index.create({ key: [amount] }); + expect(named.name).toBe("named"); const bound = Index.create(identify({ key: [amount], sorted: true }, "i.ts#bound")); - expect(bound.name).toBe("bound (i.ts#bound)"); + expect(bound.name).toBe("bound"); expect(Index.create({ key: [amount] }).name).toMatch(/^index #\d+$/); }); it("is the index already recorded when declared again under its id the same, and throws for another declaration", () => { - const first = Index.create(identify({ name: "indexCreateTest/again", key: [cell], components: { has: [shown] } }, "i.ts#again")); - expect(Index.create(identify({ name: "indexCreateTest/again", key: [cell], components: { has: [shown] } }, "i.ts#again"))).toBe(first); - expect(() => Index.create(identify({ name: "indexCreateTest/again", key: [amount] }, "i.ts#again"))).toThrow("reload the page"); + const first = Index.create(identify({ key: [cell], components: { with: [shown] } }, "i.ts#again")); + expect(Index.create(identify({ key: [cell], components: { with: [shown] } }, "i.ts#again"))).toBe(first); + expect(() => Index.create(identify({ key: [amount] }, "i.ts#again"))).toThrow("reload the page"); // Another value is another index. - expect(Index.create({ name: "indexCreateTest/again", key: [cell], components: { has: [shown] } })).not.toBe(first); + expect(Index.create({ key: [cell], components: { with: [shown] } })).not.toBe(first); }); it("throws for key parts that aren't components of data, are listed twice, or are sets", () => { - expect(() => untyped({ name: "bad" })).toThrow(`Index "bad" needs a key`); - expect(() => untyped({ name: "bad", key: amount })).toThrow(`Index "bad" needs a key`); + expect(() => untyped({ name: "bad" })).toThrow(`needs a key`); + expect(() => untyped({ name: "bad", key: amount })).toThrow(`needs a key`); expect(() => untyped({ name: "bad", key: [unregistered] })).toThrow(`key part "indexCreateTest/unregistered", which isn't a registered component of data`); expect(() => untyped({ name: "bad", key: [handle] })).toThrow("isn't a registered component of data"); expect(() => untyped({ name: "bad", key: [amount, amount] })).toThrow(`lists key part "indexCreateTest/amount" twice`); @@ -75,10 +67,10 @@ describe("Index.create", () => { }); it("takes an empty key, holding the entities with its has and not, and isn't unique then", () => { expect(() => Index.create({ key: [] })).not.toThrow(); - expect(() => Index.create({ key: [], sorted: true, components: { has: [amount], not: [shown] } })).not.toThrow(); - expect(() => untyped({ name: "bad", key: [], unique: true })).toThrow(`Index "bad" is unique without a key`); - expect(() => untyped({ name: "bad", key: [], components: { has: [shown], not: [shown] } })).toThrow("which it holds by"); - expect(() => untyped({ name: "bad", key: [], components: { has: [unregistered] } })).toThrow("isn't a registered component"); + expect(() => Index.create({ key: [], sorted: true, components: { with: [amount], not: [shown] } })).not.toThrow(); + expect(() => untyped({ name: "bad", key: [], unique: true })).toThrow(`is unique without a key`); + expect(() => untyped({ name: "bad", key: [], components: { with: [shown], not: [shown] } })).toThrow("which it holds by"); + expect(() => untyped({ name: "bad", key: [], components: { with: [unregistered] } })).toThrow("isn't a registered component"); }); it("orders sorted parts that have an order: numbers, booleans, entities, strings and tuples of them", () => { expect(() => Index.create({ key: [cell, amount, name, parent], sorted: true })).not.toThrow(); @@ -90,21 +82,22 @@ describe("Index.create", () => { it("throws for object parts on two threads and for has and not naming what they can't", () => { expect(() => untyped({ name: "bad", key: [node, view] })).toThrow(`key parts on threads "world" and "main"`); expect(() => untyped({ name: "bad", key: [name, view] })).toThrow(`key parts on threads "world" and "main"; its object parts live on one thread, the world's beside a string`); - expect(() => untyped({ name: "bad", key: [amount], components: { has: [unregistered] } })).toThrow("isn't a registered component"); + expect(() => untyped({ name: "bad", key: [amount], components: { with: [unregistered] } })).toThrow("isn't a registered component"); expect(() => untyped({ name: "bad", key: [amount], components: { not: [unregistered] } })).toThrow("isn't a registered component"); expect(() => untyped({ name: "bad", key: [amount], components: { not: [amount] } })).toThrow(`excludes "indexCreateTest/amount", which it holds by`); - expect(() => untyped({ name: "bad", key: [amount], components: { has: [shown], not: [shown] } })).toThrow("which it holds by"); + expect(() => untyped({ name: "bad", key: [amount], components: { with: [shown], not: [shown] } })).toThrow("which it holds by"); }); }); describe("systems and finalizers looking up through an index", () => { - const grid = Index.create({ name: "indexCreateTest/grid", key: [cell], components: { has: [amount] } }); - const untypedSystem = (declaration: object): unknown => Reflect.apply(System.create, undefined, [declaration, () => {}]); + const grid = Index.create({ key: [cell], components: { with: [amount] } }); + /** A system of a declaration as the world takes it, what its body reads and writes listed, bypassing the types' checks. */ + const untypedSystem = (declaration: object): unknown => Reflect.apply(createResolved, undefined, [declaration, () => {}]); it("read the index's key parts, for ordering and conflicts, and are placed by them", () => { - const reader = System.create({ name: "indexCreateTest/reader", lookup: { near: { index: grid, read: [amount] } } }, ({ lookup }) => { void lookup.near([0, 0]).at(0)[amount]; }); + const reader = System.create({ lookup: { near: { index: grid } } }, ({ lookup }) => { void lookup.near([0, 0]).at(0)[amount]; }); const leaf = requireDefinition(reader); expect(isLeaf(leaf) ? [...accessOf(leaf).reads] : null).toEqual([amount, cell]); - const writer = System.create({ name: "indexCreateTest/writer", components: { write: [cell] } }, row => { row[cell] = [0, 0]; }); + const writer = System.create({ components: { with: [cell] } }, row => { row[cell] = [0, 0]; }); expect(() => System.create([reader, writer])).toThrow(`both access "indexCreateTest/cell" and one writes it`); expect(() => System.create({ systems: [reader, writer], before: [] })).toThrow("order them"); const names = Index.create({ key: [view] }); @@ -112,35 +105,42 @@ describe("systems and finalizers looking up through an index", () => { }); it("throw for a lookup through what isn't an index, reading or watching what not every entity it holds has, or a system writing its key or reads", () => { expect(() => untypedSystem({ name: "bad", lookup: { near: { index: { name: "fake" } } } })).toThrow(`lookup "near" goes through a value Index.create didn't return`); - expect(() => untypedSystem({ name: "bad", lookup: { near: { index: grid, read: [name] } } })).toThrow(`lookup "near" reads "indexCreateTest/name", which not every entity index "indexCreateTest/grid" holds has`); + expect(() => untypedSystem({ name: "bad", lookup: { near: { index: grid, read: [name] } } })).toThrow(`lookup "near" reads "indexCreateTest/name", which not every entity index "grid" holds has`); expect(() => untypedSystem({ name: "bad", lookup: { near: { index: grid, changed: [name] } } })).toThrow(`lookup "near" watches "indexCreateTest/name"`); expect(() => untypedSystem({ name: "bad", components: { write: [cell] }, lookup: { near: { index: grid } } })).toThrow(`goes through an index keyed by "indexCreateTest/cell", which the system writes`); expect(() => untypedSystem({ name: "bad", components: { write: [amount] }, lookup: { near: { index: grid, read: [amount] } } })).toThrow(`reads "indexCreateTest/amount", which the system writes`); - expect(() => untypedSystem({ name: "bad", kernel: "cpu", components: { write: [amount] }, lookup: { near: { index: Index.create({ key: [name] }) } } })).toThrow(`keyed by "indexCreateTest/name", a string, which kernels don't read yet`); - expect(() => Reflect.apply(Finalizer.create, undefined, [{ name: "bad", components: { read: [amount] }, lookup: { near: { index: grid, read: [name] } } }, () => {}])).toThrow(`Finalizer "bad": lookup "near" reads`); - expect(labelOfItem(grid)).toBe("indexCreateTest/grid"); + expect(() => untypedSystem({ name: "bad", kernel: true, components: { write: [amount] }, lookup: { near: { index: Index.create({ key: [name] }) } } })).toThrow(`keyed by "indexCreateTest/name", a string, which kernels don't read yet`); + const reading = giveAccess({ components: { with: [amount] }, lookup: { near: { index: grid } } }, { read: [amount], lookup: { near: [name] } }); + expect(() => Reflect.apply(Finalizer.create, undefined, [reading, () => {}])).toThrow(`lookup "near" reads`); + expect(grid.name).toBe("grid"); }); }); describe("systems ordering their rows by an index", () => { - const ranked = Index.create({ name: "indexCreateTest/ranked", key: [amount], sorted: true, components: { has: [cell] } }); - const untypedSystem = (declaration: object): unknown => Reflect.apply(System.create, undefined, [declaration, () => {}]); + const ranked = Index.create({ key: [amount], sorted: true, components: { with: [cell] } }); + /** A system of a declaration as the world takes it, what its body reads and writes listed, bypassing the types' checks. */ + const untypedSystem = (declaration: object): unknown => Reflect.apply(createResolved, undefined, [declaration, () => {}]); it("read the index's key parts, for ordering and conflicts, and are placed by them", () => { - const ordered = System.create({ name: "indexCreateTest/ordered", components: { read: [cell] }, order: ranked }, row => { void row[cell]; }); + const ordered = System.create({ components: { with: [cell] }, order: ranked }, row => { void row[cell]; }); const leaf = requireDefinition(ordered); expect(isLeaf(leaf) ? [...accessOf(leaf).reads] : null).toEqual([cell, amount]); - const writer = System.create({ name: "indexCreateTest/rankWriter", components: { write: [amount] } }, row => { row[amount] = 0; }); + const writer = System.create({ components: { with: [amount] } }, row => { row[amount] = 0; }); expect(() => System.create([ordered, writer])).toThrow(`both access "indexCreateTest/amount" and one writes it`); // Every value a sorted index orders lives in shared memory, strings included, so its key places nothing. expect(threadOf({ order: Index.create({ key: [name], sorted: true }) }, "System", "unplaced")).toBe(null); }); it("throw for an order by what isn't a sorted index, without components, with up, on the GPU, or writing its key", () => { - expect(() => untypedSystem({ name: "bad", components: { read: [cell] }, order: { name: "fake" } })).toThrow(`System "bad" orders its rows by a value Index.create didn't return`); - expect(() => untypedSystem({ name: "bad", components: { read: [cell] }, order: Index.create({ name: "indexCreateTest/hashed", key: [amount] }) })).toThrow(`orders its rows by index "indexCreateTest/hashed", which isn't sorted`); + expect(() => untypedSystem({ name: "bad", components: { read: [cell] }, order: { name: "fake" } })).toThrow(`orders its rows by a value Index.create didn't return`); + expect(() => untypedSystem({ name: "bad", components: { read: [cell] }, order: Index.create({ key: [amount] }) })).toThrow(`orders its rows by index "order", which isn't sorted`); expect(() => untypedSystem({ name: "bad", order: ranked })).toThrow("orders its rows without components"); expect(() => untypedSystem({ name: "bad", components: { read: [cell] }, up: { through: parent }, order: ranked })).toThrow("uses both up and order"); expect(() => untypedSystem({ name: "bad", kernel: "gpu", components: { write: [cell] }, order: ranked })).toThrow("GPU kernels have no order yet"); expect(() => untypedSystem({ name: "bad", components: { write: [amount] }, order: ranked })).toThrow(`its rows are ordered by an index keyed by "indexCreateTest/amount", which the system writes`); - expect(() => untypedSystem({ name: "bad", kernel: "cpu", components: { write: [amount] }, order: Index.create({ key: [name], sorted: true }) })).toThrow(`ordered by an index keyed by "indexCreateTest/name", a string, which kernels don't read yet`); + expect(() => untypedSystem({ name: "bad", kernel: true, components: { write: [amount] }, order: Index.create({ key: [name], sorted: true }) })).toThrow(`ordered by an index keyed by "indexCreateTest/name", a string, which kernels don't read yet`); + }); + it("throw for a render system's order by what isn't a sorted index", () => { + const untypedRender = (order: object): unknown => Reflect.apply(System.render, undefined, [giveAccess({ order, shader: { accessors: { amount } } }), () => {}]); + expect(() => untypedRender({ name: "fake" })).toThrow(`orders its rows by a value Index.create didn't return`); + expect(() => untypedRender(Index.create({ key: [amount] }))).toThrow(/orders its rows by index "index #\d+", which isn't sorted/); }); }); diff --git a/packages/ecs/src/interface/indexes/create.ts b/packages/ecs/src/interface/indexes/create.ts index d1105a542..c1d2a5996 100644 --- a/packages/ecs/src/interface/indexes/create.ts +++ b/packages/ecs/src/interface/indexes/create.ts @@ -5,16 +5,16 @@ import { takeIdOfDeclaration } from '../registry/ids.js'; import { addIndex } from '../registry/indexes.js'; import { nameOf } from '../registry/items.js'; import { layoutOf } from '../registry/layout.js'; -import { declarationOf, firstKeyOf, kindOf, labelOf } from '../registry/names.js'; +import { declarationOf, firstKeyOf, kindOf, labelOf, whyUnregistered } from '../registry/names.js'; import type { Schema } from '../schema/schema.js'; import type { Listed, Property } from '../system/property.js'; import type { Created } from './created.js'; import type { Declaration } from './declaration.js'; import type { Index } from './index-type.js'; -/** `not` excludes what the index holds by: its key parts and `has`. An index without a key isn't unique. */ +/** `not` excludes what the index holds by: its key parts and `with`. An index without a key isn't unique. */ type Validate = { - readonly components?: { readonly not?: readonly Exclude, "has">>[] }; + readonly components?: { readonly not?: readonly Exclude, "with">>[]; readonly has?: never }; } & (D["key"] extends readonly [] ? { readonly unique?: false } : unknown); /** Whether a schema's values have an order: numbers, booleans, entities and fixed-length tuples of them, and strings. */ @@ -29,9 +29,9 @@ const collection = (schema: Schema): boolean => "type" in schema && (schema.type /** * Declares an index over components' values and returns it; nothing is built until a world's system or finalizer uses - * it. It holds the entities with every key part and every component in `has`, and none in `not`, keyed by the values + * it. It holds the entities with every key part and every component in `with`, and none in `not`, keyed by the values * of `key`, compared part by part: numbers and tuples by value, an entity with its generation, so a stale one finds - * nothing. With an empty key it holds those entities, every entity but the resource entity when `has` and `not` are + * nothing. With an empty key it holds those entities, every entity but the resource entity when `with` and `not` are * empty too, and its lookup takes no key and gives them all, in entity order. * - `sorted`: keeps key order, numbers numerically, NaN last, booleans false first, strings by code point, tuples * element by element, then entity order, so a lookup takes a prefix of the key whose last part may be a comparison. @@ -40,14 +40,15 @@ const collection = (schema: Schema): boolean => "type" in schema && (schema.type * * Throws if a key part isn't a registered component of data, is listed twice or is a set or map, a sorted part has no * order, its object parts live on different threads, or on another than the world's beside a string, which the world - * keeps its keys of, it's unique without a key, or `has` or `not` names a key that isn't a registered component - * or `not` names a key part or a `has` component. + * keeps its keys of, it's unique without a key, or `with` or `not` names a key that isn't a registered component + * or `not` names a key part or a `with` component. */ export const create = (declaration: D & Validate): Index => { const id = takeIdOfDeclaration(declaration); - const name = nameOf(declaration.name, id, "index"); + const name = nameOf(declaration, id, "index"); const fail = (message: string): never => { throw new Error(`Index "${name}" ${message}.`); }; - const { key, components, sorted } = declaration; + const { key, sorted } = declaration; + const components = declaration.components === undefined ? undefined : { has: declaration.components.with, not: declaration.components.not }; if (!Array.isArray(key)) fail("needs a key: a list of components"); if (key.length === 0 && declaration.unique === true) fail("is unique without a key: it holds at most one entity per key"); const threads = new Set(); @@ -55,7 +56,7 @@ export const create = (declaration: D & Validate for (const part of key) { const found = declarationOf(part); const type = found?.type; - if (kindOf(part) !== "component" || type === undefined) fail(`has key part "${labelOf(part)}", which isn't a registered component of data`); + if (kindOf(part) !== "component" || type === undefined) fail(`has key part "${labelOf(part)}", which isn't a registered component of data: ${whyUnregistered(part)}`); else { if (parts.has(firstKeyOf(part))) fail(`lists key part "${labelOf(part)}" twice`); parts.add(firstKeyOf(part)); @@ -69,13 +70,13 @@ export const create = (declaration: D & Validate if (threads.size > 1) fail(`has key parts on threads ${[...threads].map(thread => `"${thread}"`).join(" and ")}; its object parts live on one thread, the world's beside a string`); const held = new Set(parts); for (const component of components?.has ?? []) { - if (kindOf(component) !== "component") fail(`has "${labelOf(component)}", which isn't a registered component`); + if (kindOf(component) !== "component") fail(`has "${labelOf(component)}", which isn't a registered component: ${whyUnregistered(component)}`); held.add(firstKeyOf(component)); } for (const component of components?.not ?? []) { - if (kindOf(component) !== "component") fail(`excludes "${labelOf(component)}", which isn't a registered component`); + if (kindOf(component) !== "component") fail(`excludes "${labelOf(component)}", which isn't a registered component: ${whyUnregistered(component)}`); if (held.has(firstKeyOf(component))) fail(`excludes "${labelOf(component)}", which it holds by`); } - const index: Created = { kind: "index", name, declaration }; + const index: Created = { kind: "index", name, declaration: { ...declaration, ...components === undefined ? {} : { components } } }; return addIndex(index, id); }; diff --git a/packages/ecs/src/interface/indexes/created.ts b/packages/ecs/src/interface/indexes/created.ts index 47ea8ec0c..6744e6601 100644 --- a/packages/ecs/src/interface/indexes/created.ts +++ b/packages/ecs/src/interface/indexes/created.ts @@ -1,5 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import type { Declaration } from './declaration.js'; +import type { Stored } from './declaration.js'; /** What `Index.create` records: its kind, its label and its declaration. */ -export type Created = { readonly kind: "index"; readonly name: string; readonly declaration: Declaration }; +export type Created = { readonly kind: "index"; readonly name: string; readonly declaration: Stored }; diff --git a/packages/ecs/src/interface/indexes/declaration.ts b/packages/ecs/src/interface/indexes/declaration.ts index 3408b13d2..c036b5378 100644 --- a/packages/ecs/src/interface/indexes/declaration.ts +++ b/packages/ecs/src/interface/indexes/declaration.ts @@ -1,12 +1,15 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { Component } from '../component/component.js'; -import type { Name } from './name.js'; /** What `Index.create` takes, its fields documented there; not exported from the package. */ export type Declaration = { - readonly name?: Name; readonly key: readonly Component.Key[]; - readonly components?: { readonly has?: readonly Component.Key[]; readonly not?: readonly Component.Key[] }; + readonly components?: { readonly with?: readonly Component.Key[]; readonly not?: readonly Component.Key[] }; readonly sorted?: boolean; readonly unique?: boolean; }; + +/** An index's declaration as the world takes it: what it requires in `has`. */ +export type Stored = Omit & { + readonly components?: { readonly has?: readonly symbol[]; readonly not?: readonly symbol[] }; +}; diff --git a/packages/ecs/src/interface/indexes/index-type.ts b/packages/ecs/src/interface/indexes/index-type.ts index 39ad05d4b..817139b6a 100644 --- a/packages/ecs/src/interface/indexes/index-type.ts +++ b/packages/ecs/src/interface/indexes/index-type.ts @@ -4,9 +4,9 @@ import type { Declaration } from './declaration.js'; declare const declared: unique symbol; /** - * An index, the value `Index.create` returned: identified by that value, labeled by its name. `D` is the type of the - * declaration it was created from, which its lookups take their key and rows from; code accepting any sorted index - * takes `Index<{ readonly sorted: true }>`. + * An index, the value `Index.create` returned: identified by that value, labeled by the binding it's declared at. `D` + * is the type of the declaration it was created from, which its lookups take their key and rows from; code accepting + * any sorted index takes `Index<{ readonly sorted: true }>`. */ export type Index = { readonly name: string; readonly [declared]?: D }; diff --git a/packages/ecs/src/interface/indexes/index.type-test.ts b/packages/ecs/src/interface/indexes/index.type-test.ts index 7966ce0f3..4008d1238 100644 --- a/packages/ecs/src/interface/indexes/index.type-test.ts +++ b/packages/ecs/src/interface/indexes/index.type-test.ts @@ -38,39 +38,27 @@ declare module '@adobe/ecs' { [label]: { type: string }; } } -Component.register(cell, { type: Vec3.I32.schema }); -Component.register(position, { type: Vec3.schema }); -Component.register(velocity, { type: Vec3.schema }); -Component.register(serverId, { type: { type: "number" } }); -Component.register(health, { type: { type: "number" } }); -Component.register(track, { type: Entity.schema }); -Component.register(start, { type: { type: "number" } }); -Component.register(clip, { type: { type: "number" } }); -Component.register(playhead, { type: { type: "number" } }); -Component.register(owner, { type: Entity.schema, reference: "owner" }); -Component.register(archived, { type: { const: true } }); -Component.register(label, { type: { type: "string" } }); // An index is typed by its declaration, as written. -const grid = Index.create({ name: "indexTest/grid", key: [cell], components: { has: [position] } }); -check>>(); -const byServerId = Index.create({ key: [serverId], unique: true, components: { has: [health] } }); -const clips = Index.create({ key: [track, start], sorted: true, components: { has: [clip] } }); +const grid = Index.create({ key: [cell], components: { with: [position] } }); +check>>(); +const byServerId = Index.create({ key: [serverId], unique: true, components: { with: [health] } }); +const clips = Index.create({ key: [track, start], sorted: true, components: { with: [clip] } }); const tasksOf = Index.create({ key: [owner], components: { not: [archived] } }); const named = Index.create({ key: [label, serverId], sorted: true, unique: true }); -// With an empty key, an index holds the entities with every `has` component and no `not` one; with neither, every entity. -const living = Index.create({ key: [], components: { has: [health], not: [archived] } }); -check>>(); +// With an empty key, an index holds the entities with every `with` component and no `not` one; with neither, every entity. +const living = Index.create({ key: [], components: { with: [health], not: [archived] } }); +check>>(); const everything = Index.create({ key: [] }); -const inOrder = Index.create({ key: [], sorted: true, components: { has: [start] } }); +const inOrder = Index.create({ key: [], sorted: true, components: { with: [start] } }); // @ts-expect-error a key is a list of components Index.create({ key: [health, 3] }); // @ts-expect-error an index without a key holds no key once Index.create({ key: [], unique: true }); // @ts-expect-error `not` excludes `has` -Index.create({ key: [], components: { has: [archived], not: [archived] } }); +Index.create({ key: [], components: { with: [archived], not: [archived] } }); // @ts-expect-error `not` excludes neither key parts nor `has` -Index.create({ key: [owner], components: { has: [archived], not: [owner] } }); +Index.create({ key: [owner], components: { with: [archived], not: [owner] } }); // Code accepting any sorted index takes `Index<{ readonly sorted: true }>`. const sortedOnly = (index: Index<{ readonly sorted: true }>): string => index.name; @@ -84,8 +72,8 @@ sortedOnly(anyIndex); // A hash lookup takes the whole key and gives the bucket's rows, with `length`, `changed` and `at`. export const separate = System.create({ - components: { read: [position, cell], write: [velocity] }, - lookup: { near: { index: grid, read: [position] } }, + components: { with: [position, cell, velocity] }, + lookup: { near: { index: grid } }, }, (row, { lookup }) => { const near = lookup.near(row[cell]); check>(); @@ -97,8 +85,9 @@ export const separate = System.create({ void entity; // @ts-expect-error rows are read-only other[position] = [0, 0, 0]; - // @ts-expect-error rows hold only the lookup's read void other[cell]; + // @ts-expect-error rows hold only what every entity the index holds has + void other[velocity]; // @ts-expect-error a hash lookup takes the whole key lookup.near(); // @ts-expect-error a key part's value has its type @@ -107,19 +96,18 @@ export const separate = System.create({ // A unique lookup gives the row or null. export const applyDelta = System.create({ - input: { type: "object", properties: { id: { type: "number" }, hp: { type: "number" } } }, - lookup: { known: { index: byServerId, read: [health] } }, -}, ({ input, lookup, commands }) => { + lookup: { known: { index: byServerId } }, +}, ({ lookup, commands }, input: { readonly id: number; readonly hp: number }) => { const known = lookup.known(input.id); - check>(); + check>(); if (known === null) commands.spawn([serverId, health])({ [serverId]: input.id, [health]: input.hp }); else commands.add(known.entity, health, known[health] + input.hp); }); // A sorted lookup takes a prefix of the key, whose last part may be a comparison. export const cue = System.create({ - components: { read: [track, playhead] }, - lookup: { due: { index: clips, read: [clip, start], changed: [clip] } }, + components: { with: [track, playhead] }, + lookup: { due: { index: clips, changed: [clip] } }, }, (row, { lookup }) => { const t = row[playhead]; const due = lookup.due(row[track], { ">=": t, "<": t + 1 }); @@ -132,9 +120,9 @@ export const cue = System.create({ }); // A unique sorted lookup gives a row by a whole key of plain values, and rows by anything else. -System.create({ lookup: { names: { index: named, read: [label] } } }, ({ lookup }) => { +System.create({ lookup: { names: { index: named } } }, ({ lookup }) => { const one = lookup.names("a", 1); - check>(); + check>(); const some = lookup.names("a", { ">": 1 }); check>(); const all = lookup.names(); @@ -143,7 +131,7 @@ System.create({ lookup: { names: { index: named, read: [label] } } }, ({ lookup // A lookup through an index without a key takes none and gives every row it holds, in entity order. export const census = System.create({ - lookup: { living: { index: living, read: [health], changed: [health] }, all: { index: everything }, ordered: { index: inOrder, read: [start] } }, + lookup: { living: { index: living, changed: [health] }, all: { index: everything }, ordered: { index: inOrder } }, }, ({ lookup }) => { const rows = lookup.living(); check>(); @@ -157,115 +145,77 @@ export const census = System.create({ void lookup.all().at(0)[health]; }); System.create({ - kernel: "cpu", - components: { write: [velocity] }, - lookup: { living: { index: living, read: [health] } }, + kernel: true, + components: { with: [velocity] }, + lookup: { living: { index: living } }, }, () => {}); -System.create({ - components: { write: [health] }, - // @ts-expect-error a system doesn't write what it reads through an index - lookup: { living: { index: living, read: [health] } }, -}, () => {}); -System.create({ components: { read: [start], write: [playhead] }, order: inOrder }, row => { +System.create({ components: { with: [start, playhead] }, order: inOrder }, row => { row[playhead] = row[start]; }); // Finalizers look up through indexes too. -Finalizer.create({ components: { read: [owner] }, lookup: { tasks: { index: tasksOf } } }, (row, { lookup }) => { +Finalizer.create({ components: { with: [owner] }, lookup: { tasks: { index: tasksOf } } }, (row, { lookup }) => { const tasks = lookup.tasks(row[owner]); void tasks.at(0).entity; }); -System.create({ - components: { read: [cell] }, - // @ts-expect-error an index lookup reads only what every entity the index holds has - lookup: { near: { index: grid, read: [velocity] } }, -}, () => {}); System.create({ - components: { read: [cell] }, + components: { with: [cell] }, // @ts-expect-error an index lookup watches only what every entity the index holds has lookup: { near: { index: grid, changed: [velocity] } }, }, () => {}); -System.create({ - components: { write: [cell] }, - // @ts-expect-error a system doesn't write what it finds rows by - lookup: { near: { index: grid } }, -}, () => {}); - -System.create({ - components: { write: [position] }, - // @ts-expect-error a system doesn't write what it reads through an index - lookup: { near: { index: grid, read: [position] } }, -}, () => {}); System.create({ - kernel: "cpu", - components: { read: [cell], write: [velocity] }, - lookup: { near: { index: grid, read: [position] } }, + kernel: true, + components: { with: [cell, velocity] }, + lookup: { near: { index: grid } }, }, () => {}); System.create({ - kernel: "cpu", - components: { read: [cell], write: [velocity] }, + kernel: true, + components: { with: [cell, velocity] }, lookup: { near: { index: grid, changed: [position] } }, }, () => {}); -System.create({ - kernel: "cpu", - components: { write: [velocity] }, - // @ts-expect-error kernels look up only through indexes of struct keys - lookup: { names: { index: named } }, -}, () => {}); System.create({ kernel: "gpu", - components: { write: [velocity] }, + components: { with: [velocity] }, // @ts-expect-error GPU kernels can't use lookups lookup: { near: { index: grid } }, }, () => {}); // `order` runs a system's rows in a sorted index's order. -System.create({ components: { read: [start], write: [playhead] }, order: clips }, row => { +System.create({ components: { with: [start, playhead] }, order: clips }, row => { row[playhead] = row[start]; }); -System.create({ kernel: "cpu", components: { read: [start], write: [playhead] }, order: clips }, row => { +System.create({ kernel: true, components: { with: [start, playhead] }, order: clips }, row => { row[playhead] = row[start]; }); // A sorted index typed by its declaration is one of `Index<{ readonly sorted: true }>`. const ordering: Index<{ readonly sorted: true }> = clips; void ordering; // @ts-expect-error a hash index has no order -System.create({ components: { write: [velocity] }, order: grid }, () => {}); +System.create({ components: { with: [velocity] }, order: grid }, () => {}); // @ts-expect-error a hash index isn't one of `Index<{ readonly sorted: true }>` const unordered: Index<{ readonly sorted: true }> = grid; void unordered; // @ts-expect-error a system without components runs once, in no order System.create({ order: clips }, () => {}); System.create({ - components: { read: [clip], write: [playhead] }, + components: { with: [clip, playhead] }, up: { through: owner }, // @ts-expect-error `up` orders rows too order: clips, }, () => {}); -System.create({ - components: { write: [start] }, - // @ts-expect-error a system doesn't write what its rows are ordered by - order: clips, -}, () => {}); System.create({ kernel: "gpu", - components: { write: [velocity] }, + components: { with: [velocity] }, // @ts-expect-error GPU kernels have no order yet order: clips, }, () => {}); -System.create({ - kernel: "cpu", - components: { write: [velocity] }, - // @ts-expect-error kernels are ordered only by indexes of struct keys - order: named, -}, () => {}); // An index lookup's `changed: true` runs its system only when the index's entries changed since it last ran: its // lists' `changed` then reports entries alone. Observer systems and CPU kernels take it; it's not a list of components. @@ -274,21 +224,21 @@ System.create({ observer: true, lookup: { living: { index: living, changed: true check>(); check>(); }); -System.create({ components: { read: [cell] }, lookup: { near: { index: grid, read: [position], changed: true } } }, (row, { lookup }) => { +System.create({ components: { with: [cell] }, lookup: { near: { index: grid, changed: true } } }, (row, { lookup }) => { void lookup.near(row[cell]).at(0)[position]; }); System.create({ - kernel: "cpu", - components: { read: [cell], write: [velocity] }, + kernel: true, + components: { with: [cell, velocity] }, lookup: { near: { index: grid, changed: true } }, }, () => {}); System.create({ // @ts-expect-error only an index lookup's `changed` may be true - lookup: { player: { read: [health], changed: true } }, + lookup: { player: { with: [health], changed: true } }, }, () => {}); System.create({ // @ts-expect-error `changed` is true or a list of components lookup: { living: { index: living, changed: false } }, }, () => {}); // @ts-expect-error a finalizer runs on each entity that stops matching, so its lookups' `changed` is never true -Finalizer.create({ components: { read: [owner] }, lookup: { tasks: { index: tasksOf, changed: true } } }, () => {}); +Finalizer.create({ components: { with: [owner] }, lookup: { tasks: { index: tasksOf, changed: true } } }, () => {}); diff --git a/packages/ecs/src/interface/indexes/name.ts b/packages/ecs/src/interface/indexes/name.ts deleted file mode 100644 index 2c27dcc79..000000000 --- a/packages/ecs/src/interface/indexes/name.ts +++ /dev/null @@ -1,6 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -/** - * The label reports and errors give it, which needn't be unique: the value `Index.create` returns is its identity. - * Without one, it's labeled by its binding and the id the plugin gave it, or else by its kind numbered. - */ -export type Name = string; diff --git a/packages/ecs/src/interface/math/f32/default.ts b/packages/ecs/src/interface/math/f32/default.ts new file mode 100644 index 000000000..a1866beb1 --- /dev/null +++ b/packages/ecs/src/interface/math/f32/default.ts @@ -0,0 +1,6 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { F32 } from "./index.js"; + +/** What a value of this type starts at. */ +const start: F32 = 0; +export { start as default }; diff --git a/packages/ecs/src/interface/math/f32/index.ts b/packages/ecs/src/interface/math/f32/index.ts index 9fe4517f3..8576c1109 100644 --- a/packages/ecs/src/interface/math/f32/index.ts +++ b/packages/ecs/src/interface/math/f32/index.ts @@ -1,4 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -export type F32 = number; +import type { Schema } from "../../schema/schema.js"; +import type { schema } from "./schema.js"; + +export type F32 = Schema.Type; export * as F32 from "./public.js"; diff --git a/packages/ecs/src/interface/math/f32/public.ts b/packages/ecs/src/interface/math/f32/public.ts index f525dd499..251ce21f5 100644 --- a/packages/ecs/src/interface/math/f32/public.ts +++ b/packages/ecs/src/interface/math/f32/public.ts @@ -1,2 +1,3 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. export * from "./schema.js"; +export { default } from "./default.js"; diff --git a/packages/ecs/src/interface/math/i32/default.ts b/packages/ecs/src/interface/math/i32/default.ts new file mode 100644 index 000000000..d1282247a --- /dev/null +++ b/packages/ecs/src/interface/math/i32/default.ts @@ -0,0 +1,6 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { I32 } from "./index.js"; + +/** What a value of this type starts at. */ +const start: I32 = 0; +export { start as default }; diff --git a/packages/ecs/src/interface/math/i32/index.ts b/packages/ecs/src/interface/math/i32/index.ts index dc94e8ba1..d850e9597 100644 --- a/packages/ecs/src/interface/math/i32/index.ts +++ b/packages/ecs/src/interface/math/i32/index.ts @@ -1,5 +1,8 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Schema } from "../../schema/schema.js"; +import type { schema } from "./schema.js"; + /** Integers in [-2^31, 2^31). Functions wrap as WGSL's i32 does; for other values their results are indeterminate. */ -export type I32 = number; +export type I32 = Schema.Type; export * as I32 from "./public.js"; diff --git a/packages/ecs/src/interface/math/i32/public.ts b/packages/ecs/src/interface/math/i32/public.ts index ecfb1e4cd..ef2a77f9a 100644 --- a/packages/ecs/src/interface/math/i32/public.ts +++ b/packages/ecs/src/interface/math/i32/public.ts @@ -1,3 +1,4 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. export { schema } from "./schema.js"; export { shiftRight } from "./shift-right.js"; +export { default } from "./default.js"; diff --git a/packages/ecs/src/interface/math/mat4x4/index.ts b/packages/ecs/src/interface/math/mat4x4/index.ts index 4c5893d33..77830a52a 100644 --- a/packages/ecs/src/interface/math/mat4x4/index.ts +++ b/packages/ecs/src/interface/math/mat4x4/index.ts @@ -1,6 +1,8 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Schema } from "../../schema/schema.js"; +import type { schema } from "./schema.js"; -export type Mat4x4 = readonly [number, number, number, number, number, number, number, number, number, number, number, number, number, number, number, number]; +export type Mat4x4 = Schema.Type; /** * 4x4 Matrix operations using column-major order (WebGPU standard). diff --git a/packages/ecs/src/interface/math/mat4x4/public.ts b/packages/ecs/src/interface/math/mat4x4/public.ts index 3de5992b0..6c7615eee 100644 --- a/packages/ecs/src/interface/math/mat4x4/public.ts +++ b/packages/ecs/src/interface/math/mat4x4/public.ts @@ -19,3 +19,4 @@ export * from "./translation.js"; export * from "./transpose.js"; export * from "./zero.js"; export * from "./schema.js"; +export { identity as default } from "./identity.js"; diff --git a/packages/ecs/src/interface/math/math.type-test.ts b/packages/ecs/src/interface/math/math.type-test.ts index cc51dee09..5c34cfc3f 100644 --- a/packages/ecs/src/interface/math/math.type-test.ts +++ b/packages/ecs/src/interface/math/math.type-test.ts @@ -1,5 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Mat4x4, Quat, Vec2, Vec3, Vec4 } from '../index.js'; +import { F32, I32, Mat4x4, Quat, U32, Vec2, Vec3, Vec4 } from '../index.js'; // Each type is also a namespace of functions; `Vec3` is `Vec3.F32`, with integer vectors nested beside it. const position: Vec3 = [1, 2, 3]; @@ -19,3 +19,25 @@ export const used = [integer, unsigned, texel, color, transform, wrong]; // @ts-expect-error vectors are read-only values position[0] = 1; + +type Equal = (() => T extends X ? 1 : 2) extends (() => T extends Y ? 1 : 2) ? true : false; +const check = <_T extends true>() => { }; +type Two = readonly [number, number]; +type Three = readonly [number, number, number]; +type Four = readonly [number, number, number, number]; + +// Each is the plain type its schema describes. +check>(); +check>(); +check>(); +check>(); +check>(); +check>(); +check>(); +check>(); +check>(); +check>(); +check>(); +check>(); +check>(); +check>(); diff --git a/packages/ecs/src/interface/math/quat/index.ts b/packages/ecs/src/interface/math/quat/index.ts index a69b1e594..144928e16 100644 --- a/packages/ecs/src/interface/math/quat/index.ts +++ b/packages/ecs/src/interface/math/quat/index.ts @@ -1,4 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -export type Quat = readonly [number, number, number, number]; +import type { Schema } from "../../schema/schema.js"; +import type { schema } from "./schema.js"; + +export type Quat = Schema.Type; export * as Quat from "./public.js"; diff --git a/packages/ecs/src/interface/math/quat/public.ts b/packages/ecs/src/interface/math/quat/public.ts index 3bb6df16b..46a04edec 100644 --- a/packages/ecs/src/interface/math/quat/public.ts +++ b/packages/ecs/src/interface/math/quat/public.ts @@ -23,3 +23,4 @@ export * from "./to-axis-angle.js"; export * from "./to-euler.js"; export * from "./to-mat4.js"; export * from "./schema.js"; +export { identity as default } from "./identity.js"; diff --git a/packages/ecs/src/interface/math/u32/default.ts b/packages/ecs/src/interface/math/u32/default.ts new file mode 100644 index 000000000..3e7a3188d --- /dev/null +++ b/packages/ecs/src/interface/math/u32/default.ts @@ -0,0 +1,6 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { U32 } from "./index.js"; + +/** What a value of this type starts at. */ +const start: U32 = 0; +export { start as default }; diff --git a/packages/ecs/src/interface/math/u32/index.ts b/packages/ecs/src/interface/math/u32/index.ts index fa1611bd2..782ad40aa 100644 --- a/packages/ecs/src/interface/math/u32/index.ts +++ b/packages/ecs/src/interface/math/u32/index.ts @@ -1,5 +1,8 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Schema } from "../../schema/schema.js"; +import type { schema } from "./schema.js"; + /** Integers in [0, 2^32). Functions wrap as WGSL's u32 does; for other values their results are indeterminate. */ -export type U32 = number; +export type U32 = Schema.Type; export * as U32 from "./public.js"; diff --git a/packages/ecs/src/interface/math/u32/public.ts b/packages/ecs/src/interface/math/u32/public.ts index c9c88f493..8328ba9f1 100644 --- a/packages/ecs/src/interface/math/u32/public.ts +++ b/packages/ecs/src/interface/math/u32/public.ts @@ -5,3 +5,4 @@ export { countTrailingZeros } from "./count-trailing-zeros.js"; export { countOneBits } from "./count-one-bits.js"; export { reverseBits } from "./reverse-bits.js"; export { shiftRight } from "./shift-right.js"; +export { default } from "./default.js"; diff --git a/packages/ecs/src/interface/math/vec2/f32/f32.ts b/packages/ecs/src/interface/math/vec2/f32/f32.ts index 8833b01bb..6d5070b58 100644 --- a/packages/ecs/src/interface/math/vec2/f32/f32.ts +++ b/packages/ecs/src/interface/math/vec2/f32/f32.ts @@ -1,4 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -export type F32 = readonly [number, number]; +import type { Schema } from "../../../schema/schema.js"; +import type { schema } from "./schema.js"; + +export type F32 = Schema.Type; export * as F32 from "./public.js"; diff --git a/packages/ecs/src/interface/math/vec2/f32/public.ts b/packages/ecs/src/interface/math/vec2/f32/public.ts index cab939cc8..a6598708a 100644 --- a/packages/ecs/src/interface/math/vec2/f32/public.ts +++ b/packages/ecs/src/interface/math/vec2/f32/public.ts @@ -46,3 +46,4 @@ export * from "./tanh.js"; export * from "./trunc.js"; export * from "./zero.js"; export * from "./schema.js"; +export { zero as default } from "./zero.js"; diff --git a/packages/ecs/src/interface/math/vec2/i32/i32.ts b/packages/ecs/src/interface/math/vec2/i32/i32.ts index 5dd189bf0..526e1c9a9 100644 --- a/packages/ecs/src/interface/math/vec2/i32/i32.ts +++ b/packages/ecs/src/interface/math/vec2/i32/i32.ts @@ -1,5 +1,8 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Schema } from "../../../schema/schema.js"; +import type { schema } from "./schema.js"; + /** Vectors of integers in [-2^31, 2^31). Functions wrap as WGSL's i32 does; for other values their results are indeterminate. */ -export type I32 = readonly [number, number]; +export type I32 = Schema.Type; export * as I32 from "./public.js"; diff --git a/packages/ecs/src/interface/math/vec2/i32/public.ts b/packages/ecs/src/interface/math/vec2/i32/public.ts index e4fe1fce3..930056add 100644 --- a/packages/ecs/src/interface/math/vec2/i32/public.ts +++ b/packages/ecs/src/interface/math/vec2/i32/public.ts @@ -20,3 +20,4 @@ export * from "./shift-right.js"; export * from "./subtract.js"; export * from "./zero.js"; export * from "./schema.js"; +export { zero as default } from "./zero.js"; diff --git a/packages/ecs/src/interface/math/vec2/public.ts b/packages/ecs/src/interface/math/vec2/public.ts index 93fe90cad..14cf0270f 100644 --- a/packages/ecs/src/interface/math/vec2/public.ts +++ b/packages/ecs/src/interface/math/vec2/public.ts @@ -3,3 +3,4 @@ export { F32 } from "./f32/f32.js"; export { I32 } from "./i32/i32.js"; export { U32 } from "./u32/u32.js"; export * from "./f32/public.js"; +export { default } from "./f32/public.js"; diff --git a/packages/ecs/src/interface/math/vec2/u32/public.ts b/packages/ecs/src/interface/math/vec2/u32/public.ts index 640d94504..ff234cb15 100644 --- a/packages/ecs/src/interface/math/vec2/u32/public.ts +++ b/packages/ecs/src/interface/math/vec2/u32/public.ts @@ -19,3 +19,4 @@ export * from "./shift-right.js"; export * from "./subtract.js"; export * from "./zero.js"; export * from "./schema.js"; +export { zero as default } from "./zero.js"; diff --git a/packages/ecs/src/interface/math/vec2/u32/u32.ts b/packages/ecs/src/interface/math/vec2/u32/u32.ts index 429d7a78e..e4aefaac3 100644 --- a/packages/ecs/src/interface/math/vec2/u32/u32.ts +++ b/packages/ecs/src/interface/math/vec2/u32/u32.ts @@ -1,5 +1,8 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Schema } from "../../../schema/schema.js"; +import type { schema } from "./schema.js"; + /** Vectors of integers in [0, 2^32). Functions wrap as WGSL's u32 does; for other values their results are indeterminate. */ -export type U32 = readonly [number, number]; +export type U32 = Schema.Type; export * as U32 from "./public.js"; diff --git a/packages/ecs/src/interface/math/vec3/f32/f32.ts b/packages/ecs/src/interface/math/vec3/f32/f32.ts index 9313ba605..6d5070b58 100644 --- a/packages/ecs/src/interface/math/vec3/f32/f32.ts +++ b/packages/ecs/src/interface/math/vec3/f32/f32.ts @@ -1,4 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -export type F32 = readonly [number, number, number]; +import type { Schema } from "../../../schema/schema.js"; +import type { schema } from "./schema.js"; + +export type F32 = Schema.Type; export * as F32 from "./public.js"; diff --git a/packages/ecs/src/interface/math/vec3/f32/public.ts b/packages/ecs/src/interface/math/vec3/f32/public.ts index 1cac2de00..0bd8c720e 100644 --- a/packages/ecs/src/interface/math/vec3/f32/public.ts +++ b/packages/ecs/src/interface/math/vec3/f32/public.ts @@ -52,3 +52,4 @@ export * from "./tanh.js"; export * from "./trunc.js"; export * from "./zero.js"; export * from "./schema.js"; +export { zero as default } from "./zero.js"; diff --git a/packages/ecs/src/interface/math/vec3/i32/i32.ts b/packages/ecs/src/interface/math/vec3/i32/i32.ts index 147bc8956..526e1c9a9 100644 --- a/packages/ecs/src/interface/math/vec3/i32/i32.ts +++ b/packages/ecs/src/interface/math/vec3/i32/i32.ts @@ -1,5 +1,8 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Schema } from "../../../schema/schema.js"; +import type { schema } from "./schema.js"; + /** Vectors of integers in [-2^31, 2^31). Functions wrap as WGSL's i32 does; for other values their results are indeterminate. */ -export type I32 = readonly [number, number, number]; +export type I32 = Schema.Type; export * as I32 from "./public.js"; diff --git a/packages/ecs/src/interface/math/vec3/i32/public.ts b/packages/ecs/src/interface/math/vec3/i32/public.ts index 104673aaf..13158cb8a 100644 --- a/packages/ecs/src/interface/math/vec3/i32/public.ts +++ b/packages/ecs/src/interface/math/vec3/i32/public.ts @@ -22,3 +22,4 @@ export * from "./shift-right.js"; export * from "./subtract.js"; export * from "./zero.js"; export * from "./schema.js"; +export { zero as default } from "./zero.js"; diff --git a/packages/ecs/src/interface/math/vec3/public.ts b/packages/ecs/src/interface/math/vec3/public.ts index 93fe90cad..14cf0270f 100644 --- a/packages/ecs/src/interface/math/vec3/public.ts +++ b/packages/ecs/src/interface/math/vec3/public.ts @@ -3,3 +3,4 @@ export { F32 } from "./f32/f32.js"; export { I32 } from "./i32/i32.js"; export { U32 } from "./u32/u32.js"; export * from "./f32/public.js"; +export { default } from "./f32/public.js"; diff --git a/packages/ecs/src/interface/math/vec3/u32/public.ts b/packages/ecs/src/interface/math/vec3/u32/public.ts index e0900aef0..1ed0dc7e6 100644 --- a/packages/ecs/src/interface/math/vec3/u32/public.ts +++ b/packages/ecs/src/interface/math/vec3/u32/public.ts @@ -22,3 +22,4 @@ export * from "./subtract.js"; export * from "./volume.js"; export * from "./zero.js"; export * from "./schema.js"; +export { zero as default } from "./zero.js"; diff --git a/packages/ecs/src/interface/math/vec3/u32/u32.ts b/packages/ecs/src/interface/math/vec3/u32/u32.ts index c318ebd6b..e4aefaac3 100644 --- a/packages/ecs/src/interface/math/vec3/u32/u32.ts +++ b/packages/ecs/src/interface/math/vec3/u32/u32.ts @@ -1,5 +1,8 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Schema } from "../../../schema/schema.js"; +import type { schema } from "./schema.js"; + /** Vectors of integers in [0, 2^32). Functions wrap as WGSL's u32 does; for other values their results are indeterminate. */ -export type U32 = readonly [number, number, number]; +export type U32 = Schema.Type; export * as U32 from "./public.js"; diff --git a/packages/ecs/src/interface/math/vec4/f32/default.ts b/packages/ecs/src/interface/math/vec4/f32/default.ts new file mode 100644 index 000000000..665b99711 --- /dev/null +++ b/packages/ecs/src/interface/math/vec4/f32/default.ts @@ -0,0 +1,6 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { F32 } from "./f32.js"; + +/** What a value of this type starts at. */ +const start: F32 = [0, 0, 0, 0]; +export { start as default }; diff --git a/packages/ecs/src/interface/math/vec4/f32/f32.ts b/packages/ecs/src/interface/math/vec4/f32/f32.ts index 59d94791a..6d5070b58 100644 --- a/packages/ecs/src/interface/math/vec4/f32/f32.ts +++ b/packages/ecs/src/interface/math/vec4/f32/f32.ts @@ -1,4 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -export type F32 = readonly [number, number, number, number]; +import type { Schema } from "../../../schema/schema.js"; +import type { schema } from "./schema.js"; + +export type F32 = Schema.Type; export * as F32 from "./public.js"; diff --git a/packages/ecs/src/interface/math/vec4/f32/public.ts b/packages/ecs/src/interface/math/vec4/f32/public.ts index e81496a79..0d0ba4978 100644 --- a/packages/ecs/src/interface/math/vec4/f32/public.ts +++ b/packages/ecs/src/interface/math/vec4/f32/public.ts @@ -45,3 +45,4 @@ export * from "./tan.js"; export * from "./tanh.js"; export * from "./trunc.js"; export * from "./schema.js"; +export { default } from "./default.js"; diff --git a/packages/ecs/src/interface/math/vec4/i32/i32.ts b/packages/ecs/src/interface/math/vec4/i32/i32.ts index d40cf1a01..526e1c9a9 100644 --- a/packages/ecs/src/interface/math/vec4/i32/i32.ts +++ b/packages/ecs/src/interface/math/vec4/i32/i32.ts @@ -1,5 +1,8 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Schema } from "../../../schema/schema.js"; +import type { schema } from "./schema.js"; + /** Vectors of integers in [-2^31, 2^31). Functions wrap as WGSL's i32 does; for other values their results are indeterminate. */ -export type I32 = readonly [number, number, number, number]; +export type I32 = Schema.Type; export * as I32 from "./public.js"; diff --git a/packages/ecs/src/interface/math/vec4/i32/public.ts b/packages/ecs/src/interface/math/vec4/i32/public.ts index 104673aaf..13158cb8a 100644 --- a/packages/ecs/src/interface/math/vec4/i32/public.ts +++ b/packages/ecs/src/interface/math/vec4/i32/public.ts @@ -22,3 +22,4 @@ export * from "./shift-right.js"; export * from "./subtract.js"; export * from "./zero.js"; export * from "./schema.js"; +export { zero as default } from "./zero.js"; diff --git a/packages/ecs/src/interface/math/vec4/public.ts b/packages/ecs/src/interface/math/vec4/public.ts index 93fe90cad..14cf0270f 100644 --- a/packages/ecs/src/interface/math/vec4/public.ts +++ b/packages/ecs/src/interface/math/vec4/public.ts @@ -3,3 +3,4 @@ export { F32 } from "./f32/f32.js"; export { I32 } from "./i32/i32.js"; export { U32 } from "./u32/u32.js"; export * from "./f32/public.js"; +export { default } from "./f32/public.js"; diff --git a/packages/ecs/src/interface/math/vec4/u32/public.ts b/packages/ecs/src/interface/math/vec4/u32/public.ts index 486240733..aa7f5467f 100644 --- a/packages/ecs/src/interface/math/vec4/u32/public.ts +++ b/packages/ecs/src/interface/math/vec4/u32/public.ts @@ -21,3 +21,4 @@ export * from "./shift-right.js"; export * from "./subtract.js"; export * from "./zero.js"; export * from "./schema.js"; +export { zero as default } from "./zero.js"; diff --git a/packages/ecs/src/interface/math/vec4/u32/u32.ts b/packages/ecs/src/interface/math/vec4/u32/u32.ts index 6daca429c..e4aefaac3 100644 --- a/packages/ecs/src/interface/math/vec4/u32/u32.ts +++ b/packages/ecs/src/interface/math/vec4/u32/u32.ts @@ -1,5 +1,8 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Schema } from "../../../schema/schema.js"; +import type { schema } from "./schema.js"; + /** Vectors of integers in [0, 2^32). Functions wrap as WGSL's u32 does; for other values their results are indeterminate. */ -export type U32 = readonly [number, number, number, number]; +export type U32 = Schema.Type; export * as U32 from "./public.js"; diff --git a/packages/ecs/src/interface/registry/archetypes.test.ts b/packages/ecs/src/interface/registry/archetypes.test.ts new file mode 100644 index 000000000..eb0333800 --- /dev/null +++ b/packages/ecs/src/interface/registry/archetypes.test.ts @@ -0,0 +1,50 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { describe, expect, it } from "vitest"; +import { archetypeIndexKey, indexArchetype } from "./archetypes.js"; + +/** The index a list was given, or -1 without one. */ +const archetypeIndexOf = (list: readonly unknown[]): number => { + const index: unknown = Reflect.get(list, archetypeIndexKey); + return typeof index === "number" ? index : -1; +}; + +const position = Symbol(); +const velocity = Symbol(); +const gravity = Symbol(); +declare module "@adobe/ecs" { + interface Components { + [position]: { type: number }; + [velocity]: { type: number }; + } + interface Resources { + [gravity]: { type: number }; + } +} + +/** Lists the build gives indexes: top-level lists of components declared as const. */ +const Particle = [position, velocity] as const; +const Moving = [velocity] as const; +/** A list naming a resource takes an index too: where it's used, it's found not to be a list of components. */ +const WithResource = [position, gravity] as const; + +describe("archetype indexes", () => { + it("are given by the build to each top-level list of components declared as const", () => { + expect(archetypeIndexOf(Particle)).toBeGreaterThanOrEqual(0); + expect(archetypeIndexOf(Moving)).toBeGreaterThanOrEqual(0); + expect(archetypeIndexOf(Moving)).not.toBe(archetypeIndexOf(Particle)); + expect(Object.keys(Particle)).toEqual(["0", "1"]); + }); + it("aren't given to a list made at runtime, and are to one of symbols whatever they key", () => { + expect(archetypeIndexOf([position, velocity])).toBe(-1); + expect(archetypeIndexOf(WithResource)).toBeGreaterThanOrEqual(0); + }); + it("are given to the package's own lists once, and never twice", () => { + const list = [position]; + indexArchetype(list); + const index = archetypeIndexOf(list); + indexArchetype(list); + expect(index).toBeGreaterThanOrEqual(0); + expect(archetypeIndexOf(list)).toBe(index); + expect(() => indexArchetype([position, velocity, position])).toThrow("twice"); + }); +}); diff --git a/packages/ecs/src/interface/registry/archetypes.ts b/packages/ecs/src/interface/registry/archetypes.ts index 2f8296281..0e64d59ba 100644 --- a/packages/ecs/src/interface/registry/archetypes.ts +++ b/packages/ecs/src/interface/registry/archetypes.ts @@ -1,24 +1,59 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { labelOf } from './names.js'; -/** Where an archetype holds its program-wide index: a property no other code names. */ -const indexKey = Symbol("archetypeIndex"); +/** + * The `Symbol.for` names of the property holding a list's program-wide archetype index, and of the global count of + * indexes given, which code the plugin generates and every copy of the package share. Their version changes with the + * format. + */ +export const archetypeIndexName = "@adobe/ecs/archetype@1"; +export const archetypeCountName = "@adobe/ecs/archetypes@1"; -let next = 0; +/** + * The property a list holds its index in: a number for a list the plugin or the package indexed, which never changes; + * the world's record of a list made at runtime, which it checks against the list's keys (`world/lists.ts`). + */ +export const archetypeIndexKey: symbol = Symbol.for(archetypeIndexName); +const indexKey = archetypeIndexKey; +const countKey = Symbol.for(archetypeCountName); /** * Gives a list of components a program-wide index, once, after checking it names no component twice. Throws for a - * component listed twice. + * component listed twice. The package's own lists take it here; the plugin gives consumers' theirs. */ export const indexArchetype = (components: readonly unknown[]): void => { for (let k = 1; k < components.length; k++) { if (components.indexOf(components[k]) < k) throw new Error(`An archetype lists "${labelOf(components[k])}" twice.`); } - if (!Reflect.has(components, indexKey)) Object.defineProperty(components, indexKey, { value: next++ }); + if (typeof Reflect.get(components, indexKey) !== "number") Object.defineProperty(components, indexKey, { value: nextArchetypeIndex() }); +}; + +/** The next program-wide archetype index, from the count every copy of the package and generated code share. */ +export const nextArchetypeIndex = (): number => { + const count: unknown = Reflect.get(globalThis, countKey); + const next = typeof count === "number" ? count : 0; + Reflect.set(globalThis, countKey, next + 1); + return next; }; -/** The program-wide index `Archetype.create` gave a list, or -1 for a list it wasn't given. */ -export const archetypeIndexOf = (components: readonly unknown[]): number => { - const index: unknown = Reflect.get(components, indexKey); - return typeof index === "number" ? index : -1; +/** The keys of a list made at runtime, in its order, and the one program-wide index every list of them shares. */ +export type Interned = { readonly keys: readonly unknown[]; readonly index: number }; + +type Node = { interned: Interned | null; readonly next: Map }; + +const root: Node = { interned: null, next: new Map() }; + +/** + * The index lists made at runtime of `list`'s keys, in its order, share: given the first time, then found without + * allocating, so lists made on every call take one index between them. + */ +export const internArchetype = (list: readonly unknown[]): Interned => { + let node = root; + for (let k = 0; k < list.length; k++) { + const key = list[k]; + let next = node.next.get(key); + if (next === undefined) node.next.set(key, next = { interned: null, next: new Map() }); + node = next; + } + return node.interned ??= { keys: [...list], index: nextArchetypeIndex() }; }; diff --git a/packages/ecs/src/interface/registry/declarations.ts b/packages/ecs/src/interface/registry/declarations.ts index 2227946fd..65026ec81 100644 --- a/packages/ecs/src/interface/registry/declarations.ts +++ b/packages/ecs/src/interface/registry/declarations.ts @@ -1,18 +1,19 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { Schema } from '../schema/schema.js'; import type { Key as TraitKey } from '../trait/key.js'; +import { declarationsName } from './ids.js'; import { layoutOf } from './layout.js'; -import { claimKey, declarationOf, failRegistration, firstKeyOf, kindOf, labelOf, requireKey, sameDeclaration } from './names.js'; +import { claimKey, declarationOf, failRegistration, firstKeyOf, installCatchUp, kindOf, labelOf, recordFailure, requireKey, sameDeclaration } from './names.js'; import { isReduction } from './reduction.js'; -import { allowsNull, hasBoolean, isEntity, isString, isStruct } from './schemas.js'; -import { isTrait } from './traits.js'; +import { allowsNull, hasBoolean, isEntity, isString, isStruct, matchesSchema } from './schemas.js'; +import { addTrait, isTrait } from './traits.js'; /** * A registered declaration, as any consumer iterating components or resources sees it: its `type` and other traits. - * Without `type`, the value isn't data. A resource's `default`, or a derived object's, is what it starts at, when its - * schema has none. + * Without `type`, the value isn't data. `default` is what it starts at, never its schema's, which holds none; `fresh` + * starts it as an empty `Map` or `Set` of each world's own. */ -export type AnyDeclaration = { readonly type?: Schema; readonly default?: unknown } & { readonly [T in TraitKey]?: unknown }; +export type AnyDeclaration = { readonly type?: Schema; readonly default?: unknown; readonly fresh?: unknown } & { readonly [T in TraitKey]?: unknown }; /** The value schema of any registered component or resource, or undefined for a value that isn't data. */ export const schemaOf = (key: unknown): Schema | undefined => declarationOf(key)?.type; @@ -38,16 +39,35 @@ export const threadOfDeclaration = (declaration: AnyDeclaration): string | null return typeof declaration.thread === "string" ? declaration.thread : "world"; }; +/** Whether a schema, or any schema inside it, holds a `default`. */ +const holdsDefault = (schema: Schema): boolean => { + if ("default" in schema) return true; + if ("anyOf" in schema) return schema.anyOf.some(holdsDefault); + if ("allOf" in schema) return schema.allOf.some(holdsDefault); + if ("const" in schema) return false; + switch (schema.type) { + case "object": return Object.values(schema.properties).some(holdsDefault); + case "array": case "set": return holdsDefault(schema.items); + case "map": return holdsDefault(schema.keys) || holdsDefault(schema.values); + default: return false; + } +}; + /** Throws for a declaration whose traits can't hold, so far as values tell: types check the rest. */ const check = (kind: "component" | "resource", key: symbol, declaration: AnyDeclaration): void => { const fail = (message: string): never => failRegistration(kind, key, message); for (const trait of Reflect.ownKeys(declaration)) { - if (trait !== "default" && !isTrait(trait)) fail(`${labelOf(trait)} isn't a registered trait.`); + if (trait !== "default" && trait !== "fresh" && !isTrait(trait)) fail(`${labelOf(trait)} isn't a registered trait.`); } - const { type } = declaration; - if (type !== undefined && "default" in declaration && (kind === "component" || "default" in type)) { - fail(kind === "component" ? "its schema holds its default." : "its default is given twice, by its schema and its declaration."); + const { type, fresh } = declaration; + if (type !== undefined && holdsDefault(type)) fail("its schema holds a default, which a schema never does: its start is its declaration's default."); + if (fresh !== undefined) { + if (fresh !== "Map" && fresh !== "Set") fail(`fresh is "Map" or "Set".`); + if ("default" in declaration) fail("it starts fresh, so it has no default."); + if (type !== undefined && "type" in type && (type.type === "set" || type.type === "map") && (type.type === "set") !== (fresh === "Set")) fail(`a ${type.type} starts as a fresh ${type.type === "set" ? "Set" : "Map"}.`); } + // A start however the build found it, a literal, its type's namespace's or an identity, is one of its type's values. + if (type !== undefined && "default" in declaration && !matchesSchema(type, declaration.default)) fail(`its default, ${labelOf(declaration.default)}, isn't one of its type's values.`); try { layoutOf(type); } @@ -64,8 +84,10 @@ const check = (kind: "component" | "resource", key: symbol, declaration: AnyDecl cycle.push(first); } if (declaration.derived !== undefined && kind !== "component") fail("only a component is derived."); - if (declaration.derived === true && type !== undefined && !("default" in type || "const" in type || allowsNull(type))) { - fail("a derived component's type has a default, which it starts at."); + if (declaration.derived === true && declaration.within !== undefined) fail("a derived component's rule covers within."); + if (declaration.derived === true && declaration.reference !== undefined) fail("a rule can't set a derived component's target, so it's no reference."); + if (declaration.derived === true && type !== undefined && fresh === undefined && !("default" in declaration || "const" in type || allowsNull(type))) { + fail("a derived component has a default, which it starts at."); } if (declaration.reduce !== undefined) { const operator = declaration.reduce; @@ -94,10 +116,70 @@ const check = (kind: "component" | "resource", key: symbol, declaration: AnyDecl * Registers a declaration of `kind` under `key`, fixed from then on. The same declaration again is accepted, and so is * a new symbol with the id of a key of this kind, as a module evaluated again makes it. */ -export const createRegistry = (kind: "component" | "resource") => (key: unknown, declaration: AnyDeclaration): void => { +const createRegistry = (kind: "component" | "resource") => (key: unknown, declaration: AnyDeclaration): void => { const symbol = requireKey(kind, key); const existing = kindOf(symbol); if (existing === kind && sameDeclaration(declarationOf(symbol), declaration)) return; if (existing === undefined) check(kind, symbol, declaration); claimKey(symbol, kind, declaration); }; + +/** Registers a component: the package's own, and tests' keys made at runtime; the build declares every other. */ +export const registerComponent = createRegistry("component"); + +/** Registers a resource: the package's own, and tests' keys made at runtime; the build declares every other. */ +export const registerResource = createRegistry("resource"); + +/** The global map of what the build declared, made here when no generated code made it first. */ +const declared = ((): Map => { + const name = Symbol.for(declarationsName); + const found: unknown = Reflect.get(globalThis, name); + if (found instanceof Map) return found; + const made = new Map(); + Reflect.set(globalThis, name, made); + return made; +})(); + +/** Whether a value the generated code recorded is a declaration: an object of traits, as the build wrote it. */ +const isDeclaration = (value: unknown): value is AnyDeclaration => typeof value === "object" && value !== null; + +/** Registers one key the build declared, as its kind says. */ +const registerDeclared = (key: unknown, entry: unknown): void => { + const kind: unknown = typeof entry === "object" && entry !== null ? Reflect.get(entry, "kind") : undefined; + const declaration: unknown = typeof entry === "object" && entry !== null ? Reflect.get(entry, "declaration") : undefined; + if (kind === "trait") addTrait(key); + else if (kind === "component" && isDeclaration(declaration)) registerComponent(key, declaration); + else if (kind === "resource" && isDeclaration(declaration)) registerResource(key, declaration); +}; + +/** How many of the map's entries this copy registered. */ +let seen = 0; +let catching = false; + +/** + * Registers what the build declared since this last ran, in the order modules evaluated, as each module registered + * its keys when it was evaluated. A declaration that fails is recorded for its own key, whose every use fails giving + * it as the reason, and never for a read of another key. Reads of keys while it runs see those registered so far. + */ +const catchUp = (): void => { + if (catching || declared.size === seen) return; + catching = true; + try { + let index = 0; + for (const [key, entry] of declared) { + if (index++ < seen) continue; + seen++; + try { + registerDeclared(key, entry); + } + catch (error) { + if (typeof key === "symbol") recordFailure(key, error instanceof Error ? error.message : String(error)); + } + } + } + finally { + catching = false; + } +}; + +installCatchUp(catchUp); diff --git a/packages/ecs/src/interface/registry/declared.test.ts b/packages/ecs/src/interface/registry/declared.test.ts new file mode 100644 index 000000000..35697597a --- /dev/null +++ b/packages/ecs/src/interface/registry/declared.test.ts @@ -0,0 +1,40 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { describe, expect, it } from "vitest"; +import { identify, recordDeclaration } from "./ids.test-helper.js"; +import "./declarations.js"; +import { declarationOf, kindOf, sameKey, whyUnregistered } from "./names.js"; + +describe("declarations the build records", () => { + it("registers a key at its first use, as its kind says", () => { + const speed = Symbol("declaredTest/speed"); + const tag = Symbol("declaredTest/tag"); + recordDeclaration(speed, "resource", { type: { type: "number" }, default: 1 }); + recordDeclaration(tag, "trait"); + expect([kindOf(speed), kindOf(tag)]).toEqual(["resource", "trait"]); + expect(declarationOf(speed)).toEqual({ type: { type: "number" }, default: 1 }); + }); + it("registers in the order modules recorded them, so a trait comes before what declares it", () => { + const mark = Symbol("declaredTest/mark"); + const marked = Symbol("declaredTest/marked"); + recordDeclaration(mark, "trait"); + recordDeclaration(marked, "component", { type: { const: true }, [mark]: true }); + expect(kindOf(marked)).toBe("component"); + }); + it("takes another symbol of one id, as a module evaluated again records it, as the same key", () => { + const first = identify(Symbol("declaredTest/again"), "declaredTest/keys.ts#again"); + const again = identify(Symbol("declaredTest/again"), "declaredTest/keys.ts#again"); + recordDeclaration(first, "component", { type: { type: "number" } }); + recordDeclaration(again, "component", { type: { type: "number" } }); + expect([kindOf(again), sameKey(first, again)]).toEqual(["component", true]); + }); + it("never throws a declaration that fails for another key's use, and gives it as the reason its own key's uses fail", () => { + const late = Symbol("declaredTest/late"); + const fine = Symbol("declaredTest/fine"); + recordDeclaration(late, "component", { type: { type: "number" }, reduce: "+" }); + recordDeclaration(fine, "component", { type: { type: "number" } }); + expect([kindOf(fine), kindOf(late)]).toEqual(["component", undefined]); + expect(whyUnregistered(late)).toBe(`its declaration failed to register: Cannot register component "declaredTest/late": only a resource is reduced.`); + // Left valid, so any other copy of the registry reading the map later registers it. + recordDeclaration(late, "component", { type: { type: "number" } }); + }); +}); diff --git a/packages/ecs/src/interface/registry/finalizer-order.ts b/packages/ecs/src/interface/registry/finalizer-order.ts index e400dab6d..262859745 100644 --- a/packages/ecs/src/interface/registry/finalizer-order.ts +++ b/packages/ecs/src/interface/registry/finalizer-order.ts @@ -1,6 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { Finalizer } from '../finalizer/created.js'; -import { firstItemOf, labelOfItem } from './items.js'; +import { firstItemOf } from './items.js'; import { firstKeyOf, labelOf } from './names.js'; /** @@ -26,7 +26,7 @@ export const orderFinalizers = (runners: readonly Finalizer[]): Finalizer[] => { for (const other of successors[index]) if (--incoming[other] === 0) ready.push(other); } if (order.length < runners.length) { - const cycle = runners.filter((_, index) => !order.includes(index)).map(runner => `"${labelOfItem(runner)}"`).join(", "); + const cycle = runners.filter((_, index) => !order.includes(index)).map(runner => `"${runner.name}"`).join(", "); throw new Error(`Finalizers ${cycle} are ordered in a cycle.`); } const reaches = (from: number, to: number): boolean => { @@ -46,7 +46,7 @@ export const orderFinalizers = (runners: readonly Finalizer[]): Finalizer[] => { for (let a = 0; a < runners.length; a++) for (let b = a + 1; b < runners.length; b++) { const conflict = [...writes[a]].find(key => writes[b].has(key) || reads[b].has(key)) ?? [...writes[b]].find(key => reads[a].has(key)); if (conflict !== undefined && !reaches(a, b) && !reaches(b, a)) { - throw new Error(`Finalizers "${labelOfItem(runners[a])}" and "${labelOfItem(runners[b])}" both access "${labelOf(conflict)}", one writing it; order them with before or after.`); + throw new Error(`Finalizers "${runners[a].name}" and "${runners[b].name}" both access "${labelOf(conflict)}", one writing it; order them with before or after.`); } } return order.map(index => runners[index]); diff --git a/packages/ecs/src/interface/registry/ids.test-helper.ts b/packages/ecs/src/interface/registry/ids.test-helper.ts index 590bfc069..7d9bd9909 100644 --- a/packages/ecs/src/interface/registry/ids.test-helper.ts +++ b/packages/ecs/src/interface/registry/ids.test-helper.ts @@ -1,5 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { idsName } from './ids.js'; +import { declarationsName, idsName, accessName } from './ids.js'; /** * Gives a key or a declaration an id, as code the plugin generates does for a top-level `Symbol(...)` declaration or a @@ -13,3 +13,36 @@ export const identify = (value: T, id: string): T => Reflect.set(globalThis, name, map); return value; }; + +/** Records a key's declaration, as the function the plugin appends to a module declaring keys does. */ +export const recordDeclaration = (key: symbol, kind: "component" | "resource" | "trait", declaration?: object): void => { + const name = Symbol.for(declarationsName); + const existing: unknown = Reflect.get(globalThis, name); + const map = existing instanceof Map ? existing : new Map(); + map.set(key, { kind, declaration }); + Reflect.set(globalThis, name, map); +}; + +/** + * Gives a declaration, or the body of a system declaring nothing, what its body uses, as the plugin does where it sees + * the constructor's call: for calls it doesn't see. Each list names keys; what isn't given is used by nothing. + */ +export const giveAccess = (made: T, uses: { + readonly read?: readonly symbol[]; + readonly write?: readonly symbol[]; + readonly resources?: { readonly read?: readonly symbol[]; readonly write?: readonly symbol[]; readonly reduce?: readonly symbol[] }; + readonly lookup?: { readonly [name: string]: readonly symbol[] }; + readonly up?: readonly symbol[]; +} = {}): T => { + const name = Symbol.for(accessName); + let map: unknown = Reflect.get(globalThis, name); + if (!(map instanceof WeakMap)) Reflect.set(globalThis, name, map = new WeakMap()); + if (map instanceof WeakMap) map.set(made, { + components: { read: uses.read ?? [], write: uses.write ?? [] }, + resources: { read: uses.resources?.read ?? [], write: uses.resources?.write ?? [], reduce: uses.resources?.reduce ?? [] }, + lookup: uses.lookup ?? {}, + up: uses.up ?? [], + at: "a test", + }); + return made; +}; diff --git a/packages/ecs/src/interface/registry/ids.test.ts b/packages/ecs/src/interface/registry/ids.test.ts index af81d82da..4b1d2313f 100644 --- a/packages/ecs/src/interface/registry/ids.test.ts +++ b/packages/ecs/src/interface/registry/ids.test.ts @@ -1,12 +1,11 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; -import { Component } from "../component/component.js"; import { Finalizer } from "../finalizer/finalizer.js"; -import { Resource } from "../resource/resource.js"; import { Rule } from "../rule/rule.js"; import { System } from "../system/system.js"; -import { Trait } from "../trait/trait.js"; -import { identify } from "./ids.test-helper.js"; +import { registerComponent, registerResource } from "./declarations.js"; +import { addTrait } from "./traits.js"; +import { giveAccess, identify } from "./ids.test-helper.js"; import { declarationOf, kindOf, labelOf, programFinalizers, programRules, requireDefinition, sameItem, scheduleOf } from "./index.js"; // Each pair is one module's key and the key that module makes when evaluated again: one id, two symbols. @@ -38,88 +37,75 @@ identify(crossedAgain, "idsTest/keys.ts#crossed"); identify(tagged, "idsTest/keys.ts#tagged"); identify(taggedAgain, "idsTest/keys.ts#tagged"); -declare module "@adobe/ecs" { - export interface Components { - [score]: { type: number }; - [scoreAgain]: { type: number }; - [shown]: { type: true; derived: true }; - [shownAgain]: { type: true; derived: true }; - [changed]: { type: number }; - [changedAgain]: { type: string }; - [crossed]: { type: number }; - [unnamed]: { type: number }; - } - export interface Resources { - [level]: { type: number }; - [levelAgain]: { type: number }; - [crossedAgain]: { type: number }; - } - export interface Traits { - readonly [tagged]: true; - readonly [taggedAgain]: true; - } -} +// The keys' ids are given by hand, after the keys are made, so they're registered here rather than declared, which +// registers a key as soon as it's made. +registerComponent(score, { type: { type: "number" } }); +registerComponent(shown, { type: { const: true }, derived: true }); +registerResource(level, { type: { type: "number" }, default: 0 }); +registerComponent(changed, { type: { type: "number" } }); +registerComponent(crossed, { type: { type: "number" } }); +addTrait(tagged); -Component.register(score, { type: { type: "number" } }); -Component.register(shown, { type: { const: true }, derived: true }); -Resource.register(level, { type: { type: "number", default: 0 } }); -Component.register(changed, { type: { type: "number" } }); -Component.register(crossed, { type: { type: "number" } }); -Trait.register(tagged); +/** Calls a constructor as an untyped caller would, naming keys no declaration types. */ +const untyped = unknown>(create: F) => (...parameters: unknown[]): ReturnType => Reflect.apply(create, undefined, parameters); +const system = untyped(System.create); +const rule = untyped(Rule.create); +const finalizer = untyped(Finalizer.create); +type Row = { [key: symbol]: number }; describe("keys with ids", () => { - it("labels a key by its description, and by its id once another registered key has its description", () => { + it("labels a key by its description, however many keys share it", () => { expect(labelOf(score)).toBe("score"); - Component.register(unnamed, { type: { type: "number" } }); - expect([labelOf(score), labelOf(unnamed)]).toEqual(["idsTest/keys.ts#score", "idsTest/other.ts#score"]); + registerComponent(unnamed, { type: { type: "number" } }); + expect([labelOf(score), labelOf(unnamed)]).toEqual(["score", "score"]); expect(labelOf(Symbol("score"))).toBe("score"); }); it("accepts another symbol under a registered key's id with the same declaration, as the same key", () => { - Component.register(scoreAgain, { type: { type: "number" } }); - Component.register(shownAgain, { type: { const: true }, derived: true }); - Resource.register(levelAgain, { type: { type: "number", default: 0 } }); - Trait.register(taggedAgain); + registerComponent(scoreAgain, { type: { type: "number" } }); + registerComponent(shownAgain, { type: { const: true }, derived: true }); + registerResource(levelAgain, { type: { type: "number" }, default: 0 }); + addTrait(taggedAgain); expect([kindOf(scoreAgain), kindOf(levelAgain), kindOf(taggedAgain)]).toEqual(["component", "resource", "trait"]); expect(declarationOf(scoreAgain)).toBe(declarationOf(score)); - expect(labelOf(scoreAgain)).toBe("idsTest/keys.ts#score"); + expect(labelOf(scoreAgain)).toBe("score"); }); it("rejects another symbol under a registered key's id with another declaration, or as another kind", () => { - expect(() => Component.register(changedAgain, { type: { type: "string" } })).toThrow( - `Cannot register component "idsTest/keys.ts#changed": its id is another key's, with another declaration; if a module was evaluated again with a changed declaration, reload the page.`); - expect(() => Resource.register(crossedAgain, { type: { type: "number", default: 0 } })).toThrow( - `Cannot register resource "idsTest/keys.ts#crossed": its id is a component's.`); + expect(() => registerComponent(changedAgain, { type: { type: "string" } })).toThrow( + `Cannot register component "changed" (idsTest/keys.ts#changed): its id is another key's, with another declaration; if a module was evaluated again with a changed declaration, reload the page.`); + expect(() => registerResource(crossedAgain, { type: { type: "number" }, default: 0 })).toThrow( + `Cannot register resource "crossed" (idsTest/keys.ts#crossed): its id is a component's.`); expect([kindOf(changedAgain), kindOf(crossedAgain)]).toEqual([undefined, undefined]); }); it("keeps keys of different ids apart, though their descriptions match", () => { - Component.register(unnamed, { type: { type: "number" } }); - System.create(identify({ name: "idsTest/unnamed", components: { write: [score] } }, "idsTest/systems.ts#unnamed"), row => { row[score] = 1; }); - expect(() => System.create(identify({ name: "idsTest/unnamed", components: { write: [unnamed] } }, "idsTest/systems.ts#unnamed"), row => { row[unnamed] = 1; })).toThrow("reload the page"); + registerComponent(unnamed, { type: { type: "number" } }); + system(giveAccess(identify({ components: { with: [score] } }, "idsTest/systems.ts#unnamed"), { write: [score] }), (row: Row) => { row[score] = 1; }); + expect(() => system(giveAccess(identify({ components: { with: [unnamed] } }, "idsTest/systems.ts#unnamed"), { write: [unnamed] }), (row: Row) => { row[unnamed] = 1; })).toThrow("reload the page"); }); }); describe("declarations evaluated again", () => { it("accept a system whose keys match by id, as a new system", () => { - const first = System.create(identify({ name: "idsTest/system", components: { write: [score] }, resources: { read: [level] } }, "idsTest/systems.ts#system"), (row, { resources }) => { row[score] = resources[level]; }); - const again = System.create(identify({ name: "idsTest/system", components: { write: [scoreAgain] }, resources: { read: [levelAgain] } }, "idsTest/systems.ts#system"), (row, { resources }) => { row[scoreAgain] = resources[levelAgain]; }); + const first = system(giveAccess(identify({ components: { with: [score] } }, "idsTest/systems.ts#system"), { write: [score], resources: { read: [level] } }), (row: Row, { resources }: { resources: Row }) => { row[score] = resources[level] ?? 0; }); + const again = system(giveAccess(identify({ components: { with: [scoreAgain] } }, "idsTest/systems.ts#system"), { write: [scoreAgain], resources: { read: [levelAgain] } }), (row: Row, { resources }: { resources: Row }) => { row[scoreAgain] = resources[levelAgain] ?? 0; }); expect(again).not.toBe(first); expect(sameItem(again, first)).toBe(true); }); it("accept a rule whose keys match by id, which is already recorded", () => { - const rule = Rule.create(identify({ name: "idsTest/rule", components: { has: [score] }, derive: [shown] }, "idsTest/systems.ts#rule")); + const created = rule(identify({ components: { with: [score] }, derive: [shown] }, "idsTest/systems.ts#rule")); const rules = programRules().length; - expect(Rule.create(identify({ name: "idsTest/rule", components: { has: [scoreAgain] }, derive: [shownAgain] }, "idsTest/systems.ts#rule"))).toBe(rule); + expect(rule(identify({ components: { with: [scoreAgain] }, derive: [shownAgain] }, "idsTest/systems.ts#rule"))).toBe(created); expect(programRules()).toHaveLength(rules); }); it("accept a finalizer whose keys match by id, keeping its place with the new body", () => { - const first = Finalizer.create(identify({ name: "idsTest/finalizer", components: { read: [score] } }, "idsTest/systems.ts#finalizer"), () => {}); + const first = finalizer(giveAccess(identify({ components: { with: [score] } }, "idsTest/systems.ts#finalizer")), () => {}); const body = (): void => {}; - expect(Finalizer.create(identify({ name: "idsTest/finalizer", components: { read: [scoreAgain] } }, "idsTest/systems.ts#finalizer"), body)).toBe(first); - const found = programFinalizers().find(finalizer => finalizer.name === "idsTest/finalizer"); + expect(finalizer(giveAccess(identify({ components: { with: [scoreAgain] } }, "idsTest/systems.ts#finalizer")), body)).toBe(first); + const found = programFinalizers().find(finalizer => finalizer.name === "finalizer"); expect(found?.kind === "finalizer" ? found.body : null).toBe(body); }); it("schedule symbols of one id as one key", () => { - const write = System.create({ name: "idsTest/write", components: { write: [score] } }, row => { row[score] = 1; }); - const read = System.create({ name: "idsTest/read", components: { read: [scoreAgain] } }, row => { void row[scoreAgain]; }); - expect(() => scheduleOf(requireDefinition(System.create({ name: "idsTest/unordered", systems: [write, read] })))).toThrow("both access"); + const write = system(giveAccess({ components: { with: [score] } }, { write: [score] }), (row: Row) => { row[score] = 1; }); + const read = system(giveAccess({ components: { with: [scoreAgain] } }, { read: [scoreAgain] }), (row: Row) => { void row[scoreAgain]; }); + expect(() => scheduleOf(requireDefinition(system({ systems: [write, read] })))).toThrow("both access"); }); }); diff --git a/packages/ecs/src/interface/registry/ids.ts b/packages/ecs/src/interface/registry/ids.ts index 0c2cafe68..f95644a4f 100644 --- a/packages/ecs/src/interface/registry/ids.ts +++ b/packages/ecs/src/interface/registry/ids.ts @@ -7,6 +7,13 @@ export const idsName = "@adobe/ecs/ids@1"; const ids = Symbol.for(idsName); +/** + * The `Symbol.for` name of the global `Map` code the plugin generates fills, from each key a module declares to its + * kind and declaration, `{ kind, declaration }`, in the order modules evaluate. Every copy of the package reads it and + * none changes it. Its version changes with the format. + */ +export const declarationsName = "@adobe/ecs/declarations@1"; + const idsMap = (): Map | undefined => { const map: unknown = Reflect.get(globalThis, ids); return map instanceof Map ? map : undefined; @@ -29,3 +36,41 @@ export const takeIdOfDeclaration = (declaration: object): string | undefined => map?.delete(declaration); return typeof id === "string" ? id : undefined; }; + +/** + * The `Symbol.for` name of the global `WeakMap` from a declaration to the label the plugin gave a constructor call that + * isn't a top-level binding's, by the binding or function it's in, or an internal factory gave its declaration. Weak, + * so a declaration whose constructor threw before taking its label isn't kept. + */ +export const labelsName = "@adobe/ecs/labels@1"; + +const labels = Symbol.for(labelsName); + +/** The label given the declaration a constructor call passes, if any, taken out of the map. */ +export const takeLabelOfDeclaration = (declaration: object): string | undefined => { + const map: unknown = Reflect.get(globalThis, labels); + if (!(map instanceof WeakMap || map instanceof Map)) return undefined; + const label: unknown = map.get(declaration); + if (label === undefined) return undefined; + map.delete(declaration); + return typeof label === "string" ? label : undefined; +}; + +/** + * The `Symbol.for` name of the global `WeakMap` from a declaration, or the body of a system declaring nothing, to what + * the build inferred its body uses (`Access` in `system/resolve.ts`), which the plugin fills where it calls a + * constructor; an internal factory making declarations at runtime gives its own first, which the plugin's doesn't + * replace. Its version changes with the format. + */ +export const accessName = "@adobe/ecs/access@1"; + +const access = Symbol.for(accessName); + +/** What the build inferred the body a declaration or body is made with uses, if it inferred it, taken out of the map. */ +export const takeAccessOf = (declaration: object): unknown => { + const map: unknown = Reflect.get(globalThis, access); + if (!(map instanceof WeakMap)) return undefined; + const found: unknown = map.get(declaration); + map.delete(declaration); + return found; +}; diff --git a/packages/ecs/src/interface/registry/index.ts b/packages/ecs/src/interface/registry/index.ts index 48b62f2aa..c4a194016 100644 --- a/packages/ecs/src/interface/registry/index.ts +++ b/packages/ecs/src/interface/registry/index.ts @@ -3,7 +3,7 @@ export type { AnyDeclaration } from './declarations.js'; export { schemaOf, threadOfDeclaration } from './declarations.js'; export type { Kind } from './names.js'; -export { declarationOf, firstKeyOf, keyOfId, kindOf, labelOf, newestKeyOf } from './names.js'; +export { declarationOf, firstKeyOf, keyOfId, kindOf, labelOf, newestKeyOf, whyUnregistered } from './names.js'; export type { Item } from './program.js'; export { definitionOf, programFinalizers, programRules, programVersion, requireDefinition } from './program.js'; export type { Schedule } from './schedule.js'; @@ -16,7 +16,7 @@ export { isTrait } from './traits.js'; export { idOfKey } from './ids.js'; export { isWorldWritten } from './written.js'; export { sameDeclaration } from './names.js'; -export { firstItemOf, identifiedItems, idOfItem, itemOfId, labelOfItem, sameItem } from './items.js'; +export { firstItemOf, identifiedItems, idOfItem, idOrNull, itemOfId, sameItem } from './items.js'; export { allowsNull, conditional, isStruct } from './schemas.js'; export { isReduction, reductionOperators } from './reduction.js'; export type { Definition } from '../system/definition.js'; @@ -24,7 +24,7 @@ export type { Leaf } from '../system/leaf.js'; export type { Composite } from '../system/composite.js'; export type { Rule } from '../rule/created.js'; export type { Finalizer } from '../finalizer/created.js'; -export { archetypeIndexOf } from './archetypes.js'; +export { archetypeIndexKey, internArchetype, type Interned } from './archetypes.js'; export { indexDefinitionOf, indexOfLookup, lookupKeysOf, orderKeysOf, watchedBy, watchesEntries } from './indexes.js'; export type { Created as IndexDefinition } from '../indexes/created.js'; export type { Declaration as IndexDeclaration } from '../indexes/declaration.js'; diff --git a/packages/ecs/src/interface/registry/indexes.ts b/packages/ecs/src/interface/registry/indexes.ts index 7a040a98e..be05509ef 100644 --- a/packages/ecs/src/interface/registry/indexes.ts +++ b/packages/ecs/src/interface/registry/indexes.ts @@ -1,6 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { Created } from '../indexes/created.js'; -import { labelOfItem, recordItem, registeredAs } from './items.js'; +import { recordItem, registeredAs } from './items.js'; import { firstKeyOf, labelOf, sameDeclaration } from './names.js'; /** @@ -63,7 +63,7 @@ export const checkLookup = (what: "System" | "Finalizer", name: string, lookupNa const held = new Set([...index.declaration.key, ...index.declaration.components?.has ?? []].map(firstKeyOf)); const check = (verb: string, keys: readonly Key[] | undefined): void => { for (const key of keys ?? []) { - if (!held.has(firstKeyOf(key))) throw new Error(`${what} "${name}": lookup "${lookupName}" ${verb} "${labelOf(key)}", which not every entity index "${labelOfItem(index)}" holds has; name its key parts or its has.`); + if (!held.has(firstKeyOf(key))) throw new Error(`${what} "${name}": lookup "${lookupName}" ${verb} "${labelOf(key)}", which not every entity index "${index.name}" holds has; name its key parts or its has.`); } }; check("reads", lookup.read); diff --git a/packages/ecs/src/interface/registry/items.ts b/packages/ecs/src/interface/registry/items.ts index 2a4bed700..9ec4d4ebe 100644 --- a/packages/ecs/src/interface/registry/items.ts +++ b/packages/ecs/src/interface/registry/items.ts @@ -1,18 +1,17 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. +import { takeLabelOfDeclaration } from './ids.js'; /** A registered system, rule or finalizer: what each constructor records, told apart by `kind`. */ type Registered = { readonly kind: string; readonly name: string; readonly declaration: object }; /** - * A registered item: its kind and declaration, those of the first value registered under its id, its name, its id if - * the plugin gave one, and the label it has without one, numbered when another item had its name; shared by every - * value registered under its id. `first` is the value registered first, which stands for it wherever items compare. + * A registered item: its kind and declaration, those of the first value registered under its id, and its id if the + * plugin gave one; shared by every value registered under its id. `first` is the value registered first, which stands + * for it wherever items compare. */ type Entry = { readonly kind: string; readonly declaration: object; - readonly name: string; readonly id: string | undefined; - readonly numbered: string; readonly first: Registered; }; @@ -22,12 +21,6 @@ const entries = new WeakMap(); /** Entries by the ids the plugin gave their declarations. */ const identified = new Map(); -/** Numbered labels given, so each item's is unique. */ -const labels = new Set(); - -/** How many registered items have each name. */ -const named = new Map(); - /** Whether two values are one item: the same value, or values registered under one id. */ export const sameItem = (a: object, b: object): boolean => { if (a === b) return true; @@ -38,17 +31,6 @@ export const sameItem = (a: object, b: object): boolean => { /** The value first registered as this item, which stands for it wherever items compare; any other value as it is. */ export const firstItemOf = (item: object): object => entries.get(item)?.first ?? item; -/** - * How errors and reports name a system, rule or finalizer: its name while no other registered item has it, else its - * name with its id when the plugin gave one, and without one its name numbered by registration order. An item not - * registered is named by its name. - */ -export const labelOfItem = (item: { readonly name: string }): string => { - const entry = entries.get(item); - if (entry === undefined || (named.get(entry.name) ?? 0) <= 1) return item.name; - return entry.id === undefined ? entry.numbered : `${entry.name} (${entry.id})`; -}; - /** * The item already registered under `id` that `item` is: one of its kind with the same declaration, as a module * evaluated again declares it; undefined without an id or for an id not registered. Throws for an id registered as @@ -65,22 +47,6 @@ export const registeredAs = (item: T, id: string | undefin return entry.first; }; -/** A label for an item registered now without an id, its name numbered when another item's has it. */ -const numberedFor = (name: string): string => { - let label = name; - for (let n = 2; labels.has(label); n++) label = `${name}#${n}`; - labels.add(label); - return label; -}; - -/** An item without an id is forgotten once collected: its name's count and numbered label go with it. */ -const forgotten = new FinalizationRegistry<{ readonly name: string; readonly numbered: string }>(({ name, numbered }) => { - labels.delete(numbered); - const count = (named.get(name) ?? 0) - 1; - if (count > 0) named.set(name, count); - else named.delete(name); -}); - /** * Records an item under its id: a value of an item `registeredAs` found shares its entry, and any other is a new item. */ @@ -90,28 +56,31 @@ export const recordItem = (item: Registered, id: string | undefined, existing: o entries.set(item, shared); return; } - const entry: Entry = { kind: item.kind, declaration: item.declaration, name: item.name, id, numbered: numberedFor(item.name), first: item }; - named.set(item.name, (named.get(item.name) ?? 0) + 1); + const entry: Entry = { kind: item.kind, declaration: item.declaration, id, first: item }; if (id !== undefined) identified.set(id, entry); - else forgotten.register(item, { name: item.name, numbered: entry.numbered }); entries.set(item, entry); }; -/** How many items of each kind were declared without a name or an id, to number their labels. */ +/** How many items of each kind were labeled by number, to number the next. */ const anonymous = new Map(); /** - * An item's name: the one its declaration gives; without one, its binding with the id the plugin gave it - * (`spawn (src/setup.ts#spawn)`), and without an id, its kind numbered by creation (`system #3`). + * An item's label: the one the plugin gave its constructor call's first argument, `value`, by the binding or function + * it's in, or an internal factory gave it; else its binding, from its id (`spawn` for `src/setup.ts#spawn`); else its + * kind numbered by creation (`system #3`). */ -export const nameOf = (name: string | undefined, id: string | undefined, kind: "system" | "rule" | "finalizer" | "index"): string => { - if (name !== undefined) return name; - if (id !== undefined) return `${id.slice(id.lastIndexOf("#") + 1)} (${id})`; +export const nameOf = (value: object, id: string | undefined, kind: "system" | "rule" | "finalizer" | "index"): string => { + const label = takeLabelOfDeclaration(value); + if (label !== undefined) return label; + if (id !== undefined) return id.slice(id.lastIndexOf("#") + 1); const count = (anonymous.get(kind) ?? 0) + 1; anonymous.set(kind, count); return `${kind} #${count}`; }; +/** The id the plugin gave an item, or null without one, as reports give it beside its label. */ +export const idOrNull = (item: object): string | null => entries.get(item)?.id ?? null; + /** The id the plugin gave a system, rule or finalizer, by which other threads name it, if it gave one. */ export const idOfItem = (item: object): string | undefined => entries.get(item)?.id; diff --git a/packages/ecs/src/interface/registry/layout.test.ts b/packages/ecs/src/interface/registry/layout.test.ts index 5b4191311..fa798865e 100644 --- a/packages/ecs/src/interface/registry/layout.test.ts +++ b/packages/ecs/src/interface/registry/layout.test.ts @@ -35,6 +35,25 @@ describe("layoutOf", () => { expect(placement({ type: "object", properties: { flag: { type: "boolean" }, weight: { type: "number" }, mass: f32 } })).toEqual({ stride: 24, offsets: [0, 8, 16] }); }); + it("stores a union of number constants as a number column: integers at the narrowest width holding them", () => { + const views = (schema: Schema) => { + const layout = layoutOf(schema); + return layout.kind === "linear" ? layout.leaves.map(leaf => leaf.view) : layout.kind; + }; + expect(views({ anyOf: [{ const: 0 }, { const: 1 }, { const: 2 }] })).toEqual(["u32"]); + expect(views({ anyOf: [{ const: 1 }, { const: -1 }] })).toEqual(["i32"]); + expect(views({ anyOf: [{ const: 0.5 }, { const: 1 }] })).toEqual(["f64"]); + expect(views({ anyOf: [{ const: 0 }, { const: 2 ** 40 }] })).toEqual(["f64"]); + // An integer column would store -0 as 0. + expect(views({ anyOf: [{ const: -0 }, { const: 1 }] })).toEqual(["f64"]); + expect(views({ type: "object", properties: { mode: { anyOf: [{ const: 0 }, { const: 1 }] }, at: f32 } })).toEqual(["u32", "f32"]); + expect(views({ anyOf: [{ const: 0 }, { const: "a" }] })).toBe("object"); + expect(views({ anyOf: [{ const: 0 }, { type: "null" }] })).toBe("object"); + // A struct, so it lives in shared memory, on no thread. + expect(isStruct({ anyOf: [{ const: 0 }, { const: 1 }] })).toBe(true); + expect(threadOfDeclaration({ type: { anyOf: [{ const: 0 }, { const: 1 }] } })).toBe(null); + }); + it("stores sets and maps as objects, even of numbers, and nothing in them as a struct", () => { const set: Schema = { type: "set", items: { type: "number" } }; const map: Schema = { type: "map", keys: { type: "integer", minimum: 0, maximum: 9 }, values: { type: "boolean" } }; diff --git a/packages/ecs/src/interface/registry/layout.ts b/packages/ecs/src/interface/registry/layout.ts index ac995bec2..a9dc33a90 100644 --- a/packages/ecs/src/interface/registry/layout.ts +++ b/packages/ecs/src/interface/registry/layout.ts @@ -1,6 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { Schema } from '../schema/schema.js'; -import { isString } from './schemas.js'; +import { isString, numberConstants } from './schemas.js'; /** A path from a value to one scalar leaf: `[]` for a scalar, `[1]` for a tuple element, `["x"]` for a struct field. */ export type Path = readonly (number | string)[]; @@ -83,6 +83,12 @@ const integerScalar = ({ minimum, maximum }: { readonly minimum?: number; readon /** `null` if the schema isn't linear: numbers, booleans, fixed-length tuples and structs of those. */ const shapeOf = (schema: Schema): Shape | null => { + // A union of number constants is a number: integers at the narrowest width holding them, and with -0, which an + // integer can't hold, f64. + const constants = numberConstants(schema); + if (constants !== null) { + return { kind: "scalar", scalar: constants.every(constant => Number.isInteger(constant) && !Object.is(constant, -0)) ? integerScalar({ minimum: Math.min(...constants), maximum: Math.max(...constants) }) : "f64" }; + } if (!("type" in schema)) return null; switch (schema.type) { case "number": return { kind: "scalar", scalar: schema.precision === 1 ? "f32" : "f64" }; diff --git a/packages/ecs/src/interface/registry/names.test.ts b/packages/ecs/src/interface/registry/names.test.ts index c3ec8e159..fd5ad73d2 100644 --- a/packages/ecs/src/interface/registry/names.test.ts +++ b/packages/ecs/src/interface/registry/names.test.ts @@ -42,35 +42,32 @@ describe("claimKey", () => { }); describe("labelOf", () => { - it("labels a key by its description while no other registered key has it, else by its id", () => { + it("labels a key by its description, whatever other keys have it", () => { const first = Symbol("labelOf/shared"); const second = Symbol("labelOf/shared"); - const lone = Symbol("labelOf/lone"); identify(first, "a.ts#first"); identify(second, "b.ts#second"); - identify(lone, "c.ts#lone"); - expect([labelOf(first), labelOf(lone)]).toEqual(["labelOf/shared", "labelOf/lone"]); claimKey(first, "component", number); - claimKey(lone, "component", number); - expect([labelOf(first), labelOf(second), labelOf(lone)]).toEqual(["labelOf/shared", "b.ts#second", "labelOf/lone"]); claimKey(second, "resource", number); - expect([labelOf(first), labelOf(second), labelOf(lone)]).toEqual(["a.ts#first", "b.ts#second", "labelOf/lone"]); + expect([labelOf(first), labelOf(second)]).toEqual(["labelOf/shared", "labelOf/shared"]); + const twin = Symbol("labelOf/shared"); + claimKey(twin, "trait"); + expect(labelOf(twin)).toBe("labelOf/shared"); }); - it("labels a key without an id by its description, numbered by registration when another key has it", () => { - const first = Symbol("labelOf/twin"); - const second = Symbol("labelOf/twin"); - const third = Symbol("labelOf/twin"); - expect(labelOf(first)).toBe("labelOf/twin"); - claimKey(second, "component", { type: { type: "number" } }); - claimKey(first, "component", { type: { type: "number" } }); - claimKey(third, "trait"); - expect([labelOf(first), labelOf(second), labelOf(third)]).toEqual(["labelOf/twin#2", "labelOf/twin", "labelOf/twin#3"]); + it("labels a key without a description by its binding, which the plugin's id names", () => { + const speed = Symbol(); + const other = Symbol(); + identify(speed, "app@1.0.0/keys.ts#labelOfSpeed"); + identify(other, "app@1.0.0/other.ts#labelOfSpeed"); + claimKey(speed, "resource", number); + claimKey(other, "component", number); + expect([labelOf(speed), labelOf(other)]).toEqual(["labelOfSpeed", "labelOfSpeed"]); }); - it("gives a key without a description a numbered label, and names other values as text", () => { + it("gives a key without a description or an id a numbered label, and names other values as text", () => { const anonymous = Symbol(); expect(labelOf(anonymous)).toBe("Symbol()"); claimKey(anonymous, "trait"); - expect(labelOf(anonymous)).toMatch(/^Symbol\(\)(#\d+)?$/); + expect(labelOf(anonymous)).toBe("Symbol()"); expect([labelOf("text"), labelOf(1)]).toEqual(["text", "1"]); }); }); diff --git a/packages/ecs/src/interface/registry/names.ts b/packages/ecs/src/interface/registry/names.ts index a6c2be9d9..1d3a315f5 100644 --- a/packages/ecs/src/interface/registry/names.ts +++ b/packages/ecs/src/interface/registry/names.ts @@ -7,16 +7,14 @@ import { sameItem } from './items.js'; export type Kind = "component" | "resource" | "trait"; /** - * A registered key: its kind, its declaration (none for a trait), its description, its id if the plugin gave one, and - * the label it has without one, numbered when another key had its description; shared by every symbol registered under - * its id. `first` is the symbol registered first, `newest` the one registered last. + * A registered key: its kind, its declaration (none for a trait), its label and its id if the plugin gave one; shared + * by every symbol registered under its id. `first` is the symbol registered first, `newest` the one registered last. */ type Entry = { readonly kind: Kind; readonly declaration: AnyDeclaration | undefined; readonly description: string; readonly id: string | undefined; - readonly numbered: string; readonly first: symbol; newest: symbol; }; @@ -27,14 +25,13 @@ const entries = new Map(); /** Entries by the ids the plugin gave their keys. */ const identified = new Map(); -/** Numbered labels given, so each key's is unique. */ -const labels = new Set(); - -/** How many registered keys have each description. */ -const described = new Map(); - -/** A symbol's description, `Symbol()` without one. */ -const descriptionOf = (key: symbol): string => key.description === undefined || key.description === "" ? "Symbol()" : key.description; +/** A symbol's description; without one, its binding, which the plugin's id names, or `Symbol()` without an id. */ +const descriptionOf = (key: symbol): string => { + if (key.description !== undefined && key.description !== "") return key.description; + const id = idOfKey(key); + const binding = id === undefined ? "" : id.slice(id.lastIndexOf("#") + 1); + return binding === "" ? "Symbol()" : binding; +}; /** `Symbol.iterator` and the rest, which the platform shares, so they can't be keys. */ const wellKnown: ReadonlyMap = new Map(Reflect.ownKeys(Symbol).flatMap((name): [symbol, string][] => { @@ -42,52 +39,60 @@ const wellKnown: ReadonlyMap = new Map(Reflect.ownKeys(Symbol).f return typeof value === "symbol" ? [[value, `Symbol.${String(name)}`]] : []; })); +/** Registers what the build declared since it last ran; the registry's declarations install it. */ +let catchUp = (): void => {}; + +/** Installs what registers the build's declarations, which every read of keys runs first. */ +export const installCatchUp = (update: () => void): void => { + catchUp = update; +}; + +/** Why each key the build declared failed to register, for the errors its uses give. */ +const failures = new Map(); + +/** Records why a key the build declared failed to register. */ +export const recordFailure = (key: symbol, message: string): void => { + failures.set(key, message); +}; + +/** A key's entry, once what the build declared is registered. */ +const entryOf = (key: symbol): Entry | undefined => { + catchUp(); + return entries.get(key); +}; + /** Whether a key is a component, a resource or a trait. */ -export const kindOf = (key: unknown): Kind | undefined => typeof key === "symbol" ? entries.get(key)?.kind : undefined; +export const kindOf = (key: unknown): Kind | undefined => typeof key === "symbol" ? entryOf(key)?.kind : undefined; /** The declaration of a registered component or resource. */ -export const declarationOf = (key: unknown): AnyDeclaration | undefined => typeof key === "symbol" ? entries.get(key)?.declaration : undefined; +export const declarationOf = (key: unknown): AnyDeclaration | undefined => typeof key === "symbol" ? entryOf(key)?.declaration : undefined; /** Whether two values are one key: the same value, or symbols registered under one id. */ export const sameKey = (a: unknown, b: unknown): boolean => { if (a === b) return true; if (typeof a !== "symbol" || typeof b !== "symbol") return false; - const entry = entries.get(a); + const entry = entryOf(a); return entry !== undefined && entry === entries.get(b); }; /** The symbol first registered as this key, which stands for it wherever keys are compared; any other value as it is. */ -export const firstKeyOf = (key: K): K | symbol => typeof key === "symbol" ? entries.get(key)?.first ?? key : key; +export const firstKeyOf = (key: K): K | symbol => typeof key === "symbol" ? entryOf(key)?.first ?? key : key; /** The symbol last registered as this key, by which a world gives values; any other value as it is. */ -export const newestKeyOf = (key: K): K | symbol => typeof key === "symbol" ? entries.get(key)?.newest ?? key : key; +export const newestKeyOf = (key: K): K | symbol => typeof key === "symbol" ? entryOf(key)?.newest ?? key : key; /** - * How errors and reports name a key: its description, `Symbol()` without one, while no other registered key has it; - * else its id when the plugin gave it one, and without one, for a registered key, its description numbered by - * registration order. Any other value is named as text. + * How errors and reports name a key: its description, or without one its binding from its id, whatever other keys + * have; for display only, with its id (`idOfKey`) to tell keys of one label apart. Any other value is named as text. */ export const labelOf = (key: unknown): string => { if (typeof key !== "symbol") return String(key); - const known = wellKnown.get(key); - if (known !== undefined) return known; - const entry = entries.get(key); - const description = entry?.description ?? descriptionOf(key); - if ((described.get(description) ?? 0) <= (entry === undefined ? 0 : 1)) return description; - return entry === undefined ? idOfKey(key) ?? description : entry.id ?? entry.numbered; + return wellKnown.get(key) ?? entries.get(key)?.description ?? descriptionOf(key); }; -/** A label for a key registered now without an id, its description numbered when another key's has it. */ -const numberedFor = (description: string): string => { - let label = description; - for (let n = 2; labels.has(label); n++) label = `${description}#${n}`; - labels.add(label); - return label; -}; - -/** Throws, naming the key, for a key registration can't take. */ -export const failRegistration = (kind: Kind, key: unknown, message: string): never => { - throw new Error(`Cannot register ${kind} "${labelOf(key)}": ${message}`); +/** Throws, naming the key by its label, and by `id` too where the failure is its id's, for a key registration can't take. */ +export const failRegistration = (kind: Kind, key: unknown, message: string, id?: string): never => { + throw new Error(`Cannot register ${kind} "${labelOf(key)}"${id === undefined ? "" : ` (${id})`}: ${message}`); }; /** The key, or a throw for one that isn't a plain `Symbol(...)`: a `Symbol.for` or well-known symbol is shared. */ @@ -136,20 +141,34 @@ export const claimKey = (key: unknown, kind: Kind, declaration?: AnyDeclaration) const id = idOfKey(symbol); const named = id === undefined ? undefined : identified.get(id); if (named !== undefined) { - if (named.kind !== kind) failRegistration(kind, symbol, `its id is a ${named.kind}'s.`); + if (named.kind !== kind) failRegistration(kind, symbol, `its id is a ${named.kind}'s.`, id); if (!sameDeclaration(named.declaration, declaration)) { - failRegistration(kind, symbol, "its id is another key's, with another declaration; if a module was evaluated again with a changed declaration, reload the page."); + failRegistration(kind, symbol, "its id is another key's, with another declaration; if a module was evaluated again with a changed declaration, reload the page.", id); } named.newest = symbol; entries.set(symbol, named); return; } const description = descriptionOf(symbol); - const entry: Entry = { kind, declaration, description, id, numbered: numberedFor(description), first: symbol, newest: symbol }; - described.set(description, (described.get(description) ?? 0) + 1); + const entry: Entry = { kind, declaration, description, id, first: symbol, newest: symbol }; if (id !== undefined) identified.set(id, entry); entries.set(symbol, entry); }; /** The symbol last registered under an id, by which a world gives values, or undefined where none was. */ -export const keyOfId = (id: string): symbol | undefined => identified.get(id)?.newest; +export const keyOfId = (id: string): symbol | undefined => { + catchUp(); + return identified.get(id)?.newest; +}; + +/** + * Why a key isn't registered, for errors: its declaration failed to register; or the build registers keys from their + * declarations, so either its module wasn't built with the plugin, which gives keys ids, or it isn't declared. + */ +export const whyUnregistered = (key: unknown): string => { + const failure = typeof key === "symbol" ? failures.get(key) : undefined; + if (failure !== undefined) return `its declaration failed to register: ${failure}`; + return typeof key === "symbol" && idOfKey(key) === undefined + ? "the build's Compiler.plugin() registers keys from their declarations, and didn't build the module making it" + : "declare it beside its key, in interface Components or Resources"; +}; diff --git a/packages/ecs/src/interface/registry/schedule.ts b/packages/ecs/src/interface/registry/schedule.ts index 0127bcd13..894d3cc6c 100644 --- a/packages/ecs/src/interface/registry/schedule.ts +++ b/packages/ecs/src/interface/registry/schedule.ts @@ -2,25 +2,21 @@ import type { Schema } from '../schema/schema.js'; import type { Definition } from '../system/definition.js'; import type { Leaf } from '../system/leaf.js'; -import { firstItemOf, labelOfItem } from './items.js'; +import { firstItemOf } from './items.js'; import { requireDefinition } from './program.js'; import { firstKeyOf, labelOf, sameDeclaration } from './names.js'; import { lookupKeysOf, orderKeysOf } from './indexes.js'; type Key = symbol; -/** Throws unless each leaf's input is an object schema of named fields, and a field several declare has one schema. */ +/** Throws unless a field of the input several leaves take has one schema. */ const checkInputs = (leaves: readonly Leaf[]): void => { const fields = new Map(); for (const leaf of leaves) { - const { input } = leaf.declaration; - const name = labelOfItem(leaf); + const { input } = leaf; + const name = leaf.name; if (input === undefined) continue; - const schema: Schema = input; - if (!("type" in schema) || schema.type !== "object" || typeof schema.properties !== "object" || schema.properties === null) { - throw new Error(`System "${name}": input is an object schema of named fields.`); - } - for (const [field, property] of Object.entries(schema.properties)) { + for (const [field, property] of Object.entries(input.properties)) { const seen = fields.get(field); if (seen === undefined) fields.set(field, { schema: property, system: name }); else if (!sameDeclaration(seen.schema, property)) { @@ -40,7 +36,7 @@ export type Schedule = { readonly tiers: readonly (readonly Leaf[])[]; readonly export const isLeaf = (system: Definition): system is Leaf => system.kind !== "composite"; /** Whether a system is an observer leaf, which runs after a sync point rather than in a tier. */ -export const isObserver = (system: Definition): system is Leaf => system.kind !== "composite" && system.declaration.observer === true; +export const isObserver = (system: Definition): system is Leaf & { readonly declaration: { readonly observer: true } } => system.kind !== "composite" && system.declaration.observer === true; /** Each system is an interval, from its start node to its end node. */ const start = (unit: number): number => 2 * unit; @@ -105,7 +101,7 @@ const topologicalOrder = ({ units, successors }: Graph): number[] => { for (const next of successors[node]) if (--incoming[next] === 0) ready.push(next); } if (order.length < successors.length) { - const cyclic = units.filter((_, unit) => incoming[start(unit)] > 0 || incoming[end(unit)] > 0).map(system => `"${labelOfItem(system)}"`); + const cyclic = units.filter((_, unit) => incoming[start(unit)] > 0 || incoming[end(unit)] > 0).map(system => `"${system.name}"`); throw new Error(`Ordering cycle between ${cyclic.join(", ")}.`); } return order; @@ -144,8 +140,7 @@ type Access = { readonly reads: ReadonlySet; readonly writes: ReadonlySet { - const { reduce } = leaf.declaration; - return reduce === undefined ? [] : Object.getOwnPropertySymbols(reduce).filter(key => Reflect.get(reduce, key) !== undefined); + return leaf.declaration.resources?.reduce ?? []; }; /** @@ -205,7 +200,7 @@ const createSchedule = (system: Definition): Schedule => { for (let b = a + 1; b < leaves.length; b++) { const key = conflictOf(accesses[a], accesses[b]); if (key !== undefined && !ordered(reach, leaves[a].unit, leaves[b].unit)) { - throw new Error(`Systems "${labelOfItem(leaves[a].leaf)}" and "${labelOfItem(leaves[b].leaf)}" both access "${labelOf(key)}" and one writes it; order them with before or after.`); + throw new Error(`Systems "${leaves[a].leaf.name}" and "${leaves[b].leaf.name}" both access "${labelOf(key)}" and one writes it; order them with before or after.`); } } } diff --git a/packages/ecs/src/interface/registry/schemas.ts b/packages/ecs/src/interface/registry/schemas.ts index 9bff811a2..f357a1aa4 100644 --- a/packages/ecs/src/interface/registry/schemas.ts +++ b/packages/ecs/src/interface/registry/schemas.ts @@ -6,9 +6,21 @@ const isStructValue = (value: unknown): boolean => typeof value === "number" || typeof value === "boolean" || (Array.isArray(value) ? value.every(isStructValue) : typeof value === "object" && value !== null && Object.values(value).every(isStructValue)); +/** The values of a union of number constants, which is stored as a number, or null for any other schema. */ +export const numberConstants = (schema: Schema): readonly number[] | null => { + if (!("anyOf" in schema) || schema.anyOf.length === 0) return null; + const values: number[] = []; + for (const option of schema.anyOf) { + if (!("const" in option) || typeof option.const !== "number") return null; + values.push(option.const); + } + return values; +}; + /** Whether a schema describes a struct: numbers, booleans, entities, fixed-length tuples and structs of those. */ export const isStruct = (schema: Schema): boolean => { if ("const" in schema) return isStructValue(schema.const); + if (numberConstants(schema) !== null) return true; if (!("type" in schema)) return false; switch (schema.type) { case "number": @@ -60,3 +72,27 @@ export const isEntity = (schema: Schema): boolean => "format" in schema && schem export const conditional = (schema: Schema): boolean => allowsNull(schema) || ("type" in schema && schema.type === "boolean") || ("const" in schema && schema.const === false) || ("anyOf" in schema && schema.anyOf.some(conditional)); + +/** Whether `value` is one of `schema`'s values, as a default must be: its type, length, range and fields. */ +export const matchesSchema = (schema: Schema, value: unknown): boolean => { + if ("anyOf" in schema) return schema.anyOf.some(member => matchesSchema(member, value)); + if ("allOf" in schema) return schema.allOf.every(member => matchesSchema(member, value)); + if ("const" in schema) return Object.is(schema.const, value) || JSON.stringify(schema.const) === JSON.stringify(value); + switch (schema.type) { + case "null": return value === null; + case "boolean": return typeof value === "boolean"; + case "string": return typeof value === "string" && value.length >= (schema.minLength ?? 0) && value.length <= (schema.maxLength ?? Infinity); + case "number": return typeof value === "number" && (Number.isNaN(value) || (value >= (schema.minimum ?? -Infinity) && value <= (schema.maximum ?? Infinity))); + case "integer": return typeof value === "number" && Number.isInteger(value) && ("format" in schema || (value >= (schema.minimum ?? -Infinity) && value <= (schema.maximum ?? Infinity))); + case "array": return Array.isArray(value) && value.length >= (schema.minItems ?? 0) && value.length <= (schema.maxItems ?? Infinity) && value.every(item => matchesSchema(schema.items, item)); + case "set": return value instanceof Set; + case "map": return value instanceof Map; + case "object": { + if (typeof value !== "object" || value === null || Array.isArray(value)) return false; + const required = schema.required ?? Object.keys(schema.properties); + return Object.entries(value).every(([field, item]) => field in schema.properties && matchesSchema(schema.properties[field] ?? { allOf: [] }, item)) + && required.every(field => field in value); + } + default: return false; + } +}; diff --git a/packages/ecs/src/interface/registry/threads.ts b/packages/ecs/src/interface/registry/threads.ts index 1b36d46a1..8cac442ef 100644 --- a/packages/ecs/src/interface/registry/threads.ts +++ b/packages/ecs/src/interface/registry/threads.ts @@ -1,7 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { threadOfDeclaration } from './declarations.js'; import { lookupKeysOf, orderKeysOf } from './indexes.js'; -import { declarationOf, kindOf, labelOf } from './names.js'; +import { declarationOf, kindOf, labelOf, whyUnregistered } from './names.js'; type Key = symbol; type Lists = { @@ -28,7 +28,7 @@ export type Placed = { export const requireRegistered = (what: "System" | "Finalizer", name: string, kind: "component" | "resource", keys: readonly Key[] | undefined): void => { for (const key of keys ?? []) { if (declarationOf(key) === undefined || kindOf(key) !== kind) { - throw new Error(`${what} "${name}" uses ${kind} "${labelOf(key)}", which isn't a registered ${kind}; import the module that registers it.`); + throw new Error(`${what} "${name}" uses ${kind} "${labelOf(key)}", which isn't a registered ${kind}: ${whyUnregistered(key)}.`); } } }; diff --git a/packages/ecs/src/interface/report/components.ts b/packages/ecs/src/interface/report/components.ts index ad9ab3aab..3d2d77d5c 100644 --- a/packages/ecs/src/interface/report/components.ts +++ b/packages/ecs/src/interface/report/components.ts @@ -1,5 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { register } from '../component/register.js'; +import { registerComponent } from '../registry/declarations.js'; import type { Failure, Warning } from './failure.js'; /** @@ -38,7 +38,7 @@ declare module '@adobe/ecs' { const text = { type: "string" } as const; const count = { type: "integer", minimum: 0 } as const; -register(run, { +registerComponent(run, { type: { type: "object", properties: { @@ -47,7 +47,7 @@ register(run, { }, }, }); -register(errors, { +registerComponent(errors, { type: { type: "object", properties: { @@ -58,13 +58,14 @@ register(errors, { properties: { kind: { anyOf: [{ const: "system" }, { const: "finalizer" }, { const: "gpu" }, { const: "thread" }, { const: "index" }] }, name: text, + id: { anyOf: [text, { type: "null" }] }, message: text, stack: { anyOf: [text, { type: "null" }] }, }, }, }, skipped: { type: "array", items: text }, - warnings: { type: "array", items: { type: "object", properties: { kind: { const: "blocking" }, name: text, message: text } } }, + warnings: { type: "array", items: { type: "object", properties: { kind: { const: "blocking" }, name: text, id: { anyOf: [text, { type: "null" }] }, message: text } } }, }, }, }); diff --git a/packages/ecs/src/interface/report/failure.ts b/packages/ecs/src/interface/report/failure.ts index e4e3a7c79..70c0de079 100644 --- a/packages/ecs/src/interface/report/failure.ts +++ b/packages/ecs/src/interface/report/failure.ts @@ -9,11 +9,12 @@ export type Failure = { readonly kind: "system" | "finalizer" | "gpu" | "thread" | "index"; /** * The label of the system or finalizer that failed; for GPU work, of the system whose run submitted it; for an - * index, the index's, with the key and both entities in `message`. Labels are for display: a system's, - * finalizer's or index's is its name while no other registered system, rule, finalizer or index has it, else its - * name with its id, or numbered without one. + * index, the index's, with the key and both entities in `message`. Labels are for display and needn't be unique: + * its binding, or for one made in a function the binding or function it's in, or its kind numbered. */ readonly name: string; + /** The id the plugin gave it, its package, module and binding (`@app/game@1.0.0/src/move.ts#move`); null without one. */ + readonly id: string | null; /** An Error's message, or String() of anything else thrown. */ readonly message: string; /** The thrown Error's `stack`, as the JavaScript engine formats it, for logs and display; null when it had none. */ @@ -24,4 +25,4 @@ export type Failure = { * What ran but slowed a run. `"blocking"`: a system that runs on a thread a joined thread fills and isn't terminal, so * its tier waited for that thread, by the system's label. */ -export type Warning = { readonly kind: "blocking"; readonly name: string; readonly message: string }; +export type Warning = { readonly kind: "blocking"; readonly name: string; readonly id: string | null; readonly message: string }; diff --git a/packages/ecs/src/interface/report/index.ts b/packages/ecs/src/interface/report/index.ts index ac4007f4e..6f65dbe57 100644 --- a/packages/ecs/src/interface/report/index.ts +++ b/packages/ecs/src/interface/report/index.ts @@ -1,9 +1,11 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { create as createArchetype } from '../archetype/create.js'; +import { indexArchetype } from '../registry/archetypes.js'; import { run } from './components.js'; export { errors, run } from './components.js'; export type { Failure, Warning } from './failure.js'; /** A report's archetype: the world gives every run and tick one, which gains `errors` when something went wrong. */ -export const Archetype = createArchetype([run]); +export const Archetype = [run] as const; +// The package's own lists are built without the plugin, so they take their indexes here. +indexArchetype(Archetype); diff --git a/packages/ecs/src/interface/report/report.type-test.ts b/packages/ecs/src/interface/report/report.type-test.ts index caedfdfd1..c10222049 100644 --- a/packages/ecs/src/interface/report/report.type-test.ts +++ b/packages/ecs/src/interface/report/report.type-test.ts @@ -31,21 +31,22 @@ check>(); declare const failure: Report.Failure; check>(); check>(); -check>(); +check>(); +check>(); // A report's values: errors only when something went wrong. check>(); // An observer system follows reports by their components. const logged: Report.Failure[] = []; -export const logger = System.create({ observer: true, components: { read: [Report.run, Report.errors], changed: [Report.errors] } }, row => { +export const logger = System.create({ observer: true, components: { with: [Report.run, Report.errors], changed: [Report.errors] } }, row => { const number: number = row[Report.run].number; for (const each of row[Report.errors].failures) logged.push(each); // @ts-expect-error a report's lists are read-only row[Report.errors].failures.push(logged[0]); void number; }); -export const timings = System.create({ observer: true, components: { read: [Report.run], changed: [Report.run] } }, row => { +export const timings = System.create({ observer: true, components: { with: [Report.run], changed: [Report.run] } }, row => { const { elapsed, systems, workers, joined, main: onMain, gpu, dropped } = row[Report.run]; void [elapsed, systems, workers, joined, onMain, gpu, dropped]; }); diff --git a/packages/ecs/src/interface/resource/declaration.ts b/packages/ecs/src/interface/resource/declaration.ts deleted file mode 100644 index db397652d..000000000 --- a/packages/ecs/src/interface/resource/declaration.ts +++ /dev/null @@ -1,30 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { Describing, Starting } from "../component/declaration.js"; -import type { IsData } from "../schema/is-data.js"; -import type { Schema } from "../schema/schema.js"; -import type { Key as TraitKey } from "../trait/key.js"; -import type { Required } from "../trait/required.js"; -import type { Traits } from "../trait/types.js"; -import type { SymbolKeys } from "../trait/symbol-keys.js"; -import type { Key } from "./key.js"; -import type { Resources } from "./types.js"; - -/** - * A resource always has a value: its schema's `default` or the declaration's, of the declared type `T`, either left - * out only when `T` allows `null`. A type that isn't data has no schema, so only the declaration's. - */ -type TypePart = - IsData extends true - ? [S] extends [never] ? { readonly type: Schema } : Describing & (S extends { readonly default: unknown } ? { readonly default?: never } : Starting) - : { readonly type?: never } & Starting; - -/** As for components, with the value a resource starts at. */ -export type Declaration = - { readonly type?: S } - & TypePart - & { readonly [T in Exclude]: - T extends TraitKey ? (Resources[K][T] extends Traits[T] ? Resources[K][T] : never) : - never } - & { readonly [T in Exclude]?: never } - & Required - & SymbolKeys; diff --git a/packages/ecs/src/interface/resource/declaration.type-test.ts b/packages/ecs/src/interface/resource/declaration.type-test.ts deleted file mode 100644 index 9df0bcec0..000000000 --- a/packages/ecs/src/interface/resource/declaration.type-test.ts +++ /dev/null @@ -1,90 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Resource } from './resource.js'; - -const gravity = Symbol("declarationTypeTest/gravity"); -const total = Symbol("declarationTypeTest/total"); -const names = Symbol("declarationTypeTest/names"); -const tags = Symbol("declarationTypeTest/tags"); -const selected = Symbol("declarationTypeTest/selected"); -const device = Symbol("declarationTypeTest/device"); -const page = Symbol("declarationTypeTest/page"); -const handles = Symbol("declarationTypeTest/handles"); -const candidates = Symbol("declarationTypeTest/candidates"); -const misnamed = Symbol("declarationTypeTest/misnamed"); - -type Box = { readonly x: number; readonly y: number }; -type Candidate = Box & { readonly id: number }; - -declare module '@adobe/ecs' { - export interface Resources { - [gravity]: { type: number }; - [total]: { type: number; reduce: "+" }; - [names]: { type: ReadonlyMap }; - [tags]: { type: ReadonlySet }; - [selected]: { type: string | null }; - [device]: { type: GPUDevice | null }; - [page]: { type: Document; thread: "main" }; - [handles]: { type: Map }; - [candidates]: { type: readonly Candidate[] }; - [misnamed]: { type: readonly Candidate[] }; - } -} - -const namesSchema = { type: "map", keys: { type: "string" }, values: { type: "number" } } as const; - -// A resource starts at its schema's default or its declaration's. -Resource.register(gravity, { type: { type: "number", default: -9.8 } }); -Resource.register(total, { type: { type: "number", default: 0 }, reduce: "+" }); -Resource.register(names, { type: namesSchema, default: new Map() }); -Resource.register(tags, { type: { type: "set", items: { type: "string" } }, default: new Set(["a"]) }); -// A type that allows null starts at null without one. -Resource.register(selected, { type: { anyOf: [{ type: "string" }, { type: "null" }] } }); -Resource.register(device, {}); -Resource.register(page, { default: document, thread: "main" }); -Resource.register(handles, { default: new Map() }); - -// A schema describes exactly the declared type. -// @ts-expect-error a string isn't a number -Resource.register(gravity, { type: { type: "string", default: "" } }); -// @ts-expect-error a map's values are numbers -Resource.register(names, { type: { type: "map", keys: { type: "string" }, values: { type: "string" } }, default: new Map() }); - -// An intersection describes the object it equals. -const number = { type: "number" } as const; -Resource.register(candidates, { type: { type: "array", items: { type: "object", properties: { id: number, x: number, y: number }, required: ["id", "x", "y"] }, default: [] } }); -// @ts-expect-error a member's name differs -Resource.register(misnamed, { type: { type: "array", items: { type: "object", properties: { key: number, x: number, y: number }, required: ["key", "x", "y"] }, default: [] } }); - -// Every declared trait is registered, with its declared value, and nothing undeclared is. -// @ts-expect-error a declared trait is missing -Resource.register(total, { type: { type: "number", default: 0 } }); -// @ts-expect-error an undeclared trait is given -Resource.register(gravity, { type: { type: "number", default: 0 }, reduce: "+" }); -// @ts-expect-error a trait's value differs from the declared one -Resource.register(total, { type: { type: "number", default: 0 }, reduce: "max" }); -// A declaration held in a constant gets no excess property check, so the types name each undeclared trait. -const reduced = { type: { type: "number", default: 0 }, reduce: "+" } as const; -// @ts-expect-error an undeclared trait is given -Resource.register(gravity, reduced); - -// A type that's data is registered with its schema; one that isn't data has none. -// @ts-expect-error a map of strings to numbers is data, so it has a schema -Resource.register(names, { default: new Map() }); -// @ts-expect-error a string is data, so it has a schema, though it may start at null -Resource.register(selected, {}); -// @ts-expect-error a value that isn't data has no schema -Resource.register(handles, { type: { type: "object", properties: {} }, default: new Map() }); - -// A default is a value of the declared type, given once. -// @ts-expect-error the schema's default isn't a number -Resource.register(gravity, { type: { type: "number", default: "down" } }); -// @ts-expect-error the declaration's default isn't a map of strings to numbers -Resource.register(names, { type: namesSchema, default: new Set() }); -// @ts-expect-error a default isn't a Map -Resource.register(handles, { default: "map" }); -// @ts-expect-error a type without null starts at a default -Resource.register(gravity, { type: { type: "number" } }); -// @ts-expect-error a value that isn't data, without null, starts at a default -Resource.register(handles, {}); -// @ts-expect-error a schema's default and the declaration's can't both be given -Resource.register(gravity, { type: { type: "number", default: 0 }, default: 1 }); diff --git a/packages/ecs/src/interface/resource/index.ts b/packages/ecs/src/interface/resource/index.ts index 540c26d77..fafde2f63 100644 --- a/packages/ecs/src/interface/resource/index.ts +++ b/packages/ecs/src/interface/resource/index.ts @@ -1,4 +1,3 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -export * from './register.js'; export type { Key } from './key.js'; export type { Value } from './value.js'; diff --git a/packages/ecs/src/interface/resource/register.test.ts b/packages/ecs/src/interface/resource/register.test.ts index aa81d1d86..21112ea0a 100644 --- a/packages/ecs/src/interface/resource/register.test.ts +++ b/packages/ecs/src/interface/resource/register.test.ts @@ -1,13 +1,11 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; -import { Component } from "../component/component.js"; +import { registerComponent, registerResource } from "../registry/declarations.js"; import { declarationOf, kindOf, threadOfDeclaration } from "../registry/index.js"; import { always, cadence, clock, gpuDevice, wake, workers } from "../resources/index.js"; -import { Resource } from "./resource.js"; const time = Symbol("typesResourceTest/time"); const symbol = Symbol("typesResourceTest/symbol"); -const shared = Symbol("typesResourceTest/shared"); const cache = Symbol("typesResourceTest/cache"); const port = Symbol("typesResourceTest/port"); const total = Symbol("typesResourceTest/total"); @@ -17,92 +15,79 @@ const tags = Symbol("typesResourceTest/tags"); declare module "@adobe/ecs" { export interface Resources { - [time]: { type: number }; - [symbol]: { type: boolean }; - [shared]: { type: true }; - [cache]: { type: Map; thread: "main" }; + [time]: { type: number; default: 0 }; + [symbol]: { type: boolean; default: false }; + [cache]: { type: ReadonlyMap; thread: "main" }; [port]: { type: MessagePort | null; transfer: true }; - [total]: { type: number; reduce: "+" }; - [flags]: { type: boolean; reduce: "|" }; + [total]: { type: number; default: 0; reduce: "+" }; + [flags]: { type: boolean; default: false; reduce: "|" }; [names]: { type: ReadonlyMap }; [tags]: { type: ReadonlySet }; } - export interface Components { - [shared]: { type: true }; - } } -Resource.register(time, { type: { type: "number", default: 0 } }); -Resource.register(symbol, { type: { type: "boolean", default: false } }); -// @ts-expect-error a key declared as both a component and a resource can't be registered as either -Component.register(shared, { type: { const: true } }); -Resource.register(cache, { default: new Map(), thread: "main" }); -Resource.register(port, { transfer: true }); -Resource.register(total, { type: { type: "number", default: 0 }, reduce: "+" }); -Resource.register(flags, { type: { type: "boolean", default: false }, reduce: "|" }); -const namesSchema = { type: "map", keys: { type: "string" }, values: { type: "number" } } as const; -Resource.register(names, { type: namesSchema, default: new Map([["a", 1]]) }); -Resource.register(tags, { type: { type: "set", items: { type: "string" }, default: new Set(["x"]) } }); - -const untyped: (...parameters: never[]) => unknown = Resource.register; +/** Registers a declaration as the build does, which a test may write wrong. */ +const register = (key: unknown, declaration: object): void => registerResource(key, declaration); -/** Registers as an untyped caller would, bypassing the types' checks. */ -const register = (key: unknown, declaration: object): void => { - Reflect.apply(untyped, undefined, [key, declaration]); -}; +const namesSchema = { type: "map", keys: { type: "string" }, values: { type: "number" } } as const; -describe("Resource.register", () => { +describe("registering a resource", () => { it("records the declaration by its symbol, which needs no description", () => { - expect(declarationOf(time)).toEqual({ type: { type: "number", default: 0 } }); - expect(declarationOf(symbol)).toEqual({ type: { type: "boolean", default: false } }); + expect(declarationOf(time)).toEqual({ type: { type: "number" }, default: 0 }); + expect(declarationOf(symbol)).toEqual({ type: { type: "boolean" }, default: false }); expect(kindOf(symbol)).toBe("resource"); const anonymous = Symbol(); - register(anonymous, { type: { type: "boolean", default: false } }); + register(anonymous, { type: { type: "boolean" }, default: false }); expect(kindOf(anonymous)).toBe("resource"); // Another symbol with the same description is another key. - expect(() => register(Symbol("typesResourceTest/symbol"), { type: { type: "number", default: 0 } })).not.toThrow(); - expect(() => register("typesResourceTest/text", { type: { type: "number", default: 0 } })).toThrow("a key is a plain Symbol(...), not a string."); + expect(() => register(Symbol("typesResourceTest/symbol"), { type: { type: "number" }, default: 0 })).not.toThrow(); + expect(() => register("typesResourceTest/text", { type: { type: "number" }, default: 0 })).toThrow("a key is a plain Symbol(...), not a string."); }); it("accepts the same declaration again, and rejects a different one or one also declared as a component", () => { - expect(() => Resource.register(time, { type: { type: "number", default: 0 } })).not.toThrow(); - expect(() => Resource.register(time, { type: { type: "number", default: 1 } })).toThrow(`Cannot register resource "typesResourceTest/time": already registered as a resource, with another declaration.`); - // @ts-expect-error a key declared as both a component and a resource can't be registered as either - expect(() => Resource.register(shared, { type: { const: true, default: true } })).toThrow("already registered as a component"); + expect(() => register(time, { type: { type: "number" }, default: 0 })).not.toThrow(); + expect(() => register(time, { type: { type: "number" }, default: 1 })).toThrow(`Cannot register resource "typesResourceTest/time": already registered as a resource, with another declaration.`); + const shared = Symbol("typesResourceTest/shared"); + registerComponent(shared, { type: { const: true } }); + expect(() => register(shared, { type: { const: true }, default: true })).toThrow("already registered as a component"); }); - it("takes a resource's default from its declaration or its schema, never both, and compares sets and maps by their entries", () => { - expect(declarationOf(names)).toEqual({ type: namesSchema, default: new Map([["a", 1]]) }); - expect(() => Resource.register(names, { type: namesSchema, default: new Map([["a", 1]]) })).not.toThrow(); - expect(() => Resource.register(names, { type: namesSchema, default: new Map([["a", 2]]) })).toThrow("with another declaration"); - expect(() => Resource.register(tags, { type: { type: "set", items: { type: "string" }, default: new Set(["x"]) } })).not.toThrow(); - expect(() => Resource.register(tags, { type: { type: "set", items: { type: "string" }, default: new Set(["y"]) } })).toThrow("with another declaration"); - expect(() => register(Symbol("typesResourceTest/twice"), { type: { type: "number", default: 0 }, default: 1 })).toThrow("its default is given twice"); + it("takes a resource's default from its declaration, never its schema, and compares sets and maps by their entries", () => { + expect(declarationOf(names)).toEqual({ type: namesSchema, fresh: "Map" }); + expect(declarationOf(tags)).toEqual({ type: { type: "set", items: { type: "string" } }, fresh: "Set" }); + const listed = Symbol("typesResourceTest/listed"); + expect(() => register(listed, { type: { ...namesSchema }, default: new Map([["a", 1]]) })).not.toThrow(); + expect(() => register(listed, { type: { ...namesSchema }, default: new Map([["a", 1]]) })).not.toThrow(); + expect(() => register(listed, { type: { ...namesSchema }, default: new Map([["a", 2]]) })).toThrow("with another declaration"); + expect(() => register(Symbol("typesResourceTest/inSchema"), { type: { type: "number", default: 0 } })).toThrow("its schema holds a default, which a schema never does"); + expect(() => register(Symbol("typesResourceTest/inField"), { type: { type: "object", properties: { a: { type: "number", default: 0 } } } })).toThrow("its schema holds a default"); expect(threadOfDeclaration(declarationOf(names) ?? {})).toBe("world"); }); it("declares gpuDevice on \"world\", and the loop's state as resources", () => { expect(declarationOf(gpuDevice)).toEqual({}); expect(threadOfDeclaration(declarationOf(gpuDevice) ?? {})).toBe("world"); - expect(declarationOf(workers)).toEqual({ type: { anyOf: [{ type: "integer", minimum: 0 }, { type: "null" }], default: null } }); + expect(declarationOf(workers)).toEqual({ type: { anyOf: [{ type: "integer", minimum: 0 }, { type: "null" }] }, default: null }); // null isn't a struct, so workers is an object resource on "world". expect(threadOfDeclaration(declarationOf(workers) ?? {})).toBe("world"); - const defaults = [cadence, always, wake, clock].map(name => { - const type = declarationOf(name)?.type; - return type !== undefined && "default" in type ? type.default : undefined; - }); + const defaults = [cadence, always, wake, clock].map(name => declarationOf(name)?.default); expect(defaults).toEqual([{ kind: "frame" }, false, Infinity, { now: 0, delta: 0, tick: 0 }]); expect(declarationOf(wake)?.reduce).toBe("min"); expect([cadence, always, wake, clock].map(name => threadOfDeclaration(declarationOf(name) ?? {}))).toEqual(["world", null, null, null]); }); it("checks thread and transfer as far as values tell", () => { - expect(declarationOf(cache)).toEqual({ default: new Map(), thread: "main" }); + expect(declarationOf(cache)).toEqual({ type: namesSchema, thread: "main", fresh: "Map" }); expect(threadOfDeclaration(declarationOf(cache) ?? {})).toBe("main"); expect(declarationOf(port)).toEqual({ transfer: true }); expect(() => register(Symbol("typesResourceTest/buffer"), { default: new ArrayBuffer(8), transfer: true })).not.toThrow(); expect(() => register(Symbol("typesResourceTest/map"), { default: new Map(), transfer: true })).toThrow("its value isn't Transferable"); - expect(() => register(Symbol("typesResourceTest/count"), { type: { type: "number", default: 0 }, thread: "main" })).toThrow("a struct lives in shared memory"); + expect(() => register(Symbol("typesResourceTest/count"), { type: { type: "number" }, default: 0, thread: "main" })).toThrow("a struct lives in shared memory"); + }); + it("starts a fresh value only as an empty Map or Set, and not beside a default", () => { + expect(() => register(Symbol("typesResourceTest/array"), { fresh: "Array" })).toThrow(`fresh is "Map" or "Set".`); + expect(() => register(Symbol("typesResourceTest/both"), { fresh: "Map", default: null })).toThrow("it starts fresh, so it has no default."); + expect(() => register(Symbol("typesResourceTest/set"), { type: { type: "set", items: { type: "string" } }, fresh: "Map" })).toThrow("a set starts as a fresh Set."); }); it("checks reductions", () => { - expect(() => register(Symbol("typesResourceTest/badOperator"), { type: { type: "number", default: 0 }, reduce: "-" })).toThrow('reduce is one of "+"'); - expect(() => register(Symbol("typesResourceTest/sumFlags"), { type: { type: "boolean", default: false }, reduce: "+" })).toThrow('booleans reduce with "&", "|" or "^"'); - expect(() => register(Symbol("typesResourceTest/text"), { type: { type: "string", default: "" }, reduce: "max" })).toThrow("a reduced resource is a struct"); + expect(() => register(Symbol("typesResourceTest/badOperator"), { type: { type: "number" }, default: 0, reduce: "-" })).toThrow('reduce is one of "+"'); + expect(() => register(Symbol("typesResourceTest/sumFlags"), { type: { type: "boolean" }, default: false, reduce: "+" })).toThrow('booleans reduce with "&", "|" or "^"'); + expect(() => register(Symbol("typesResourceTest/text"), { type: { type: "string" }, default: "", reduce: "max" })).toThrow("a reduced resource is a struct"); }); }); diff --git a/packages/ecs/src/interface/resource/register.ts b/packages/ecs/src/interface/resource/register.ts deleted file mode 100644 index 4b5351d25..000000000 --- a/packages/ecs/src/interface/resource/register.ts +++ /dev/null @@ -1,19 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { Component } from "../component/component.js"; -import { createRegistry, type AnyDeclaration } from "../registry/declarations.js"; -import type { Schema } from "../schema/schema.js"; -import type { Key as TraitKey } from "../trait/key.js"; -import type { Declaration } from "./declaration.js"; -import type { Key } from "./key.js"; - -const registry = createRegistry("resource"); - -/** - * Registers the resource `key`, a plain `Symbol(...)` whose traits `Resources` declares, as `Component.register` - * does: `Resource.register(gravity, { type: { type: "number", default: -9.8 } })`. A resource always has a value: its - * schema's `default` or the declaration's, either left out only when its type allows `null`, which it then starts at. - */ -export function register & symbol, const S extends Schema & { readonly default?: unknown } = never>(key: K, declaration: Declaration): void; -export function register(key: unknown, declaration: AnyDeclaration): void { - registry(key, declaration); -} diff --git a/packages/ecs/src/interface/resource/types.ts b/packages/ecs/src/interface/resource/types.ts index 04cd4fb4b..fa425337f 100644 --- a/packages/ecs/src/interface/resource/types.ts +++ b/packages/ecs/src/interface/resource/types.ts @@ -1,4 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -/** Each resource's traits, `type` included, by augmentation: `[time]: { type: number }`. Registration must give exactly these. */ +/** + * Each resource's traits, `type` and its `default` included, by augmentation in the module declaring its key: + * `[time]: { type: number; default: 0 }`. The build registers each. + */ export interface Resources { } diff --git a/packages/ecs/src/interface/resources/always.ts b/packages/ecs/src/interface/resources/always.ts index 03901815c..ad1ca7fc7 100644 --- a/packages/ecs/src/interface/resources/always.ts +++ b/packages/ecs/src/interface/resources/always.ts @@ -1,5 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Resource } from '../resource/resource.js'; +import { registerResource } from '../registry/declarations.js'; /** Whether every beat of the cadence runs a tick, not only beats where `wake` is due or a run arrived. False by default. */ export const always = Symbol("always"); @@ -10,4 +10,4 @@ declare module '@adobe/ecs' { } } -Resource.register(always, { type: { type: "boolean", default: false } }); +registerResource(always, { type: { type: "boolean" }, default: false }); diff --git a/packages/ecs/src/interface/resources/cadence.ts b/packages/ecs/src/interface/resources/cadence.ts index 052cab4a5..942791036 100644 --- a/packages/ecs/src/interface/resources/cadence.ts +++ b/packages/ecs/src/interface/resources/cadence.ts @@ -1,5 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Resource } from '../resource/resource.js'; +import { registerResource } from '../registry/declarations.js'; /** The cadence's value, for the implementation: `Resource.Value`. */ export type Cadence = { readonly kind: "frame" } | { readonly kind: "fixed"; readonly step: number; readonly maxSteps?: number }; @@ -17,12 +17,12 @@ declare module '@adobe/ecs' { } } -Resource.register(cadence, { +registerResource(cadence, { type: { anyOf: [ { type: "object", properties: { kind: { const: "frame" } } }, { type: "object", properties: { kind: { const: "fixed" }, step: { type: "number", minimum: 0 }, maxSteps: { type: "integer", minimum: 1 } }, required: ["kind", "step"] }, ], - default: { kind: "frame" }, }, + default: { kind: "frame" }, }); diff --git a/packages/ecs/src/interface/resources/clock.ts b/packages/ecs/src/interface/resources/clock.ts index 7ae4e3426..e15951403 100644 --- a/packages/ecs/src/interface/resources/clock.ts +++ b/packages/ecs/src/interface/resources/clock.ts @@ -1,5 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Resource } from '../resource/resource.js'; +import { registerResource } from '../registry/declarations.js'; /** * The current tick, written only by the world before each tick: `now`, the beat's time in milliseconds, `delta`, the @@ -13,6 +13,7 @@ declare module '@adobe/ecs' { } } -Resource.register(clock, { - type: { type: "object", properties: { now: { type: "number" }, delta: { type: "number" }, tick: { type: "number" } }, default: { now: 0, delta: 0, tick: 0 } }, +registerResource(clock, { + type: { type: "object", properties: { now: { type: "number" }, delta: { type: "number" }, tick: { type: "number" } } }, + default: { now: 0, delta: 0, tick: 0 }, }); diff --git a/packages/ecs/src/interface/resources/gpu-device.ts b/packages/ecs/src/interface/resources/gpu-device.ts index 400a7a61a..38c50c4e9 100644 --- a/packages/ecs/src/interface/resources/gpu-device.ts +++ b/packages/ecs/src/interface/resources/gpu-device.ts @@ -1,5 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Resource } from '../resource/resource.js'; +import { registerResource } from '../registry/declarations.js'; /** * The device the world's GPU storage lives on, for both `kernel: "gpu"` kernels and render systems, or null for a world @@ -14,4 +14,4 @@ declare module '@adobe/ecs' { } } -Resource.register(gpuDevice, {}); +registerResource(gpuDevice, {}); diff --git a/packages/ecs/src/interface/resources/wake.ts b/packages/ecs/src/interface/resources/wake.ts index 9ae439241..b0f5a8f47 100644 --- a/packages/ecs/src/interface/resources/wake.ts +++ b/packages/ecs/src/interface/resources/wake.ts @@ -1,5 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Resource } from '../resource/resource.js'; +import { registerResource } from '../registry/declarations.js'; /** * The earliest `clock.now`, in milliseconds, at which a system asked for the next tick, by contributing to it; Infinity @@ -13,4 +13,4 @@ declare module '@adobe/ecs' { } } -Resource.register(wake, { type: { type: "number", default: Infinity }, reduce: "min" }); +registerResource(wake, { type: { type: "number" }, reduce: "min", default: Infinity }); diff --git a/packages/ecs/src/interface/resources/workers.ts b/packages/ecs/src/interface/resources/workers.ts index eb4be500e..68b1cdd8a 100644 --- a/packages/ecs/src/interface/resources/workers.ts +++ b/packages/ecs/src/interface/resources/workers.ts @@ -1,5 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Resource } from '../resource/resource.js'; +import { registerResource } from '../registry/declarations.js'; /** The kernel pool's size, 0 for none, or `null`, the default, for the platform's choice. The pool follows it at sync points. */ export const workers = Symbol("workers"); @@ -10,4 +10,4 @@ declare module '@adobe/ecs' { } } -Resource.register(workers, { type: { anyOf: [{ type: "integer", minimum: 0 }, { type: "null" }], default: null } }); +registerResource(workers, { type: { anyOf: [{ type: "integer", minimum: 0 }, { type: "null" }] }, default: null }); diff --git a/packages/ecs/src/interface/rule/create.ts b/packages/ecs/src/interface/rule/create.ts index 42075f6c4..6e964a325 100644 --- a/packages/ecs/src/interface/rule/create.ts +++ b/packages/ecs/src/interface/rule/create.ts @@ -1,5 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { declarationOf, kindOf, labelOf } from '../registry/names.js'; +import { declarationOf, kindOf, labelOf, whyUnregistered } from '../registry/names.js'; import { takeIdOfDeclaration } from '../registry/ids.js'; import { nameOf } from '../registry/items.js'; import { addRule } from '../registry/program.js'; @@ -7,20 +7,21 @@ import type { Rule as Created } from './created.js'; import type { Declaration } from './declaration.js'; /** - * Declares a rule, which applies in every world, and returns it: identified by that value, labeled by its name. Throws + * Declares a rule, which applies in every world, and returns it: identified by that value, labeled by its binding. Throws * if it matches on a key that isn't a registered component, derives a component that isn't derived, or derives in a * cycle through a negation. */ -export const create = (declaration: D): { readonly name: string } => { +export const create = (declaration: D & { readonly components: { readonly has?: never } }): { readonly name: string } => { const id = takeIdOfDeclaration(declaration); - const name = nameOf(declaration.name, id, "rule"); - const { components, derive } = declaration; + const name = nameOf(declaration, id, "rule"); + const { derive } = declaration; + const components = { has: declaration.components.with, not: declaration.components.not }; for (const key of [...components.has ?? [], ...components.not ?? [], ...derive]) { - if (kindOf(key) !== "component") throw new Error(`Rule "${name}" uses "${labelOf(key)}", which isn't a registered component.`); + if (kindOf(key) !== "component") throw new Error(`Rule "${name}" uses "${labelOf(key)}", which isn't a registered component: ${whyUnregistered(key)}.`); } for (const key of derive) { if (declarationOf(key)?.derived !== true) throw new Error(`Rule "${name}" derives "${labelOf(key)}", which isn't declared derived.`); } - const rule: Created = { kind: "rule", name, declaration }; + const rule: Created = { kind: "rule", name, declaration: { ...declaration, components } }; return addRule(rule, id); }; diff --git a/packages/ecs/src/interface/rule/created.ts b/packages/ecs/src/interface/rule/created.ts index 45e69e97e..1d23c26cd 100644 --- a/packages/ecs/src/interface/rule/created.ts +++ b/packages/ecs/src/interface/rule/created.ts @@ -1,5 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import type { Declaration } from './declaration.js'; +import type { Stored } from './declaration.js'; /** A rule: entities matching its components have the components it derives. */ -export type Rule = { readonly kind: "rule"; readonly name: string; readonly declaration: D }; +export type Rule = { readonly kind: "rule"; readonly name: string; readonly declaration: Stored }; diff --git a/packages/ecs/src/interface/rule/declaration.ts b/packages/ecs/src/interface/rule/declaration.ts index d48bf589d..f334d0fc7 100644 --- a/packages/ecs/src/interface/rule/declaration.ts +++ b/packages/ecs/src/interface/rule/declaration.ts @@ -1,12 +1,15 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { Component } from '../component/component.js'; -import type { Name } from './name.js'; import type { Derived } from '../component/value.js'; export type Declaration = { - readonly name?: Name; /** Which entities the rule matches, by the components they have and don't have. */ - readonly components: { readonly has?: readonly Component.Key[]; readonly not?: readonly Component.Key[] }; + readonly components: { readonly with?: readonly Component.Key[]; readonly not?: readonly Component.Key[] }; /** Components present on matching entities, while any rule deriving them matches. */ readonly derive: readonly Derived[]; }; + +/** A rule's declaration as the world takes it: what it requires in `has`. */ +export type Stored = Omit & { + readonly components: { readonly has?: readonly symbol[]; readonly not?: readonly symbol[] }; +}; diff --git a/packages/ecs/src/interface/rule/name.ts b/packages/ecs/src/interface/rule/name.ts deleted file mode 100644 index ef87e3b53..000000000 --- a/packages/ecs/src/interface/rule/name.ts +++ /dev/null @@ -1,7 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -/** - * The label reports and errors give it, which needn't be unique: the value its constructor returns is its identity, so - * separately written code can't collide by name. Namespacing it (`"physics/integrate"`) keeps labels short. Without - * one, it's labeled by its binding and the id the plugin gave it, or else by its kind numbered. - */ -export type Name = string; diff --git a/packages/ecs/src/interface/rule/rule.type-test.ts b/packages/ecs/src/interface/rule/rule.type-test.ts index ae3685062..e5597f1ca 100644 --- a/packages/ecs/src/interface/rule/rule.type-test.ts +++ b/packages/ecs/src/interface/rule/rule.type-test.ts @@ -30,20 +30,14 @@ declare module '@adobe/ecs' { [time]: { type: number }; } } -Component.register(source, { type: { type: "number" } }); -Component.register(hidden, { type: { const: true } }); -Component.register(slot, { type: { type: "number", default: 0 }, derived: true }); -Component.register(parent, { type: Entity.schema, reference: "owner" }); -Resource.register(time, { type: { type: "number", default: 0 } }); // Rules derive only derived components. -const slots = Rule.create({ name: "ruleTypeTest/slots", components: { has: [source], not: [hidden] }, derive: [slot] }); +const slots = Rule.create({ components: { with: [source], not: [hidden] }, derive: [slot] }); // @ts-expect-error a rule derives only derived components -Rule.create({ name: "ruleTypeTest/slots", components: { has: [source] }, derive: [source] }); +Rule.create({ components: { with: [source] }, derive: [source] }); -// A finalizer's context holds only what it declares, and its commands. -const other = Finalizer.create({ name: "ruleTypeTest/other", components: { read: [source] } }, (_, context) => { - // @ts-expect-error no resources declared +// A finalizer's context holds every resource, the lookups it declares, and its commands. +const other = Finalizer.create({ components: { with: [source] } }, (_, context) => { void context.resources; // @ts-expect-error no lookups declared void context.lookup; @@ -52,9 +46,7 @@ const other = Finalizer.create({ name: "ruleTypeTest/other", components: { read: // Per-query finalizers read the stopping row; it's read-only. Finalizer.create({ - name: "ruleTypeTest/release", - components: { read: [slot, parent], not: [hidden] }, - resources: { read: [time] }, + components: { with: [slot, parent], not: [hidden] }, after: [other], }, (row, { resources, commands }) => { const value: number = row[slot]; @@ -63,21 +55,16 @@ Finalizer.create({ commands.despawn(target); // @ts-expect-error a finalizer's row is read-only row[slot] = 1; - // @ts-expect-error only declared reads are in the row + // @ts-expect-error only what with requires is in the row void row[source]; - // @ts-expect-error read resources are read-only - resources[time] = 0; void value; void now; }); Finalizer.create({ - name: "ruleTypeTest/other", components: {}, - // @ts-expect-error write already makes a resource readable - resources: { read: [time], write: [time] }, }, () => {}); // Changes finalizers see only the essential components they watch. -Finalizer.changes({ name: "ruleTypeTest/save", watch: [source, parent] }, (changes, { commands }) => { +Finalizer.changes({ watch: [source, parent] }, (changes, { commands }) => { check>>(); void commands; for (const group of changes.groups) { @@ -92,19 +79,21 @@ Finalizer.changes({ name: "ruleTypeTest/save", watch: [source, parent] }, (chang } }); // @ts-expect-error changes finalizers don't watch derived components -Finalizer.changes({ name: "ruleTypeTest/save", watch: [slot] }, () => {}); +Finalizer.changes({ watch: [slot] }, () => {}); // References change only through commands, so systems can't write them. -System.create({ name: "ruleTypeTest/move", components: { read: [parent], write: [source] } }, row => { row[source] = row[parent]; }); -// @ts-expect-error a reference changes only through commands -System.create({ name: "ruleTypeTest/move", components: { write: [parent] } }, () => {}); +System.create({ components: { with: [parent, source] } }, row => { row[source] = row[parent]; }); +System.create({ components: { with: [parent] } }, row => { + // @ts-expect-error a reference changes only through commands + row[parent] = row.entity; +}); check, typeof source>>(); // A rule and a finalizer are the values their constructors return, labeled by their names, which needn't be unique. check>>(); check>(); check>(); -const sameName: Finalizer = Finalizer.create({ name: "ruleTypeTest/other", components: { read: [source] } }, () => {}); +const sameName: Finalizer = Finalizer.create({ components: { with: [source] } }, () => {}); void sameName; // Rules and finalizers apply in every world. @@ -112,7 +101,7 @@ declare const create: Create; export const created: World = create(); // Names are optional for rules and finalizers too. -const unnamedRule = Rule.create({ components: { has: [source] }, derive: [slot] }); -const unnamedFinalizer: Finalizer = Finalizer.create({ components: { read: [source] } }, row => { void row[source]; }); +const unnamedRule = Rule.create({ components: { with: [source] }, derive: [slot] }); +const unnamedFinalizer: Finalizer = Finalizer.create({ components: { with: [source] } }, row => { void row[source]; }); const unnamedChanges: Finalizer = Finalizer.changes({ watch: [source] }, changes => { void changes.groups; }); void [unnamedRule, unnamedFinalizer, unnamedChanges]; diff --git a/packages/ecs/src/interface/schema/is-data.ts b/packages/ecs/src/interface/schema/is-data.ts deleted file mode 100644 index 3b6dd6e74..000000000 --- a/packages/ecs/src/interface/schema/is-data.ts +++ /dev/null @@ -1,26 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -type Method = (...parameters: never[]) => unknown; - -/** How deep `NotData` looks into members: deeper ones count as data, so recursive types stay finite. */ -type Depth = [never, 0, 1, 2, 3, 4]; - -/** - * True for a value that isn't data: a function, an object with a member that isn't data, or a `Set` or `Map`, which - * changes in place. A `ReadonlySet` or `ReadonlyMap` is data when what it holds is. Members are looked at five levels - * deep. - */ -type NotData = - [D] extends [never] ? false : - T extends string | number | boolean | bigint | symbol | null | undefined ? false : - T extends Method ? true : - T extends readonly (infer E)[] ? NotData : - T extends ReadonlyMap ? (T extends Map ? true : NotData | NotData) : - T extends ReadonlySet ? (T extends Set ? true : NotData) : - T extends object ? (true extends { [K in keyof T]-?: NotData }[keyof T] ? true : false) : - false; - -/** - * Whether a declared type is data a schema describes, so its registration gives that schema. A `Blob`, an - * `HTMLElement` or a `GPUDevice` isn't: its registration has no `type`, and the world stores the value as it is. - */ -export type IsData = true extends NotData ? false : true; diff --git a/packages/ecs/src/interface/schema/schema.ts b/packages/ecs/src/interface/schema/schema.ts index 3af7f1405..ed8ac6f11 100644 --- a/packages/ecs/src/interface/schema/schema.ts +++ b/packages/ecs/src/interface/schema/schema.ts @@ -17,12 +17,14 @@ interface ObjectSchema { readonly properties: { readonly [P in K]: Schema }; readonly required?: readonly K[]; readonly layout?: GpuLayout; + readonly default?: never; } interface StringSchema { readonly type: 'string'; readonly minLength?: number; readonly maxLength?: number; + readonly default?: never; } interface ArraySchema { @@ -31,6 +33,7 @@ interface ArraySchema { readonly layout?: GpuLayout; readonly minItems?: number; readonly maxItems?: number; + readonly default?: never; } interface NumberSchema { @@ -38,12 +41,14 @@ interface NumberSchema { readonly minimum?: number; readonly maximum?: number; readonly precision?: 1 | 2; + readonly default?: never; } /** An entity reference, typed `Entity`. Every entity-typed value uses it, so references can be found by schema. */ interface EntitySchema { readonly type: 'integer'; readonly format: 'entity'; + readonly default?: never; } interface IntegerSchema { @@ -51,20 +56,24 @@ interface IntegerSchema { readonly format?: never; readonly minimum?: number; readonly maximum?: number; + readonly default?: never; } interface BooleanSchema { readonly type: 'boolean'; + readonly default?: never; } interface NullSchema { readonly type: 'null'; + readonly default?: never; } /** A `ReadonlySet` of `items`' values. JSON has no set, so `'set'` isn't a JSON Schema type. */ interface SetSchema { readonly type: 'set'; readonly items: T; + readonly default?: never; } /** A `ReadonlyMap` from `keys`' values to `values`' values. JSON has no map, so `'map'` isn't a JSON Schema type. */ @@ -72,6 +81,7 @@ interface MapSchema { readonly type: 'map'; readonly keys: K; readonly values: V; + readonly default?: never; } /** Matches exactly one JSON value of any shape. */ @@ -79,19 +89,23 @@ interface ConstSchema { /** The value alone fully describes the schema; combining it with `type` is redundant and disallowed. */ readonly type?: never; readonly const: JSONValue; + readonly default?: never; } interface AndSchema { readonly allOf: readonly Schema[]; + readonly default?: never; } interface OrSchema { readonly anyOf: readonly Schema[]; + readonly default?: never; } /** * A JSON Schema of the forms the ECS reads, and two forms JSON lacks: `{ type: 'set', items }` and - * `{ type: 'map', keys, values }`. + * `{ type: 'map', keys, values }`. A schema describes values and holds no `default`: a value's start comes from its + * declaration or its type's namespace, `T.default`. */ export type Schema = EntitySchema | ObjectSchema | StringSchema | ArraySchema | NumberSchema | IntegerSchema | BooleanSchema | NullSchema | SetSchema | MapSchema | ConstSchema | AndSchema | OrSchema; @@ -140,6 +154,13 @@ export namespace Schema { T extends NullSchema ? null : never; + /** + * The type schema `S` describes, as a plain type: `type Level = Schema.Type<{ type: "integer"; minimum: 0; maximum: 9 }>` + * is a `number`. Declarations naming such a type, directly or through aliases, get `S` as their schema from the + * build, so a named type carries its precision, width and bounds. + */ + export type Type = ToType; + /** The schemas whose `ToType` is `T`; constrains a schema to describe a known type. */ export type ForType = Schema & ( diff --git a/packages/ecs/src/interface/schema/schema.type-test.ts b/packages/ecs/src/interface/schema/schema.type-test.ts index 09223bcf7..1769737a9 100644 --- a/packages/ecs/src/interface/schema/schema.type-test.ts +++ b/packages/ecs/src/interface/schema/schema.type-test.ts @@ -1,5 +1,4 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import type { IsData } from "./is-data.js"; import type { IsStruct } from "./is-struct.js"; import type { Schema } from "./schema.js"; @@ -135,20 +134,22 @@ const badSetForm: Schema = { type: 'set', keys: { type: 'string' }, values: { ty const badMapForm: Schema = { type: 'map', items: { type: 'string' } }; void [okSet, okMap, badSet, badMap, badSetForm, badMapForm]; -// A ReadonlySet or ReadonlyMap of data is data; a Set or Map, which changes in place, isn't, nor one holding functions. -check>, true>>(); -check>, true>>(); -check void>>, false>>(); -check>, false>>(); -check>, false>>(); -// An object is data only when every member is: a record of handles isn't, however deep they are. -check, false>>(); -check, false>>(); -check, false>>(); -check, true>>(); -// A recursive data type is still data. -type Tree = { readonly value: number; readonly children: readonly Tree[] }; -check, true>>(); -// Neither is a struct: neither lives in shared memory. +// A set or map isn't a struct: neither lives in shared memory. check>, false>>(); check>, false>>(); + +// Schema.Type is the plain type a schema describes, so a type named by it is that type: a number or a tuple, unbranded. +type Level = Schema.Type<{ type: "integer"; minimum: 0; maximum: 9 }>; +check>(); +check, readonly [number, number, number]>>(); +// @ts-expect-error its argument is a schema +type NotSchema = Schema.Type<{ type: "integer"; minimum: "zero" }>; + +// A schema holds no default, anywhere: a value's start comes from its declaration or its type's namespace. +// @ts-expect-error a default isn't part of a schema +const badDefault: Schema = { type: 'number', default: 0 }; +// @ts-expect-error nor of a field's schema +const badFieldDefault: Schema = { type: 'object', properties: { x: { type: 'number', default: 0 } } }; +// @ts-expect-error Schema.Type takes a schema, so a default fails there too +type BadType = Schema.Type<{ type: 'integer'; minimum: 1; default: 5 }>; +void [badDefault, badFieldDefault]; diff --git a/packages/ecs/src/interface/system/access.ts b/packages/ecs/src/interface/system/access.ts index 73cf534e3..f76896bb9 100644 --- a/packages/ecs/src/interface/system/access.ts +++ b/packages/ecs/src/interface/system/access.ts @@ -1,21 +1,17 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { Component } from '../component/component.js'; import type { Resource } from '../resource/resource.js'; -import type { Writable } from '../component/value.js'; import type { Condition } from '../resource/condition.js'; -import type { Reduced } from '../resource/reduction.js'; -import type { WorldWritten } from '../resource/written.js'; import type { Index } from '../indexes/index-type.js'; /** - * Matches every component in `read`, `write`, `has`, `added` and `changed`, and excludes `not`. A component in `write` - * is also readable, so it's never listed in `read` as well. + * The entities a system runs over: those with every component in `with`, `added` and `changed`, and none in `not`. + * What its body reads and writes of them the build infers from the body. */ export type ComponentAccess = { - readonly read?: readonly Component.Key[]; - /** A component holding a `reference` changes only through commands, so it can't be written here. */ - readonly write?: readonly Writable[]; - readonly has?: readonly Component.Key[]; + readonly with?: readonly Component.Key[]; + /** Components its rows hold where an entity has them, `undefined` where it doesn't; they never narrow what it matches. */ + readonly optional?: readonly Component.Key[]; readonly not?: readonly Component.Key[]; /** Passes rows where any of these were added since the system last ran. */ readonly added?: readonly Component.Key[]; @@ -24,15 +20,14 @@ export type ComponentAccess = { }; /** - * Read-only access to other entities: by entity, matching `read`, `has` and `not`, or through an index by key. A lookup - * by entity's `changed` names components it reads or has, whose changes its row's `changed` reports. An index lookup's - * `read` names components every entity the index holds has, its key parts or its `has`, and its `changed` the ones - * whose changes its lists' `changed` reports. Both report as a system's `changed` filter does; its system can't write a - * key part or what it reads. + * Read-only access to other entities: by entity, matching `with` and `not`, its `optional` components read where an + * entity has them, or through an index by key, whose rows hold what every entity the index holds has. What the body reads of them the build infers. A lookup by entity's + * `changed` names components in `with`, whose changes its row's `changed` reports, and an index lookup's those of its + * rows. Both report as a system's `changed` filter does; its system can't write a key part or what it reads. */ export type Lookup = - | Pick & { readonly changed?: readonly Component.Key[] } - | { readonly index: Index; readonly read?: readonly Component.Key[]; readonly changed?: readonly Component.Key[] }; + | { readonly with?: readonly Component.Key[]; readonly optional?: readonly Component.Key[]; readonly not?: readonly Component.Key[]; readonly changed?: readonly Component.Key[]; readonly index?: never } + | { readonly index: Index; readonly changed?: readonly Component.Key[]; readonly with?: never; readonly optional?: never; readonly not?: never }; /** * A system's lookup. An index lookup's `changed: true` makes the index a change filter, as a resource in `changed` is: @@ -41,24 +36,17 @@ export type Lookup = * when a condition fails, keeping its `lastRun`. Its lists' `changed` then reports those changes alone. Only on the * world's thread. */ -export type SystemLookup = - | Lookup - | { readonly index: Index; readonly read?: readonly Component.Key[]; readonly changed: true }; +export type SystemLookup = Lookup | { readonly index: Index; readonly changed: true }; /** * The parent each row's values flow down from: the entity its `through` reference names, read before the row. Rows - * run parent first, so a parent's value is the one written earlier in the same run, and `read` may name components the - * system writes. + * run parent first, so a parent's value is the one written earlier in the same run. The context's `up` holds the + * parent's values the body reads, or null for a row without a parent or whose parent lacks one of them. */ -export type Up = { readonly through: Component.With<"reference">; readonly read?: readonly Component.Key[] }; +export type Up = { readonly through: Component.With<"reference"> }; -/** - * A resource in `write` is also readable, so it's never listed in `read` as well. Reduced resources are never written, - * nor are those only the world writes. - */ +/** The resources a system filters and is conditioned on; what its body reads, writes and reduces the build infers. */ export type ResourceAccess = { - readonly read?: readonly Resource.Key[]; - readonly write?: readonly Exclude[]; /** Passes every row when any of these changed since the system last ran. */ readonly changed?: readonly Resource.Key[]; /** Runs only while each of these is neither `null` nor `false`, and reads it as such. Otherwise it's skipped, successfully. */ @@ -66,3 +54,40 @@ export type ResourceAccess = { /** Runs only while each of these is `null` or `false`. Otherwise it's skipped, successfully. */ readonly not?: readonly Condition[]; }; + +/** A key, as the world takes declarations: their lists come from the build, untyped. */ +type Key = symbol; + +/** + * What a system reads and writes, as the world takes it: the build's inferred access joined with its declaration. + * Matches every component in `read`, `write`, `has`, `added` and `changed` but those in `optional`, and excludes `not`. + */ +export type ComponentLists = { + /** Members its rows hold only where an entity has them; those it uses are in `read` or `write` too. */ + readonly optional?: readonly Key[]; + readonly read?: readonly Key[]; + readonly write?: readonly Key[]; + readonly has?: readonly Key[]; + readonly not?: readonly Key[]; + readonly added?: readonly Key[]; + readonly changed?: readonly Key[]; +}; + +/** A lookup as the world takes it, with what its body reads of its rows in `read`. */ +export type LookupLists = + | { readonly read?: readonly Key[]; readonly has?: readonly Key[]; readonly optional?: readonly Key[]; readonly not?: readonly Key[]; readonly changed?: readonly Key[] } + | { readonly index: Index; readonly read?: readonly Key[]; readonly changed?: readonly Key[] }; + +export type SystemLookupLists = LookupLists | { readonly index: Index; readonly read?: readonly Key[]; readonly changed: true }; + +export type UpLists = { readonly through: Key; readonly read?: readonly Key[] }; + +/** A system's resources as the world takes them. */ +export type ResourceLists = { + readonly read?: readonly Key[]; + readonly write?: readonly Key[]; + readonly reduce?: readonly Key[]; + readonly changed?: readonly Key[]; + readonly is?: readonly Key[]; + readonly not?: readonly Key[]; +}; diff --git a/packages/ecs/src/interface/system/body.ts b/packages/ecs/src/interface/system/body.ts index 4f89a7e19..a979bbfad 100644 --- a/packages/ecs/src/interface/system/body.ts +++ b/packages/ecs/src/interface/system/body.ts @@ -1,50 +1,78 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { Component } from '../component/component.js'; +import type { Writable } from '../component/value.js'; import type { Entity } from '../entity/entity.js'; import type { Resource } from '../resource/resource.js'; import type { Holding } from '../resource/condition.js'; -import type { Schema } from '../schema/schema.js'; +import type { Reduced } from '../resource/reduction.js'; +import type { WorldWritten } from '../resource/written.js'; import type { Commands } from './commands.js'; import type { Declaration } from './declaration.js'; import type { Listed, Property } from './property.js'; import type { RenderContext } from './render.js'; import type { ComponentValues, LookupFunction, ResourceValues, Values } from './values.js'; -/** `read` values are read-only, `write` values readable and writable. */ +/** The components every entity a declaration matches has: those in `with`, `added` and `changed`. */ +export type Members = + | Listed, "with"> | Listed, "added"> | Listed, "changed">; + +/** Whether a declaration only reads: an observer or a render system. */ +type Reading = D extends { readonly observer: true } | { readonly shader: object } ? true : false; + +/** The components a declaration's rows hold only where an entity has them. */ +export type Optional = Exclude, "optional">, Members>; + +/** Each optional component's value or `undefined`, as `ComponentValues` gives members'. */ +type OptionalValues = { readonly [K in Component.Key]: Component.Value | undefined }; + +/** + * Its members' values, writable but for references, and in a system that only reads; its optional components' too, or + * `undefined` where an entity lacks one, which assigning to fails the system. + */ export type Row = { readonly entity: Entity } - & Values, "read">, Listed, "write">, Component.Key, ComponentValues>; + & Values, Reading extends true ? never : Members & Writable, Component.Key, ComponentValues> + & Values, Reading extends true ? never : Optional & Writable, Component.Key, OptionalValues>; + +/** Resources systems write: every one but those reduced, which only their contributors' fold sets, and those only the world writes. */ +type Writes = Exclude; + +/** Every resource, read-only. */ +export type ReadResources = Values; + +/** Every resource, writable as `Writes` says. */ +export type WriteResources = Values; + +/** Each reduced resource, as a function contributing a value to the run's fold. */ +type Reductions = { readonly [K in Reduced]: (value: Resource.Value) => void }; -type Resources = Listed, "read"> | Listed, "write"> | Is; type Is = Listed, "is">; -type Reduced = keyof Property & Resource.Key; /** - * What a body uses besides its row, by kind, under the declaration's own keys: each is present only when the - * declaration declares something of its kind. `commands` is there for every system but a GPU kernel, a render system - * and an observer system. + * What a body uses besides its row: every resource, its lookups by name, the resources it may reduce into, its + * parent's values with `up`, and its commands, there for every system but a GPU kernel, a render system and an + * observer system. The build infers which of them the body reads and writes. */ export type Context = - ([Resources] extends [never] ? {} : { - readonly resources: Values, "read">, Listed, "write">, Resource.Key, ResourceValues> - & { readonly [K in Is & Resource.Key]: Holding }; - }) + { + readonly resources: (Reading extends true ? ReadResources : WriteResources) & { readonly [K in Is & Resource.Key]: Holding }; + } & ([keyof Property] extends [never] ? {} : { readonly lookup: { readonly [K in keyof Property]: LookupFunction[K]> }; }) - & ([Reduced] extends [never] ? {} : { - /** Each resource it reduces into, as a function contributing a value to the run's fold. */ - readonly reduce: { readonly [K in Reduced]: (value: Resource.Value) => void }; - }) - & (Property extends Schema ? { readonly input: Schema.ToType> } : {}) + & (Reading extends true ? {} : { readonly reduce: Reductions }) & (Property extends object ? { - /** The parent's `up.read` values, or null for a row without a parent. */ - readonly up: { readonly [K in Listed, "read"> & Component.Key]: Component.Value } | null; + /** The parent's values the body reads, or null for a row without a parent or whose parent lacks one of them. */ + readonly up: ComponentValues | null; } : {}) & (D extends { readonly kernel: "gpu" } | { readonly shader: object } | { readonly observer: true } ? {} : { readonly commands: Commands }); -/** `(row, context)` for a system with `components`, `(context)` for one without, and `(gpu, context)` for a render system. */ -export type Body = +/** + * `(row, context, input)` for a system with `components`, `(context, input)` for one without, and `(gpu, context)` for + * a render system. `input` is the value each run passes, of the type the body gives its parameter; a body without one + * takes none. + */ +export type Body = D extends { readonly shader: object } ? (gpu: RenderContext, context: Context) => void : - Property extends undefined ? (context: Context) => void : - (row: Row, context: Context) => void; + D extends { readonly components: object } ? (row: Row, context: Context, input: I) => void : + (context: Context, input: I) => void; diff --git a/packages/ecs/src/interface/system/check.ts b/packages/ecs/src/interface/system/check.ts index fb94dca85..76ee7bca7 100644 --- a/packages/ecs/src/interface/system/check.ts +++ b/packages/ecs/src/interface/system/check.ts @@ -1,17 +1,16 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { layoutOf } from '../registry/layout.js'; -import { declarationOf, kindOf, labelOf } from '../registry/names.js'; +import { declarationOf, kindOf, labelOf, whyUnregistered } from '../registry/names.js'; import { definitionOf } from '../registry/program.js'; import { reducedBy, scheduleOf } from '../registry/schedule.js'; import { conditional } from '../registry/schemas.js'; import { requireRegistered, threadOf } from '../registry/threads.js'; import { checkWorldWritten } from '../registry/written.js'; import { checkLookup, indexDefinitionOf, lookupKeysOf, orderKeysOf } from '../registry/indexes.js'; -import { labelOfItem } from '../registry/items.js'; import type { Schema } from '../schema/schema.js'; import type { Composite } from './composite.js'; import type { Definition } from './definition.js'; -import type { Leaf } from './leaf.js'; +import type { InputSchema, Leaf } from './leaf.js'; type Key = symbol; @@ -23,18 +22,15 @@ const checkLists = (name: string, what: string, lists: { readonly read?: readonl }; /** - * Throws if a leaf reduces into a resource with an operator its `reduce` trait doesn't declare, or also reads, writes - * or filters on it, or writes a resource that's reduced, which only its contributors' fold sets. + * Throws if a leaf reduces into a resource without a `reduce` trait, which gives the operator it folds with, or also + * reads, writes or filters on it, or writes a resource that's reduced, which only its contributors' fold sets. */ const checkReductions = (leaf: Leaf): void => { const { name } = leaf; - const { reduce, resources } = leaf.declaration; + const { resources } = leaf.declaration; const others = new Set([...resources?.read ?? [], ...resources?.write ?? [], ...resources?.changed ?? []]); for (const key of reducedBy(leaf)) { - const declared = declarationOf(key)?.reduce; - const operator: unknown = Reflect.get(reduce ?? {}, key); - if (declared === undefined) throw new Error(`System "${name}" reduces "${labelOf(key)}", which has no reduce trait.`); - if (declared !== operator) throw new Error(`System "${name}" reduces "${labelOf(key)}" with "${labelOf(operator)}", but it's declared reduce: "${labelOf(declared)}".`); + if (declarationOf(key)?.reduce === undefined) throw new Error(`System "${name}" reduces "${labelOf(key)}", which has no reduce trait.`); if (others.has(key)) throw new Error(`System "${name}" reduces "${labelOf(key)}", so it can't also read, write or filter on it.`); } for (const key of resources?.write ?? []) { @@ -54,7 +50,7 @@ const checkConditions = (leaf: Leaf): void => { const listed = new Set([...resources?.read ?? [], ...resources?.write ?? []]); for (const key of [...is, ...not]) { const declaration = declarationOf(key); - if (kindOf(key) !== "resource" || declaration === undefined) throw new Error(`System "${name}" runs on the condition of "${labelOf(key)}", which isn't a registered resource.`); + if (kindOf(key) !== "resource" || declaration === undefined) throw new Error(`System "${name}" runs on the condition of "${labelOf(key)}", which isn't a registered resource: ${whyUnregistered(key)}.`); const { type } = declaration; if (type !== undefined && !conditional(type)) throw new Error(`System "${name}" runs on the condition of "${labelOf(key)}", which is never null or false.`); } @@ -70,6 +66,30 @@ const unreadByKernels = (key: symbol): string | null => { return kind === "object" ? "which isn't stored in linear memory" : kind === "string" ? "a string, which kernels don't read yet" : null; }; +/** + * What of a leaf's ordering index and lookups a kernel can't read, or null: workers read them from shared memory, so + * their values must be linear there too. + */ +export const unreadByKernel = (leaf: Leaf): string | null => { + const { lookup, order } = leaf.declaration; + if (order !== undefined) { + for (const key of orderKeysOf(leaf.declaration)) { + const unread = unreadByKernels(key); + if (unread !== null) return `its rows are ordered by an index keyed by "${labelOf(key)}", ${unread}`; + } + } + const lookups = Object.values(lookup ?? {}); + for (const key of lookups.flatMap(found => found.read ?? [])) { + const unread = unreadByKernels(key); + if (unread !== null) return `a lookup reads "${labelOf(key)}", ${unread}`; + } + for (const key of lookups.flatMap(lookupKeysOf)) { + const unread = unreadByKernels(key); + if (unread !== null) return `a lookup goes through an index keyed by "${labelOf(key)}", ${unread}`; + } + return null; +}; + /** * Throws for what a leaf's kind and placement rule out: `up` without components or through a key that isn't a reference * component, `order` by anything but a sorted index, without components, with `up` or on the GPU, a kernel's lookup of @@ -83,16 +103,10 @@ const checkPlacement = (leaf: Leaf): void => { if (order !== undefined) { const index = indexDefinitionOf(order); if (index === undefined) fail("orders its rows by a value Index.create didn't return"); - else if (index.declaration.sorted !== true) fail(`orders its rows by index "${labelOfItem(index)}", which isn't sorted`); + else if (index.declaration.sorted !== true) fail(`orders its rows by index "${index.name}", which isn't sorted`); if (components === undefined) fail("orders its rows without components, so it runs once"); if (up !== undefined) fail("uses both up and order, which both decide the order of its rows"); if (kernel === "gpu") fail("can't run on the GPU: GPU kernels have no order yet"); - if (kernel !== undefined) { - for (const key of orderKeysOf(leaf.declaration)) { - const unread = unreadByKernels(key); - if (unread !== null) throw new Error(`Kernel "${name}": its rows are ordered by an index keyed by "${labelOf(key)}", ${unread}.`); - } - } } if (up !== undefined) { if (components === undefined) fail("uses up, which orders rows, without components"); @@ -101,16 +115,9 @@ const checkPlacement = (leaf: Leaf): void => { } } const lookups = Object.values(lookup ?? {}); - if (kernel !== undefined) { - // Workers read lookups from shared memory, so their values must be linear there too. - for (const key of lookups.flatMap(found => found.read ?? [])) { - const unread = unreadByKernels(key); - if (unread !== null) throw new Error(`Kernel "${name}": a lookup reads "${labelOf(key)}", ${unread}.`); - } - for (const key of lookups.flatMap(lookupKeysOf)) { - const unread = unreadByKernels(key); - if (unread !== null) throw new Error(`Kernel "${name}": a lookup goes through an index keyed by "${labelOf(key)}", ${unread}.`); - } + if (kernel === true || kernel === "gpu") { + const unread = unreadByKernel(leaf); + if (unread !== null) throw new Error(`Kernel "${name}": ${unread}.`); } const reduces = reducedBy(leaf).length > 0; if (kernel === "gpu") { @@ -133,23 +140,22 @@ const checkObserver = (leaf: Leaf): void => { const fail = (message: string): never => { throw new Error(`System "${name}" is an observer, so it ${message}.`); }; if (observer !== true) throw new Error(`System "${name}": observer is true or left out.`); if (leaf.kind === "render") fail("records no GPU work"); - if (declaration.kernel !== undefined) fail("isn't a kernel"); + if (declaration.kernel === true || declaration.kernel === "gpu") fail("isn't a kernel"); if ((declaration.components?.write?.length ?? 0) + (declaration.resources?.write?.length ?? 0) > 0) fail("writes nothing"); if (reducedBy(leaf).length > 0) fail("reduces into nothing"); - if (declaration.input !== undefined) fail("takes no input"); + if (leaf.input !== undefined) fail("takes no input"); if (declaration.up !== undefined) fail("has no up"); if ((declaration.before?.length ?? 0) + (declaration.after?.length ?? 0) + (declaration.during?.length ?? 0) > 0) fail("is ordered with nothing"); }; /** * Throws for a leaf that names a component or resource that isn't registered as one, lists a key in both `read` and - * `write`, writes a reference, has a lookup reading a component it writes, takes input that isn't an object schema, - * uses object components and resources of different threads, declares what an observer system can't, or whose + * `write`, writes a reference, has a lookup reading a component it writes, uses object components and resources of different threads, declares what an observer system can't, or whose * reductions, conditions, `up` or placement can't hold. */ export const checkLeaf = (leaf: Leaf): void => { const { name, declaration } = leaf; - const { components, resources, lookup, input } = declaration; + const { components, resources, lookup } = declaration; checkLists(name, "components", components); checkLists(name, "resources", resources); checkWorldWritten(`System "${name}"`, resources?.write); @@ -166,12 +172,6 @@ export const checkLeaf = (leaf: Leaf): void => { for (const key of orderKeysOf(declaration)) { if (writes.has(key)) throw new Error(`System "${name}": its rows are ordered by an index keyed by "${labelOf(key)}", which the system writes.`); } - if (input !== undefined) { - const schema: Schema = input; - if (!("type" in schema) || schema.type !== "object" || typeof schema.properties !== "object" || schema.properties === null) { - throw new Error(`System "${name}": input is an object schema of named fields.`); - } - } checkTarget(leaf); threadOf(declaration, "System", name); requireRegistered("System", name, "component", components?.has); @@ -190,21 +190,39 @@ export const checkLeaf = (leaf: Leaf): void => { checkPlacement(leaf); }; +const isInputSchema = (value: unknown): value is InputSchema => + typeof value === "object" && value !== null && Reflect.get(value, "type") === "object" + && typeof Reflect.get(value, "properties") === "object" && Reflect.get(value, "properties") !== null; + +/** + * The schema of a body's input, which the plugin passes from the type of its parameter after the `before` before it. + * Throws for a body taking input without one, as one not built with the plugin does, and for a schema that isn't an + * object of named fields. + */ +export const inputSchemaOf = (name: string, body: (...parameters: never[]) => void, before: number, input: unknown): InputSchema | undefined => { + if (input === undefined) { + if (body.length > before) throw new Error(`System "${name}" takes input, whose schema the plugin gives from the type of its body's parameter: write its body in place and build it with Compiler.plugin().`); + return undefined; + } + if (!isInputSchema(input)) throw new Error(`System "${name}": input is an object schema of named fields.`); + return input; +}; + /** * Throws if a system on one entity takes it from what isn't an entity field of its input, has no components, or is a * kernel, has `up` or `order`, or reduces. */ const checkTarget = (leaf: Leaf): void => { - const { name, declaration } = leaf; - const { target, input, components, kernel, up, order, reduce } = declaration; + const { name, declaration, input } = leaf; + const { target, components, kernel, up, order, resources } = declaration; if (target === undefined) return; const fail = (message: string): never => { throw new Error(`System "${name}" runs on one entity, so it ${message}.`); }; const field: Schema | undefined = input !== undefined && "properties" in input ? Reflect.get(input.properties, target) : undefined; if (field === undefined || !("format" in field) || field.format !== "entity") fail(`takes it from an input field "${target}" holding an entity`); if (components === undefined) fail("needs components"); - if (kernel !== undefined) fail("isn't a kernel"); + if (kernel === true || kernel === "gpu") fail("isn't a kernel"); if (up !== undefined || order !== undefined) fail("has no up or order"); - if (reduce !== undefined) fail("reduces into nothing"); + if ((resources?.reduce?.length ?? 0) > 0) fail("reduces into nothing"); }; /** Every leaf under a system, each once. Throws for a member no System constructor returned. */ diff --git a/packages/ecs/src/interface/system/commands.ts b/packages/ecs/src/interface/system/commands.ts index 6269d7b26..ce1953639 100644 --- a/packages/ecs/src/interface/system/commands.ts +++ b/packages/ecs/src/interface/system/commands.ts @@ -2,7 +2,7 @@ import type { Component } from '../component/component.js'; import type { Entity } from '../entity/entity.js'; import type { Essential } from '../component/value.js'; -import type { ValuesOf } from '../archetype/values-of.js'; +import type { ValuesOf } from './values-of.js'; /** The value an `add` of one component takes: none for a tag. */ type ValueArgument = Component.Value extends true ? [value?: true] : [value: Component.Value]; diff --git a/packages/ecs/src/interface/system/commands.type-test.ts b/packages/ecs/src/interface/system/commands.type-test.ts index 82a83a58f..127eebd86 100644 --- a/packages/ecs/src/interface/system/commands.type-test.ts +++ b/packages/ecs/src/interface/system/commands.type-test.ts @@ -1,7 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { Resource } from '../resource/resource.js'; import { Component } from '../component/component.js'; -import { Archetype } from '../archetype/archetype.js'; import type { Entity } from '../entity/entity.js'; import type { Commands } from './commands.js'; @@ -26,14 +25,8 @@ declare module '@adobe/ecs' { [time]: { type: number }; } } -Component.register(position, { type: { type: "number" } }); -Component.register(velocity, { type: { type: "number" } }); -Component.register(label, { type: { anyOf: [{ type: "string" }, { type: "null" }] } }); -Component.register(player, { type: { const: true } }); -Component.register(slot, { type: { type: "number", default: 0 }, derived: true }); -Resource.register(time, { type: { type: "number", default: 0 } }); -const particle = Archetype.create([position, velocity, label, player]); +const particle = [position, velocity, label, player] as const; export const issue = (commands: Commands, entity: Entity): void => { // `spawn` is curried: the components first, then their values, giving an entity. diff --git a/packages/ecs/src/interface/system/composite-declaration.ts b/packages/ecs/src/interface/system/composite-declaration.ts index f3c139222..07d0c1fa2 100644 --- a/packages/ecs/src/interface/system/composite-declaration.ts +++ b/packages/ecs/src/interface/system/composite-declaration.ts @@ -1,10 +1,8 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import type { Name } from './name.js'; import type { System } from './system.js'; /** Each run passes every member the same input, with the fields of all their inputs. */ export type CompositeDeclaration = { - readonly name?: Name; readonly systems: readonly System[]; /** Hard constraint, applied to every member. */ readonly before?: readonly System[]; diff --git a/packages/ecs/src/interface/system/composite-input.type-test.ts b/packages/ecs/src/interface/system/composite-input.type-test.ts index edd7a682f..93b087110 100644 --- a/packages/ecs/src/interface/system/composite-input.type-test.ts +++ b/packages/ecs/src/interface/system/composite-input.type-test.ts @@ -5,60 +5,43 @@ import { System } from './system.js'; type Equal = (() => T extends X ? 1 : 2) extends (() => T extends Y ? 1 : 2) ? true : false; const check = <_T extends true>() => { }; -const move = System.create({ - name: "compositeInputTest/move", - input: { type: "object", properties: { distance: { type: "number" } } }, -}, ({ input }) => { void input.distance; }); -const step = System.create({ - name: "compositeInputTest/step", - input: { type: "object", properties: { distance: { type: "number" }, dt: { type: "number" } } }, -}, ({ input }) => { void input.dt; }); -const pause = System.create({ - name: "compositeInputTest/pause", - input: { type: "object", properties: { dt: { type: "number" } }, required: [] }, -}, ({ input }) => { void input.dt; }); -const label = System.create({ - name: "compositeInputTest/label", - input: { type: "object", properties: { distance: { type: "string" } } }, -}, ({ input }) => { void input.distance; }); -const idle = System.create({ name: "compositeInputTest/idle" }, () => {}); +const move = System.create({}, (_context, input: { readonly distance: number }) => { void input.distance; }); +const step = System.create({}, (_context, input: { readonly distance: number; readonly dt: number }) => { void input.dt; }); +const pause = System.create({}, (_context, input: { readonly dt?: number }) => { void input.dt; }); +const label = System.create({}, (_context, input: { readonly distance: string }) => { void input.distance; }); +const idle = System.create({}, () => {}); // A composite takes every field its members' inputs have; a field in several is one value for all of them. -const frame = System.create({ name: "compositeInputTest/frame", systems: [move, step, idle] }); +const frame = System.create({ systems: [move, step, idle] }); check>>(); // A field optional in one member and required in another is required. -const paused = System.create({ name: "compositeInputTest/paused", systems: [pause, step] }); +const paused = System.create({ systems: [pause, step] }); check>>(); // Nested composites take their members' fields too. -const outer = System.create({ name: "compositeInputTest/outer", systems: [System.create({ name: "compositeInputTest/inner", systems: [move] }), pause] }); +const outer = System.create({ systems: [System.create({ systems: [move] }), pause] }); check>>(); -// A composite without a name or constraints is its list of members. +// A composite without constraints is its list of members. const listed = System.create([move, step, idle]); check>>(); const constrained = System.create({ systems: [pause], after: [listed] }); check>>(); // Without inputs, a composite takes none. -const quiet = System.create({ name: "compositeInputTest/quiet", systems: [idle] }); +const quiet = System.create({ systems: [idle] }); check>(); // @ts-expect-error a field with different types in two members is an error at the member -System.create({ name: "compositeInputTest/conflict", systems: [move, label] }); +System.create({ systems: [move, label] }); // @ts-expect-error in a list of members too System.create([move, label]); // @ts-expect-error a composite declaration lists its systems -System.create({ name: "compositeInputTest/empty", after: [move] }); +System.create({ after: [move] }); declare const world: World; void world.run(frame, { distance: 1, dt: 0.5 }); // @ts-expect-error run takes every field void world.run(frame, { distance: 1 }); -System.create({ - name: "compositeInputTest/scalar", - // @ts-expect-error input is an object of named fields - input: { type: "number" }, -}, () => {}); diff --git a/packages/ecs/src/interface/system/conditions.type-test.ts b/packages/ecs/src/interface/system/conditions.type-test.ts index e56d94a37..081da4021 100644 --- a/packages/ecs/src/interface/system/conditions.type-test.ts +++ b/packages/ecs/src/interface/system/conditions.type-test.ts @@ -25,45 +25,33 @@ declare module '@adobe/ecs' { [position]: { type: number }; } } -Resource.register(handle, { default: new Map() }); -Resource.register(level, { type: { anyOf: [{ type: "object", properties: { size: { type: "number" } } }, { type: "null" }], default: null } }); -Resource.register(paused, { type: { type: "boolean", default: false } }); -Resource.register(cache, {}); -Resource.register(count, { type: { type: "number", default: 0 } }); -Component.register(position, { type: { type: "number" } }); // `is`: runs while each is neither null nor false, and reads it narrowed so. -System.create({ name: "conditionsTypeTest/loaded", resources: { is: [level, cache] } }, ({ resources }) => { +System.create({ resources: { is: [level, cache] } }, ({ resources }) => { check>(); check>>(); // @ts-expect-error a condition is read-only resources[level] = null; }); -System.create({ name: "conditionsTypeTest/playing", resources: { is: [paused] } }, ({ resources }) => { +System.create({ resources: { is: [paused] } }, ({ resources }) => { check>(); }); -// `not`: runs while each is null or false; its value is known, so it isn't readable. -System.create({ name: "conditionsTypeTest/running", components: { write: [position] }, resources: { not: [paused] } }, (row, context) => { +// `not`: runs while each is null or false. +System.create({ components: { with: [position] }, resources: { not: [paused] } }, row => { row[position] += 1; - // @ts-expect-error a `not` condition isn't readable - void context.resources; }); // Only a resource whose type allows null or false is a condition. // @ts-expect-error a number is never null or false -System.create({ name: "conditionsTypeTest/counted", resources: { is: [count] } }, () => {}); +System.create({ resources: { is: [count] } }, () => {}); // @ts-expect-error a value that isn't data and doesn't allow null is never null -System.create({ name: "conditionsTypeTest/handled", resources: { not: [handle] } }, () => {}); +System.create({ resources: { not: [handle] } }, () => {}); -// A condition is listed once: `is` already makes it readable, and a written value would have two types. +// A condition is listed once. // @ts-expect-error both is and not -System.create({ name: "conditionsTypeTest/both", resources: { is: [paused], not: [paused] } }, () => {}); -// @ts-expect-error is already makes it readable -System.create({ name: "conditionsTypeTest/read", resources: { is: [level], read: [level] } }, () => {}); -// @ts-expect-error a condition isn't written -System.create({ name: "conditionsTypeTest/write", resources: { is: [level], write: [level] } }, () => {}); +System.create({ resources: { is: [paused], not: [paused] } }, () => {}); // Kernels and render systems take conditions too. -System.create({ name: "conditionsTypeTest/kernel", kernel: "cpu", components: { write: [position] }, resources: { not: [paused] } }, row => { row[position] += 1; }); -System.render({ name: "conditionsTypeTest/draw", resources: { is: [level] }, shader: { accessors: { place: position } } }, () => {}); +System.create({ kernel: true, components: { with: [position] }, resources: { not: [paused] } }, row => { row[position] += 1; }); +System.render({ resources: { is: [level] }, shader: { accessors: { place: position } } }, () => {}); diff --git a/packages/ecs/src/interface/system/create-resolved.ts b/packages/ecs/src/interface/system/create-resolved.ts new file mode 100644 index 000000000..e1e07e4f7 --- /dev/null +++ b/packages/ecs/src/interface/system/create-resolved.ts @@ -0,0 +1,21 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { nameOf } from '../registry/items.js'; +import { claimSystem } from '../registry/program.js'; +import { checkLeaf, inputSchemaOf } from './check.js'; +import type { Resolved } from './declaration.js'; +import type { Leaf } from './leaf.js'; +import type { System } from './system.js'; + +/** + * Makes a system of a declaration as the world takes it, with what its body reads and writes already listed: for the + * implementation's own systems, such as those standing for a joined thread's, whose bodies no build reads. Checks it + * as `System.create` does. Not part of the package's interface. + */ +export const createResolved = (declaration: Resolved, body: Leaf["body"], input?: unknown): System => { + const name = nameOf(declaration, undefined, "system"); + const schema = inputSchemaOf(name, body, declaration.components === undefined ? 1 : 2, input); + const system: Leaf & System = { kind: "system", name, declaration, input: schema, body }; + checkLeaf(system); + claimSystem(system, undefined); + return system; +}; diff --git a/packages/ecs/src/interface/system/create.test.ts b/packages/ecs/src/interface/system/create.test.ts index 1f78e7278..0324b73c4 100644 --- a/packages/ecs/src/interface/system/create.test.ts +++ b/packages/ecs/src/interface/system/create.test.ts @@ -1,18 +1,19 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; -import { Component } from "../component/component.js"; import { Entity } from "../entity/entity.js"; -import { definitionOf, firstItemOf, labelOfItem, requireDefinition, sameItem, scheduleOf, threadOf } from "../registry/index.js"; -import { identify } from "../registry/ids.test-helper.js"; -import { Resource } from "../resource/resource.js"; +import { definitionOf, firstItemOf, idOrNull, requireDefinition, sameItem, scheduleOf, threadOf } from "../registry/index.js"; +import { giveAccess, identify } from "../registry/ids.test-helper.js"; import { clock, gpuDevice } from "../resources/index.js"; import { System } from "./system.js"; +import { createResolved } from "./create-resolved.js"; +import type { F32 } from "../math/index.js"; const position = Symbol("typesSystemTest/position"); const label = Symbol("typesSystemTest/label"); const node = Symbol("typesSystemTest/node"); const document = Symbol("typesSystemTest/document"); const time = Symbol("typesSystemTest/time"); +// Never declared, so never registered. const unregistered = Symbol("typesSystemTest/unregistered"); const parent = Symbol("typesSystemTest/parent"); const total = Symbol("typesSystemTest/total"); @@ -25,9 +26,8 @@ declare module "@adobe/ecs" { [position]: { type: number }; [label]: { type: string }; [node]: { type: HTMLElement; thread: "main" }; - [unregistered]: { type: number }; [parent]: { type: Entity; reference: "weak" }; - [height]: { type: number }; + [height]: { type: F32 }; [tags]: { type: readonly string[] }; } export interface Resources { @@ -38,76 +38,54 @@ declare module "@adobe/ecs" { } } -Component.register(position, { type: { type: "number" } }); -Component.register(label, { type: { type: "string" } }); -Component.register(node, { thread: "main" }); -Component.register(tags, { type: { type: "array", items: { type: "string" } } }); -Resource.register(document, { thread: "main" }); -Resource.register(time, { type: { type: "number", default: 0 } }); -Component.register(parent, { type: Entity.schema, reference: "weak" }); -Resource.register(total, { type: { type: "number", default: 0 }, reduce: "+" }); -Resource.register(paused, { type: { type: "boolean", default: false } }); -Component.register(height, { type: { type: "number", precision: 1 } }); -/** Creates as an untyped caller would, bypassing the types' checks. */ -const create = (declaration: object): unknown => Reflect.apply(System.create, undefined, [declaration, () => {}]); +/** Creates a system of a declaration as the world takes it, what its body reads and writes listed, bypassing the types' checks. */ +const create = (declaration: object): unknown => Reflect.apply(createResolved, undefined, [declaration, () => {}]); +/** As the plugin calls it for a body taking input: with the schema of its input after the body. */ +const createTaking = (declaration: object, input: unknown): unknown => Reflect.apply(createResolved, undefined, [declaration, (_context: unknown, _input: unknown) => {}, input]); describe("System.create", () => { it("records the system, its value holding its declaration and body", () => { const body = (): void => {}; - const move = System.create({ name: "typesSystemTest/move", components: { write: [position] }, resources: { read: [time] } }, body); - expect(definitionOf(move)).toEqual({ kind: "system", name: "typesSystemTest/move", declaration: { name: "typesSystemTest/move", components: { write: [position] }, resources: { read: [time] } }, body }); - expect(labelOfItem(move)).toBe("typesSystemTest/move"); - expect(definitionOf({ name: "typesSystemTest/move" })).toBe(undefined); - }); - it("keeps systems of one name apart, labeling each by its name numbered", () => { - const first = System.create({ name: "typesSystemTest/twice", components: { read: [position] } }, row => { void row[position]; }); - expect(labelOfItem(first)).toBe("typesSystemTest/twice"); - const again = System.create({ name: "typesSystemTest/twice", components: { read: [position] } }, row => { void row[position]; }); - const other = System.create({ name: "typesSystemTest/twice", components: { write: [position] } }, row => { row[position] = 0; }); + // A body the build doesn't see is given what it uses. + const move = System.create(giveAccess({ components: { with: [position] } }, { write: [position], resources: { read: [time] } }), body); + expect(definitionOf(move)).toEqual({ kind: "system", name: "move", declaration: { components: { write: [position] }, resources: { read: [time] } }, body }); + expect(move.name).toBe("move"); + expect(definitionOf({ name: "move" })).toBe(undefined); + }); + it("labels each system by the binding or function it's made in, however many share the label", () => { + const twice = (): System => System.create({ components: { with: [position] } }, row => { void row[position]; }); + const [first, again] = [twice(), twice()]; expect(sameItem(first, again)).toBe(false); - expect([first, again, other].map(labelOfItem)).toEqual(["typesSystemTest/twice", "typesSystemTest/twice#2", "typesSystemTest/twice#3"]); - expect(scheduleOf(requireDefinition(System.create({ name: "typesSystemTest/twice", systems: [first, again] }))).tiers.flat()).toEqual([first, again]); - }); - it("labels systems of one name by name and id, and takes a declaration of a registered id, the same, as the same system", () => { - const declare = (id: string, write: boolean) => System.create(identify({ name: "typesSystemTest/linked", components: write ? { write: [position] } : { read: [position] } }, id), () => {}); - const first = declare("a.ts#linked", false); - const elsewhere = declare("b.ts#linked", false); - expect([labelOfItem(first), labelOfItem(elsewhere)]).toEqual(["typesSystemTest/linked (a.ts#linked)", "typesSystemTest/linked (b.ts#linked)"]); - const again = declare("a.ts#linked", false); + expect([first.name, again.name, idOrNull(first)]).toEqual(["twice", "twice", null]); + expect(scheduleOf(requireDefinition(System.create({ systems: [first, again] }))).tiers.flat()).toEqual([first, again]); + }); + it("labels a system by the binding of its id, which reports give beside it, and takes a declaration of a registered id, the same, as the same system", () => { + const [first, elsewhere, again] = ["a.ts#linked", "b.ts#linked", "a.ts#linked"].map(id => System.create(identify({ components: { with: [position] } }, id), () => {})); + expect([first?.name, elsewhere?.name, first === undefined ? null : idOrNull(first)]).toEqual(["linked", "linked", "a.ts#linked"]); expect(again).not.toBe(first); - expect([sameItem(first, again), sameItem(first, elsewhere), firstItemOf(again)]).toEqual([true, false, first]); - expect(labelOfItem(again)).toBe("typesSystemTest/linked (a.ts#linked)"); - const write = System.create({ name: "typesSystemTest/afterLinked", components: { write: [position] }, after: [again] }, row => { row[position] = 0; }); - const tiers = scheduleOf(requireDefinition(System.create({ name: "typesSystemTest/linkedFrame", systems: [write, first, again] }))).tiers; - expect(tiers).toEqual([[first], [write]]); - expect(() => declare("a.ts#linked", true)).toThrow('Cannot declare "typesSystemTest/linked" (a.ts#linked): its id is another item\'s, with another declaration; if a module was evaluated again with a changed declaration, reload the page.'); - expect(() => System.create(identify({ name: "typesSystemTest/linked", systems: [first] }, "a.ts#linked"))).toThrow("its id is a system's"); - expect(labelOfItem(declare("c.ts#linked", false))).toBe("typesSystemTest/linked (c.ts#linked)"); - }); - it("names a system declared without a name by its binding and id, or else by its kind numbered", () => { + expect(first !== undefined && again !== undefined && elsewhere !== undefined ? [sameItem(first, again), sameItem(first, elsewhere), firstItemOf(again)] : null).toEqual([true, false, first]); + expect(() => ["a.ts#linked"].map(id => System.create(identify({ components: { with: [position], changed: [position] } }, id), () => {}))).toThrow('Cannot declare "linked" (a.ts#linked): its id is another item\'s, with another declaration; if a module was evaluated again with a changed declaration, reload the page.'); + expect(() => ["a.ts#linked"].map(id => first === undefined ? null : System.create(identify({ systems: [first] }, id)))).toThrow("its id is a system's"); + }); + it("labels a system made with neither an id nor a binding by its kind numbered", () => { const body = ({ commands }: { readonly commands: System.Commands }): void => { commands.spawn([position])({ [position]: 0 }); }; - const alone = System.create(identify(body, "setup.ts#spawnOnce")); - expect(definitionOf(alone)).toEqual({ kind: "system", name: "spawnOnce (setup.ts#spawnOnce)", declaration: {}, body }); - expect(labelOfItem(alone)).toBe("spawnOnce (setup.ts#spawnOnce)"); - expect(sameItem(System.create(identify(body, "setup.ts#spawnOnce")), alone)).toBe(true); - const show = System.create(identify({ components: { read: [position] } }, "show.ts#show"), row => { void row[position]; }); - expect(labelOfItem(show)).toBe("show (show.ts#show)"); - const [first, second] = [System.create(() => {}), System.create({ systems: [show] })].map(labelOfItem); + const [alone] = [System.create(giveAccess(identify(body, "setup.ts#spawnOnce")))]; + expect(definitionOf(alone)).toEqual({ kind: "system", name: "spawnOnce", declaration: {}, body }); + const [first, second] = [System.create(() => {}), System.create({ systems: [alone] })].map(system => system.name); const number = Number(first?.slice("system #".length)); expect([first, second]).toEqual([`system #${number}`, `system #${number + 1}`]); - expect(labelOfItem(System.render({ shader: { accessors: {} } }, () => {}))).toBe(`system #${number + 2}`); - expect(() => create({ components: { read: [position], write: [position] } })).toThrow(`System "system #${number + 3}" lists "typesSystemTest/position" in both components read and write`); + expect([System.render({ shader: { accessors: {} } }, () => {})].map(system => system.name)).toEqual([`system #${number + 2}`]); + expect(() => create({ components: { read: [position], write: [position] } })).toThrow(`lists "typesSystemTest/position" in both components read and write`); }); it("rejects writing what only the world writes, for untyped callers", () => { - expect(() => create({ name: "typesSystemTest/clock", resources: { write: [clock] } })).toThrow(`writes "clock", which only the world writes`); + expect(() => create({ resources: { write: [clock] } })).toThrow(`writes "clock", which only the world writes`); }); it("rejects what types reject, for untyped callers", () => { - expect(() => create({ name: "typesSystemTest/both", components: { read: [position], write: [position] } })).toThrow("in both components read and write"); - expect(() => create({ name: "typesSystemTest/bothResources", resources: { read: [time], write: [time] } })).toThrow("in both resources read and write"); - expect(() => create({ name: "typesSystemTest/lookup", components: { write: [position] }, lookup: { others: { read: [position] } } })).toThrow('lookup "others" reads "typesSystemTest/position", which the system writes'); - expect(() => create({ name: "typesSystemTest/input", input: { type: "number" } })).toThrow("input is an object schema of named fields"); - expect(labelOfItem(System.create({ name: "typesSystemTest/both" }, () => {}))).toBe("typesSystemTest/both"); + expect(() => create({ components: { read: [position], write: [position] } })).toThrow("in both components read and write"); + expect(() => create({ resources: { read: [time], write: [time] } })).toThrow("in both resources read and write"); + expect(() => create({ components: { write: [position] }, lookup: { others: { read: [position] } } })).toThrow('lookup "others" reads "typesSystemTest/position", which the system writes'); + expect(() => createTaking({}, { type: "number" })).toThrow("input is an object schema of named fields"); }); it("rejects at creation what the registry rules out, for untyped callers", () => { const rejected: readonly (readonly [object, string])[] = [ @@ -115,10 +93,9 @@ describe("System.create", () => { [{ components: { read: [position] }, lookup: { others: { read: [position], not: [unregistered] } } }, `uses component "typesSystemTest/unregistered", which isn't a registered component`], [{ components: { read: [position] }, lookup: { others: { read: [position], changed: [height] } } }, `lookup "others" watches "typesSystemTest/height", which it neither reads nor has`], [{ components: { write: [parent] } }, `writes "typesSystemTest/parent", a reference, which changes only through commands`], - [{ components: { has: [position] }, reduce: { [total]: "max" } }, `reduces "typesSystemTest/total" with "max", but it's declared reduce: "+"`], - [{ components: { has: [position] }, reduce: { [time]: "+" } }, `reduces "typesSystemTest/time", which has no reduce trait`], + [{ components: { has: [position] }, resources: { reduce: [time] } }, `reduces "typesSystemTest/time", which has no reduce trait`], [{ resources: { write: [total] } }, `writes "typesSystemTest/total", which is reduced`], - [{ components: { has: [position] }, resources: { read: [total] }, reduce: { [total]: "+" } }, `reduces "typesSystemTest/total", so it can't also read, write or filter on it`], + [{ components: { has: [position] }, resources: { read: [total], reduce: [total] } }, `reduces "typesSystemTest/total", so it can't also read, write or filter on it`], [{ resources: { is: [time] } }, `runs on the condition of "typesSystemTest/time", which is never null or false`], [{ resources: { not: [position] } }, `runs on the condition of "typesSystemTest/position", which isn't a registered resource`], [{ resources: { is: [paused], not: [paused] } }, `lists "typesSystemTest/paused" in both resources is and not`], @@ -127,105 +104,107 @@ describe("System.create", () => { [{ up: { through: parent } }, "uses up, which orders rows, without components"], [{ kernel: "gpu", components: { write: [position], changed: [position] } }, "can't run on the GPU: GPU kernels don't support change filters yet"], [{ kernel: "gpu", components: { read: [position] }, lookup: { others: { read: [position] } } }, "can't run on the GPU: GPU kernels don't support lookups yet"], - [{ kernel: "gpu", components: { has: [position] }, reduce: { [total]: "+" } }, "can't run on the GPU: GPU kernels don't support reductions yet"], + [{ kernel: "gpu", components: { has: [position] }, resources: { reduce: [total] } }, "can't run on the GPU: GPU kernels don't support reductions yet"], [{ observer: false, resources: { read: [time] } }, "observer is true or left out"], [{ observer: true, components: { write: [position] } }, "is an observer, so it writes nothing"], [{ observer: true, resources: { write: [time] } }, "is an observer, so it writes nothing"], - [{ observer: true, components: { has: [position] }, reduce: { [total]: "+" } }, "is an observer, so it reduces into nothing"], - [{ observer: true, input: { type: "object", properties: {} } }, "is an observer, so it takes no input"], - [{ observer: true, kernel: "cpu", components: { read: [position] } }, "is an observer, so it isn't a kernel"], + [{ observer: true, components: { 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"], ]; + expect(() => createTaking({ observer: true }, { type: "object", properties: {} })).toThrow("is an observer, so it takes no input"); rejected.forEach(([declaration, message], index) => { - expect(() => create({ name: `typesSystemTest/rejected/${index}`, ...declaration })).toThrow(message); - // A rejected system records nothing, so its name labels only the next. - expect(labelOfItem(System.create({ name: `typesSystemTest/rejected/${index}` }, () => {}))).toBe(`typesSystemTest/rejected/${index}`); + expect(() => create({ ...declaration })).toThrow(message); }); }); it("derives its thread from its object components and resources, and rejects two", () => { - const show = System.create({ name: "typesSystemTest/show", components: { read: [position, node] }, resources: { read: [time, document] } }, (row, { resources }) => { row[node].textContent = `${row[position]} ${resources[time]} ${resources[document]?.title}`; }); - const count = System.create({ name: "typesSystemTest/count", components: { read: [tags], has: [node] } }, row => { void row[tags]; }); - const tick = System.create({ name: "typesSystemTest/tick", components: { write: [position] } }, row => { row[position] += 1; }); + const show = System.create({ components: { with: [position, node] } }, (row, { resources }) => { row[node].textContent = `${row[position]} ${resources[time]} ${resources[document]?.title}`; }); + const count = System.create({ components: { with: [tags, node] } }, row => { void row[tags]; }); + const tick = System.create({ components: { with: [position] } }, row => { row[position] += 1; }); const leaves = [show, count, tick].map(system => definitionOf(system)); expect(leaves.map(leaf => leaf === undefined || leaf.kind === "composite" ? undefined : threadOf(leaf.declaration, "System", leaf.name))).toEqual(["main", "world", null]); - expect(() => create({ name: "typesSystemTest/span", components: { read: [tags], write: [node] } })).toThrow('System "typesSystemTest/span" uses "typesSystemTest/tags" on "world" and "typesSystemTest/node" on "main"'); - expect(() => create({ name: "typesSystemTest/spanResource", components: { read: [tags] }, resources: { is: [document] } })).toThrow('uses "typesSystemTest/tags" on "world" and "typesSystemTest/document" on "main"'); + expect(() => create({ components: { read: [tags], write: [node] } })).toThrow('uses "typesSystemTest/tags" on "world" and "typesSystemTest/node" on "main"'); + expect(() => create({ components: { read: [tags] }, resources: { is: [document] } })).toThrow('uses "typesSystemTest/tags" on "world" and "typesSystemTest/document" on "main"'); // A string lives in shared memory, so it goes with any thread's objects. - const caption = System.create({ name: "typesSystemTest/caption", components: { read: [label, node] } }, row => { row[node].textContent = row[label]; }); + const caption = System.create({ components: { with: [label, node] } }, row => { row[node].textContent = row[label]; }); const captionLeaf = definitionOf(caption); expect(captionLeaf === undefined || captionLeaf.kind === "composite" ? undefined : threadOf(captionLeaf.declaration, "System", captionLeaf.name)).toBe("main"); - expect(() => create({ name: "typesSystemTest/unregistered", components: { read: [unregistered] } })).toThrow(`uses component "typesSystemTest/unregistered", which isn't a registered component`); - expect(() => create({ name: "typesSystemTest/wrongKind", components: { read: [time] } })).toThrow(`uses component "typesSystemTest/time", which isn't a registered component`); + expect(() => create({ components: { read: [unregistered] } })).toThrow(`uses component "typesSystemTest/unregistered", which isn't a registered component`); + expect(() => create({ components: { read: [time] } })).toThrow(`uses component "typesSystemTest/time", which isn't a registered component`); + }); + it("says why a key isn't registered: undeclared, or its module not built with the plugin", () => { + const create = (declaration: unknown) => Reflect.apply(createResolved, undefined, [declaration, () => {}]); + expect(() => create({ components: { read: [unregistered] } })).toThrow("declare it beside its key, in interface Components or Resources"); + // Made in a function, so the build gave it no id, as one made in a module the plugin didn't build has none. + const unbuilt = Symbol("unbuilt"); + expect(() => create({ components: { read: [unbuilt] } })).toThrow("Compiler.plugin() registers keys from their declarations, and didn't build the module making it"); }); }); describe("System.create of a composite", () => { it("records a composite whose members may live on different threads, from its list or its declaration", () => { - const show = System.create({ name: "typesSystemTest/showMember", components: { read: [node] } }, row => { row[node].hidden = true; }); - const name = System.create({ name: "typesSystemTest/nameMember", components: { write: [label] } }, row => { row[label] = ""; }); - const frame = System.create({ name: "typesSystemTest/frame", systems: [show, name] }); + const show = System.create({ components: { with: [node] } }, row => { row[node].hidden = true; }); + const name = System.create({ components: { with: [label] } }, row => { row[label] = ""; }); + const frame = System.create({ systems: [show, name] }); expect(definitionOf(frame)?.kind).toBe("composite"); - expect(definitionOf(System.create({ name: "typesSystemTest/frame", systems: [show] }))).not.toBe(definitionOf(frame)); + expect(definitionOf(System.create({ systems: [show] }))).not.toBe(definitionOf(frame)); const listed = System.create([show, name]); const listedDefinition = definitionOf(listed); expect(listedDefinition?.kind === "composite" ? listedDefinition.declaration.systems : undefined).toEqual([show, name]); - expect(listed.name).toMatch(/^system #\d+$/); + expect(listed.name).toBe("listed"); }); it("tells a composite's declaration from a system's by its body", () => { const untyped: (...parameters: never[]) => unknown = System.create; - expect(() => Reflect.apply(untyped, undefined, [{ name: "typesSystemTest/bodiless" }])).toThrow("A system needs a body, and a composite its systems."); - const leaf = Reflect.apply(untyped, undefined, [{ name: "typesSystemTest/leafWithSystems", systems: [] }, () => {}]); + expect(() => Reflect.apply(untyped, undefined, [{ name: "bodiless" }])).toThrow("A system needs a body, and a composite its systems."); + const leaf = Reflect.apply(untyped, undefined, [giveAccess({ systems: [] }), () => {}]); expect(definitionOf(leaf)?.kind).toBe("system"); }); it("rejects members ordered in a cycle, and conflicting members with no order between them", () => { - const first = System.create({ name: "typesSystemTest/first", components: { write: [position] } }, row => { row[position] += 1; }); - const second = System.create({ name: "typesSystemTest/second", components: { read: [position] }, after: [first] }, row => { void row[position]; }); - const third = System.create({ name: "typesSystemTest/third", components: { read: [position] } }, row => { void row[position]; }); - expect(scheduleOf(requireDefinition(System.create({ name: "typesSystemTest/ordered", systems: [second, first] }))).tiers.map(tier => tier.map(leaf => leaf.name))).toEqual([["typesSystemTest/first"], ["typesSystemTest/second"]]); - expect(() => System.create({ name: "typesSystemTest/unordered", systems: [first, third] })).toThrow('Systems "typesSystemTest/first" and "typesSystemTest/third" both access "typesSystemTest/position" and one writes it'); - const looped = System.create({ name: "typesSystemTest/looped", before: [first], after: [second] }, () => {}); - expect(() => System.create({ name: "typesSystemTest/cycle", systems: [first, second, looped] })).toThrow("Ordering cycle"); - expect(labelOfItem(System.create({ name: "typesSystemTest/unordered", systems: [first] }))).toBe("typesSystemTest/unordered"); + const first = System.create({ components: { with: [position] } }, row => { row[position] += 1; }); + const second = System.create({ components: { with: [position] }, after: [first] }, row => { void row[position]; }); + const third = System.create({ components: { with: [position] } }, row => { void row[position]; }); + expect(scheduleOf(requireDefinition(System.create({ systems: [second, first] }))).tiers.map(tier => tier.map(leaf => leaf.name))).toEqual([["first"], ["second"]]); + expect(() => System.create({ systems: [first, third] })).toThrow('Systems "first" and "third" both access "typesSystemTest/position" and one writes it'); + const looped = System.create({ before: [first], after: [second] }, () => {}); + expect(() => System.create({ systems: [first, second, looped] })).toThrow("Ordering cycle"); }); it("runs observer members after its sync point, in no tier, never conflicting and ignoring constraints naming them", () => { - const write = System.create({ name: "typesSystemTest/observerWriter", components: { write: [position] } }, row => { row[position] += 1; }); - const watch = System.create({ name: "typesSystemTest/observer", observer: true, components: { read: [position] } }, row => { void row[position]; }); - const later = System.create({ name: "typesSystemTest/afterObserver", components: { read: [position] }, after: [watch, write] }, row => { void row[position]; }); - const free = System.create({ name: "typesSystemTest/afterObserverOnly", resources: { read: [time] }, after: [watch] }, ({ resources }) => { void resources[time]; }); + const write = System.create({ components: { with: [position] } }, row => { row[position] += 1; }); + const watch = System.create({ observer: true, components: { with: [position] } }, row => { void row[position]; }); + const later = System.create({ components: { with: [position] }, after: [watch, write] }, row => { void row[position]; }); + const free = System.create({ after: [watch] }, ({ resources }) => { void resources[time]; }); const schedule = scheduleOf(requireDefinition(System.create([write, watch, later, free]))); - expect(schedule.tiers.map(tier => tier.map(leaf => leaf.name))).toEqual([["typesSystemTest/observerWriter", "typesSystemTest/afterObserverOnly"], ["typesSystemTest/afterObserver"]]); - expect(schedule.observer.map(leaf => leaf.name)).toEqual(["typesSystemTest/observer"]); + expect(schedule.tiers.map(tier => tier.map(leaf => leaf.name))).toEqual([["write", "free"], ["later"]]); + expect(schedule.observer.map(leaf => leaf.name)).toEqual(["watch"]); const alone = scheduleOf(requireDefinition(watch)); - expect([alone.tiers.length, alone.observer.map(leaf => leaf.name)]).toEqual([0, ["typesSystemTest/observer"]]); + expect([alone.tiers.length, alone.observer.map(leaf => leaf.name)]).toEqual([0, ["watch"]]); }); it("rejects a member no constructor returned, and an input field two members declare differently", () => { - expect(() => System.create({ name: "typesSystemTest/forged", systems: [{ name: "typesSystemTest/showMember" }] })).toThrow('member "typesSystemTest/showMember" isn\'t a system a System constructor returned'); - const move = System.create({ name: "typesSystemTest/moveBy", input: { type: "object", properties: { distance: { type: "number" } } } }, () => {}); - const tag = System.create({ name: "typesSystemTest/tagBy", input: { type: "object", properties: { distance: { type: "string" } } } }, () => {}); - const nested = System.create({ name: "typesSystemTest/nested", systems: [tag] }); + expect(() => System.create({ systems: [{ name: "showMember" }] })).toThrow('member "showMember" isn\'t a system a System constructor returned'); + const move = System.create({}, (_context, input: { readonly distance: number }) => {}); + const tag = System.create({}, (_context, input: { readonly distance: string }) => {}); + const nested = System.create({ systems: [tag] }); const untyped: (...parameters: never[]) => unknown = System.create; const composite = (declaration: object): unknown => Reflect.apply(untyped, undefined, [declaration]); - expect(() => composite({ name: "typesSystemTest/conflict", systems: [move, nested] })).toThrow('Systems "typesSystemTest/moveBy" and "typesSystemTest/tagBy" declare input field "distance" with different schemas.'); - expect(labelOfItem(System.create({ name: "typesSystemTest/conflict", systems: [move] }))).toBe("typesSystemTest/conflict"); + expect(() => composite({ systems: [move, nested] })).toThrow('Systems "move" and "tag" declare input field "distance" with different schemas.'); }); }); describe("System.render", () => { it("reads the components its accessors name, once each, and runs only while gpuDevice is set", () => { - const draw = System.render({ name: "typesSystemTest/draw", shader: { accessors: { place: height, again: height } } }, () => {}); + const draw = System.render({ shader: { accessors: { place: height, again: height } } }, () => {}); const definition = definitionOf(draw); expect(definition?.kind === "render" ? [definition.declaration.components, definition.declaration.resources] : null).toEqual([{ read: [height] }, { is: [gpuDevice] }]); - const render: (declaration: object) => unknown = declaration => Reflect.apply(System.render, undefined, [declaration, () => {}]); - expect(() => render({ name: "typesSystemTest/drawDocument", resources: { read: [document] }, shader: { accessors: {} } })).toThrow('uses "typesSystemTest/document" on "main" and "gpuDevice" on "world"'); + const render = (declaration: object, uses: Parameters[1] = {}): unknown => Reflect.apply(System.render, undefined, [giveAccess(declaration, uses), () => {}]); + expect(() => render({ shader: { accessors: {} } }, { resources: { read: [document] } })).toThrow('uses "typesSystemTest/document" on "main" and "gpuDevice" on "world"'); }); it("rejects at creation accessors its shader can't read, and reductions", () => { - const render: (declaration: object) => unknown = declaration => Reflect.apply(System.render, undefined, [declaration, () => {}]); - expect(() => System.render({ name: "typesSystemTest/drawReserved", shader: { accessors: { loop: height } } }, () => {})).toThrow(`System "typesSystemTest/drawReserved" can't render: "loop" isn't usable as a WGSL function name.`); - expect(() => render({ name: "typesSystemTest/drawReduced", shader: { accessors: {} }, reduce: { [total]: "+" } })).toThrow("can't reduce: a render system records GPU work rather than running rows"); - expect(() => render({ name: "typesSystemTest/drawObserver", shader: { accessors: {} }, observer: true })).toThrow("is an observer, so it records no GPU work"); - expect(labelOfItem(System.render({ name: "typesSystemTest/drawReserved", shader: { accessors: {} } }, () => {}))).toBe("typesSystemTest/drawReserved"); + 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"); }); }); diff --git a/packages/ecs/src/interface/system/create.ts b/packages/ecs/src/interface/system/create.ts index 3160273a3..f484801eb 100644 --- a/packages/ecs/src/interface/system/create.ts +++ b/packages/ecs/src/interface/system/create.ts @@ -1,131 +1,113 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import type { Component } from '../component/component.js'; import type { Entity } from '../entity/entity.js'; import type { Index } from '../indexes/index-type.js'; +import type { Condition } from '../resource/condition.js'; import { takeIdOfDeclaration } from '../registry/ids.js'; import { nameOf } from '../registry/items.js'; import { claimSystem } from '../registry/program.js'; -import type { Resource } from '../resource/resource.js'; -import type { Schema } from '../schema/schema.js'; -import type { Body } from './body.js'; -import type { Commands } from './commands.js'; -import { checkComposite, checkLeaf } from './check.js'; +import type { Body, Context } from './body.js'; +import { checkComposite, checkLeaf, inputSchemaOf } from './check.js'; import type { Composite } from './composite.js'; import type { CompositeDeclaration } from './composite-declaration.js'; -import type { Declaration } from './declaration.js'; +import type { Declaration, Resolved } from './declaration.js'; import type { CheckMembers, CompositeInputOf, InputOf } from './input.js'; import type { Leaf } from './leaf.js'; import type { Listed, Property } from './property.js'; -import type { StructComponent, StructResource } from './struct.js'; import type { System } from './system.js'; import type { OneThread, ThreadedBy, ThreadsOf } from './threads.js'; import type { HeldBy } from './values.js'; +import { accessOf, compact, componentLists, lookupsLists, resourceUses, upReads } from './resolve.js'; -type Reduced = keyof Property & Resource.Key; -type Written = Listed, "write">; -type Is = Listed, "is">; type Lookups = Property; -/** - * An index lookup reads and watches only what every entity its index holds has, and its system doesn't write what it - * finds rows by or reads through it. A lookup by entity watches only what it reads or has. - */ -type IndexLookups = { +/** A lookup's `changed` names what it requires: a lookup by entity's `with`, an index lookup's what its index holds. */ +type LookupChanges = { readonly lookup?: { readonly [N in keyof Lookups]?: Lookups[N] extends { readonly index: Index } - ? { - readonly index?: Index<{ readonly key: readonly Exclude>[] }>; - readonly read?: readonly Exclude, Written>[]; - readonly changed?: readonly HeldBy[] | true; - } - : { readonly changed?: readonly (Listed[N], "read"> | Listed[N], "has">)[] } + ? { readonly changed?: readonly HeldBy[] | true } + : { readonly changed?: readonly Listed[N], "with">[] } }; }; -/** A system's rows are ordered by an index whose key parts it doesn't write. */ -type Order = { readonly order?: Index<{ readonly key: readonly Exclude>[] }> }; - -/** - * A key in `write` is readable too, so it isn't also in `read`. A system doesn't read or filter on what it reduces - * into, since the fold is only finished after every contributor runs. A condition in `is` is readable too, and isn't - * written or also in `not`. - */ -type Lists = { - readonly components?: { readonly read?: readonly Exclude>[] }; - readonly resources?: { - readonly read?: readonly Exclude | Reduced | Is>[]; - readonly write?: readonly Exclude>[]; - readonly changed?: readonly Exclude>[]; - readonly not?: readonly Exclude>[]; - }; +/** What a body reads, writes and reduces into is inferred, never declared. */ +type Inferred = { + readonly components?: { readonly read?: never; readonly write?: never; readonly has?: never }; + readonly resources?: { readonly read?: never; readonly write?: never; readonly reduce?: never }; + readonly up?: { readonly read?: never }; + readonly reduce?: never; }; -type Kernel = { - readonly components?: { readonly read?: readonly StructComponent[]; readonly write?: readonly StructComponent[] }; - readonly lookup?: { - readonly [name: string]: - | { readonly read?: readonly StructComponent[]; readonly has?: readonly Component.Key[]; readonly not?: readonly Component.Key[]; readonly changed?: readonly Component.Key[]; readonly index?: never } - | { readonly read?: readonly StructComponent[]; readonly changed?: readonly Component.Key[] | true; readonly index: Index<{ readonly key: readonly StructComponent[] }> }; - }; - readonly up?: { readonly read?: readonly StructComponent[] }; - readonly order?: Index<{ readonly key: readonly StructComponent[] }>; - readonly resources?: { readonly read?: readonly StructResource[]; readonly write?: never }; -}; +/** A condition is in `is` or `not`, not both. */ +type Conditions = { readonly resources?: { readonly not?: readonly Exclude, "is">>[] } }; /** What a GPU kernel can't use yet, beyond any kernel's limits. */ type GpuKernel = { readonly lookup?: { readonly [name: string]: never }; readonly components?: { readonly added?: never; readonly changed?: never }; readonly resources?: { readonly changed?: never }; - readonly reduce?: { readonly [K in Resource.Key]?: never }; readonly order?: { readonly "error: GPU kernels have no order yet": true }; }; /** - * An observer system only reads, after a sync point: it writes and reduces into nothing, takes no input, isn't a - * kernel, has no `up`, and is ordered with nothing. + * An observer system only reads, after a sync point: its row and resources are read-only, and it never compiles to a + * kernel, has no `up`, and is ordered with nothing. That it takes no input is checked at its body. */ type Observer = { - readonly kernel?: never; - readonly components?: { readonly write?: never }; - readonly resources?: { readonly write?: never }; - readonly reduce?: never; - readonly input?: never; + readonly kernel?: false; readonly up?: never; readonly before?: never; readonly after?: never; readonly during?: never; }; -/** The fields of input `I` that hold an entity. */ -type EntityFields = { [K in keyof I]-?: [Exclude] extends [Entity] ? K : never }[keyof I] & string; - -/** A system on one entity takes it from an entity field of its input, and runs on its row, in no order. */ -type Targeted = { - readonly target?: EntityFields>; - readonly kernel?: never; +/** A system on one entity runs on its row, in no order; that its target is an entity field of its input is checked at its body. */ +type Targeted = { + readonly kernel?: false; readonly up?: never; readonly order?: never; - readonly reduce?: never; }; type Validate = - Lists - & IndexLookups - & Order - & (D extends { readonly input: Schema } ? { readonly components?: { readonly added?: never; readonly changed?: never } } : {}) - & (D extends { readonly kernel: "cpu" | "gpu" } ? Kernel : {}) + LookupChanges + & Conditions + & Inferred & (D extends { readonly kernel: "gpu" } ? GpuKernel : {}) // `up` and `order` order rows, so a system without them has neither, and one has at most one of them. - & (Property extends undefined ? { readonly up?: { readonly [key: string]: never }; readonly order?: { readonly "error: a system without components runs once, in no order": true } } : {}) + & (Property extends undefined ? { + readonly up?: { readonly [key: string]: never }; + readonly order?: { readonly "error: a system without components runs once, in no order": true }; + readonly kernel?: false | { readonly "error: a system without components runs once, so there's nothing to compile": true }; + } : {}) & (D extends { readonly up: object } ? { readonly order?: { readonly "error: up and order both decide the order of its rows": true } } : {}) + // 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 target: string } ? Targeted : {}) + & (D extends { readonly target: string } ? Targeted : {}) & (D extends { readonly target: string } ? Property extends undefined ? { readonly target?: { readonly "error: a system on one entity runs on its row, so it needs components": true } } : {} : {}) - & { readonly shader?: never }; + // A body's input is the type of its parameter after its context, not part of its declaration. + & { readonly shader?: never; readonly input?: never }; -/** The body of a system that declares nothing, which runs once per run with its commands. */ -type Alone = (context: { readonly commands: Commands }) => void; +/** The fields of input `I` that hold an entity. */ +type EntityFields = { [K in keyof I]-?: [Exclude] extends [Entity] ? K : never }[keyof I] & string; + +/** + * What a declaration requires of the input its body takes, checked at the body, since the input is known only once the + * body is: a target names an entity field of it, and a system with input doesn't filter on added or changed, as its + * runs pass different input, nor is it an observer. Every check passes for `never`, the input before the body is + * inferred. + */ +type Checks = [I] extends [never] ? unknown : + (D extends { readonly target: infer T extends string } + ? [I] extends [undefined] ? { readonly "error: a system with a target takes its input after its context": T } + : T extends EntityFields ? unknown : { readonly "error: its target is no entity field of its input": T } + : unknown) + & ([I] extends [undefined] ? unknown + : (D extends { readonly components: { readonly added: object } | { readonly changed: object } } ? { readonly "error: a system with input can't filter on added or changed, as its runs pass different input; pass it through a resource": true } : unknown) + & (D extends { readonly observer: true } ? { readonly "error: an observer system takes no input": true } : unknown)); + +/** The body of a system that declares nothing, which runs once per run with its resources and commands. */ +type Alone = (context: Context<{}>) => void; /** A composite: its list of members, or its declaration. */ type Composition = readonly System[] | CompositeDeclaration; @@ -138,44 +120,65 @@ type Checked = C extends Alone ? unknown : C extends CompositeDeclaration ? { /** * Declares a system and returns it; nothing runs. The arguments tell the forms apart: - * - `create(body)`: a system that declares nothing, whose body runs once per run with its commands. + * - `create(body)`: a system that declares nothing, whose body runs once per run with its resources and commands. * - `create(declaration, body)`: a system. * - `create([move, collide])`: a composite of the systems listed, which may live on different threads. - * - `create({ systems: [move, collide], after: [input] })`: a composite with a name or constraints. + * - `create({ systems: [move, collide], after: [input] })`: a composite with constraints. * - * Each run passes a composite's members one input, with every field of theirs. Throws if a system names a key that - * isn't registered as its kind, lists a key in both `read` and `write`, writes a reference, a lookup reads a component - * it writes or goes through an index keyed by one, or reads through an index what not every entity it holds has, its + * What a body reads, writes and reduces into the build infers from it. Each run passes a composite's members one + * input, with every field of theirs. Throws if a system names a key that isn't registered as its kind, its body writes + * a reference, a lookup's rows are read where it writes them or it goes through an index keyed by what it writes, its * rows are ordered by an index it isn't sorted or keyed by what it writes, its object components and resources live on * different threads, or its reductions, conditions, `up`, `order` or placement can't hold, or it's an observer and - * writes, reduces, takes input, is a kernel, has `up` or is ordered; or if a composite's member isn't a system a - * constructor returned, or two members declare an input field with different schemas. A composite's observer members - * run after its sync point, ordered with nothing. + * writes, reduces, takes input, is a kernel, has `up` or is ordered; if the build couldn't follow how its body uses its + * resources, lookups or parent; or if a composite's member isn't a system a constructor returned, or two members + * declare an input field with different schemas. A composite's observer members run after its sync point, ordered + * with nothing. * * One argument, a body or a composite, is one overload, so a mistake in it is reported where it is rather than as no * overload matching. */ -export function create(declaration: D & Validate & OneThread, ThreadedBy>, body: Body): System>; +export function create>(declaration: D & Validate & OneThread, ThreadedBy>, body: B & Checks>): System>; export function create(only: C & Checked): C extends Alone ? System : System>>; -export function create(first: Declaration | Leaf["body"] | readonly System[] | CompositeDeclaration, second?: Leaf["body"]): System { +// The plugin passes the schema of the body's input as a third argument. +export function create(first: Declaration | Leaf["body"] | readonly System[] | CompositeDeclaration, second?: Leaf["body"], input?: unknown): System { // The plugin gives its id to the call's first argument: the declaration, the members, or the body made into a // system alone. const id = takeIdOfDeclaration(first); - if (isList(first)) return compose({ systems: first }, id); - if (typeof first !== "function" && second === undefined && "systems" in first) return compose(first, id); - const declaration: Declaration = typeof first === "function" ? {} : first; + if (isList(first)) return compose(first, { systems: first }, id); + if (typeof first !== "function" && second === undefined && "systems" in first) return compose(first, first, id); + const declared: Declaration = typeof first === "function" ? {} : first; const body = typeof first === "function" ? first : second; if (body === undefined) throw new Error("A system needs a body, and a composite its systems."); - const system: Leaf & System = { kind: "system", name: nameOf(declaration.name, id, "system"), declaration, body }; + const name = nameOf(first, id, "system"); + const declaration = resolve(`System "${name}"`, declared, accessOf(`System "${name}"`, first)); + const schema = inputSchemaOf(name, body, declaration.components === undefined ? 1 : 2, input); + const system: Leaf & System = { kind: "system", name, declaration, input: schema, body }; checkLeaf(system); claimSystem(system, id); return system; } +/** A declaration as the world takes it, with what the build inferred its body uses. */ +const resolve = (what: string, declared: Declaration, access: ReturnType): Resolved => { + const reading = declared.observer === true; + const { components, lookup, up, resources, ...rest } = declared; + const uses = resourceUses(what, access, resources?.is); + const resolved: Resolved = { + ...rest, + ...components === undefined ? {} : { components: componentLists(components, access.components, reading) }, + ...lookup === undefined ? {} : { lookup: lookupsLists(lookup, access) }, + ...up === undefined ? {} : { up: { through: up.through, read: upReads(what, access) } }, + }; + const lists = compact({ read: uses.read, write: uses.write, reduce: uses.reduce, changed: resources?.changed, is: resources?.is, not: resources?.not }); + return Object.keys(lists).length === 0 ? resolved : { ...resolved, resources: lists }; +}; + const isList = (value: unknown): value is readonly System[] => Array.isArray(value); -const compose = (declaration: CompositeDeclaration, id: string | undefined): System => { - const system: Composite & System = { kind: "composite", name: nameOf(declaration.name, id, "system"), declaration }; +/** A composite of `declaration`, labeled by `first`, the argument the plugin labeled. */ +const compose = (first: object, declaration: CompositeDeclaration, id: string | undefined): System => { + const system: Composite & System = { kind: "composite", name: nameOf(first, id, "system"), declaration }; checkComposite(system); claimSystem(system, id); return system; diff --git a/packages/ecs/src/interface/system/declaration.ts b/packages/ecs/src/interface/system/declaration.ts index 1fdb39b2b..f9ca0dacf 100644 --- a/packages/ecs/src/interface/system/declaration.ts +++ b/packages/ecs/src/interface/system/declaration.ts @@ -1,36 +1,39 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { Index } from '../indexes/index-type.js'; import type { Resource } from '../resource/resource.js'; -import type { Schema } from '../schema/schema.js'; -import type { ComponentAccess, ResourceAccess, SystemLookup, Up } from './access.js'; -import type { Name } from './name.js'; +import type { 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 { OperatorOf, Reduced } from '../resource/reduction.js'; /** * A system never declares its thread: it runs on the one its object components and resources live on, those it - * reads, writes or filters on by change, through `components`, `lookup`, `up` and `resources` but `has` and `not`. - * Without them, a kernel runs on the kernel pool and any other system on `"world"`. + * reads, writes or filters on by change, through its row, lookups, `up` and resources. Without them, a kernel runs on + * the kernel pool and any other system on `"world"`. A declaration says only what its body can't show: which entities + * it runs over, its filters and conditions, and its order; the build infers what the body reads, writes and reduces. */ export type Declaration = { - readonly name?: Name; /** - * Compiled to a kernel, which the build rejects if the body leaves the kernel subset. Values in `components` and - * `lookup` must be structs and resources read-only. Without it, the system runs as written, on its thread. - * - `"cpu"`: runs on the kernel pool. - `"gpu"`: also has no lookups, change filters, commands or loops, and only - * 4-byte scalar values, its `up` included. It runs as a compute shader in a world with a GPU, and as a `"cpu"` - * kernel in one without. Every matched row counts as changed in what it writes. + * Whether its body compiles to a kernel, a function the build compiles to run on every matched row, in parallel, on + * the kernel pool. Values in `components` and `lookup` must be structs and resources read-only. + * - Without it, a system with components compiles when its body is in the kernel subset and its declaration allows + * it, and otherwise runs as written, computing as the body as written does. A system without components runs once, + * as written. + * - `true`: compiles, or the build fails saying why. Its arithmetic may round each step to storage precision, as + * WASM's f32 SIMD lanes do, so its results may differ from the body as written. + * - `false`: runs as written, on its thread. + * - `"gpu"`: also has no lookups, change filters, commands or loops, and only 4-byte scalar values, its `up` + * included. It runs as a compute shader in a world with a GPU, and as a kernel on the pool in one without. Every + * matched row counts as changed in what it writes. */ - readonly kernel?: "cpu" | "gpu"; - /** The entities it runs over, and what it reads and writes of theirs. Omitted for a system that runs once per run. */ + readonly kernel?: boolean | "gpu"; + /** The entities it runs over. Omitted for a system that runs once per run. */ readonly components?: ComponentAccess; readonly lookup?: { readonly [name: string]: SystemLookup }; /** - * Runs rows in depth order through `through`, so each reads its parent's `read` values after the parent ran, as - * the context's `up`, or null for a row without a parent. With change filters, a row also passes when its parent, - * or what it reads of its parent, changed since the system last ran, or it moved to another archetype, as removing - * its parent moves it. Rows on or under a parent cycle don't run. + * Runs rows in depth order through `through`, so each reads its parent's values after the parent ran, as the + * context's `up`, or null for a row without a parent. With change filters, a row also passes when its parent, or + * what it reads of its parent, changed since the system last ran, or it moved to another archetype, as removing its + * parent moves it. Rows on or under a parent cycle don't run. */ readonly up?: Up; /** @@ -40,14 +43,10 @@ export type Declaration = { */ readonly order?: Index<{ readonly sorted: true }>; readonly resources?: ResourceAccess; - /** The resources its body contributes to, each with the operator its `reduce` trait declares. */ - readonly reduce?: { readonly [K in Reduced]?: OperatorOf }; - /** The object of named fields passed to each run. A system with input can't use `added` or `changed`. */ - readonly input?: Extract; /** - * The input field naming the one entity it runs on: its body runs once, on that entity's row, when the entity is - * alive and matches `components`, and not at all otherwise. Needs `components`; not with `kernel`, `up`, `order` - * or `reduce`. + * The field of its body's input naming the one entity it runs on: its body runs once, on that entity's row, when + * the entity is alive and matches `components`, and not at all otherwise. Needs `components`; not with `kernel`, + * `up` or `order`, nor with a body that reduces. */ readonly target?: string; /** Makes this a render system (see `render`): its body records GPU work over the GPU copy of the components it names. */ @@ -64,3 +63,11 @@ export type Declaration = { */ readonly observer?: true; }; + +/** A declaration as the world takes it: the build's inferred reads, writes and reductions joined with what it declares. */ +export type Resolved = Omit & { + readonly components?: ComponentLists; + readonly lookup?: { readonly [name: string]: SystemLookupLists }; + readonly up?: UpLists; + readonly resources?: ResourceLists; +}; diff --git a/packages/ecs/src/interface/system/input.ts b/packages/ecs/src/interface/system/input.ts index 9f15c7594..c064e3fbe 100644 --- a/packages/ecs/src/interface/system/input.ts +++ b/packages/ecs/src/interface/system/input.ts @@ -1,12 +1,11 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import type { Schema } from '../schema/schema.js'; import type { System } from './system.js'; -/** - * The input a system declares: a value of its `input` schema, or `undefined` without one; `unknown` for `any`, as an - * overload's declaration erases to, since every schema's value type is too deep to compute. - */ -export type InputOf = 0 extends 1 & D ? unknown : D extends { readonly input: infer A extends Schema } ? Schema.ToType : undefined; +/** How many parameters come before a body's input: the row and the context, or the context alone. */ +type Before = D extends { readonly components: object } ? 2 : 1; + +/** The input body `B` of declaration `D` takes: the type of its parameter after the context, or `undefined` without one. */ +export type InputOf = B extends (...parameters: infer P) => void ? P["length"] extends Before ? undefined : P[Before] : undefined; /** What `run` takes after a system: nothing, or one value. */ export type Input = [I] extends [undefined] ? [] : [input: I]; diff --git a/packages/ecs/src/interface/system/leaf.ts b/packages/ecs/src/interface/system/leaf.ts index c8b7b0f46..daf745e8b 100644 --- a/packages/ecs/src/interface/system/leaf.ts +++ b/packages/ecs/src/interface/system/leaf.ts @@ -1,14 +1,20 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import type { Declaration } from './declaration.js'; +import type { Schema } from '../schema/schema.js'; +import type { Resolved } from './declaration.js'; import type { Name } from './name.js'; +/** The schema of a body's input: an object of named fields. */ +export type InputSchema = Extract; + /** * A system that runs a body: `"render"` for one made by `render`, `"system"` otherwise. Its body's parameters are - * checked where it's created, so a leaf holds any body. + * checked where it's created, so a leaf holds any body. `input` is the schema of its body's input, which the plugin + * gives from the type of the body's parameter after its context, or undefined; every leaf has it, so leaves share a shape. */ export type Leaf = { readonly kind: "system" | "render"; readonly name: Name; - readonly declaration: Declaration; + readonly declaration: Resolved; + readonly input: InputSchema | undefined; readonly body: (...parameters: never[]) => void; }; diff --git a/packages/ecs/src/interface/system/lists.type-test.ts b/packages/ecs/src/interface/system/lists.type-test.ts new file mode 100644 index 000000000..c3e37b946 --- /dev/null +++ b/packages/ecs/src/interface/system/lists.type-test.ts @@ -0,0 +1,53 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Component } from '../component/component.js'; +import { Entity, Index, System } from "@adobe/ecs"; + +const position = Symbol("listsTypeTest/position"); +const velocity = Symbol("listsTypeTest/velocity"); +const scale = Symbol("listsTypeTest/scale"); +declare module "@adobe/ecs" { + interface Components { + [position]: { type: number }; + [velocity]: { type: number }; + } + interface Resources { + [scale]: { type: number }; + } +} + +type Equal = (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) ? true : false; +const expectType = (): T | undefined => undefined; + +// A list of components declared `as const` is a tuple, an archetype wherever component lists go. +export const Particle = [position, velocity] as const; +expectType>(); +export const one: readonly Component.Key[] = Particle; +export const move = System.create({ components: { with: [...Particle] } }, row => { row[position] += row[velocity]; }); +export const read = System.create({ observer: true, components: { with: [...Particle] } }, row => { void row[velocity]; }); + +// Declared without `as const`, a list's keys widen to `symbol`, which no list of components takes. +const loose = [position, velocity]; +expectType>(); +// @ts-expect-error so it's never a list of components +export const looseList: readonly Component.Key[] = loose; +// @ts-expect-error nor an index's +export const byLoose = Index.create({ key: [position], components: { with: loose } }); + +/** A list whose components are known only at runtime says so by its type. */ +const runtime: readonly (typeof position | typeof velocity)[] = Particle; + +export const spawn = System.create(({ commands }) => { + commands.spawn(Particle)({ [position]: 1, [velocity]: 2 }); + // @ts-expect-error a tuple's values are required + commands.spawn(Particle)({ [position]: 1 }); + commands.spawn([position, velocity])({ [position]: 1, [velocity]: 2 }); + // @ts-expect-error a list declared without `as const` can't be spawned + commands.spawn(loose); + // @ts-expect-error nor added + commands.add(Entity.none, loose, {}); + // A runtime list takes any of its values. + commands.spawn(runtime)({ [position]: 1 }); + commands.add(Entity.none, Particle, { [position]: 1, [velocity]: 2 }); + // @ts-expect-error a resource is never in a list of components + commands.spawn([position, scale]); +}); diff --git a/packages/ecs/src/interface/system/name.ts b/packages/ecs/src/interface/system/name.ts index ef87e3b53..a5b7d02d2 100644 --- a/packages/ecs/src/interface/system/name.ts +++ b/packages/ecs/src/interface/system/name.ts @@ -1,7 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. /** - * The label reports and errors give it, which needn't be unique: the value its constructor returns is its identity, so - * separately written code can't collide by name. Namespacing it (`"physics/integrate"`) keeps labels short. Without - * one, it's labeled by its binding and the id the plugin gave it, or else by its kind numbered. + * The label reports and errors give it, for display: its binding, from the id the plugin gave it; for one made in a + * function, the binding or function the plugin found it in; else its kind numbered. The value its constructor returns + * is its identity, so labels needn't be unique. */ export type Name = string; diff --git a/packages/ecs/src/interface/system/optional.type-test.ts b/packages/ecs/src/interface/system/optional.type-test.ts new file mode 100644 index 000000000..7bc51c5d2 --- /dev/null +++ b/packages/ecs/src/interface/system/optional.type-test.ts @@ -0,0 +1,51 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Entity } from '../entity/entity.js'; +import { System } from './system.js'; + +type Equal = (() => T extends X ? 1 : 2) extends (() => T extends Y ? 1 : 2) ? true : false; +const check = <_T extends true>() => { }; + +const position = Symbol("optionalTest/position"); +const velocity = Symbol("optionalTest/velocity"); +const pilot = Symbol("optionalTest/pilot"); + +declare module '@adobe/ecs' { + export interface Components { + [position]: { type: number }; + [velocity]: { type: number }; + [pilot]: { type: Entity; reference: "owner" }; + } +} + +// An optional component is its value, or undefined where an entity lacks it. +System.create({ components: { with: [position], optional: [velocity, pilot] } }, row => { + check>(); + const speed = row[velocity]; + if (speed !== undefined) row[position] += speed; + row[velocity] = 2; + // @ts-expect-error a reference is read-only in a row, optional or not + row[pilot] = Entity.none; +}); + +// A CPU kernel reads them as a body run as written does; GPU kernels don't read components an archetype may lack yet. +System.create({ kernel: true, components: { with: [position], optional: [velocity] } }, row => { void row[velocity]; }); +System.create({ + // @ts-expect-error a GPU kernel can't have optional components yet + kernel: "gpu", + components: { with: [position], optional: [velocity] }, +}, row => { void row[velocity]; }); + +// An observer system reads its optional components and writes nothing. +System.create({ observer: true, components: { with: [position], optional: [velocity] } }, row => { + void row[velocity]; + // @ts-expect-error an observer system's row is read-only + row[velocity] = 1; +}); + +// A lookup by entity's optional component is its value, or undefined where the entity lacks it. +System.create({ components: { with: [pilot] }, lookup: { pilots: { with: [position], optional: [velocity] } } }, (row, { lookup }) => { + const found = lookup.pilots(row[pilot]); + if (found === null) return; + check>(); + check>(); +}); diff --git a/packages/ecs/src/interface/system/render.ts b/packages/ecs/src/interface/system/render.ts index f031d58a0..04d374229 100644 --- a/packages/ecs/src/interface/system/render.ts +++ b/packages/ecs/src/interface/system/render.ts @@ -1,17 +1,17 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import type { Component } from '../component/component.js'; +import type { Index } from '../indexes/index-type.js'; import { takeIdOfDeclaration } from '../registry/ids.js'; import { nameOf } from '../registry/items.js'; import { claimSystem } from '../registry/program.js'; import type { Condition } from '../resource/condition.js'; -import type { Resource } from '../resource/resource.js'; import type { ThreadOfResource } from '../resource/value.js'; import { gpuDevice } from '../resources/gpu-device.js'; import type { Body } from './body.js'; import { accessorsOf } from '../gpu/accessors.js'; import { checkLeaf } from './check.js'; +import { accessOf, compact, resourceUses } from './resolve.js'; import type { Leaf } from './leaf.js'; -import type { Name } from './name.js'; import type { StructComponent } from './struct.js'; import type { System } from './system.js'; import type { OneThread, ThreadedBy, ThreadsOf } from './threads.js'; @@ -22,7 +22,10 @@ import type { OneThread, ThreadedBy, ThreadsOf } from './threads.js'; */ export type Shader = { readonly group?: number; readonly accessors: { readonly [name: string]: StructComponent } }; -/** One group of matching rows, `0` to `count`. `bind` sets the world's bind group for it on a pass. */ +/** + * One group of matching rows, `0` to `count`: an archetype's, or with `order` every matching row in its order. `bind` + * sets the world's bind group for it on a pass. + */ export type RenderTable = { readonly count: number; readonly bind: (pass: GPURenderPassEncoder | GPUComputePassEncoder) => void; @@ -39,41 +42,60 @@ export type RenderContext = { }; type RenderDeclaration = { - readonly name?: Name; - /** It reads the components its accessors name. */ - readonly components?: { readonly has?: readonly Component.Key[]; readonly not?: readonly Component.Key[] }; - /** It runs only while `gpuDevice` is set, as if `is` listed it. */ - readonly resources?: { readonly read?: readonly Resource.Key[]; readonly is?: readonly Condition[]; readonly not?: readonly Condition[] }; + /** It matches the components its accessors name, and those in `with`, and none in `not`. */ + readonly components?: { readonly with?: readonly Component.Key[]; readonly not?: readonly Component.Key[] }; + /** It runs only while `gpuDevice` is set, as if `is` listed it. What its body reads of resources the build infers. */ + readonly resources?: { readonly is?: readonly Condition[]; readonly not?: readonly Condition[] }; + /** + * Draws every matching row as one table, in this sorted index's order: key order, then entity order. Its shaders' + * accessors then take a row's position in that order. + */ + readonly order?: Index<{ readonly sorted: true; readonly key: readonly Component.Key[] }>; readonly shader: Shader; readonly before?: readonly System[]; readonly after?: readonly System[]; readonly during?: readonly System[]; }; -/** A render declaration with the components its accessors name as `components.read`. */ -type Rendered = D & { readonly components: { readonly read: readonly StructComponent[] } }; +/** What a render body reads of resources is inferred, and what it reads of components its accessors name. */ +type Inferred = { + readonly components?: { readonly read?: never; readonly has?: never }; + readonly resources?: { readonly read?: never }; +}; /** * Declares a render system: it runs once per run on `gpuDevice`'s thread, in schedule order, and records GPU work over * the GPU copy of the components its accessors name, which the world uploads first as needed. It runs only while the - * world has a `gpuDevice`. Throws as `create` does, and for an accessor its shader can't read on the GPU. + * world has a `gpuDevice`. Its body's `tables` hold one table per matching archetype, each in row order; with `order`, + * exactly one table of every matching row, across archetypes, in the index's order, so instance `i` is the i-th: a + * shader's accessors take that position as their `row`, with the same names and signatures. Throws as `create` does, + * for an `order` that isn't a sorted index, and for an accessor its shader can't read on the GPU. A run with `order` + * fails, as a failure in its report, when a matching archetype holds more rows than 32 bits name beside its slot: + * 2^31 with up to two matching archetypes, 2^30 with up to four, and so on. */ export const render = ( - declaration: D & OneThread | ThreadOfResource, ThreadedBy>, - body: Body>, + declaration: D & Inferred & OneThread | ThreadOfResource, ThreadedBy>, + body: Body, ): System => { const id = takeIdOfDeclaration(declaration); - const name = nameOf(declaration.name, id, "system"); + const name = nameOf(declaration, id, "system"); + const what = `System "${name}"`; + const read = [...new Set(Object.values(declaration.shader.accessors))]; + const is = [...new Set([...declaration.resources?.is ?? [], gpuDevice])]; + const uses = resourceUses(what, accessOf(what, declaration), is); + const { components, resources, ...rest } = declaration; const system: Leaf & System = { kind: "render", name, declaration: { - ...declaration, - components: { ...declaration.components, read: [...new Set(Object.values(declaration.shader.accessors))] }, - resources: { ...declaration.resources, is: [...new Set([...declaration.resources?.is ?? [], gpuDevice])] }, + ...rest, + components: compact({ read, has: (components?.with ?? []).filter(key => !read.includes(key)), not: components?.not }), + resources: compact({ read: uses.read, is, not: resources?.not }), }, + input: undefined, body, }; + if (uses.write.length > 0 || uses.reduce.length > 0) throw new Error(`${what} writes or reduces into resources; a render system only reads.`); checkLeaf(system); const accessors = accessorsOf(declaration.shader); if (typeof accessors === "string") throw new Error(`System "${name}" can't render: ${accessors}.`); diff --git a/packages/ecs/src/interface/system/resolve.test.ts b/packages/ecs/src/interface/system/resolve.test.ts new file mode 100644 index 000000000..80a5f3b8b --- /dev/null +++ b/packages/ecs/src/interface/system/resolve.test.ts @@ -0,0 +1,49 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { describe, expect, it } from "vitest"; +import "../registry/declarations.js"; +import { componentLists, lookupsLists, resourceUses, type Access } from "./resolve.js"; + +const mass = Symbol("resolveTest/mass"); +const pool = Symbol("resolveTest/pool"); +const position = Symbol("resolveTest/position"); +declare module "@adobe/ecs" { + interface Components { + [mass]: { type: number }; + [position]: { type: number }; + [pool]: { type: ReadonlyMap; derived: true }; + } +} +const access = (lookup: Access["lookup"]): Access => ({ components: null, resources: null, lookup, up: null, at: "a test" }); + +describe("lookupsLists", () => { + it("takes a lookup whose rows the build didn't follow as reading everything it requires", () => { + expect(lookupsLists({ others: { with: [mass, position] } }, access({ others: null }))).toEqual({ others: { read: [mass, position] } }); + }); + it("takes a lookup the body never used as reading nothing, and one it followed as reading what it read", () => { + expect(lookupsLists({ others: { with: [mass, position] } }, access({}))).toEqual({ others: { has: [mass, position] } }); + expect(lookupsLists({ others: { with: [mass, position] } }, access({ others: [position] }))).toEqual({ others: { read: [position], has: [mass] } }); + }); +}); + +describe("componentLists", () => { + it("takes a row the build didn't follow as writing every member, its added and changed filters' included", () => { + expect(componentLists({ with: [mass], changed: [position] }, null, false)).toEqual({ write: [mass, position], changed: [position] }); + }); + it("takes an observer's row the build didn't follow as reading every member, writing none", () => { + expect(componentLists({ with: [mass], changed: [position] }, null, true)).toEqual({ read: [mass, position], changed: [position] }); + }); + it("lists optional components apart, reading and writing them as the body does, or as members when unfollowed", () => { + const used = { read: [position], write: [mass] }; + expect(componentLists({ with: [mass], optional: [position] }, used, false)).toEqual({ read: [position], write: [mass], optional: [position] }); + expect(componentLists({ with: [mass], optional: [position] }, null, false)).toEqual({ write: [mass, position], optional: [position] }); + expect(componentLists({ with: [mass], optional: [mass, position] }, used, false)).toEqual({ read: [position], write: [mass], optional: [position] }); + }); +}); + +describe("resourceUses", () => { + const changing = (key: symbol): Access => ({ components: { read: [key], write: [], mutate: [key] }, resources: { read: [], write: [], reduce: [] }, lookup: null, up: null, at: "a test" }); + it("rejects a change inside a data value, given as a copy, and takes one inside an object as a read", () => { + expect(() => resourceUses("System \"s\"", changing(mass), undefined)).toThrow(`changes something inside its row's "resolveTest/mass", a value given as a copy`); + expect(() => resourceUses("System \"s\"", changing(pool), undefined)).not.toThrow(); + }); +}); diff --git a/packages/ecs/src/interface/system/resolve.ts b/packages/ecs/src/interface/system/resolve.ts new file mode 100644 index 000000000..ef98f0db5 --- /dev/null +++ b/packages/ecs/src/interface/system/resolve.ts @@ -0,0 +1,146 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { takeAccessOf } from '../registry/ids.js'; +import { indexDefinitionOf } from '../registry/indexes.js'; +import { declarationOf, labelOf } from '../registry/names.js'; +import { isString, isStruct } from '../registry/schemas.js'; +import type { ComponentAccess, ComponentLists, Lookup, SystemLookup, SystemLookupLists } from './access.js'; + +type Key = symbol; + +/** What a body reads and writes of a group of keys, and those it changes inside their values, read as well. */ +type Used = { readonly read: readonly Key[]; readonly write: readonly Key[]; readonly mutate?: readonly Key[] }; + +/** + * What the build infers a body uses, which the plugin gives the declaration or body a constructor is called with: what + * it reads and writes of its row, and of resources with what it reduces into; what it reads of each lookup's rows, + * and of its parent's values with `up`. Each is null where the build couldn't follow the body's uses, and `at` names + * where the body is, for errors. + */ +export type Access = { + readonly components: Used | null; + readonly resources: (Used & { readonly reduce: readonly Key[] }) | null; + readonly lookup: { readonly [name: string]: readonly Key[] | null } | null; + readonly up: readonly Key[] | null; + readonly at: string; +}; + +const isKeys = (value: unknown): value is readonly Key[] => Array.isArray(value) && value.every(key => typeof key === "symbol"); + +const isUsed = (value: unknown): value is Used => typeof value === "object" && value !== null + && isKeys(Reflect.get(value, "read")) && isKeys(Reflect.get(value, "write")) + && (Reflect.get(value, "mutate") === undefined || isKeys(Reflect.get(value, "mutate"))); + +const isAccess = (value: unknown): value is Access => { + if (typeof value !== "object" || value === null) return false; + const components: unknown = Reflect.get(value, "components"); + const resources: unknown = Reflect.get(value, "resources"); + const lookup: unknown = Reflect.get(value, "lookup"); + const up: unknown = Reflect.get(value, "up"); + return (components === null || isUsed(components)) + && (resources === null || (isUsed(resources) && isKeys(Reflect.get(resources, "reduce")))) + && (lookup === null || (typeof lookup === "object" && Object.values(lookup).every(reads => reads === null || isKeys(reads)))) + && (up === null || isKeys(up)) && typeof Reflect.get(value, "at") === "string"; +}; + +/** + * What the build inferred the body a constructor is called with uses: given to its declaration, or for a system + * declaring nothing to its body. Throws when there's none, as when a module isn't built with the plugin. + */ +export const accessOf = (what: string, made: object): Access => { + const access = takeAccessOf(made); + if (!isAccess(access)) throw new Error(`${what} has no access the build inferred from its body: the build reads what a body uses where the constructor is called with its declaration written in place, as an object literal, and its body as a function, in a module built with Compiler.plugin().`); + return access; +}; + +/** + * Throws for a body changing something inside a data value, a struct or a string, which a row or the resources give + * as a copy and take only whole, so the change would be lost. An object, changed in place, is read. + */ +const checkChanges = (what: string, access: Access): void => { + for (const [group, keys] of [["its row's", access.components?.mutate], ["resources'", access.resources?.mutate]] as const) { + for (const key of keys ?? []) { + const type = declarationOf(key)?.type; + if (type !== undefined && (isStruct(type) || isString(type))) { + throw new Error(`${what}'s body (${access.at}) changes something inside ${group} "${labelOf(key)}", a value given as a copy and taken only whole: assign it a new value instead.`); + } + } + } +}; + +/** Throws for a group the build couldn't follow, naming what the body did instead. */ +const followed = (what: string, used: T | null, group: string, access: Access): T => { + if (used !== null) return used; + throw new Error(`${what}'s body (${access.at}) uses ${group} in a way the build can't follow, so it can't tell what it reads and writes of them: index them by key directly, or destructure them by key, rather than passing them on, aliasing or spreading them.`); +}; + +const without = (keys: readonly Key[], ...others: (readonly Key[] | undefined)[]): Key[] => + keys.filter(key => others.every(other => !other?.includes(key))); + +/** `lists` without its empty lists. */ +export const compact = (lists: T): T => { + const result: { [name: string]: unknown } = {}; + for (const [name, value] of Object.entries(lists)) if (value !== undefined && !(Array.isArray(value) && value.length === 0)) result[name] = value; + // Every field is one of `lists`', with its own value. + return result as T; +}; + +/** + * A row's lists: what the body reads and writes of its members, the others only required. Without the body's uses, + * conservatively every member is written but references, which change only through commands, or read where it only + * reads. + */ +export const componentLists = (components: ComponentAccess, used: Used | null, reading: boolean): ComponentLists => { + // A row holds what its added and changed filters name too, and its optional components where an entity has them. + const members = [...new Set([...components.with ?? [], ...components.added ?? [], ...components.changed ?? []])]; + const optional = without(components.optional ?? [], members); + const held = [...members, ...optional]; + const write = used === null + ? reading ? [] : held.filter(key => declarationOf(key)?.reference === undefined) + : used.write; + const read = used === null ? without(held, write) : without(used.read, write); + const has = without(members, read, write); + return compact({ read, write, has, optional, not: components.not, added: components.added, changed: components.changed }); +}; + +/** + * A lookup's lists: what the body reads of its rows, the rest of its `with` only required. Without the body's uses, + * conservatively everything its rows hold is read: its `with`, or every component its index holds. + */ +export const lookupLists = (lookup: SystemLookup | Lookup, reads: readonly Key[] | null): SystemLookupLists => { + if (lookup.index !== undefined) { + const index = indexDefinitionOf(lookup.index)?.declaration; + const read = reads ?? [...index?.key ?? [], ...index?.components?.has ?? []]; + const { changed } = lookup; + return changed === true ? { index: lookup.index, read, changed } : compact({ index: lookup.index, read, changed }); + } + // Its optional components are read where an entity has them and never required. + const optional = without(lookup.optional ?? [], lookup.with ?? []); + const read = reads ?? [...lookup.with ?? [], ...optional]; + return compact({ read, has: without(lookup.with ?? [], read), optional, not: lookup.not, changed: lookup.changed }); +}; + +/** Each lookup's lists, by name. */ +export const lookupsLists = (lookups: { readonly [name: string]: L } | undefined, access: Access): { readonly [name: string]: SystemLookupLists } | undefined => { + if (lookups === undefined) return undefined; + const lists: { [name: string]: SystemLookupLists } = {}; + for (const name in lookups) { + const lookup = lookups[name]; + // A lookup whose rows the build didn't follow is null; one it saw unused has no entry. + const reads = access.lookup === null ? null : name in access.lookup ? access.lookup[name] ?? null : []; + if (lookup !== undefined) lists[name] = lookupLists(lookup, reads); + } + return lists; +}; + +/** + * What a body reads, writes and reduces into of resources, conditions in `is` read already. A read of what it reduces + * into stays, for registration to reject. + */ +export const resourceUses = (what: string, access: Access, is: readonly Key[] | undefined): { readonly read: Key[]; readonly write: readonly Key[]; readonly reduce: readonly Key[] } => { + checkChanges(what, access); + const used = followed(what, access.resources, "its resources", access); + return { read: without(used.read, used.write, is), write: used.write, reduce: used.reduce }; +}; + +/** What a body reads of its parent's values. */ +export const upReads = (what: string, access: Access): readonly Key[] => followed(what, access.up, "its parent's values", access); diff --git a/packages/ecs/src/interface/system/system.ts b/packages/ecs/src/interface/system/system.ts index 72f2c3cf5..daca38d7c 100644 --- a/packages/ecs/src/interface/system/system.ts +++ b/packages/ecs/src/interface/system/system.ts @@ -2,8 +2,8 @@ declare const input: unique symbol; /** - * A system, the value a System constructor returned: identified by that value and labeled by its name. `run` takes it - * with a value of type `I`, or with nothing when `I` is `undefined`. + * A system, the value a System constructor returned: identified by that value and labeled by `name`, the binding it's + * declared at. `run` takes it with a value of type `I`, or with nothing when `I` is `undefined`. */ export type System = { readonly name: string; readonly [input]?: I }; diff --git a/packages/ecs/src/interface/system/system.type-test.ts b/packages/ecs/src/interface/system/system.type-test.ts index e000bb668..b3615efd4 100644 --- a/packages/ecs/src/interface/system/system.type-test.ts +++ b/packages/ecs/src/interface/system/system.type-test.ts @@ -12,6 +12,7 @@ export const position = Symbol("systemTest/position"); const velocity = Symbol("systemTest/velocity"); const target = Symbol("systemTest/target"); const player = Symbol('systemTest/player'); +const owner = Symbol("systemTest/owner"); export const time = Symbol("systemTest/time"); const score = Symbol("systemTest/score"); @@ -21,12 +22,9 @@ declare module '@adobe/ecs' { [velocity]: { type: number }; [target]: { type: Entity }; [player]: { type: true }; + [owner]: { type: Entity; reference: "owner" }; } } -Component.register(position, { type: { type: "number" } }); -Component.register(velocity, { type: { type: "number" } }); -Component.register(target, { type: Entity.schema }); -Component.register(player, { type: { const: true } }); const flagged = Symbol("systemTest/flagged"); const bounds = Symbol("systemTest/bounds"); export const total = Symbol("systemTest/total"); @@ -39,40 +37,38 @@ declare module '@adobe/ecs' { [total]: { type: number; reduce: "+" }; } } -Resource.register(bounds, { type: { type: "array", items: { type: "number" }, minItems: 3, maxItems: 3, default: [Infinity, Infinity, Infinity] }, reduce: "min" }); -Resource.register(time, { type: { type: "number", default: 0 } }); -Resource.register(score, { type: { type: "number", default: 0 } }); -Resource.register(flagged, { type: { type: "boolean", default: false }, reduce: "|" }); -Resource.register(total, { type: { type: "number", default: 0 }, reduce: "+" }); -// Row: read values are read-only, write values writable, others absent. +// Row: the values of what `with` requires, writable; what the body reads and writes the build infers. export const integrate = System.create({ - name: "test/integrate", - components: { read: [velocity], write: [position], has: [player] }, - resources: { read: [time] }, + components: { with: [velocity, position, player] }, }, (row, { resources: { [time]: delta } }) => { row[position] += row[velocity] * delta; const entity: Entity = row.entity; - // @ts-expect-error read values are read-only - row[velocity] = 0; // @ts-expect-error entity is read-only row.entity = entity; - // @ts-expect-error components outside read and write are not in the row void row[player]; }); -// Write grants read: a component in write is readable, and listing it in read as well is rejected. +System.create({ components: { with: [position] } }, row => { + // @ts-expect-error components outside with are not in the row + void row[velocity]; +}); + System.create({ - name: "test/integrate", - // @ts-expect-error write already makes it readable - components: { read: [position], write: [position] }, + // @ts-expect-error what the body reads and writes isn't declared + components: { read: [position] }, }, () => {}); +// A reference changes only through commands, so the row holds it read-only. +System.create({ components: { with: [owner] } }, row => { + // @ts-expect-error a reference is read-only in a row + row[owner] = Entity.none; +}); + // Lookups: read-only values by entity, or null. export const follow = System.create({ - name: "test/follow", - components: { read: [target, position], write: [velocity] }, - lookup: { targets: { read: [position] } }, + components: { with: [target, position, velocity] }, + lookup: { targets: { with: [position] } }, after: [integrate], }, (row, { lookup: { targets } }) => { const other = targets(row[target]); @@ -90,9 +86,8 @@ export const follow = System.create({ // A lookup by entity with `changed`: its row also says whether what `changed` names may have changed since the system // last ran. System.create({ - name: "test/followChanged", - components: { read: [target], write: [velocity] }, - lookup: { targets: { read: [position], changed: [position] }, players: { has: [player], changed: [player] } }, + components: { with: [target, velocity] }, + lookup: { targets: { with: [position], changed: [position] }, players: { with: [player], changed: [player] } }, }, (row, { lookup: { targets, players } }) => { const other = targets(row[target]); if (other !== null) { @@ -102,78 +97,72 @@ System.create({ other.changed = false; } const found = players(row[target]); - if (found !== null) check>(); + if (found !== null) check>(); }); System.create({ - name: "test/followChanged", - components: { read: [target], write: [velocity] }, - // @ts-expect-error a lookup by entity's changed names what it reads or has - lookup: { targets: { read: [position], changed: [velocity] } }, + components: { with: [target, velocity] }, + // @ts-expect-error a lookup by entity's changed names what its with requires + lookup: { targets: { with: [position], changed: [velocity] } }, }, () => {}); -// Input: typed from its schema. +// Input: the type of the body's parameter after its context. export const move = System.create({ - name: "test/move", - input: { type: "object", properties: { distance: { type: "number" } } }, - components: { has: [player], write: [position] }, -}, (row, { input }) => { + components: { with: [position, player] }, +}, (row, _context, input: { readonly distance: number }) => { row[position] += input.distance; - // @ts-expect-error input has its schema's type + // @ts-expect-error input has the type the body gives it void input.missing; }); +// The row is still typed by the declaration, and the context holds every resource. +System.create({ components: { with: [velocity, position] } }, (row, { resources }, input: { readonly by: number }) => { + row[position] += row[velocity] * resources[time] * input.by; + // @ts-expect-error components outside with are not in the row + void row[player]; + void resources[score]; +}); +// @ts-expect-error input is the body's parameter, not part of its declaration +System.create({ input: { type: "object", properties: {} } }, () => {}); -// A system without components takes only its context; resource writes are writable, and written ones readable. -export const tally = System.create({ - name: "test/tally", - resources: { read: [time], write: [score] }, -}, ({ resources, commands }) => { +// A system without components takes only its context, which holds every resource, writable but those reduced. +export const tally = System.create({}, ({ resources, commands }) => { resources[score] += resources[time]; - // @ts-expect-error read resources are read-only - resources[time] = 0; + // @ts-expect-error a reduced resource is never written + resources[total] = 0; const spawned: Entity = commands.spawn([position, player])({ [position]: 0, [player]: true }); // @ts-expect-error spawn values must match the component type commands.spawn([position])({ [position]: "zero" }); commands.remove(spawned, velocity); }); -// The context holds only what the declaration declares. -System.create({ name: "test/tally" }, context => { - // @ts-expect-error no resources declared +// The context holds every resource and reduction, and the lookups the declaration declares. +System.create({}, context => { void context.resources; + void context.reduce; // @ts-expect-error no lookups declared void context.lookup; - // @ts-expect-error no reductions declared - void context.reduce; - // @ts-expect-error no input declared + // @ts-expect-error input isn't in the context void context.input; void context.commands; }); -// @ts-expect-error a system without components has no row -System.create({ name: "test/tally" }, (row: object, context: object) => { void [row, context]; }); - -System.create({ - name: "test/tally", - // @ts-expect-error write already makes a resource readable - resources: { read: [score], write: [score] }, -}, () => {}); +// A system without components has no row: its second parameter is its input. +check>>(); +const alone = System.create({}, (_context, input: { readonly count: number }) => { void input.count; }); System.create({ - name: "test/integrate", // @ts-expect-error ordering takes systems after: ["test/unregistered"], }, () => {}); -System.create({ - name: "test/move", - input: { type: "object", properties: { distance: { type: "number" } } }, - // @ts-expect-error a system with input can't use change filters - components: { changed: [position] }, -}, () => {}); +// @ts-expect-error a system with input can't use change filters +System.create({ components: { changed: [position] } }, (_row, _context, input: { readonly distance: number }) => { void input; }); +// @ts-expect-error a system with input can't use change filters +System.create({ components: { added: [position] } }, (_row, _context, input: { readonly distance: number }) => { void input; }); +System.create({ components: { changed: [position] } }, row => { void row; }); -export const main = System.create({ name: "test/main", systems: [integrate, follow, tally] }); -const moving = System.create({ name: "test/moving", systems: [move] }); +export const main = System.create({ systems: [integrate, follow, tally] }); +const moving = System.create({ systems: [move] }); // A composite takes its members' input. check>>(); @@ -195,126 +184,89 @@ declare module '@adobe/ecs' { [marks]: { type: ReadonlySet }; } } -Component.register(label, { type: { type: "string" } }); -Component.register(marks, { type: { type: "set", items: { type: "number" } } }); -Component.register(path, { type: { type: "array", items: { type: "number" } } }); -Component.register(vector, { type: { type: "array", items: { type: "number" }, minItems: 3, maxItems: 3 } }); -// Kernels: linear values (numbers, booleans, entities, fixed tuples), read-only resources. +// Kernels: a kernel's `with` may require any component; what its body reads must be linear, which the build checks. System.create({ - name: "test/integrate", - kernel: "cpu", - components: { read: [velocity, vector, player, target], write: [position], has: [label] }, - resources: { read: [time] }, -}, () => {}); - -System.create({ - name: "test/integrate", - kernel: "cpu", - // @ts-expect-error kernel values must be linear - components: { read: [label] }, -}, () => {}); - -System.create({ - name: "test/integrate", - kernel: "cpu", - // @ts-expect-error kernel values can't be variable-length arrays - components: { write: [path] }, -}, () => {}); - -System.create({ - name: "test/integrate", - kernel: "cpu", - // @ts-expect-error kernel values can't be sets or maps - components: { read: [marks] }, -}, () => {}); - -System.create({ - name: "test/integrate", - kernel: "cpu", - // @ts-expect-error kernel lookups must read linear values - lookup: { labels: { read: [label] } }, -}, () => {}); + kernel: true, + components: { with: [velocity, vector, player, target, position, label, path, marks] }, +}, row => { + row[position] = row[velocity]; +}); declare const byTarget: Index<{ readonly key: readonly [typeof target] }>; // A kernel's lookup by entity matches has and not, as any system's does. System.create({ - name: "test/followPlayers", - kernel: "cpu", - components: { read: [target], write: [position] }, - lookup: { players: { read: [position], has: [player], not: [velocity] } }, + kernel: true, + components: { with: [target, position] }, + lookup: { players: { with: [position, player], not: [velocity] } }, }, (row, { lookup }) => { const found = lookup.players(row[target]); if (found !== null) row[position] = found[position]; }); System.create({ - name: "test/followPlayers", - kernel: "cpu", - // @ts-expect-error an index lookup matches what its index holds, so it takes no has - lookup: { byTarget: { index: byTarget, read: [position], has: [player] } }, + kernel: true, + components: { with: [velocity] }, + // @ts-expect-error an index lookup matches what its index holds, so it takes no with + lookup: { byTarget: { index: byTarget, with: [player] } }, }, () => {}); System.create({ - name: "test/tally", - kernel: "cpu", - // @ts-expect-error kernels can't write resources - resources: { write: [score] }, + kernel: true, + components: { with: [velocity] }, }, () => {}); // GPU kernels: everything kernels can't do, and no lookups, change filters or commands. System.create({ - name: "test/integrate", kernel: "gpu", - components: { read: [velocity], write: [position] }, - resources: { read: [time] }, + components: { with: [velocity, position] }, }, row => { row[position] = row[velocity]; }); System.create({ - name: "test/integrate", kernel: "gpu", - // @ts-expect-error GPU kernel values must be linear too - components: { read: [label] }, -}, () => {}); - -System.create({ - name: "test/follow", - kernel: "gpu", - components: { read: [target], write: [position] }, + components: { with: [target, position] }, // @ts-expect-error GPU kernels can't use lookups - lookup: { targets: { read: [position] } }, + lookup: { targets: { with: [position] } }, }, () => {}); System.create({ - name: "test/integrate", kernel: "gpu", // @ts-expect-error GPU kernels can't use change filters - components: { write: [position], changed: [velocity] }, + components: { with: [position], changed: [velocity] }, }, () => {}); System.create({ - name: "test/integrate", kernel: "gpu", - components: { read: [velocity], write: [position] }, + components: { with: [velocity, position] }, }, (row, context) => { // @ts-expect-error GPU kernels can't issue commands context.commands.despawn(row.entity); }); System.create({ - name: "test/integrate", - // @ts-expect-error a kernel is "cpu" or "gpu" + // @ts-expect-error a kernel is true, false or "gpu" + kernel: "cpu", +}, () => {}); + +// Without the flag a body compiles when it can; false runs it as written, whatever it uses. +System.create({ components: { with: [label] } }, () => {}); +System.create({ kernel: false, components: { with: [label, path] } }, () => {}); + +System.create({ + // @ts-expect-error a system without components runs once, so there's nothing to compile kernel: true, }, () => {}); +System.create({ + // @ts-expect-error a system without components runs once, so there's nothing to compile, for the GPU either + kernel: "gpu", +}, () => {}); -// Reductions: each reduced resource is a function contributing a value of its type, with its trait's operator repeated. +// Reductions: each reduced resource is a function contributing a value of its type, folded with its trait's operator. System.create({ - name: "test/tally", - components: { read: [velocity, vector], changed: [velocity] }, - reduce: { [total]: "+", [bounds]: "min", [flagged]: "|" }, + components: { with: [velocity, vector], changed: [velocity] }, }, (row, { reduce }) => { reduce[total](row[velocity]); reduce[bounds](row[vector]); @@ -325,54 +277,34 @@ System.create({ void (reduce[total] + 1); }); -System.create({ - name: "test/tally", - // @ts-expect-error the operator must equal the resource's reduce trait - reduce: { [total]: "max" }, -}, () => {}); - -System.create({ - name: "test/tally", +System.create({}, ({ reduce }) => { // @ts-expect-error only resources with a reduce trait can be reduced - reduce: { [score]: "+" }, -}, () => {}); - -System.create({ - name: "test/tally", - // @ts-expect-error a reduced resource is never written - resources: { write: [total] }, -}, () => {}); - -System.create({ - name: "test/tally", - // @ts-expect-error a contributor doesn't read what it reduces into - resources: { read: [total] }, - reduce: { [total]: "+" }, -}, () => {}); + reduce[time](1); +}); System.create({ - name: "test/integrate", kernel: "gpu", - components: { read: [velocity] }, - // @ts-expect-error GPU kernels can't reduce yet - reduce: { [total]: "+" }, + components: { with: [velocity] }, }, () => {}); // Render systems: the accessors name the components they read. -System.render({ name: "test/draw", components: { has: [player] }, resources: { read: [time] }, shader: { accessors: { place: position, speed: velocity } } }, (gpu, { resources }) => { +System.render({ components: { with: [player] }, shader: { accessors: { place: position, speed: velocity } } }, (gpu, { resources }) => { void [gpu.encoder, resources[time]]; }); -System.render({ name: "test/draw", shader: { accessors: { place: position } } }, (_, context) => { +System.render({ shader: { accessors: { place: position } } }, (_, context) => { // @ts-expect-error a render body issues no commands void context.commands; }); System.render({ - name: "test/draw", // @ts-expect-error a render system reads the components its accessors name components: { read: [position] }, shader: { accessors: { place: position } }, }, () => {}); +System.render({ shader: { accessors: { place: position } } }, (_, { resources }) => { + // @ts-expect-error a render system only reads + resources[score] = 0; +}); // A pass helper several render systems share declares its context and tables by name. const drawAll = (gpu: System.RenderContext, pass: GPURenderPassEncoder): void => { for (const table of gpu.tables) drawTable(table, pass); @@ -381,18 +313,42 @@ const drawTable = (table: System.RenderTable, pass: GPURenderPassEncoder): void table.bind(pass); pass.draw(6, table.count); }; -System.render({ name: "test/drawShared", shader: { accessors: { place: position } } }, gpu => { +System.render({ shader: { accessors: { place: position } } }, gpu => { const pass = gpu.encoder.beginRenderPass({ colorAttachments: [] }); drawAll(gpu, pass); pass.end(); }); +// `order` draws every matching archetype as one table, in a sorted index's order. +declare const byPosition: Index<{ readonly key: readonly [typeof position]; readonly sorted: true }>; +declare const hashedByPosition: Index<{ readonly key: readonly [typeof position] }>; +declare const someIndex: Index; +System.render({ components: { with: [player] }, order: byPosition, shader: { accessors: { place: position } } }, gpu => { + const pass = gpu.encoder.beginRenderPass({ colorAttachments: [] }); + drawAll(gpu, pass); + pass.end(); +}); +System.render({ + // @ts-expect-error a render system is ordered by a sorted index + order: hashedByPosition, + shader: { accessors: { place: position } }, +}, () => {}); +System.render({ + // @ts-expect-error an index whose declaration isn't known isn't known to be sorted + order: someIndex, + shader: { accessors: { place: position } }, +}, () => {}); +System.render({ + // @ts-expect-error an order is an index + order: [position], + shader: { accessors: { place: position } }, +}, () => {}); // Names are optional: a declaration may leave it out, and a system that declares nothing is just its body. -export const unnamed = System.create({ components: { write: [position] }, resources: { read: [time] } }, (row, { resources }) => { +export const unnamed = System.create({ components: { with: [position] } }, (row, { resources }) => { row[position] += resources[time]; }); check>(); -export const unnamedInput = System.create({ input: { type: "object", properties: { count: { type: "integer" } } } }, ({ input }) => { +export const unnamedInput = System.create({}, (_context, input: { readonly count: number }) => { const count: number = input.count; void count; }); @@ -401,8 +357,8 @@ export const bodyOnly = System.create(({ commands }) => { commands.spawn([position])({ [position]: 0 }); }); check>(); -// @ts-expect-error a body alone declares no resources -System.create(({ resources }) => { void resources; }); +// A body alone reads and writes resources as any system does. +System.create(({ resources }) => { resources[score] += 1; }); // @ts-expect-error a body alone runs once per run, so it has no row System.create((row: { readonly entity: Entity }) => { void row; }); export const unnamedAll = System.create([unnamed, bodyOnly]); @@ -411,11 +367,9 @@ System.render({ shader: { accessors: { place: position } } }, gpu => { void gpu. // Observer systems read what a sync point left, after it: rows, resources, lookups and change filters, and no commands. export const watchPositions = System.create({ - name: "test/watchPositions", observer: true, - components: { read: [position], changed: [position] }, - resources: { read: [time] }, - lookup: { targets: { read: [velocity] } }, + components: { with: [position], changed: [position] }, + lookup: { targets: { with: [velocity] } }, }, (row, context) => { const read: number = row[position] + context.resources[time]; void [read, context.lookup.targets(row.entity)]; @@ -425,37 +379,45 @@ export const watchPositions = System.create({ void context.commands; }); check>(); -export const watchTime = System.create({ observer: true, resources: { read: [time], changed: [time] } }, ({ resources }) => { void resources[time]; }); +export const watchTime = System.create({ observer: true, resources: { changed: [time] } }, ({ resources }) => { void resources[time]; }); // A composite may hold observer members, which run after its sync point. export const withWatch = System.create([integrate, watchTime]); check>(); // @ts-expect-error observer is true or left out -System.create({ observer: false, resources: { read: [time] } }, () => {}); -// @ts-expect-error an observer system writes no components -System.create({ observer: true, components: { write: [position] } }, () => {}); -// @ts-expect-error an observer system writes no resources -System.create({ observer: true, resources: { write: [score] } }, () => {}); -// @ts-expect-error an observer system reduces into nothing -System.create({ observer: true, components: { read: [velocity] }, reduce: { [total]: "+" } }, () => {}); +System.create({ observer: false }, () => {}); +System.create({ observer: true, components: { with: [position] } }, (row, { resources }) => { + // @ts-expect-error an observer system writes no components + row[position] = 0; + // @ts-expect-error an observer system writes no resources + resources[score] = 0; +}); +System.create({ observer: true }, context => { + // @ts-expect-error an observer system reduces into nothing + void context.reduce; +}); // @ts-expect-error an observer system takes no input -System.create({ observer: true, input: { type: "object", properties: { count: { type: "integer" } } } }, () => {}); +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: "cpu", components: { read: [position] } }, () => {}); +System.create({ observer: true, kernel: true, components: { with: [position] } }, () => {}); +System.create({ observer: true, kernel: false, components: { with: [position] } }, () => {}); // @ts-expect-error an observer system is ordered with nothing -System.create({ observer: true, resources: { read: [time] }, after: [integrate] }, () => {}); +System.create({ observer: true, after: [integrate] }, () => {}); // @ts-expect-error an observer system is ordered with nothing -System.create({ observer: true, resources: { read: [time] }, before: [integrate] }, () => {}); +System.create({ observer: true, before: [integrate] }, () => {}); // @ts-expect-error an observer system is ordered with nothing -System.create({ observer: true, resources: { read: [time] }, during: [integrate] }, () => {}); +System.create({ observer: true, during: [integrate] }, () => {}); // A system on one entity: its input names the entity, and its body runs on that entity's row. -const onOne = { type: "object", properties: { entity: Entity.schema, by: { type: "number" } } } as const; -export const nudge = System.create({ input: onOne, target: "entity", components: { write: [position] } }, (row, { input }) => { +type OnOne = { readonly entity: Entity; readonly by: number }; +export const nudge = System.create({ target: "entity", components: { with: [position] } }, (row, _context, input: OnOne) => { row[position] += input.by; }); +check>>(); // @ts-expect-error its target is an entity field of its input -System.create({ input: onOne, target: "by", components: { write: [position] } }, () => {}); +System.create({ target: "by", components: { with: [position] } }, (_row, _context, input: OnOne) => { void input; }); +// @ts-expect-error it takes its target from its input, so it takes input +System.create({ target: "entity", components: { with: [position] } }, row => { void row; }); // @ts-expect-error it runs on a row, so it needs components -System.create({ input: onOne, target: "entity" }, () => {}); +System.create({ target: "entity" }, (_context, input: OnOne) => { void input; }); // @ts-expect-error not a kernel -System.create({ input: onOne, target: "entity", kernel: "cpu", components: { write: [position] } }, () => {}); +System.create({ target: "entity", kernel: true, components: { with: [position] } }, (_row, _context, input: OnOne) => { void input; }); diff --git a/packages/ecs/src/interface/system/threads.ts b/packages/ecs/src/interface/system/threads.ts index 7e49f0d76..ae0d7d928 100644 --- a/packages/ecs/src/interface/system/threads.ts +++ b/packages/ecs/src/interface/system/threads.ts @@ -8,20 +8,20 @@ import type { Listed, Property } from './property.js'; import type { KeyOf } from './values.js'; type Lookups = Property; -type LookupReads = { [L in keyof Lookups]: Listed[L], "read"> }[keyof Lookups]; type LookupKeys = { [L in keyof Lookups]: Lookups[L] extends { readonly index: Index } ? KeyOf[number] : never }[keyof Lookups]; type OrderKeys = Property extends Index ? KeyOf[number] : never; type Components = Property; type Resources = Property; -/** The components that place a declaration: those it reads, writes or filters on by change, and the keys it looks up and orders by. */ -type Placing = - | Listed, "read"> | Listed, "write"> | Listed, "added"> | Listed, "changed"> - | LookupReads | LookupKeys | Listed, "read"> | OrderKeys; +/** + * The components that place a declaration, as far as its type shows: those it filters on by change, and the keys it + * looks up and orders by. A member of `with` places it only where its body uses one, which the build infers and + * registration checks, so types can't tell it from one only required. + */ +type Placing = Listed, "added"> | Listed, "changed"> | LookupKeys | OrderKeys; -/** The resources that place a declaration: those it reads, writes, filters on by change, or reads as a condition. */ -type PlacingResources = - Listed, "read"> | Listed, "write"> | Listed, "changed"> | Listed, "is">; +/** The resources that place a declaration, as far as its type shows: those it filters on by change or reads as a condition. */ +type PlacingResources = Listed, "changed"> | Listed, "is">; /** The threads of the object components and resources a declaration uses. */ export type ThreadsOf = ThreadOfComponent & Component.Key> | ThreadOfResource & Resource.Key>; diff --git a/packages/ecs/src/interface/system/threads.type-test.ts b/packages/ecs/src/interface/system/threads.type-test.ts index a01cc0921..d3e3bffc5 100644 --- a/packages/ecs/src/interface/system/threads.type-test.ts +++ b/packages/ecs/src/interface/system/threads.type-test.ts @@ -34,82 +34,50 @@ declare module '@adobe/ecs' { [time]: { type: number }; } } -Resource.register(settings, { type: { anyOf: [{ type: "object", properties: { name: { type: "string" } } }, { type: "null" }], default: null } }); -Component.register(node, { thread: "main" }); -Component.register(label, { type: { type: "string" } }); -Component.register(audio, { thread: "threadsTypeTest" }); -Component.register(position, { type: { type: "array", items: { type: "number" }, minItems: 2, maxItems: 2 } }); -Component.register(tags, { type: { type: "array", items: { type: "string" } } }); -Resource.register(document, { thread: "main" }); -Resource.register(time, { type: { type: "number", default: 0 } }); -// A system runs on the thread of the object components and resources it uses; strings and structs go with any. -System.create({ name: "threadsTypeTest/render", components: { read: [position], write: [node] }, resources: { read: [time, document] } }, () => {}); -System.create({ name: "threadsTypeTest/play", components: { read: [audio, position] } }, () => {}); -System.create({ name: "threadsTypeTest/label", components: { write: [label] }, resources: { is: [settings] } }, () => {}); -// Presence-only lists don't place a system. -System.create({ name: "threadsTypeTest/count", components: { read: [label], has: [node], not: [audio] }, resources: { read: [time] } }, () => {}); -System.create({ name: "threadsTypeTest/paused", components: { read: [node] }, resources: { not: [settings] } }, () => {}); -System.create({ name: "threadsTypeTest/caption", components: { read: [label], write: [node] } }, () => {}); -System.create({ name: "threadsTypeTest/captionChanged", components: { read: [node], changed: [label] }, lookup: { labels: { read: [label] } } }, () => {}); +// A system runs on the thread of the object components and resources it uses; strings and structs go with any. Its +// type places it by what it filters on, conditions and lookup keys; what its body uses places it at registration. +System.create({ components: { with: [position, node] } }, () => {}); +System.create({ components: { with: [audio, position] } }, () => {}); +System.create({ components: { with: [label] }, resources: { is: [settings] } }, () => {}); +System.create({ components: { with: [label, node], not: [audio] } }, () => {}); +System.create({ components: { with: [node] }, resources: { not: [settings] } }, () => {}); +System.create({ components: { with: [node], changed: [label] }, lookup: { labels: { with: [label] } } }, () => {}); +System.create({ components: { with: [tags, node] } }, () => {}); -// @ts-expect-error a system's object components live on one thread -System.create({ name: "threadsTypeTest/both", components: { read: [node, tags] } }, () => {}); -// @ts-expect-error writes and change filters place it too -System.create({ name: "threadsTypeTest/changed", components: { write: [tags], changed: [audio] } }, () => {}); -// @ts-expect-error a lookup's reads place it too -System.create({ name: "threadsTypeTest/lookup", components: { read: [tags] }, lookup: { nodes: { read: [node] } } }, () => {}); +// @ts-expect-error change filters and conditions place it +System.create({ components: { changed: [tags, audio] } }, () => {}); // @ts-expect-error resources and components live on one thread -System.create({ name: "threadsTypeTest/mixed", components: { read: [audio] }, resources: { read: [document] } }, () => {}); -// @ts-expect-error a condition it reads places it -System.create({ name: "threadsTypeTest/condition", components: { read: [node] }, resources: { is: [settings] } }, () => {}); +System.create({ components: { changed: [audio] }, resources: { is: [document] } }, () => {}); // Render systems run on gpuDevice's thread, "world". -System.render({ name: "threadsTypeTest/draw", resources: { read: [settings] }, shader: { accessors: { place: position } } }, () => {}); +System.render({ shader: { accessors: { place: position } } }, () => {}); // @ts-expect-error a render system's resources live with gpuDevice -System.render({ name: "threadsTypeTest/drawNode", resources: { read: [document] }, shader: { accessors: { place: position } } }, () => {}); +System.render({ resources: { is: [document] }, shader: { accessors: { place: position } } }, () => {}); // A composite's members may live on different threads. -const render = System.create({ name: "threadsTypeTest/renderMember", components: { write: [node] } }, () => {}); -const play = System.create({ name: "threadsTypeTest/playMember", components: { write: [audio] } }, () => {}); -export const frame = System.create({ name: "threadsTypeTest/frame", systems: [render, play] }); +const render = System.create({ components: { with: [node] } }, () => {}); +const play = System.create({ components: { with: [audio] } }, () => {}); +export const frame = System.create({ systems: [render, play] }); -// Finalizers too run on one thread. -Finalizer.create({ name: "threadsTypeTest/release", components: { read: [node] }, resources: { read: [document] } }, () => {}); -// @ts-expect-error a finalizer's object components and resources live on one thread -Finalizer.create({ name: "threadsTypeTest/releaseBoth", components: { read: [node, audio] } }, () => {}); -Finalizer.create({ name: "threadsTypeTest/releaseCaption", components: { read: [node, label] } }, () => {}); - -// A finalizer on another thread than the world's, which a joined thread may fill, runs there after the sync point: it -// issues no commands, writes no resources and looks up nothing. -Finalizer.create({ name: "threadsTypeTest/detach", components: { read: [node, position] }, resources: { read: [document, time] } }, (row, context) => { +// Finalizers too run on one thread, which registration finds from what their bodies use. One on another thread than +// the world's, which a joined thread may fill, runs there after the sync point: it issues no commands, writes no +// resources and looks up nothing. +Finalizer.create({ components: { with: [node] } }, () => {}); +Finalizer.create({ components: { with: [node, label] } }, () => {}); +Finalizer.create({ components: { with: [node, position] } }, (row, context) => { row[node].remove(); void [row[position], context.resources[document], context.resources[time]]; - // @ts-expect-error a finalizer on another thread issues no commands - void context.commands; -}); -Finalizer.create({ name: "threadsTypeTest/silence", components: { read: [audio] } }, (row, context) => { - void row[audio]; - // @ts-expect-error a finalizer on another thread issues no commands - void context.commands; }); -// @ts-expect-error a finalizer on another thread writes no resources -Finalizer.create({ name: "threadsTypeTest/detachWriting", components: { read: [node] }, resources: { write: [time] } }, () => {}); -// @ts-expect-error a finalizer writing another thread's resource is on that thread -Finalizer.create({ name: "threadsTypeTest/documentWriting", components: { read: [position] }, resources: { write: [document] } }, () => {}); -// @ts-expect-error a finalizer on another thread looks up nothing -Finalizer.create({ name: "threadsTypeTest/detachLooking", components: { read: [node] }, lookup: { places: { read: [position] } } }, () => {}); // On the world's thread, structs and its objects alike, it does. -Finalizer.create({ name: "threadsTypeTest/forget", components: { read: [label, position] }, resources: { write: [time] }, lookup: { labels: { read: [label] } } }, (row, { commands, resources, lookup }) => { +Finalizer.create({ components: { with: [label, position] }, lookup: { labels: { with: [label] } } }, (row, { commands, resources, lookup }) => { resources[time] = lookup.labels(row.entity)?.[label].length ?? 0; commands.despawn(row.entity); }); -// A changes finalizer runs on the world's thread: watching another thread's objects, or reading its resources, doesn't compile. -Finalizer.changes({ name: "threadsTypeTest/save", watch: [label, position], resources: { read: [time] } }, (_, { commands }) => { void commands; }); +// A changes finalizer runs on the world's thread: watching another thread's objects doesn't compile. +Finalizer.changes({ watch: [label, position] }, (_, { commands }) => { void commands; }); // @ts-expect-error a changes finalizer watches no other thread's objects -Finalizer.changes({ name: "threadsTypeTest/saveNodes", watch: [node] }, () => {}); -// @ts-expect-error a changes finalizer reads no other thread's resources -Finalizer.changes({ name: "threadsTypeTest/saveDocument", resources: { read: [document] } }, () => {}); +Finalizer.changes({ watch: [node] }, () => {}); // The error names each object component and resource with its thread; data places nothing. -check, { readonly [audio]: "threadsTypeTest"; readonly [document]: "main" }>>(); +check, { readonly [audio]: "threadsTypeTest"; readonly [document]: "main" }>>(); diff --git a/packages/ecs/src/interface/system/up.type-test.ts b/packages/ecs/src/interface/system/up.type-test.ts index 885adaa58..d3b811ec2 100644 --- a/packages/ecs/src/interface/system/up.type-test.ts +++ b/packages/ecs/src/interface/system/up.type-test.ts @@ -19,63 +19,49 @@ declare module '@adobe/ecs' { [name]: { type: string }; } } -Component.register(local, { type: { type: "number" } }); -Component.register(world, { type: { type: "number" } }); -Component.register(parent, { type: Entity.schema, reference: "owner" }); -Component.register(name, { type: { type: "string" } }); -// `up` reads what `read` lists of the entity `through` names, or null for a row without one; it may read what the -// system writes. +// `up` holds the values of the entity `through` names, or null for a row without one; it may read what the system +// writes. What the body reads of it the build infers. System.create({ - name: "upTypeTest/propagate", - kernel: "cpu", - components: { read: [local], write: [world] }, - up: { through: parent, read: [world] }, + kernel: true, + components: { with: [local, world] }, + up: { through: parent }, }, (row, { up }) => { - check>(); + if (up !== null) check>(); row[world] = up === null ? row[local] : up[world] + row[local]; // @ts-expect-error up's values are read-only if (up !== null) up[world] = 0; }); // Without `up`, the context has none. -System.create({ name: "upTypeTest/none", components: { read: [local] } }, (row, context) => { +System.create({ components: { with: [local] } }, (row, context) => { void row[local]; // @ts-expect-error no up declared void context.up; }); System.create({ - name: "upTypeTest/unreferenced", - components: { write: [world] }, + components: { with: [world] }, // @ts-expect-error through is a reference component - up: { through: local, read: [world] }, + up: { through: local }, }, () => {}); -System.create({ - name: "upTypeTest/text", - kernel: "cpu", - components: { write: [world] }, - // @ts-expect-error a kernel's up reads linear values - up: { through: parent, read: [name] }, -}, () => {}); + // A GPU kernel reads up too. System.create({ - name: "upTypeTest/gpu", kernel: "gpu", - components: { read: [local], write: [world] }, - up: { through: parent, read: [world] }, + components: { with: [local, world] }, + up: { through: parent }, }, (row, { up }) => { row[world] = up === null ? row[local] : up[world] + row[local]; }); System.create({ - name: "upTypeTest/rowless", // @ts-expect-error up orders rows, so it needs components - up: { through: parent, read: [world] }, + up: { through: parent }, }, () => {}); // An observer system reads what a sync point left, in no order. // @ts-expect-error an observer system has no up -System.create({ observer: true, components: { read: [local] }, up: { through: parent, read: [local] } }, () => {}); +System.create({ observer: true, components: { with: [local] }, up: { through: parent } }, () => {}); diff --git a/packages/ecs/src/interface/archetype/values-of.ts b/packages/ecs/src/interface/system/values-of.ts similarity index 100% rename from packages/ecs/src/interface/archetype/values-of.ts rename to packages/ecs/src/interface/system/values-of.ts diff --git a/packages/ecs/src/interface/system/values.ts b/packages/ecs/src/interface/system/values.ts index 66f96242c..cd8f2bfe0 100644 --- a/packages/ecs/src/interface/system/values.ts +++ b/packages/ecs/src/interface/system/values.ts @@ -17,8 +17,8 @@ export type Values = /** An index declaration's key. */ export type KeyOf = D extends { readonly key: infer K extends readonly Component.Key[] } ? K : readonly Component.Key[]; -/** The components every entity an index holds has: its key parts and its `has`. */ -export type HeldBy = KeyOf[number] | Listed, "has">; +/** The components every entity an index holds has: its key parts and its `with`. */ +export type HeldBy = KeyOf[number] | Listed, "with">; /** The key's values, in key order. */ type KeyValues = { readonly [I in keyof K]: Component.Value }; @@ -28,8 +28,8 @@ type Prefix = V extends readonly [infer First, ... ? readonly [] | readonly [First | Comparison] | readonly [First, ...Prefix] : readonly []; -/** A row an index lookup gives: its entity and the lookup's `read` values, read-only. */ -type IndexRow = { readonly entity: Entity } & { readonly [K in Listed & Component.Key]: Component.Value }; +/** A row an index lookup gives: its entity and the values of what every entity the index holds has, read-only. */ +type IndexRow = { readonly entity: Entity } & { readonly [K in HeldBy & Component.Key]: Component.Value }; /** * The rows a lookup found, in the index's order: by entity for a hash index, by key and then entity for a sorted one. @@ -48,24 +48,26 @@ type Rows = { }; /** A lookup through an index, by its declaration `D`: by key, a prefix of it for a sorted index. */ -type IndexLookup = +type IndexLookup = D extends { readonly unique: true } ? D extends { readonly sorted: true } - ? ((...key: KeyValues>) => IndexRow | null) & ((...key: Prefix>>) => Rows>) - : (...key: KeyValues>) => IndexRow | null + ? ((...key: KeyValues>) => IndexRow | null) & ((...key: Prefix>>) => Rows>) + : (...key: KeyValues>) => IndexRow | null : D extends { readonly sorted: true } - ? (...key: Prefix>>) => Rows> - : (...key: KeyValues>) => Rows>; + ? (...key: Prefix>>) => Rows> + : (...key: KeyValues>) => Rows>; /** - * The row a lookup by entity gives: the lookup's `read` values, read-only, and with `changed` declared, whether a + * The row a lookup by entity gives: the values of its `with`, read-only, and with `changed` declared, whether a * component it names may have changed in the entity since its system last ran: conservative, as `changed` filters are, * and true on a system's first run and in finalizers. With `changed`, valid until the lookup is called again. */ type EntityRow = { - readonly [K in (Listed & Component.Key) | (L extends { readonly changed: readonly Component.Key[] } ? "changed" : never)]: K extends Component.Key ? Component.Value : boolean + readonly [K in (Listed & Component.Key) | (Exclude, Listed> & Component.Key) | (L extends { readonly changed: readonly Component.Key[] } ? "changed" : never)]: + // Its `optional` components are their values where the entity has them, `undefined` where it doesn't. + K extends Component.Key ? K extends Listed ? Component.Value : Component.Value | undefined : boolean }; export type LookupFunction = L extends { readonly index: Index } - ? IndexLookup + ? IndexLookup : (entity: Entity) => EntityRow | null; diff --git a/packages/ecs/src/interface/thread/thread.type-test.ts b/packages/ecs/src/interface/thread/thread.type-test.ts index 103e7441d..89397148d 100644 --- a/packages/ecs/src/interface/thread/thread.type-test.ts +++ b/packages/ecs/src/interface/thread/thread.type-test.ts @@ -22,105 +22,39 @@ void undeclared; const label = Symbol("threadTypeTest/label"); const path = Symbol("threadTypeTest/path"); const handle = Symbol("threadTypeTest/handle"); -const placedLabel = Symbol("threadTypeTest/placedLabel"); -const unknownThread = Symbol("threadTypeTest/unknownThread"); const position = Symbol("threadTypeTest/position"); -const placedPosition = Symbol("threadTypeTest/placedPosition"); -const placedTag = Symbol("threadTypeTest/placedTag"); const element = Symbol("threadTypeTest/element"); -const unplacedElement = Symbol("threadTypeTest/unplacedElement"); -const workerElement = Symbol("threadTypeTest/workerElement"); const maybeElement = Symbol("threadTypeTest/maybeElement"); const bitmap = Symbol("threadTypeTest/bitmap"); const buffer = Symbol("threadTypeTest/buffer"); -const notTransferable = Symbol("threadTypeTest/notTransferable"); const canvas = Symbol("threadTypeTest/canvas"); const cache = Symbol("threadTypeTest/cache"); -const count = Symbol("threadTypeTest/count"); const port = Symbol("threadTypeTest/port"); -const text = Symbol("threadTypeTest/text"); const names = Symbol("threadTypeTest/names"); -const mode = Symbol("threadTypeTest/mode"); -const caption = Symbol("threadTypeTest/caption"); declare module '@adobe/ecs' { export interface Components { // Objects: on "world" without `thread`, or on the thread they name. [path]: { type: readonly number[]; thread: "threadTypeTest" }; [handle]: { type: Map; thread: "world" }; - [unknownThread]: { type: readonly number[]; thread: "elsewhere" }; - // Strings and structs live in shared memory, so they have no thread. + // Strings and structs live in shared memory, so they have no thread; World.create rejects one with it. [label]: { type: string }; - [placedLabel]: { type: string; thread: "main" }; [position]: { type: readonly [number, number, number] }; - [placedPosition]: { type: readonly [number, number, number]; thread: "main" }; - [placedTag]: { type: true; thread: "world" }; - // A DOM Node lives on "main", and says so. + // A DOM Node lives on "main", and says so; the build rejects one without it. [element]: { type: HTMLElement; thread: "main" }; - [unplacedElement]: { type: HTMLElement }; - [workerElement]: { type: HTMLElement; thread: "threadTypeTest" }; [maybeElement]: { type: HTMLCanvasElement | null; thread: "main" }; // Transferable values may be moved, never copied, to their thread. [bitmap]: { type: ImageBitmap; thread: "threadTypeTest"; transfer: true }; [buffer]: { type: ArrayBuffer; transfer: true }; - [notTransferable]: { type: Map; transfer: true }; } export interface Resources { [canvas]: { type: OffscreenCanvas | null; thread: "threadTypeTest"; transfer: true }; [cache]: { type: Map | null; thread: "main" }; - [count]: { type: number; thread: "main" }; [port]: { type: MessagePort | null; transfer: true }; - [text]: { type: string; transfer: true }; [names]: { type: Map; thread: "main" }; - [mode]: { type: "edit" | "view"; thread: "main" }; - [caption]: { type: string | null; thread: "main" }; } } -Component.register(label, { type: { type: "string" } }); -Component.register(path, { type: { type: "array", items: { type: "number" } }, thread: "threadTypeTest" }); -Component.register(handle, { thread: "world" }); -Component.register(position, { type: { type: "array", items: { type: "number" }, minItems: 3, maxItems: 3 } }); -Component.register(element, { thread: "main" }); -Component.register(maybeElement, { thread: "main" }); -Component.register(bitmap, { thread: "threadTypeTest", transfer: true }); -Component.register(buffer, { transfer: true }); -Resource.register(canvas, { thread: "threadTypeTest", transfer: true }); -Resource.register(cache, { thread: "main" }); -Resource.register(port, { transfer: true }); - -// @ts-expect-error a thread is a declared one -Component.register(unknownThread, { type: { type: "array", items: { type: "number" } }, thread: "elsewhere" }); -// @ts-expect-error a string lives in shared memory, so it has no thread -Component.register(placedLabel, { type: { type: "string" }, thread: "main" }); -// @ts-expect-error a union of string literals is a string too -Resource.register(mode, { type: { anyOf: [{ const: "edit" }, { const: "view" }], default: "view" }, thread: "main" }); -// @ts-expect-error and so is a string or null -Resource.register(caption, { type: { anyOf: [{ type: "string" }, { type: "null" }], default: null }, thread: "main" }); -// @ts-expect-error a struct lives in shared memory, so it has no thread -Component.register(placedPosition, { type: { type: "array", items: { type: "number" }, minItems: 3, maxItems: 3 }, thread: "main" }); -// @ts-expect-error a tag is a struct too -Component.register(placedTag, { type: { const: true }, thread: "world" }); -// @ts-expect-error a struct resource has no thread either -Resource.register(count, { type: { type: "number", default: 0 }, thread: "main" }); -// @ts-expect-error a DOM Node declares thread: "main" -Component.register(unplacedElement, {}); -// @ts-expect-error a DOM Node lives on "main", not another thread -Component.register(workerElement, { thread: "threadTypeTest" }); -// @ts-expect-error only a Transferable value is moved -Component.register(notTransferable, { transfer: true }); -// @ts-expect-error a string is data, copied, never moved -Resource.register(text, { type: { type: "string", default: "" }, transfer: true }); -// @ts-expect-error a value that's data is registered with its schema -Component.register(label, {}); -// @ts-expect-error a component's object has no default: its value comes from commands -Component.register(handle, { default: new Map(), thread: "world" }); -// @ts-expect-error a resource without null starts at a default -Resource.register(names, { thread: "main" }); -// @ts-expect-error a default is a value of the type -Resource.register(names, { default: "map", thread: "main" }); -Resource.register(names, { default: new Map(), thread: "main" }); - check, readonly number[]>>(); check, never>>(); check, never>>(); diff --git a/packages/ecs/src/interface/trait/index.ts b/packages/ecs/src/interface/trait/index.ts deleted file mode 100644 index 5b9912448..000000000 --- a/packages/ecs/src/interface/trait/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -export { register } from './register.js'; diff --git a/packages/ecs/src/interface/trait/register.ts b/packages/ecs/src/interface/trait/register.ts deleted file mode 100644 index ea883b4e1..000000000 --- a/packages/ecs/src/interface/trait/register.ts +++ /dev/null @@ -1,10 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { addTrait } from '../registry/traits.js'; -import type { BuiltIn, Key } from './key.js'; - -/** - * Registers a trait under `key`, a plain `Symbol(...)`: string trait names are the core's built-in traits. The same - * trait again is accepted, and so is a new symbol with a trait's id, as a module evaluated again makes it. Throws for a key that isn't a plain symbol, or - * one registered as a component or resource. - */ -export const register = (key: Exclude & symbol): void => addTrait(key); diff --git a/packages/ecs/src/interface/trait/required.ts b/packages/ecs/src/interface/trait/required.ts deleted file mode 100644 index 785ba3cba..000000000 --- a/packages/ecs/src/interface/trait/required.ts +++ /dev/null @@ -1,8 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { IsNode } from '../platform/platform.js'; - -/** - * The traits a declaration must have beyond those it lists: a DOM `Node` lives on `"main"`, so a declaration of one - * without `thread` can't be registered. Where the DOM library isn't loaded, nothing is required. - */ -export type Required = IsNode extends true ? (Entry extends { readonly thread: unknown } ? {} : { readonly thread: "main" }) : {}; diff --git a/packages/ecs/src/interface/trait/symbol-keys.ts b/packages/ecs/src/interface/trait/symbol-keys.ts deleted file mode 100644 index ade3778cc..000000000 --- a/packages/ecs/src/interface/trait/symbol-keys.ts +++ /dev/null @@ -1,20 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { Components } from '../component/types.js'; -import type { Resources } from '../resource/types.js'; -import type { BuiltIn } from './key.js'; -import type { Traits } from './types.js'; - -/** A required property naming `problem` and the keys that cause it, or nothing without such keys. */ -type Problem = [Keys] extends [never] ? {} : { readonly [P in Text]: Keys }; - -/** - * What every declaration also requires while `C`, `R` or `T` has a key that isn't a symbol, besides the built-in - * traits: a property named for the problem, holding the keys, so registration fails to compile and says why. - */ -export type KeysMustBeSymbols = - Problem, "component keys must be symbols"> - & Problem, "resource keys must be symbols"> - & Problem, "trait keys must be symbols">; - -/** `KeysMustBeSymbols` of the program's components, resources and traits, computed once. */ -export type SymbolKeys = KeysMustBeSymbols>; diff --git a/packages/ecs/src/interface/trait/symbol-keys.type-test.ts b/packages/ecs/src/interface/trait/symbol-keys.type-test.ts deleted file mode 100644 index 70ae8c113..000000000 --- a/packages/ecs/src/interface/trait/symbol-keys.type-test.ts +++ /dev/null @@ -1,15 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import type { KeysMustBeSymbols } from './symbol-keys.js'; - -type Equal = (() => T extends X ? 1 : 2) extends (() => T extends Y ? 1 : 2) ? true : false; -const check = <_T extends true>() => { }; - -declare const key: unique symbol; -type BuiltInTraits = { readonly type: unknown; readonly derived: true; readonly within: never; readonly reference: never; readonly reduce: never; readonly thread: never; readonly transfer: never }; - -// Symbol keys, and the built-in string traits, need nothing. -check, {}>>(); -// Each kind with a string or number key requires a property naming the problem and the keys. -check, { readonly "component keys must be symbols": "position" }>>(); -check, { readonly "resource keys must be symbols": 1 }>>(); -check, { readonly "trait keys must be symbols": "gpuOnly" }>>(); diff --git a/packages/ecs/src/interface/trait/trait.ts b/packages/ecs/src/interface/trait/trait.ts deleted file mode 100644 index 5534fc570..000000000 --- a/packages/ecs/src/interface/trait/trait.ts +++ /dev/null @@ -1,4 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -export type Trait = never; - -export * as Trait from './index.js'; diff --git a/packages/ecs/src/interface/trait/trait.type-test.ts b/packages/ecs/src/interface/trait/trait.type-test.ts index b0c3f05e4..0ed58cfbd 100644 --- a/packages/ecs/src/interface/trait/trait.type-test.ts +++ b/packages/ecs/src/interface/trait/trait.type-test.ts @@ -5,7 +5,6 @@ import { Resource } from '../resource/resource.js'; import type { Commands } from '../system/commands.js'; import type { IsStruct } from '../schema/is-struct.js'; import type { Components } from '../component/types.js'; -import { Trait } from './trait.js'; import type { Derived, Essential } from '../component/value.js'; import type { Reduced } from '../resource/reduction.js'; @@ -21,10 +20,6 @@ declare module '@adobe/ecs' { readonly [nonPersistent]: N extends Component.Key ? (Components[N] extends { readonly derived: true } ? never : true) : true; } } -Trait.register(gpuOnly); -Trait.register(nonPersistent); -// @ts-expect-error string trait names are the core's built-in traits -Trait.register("derived"); const parent = Symbol("traitTest/parent"); const follows = Symbol("traitTest/follows"); @@ -34,90 +29,27 @@ const score = Symbol("traitTest/score"); const link = Symbol("traitTest/link"); const pair = Symbol("traitTest/pair"); const cache = Symbol("traitTest/cache"); -const badReference = Symbol("traitTest/badReference"); -const selfWithin = Symbol("traitTest/selfWithin"); -const derivedWithin = Symbol("traitTest/derivedWithin"); -const derivedReference = Symbol("traitTest/derivedReference"); -const gpuString = Symbol("traitTest/gpuString"); -const derivedNonPersistent = Symbol("traitTest/derivedNonPersistent"); const time = Symbol("traitTest/time"); -const derivedResource = Symbol("traitTest/derivedResource"); const sum = Symbol("traitTest/sum"); -const anyFlag = Symbol("traitTest/anyFlag"); -const named = Symbol("traitTest/named"); -const reducedComponent = Symbol("traitTest/reducedComponent"); declare module '@adobe/ecs' { export interface Components { [parent]: { type: Entity; reference: "owner" }; [follows]: { type: Entity; reference: "weak" }; - [slot]: { type: number; derived: true }; + [slot]: { type: number; derived: true; default: 0 }; [ready]: { type: true; within: typeof slot }; [score]: { type: number }; [link]: { type: { readonly to: Entity; readonly weight: number } }; [pair]: { type: readonly [number, number]; [gpuOnly]: true }; [cache]: { type: number; [nonPersistent]: true }; - [badReference]: { type: number; reference: "owner" }; - [selfWithin]: { type: true; within: typeof selfWithin }; - [derivedWithin]: { type: number; derived: true; within: typeof score }; - [derivedReference]: { type: Entity; derived: true; reference: "owner" }; - [gpuString]: { type: string; [gpuOnly]: true }; - [derivedNonPersistent]: { type: number; derived: true; [nonPersistent]: true }; - [reducedComponent]: { type: number; reduce: "+" }; } export interface Resources { - [time]: { type: number }; - [derivedResource]: { type: number; derived: true }; + [time]: { type: number; default: 0 }; [sum]: { type: number; reduce: "+" }; - [anyFlag]: { type: boolean; reduce: "+" }; - [named]: { type: string; reduce: "max" }; } } -// Registration takes exactly the declared traits, `type` as a schema for the declared type. -Component.register(parent, { type: Entity.schema, reference: "owner" }); -Component.register(follows, { type: Entity.schema, reference: "weak" }); -Component.register(slot, { type: { type: "number", default: 0 }, derived: true }); -Component.register(ready, { type: { const: true }, within: slot }); -Component.register(score, { type: { type: "number" } }); -Component.register(link, { type: { type: "object", properties: { to: Entity.schema, weight: { type: "number" } } } }); -Component.register(pair, { type: { type: "array", items: { type: "number" }, minItems: 2, maxItems: 2 }, [gpuOnly]: true }); -Component.register(cache, { type: { type: "number" }, [nonPersistent]: true }); -Resource.register(time, { type: { type: "number", default: 0 } }); -Resource.register(sum, { type: { type: "number", default: 0 }, reduce: "+" }); - -// @ts-expect-error a derived component's type has a default, which it starts at -Component.register(slot, { type: { type: "number" }, derived: true }); -// @ts-expect-error a reference's value is an entity -Component.register(badReference, { type: { type: "number" }, reference: "owner" }); -// @ts-expect-error a component can't live within itself -Component.register(selfWithin, { type: { const: true }, within: selfWithin }); -// @ts-expect-error a derived component's rule covers within -Component.register(derivedWithin, { type: { type: "number", default: 0 }, derived: true, within: score }); -// @ts-expect-error a rule can't set a derived component's target -Component.register(derivedReference, { type: { ...Entity.schema, default: Entity.none }, derived: true, reference: "owner" }); -// @ts-expect-error gpuOnly takes only linear components -Component.register(gpuString, { type: { type: "string" }, [gpuOnly]: true }); -// @ts-expect-error nonPersistent is redundant on a derived component -Component.register(derivedNonPersistent, { type: { type: "number", default: 0 }, derived: true, [nonPersistent]: true }); -// @ts-expect-error a declared trait is missing -Component.register(parent, { type: Entity.schema }); -// @ts-expect-error an undeclared trait is given -Component.register(score, { type: { type: "number" }, derived: true }); -// @ts-expect-error a trait's value differs from the declared one -Component.register(follows, { type: Entity.schema, reference: "owner" }); -// @ts-expect-error an unknown trait -Component.register(score, { type: { type: "number" }, unknown: true }); -// @ts-expect-error an entity's schema is Entity.schema -Component.register(parent, { type: { type: "integer" }, reference: "owner" }); -// @ts-expect-error a number's schema isn't Entity.schema -Component.register(score, { type: Entity.schema }); -// @ts-expect-error an entity field's schema is Entity.schema too -Component.register(link, { type: { type: "object", properties: { to: { type: "integer" }, weight: { type: "number" } } } }); -// @ts-expect-error a resource's schema has a default -Resource.register(time, { type: { type: "number" } }); -// @ts-expect-error derived is a component trait -Resource.register(derivedResource, { type: { type: "number", default: 0 }, derived: true }); +// The build registers each as declared; the registry checks the built-in traits when it does. // Types filter components by their traits and value types. type Ours = typeof parent | typeof follows | typeof slot | typeof ready | typeof score | typeof link | typeof pair | typeof cache; @@ -149,11 +81,5 @@ const raw: number = Entity.none; const forged: Entity = 3; void raw; void forged; -// @ts-expect-error booleans reduce bitwise -Resource.register(anyFlag, { type: { type: "boolean", default: false }, reduce: "+" }); -// @ts-expect-error only numbers and booleans, and tuples and structs of them, are reduced -Resource.register(named, { type: { type: "string", default: "" }, reduce: "max" }); -// @ts-expect-error reduce is a resource trait -Component.register(reducedComponent, { type: { type: "number" }, reduce: "+" }); check>(); check>(); diff --git a/packages/ecs/src/interface/trait/types.ts b/packages/ecs/src/interface/trait/types.ts index f2675bc00..165e0d47b 100644 --- a/packages/ecs/src/interface/trait/types.ts +++ b/packages/ecs/src/interface/trait/types.ts @@ -20,7 +20,8 @@ type Structs = { readonly [K in Component.Key | Resource.Key as K]: IsString World; +export type Create = ( + // Unknown while every declaration holds; else what a call's `this` can't be, so the call reports it, printed whole. + this: [InvalidDeclarations] extends [never] ? unknown : { readonly "declarations don't hold": { readonly [D in InvalidDeclarations as D["key"]]: D["members"] } }, + resources?: InitialResources, +) => World; diff --git a/packages/ecs/src/interface/world/declarations.ts b/packages/ecs/src/interface/world/declarations.ts new file mode 100644 index 000000000..a0f5b1d48 --- /dev/null +++ b/packages/ecs/src/interface/world/declarations.ts @@ -0,0 +1,34 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Component } from '../component/component.js'; +import type { Components } from '../component/types.js'; +import type { Resource } from '../resource/resource.js'; +import type { Resources } from '../resource/types.js'; +import type { Key as TraitKey } from '../trait/key.js'; +import type { Traits } from '../trait/types.js'; + +/** + * The members of key `K`'s declaration `Entry` that don't hold: a trait whose value `Traits` doesn't allow, a name + * that isn't a trait, and a `default` that isn't of the declared type. Checks only `K`'s own traits, so the whole check + * is linear in the declarations. + */ +type Invalid = + | { readonly [T in Exclude]-?: T extends TraitKey ? ([Entry[T]] extends [Traits[T]] ? never : T) : T }[Exclude] + | (Entry extends { readonly default: infer D } ? ([D] extends [Value] ? never : "default") : never); + +/** + * Key `K` with what's wrong with each member that doesn't hold, keyed by member so an error prints a trait by its + * key's binding; written in place, as an alias would print by its name. + */ +type Reported = [Members] extends [never] ? never : { + readonly key: K; + readonly members: { readonly [M in Members]: M extends "default" ? "isn't of the declared type" : M extends TraitKey ? "isn't allowed on this key" : "isn't a trait" }; +}; + +/** + * Every declaration in `Components` and `Resources` with members that don't hold, each as its key and what's wrong + * with those members; `never` when all hold. The build registers declarations without a type checker, so + * `World.create` reports these. + */ +export type InvalidDeclarations = + | { readonly [K in Component.Key]: Reported>> }[Component.Key] + | { readonly [K in Resource.Key]: Reported>> }[Resource.Key]; diff --git a/packages/ecs/src/interface/world/loop.type-test.ts b/packages/ecs/src/interface/world/loop.type-test.ts index db5a1213c..cc75fd055 100644 --- a/packages/ecs/src/interface/world/loop.type-test.ts +++ b/packages/ecs/src/interface/world/loop.type-test.ts @@ -43,23 +43,27 @@ type Removed = "component" | "persistent" | "shared" | "threadPort" | "threadSta check, never>>(); // Systems read the loop's state, ask for a wake time and change its cadence. -System.create({ name: "loopTypeTest/read", resources: { read: [clock] } }, ({ resources }) => { +System.create({}, ({ resources }) => { const now: number = resources[clock].now; void now; }); -System.create({ name: "loopTypeTest/pace", resources: { write: [cadence, always] } }, ({ resources }) => { +System.create({}, ({ resources }) => { resources[cadence] = { kind: "fixed", step: 1000 / 60, maxSteps: 2 }; resources[always] = true; }); -System.create({ name: "loopTypeTest/sleep", reduce: { [wake]: "min" } }, ({ reduce }) => { reduce[wake](1000); }); -// @ts-expect-error clock is written only by the world -System.create({ name: "loopTypeTest/clock", resources: { write: [clock] } }, () => {}); -// @ts-expect-error wake is reduced, never written -System.create({ name: "loopTypeTest/wake", resources: { write: [wake] } }, () => {}); -// @ts-expect-error a finalizer doesn't write clock either -Finalizer.create({ name: "loopTypeTest/finalizeClock", components: {}, resources: { write: [clock] } }, () => {}); +System.create({}, ({ reduce }) => { reduce[wake](1000); }); +System.create({}, ({ resources }) => { + // @ts-expect-error clock is written only by the world + resources[clock] = resources[clock]; + // @ts-expect-error wake is reduced, never written + resources[wake] = 0; +}); +Finalizer.create({ components: {} }, (_row, { resources }) => { + // @ts-expect-error a finalizer doesn't write clock either + resources[clock] = resources[clock]; +}); // @ts-expect-error a cadence is a frame or a fixed step -System.create({ name: "loopTypeTest/badCadence", resources: { write: [cadence] } }, ({ resources }) => { resources[cadence] = { kind: "fixed" }; }); +System.create({}, ({ resources }) => { resources[cadence] = { kind: "fixed" }; }); // Worlds start the loop's settings at other values; clock is the world's own. const paced: World = create({ [cadence]: { kind: "fixed", step: 10 }, [always]: true, [workers]: 2 }); diff --git a/packages/ecs/src/interface/world/world.type-test.ts b/packages/ecs/src/interface/world/world.type-test.ts index cbb1b88d8..7d58f7b19 100644 --- a/packages/ecs/src/interface/world/world.type-test.ts +++ b/packages/ecs/src/interface/world/world.type-test.ts @@ -23,9 +23,6 @@ declare module '@adobe/ecs' { [shared]: { type: true }; } } -Component.register(point, { type: { type: "object", properties: { x: { type: "number" } } } }); -Component.register(persistent, { type: { const: true } }); -Component.register(shared, { type: { const: true } }); // Reading goes through observer systems: a world has no observers, and no synchronous reads. // @ts-expect-error no observe: observer systems read a world void world.observe; diff --git a/packages/ecs/src/observe/entity.ts b/packages/ecs/src/observe/entity.ts index e3f478306..952435b33 100644 --- a/packages/ecs/src/observe/entity.ts +++ b/packages/ecs/src/observe/entity.ts @@ -1,7 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { Index, System, type Component, type Entity, type World } from '../interface/index.js'; import type { Observe } from './observe.js'; -import { createEntry, give, giveFirst, keysId, labelOf, none, noDelivery, ownName, passed, scheduleDelivery, scheduleRead, stateOf, subscribe, sweepSubscribers, throwFailed, type Entry, type Read, type Shape, type WorldState } from './world-state.js'; +import { createEntry, give, giveFirst, keysId, labelOf, labelled, none, noDelivery, ownName, passed, scheduleDelivery, scheduleRead, stateOf, subscribe, sweepSubscribers, throwFailed, type Entry, type Read, type Shape, type WorldState } from './world-state.js'; /** An observed entity's value, shared by its observers, and by optional component whether it had it last. */ type EntityEntry = Entry & { readonly entity: Entity; readonly present: boolean[] }; @@ -27,12 +27,12 @@ type EntityShape = Shape & { const shapes = new WeakMap>(); /** Indexes of the entities with every component of a list, by its id: one per list in the program. */ -const memberIndexes = new Map>(); +const memberIndexes = new Map>(); const membersOf = (required: readonly Component.Key[]) => { const id = keysId(required); let index = memberIndexes.get(id); - if (index === undefined) memberIndexes.set(id, index = Index.create({ name: `Observe.entity(${labelOf(required)})`, key: [], components: { has: [...required] } })); + if (index === undefined) memberIndexes.set(id, index = Index.create(labelled({ key: [], components: { with: [...required] } }, `Observe.entity(${labelOf(required)})`))); return index; }; @@ -129,14 +129,14 @@ const createShape = (state: WorldState, byShape: Map, id: s let finders: Finders | null = null; let source: object | null = null; const system = optional.length === 0 - ? System.create({ name, observer: true, lookup: { presence: { has: required, changed: required }, values: { read: required }, members: { index: membersOf(required), changed: required } } }, ({ lookup }) => { + ? System.create(labelled({ observer: true, lookup: { presence: { 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({ name, observer: true, lookup: optionalLookups(required, optional) }, ({ lookup }) => { + : System.create(labelled({ observer: true, lookup: optionalLookups(required, optional) }, name, { lookup: optionalReads(required, optional) }), ({ lookup }) => { if (source !== lookup || finders === null) { source = lookup; finders = { presence: lookup.presence, values: lookup.values, optional: (index, entity) => lookup[index](entity) }; @@ -173,22 +173,29 @@ const createShape = (state: WorldState, byShape: Map, id: s const noOptional = (): null => null; -type Watched = { readonly read: readonly Component.Key[]; readonly changed: readonly Component.Key[] }; +type Watched = { readonly with: readonly Component.Key[]; readonly changed: readonly Component.Key[] }; /** Lookups of an entity's required components, whether and what, and one of each optional one, by its position. */ const optionalLookups = (required: readonly Component.Key[], optional: readonly Component.Key[]): { - readonly presence: { readonly has: readonly Component.Key[]; readonly changed: readonly Component.Key[] }; - readonly values: { readonly read: readonly Component.Key[] }; + readonly presence: { readonly with: readonly Component.Key[]; readonly changed: readonly Component.Key[] }; + readonly values: { readonly with: readonly Component.Key[] }; readonly [index: number]: Watched; } => { - const lookups: { presence: { readonly has: readonly Component.Key[]; readonly changed: readonly Component.Key[] }; values: { readonly read: readonly Component.Key[] }; [index: number]: Watched } = { - presence: { has: required, changed: required }, - values: { read: required }, + const lookups: { presence: { readonly with: readonly Component.Key[]; readonly changed: readonly Component.Key[] }; values: { readonly with: readonly Component.Key[] }; [index: number]: Watched } = { + presence: { with: required, changed: required }, + values: { with: required }, }; - for (let i = 0; i < optional.length; i++) lookups[i] = { read: [optional[i]], changed: [optional[i]] }; + for (let i = 0; i < optional.length; i++) lookups[i] = { with: [optional[i]], changed: [optional[i]] }; return lookups; }; +/** What the optional shape's body reads of its lookups' rows: the required values, and each optional one. */ +const optionalReads = (required: readonly Component.Key[], optional: readonly Component.Key[]): { readonly [name: string]: readonly Component.Key[] } => { + const reads: { [name: string]: readonly Component.Key[] } = { values: required }; + for (let i = 0; i < optional.length; i++) reads[i] = [optional[i]]; + return reads; +}; + export const observeEntity = (world: World, entity: Entity, components: { readonly required?: readonly Component.Key[]; readonly optional?: readonly Component.Key[] }): Observe => { const required = [...components.required ?? []]; const optional = [...components.optional ?? []]; diff --git a/packages/ecs/src/observe/observe.type-test.ts b/packages/ecs/src/observe/observe.type-test.ts index 521a0c116..615f948e7 100644 --- a/packages/ecs/src/observe/observe.type-test.ts +++ b/packages/ecs/src/observe/observe.type-test.ts @@ -22,10 +22,6 @@ declare module '@adobe/ecs' { [time]: { type: number }; } } -Component.register(point, { type: { type: "object", properties: { x: { type: "number" } } } }); -Component.register(persistent, { type: { const: true } }); -Component.register(shared, { type: { const: true } }); -Resource.register(time, { type: { type: "number", default: 0 } }); // The module's `Observe` has the shape of the package root's. check, (notify: (value: number) => void) => () => void>>(); diff --git a/packages/ecs/src/observe/resource.ts b/packages/ecs/src/observe/resource.ts index 3751df1a3..0448ba9ce 100644 --- a/packages/ecs/src/observe/resource.ts +++ b/packages/ecs/src/observe/resource.ts @@ -1,7 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { System, type Resource, type World } from '../interface/index.js'; import type { Observe } from './observe.js'; -import { createEntry, give, giveFirst, none, noDelivery, ownName, passed, scheduleRead, stateOf, subscribe, sweepSubscribers, throwFailed, type Entry, type Read, type Shape, type WorldState } from './world-state.js'; +import { createEntry, give, giveFirst, labelled, none, noDelivery, ownName, passed, scheduleRead, stateOf, subscribe, sweepSubscribers, throwFailed, type Entry, type Read, type Shape, type WorldState } from './world-state.js'; /** * A resource's observers, sharing one value: `notifier`, added, gives the value after each pass whose sync point @@ -31,14 +31,14 @@ const createShape = (state: WorldState, byKey: Map, const label = ownName(`Observe.resource(${key.description ?? "?"})`); // On a joined thread, first values come once a run of this system resolved, never in its pass: none to skip. const { joined } = state; - const notifier = System.create({ name: label, observer: true, resources: { read: [key], changed: [key] } }, ({ resources }) => { + const notifier = System.create(labelled({ observer: true, resources: { 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({ name: label, observer: true, resources: { read: [key] } }, ({ resources }) => { + const reader = System.create(labelled({ observer: true }, label, { resources: [key] }), ({ resources }) => { const through = shape.called.shift() ?? 0; entry.value = resources[key]; entry.valid = true; diff --git a/packages/ecs/src/observe/select.ts b/packages/ecs/src/observe/select.ts index ba6cf1630..1cd1fb007 100644 --- a/packages/ecs/src/observe/select.ts +++ b/packages/ecs/src/observe/select.ts @@ -4,7 +4,7 @@ import { compare, predicateOf } from './compare.js'; import type { Observe } from './observe.js'; import { createAll, createCount, createHeap, createSorted, inEntityOrder, indexOf, type Gather, type Page, type Term, type Values } from './plans.js'; import { - createEntry, deliverAlso, give, giveFirst, keysId, labelOf, none, noDelivery, numberOf, ownName, passed, scheduleDelivery, scheduleRead, stateOf, subscribe, sweepSubscribers, throwFailed, + createEntry, deliverAlso, give, giveFirst, keysId, labelOf, labelled, none, noDelivery, numberOf, ownName, passed, scheduleDelivery, scheduleRead, stateOf, subscribe, sweepSubscribers, throwFailed, type Entry, type Read, type Shape, type WorldState, } from './world-state.js'; @@ -72,8 +72,8 @@ const shapes = new WeakMap>(); type Rows = { readonly length: number; readonly changed: boolean; at(index: number): { readonly entity: Entity } & Values }; /** Indexes the trackers look up through, by declaration: one each in the program. */ -const keyless = new Map>(); -const sortedIndexes = new Map | null>(); +const keyless = new Map>(); +const sortedIndexes = new Map | null>(); /** * Whether `not` excludes none of `held`, an index's key parts and `has`: what an index declaration's type checks of @@ -93,7 +93,7 @@ const excluding = (not: readonly Component.Key[], held: readonly Component.Key[] const keylessOf = (has: readonly Component.Key[], not: readonly Component.Key[]) => { const id = `${keysId(has)}|${keysId(not)}`; let index = keyless.get(id); - if (index === undefined) keyless.set(id, index = Index.create({ name: `Observe.query(${labelOf(has)})`, key: [], components: { has: [...has], not: excluding(not, has) } })); + if (index === undefined) keyless.set(id, index = Index.create(labelled({ key: [], components: { with: [...has], not: excluding(not, has) } }, `Observe.query(${labelOf(has)})`))); return index; }; @@ -103,7 +103,7 @@ const sortedOf = (key: readonly Component.Key[], has: readonly Component.Key[], let index = sortedIndexes.get(id); if (index === undefined) { try { - index = Index.create({ name: `Observe.query(${labelOf(has)}) by ${labelOf(key)}`, key: [...key], sorted: true, components: { has: [...has], not: excluding(not, [...key, ...has]) } }); + index = Index.create(labelled({ key: [...key], sorted: true, components: { with: [...has], not: excluding(not, [...key, ...has]) } }, `Observe.query(${labelOf(has)}) by ${labelOf(key)}`)); } catch { index = null; @@ -179,7 +179,7 @@ const rangedOf = (key: Component.Key, has: readonly Component.Key[], not: readon let index = rangedIndexes.get(id); if (index === undefined) { try { - index = Index.create({ name: `Observe.query(${labelOf(has)}) by ${labelOf([key])}`, key: [key], sorted: true, components: { has: [...has], not: excluding(not, [key, ...has]) } }); + index = Index.create(labelled({ key: [key], sorted: true, components: { with: [...has], not: excluding(not, [key, ...has]) } }, `Observe.query(${labelOf(has)}) by ${labelOf([key])}`)); } catch { index = null; @@ -188,7 +188,7 @@ const rangedOf = (key: Component.Key, has: readonly Component.Key[], not: readon } return index; }; -const rangedIndexes = new Map | null>(); +const rangedIndexes = new Map | null>(); const createShape = (state: WorldState, byShape: Map, id: string, counting: boolean, impossible: boolean, paged: boolean, required: readonly Component.Key[], not: readonly Component.Key[], where: readonly Component.Key[], terms: readonly Term[]): QueryShape => { const reads = unique([...where, ...terms.map(term => term.key)]); @@ -403,16 +403,16 @@ const createShape = (state: WorldState, byShape: Map, id: st }; const tracker = ordered !== null - ? System.create({ name, observer: true, lookup: { list: { index: ordered, read: reads, changed: reads } } }, ({ lookup }) => { + ? System.create(labelled({ observer: true, lookup: { list: { index: ordered, changed: reads } } }, name, { lookup: { list: reads } }), ({ lookup }) => { track(lookup.list(), null, fromSorted); }) : byRange !== null - ? System.create({ name, observer: true, lookup: { list: { index: byRange, read: reads, changed: reads } } }, ({ lookup }) => { + ? System.create(labelled({ observer: true, lookup: { list: { index: byRange, changed: reads } } }, name, { lookup: { list: reads } }), ({ lookup }) => { // A range is of a `where` condition on the index's key, which the query's type checked against its // values. track(null, entry => ownRange(entry, range => lookup.list(range as Parameters[0]), () => lookup.list()), fromSorted); }) - : System.create({ name, observer: true, lookup: { list: { index: keylessOf(has, not), read: reads, changed: reads } } }, ({ lookup }) => { + : System.create(labelled({ observer: true, lookup: { list: { index: keylessOf(has, not), 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({ name, observer: true, components: { read: reads, has: required.filter(key => !reads.includes(key)), not } }, row => { + const scanner = System.create(labelled({ observer: true, components: { with: [...reads, ...required.filter(key => !reads.includes(key))], not } }, name, { components: reads }), row => { if (accept === null) start(); if (direct === 0) { accept?.(row); diff --git a/packages/ecs/src/observe/world-state.ts b/packages/ecs/src/observe/world-state.ts index 43ccdd5bf..1baea2982 100644 --- a/packages/ecs/src/observe/world-state.ts +++ b/packages/ecs/src/observe/world-state.ts @@ -91,18 +91,54 @@ export const rethrowLater = (error: unknown): void => { */ const ownNames = new Set(); -/** Names a system the factories make. */ +/** The registry's map of labels (`@adobe/ecs/labels@1`), which the plugin fills for declarations made in functions. */ +const labels = Symbol.for("@adobe/ecs/labels@1"); + +/** The registry's map of what bodies use (`@adobe/ecs/access@1`), which the plugin fills from bodies it can follow. */ +const access = Symbol.for("@adobe/ecs/access@1"); + +/** What a factory's body reads: of its row, of resources, and of each lookup's rows. Factories' systems only read. */ +type Reads = { + readonly components?: readonly symbol[]; + readonly resources?: readonly symbol[]; + readonly lookup?: { readonly [name: string]: readonly symbol[] }; +}; + +/** + * Labels a declaration of a system or index the factories make, gives it what its body reads, since the keys it reads + * are values the build can't follow, and returns it. + */ +export const labelled = (declaration: D, label: string, reads: Reads = {}): D => { + let map: unknown = Reflect.get(globalThis, labels); + if (!(map instanceof WeakMap || map instanceof Map)) Reflect.set(globalThis, labels, map = new WeakMap()); + if (map instanceof WeakMap || map instanceof Map) map.set(declaration, label); + let uses: unknown = Reflect.get(globalThis, access); + if (!(uses instanceof WeakMap)) Reflect.set(globalThis, access, uses = new WeakMap()); + if (uses instanceof WeakMap) uses.set(declaration, { + components: { read: reads.components ?? [], write: [] }, + resources: { read: reads.resources ?? [], write: [], reduce: [] }, + lookup: reads.lookup ?? {}, + up: [], + at: label, + }); + return declaration; +}; + +/** Names a system the factories make, whose failures are theirs to rethrow. */ export const ownName = (name: string): string => { ownNames.add(name); return name; }; +/** Labels a declaration of a system the factories make, whose failures are theirs to rethrow. */ +export const own = (declaration: D, label: string, reads: Reads = {}): D => labelled(declaration, ownName(label), reads); + const ignore = (): void => { // A disposed world runs nothing more: its observers' first values never come. }; /** Runs nothing: a run of it resolves while its world lives. */ -const alive = System.create({ name: ownName("Observe/alive"), observer: true }, () => {}); +const alive = System.create(own({ observer: true }, "Observe/alive"), () => {}); /** * What a rejected run of the factories' own does: nothing for a disposed world, whose first values never come, and for @@ -174,7 +210,7 @@ const sweepShapes = (state: WorldState): void => { }; /** A system no world adds, which only a joined thread's `remove` refuses, since it isn't an observer. */ -const probe = System.create({ name: "Observe/probe" }, () => {}); +const probe = System.create(labelled({}, "Observe/probe"), () => {}); /** Whether `world` is a joined thread's: its `remove` takes only observer systems, where the world's ignores one never added. */ const isJoined = (world: World): boolean => { @@ -222,11 +258,11 @@ export const stateOf = (world: World): WorldState => { rejected: rejectedOf(world), // Run with the factories' own systems, never added: what one of them throws is in a later report, whose errors // are written once, which the next of the factories' runs sees. - rethrow: System.create({ name: ownName("Observe/rethrow"), observer: true, components: { read: [Report.errors], changed: [Report.errors] } }, row => { + rethrow: System.create(own({ observer: true, components: { changed: [Report.errors] } }, "Observe/rethrow", { components: [Report.errors] }), row => { for (const failure of row[Report.errors].failures) if (ownNames.has(failure.name)) rethrowLater(new Error(`${failure.name}: ${failure.message}`)); }), composites: new WeakMap(), - deliver: System.create({ name: ownName("Observe/deliver"), observer: true }, () => { + deliver: System.create(own({ observer: true }, "Observe/deliver"), () => { const delivery = created.deliveries.shift(); if (delivery === undefined) return; created.delivered = delivery.batch; @@ -237,7 +273,7 @@ export const stateOf = (world: World): WorldState => { states.set(world, created); if (joined) return created; // Added before any shape's system, so in each pass it counts the pass before they run. - world.add(System.create({ name: ownName("Observe/maintain"), observer: true }, () => { + world.add(System.create(own({ observer: true }, "Observe/maintain"), () => { created.pass++; if (created.idle.length > 0) sweepShapes(created); })); diff --git a/packages/ecs/src/perftest/benchmarks.ts b/packages/ecs/src/perftest/benchmarks.ts index e0cea84bc..34b90a8de 100644 --- a/packages/ecs/src/perftest/benchmarks.ts +++ b/packages/ecs/src/perftest/benchmarks.ts @@ -9,6 +9,7 @@ import * as structural from "./structural.js"; import { chain, churn, emit, failing, fastest, follow, highest, hits, idle, idles, idleObserver, index, indexSum, observe, poke, pokeCluster, reduce, retag, seeded, small, smalls, spawnOne, tag, totalOf } from "./suite.js"; import type { Benchmark } from "./benchmark.js"; +import { retainedRuns } from "../implementation/world/reports.js"; import { loopTickIdle, loopTicks, observerIdles } from "./loop.js"; import { main, placed, pooled } from "./placed.js"; import { wake } from "./wake.js"; @@ -21,8 +22,11 @@ import { readBenchmarks } from "./reads.js"; import { observedBenchmarks } from "./observed.js"; import { systemBenchmarks } from "./systems.js"; import { indexBenchmarks } from "./indexes.js"; +import { editorBenchmarks } from "./editor.js"; import { stringBenchmarks } from "./strings.js"; import { structReplace, valueBenchmarks } from "./values.js"; +import { filterBenchmarks } from "./filters.js"; +import { optionalBenchmarks } from "./optional.js"; import { createWorld } from "../implementation/index.js"; import { workers } from "../interface/resources/workers.js"; // Last, so the bundle orders the modules the benchmarks share as it did before these imports. @@ -50,14 +54,19 @@ const observed = async (world: World, writer: typeof poke | typeof pokeCluster): return best; }; -/** The fastest run of `measured` right after `before` runs, over `rounds` rounds after `warmup`. */ +/** + * The fastest run of `measured` right after `before` runs, over `rounds - warmup` rounds after a warmup of at least + * `warmup` rounds, and enough for the world to keep its full count of reports: until then each run spawns its own, + * which its sync point costs. + */ const after = async (before: () => Promise, measured: () => Promise, rounds = 100, warmup = 20): Promise => { let best = Infinity; - for (let i = 0; i < rounds; i++) { + const warm = Math.max(warmup, Math.ceil(retainedRuns / 2)); + for (let i = 0; i < warm + rounds - warmup; i++) { await before(); const start = performance.now(); await measured(); - if (i >= warmup) best = Math.min(best, performance.now() - start); + if (i >= warm) best = Math.min(best, performance.now() - start); } return best; }; @@ -496,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: { read: [hierarchyParent] } }, row => { + await world.run(System.create({ observer: true, components: { with: [hierarchyParent] } }, row => { if (found.child === null && row[hierarchyParent] === roots[0]) found.child = row.entity; })); const { child } = found; @@ -687,7 +696,10 @@ export const benchmarks: readonly Benchmark[] = [ ...observedBenchmarks, ...systemBenchmarks, ...indexBenchmarks, + ...editorBenchmarks, ...stringBenchmarks, ...valueBenchmarks, structReplace("main", 0), + ...filterBenchmarks, + ...optionalBenchmarks, ]; diff --git a/packages/ecs/src/perftest/editor.ts b/packages/ecs/src/perftest/editor.ts new file mode 100644 index 000000000..6f967bff4 --- /dev/null +++ b/packages/ecs/src/perftest/editor.ts @@ -0,0 +1,126 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Entity } from "../interface/entity/entity.js"; +import { Index } from "../interface/indexes/index-type.js"; +import { workers } from "../interface/resources/workers.js"; +import { System } from "../interface/system/system.js"; +import { createWorld } from "../implementation/index.js"; +import type { Benchmark } from "./benchmark.js"; + +// A 2D editor loading a document: a tree of groups and leaves, a sorted paint-order index, a hash index of children +// by parent, and `up` systems with change filters propagating transforms and opacity down the tree. + +const localX = Symbol("editor/localX"); +const localY = Symbol("editor/localY"); +const worldX = Symbol("editor/worldX"); +const worldY = Symbol("editor/worldY"); +const opacity = Symbol("editor/opacity"); +const worldOpacity = Symbol("editor/worldOpacity"); +const paintDepth = Symbol("editor/paintDepth"); +const parent = Symbol("editor/parent"); +const group = Symbol("editor/group"); +const shape = Symbol("editor/shape"); +const image = Symbol("editor/image"); +declare module "@adobe/ecs" { + export interface Components { + [localX]: { type: number }; + [localY]: { type: number }; + [worldX]: { type: number }; + [worldY]: { type: number }; + [opacity]: { type: number }; + [worldOpacity]: { type: number }; + [paintDepth]: { type: number }; + [parent]: { type: Entity; reference: "owner" }; + [group]: { type: true }; + [shape]: { type: true }; + [image]: { type: true }; + } +} + +const node = [localX, localY, worldX, worldY, opacity, worldOpacity, paintDepth] as const; +const Document = [...node, group] as const; +const Group = [...node, parent, group] as const; +const Shape = [...node, parent, shape] as const; +const Image = [...node, parent, image] as const; + +const byPaintDepth = Index.create({ key: [paintDepth], sorted: true }); +const childrenOf = Index.create({ key: [parent] }); + +/** Groups under the document, subgroups in each, and leaves in each subgroup: 50,201 nodes. */ +const groups = 200; +const subgroups = 2; +const leaves = 124; +const nodes = 1 + groups * (1 + subgroups * (1 + leaves)); + +/** Spawns the document depth first, as a file lists it, its paint depth its place in that order. */ +const load = System.create({}, ({ commands }) => { + let depth = 0; + const values = (x: number) => ({ [localX]: x, [localY]: 1, [worldX]: 0, [worldY]: 0, [opacity]: 0.99, [worldOpacity]: 1, [paintDepth]: depth++ }); + const document = commands.spawn(Document)(values(0)); + for (let g = 0; g < groups; g++) { + const outer = commands.spawn(Group)({ ...values(g), [parent]: document, [group]: true }); + for (let s = 0; s < subgroups; s++) { + const inner = commands.spawn(Group)({ ...values(s), [parent]: outer, [group]: true }); + for (let l = 0; l < leaves; l++) { + if (l % 2 === 0) commands.spawn(Shape)({ ...values(l), [parent]: inner, [shape]: true }); + else commands.spawn(Image)({ ...values(l), [parent]: inner, [image]: true }); + } + } + } +}); + +/** Each node's world position is its parent's plus its own. */ +const place = System.create({ + kernel: false, + components: { with: [localX, localY, worldX, worldY], changed: [localX, localY] }, + up: { through: parent }, +}, (row, { up }) => { + row[worldX] = (up === null ? 0 : up[worldX]) + row[localX]; + row[worldY] = (up === null ? 0 : up[worldY]) + row[localY]; +}); +/** Each node's world opacity is its parent's times its own. */ +const fade = System.create({ + kernel: false, + components: { with: [opacity, worldOpacity], changed: [opacity] }, + up: { through: parent }, +}, (row, { up }) => { + row[worldOpacity] = (up === null ? 1 : up[worldOpacity]) * row[opacity]; +}); +let checksum = 0; +/** Each group counts its children, as a layout does. */ +const layout = System.create({ components: { with: [group] }, lookup: { children: { index: childrenOf } } }, (row, { lookup }) => { + checksum += lookup.children(row.entity).length; +}); +/** Visits every node in paint order, as drawing does. */ +const paint = System.create({ components: { with: [worldX, worldOpacity] }, order: byPaintDepth, after: [place, fade] }, row => { + checksum = (checksum * 31 + row[worldX] * row[worldOpacity]) % 1000003; +}); +const frame = System.create({ systems: [place, fade, layout, paint] }); + +const check = (condition: boolean, message: string): void => { + if (!condition) throw new Error(message); +}; + +export const editorBenchmarks: readonly Benchmark[] = [{ + name: "editor load tick 50K", + operation: "A 2D editor's first tick after loading a 50K-node document of groups and leaves: two `up` systems with change filters propagate transforms and opacity down the tree, a layout looks up children through a hash index on the parent, and painting runs in a sorted index's order, so every `up` order and index is built from scratch.", + per: "nodes", + n: nodes, + typical: [1_000, 100_000], + best: { bytes: 100, basis: "Each node reads its parent link, its 7 values and its parent's 3, writes 3, and enters two indexes and two orders." }, + run: async () => { + let best = Infinity; + for (let i = 0; i < 8; i++) { + const world = createWorld({ [workers]: 0 }); + await world.run(frame); + await world.run(load); + checksum = 0; + const start = performance.now(); + await world.run(frame); + best = Math.min(best, performance.now() - start); + check(checksum !== 0, "editor load tick: nothing ran"); + await world.dispose(); + } + return best; + }, +}]; + diff --git a/packages/ecs/src/perftest/filters.ts b/packages/ecs/src/perftest/filters.ts new file mode 100644 index 000000000..e0fee4b0f --- /dev/null +++ b/packages/ecs/src/perftest/filters.ts @@ -0,0 +1,125 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { I32 } from "../interface/math/i32/index.js"; +import { workers } from "../interface/resources/workers.js"; +import { System } from "../interface/system/system.js"; +import type { World } from "../interface/world/world.js"; +import { createWorld } from "../implementation/index.js"; +import { retainedRuns } from "../implementation/world/reports.js"; +import type { Benchmark } from "./benchmark.js"; +import type { U32 } from "../interface/math/index.js"; + +// Change filters over one archetype of 100K rows after a writer changed one row, a few, or every one: what finding the +// rows that pass costs each system that filters, apart from running them. + +const value = Symbol("filters/value"); +const other = Symbol("filters/other"); +const at = Symbol("filters/at"); +const out0 = Symbol("filters/out0"); +const out1 = Symbol("filters/out1"); +const out2 = Symbol("filters/out2"); +const out3 = Symbol("filters/out3"); +declare module "@adobe/ecs" { + export interface Components { + [value]: { type: number }; + [other]: { type: number }; + [at]: { type: U32 }; + [out0]: { type: number }; + [out1]: { type: number }; + [out2]: { type: number }; + [out3]: { type: number }; + } +} + +const rows = 100_000; +const filtered = [value, other, at, out0, out1, out2, out3] as const; + +const seed = System.create({}, ({ commands }, input: { readonly count: number }) => { + for (let i = 0; i < input.count; i++) commands.spawn(filtered)({ [value]: 0, [other]: 0, [at]: i, [out0]: 0, [out1]: 0, [out2]: 0, [out3]: 0 }); +}); + +/** Writes the value of the rows whose `at` is `first` plus a multiple of `stride`. */ +const write = System.create({ + kernel: true, + components: { with: [at, value] }, +}, (row, _context, input: { readonly first: I32; readonly stride: I32 }) => { + const offset = row[at] - input.first; + if (offset >= 0 && offset % input.stride === 0) row[value] += 1; +}); + +let seen = 0; +/** Main-thread systems each filtering on `value`'s changes. */ +const readers = System.create({ + systems: Array.from({ length: 8 }, (_, i) => System.create({ components: { with: [value], changed: [value] } }, row => { + seen += row[value]; + })), +}); +/** The same filtering on `value`'s or `other`'s changes, of which only `value` is written. */ +const readersOfTwo = System.create({ + systems: Array.from({ length: 8 }, (_, i) => System.create({ components: { with: [value], changed: [value, other] } }, row => { + seen += row[value]; + })), +}); +/** Kernels each filtering on `value`'s changes, writing an output of its own. */ +const kernels = System.create({ + systems: [ + System.create({ kernel: true, components: { with: [value, out0], changed: [value] } }, row => { row[out0] = row[value] * 2; }), + System.create({ kernel: true, components: { with: [value, out1], changed: [value] } }, row => { row[out1] = row[value] * 3; }), + System.create({ kernel: true, components: { with: [value, out2], changed: [value] } }, row => { row[out2] = row[value] * 4; }), + System.create({ kernel: true, components: { with: [value, out3], changed: [value] } }, row => { row[out3] = row[value] * 5; }), + ], +}); + +/** The fastest run of `measured` right after a write of every `stride`th row from a moving first row. */ +const afterWrites = async (measured: System, stride: number): Promise => { + const world: World = createWorld({ [workers]: 0 }); + await world.run(seed, { count: rows }); + await world.run(measured); + let best = Infinity; + const warm = Math.max(20, Math.ceil(retainedRuns / 2)); + for (let i = 0; i < warm + 200; i++) { + await world.run(write, { first: (i * 7919) % Math.min(stride, rows), stride }); + const start = performance.now(); + await world.run(measured); + if (i >= warm) best = Math.min(best, performance.now() - start); + } + await world.dispose(); + return best; +}; + +const cases = [ + { name: "one", stride: rows * 2, said: "one row changed" }, + { name: "few", stride: 10_000, said: "ten rows changed, 10,000 apart" }, + { name: "all", stride: 1, said: "every row changed" }, +] as const; + +export const filterBenchmarks: readonly Benchmark[] = [ + ...cases.map(({ name, stride, said }): Benchmark => ({ + name: `changed ${name} 100K`, + operation: `Eight main-thread systems filtering on \`changed\` over 100K rows after ${said}: views and derived values reacting to edits.`, + per: "systems", + n: 8, + typical: [1, 100], + best: { bytes: 0, basis: "Finding the changed rows could cost nothing beyond running them." }, + run: () => afterWrites(readers, stride), + })), + { + name: "changed one 100K two filters", + operation: "Eight main-thread systems filtering on `changed` of two components over 100K rows after one row's first component changed.", + per: "systems", + n: 8, + typical: [1, 100], + best: { bytes: 0, basis: "Finding the changed row could cost nothing beyond running it." }, + run: () => afterWrites(readersOfTwo, rows * 2), + }, + ...cases.map(({ name, stride, said }): Benchmark => ({ + name: `changed ${name} 100K kernels`, + operation: `Four kernels filtering on \`changed\` over 100K rows after ${said}, on the world's thread: derived columns kept current.`, + per: "systems", + n: 4, + typical: [1, 100], + best: { bytes: 0, basis: "Finding the changed rows could cost nothing beyond running them." }, + run: () => afterWrites(kernels, stride), + })), +]; + +export const seenTotal = (): number => seen; diff --git a/packages/ecs/src/perftest/finalized.ts b/packages/ecs/src/perftest/finalized.ts index 6039cd491..da9e72251 100644 --- a/packages/ecs/src/perftest/finalized.ts +++ b/packages/ecs/src/perftest/finalized.ts @@ -1,46 +1,81 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. /// -import { Component } from "../interface/component/component.js"; import { Finalizer } from "../interface/finalizer/finalizer.js"; import { workers } from "../interface/resources/workers.js"; import { System } from "../interface/system/system.js"; import { createWorld, type CreatedWorld } from "../implementation/index.js"; import type { Benchmark } from "./benchmark.js"; import { runReported } from "../implementation/world/reports.test-helper.js"; +import type { Vec3 } from "../interface/math/index.js"; // Despawning entities whose objects a joined thread fills, with a finalizer on that thread: the world's thread writes // each stop's declared struct reads into the thread's stop list and doesn't wait for it. The joined thread is -// `joined.ts`, which imports this module, so it runs the finalizer. +// `joined.ts`, which imports this module, so it runs the finalizers. A second finalizer, over other entities, reads a +// string resource too, and a third two linear resources, which each sync point's stop list holds copies of. const spot = Symbol("finalized/spot"); const handle = Symbol("finalized/handle"); +const noted = Symbol("finalized/noted"); +const note = Symbol("finalized/note"); +const measured = Symbol("finalized/measured"); +const scale = Symbol("finalized/scale"); +const offset = Symbol("finalized/offset"); declare module "@adobe/ecs" { export interface Threads { finalizedBenchmark: true; } export interface Components { - [spot]: { type: readonly [number, number, number] }; + [spot]: { type: Vec3 }; [handle]: { type: { readonly release: () => void } | null; thread: "finalizedBenchmark" }; + [noted]: { type: { readonly release: () => void } | null; thread: "finalizedBenchmark" }; + [measured]: { type: { readonly release: () => void } | null; thread: "finalizedBenchmark" }; + } + export interface Resources { + [note]: { type: string; default: "released" }; + [scale]: { type: number; default: 1 }; + [offset]: { type: Vec3 }; } } -Component.register(spot, { type: { type: "array", items: { type: "number", precision: 1 }, minItems: 3, maxItems: 3 } }); /** An object of the joined thread, as a DOM node or a GPU handle is. */ -Component.register(handle, { thread: "finalizedBenchmark" }); /** Entities each timed sync point despawns. */ export const despawnedObjects = 10_000; /** On the joined thread: what the finalizer released. */ export const releases = { count: 0 }; -export const release = Finalizer.create({ name: "finalized/release", components: { read: [handle, spot] } }, row => { +export const release = Finalizer.create({ components: { with: [handle, spot] } }, row => { row[handle]?.release(); if (row[spot][0] >= 0) releases.count++; }); -const spawn = System.create({ name: "finalized/spawn" }, ({ commands }) => { +/** On the joined thread: the same, reading the world's string resource too. */ +export const releaseNoted = Finalizer.create({ components: { with: [noted, spot] } }, (row, { resources }) => { + row[noted]?.release(); + if (row[spot][0] >= 0 && resources[note].length > 0) releases.count++; +}); + +/** On the joined thread: the same, reading two linear resources of the world's too. */ +export const releaseMeasured = Finalizer.create({ components: { with: [measured, spot] } }, (row, { resources }) => { + row[measured]?.release(); + if (row[spot][0] * resources[scale] + resources[offset][0] >= 0) releases.count++; +}); + +const spawn = System.create({}, ({ commands }) => { for (let k = 0; k < despawnedObjects; k++) commands.spawn([spot, handle])({ [spot]: [k, 0, 0], [handle]: null }); }); -const despawn = System.create({ name: "finalized/despawn", components: { has: [handle, spot] } }, (row, { commands }) => { +const despawn = System.create({ kernel: false, components: { with: [handle, spot] } }, (row, { commands }) => { + commands.despawn(row.entity); +}); +const spawnNoted = System.create({}, ({ commands }) => { + for (let k = 0; k < despawnedObjects; k++) commands.spawn([spot, noted])({ [spot]: [k, 0, 0], [noted]: null }); +}); +const spawnMeasured = System.create({}, ({ commands }) => { + for (let k = 0; k < despawnedObjects; k++) commands.spawn([spot, measured])({ [spot]: [k, 0, 0], [measured]: null }); +}); +const despawnMeasured = System.create({ kernel: false, components: { with: [measured, spot] } }, (row, { commands }) => { + commands.despawn(row.entity); +}); +const despawnNoted = System.create({ kernel: false, components: { with: [noted, spot] } }, (row, { commands }) => { commands.despawn(row.entity); }); @@ -87,15 +122,15 @@ const startClaiming = async (world: CreatedWorld): Promise<{ readonly terminate: * The fastest of `rounds` sync points despawning `despawnedObjects` entities holding the joined thread's objects, * timed on the world's thread, each after a spawn and a pause in which the thread finalizes the last. */ -const despawnObjects = async (rounds = 30): Promise => { +const despawnObjects = async (spawning: typeof spawn = spawn, despawning: typeof despawn = despawn, rounds = 30): Promise => { const world = createWorld({ [workers]: 0 }); const joined = await startClaiming(world); let best = Infinity; try { for (let round = 0; round < rounds; round++) { - await world.run(spawn); + await world.run(spawning); const start = performance.now(); - const report = await runReported(world, despawn); + const report = await runReported(world, despawning); const time = performance.now() - start; if (report.failures.length > 0) throw new Error(`despawn objects: ${report.failures[0].message}`); if (round > 1) best = Math.min(best, time); @@ -120,4 +155,22 @@ export const finalizedBenchmarks: readonly Benchmark[] = [ best: { bytes: 56, basis: "Each despawn frees its 8-byte location and entity, and writes its stop: its entity, archetype, row and tag, 20 bytes, and its 12-byte vec3, read and written." }, run: () => despawnObjects(), }, + { + name: "despawn objects 10K string resource", + operation: "`despawn objects 10K` with the finalizer reading a string resource too, whose record the sync point's stop list holds a copy of, so the thread reads it as it was before apply.", + per: "despawns", + n: despawnedObjects, + typical: [1, 10_000], + best: { bytes: 56, basis: "As `despawn objects 10K`: the resource's copy is once per sync point." }, + run: () => despawnObjects(spawnNoted, despawnNoted), + }, + { + name: "despawn objects 10K linear resource", + operation: "`despawn objects 10K` with the finalizer reading a number and a vec3 resource too, which the sync point's stop list holds copies of, so the thread reads them as they were before apply.", + per: "despawns", + n: despawnedObjects, + typical: [1, 10_000], + best: { bytes: 56, basis: "As `despawn objects 10K`: the resources' copies are once per sync point." }, + run: () => despawnObjects(spawnMeasured, despawnMeasured), + }, ]; diff --git a/packages/ecs/src/perftest/flat.ts b/packages/ecs/src/perftest/flat.ts index 26382f7c3..9d5edfb05 100644 --- a/packages/ecs/src/perftest/flat.ts +++ b/packages/ecs/src/perftest/flat.ts @@ -1,7 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Component } from "../interface/component/component.js"; import { System } from "../interface/system/system.js"; -import { Archetype } from '../interface/archetype/archetype.js'; +import type { F32, U32 } from '../interface/math/index.js'; // Mirrors adobe/data's ecs2 `create` perftest (packages/data/src/perftest/ecs2-perf.ts): 1M particles of eight scalar // components, interleaved over four archetypes by visible/enabled, each with its own values. @@ -19,48 +18,38 @@ export const enabled = Symbol("flat/enabled"); declare module "@adobe/ecs" { export interface Components { - [color]: { type: number }; - [mass]: { type: number }; - [positionX]: { type: number }; - [positionY]: { type: number }; - [positionZ]: { type: number }; - [velocityX]: { type: number }; - [velocityY]: { type: number }; - [velocityZ]: { type: number }; + [color]: { type: U32 }; + [mass]: { type: F32 }; + [positionX]: { type: F32 }; + [positionY]: { type: F32 }; + [positionZ]: { type: F32 }; + [velocityX]: { type: F32 }; + [velocityY]: { type: F32 }; + [velocityZ]: { type: F32 }; [visible]: { type: true }; [enabled]: { type: true }; } } -Component.register(color, { type: { type: "integer", minimum: 0, maximum: 0xFFFFFFFF } }); -Component.register(mass, { type: { type: "number", precision: 1 } }); -Component.register(positionX, { type: { type: "number", precision: 1 } }); -Component.register(positionY, { type: { type: "number", precision: 1 } }); -Component.register(positionZ, { type: { type: "number", precision: 1 } }); -Component.register(velocityX, { type: { type: "number", precision: 1 } }); -Component.register(velocityY, { type: { type: "number", precision: 1 } }); -Component.register(velocityZ, { type: { type: "number", precision: 1 } }); -Component.register(visible, { type: { const: true } }); -Component.register(enabled, { type: { const: true } }); const scalars = [color, mass, positionX, positionY, positionZ, velocityX, velocityY, velocityZ] as const; /** Entity i goes to archetype i % 4, like ecs2's seedSingle. */ const archetypes = [ - Archetype.create([...scalars, visible, enabled]), - Archetype.create([...scalars, visible]), - Archetype.create([...scalars, enabled]), - Archetype.create(scalars), + [...scalars, visible, enabled] as const, + [...scalars, visible] as const, + [...scalars, enabled] as const, + scalars, ] as const; /** Spawns with values passed inline. */ -export const spawnFlat = System.create({ name: "flat/spawn", input: { type: "object", properties: { count: { type: "integer" } } } }, ({ input, commands }) => { +export const spawnFlat = System.create({}, ({ commands }, input: { readonly count: number }) => { for (let i = 0; i < input.count; i++) { commands.spawn(archetypes[i % 4])({ [color]: 0xff00ff, [mass]: 10, [positionX]: i + 1, [positionY]: i + 1, [positionZ]: i + 1, [velocityX]: -i, [velocityY]: -i, [velocityZ]: -i }); } }); /** The same spawns with each value object built first, as values from data or from another system's results are. */ -export const spawnFlatDynamic = System.create({ name: "flat/dynamic", input: { type: "object", properties: { count: { type: "integer" } } } }, ({ input, commands }) => { +export const spawnFlatDynamic = System.create({}, ({ commands }, input: { readonly count: number }) => { for (let i = 0; i < input.count; i++) { const values = { [color]: 0xff00ff, [mass]: 10, [positionX]: i + 1, [positionY]: i + 1, [positionZ]: i + 1, [velocityX]: -i, [velocityY]: -i, [velocityZ]: -i }; commands.spawn(archetypes[i % 4])(values); diff --git a/packages/ecs/src/perftest/growth.ts b/packages/ecs/src/perftest/growth.ts index 27b644003..28a41ecca 100644 --- a/packages/ecs/src/perftest/growth.ts +++ b/packages/ecs/src/perftest/growth.ts @@ -1,7 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Archetype } from "../interface/archetype/archetype.js"; -import { Component } from "../interface/component/component.js"; import { System } from "../interface/system/system.js"; +import type { F32 } from "../interface/math/index.js"; // A hundred archetypes, each grown at once: what a sync point costs where every archetype it touches grows. @@ -16,7 +15,7 @@ const g6 = Symbol("growth/g6"); declare module "@adobe/ecs" { export interface Components { - [value]: { type: number }; + [value]: { type: F32 }; [g0]: { type: true }; [g1]: { type: true }; [g2]: { type: true }; @@ -27,30 +26,22 @@ declare module "@adobe/ecs" { } } -Component.register(value, { type: { type: "number", precision: 1 } }); -Component.register(g0, { type: { const: true } }); -Component.register(g1, { type: { const: true } }); -Component.register(g2, { type: { const: true } }); -Component.register(g3, { type: { const: true } }); -Component.register(g4, { type: { const: true } }); -Component.register(g5, { type: { const: true } }); -Component.register(g6, { type: { const: true } }); const tags = [g0, g1, g2, g3, g4, g5, g6] as const; /** `value` with each of the first 100 sets of the seven tags, by their bits. */ -const hundred = Array.from({ length: 100 }, (_, bits) => Archetype.create([value, ...tags.filter((_, bit) => (bits & (1 << bit)) !== 0)])); +const hundred = Array.from({ length: 100 }, (_, bits) => [value, ...tags.filter((_, bit) => (bits & (1 << bit)) !== 0)] as const); /** Spawns `count` entities into each of the hundred archetypes. */ -export const fillEach = System.create({ name: "growth/fillEach", input: { type: "object", properties: { count: { type: "integer" } } } }, ({ input, commands }) => { +export const fillEach = System.create({}, ({ commands }, input: { readonly count: number }) => { for (const archetype of hundred) for (let i = 0; i < input.count; i++) commands.spawn(archetype)({ [value]: i }); }); /** One spawn into each of the hundred archetypes. */ -export const spawnEach = System.create({ name: "growth/spawnEach" }, ({ commands }) => { +export const spawnEach = System.create({}, ({ commands }) => { for (const archetype of hundred) commands.spawn(archetype)({ [value]: 0 }); }); /** A kernel over every row of the hundred archetypes. */ -export const bumpEach = System.create({ name: "growth/bumpEach", kernel: "cpu", components: { write: [value] } }, row => { +export const bumpEach = System.create({ kernel: true, components: { with: [value] } }, row => { row[value] = row[value] + 1; }); diff --git a/packages/ecs/src/perftest/handoff.ts b/packages/ecs/src/perftest/handoff.ts index 13022cbf0..76d3b338a 100644 --- a/packages/ecs/src/perftest/handoff.ts +++ b/packages/ecs/src/perftest/handoff.ts @@ -1,6 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. /// -import { Resource } from "../interface/resource/resource.js"; import { always } from "../interface/resources/always.js"; import { eachTick } from "./observer.js"; import { workers } from "../interface/resources/workers.js"; @@ -23,33 +22,32 @@ declare module "@adobe/ecs" { } } /** An object of the joined thread, which places the systems reading it there. */ -Resource.register(token, { thread: "handoffBenchmark" }); -const here0 = System.create({ name: "handoff/here0" }, () => {}); -const there0 = System.create({ name: "handoff/there0", resources: { read: [token] }, after: [here0] }, ({ resources, commands }) => { void resources[token]; void commands; }); -const here1 = System.create({ name: "handoff/here1", after: [there0] }, () => {}); -const there1 = System.create({ name: "handoff/there1", resources: { read: [token] }, after: [here1] }, ({ resources, commands }) => { void resources[token]; void commands; }); -const here2 = System.create({ name: "handoff/here2", after: [there1] }, () => {}); -const there2 = System.create({ name: "handoff/there2", resources: { read: [token] }, after: [here2] }, ({ resources, commands }) => { void resources[token]; void commands; }); -const here3 = System.create({ name: "handoff/here3", after: [there2] }, () => {}); -const there3 = System.create({ name: "handoff/there3", resources: { read: [token] }, after: [here3] }, ({ resources, commands }) => { void resources[token]; void commands; }); -const here4 = System.create({ name: "handoff/here4", after: [there3] }, () => {}); -const there4 = System.create({ name: "handoff/there4", resources: { read: [token] }, after: [here4] }, ({ resources, commands }) => { void resources[token]; void commands; }); +const here0 = System.create({}, () => {}); +const there0 = System.create({ after: [here0] }, ({ resources, commands }) => { void resources[token]; void commands; }); +const here1 = System.create({ after: [there0] }, () => {}); +const there1 = System.create({ after: [here1] }, ({ resources, commands }) => { void resources[token]; void commands; }); +const here2 = System.create({ after: [there1] }, () => {}); +const there2 = System.create({ after: [here2] }, ({ resources, commands }) => { void resources[token]; void commands; }); +const here3 = System.create({ after: [there2] }, () => {}); +const there3 = System.create({ after: [here3] }, ({ resources, commands }) => { void resources[token]; void commands; }); +const here4 = System.create({ after: [there3] }, () => {}); +const there4 = System.create({ after: [here4] }, ({ resources, commands }) => { void resources[token]; void commands; }); /** Ten tiers alternating the world's thread and the joined one, empty systems. */ -export const handoff = System.create({ name: "handoff/ticks", systems: [here0, there0, here1, there1, here2, there2, here3, there3, here4, there4] }); +export const handoff = System.create({ systems: [here0, there0, here1, there1, here2, there2, here3, there3, here4, there4] }); -const fused0 = System.create({ name: "handoff/fused0", resources: { read: [token] } }, ({ resources, commands }) => { void resources[token]; void commands; }); -const fused1 = System.create({ name: "handoff/fused1", resources: { read: [token] }, after: [fused0] }, ({ resources, commands }) => { void resources[token]; void commands; }); -const fused2 = System.create({ name: "handoff/fused2", resources: { read: [token] }, after: [fused1] }, ({ resources, commands }) => { void resources[token]; void commands; }); -const fused3 = System.create({ name: "handoff/fused3", resources: { read: [token] }, after: [fused2] }, ({ resources, commands }) => { void resources[token]; void commands; }); -const fused4 = System.create({ name: "handoff/fused4", resources: { read: [token] }, after: [fused3] }, ({ resources, commands }) => { void resources[token]; void commands; }); -const fused5 = System.create({ name: "handoff/fused5", resources: { read: [token] }, after: [fused4] }, ({ resources, commands }) => { void resources[token]; void commands; }); -const fused6 = System.create({ name: "handoff/fused6", resources: { read: [token] }, after: [fused5] }, ({ resources, commands }) => { void resources[token]; void commands; }); -const fused7 = System.create({ name: "handoff/fused7", resources: { read: [token] }, after: [fused6] }, ({ resources, commands }) => { void resources[token]; void commands; }); -const fused8 = System.create({ name: "handoff/fused8", resources: { read: [token] }, after: [fused7] }, ({ resources, commands }) => { void resources[token]; void commands; }); -const fused9 = System.create({ name: "handoff/fused9", resources: { read: [token] }, after: [fused8] }, ({ resources, commands }) => { void resources[token]; void commands; }); +const fused0 = System.create({}, ({ resources, commands }) => { void resources[token]; void commands; }); +const fused1 = System.create({ after: [fused0] }, ({ resources, commands }) => { void resources[token]; void commands; }); +const fused2 = System.create({ after: [fused1] }, ({ resources, commands }) => { void resources[token]; void commands; }); +const fused3 = System.create({ after: [fused2] }, ({ resources, commands }) => { void resources[token]; void commands; }); +const fused4 = System.create({ after: [fused3] }, ({ resources, commands }) => { void resources[token]; void commands; }); +const fused5 = System.create({ after: [fused4] }, ({ resources, commands }) => { void resources[token]; void commands; }); +const fused6 = System.create({ after: [fused5] }, ({ resources, commands }) => { void resources[token]; void commands; }); +const fused7 = System.create({ after: [fused6] }, ({ resources, commands }) => { void resources[token]; void commands; }); +const fused8 = System.create({ after: [fused7] }, ({ resources, commands }) => { void resources[token]; void commands; }); +const fused9 = System.create({ after: [fused8] }, ({ resources, commands }) => { void resources[token]; void commands; }); /** Ten consecutive tiers on the joined thread, empty systems. */ -export const fused = System.create({ name: "handoff/fused", systems: [fused0, fused1, fused2, fused3, fused4, fused5, fused6, fused7, fused8, fused9] }); +export const fused = System.create({ systems: [fused0, fused1, fused2, fused3, fused4, fused5, fused6, fused7, fused8, fused9] }); /** Every system the joined thread runs, which it imports so the world can place them there. */ export const handoffSystems: readonly System[] = [handoff, fused]; diff --git a/packages/ecs/src/perftest/hierarchy.ts b/packages/ecs/src/perftest/hierarchy.ts index 60d97a844..bfaf00f27 100644 --- a/packages/ecs/src/perftest/hierarchy.ts +++ b/packages/ecs/src/perftest/hierarchy.ts @@ -1,8 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Archetype } from "../interface/archetype/archetype.js"; -import { Component } from "../interface/component/component.js"; import { Entity } from "../interface/entity/entity.js"; import { System } from "../interface/system/system.js"; +import type { F32 } from "../interface/math/index.js"; export const local = Symbol("hierarchy/local"); export const world = Symbol("hierarchy/world"); @@ -18,40 +17,32 @@ declare module "@adobe/ecs" { } } -Component.register(local, { type: { type: "number" } }); -Component.register(world, { type: { type: "number" } }); -Component.register(parent, { type: Entity.schema, reference: "owner" }); -Component.register(leaf, { type: { const: true } }); -const root = Archetype.create([local, world]); -const child = Archetype.create([local, world, parent]); -const taggedChild = Archetype.create([local, world, parent, leaf]); +const root = [local, world] as const; +const child = [local, world, parent] as const; +const taggedChild = [local, world, parent, leaf] as const; /** Each row's world value is its parent's plus its own. */ export const propagate = System.create({ - name: "hierarchy/propagate", - kernel: "cpu", - components: { read: [local], write: [world] }, - up: { through: parent, read: [world] }, + kernel: true, + components: { with: [local, world] }, + up: { through: parent }, }, (row, { up }) => { row[world] = (up === null ? 0 : up[world]) + row[local]; }); /** The same, only for rows whose own or parent's values changed since it last ran. */ export const propagateChanged = System.create({ - name: "hierarchy/propagateChanged", - kernel: "cpu", - components: { read: [local], write: [world], changed: [local] }, - up: { through: parent, read: [world] }, + kernel: true, + components: { with: [local, world], changed: [local] }, + up: { through: parent }, }, (row, { up }) => { row[world] = (up === null ? 0 : up[world]) + row[local]; }); /** `roots` chains of `depth` rows each. */ export const forest = System.create({ - name: "hierarchy/forest", - input: { type: "object", properties: { roots: { type: "integer" }, depth: { type: "integer" } } }, -}, ({ input, commands }) => { +}, ({ commands }, input: { readonly roots: number; readonly depth: number }) => { for (let r = 0; r < input.roots; r++) { let previous = commands.spawn(root)({ [local]: 1, [world]: 0 }); for (let d = 1; d < input.depth; d++) previous = commands.spawn(child)({ [local]: 1, [world]: 0, [parent]: previous }); @@ -61,12 +52,12 @@ export const forest = System.create({ const target = { type: "object", properties: { entity: Entity.schema } } as const; /** Changes one row's own value. */ -export const nudge = System.create({ name: "hierarchy/nudge", input: target, components: { write: [local] } }, (row, { input }) => { +export const nudge = System.create({ kernel: false, components: { with: [local] } }, (row, _context, input: { readonly entity: Entity }) => { if (row.entity === input.entity) row[local] += 1; }); /** Adds one leaf under a row. */ -export const sprout = System.create({ name: "hierarchy/sprout", input: target }, ({ input, commands }) => { +export const sprout = System.create({}, ({ commands }, input: { readonly entity: Entity }) => { commands.spawn(child)({ [local]: 1, [world]: 0, [parent]: input.entity }); }); @@ -76,9 +67,7 @@ const leaves: Entity[] = []; /** Despawns the leaves it spawned last time and spawns `count` new ones, one under each of `under`'s first rows; `tagged` puts them in an archetype of their own. */ export const turnover = System.create({ - name: "hierarchy/turnover", - input: { type: "object", properties: { count: { type: "integer" }, tagged: { type: "boolean" } } }, -}, ({ input, commands }) => { +}, ({ commands }, input: { readonly count: number; readonly tagged: boolean }) => { for (const entity of leaves.splice(0)) commands.despawn(entity); for (let i = 0; i < input.count; i++) { leaves.push(input.tagged @@ -89,9 +78,7 @@ export const turnover = System.create({ /** Moves a row, and the subtree under it, to another parent. */ export const reparent = System.create({ - name: "hierarchy/reparent", - input: { type: "object", properties: { entity: Entity.schema, to: Entity.schema } }, -}, ({ input, commands }) => { +}, ({ commands }, input: { readonly entity: Entity; readonly to: Entity }) => { commands.add(input.entity, parent, input.to); }); @@ -100,32 +87,27 @@ export const placed = Symbol("hierarchy/placed"); declare module "@adobe/ecs" { export interface Components { - [offset]: { type: number }; - [placed]: { type: number }; + [offset]: { type: F32 }; + [placed]: { type: F32 }; } } -Component.register(offset, { type: { type: "number", precision: 1 } }); -Component.register(placed, { type: { type: "number", precision: 1 } }); -const gpuRoot = Archetype.create([offset, placed]); -const gpuChild = Archetype.create([offset, placed, parent]); +const gpuRoot = [offset, placed] as const; +const gpuChild = [offset, placed, parent] as const; /** `propagate` in 4-byte values, on the GPU. */ export const place = System.create({ - name: "hierarchy/place", kernel: "gpu", - components: { read: [offset], write: [placed] }, - up: { through: parent, read: [placed] }, + components: { with: [offset, placed] }, + up: { through: parent }, }, (row, { up }) => { row[placed] = (up === null ? 0 : up[placed]) + row[offset]; }); /** `forest` in 4-byte values. */ export const gpuForest = System.create({ - name: "hierarchy/gpuForest", - input: { type: "object", properties: { roots: { type: "integer" }, depth: { type: "integer" } } }, -}, ({ input, commands }) => { +}, ({ commands }, input: { readonly roots: number; readonly depth: number }) => { for (let r = 0; r < input.roots; r++) { let previous = commands.spawn(gpuRoot)({ [offset]: 1, [placed]: 0 }); for (let d = 1; d < input.depth; d++) previous = commands.spawn(gpuChild)({ [offset]: 1, [placed]: 0, [parent]: previous }); diff --git a/packages/ecs/src/perftest/indexes.ts b/packages/ecs/src/perftest/indexes.ts index 20f38444d..b8c05e1aa 100644 --- a/packages/ecs/src/perftest/indexes.ts +++ b/packages/ecs/src/perftest/indexes.ts @@ -1,5 +1,4 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Component } from "../interface/component/component.js"; import { Entity } from "../interface/entity/entity.js"; import { Index } from "../interface/indexes/index-type.js"; import { workers } from "../interface/resources/workers.js"; @@ -8,6 +7,7 @@ import type { World } from "../interface/world/world.js"; import { createWorld } from "../implementation/index.js"; import type { Benchmark } from "./benchmark.js"; import { fastest } from "./suite.js"; +import type { U32, Vec3 } from "../interface/math/index.js"; // Benchmarks of indexes: keeping them current, and looking up through them. @@ -20,30 +20,23 @@ const place = Symbol("indexes/place"); const cell = Symbol("indexes/cell"); declare module "@adobe/ecs" { export interface Components { - [key]: { type: number }; + [key]: { type: U32 }; [amount]: { type: number }; [marked]: { type: true }; - [serial]: { type: number }; + [serial]: { type: U32 }; [parent]: { type: Entity }; - [place]: { type: readonly [number, number, number] }; - [cell]: { type: readonly [number, number, number] }; + [place]: { type: Vec3 }; + [cell]: { type: Vec3.I32 }; } } -Component.register(key, { type: { type: "integer", minimum: 0, maximum: 0x7FFFFFFF } }); -Component.register(amount, { type: { type: "number" } }); -Component.register(marked, { type: { const: true } }); -Component.register(serial, { type: { type: "integer", minimum: 0, maximum: 0x7FFFFFFF } }); -Component.register(parent, { type: Entity.schema }); -Component.register(place, { type: { type: "array", items: { type: "number", precision: 1 }, minItems: 3, maxItems: 3 } }); -Component.register(cell, { type: { type: "array", items: { type: "integer", minimum: -0x80000000, maximum: 0x7FFFFFFF }, minItems: 3, maxItems: 3 } }); const million = 1_000_000; /** Distinct keys among the indexed entities: ten entities a bucket. */ const keys = million / 10; -const byKey = Index.create({ name: "indexes/byKey", key: [key] }); +const byKey = Index.create({ key: [key] }); -const seedKeys = System.create({ name: "indexes/seedKeys", input: { type: "object", properties: { count: { type: "integer" }, marked: { type: "integer" } } } }, ({ input, commands }) => { +const seedKeys = System.create({}, ({ commands }, input: { readonly count: number; readonly marked: number }) => { for (let i = 0; i < input.count; i++) { if (i < input.marked) commands.spawn([key, amount, marked])({ [key]: (i * 7919) % keys, [amount]: i, [marked]: true }); else commands.spawn([key, amount])({ [key]: (i * 7919) % keys, [amount]: i }); @@ -52,13 +45,13 @@ const seedKeys = System.create({ name: "indexes/seedKeys", input: { type: "objec /** Moves every marked entity to another bucket, and back again the next run. */ let flip = 1; -const rekeyMarked = System.create({ name: "indexes/rekeyMarked", components: { write: [key], has: [marked] } }, row => { row[key] = (row[key] + flip + keys) % keys; }); +const rekeyMarked = System.create({ components: { with: [key, marked] } }, row => { row[key] = (row[key] + flip + keys) % keys; }); /** Moves every entity to another bucket. */ -const rekeyAll = System.create({ name: "indexes/rekeyAll", components: { write: [key] } }, row => { row[key] = (row[key] + flip + keys) % keys; }); +const rekeyAll = System.create({ components: { with: [key] } }, row => { row[key] = (row[key] + flip + keys) % keys; }); let found = 0; /** Uses `byKey`, so a run of it refreshes the index. */ -const lookOnce = System.create({ name: "indexes/lookOnce", lookup: { byKey: { index: byKey } } }, ({ lookup }) => { found += lookup.byKey(0).length; }); +const lookOnce = System.create({ lookup: { byKey: { index: byKey } } }, ({ lookup }) => { found += lookup.byKey(0).length; }); /** The fastest run of `measured` right after `before`. */ const after = async (before: () => Promise, measured: () => Promise, rounds = 40, warmup = 6): Promise => { @@ -82,7 +75,7 @@ const threads = navigator.hardwareConcurrency - 1; const pooled = (): World => createWorld({ [workers]: threads }); /** A kernel touching nothing, so a world's pool starts, as it does with its first kernel. */ -const startPool = System.create({ name: "indexes/startPool", kernel: "cpu", components: { has: [marked] } }, () => {}); +const startPool = System.create({ kernel: true, components: { with: [marked] } }, () => {}); /** Indexes `index idle` keeps, each over its own thousand entities. */ const idleIndexes = 1000; @@ -92,15 +85,14 @@ declare module "@adobe/ecs" { [idleKey]: { type: number }; } } -Component.register(idleKey, { type: { type: "number" } }); /** Unique serials among `unique lookup 1M`'s entities. */ -const bySerial = Index.create({ name: "indexes/bySerial", key: [serial], unique: true, components: { has: [amount] } }); -const seedSerials = System.create({ name: "indexes/seedSerials" }, ({ commands }) => { +const bySerial = Index.create({ key: [serial], unique: true, components: { with: [amount] } }); +const seedSerials = System.create({}, ({ commands }) => { for (let i = 0; i < million; i++) commands.spawn([serial, amount])({ [serial]: i, [amount]: 1 }); }); let summed = 0; -const lookUnique = System.create({ name: "indexes/lookUnique", lookup: { bySerial: { index: bySerial, read: [amount] } } }, ({ lookup }) => { +const lookUnique = System.create({ lookup: { bySerial: { index: bySerial } } }, ({ lookup }) => { let sum = 0; for (let i = 0; i < million; i++) sum += lookup.bySerial(i)?.[amount] ?? 0; summed = sum; @@ -108,22 +100,22 @@ const lookUnique = System.create({ name: "indexes/lookUnique", lookup: { bySeria /** Parents in `children lookup`, with ten children each. */ const parents = 10_000; -const childrenOf = Index.create({ name: "indexes/childrenOf", key: [parent], components: { has: [amount] } }); -const seedFamilies = System.create({ name: "indexes/seedFamilies" }, ({ commands }) => { +const childrenOf = Index.create({ key: [parent], components: { with: [amount] } }); +const seedFamilies = System.create({}, ({ commands }) => { for (let p = 0; p < parents; p++) { const entity = commands.spawn([marked])({ [marked]: true }); for (let c = 0; c < 10; c++) commands.spawn([parent, amount])({ [parent]: entity, [amount]: 1 }); } }); -const sumChildren = System.create({ name: "indexes/sumChildren", components: { has: [marked] }, lookup: { children: { index: childrenOf, read: [amount] } } }, (row, { lookup }) => { +const sumChildren = System.create({ components: { with: [marked] }, lookup: { children: { index: childrenOf } } }, (row, { lookup }) => { const children = lookup.children(row.entity); for (let i = 0; i < children.length; i++) summed += children.at(i)[amount]; }); /** Particles in `grid neighbors 100K`, in a 100 by 100 by 10 grid of unit cells, ten a cell. */ const particles = 100_000; -const grid = Index.create({ name: "indexes/grid", key: [cell], components: { has: [place] } }); -const seedParticles = System.create({ name: "indexes/seedParticles" }, ({ commands }) => { +const grid = Index.create({ key: [cell], components: { with: [place] } }); +const seedParticles = System.create({}, ({ commands }) => { for (let i = 0; i < particles; i++) { const x = ((i * 7919) % 1000) / 10; const y = ((i * 104729) % 1000) / 10; @@ -132,7 +124,7 @@ const seedParticles = System.create({ name: "indexes/seedParticles" }, ({ comman } }); /** The same on a kernel: on the pool, each row's 27 probes in straight-line code. */ -const neighborsKernel = System.create({ name: "indexes/neighborsKernel", kernel: "cpu", components: { read: [place, cell], write: [amount] }, lookup: { near: { index: grid, read: [place] } } }, (row, { lookup }) => { +const neighborsKernel = System.create({ kernel: true, components: { with: [place, cell, amount] }, lookup: { near: { index: grid } } }, (row, { lookup }) => { const [cx, cy, cz] = row[cell]; const [x] = row[place]; let push = 0; @@ -149,7 +141,7 @@ const neighborsKernel = System.create({ name: "indexes/neighborsKernel", kernel: } row[amount] = push; }); -const seedKernelParticles = System.create({ name: "indexes/seedKernelParticles" }, ({ commands }) => { +const seedKernelParticles = System.create({}, ({ commands }) => { for (let i = 0; i < particles; i++) { const x = ((i * 7919) % 1000) / 10; const y = ((i * 104729) % 1000) / 10; @@ -160,12 +152,12 @@ const seedKernelParticles = System.create({ name: "indexes/seedKernelParticles" /** Particles of `rekey 1M`, drifting through cells of 4 units. */ const drifting = million; -const drift = System.create({ name: "indexes/drift", kernel: "cpu", components: { write: [place] } }, row => { +const drift = System.create({ kernel: true, components: { with: [place] } }, row => { const [x, y, z] = row[place]; row[place] = [x + 0.25, y, z]; }); /** The cell a particle is in, written only when it crosses into another, so the index's work is the crossings. */ -const rekeyCells = System.create({ name: "indexes/rekeyCells", kernel: "cpu", components: { read: [place], write: [cell], changed: [place] }, after: [drift] }, row => { +const rekeyCells = System.create({ kernel: true, components: { with: [place, cell], changed: [place] }, after: [drift] }, row => { const [x, y, z] = row[place]; const [cx, cy, cz] = row[cell]; const nx = Math.floor(x / 4); @@ -173,11 +165,11 @@ const rekeyCells = System.create({ name: "indexes/rekeyCells", kernel: "cpu", co const nz = Math.floor(z / 4); if (nx !== cx || ny !== cy || nz !== cz) row[cell] = [nx, ny, nz]; }); -const coarse = Index.create({ name: "indexes/coarse", key: [cell], components: { has: [place] } }); +const coarse = Index.create({ key: [cell], components: { with: [place] } }); /** Uses `coarse` once, so a run refreshes it. */ -const lookCoarse = System.create({ name: "indexes/lookCoarse", lookup: { coarse: { index: coarse } }, after: [rekeyCells] }, ({ lookup }) => { found += lookup.coarse([0, 0, 0]).length; }); -const frame = System.create({ name: "indexes/frame", systems: [drift, rekeyCells, lookCoarse] }); -const seedDrifting = System.create({ name: "indexes/seedDrifting" }, ({ commands }) => { +const lookCoarse = System.create({ lookup: { coarse: { index: coarse } }, after: [rekeyCells] }, ({ lookup }) => { found += lookup.coarse([0, 0, 0]).length; }); +const frame = System.create({ systems: [drift, rekeyCells, lookCoarse] }); +const seedDrifting = System.create({}, ({ commands }) => { for (let i = 0; i < drifting; i++) { const x = (i * 7919) % 4000 / 10; const y = (i * 104729) % 4000 / 10; @@ -185,12 +177,12 @@ const seedDrifting = System.create({ name: "indexes/seedDrifting" }, ({ commands } }); /** The same frame without the index: what computing the key costs alone. */ -const frameUnindexed = System.create({ name: "indexes/frameUnindexed", systems: [drift, rekeyCells] }); +const frameUnindexed = System.create({ systems: [drift, rekeyCells] }); /** Rows of `order run 100K`, ranked in a scattered order. */ const orderedRows = 100_000; -const byRank = Index.create({ name: "indexes/byRank", key: [serial], sorted: true, components: { has: [amount] } }); -const seedRanked = System.create({ name: "indexes/seedRanked" }, ({ commands }) => { +const byRank = Index.create({ key: [serial], sorted: true, components: { with: [amount] } }); +const seedRanked = System.create({}, ({ commands }) => { for (let i = 0; i < orderedRows; i++) { if (i < orderedRows / 100) commands.spawn([serial, amount, marked])({ [serial]: (i * 7919) % orderedRows, [amount]: i }); else commands.spawn([serial, amount])({ [serial]: (i * 7919) % orderedRows, [amount]: i }); @@ -198,19 +190,19 @@ const seedRanked = System.create({ name: "indexes/seedRanked" }, ({ commands }) }); let accumulated = 0; /** Sums its rows' amounts, weighting each by the sum so far, so the order rows run in is part of the result. */ -const inRankOrder = System.create({ name: "indexes/inRankOrder", components: { read: [amount] }, order: byRank }, row => { +const inRankOrder = System.create({ components: { with: [amount] }, order: byRank }, row => { accumulated = (accumulated * 31 + row[amount]) % 1000003; }); -const inStorageOrder = System.create({ name: "indexes/inStorageOrder", components: { read: [amount] } }, row => { +const inStorageOrder = System.create({ components: { with: [amount] } }, row => { accumulated = (accumulated * 31 + row[amount]) % 1000003; }); -const changedInRankOrder = System.create({ name: "indexes/changedInRankOrder", components: { read: [amount], changed: [amount] }, order: byRank }, row => { +const changedInRankOrder = System.create({ components: { with: [amount], changed: [amount] }, order: byRank }, row => { accumulated = (accumulated * 31 + row[amount]) % 1000003; }); -const bumpMarked = System.create({ name: "indexes/bumpMarked", components: { write: [amount], has: [marked] } }, row => { row[amount] += 1; }); +const bumpMarked = System.create({ kernel: false, components: { with: [amount, marked] } }, row => { row[amount] += 1; }); let pushed = 0; -const neighbors = System.create({ name: "indexes/neighbors", components: { read: [place, cell] }, lookup: { near: { index: grid, read: [place] } } }, (row, { lookup }) => { +const neighbors = System.create({ components: { with: [place, cell] }, lookup: { near: { index: grid } } }, (row, { lookup }) => { const [cx, cy, cz] = row[cell]; const [x] = row[place]; let push = 0; @@ -222,9 +214,9 @@ const neighbors = System.create({ name: "indexes/neighbors", components: { read: }); /** Ranks of `sorted range 1M`, one an entity, and the ranges it looks up, ten ranks each. */ -const ranked = Index.create({ name: "indexes/ranked", key: [serial], sorted: true, components: { has: [amount] } }); +const ranked = Index.create({ key: [serial], sorted: true, components: { with: [amount] } }); const ranges = 10_000; -const lookRanges = System.create({ name: "indexes/lookRanges", lookup: { ranked: { index: ranked, read: [amount] } } }, ({ lookup }) => { +const lookRanges = System.create({ lookup: { ranked: { index: ranked } } }, ({ lookup }) => { let sum = 0; for (let r = 0; r < ranges; r++) { const start = (r * 7919) % (million - 10); @@ -236,15 +228,15 @@ const lookRanges = System.create({ name: "indexes/lookRanges", lookup: { ranked: /** Entities `sorted page 100K` pages through, by a value one of them changes each run, the page the 20 after the first 100 with a small rank. */ const pageRows = 100_000; -const byAmount = Index.create({ name: "indexes/byAmount", key: [amount], sorted: true, components: { has: [key] } }); -const seedPage = System.create({ name: "indexes/seedPage" }, ({ commands }) => { +const byAmount = Index.create({ key: [amount], sorted: true, components: { with: [key] } }); +const seedPage = System.create({}, ({ commands }) => { commands.spawn([amount, key, marked])({ [amount]: 0, [key]: 0, [marked]: true }); for (let i = 1; i < pageRows; i++) commands.spawn([amount, key])({ [amount]: (i * 7919) % pageRows, [key]: i }); }); -const nudgeAmount = System.create({ name: "indexes/nudgeAmount", components: { has: [marked], write: [amount] } }, row => { row[amount] += 1; }); +const nudgeAmount = System.create({ kernel: false, components: { with: [amount, marked] } }, row => { row[amount] += 1; }); let page: number[] = []; /** The page, recomputed only when what it reads changed: the 20 rows after the first 100 with a key below half, highest amounts first. */ -const readPage = System.create({ name: "indexes/readPage", lookup: { byAmount: { index: byAmount, read: [key], changed: [key] } }, after: [nudgeAmount] }, ({ lookup }) => { +const readPage = System.create({ lookup: { byAmount: { index: byAmount, changed: [key] } }, after: [nudgeAmount] }, ({ lookup }) => { const all = lookup.byAmount(); if (!all.changed) return; const rows: number[] = []; @@ -257,7 +249,7 @@ const readPage = System.create({ name: "indexes/readPage", lookup: { byAmount: { } page = rows; }); -const nudgeThenPage = System.create({ name: "indexes/nudgeThenPage", systems: [nudgeAmount, readPage] }); +const nudgeThenPage = System.create({ systems: [nudgeAmount, readPage] }); /** Entities of the keyless benchmarks, and the 1% of them `keyless refresh 1M 1%` moves in and out. */ const tagged = Symbol("indexes/tagged"); @@ -266,9 +258,8 @@ declare module "@adobe/ecs" { [tagged]: { type: true }; } } -Component.register(tagged, { type: { const: true } }); -const untagged = Index.create({ name: "indexes/untagged", key: [], components: { has: [amount], not: [tagged] } }); -const seedAmounts = System.create({ name: "indexes/seedAmounts" }, ({ commands }) => { +const untagged = Index.create({ key: [], components: { with: [amount], not: [tagged] } }); +const seedAmounts = System.create({}, ({ commands }) => { for (let i = 0; i < million; i++) { if (i % 100 === 0) commands.spawn([amount, marked])({ [amount]: i, [marked]: true }); else commands.spawn([amount])({ [amount]: i }); @@ -276,24 +267,24 @@ const seedAmounts = System.create({ name: "indexes/seedAmounts" }, ({ commands } }); /** Tags every marked entity, which leaves the index, and untags them the next run, which enter it again. */ let tagging = true; -const toggleMarked = System.create({ name: "indexes/toggleMarked", components: { has: [marked] } }, (row, { commands }) => { +const toggleMarked = System.create({ components: { with: [marked] } }, (row, { commands }) => { if (tagging) commands.add(row.entity, tagged); else commands.remove(row.entity, tagged); }); -const lookUntagged = System.create({ name: "indexes/lookUntagged", lookup: { untagged: { index: untagged } } }, ({ lookup }) => { found += lookup.untagged().length; }); +const lookUntagged = System.create({ lookup: { untagged: { index: untagged } } }, ({ lookup }) => { found += lookup.untagged().length; }); /** Despawns the entities it spawned the run before and spawns as many again. */ let spawnedLast: Entity[] = []; -const spawnAndDespawn = System.create({ name: "indexes/spawnAndDespawn" }, ({ commands }) => { +const spawnAndDespawn = System.create({}, ({ commands }) => { for (const entity of spawnedLast) commands.despawn(entity); const spawned: Entity[] = []; for (let i = 0; i < million / 100; i++) spawned.push(commands.spawn([amount])({ [amount]: i })); spawnedLast = spawned; }); /** Writes every entity's amount, which the index holds by but doesn't watch. */ -const writeAmounts = System.create({ name: "indexes/writeAmounts", kernel: "cpu", components: { write: [amount] } }, row => { row[amount] = row[amount] + 1; }); +const writeAmounts = System.create({ kernel: true, components: { with: [amount] } }, row => { row[amount] = row[amount] + 1; }); /** Reads the index's length and whether it changed, as a count kept by a system would. */ const lengthReads = 100_000; -const readLengths = System.create({ name: "indexes/readLengths", lookup: { untagged: { index: untagged } } }, ({ lookup }) => { +const readLengths = System.create({ lookup: { untagged: { index: untagged } } }, ({ lookup }) => { let sum = 0; for (let i = 0; i < lengthReads; i++) { const rows = lookup.untagged(); @@ -311,13 +302,12 @@ declare module "@adobe/ecs" { [flag]: { type: boolean }; } } -Component.register(flag, { type: { type: "boolean" } }); -const byFlag = Index.create({ name: "indexes/byFlag", key: [flag] }); -const seedFlags = System.create({ name: "indexes/seedFlags" }, ({ commands }) => { +const byFlag = Index.create({ key: [flag] }); +const seedFlags = System.create({}, ({ commands }) => { for (let i = 0; i < flagged; i++) commands.spawn([flag])({ [flag]: i % 2 === 0 }); }); /** A run of observer systems alone, which passes every one added. */ -const pass = System.create({ name: "indexes/pass", observer: true }, () => {}); +const pass = System.create({ observer: true }, () => {}); /** A count of each bucket, written only when the index's entries changed: by `changed: true`, or by its lists' `changed`. */ const countsWhenChanged = (filtered: boolean): System => filtered ? System.create({ observer: true, lookup: { byFlag: { index: byFlag, changed: true } } }, ({ lookup }) => { @@ -343,6 +333,82 @@ const restingCounts = async (filtered: boolean): Promise => { return best; }; +/** Entities toggled between `byFlag`'s two buckets, one a run, each a different one spread through entity order. */ +let flaggedEntities: Entity[] = []; +const seedFlagged = System.create({}, ({ commands }, input: { readonly count: number }) => { + flaggedEntities = []; + for (let i = 0; i < input.count; i++) flaggedEntities.push(commands.spawn([flag])({ [flag]: i % 2 === 0 })); +}); +const toggleFlag = System.create({ target: "entity", components: { with: [flag] } }, (row, _context, _input: { readonly entity: Entity }) => { + row[flag] = !row[flag]; +}); +/** Both buckets' lengths, as a count of done and not done todos. */ +const readFlagLengths = System.create({ lookup: { byFlag: { index: byFlag } } }, ({ lookup }) => { + found = lookup.byFlag(false).length + lookup.byFlag(true).length; +}); +/** Both buckets' first ten rows, in entity order. */ +const readFlagRows = System.create({ lookup: { byFlag: { index: byFlag } } }, ({ lookup }) => { + let sum = 0; + for (const value of [false, true]) { + const rows = lookup.byFlag(value); + let last = 0; + for (let i = 0; i < 10 && i < rows.length; i++) { + const entity = rows.at(i).entity; + check(entity > last, "bucket move: rows out of entity order"); + last = entity; + sum += entity; + } + } + found = sum; +}); +/** Moves a round of `bucketMove` takes. */ +const movesPerRound = 200; +/** + * The fastest round's mean time of moving one entity between `byFlag`'s two buckets of `count` entities between them, + * then running `reader`, which refreshes the index and reads it. + */ +const bucketMove = async (count: number, reader: System): Promise => { + const world = inline(); + await world.run(seedFlagged, { count }); + await world.run(reader); + let best = Infinity; + let next = 0; + for (let round = 0; round < 8; round++) { + const start = performance.now(); + for (let k = 0; k < movesPerRound; k++) { + await world.run(toggleFlag, { entity: flaggedEntities[(next++ * 7919) % count] }); + await world.run(reader); + } + if (round >= 2) best = Math.min(best, (performance.now() - start) / movesPerRound); + } + check(found > 0, "bucket move: nothing found"); + await world.dispose(); + return best; +}; +const bucketMoveBenchmarks = [1_000, 10_000, 100_000].flatMap((count): Benchmark[] => { + const label = `${count / 1000}K`; + return [ + { + name: `bucket move ${label} length`, + operation: `A run moving one entity between the two buckets of a hash index on a boolean over ${label} entities, then a system reading both buckets' lengths: toggling a todo while counts of done and not done ones are shown.`, + per: "moves", + n: 1, + typical: [1, 100], + best: { bytes: 48, basis: "The move reads its 8-byte key and tick, and writes one 12-byte entry out of one bucket and one into the other; each length reads 4 bytes." }, + run: () => bucketMove(count, readFlagLengths), + }, + { + name: `bucket move ${label} in order`, + operation: `As \`bucket move ${label} length\`, the system reading each bucket's first ten rows in entity order: a list of done todos shown after a toggle.`, + per: "moves", + n: 1, + typical: [1, 100], + best: { bytes: 288, basis: "As the length's, and twenty 12-byte entries read." }, + run: () => bucketMove(count, readFlagRows), + }, + ]; +}); + export const indexBenchmarks: readonly Benchmark[] = [ { name: "observer index unchanged 100", @@ -389,7 +455,7 @@ export const indexBenchmarks: readonly Benchmark[] = [ best: { bytes: 8, basis: "Each index reads its journal position, 8 bytes." }, run: async () => { const world = inline(); - const indexes = Array.from({ length: idleIndexes }, () => Index.create({ key: [], components: { has: [idleKey] } })); + const indexes = Array.from({ length: idleIndexes }, () => Index.create({ key: [], components: { with: [idleKey] } })); await world.run(System.create(({ commands }) => { for (let i = 0; i < 1000; i++) commands.spawn([idleKey])({ [idleKey]: i }); })); const lookup = Object.fromEntries(indexes.map((index, k) => [`i${k}`, { index }])); const user = System.create({ lookup }, () => {}); @@ -689,4 +755,5 @@ export const indexBenchmarks: readonly Benchmark[] = [ return best; }, }, + ...bucketMoveBenchmarks, ]; diff --git a/packages/ecs/src/perftest/log.ts b/packages/ecs/src/perftest/log.ts index 717517521..37ab2d3d0 100644 --- a/packages/ecs/src/perftest/log.ts +++ b/packages/ecs/src/perftest/log.ts @@ -1,5 +1,4 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Component } from "../interface/component/component.js"; import { createBuffer } from "../implementation/world/buffer.js"; import { createCommands } from "../implementation/world/commands.js"; import { asEntity } from "../implementation/world/entities.js"; @@ -22,14 +21,14 @@ declare module "@adobe/ecs" { } export interface Components { [serial]: { type: number }; - [label]: { type: { readonly text: string } | null; thread: "logBenchmark" }; + [label]: { type: Label; thread: "logBenchmark" }; [moved]: { type: true }; } } -Component.register(serial, { type: { type: "number" } }); -const none = { text: "none" }; -Component.register(label, { type: { anyOf: [{ type: "object", properties: { text: { type: "string" } } }, { type: "null" }], default: none }, thread: "logBenchmark" }); -Component.register(moved, { type: { const: true } }); +const none = { text: "none" } as const; +/** A row's label, which starts as `none`, so the mirror's copies are told apart by identity. */ +type Label = { readonly text: string } | null; +const Label = { default: none }; /** Entities moved, and swapped out of the rows they leave, per sync point of `log replay`. */ export const replayMoves = 10_000; diff --git a/packages/ecs/src/perftest/loop.ts b/packages/ecs/src/perftest/loop.ts index 48b84da9e..38a6c450a 100644 --- a/packages/ecs/src/perftest/loop.ts +++ b/packages/ecs/src/perftest/loop.ts @@ -12,7 +12,7 @@ export const loopTicks = 10_000; export const observerIdles = 100; /** Observer systems no entity matches, each run after every tick. */ -const idleObserver = Array.from({ length: observerIdles }, (_, i) => System.create({ name: `loop/idleObserver${i}`, observer: true, components: { has: [unused] } }, () => {})); +const idleObserver = Array.from({ length: observerIdles }, (_, i) => System.create({ observer: true, components: { with: [unused] } }, () => {})); /** * The fastest of `stretches` stretches of `loopTicks` ticks with nothing changed, after one to warm up: a world with diff --git a/packages/ecs/src/perftest/observed-gpu.ts b/packages/ecs/src/perftest/observed-gpu.ts index 625741140..e479581c9 100644 --- a/packages/ecs/src/perftest/observed-gpu.ts +++ b/packages/ecs/src/perftest/observed-gpu.ts @@ -1,5 +1,4 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Component } from "../interface/component/component.js"; import type { Entity } from "../interface/entity/entity.js"; import { gpuDevice } from "../interface/resources/gpu-device.js"; import { workers } from "../interface/resources/workers.js"; @@ -7,6 +6,7 @@ import { System } from "../interface/system/system.js"; import { createWorld } from "../implementation/world/create.js"; import { Observe } from "../observe/index.js"; import type { Benchmark } from "./benchmark.js"; +import type { F32 } from "../interface/math/index.js"; // An entity observer factory of values a GPU kernel writes: each run reads back what the observer reads. @@ -14,20 +14,18 @@ const heat = Symbol("observedGpu/heat"); const rate = Symbol("observedGpu/rate"); declare module "@adobe/ecs" { export interface Components { - [heat]: { type: number }; - [rate]: { type: number }; + [heat]: { type: F32 }; + [rate]: { type: F32 }; } } -Component.register(heat, { type: { type: "number", precision: 1 } }); -Component.register(rate, { type: { type: "number", precision: 1 } }); const heated = 100_000; let spawned: Entity[] = []; -const spawn = System.create({ name: "observedGpu/spawn" }, ({ commands }) => { +const spawn = System.create({}, ({ commands }) => { spawned = []; for (let i = 0; i < heated; i++) spawned.push(commands.spawn([heat, rate])({ [heat]: 0, [rate]: 1 })); }); -const warm = System.create({ name: "observedGpu/warm", kernel: "gpu", components: { read: [rate], write: [heat] } }, row => { +const warm = System.create({ kernel: "gpu", components: { with: [rate, heat] } }, row => { row[heat] = row[heat] + row[rate]; }); diff --git a/packages/ecs/src/perftest/observed.ts b/packages/ecs/src/perftest/observed.ts index 8f02842f1..3390349cd 100644 --- a/packages/ecs/src/perftest/observed.ts +++ b/packages/ecs/src/perftest/observed.ts @@ -1,7 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Component } from "../interface/component/component.js"; import type { Entity } from "../interface/entity/entity.js"; -import { Resource } from "../interface/resource/resource.js"; import { workers } from "../interface/resources/workers.js"; import { System } from "../interface/system/system.js"; import type { World } from "../interface/world/world.js"; @@ -25,29 +23,24 @@ declare module "@adobe/ecs" { [unmatched]: { type: true }; } export interface Resources { - [level]: { type: number }; + [level]: { type: number; default: 3 }; } } -Resource.register(level, { type: { type: "number", default: 3 } }); -Component.register(value, { type: { type: "number" } }); -Component.register(rank, { type: { type: "number" } }); -Component.register(marked, { type: { const: true } }); -Component.register(unmatched, { type: { const: true } }); /** `count` entities with a value and a rank, the first of them marked. */ -const seed = System.create({ name: "observed/seed", input: { type: "object", properties: { count: { type: "integer" } } } }, ({ input, commands }) => { +const seed = System.create({}, ({ commands }, input: { readonly count: number }) => { commands.spawn([value, rank, marked])({ [value]: 0, [rank]: 0, [marked]: true }); for (let i = 1; i < input.count; i++) commands.spawn([value, rank])({ [value]: (i * 7919) % input.count, [rank]: i }); }); /** Writes the marked entity's value, so one entity observer is notified and a selection or count is a candidate. */ -const nudge = System.create({ name: "observed/nudge", components: { has: [marked], write: [value] } }, row => { row[value] += 1; }); +const nudge = System.create({ kernel: false, components: { with: [value, marked] } }, row => { row[value] += 1; }); /** A kernel writing every row's rank. */ -const advance = System.create({ name: "observed/advance", kernel: "cpu", components: { write: [rank] } }, row => { row[rank] += 1; }); +const advance = System.create({ kernel: true, components: { with: [rank] } }, row => { row[rank] += 1; }); /** A system no entity matches: a run that changes nothing. */ -const idle = System.create({ name: "observed/idle", components: { has: [unmatched] } }, () => {}); +const idle = System.create({ kernel: false, components: { with: [unmatched] } }, () => {}); const check = (condition: boolean, message: string): void => { if (!condition) throw new Error(message); diff --git a/packages/ecs/src/perftest/observer.ts b/packages/ecs/src/perftest/observer.ts index 7f479fa9c..55f5657b5 100644 --- a/packages/ecs/src/perftest/observer.ts +++ b/packages/ecs/src/perftest/observer.ts @@ -14,14 +14,14 @@ import type { World } from "../interface/world/world.js"; * first call only starts the stretch that warms up. */ export const eachTick = (world: World, notify: () => void): (() => void) => { - const counter = System.create({ name: "perftest/eachTick", observer: true, resources: { changed: [clock] } }, () => { notify(); }); + const counter = System.create({ observer: true, resources: { changed: [clock] } }, () => { notify(); }); world.add(counter); return () => world.remove(counter); }; /** The clock as an observer run last read it. */ const read = { tick: 0, now: 0 }; -const readClock = System.create({ name: "perftest/readClock", observer: true, resources: { read: [clock] } }, ({ resources }) => { +const readClock = System.create({ observer: true }, ({ resources }) => { read.tick = resources[clock].tick; read.now = resources[clock].now; }); @@ -36,7 +36,7 @@ export const ticksAfter = async (world: World, count: number): Promise => let { tick, now } = read; let seen = 0; await new Promise(resolve => { - const counter = System.create({ name: "perftest/ticksAfter", observer: true, resources: { read: [clock] } }, ({ resources }) => { + const counter = System.create({ observer: true }, ({ resources }) => { const current = resources[clock]; if (current.tick === tick && current.now === now) return; tick = current.tick; @@ -52,7 +52,7 @@ export const ticksAfter = async (world: World, count: number): Promise => /** The entities with every component of `has` and none of `not`, in storage order, read by an observer run. */ export const entitiesWith = async (world: World, has: readonly Component.Key[], not: readonly Component.Key[] = []): Promise => { const found: Entity[] = []; - await world.run(System.create({ observer: true, components: { has, not } }, row => { found.push(row.entity); })); + await world.run(System.create({ observer: true, components: { with: [...has], not } }, row => { found.push(row.entity); })); return found; }; diff --git a/packages/ecs/src/perftest/optional.ts b/packages/ecs/src/perftest/optional.ts new file mode 100644 index 000000000..08bbd9192 --- /dev/null +++ b/packages/ecs/src/perftest/optional.ts @@ -0,0 +1,102 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { workers } from "../interface/resources/workers.js"; +import { System } from "../interface/system/system.js"; +import type { World } from "../interface/world/world.js"; +import { createWorld } from "../implementation/index.js"; +import type { Benchmark } from "./benchmark.js"; +import { fastest } from "./suite.js"; + +// A main-thread system with an optional component against the same system requiring it, over rows that all have it, +// and over rows of which half lack it: optional components bind per archetype, so a row costs what a member's does. + +const height = Symbol("optional/height"); +const lift = Symbol("optional/lift"); +declare module "@adobe/ecs" { + export interface Components { + [height]: { type: number }; + [lift]: { type: number }; + } +} + +const rows = 100_000; + +const seed = System.create({}, ({ commands }, input: { readonly lacking: boolean }) => { + for (let i = 0; i < rows; i++) { + if (input.lacking && i % 2 === 1) commands.spawn([height])({ [height]: 0 }); + else commands.spawn([height, lift])({ [height]: 0, [lift]: 1 }); + } +}); + +/** Raises each row by its lift, which every row has. */ +const raiseRequired = System.create({ kernel: false, components: { with: [height, lift] } }, row => { + row[height] += row[lift]; +}); + +/** Raises each row by its lift where it has one. */ +const raiseOptional = System.create({ kernel: false, components: { with: [height], optional: [lift] } }, row => { + const by = row[lift]; + if (by !== undefined) row[height] += by; +}); + +/** As `raiseRequired` and `raiseOptional`, compiled to kernels. */ +const raiseRequiredKernel = System.create({ kernel: true, components: { with: [height, lift] } }, row => { + row[height] += row[lift]; +}); +const raiseOptionalKernel = System.create({ kernel: true, components: { with: [height], optional: [lift] } }, row => { + const by = row[lift]; + if (by !== undefined) row[height] += by; +}); + +const seeded = async (lacking: boolean): Promise => { + const world = createWorld({ [workers]: 0 }); + await world.run(seed, { lacking }); + return world; +}; + +const run = async (system: System, lacking: boolean): Promise => { + const world = await seeded(lacking); + const best = await fastest(() => world.run(system)); + await world.dispose(); + return best; +}; + +const per = { per: "rows", n: rows, typical: [1_000, 100_000], best: { bytes: 16, basis: "Each row reads its 8-byte lift and reads and writes its 8-byte height." } } as const; + +export const optionalBenchmarks: readonly Benchmark[] = [ + { + name: "with 100K", + operation: "A main-thread row system reading a component it requires and writing another, over 100K rows: the baseline for `optional 100K`.", + ...per, + run: () => run(raiseRequired, false), + }, + { + name: "optional 100K", + operation: "As `with 100K`, the component it reads optional, over rows that all have it: what reading an optional component costs a row.", + ...per, + run: () => run(raiseOptional, false), + }, + { + name: "optional half 100K", + operation: "As `optional 100K`, over rows of which half lack the optional component, in an archetype of their own.", + ...per, + run: () => run(raiseOptional, true), + }, + { + name: "with 100K kernel", + operation: "As `with 100K`, compiled to a kernel on the world's thread: the baseline for `optional 100K kernel`.", + ...per, + run: () => run(raiseRequiredKernel, false), + }, + { + name: "optional 100K kernel", + operation: "As `optional 100K`, compiled to a kernel: what an optional component costs a kernel's row.", + ...per, + run: () => run(raiseOptionalKernel, false), + }, + { + name: "optional half 100K kernel", + operation: "As `optional half 100K`, compiled to a kernel.", + ...per, + run: () => run(raiseOptionalKernel, true), + }, +]; diff --git a/packages/ecs/src/perftest/particles.ts b/packages/ecs/src/perftest/particles.ts index 3eb23ad2a..7b00ceaee 100644 --- a/packages/ecs/src/perftest/particles.ts +++ b/packages/ecs/src/perftest/particles.ts @@ -1,8 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Archetype } from "../interface/archetype/archetype.js"; -import { Component } from "../interface/component/component.js"; import type { Schema } from "../interface/schema/schema.js"; import { System } from "../interface/system/system.js"; +import type { F32, U32 } from "../interface/math/index.js"; // Mirrors adobe/data's ecs1/ecs2 perftests (packages/data/src/perftest): 1M particles split evenly across // 4 archetypes by visible/enabled; move adds velocity to position for the visible and enabled quarter. @@ -19,31 +18,25 @@ export const enabled = Symbol("perf/enabled"); declare module "@adobe/ecs" { export interface Components { - [color]: { type: number }; - [mass]: { type: number }; - [position]: { type: readonly [number, number, number] }; - [velocity]: { type: readonly [number, number, number] }; + [color]: { type: U32 }; + [mass]: { type: F32 }; + [position]: { type: Schema.Type<{ type: "array"; items: { type: "number"; precision: 1 }; minItems: 3; maxItems: 3 }> }; + [velocity]: { type: Schema.Type<{ type: "array"; items: { type: "number"; precision: 1 }; minItems: 3; maxItems: 3 }> }; [visible]: { type: true }; [enabled]: { type: true }; } } -Component.register(color, { type: { type: "integer", minimum: 0, maximum: 0xFFFFFFFF } }); -Component.register(mass, { type: { type: "number", precision: 1 } }); -Component.register(position, { type: vec3 }); -Component.register(velocity, { type: vec3 }); -Component.register(visible, { type: { const: true } }); -Component.register(enabled, { type: { const: true } }); /** Interleaved like ecs2's seedSingle: entity i goes to archetype i % 4. */ const archetypes = [ - Archetype.create([color, mass, position, velocity, visible, enabled]), - Archetype.create([color, mass, position, velocity, visible]), - Archetype.create([color, mass, position, velocity, enabled]), - Archetype.create([color, mass, position, velocity]), + [color, mass, position, velocity, visible, enabled] as const, + [color, mass, position, velocity, visible] as const, + [color, mass, position, velocity, enabled] as const, + [color, mass, position, velocity] as const, ] as const; -export const spawn = System.create({ name: "perf/spawn", input: { type: "object", properties: { count: { type: "integer" } } } }, ({ input, commands }) => { +export const spawn = System.create({}, ({ commands }, input: { readonly count: number }) => { for (let i = 0; i < input.count; i++) { const p = [i + 1, i + 1, i + 1] as const; const v = [-i, -i, -i] as const; @@ -52,9 +45,8 @@ export const spawn = System.create({ name: "perf/spawn", input: { type: "object" }); export const move = System.create({ - name: "perf/move", - kernel: "cpu", - components: { read: [velocity], write: [position], has: [visible, enabled] }, + kernel: true, + components: { with: [velocity, position, visible, enabled] }, }, row => { const [px, py, pz] = row[position]; const [vx, vy, vz] = row[velocity]; diff --git a/packages/ecs/src/perftest/reads.ts b/packages/ecs/src/perftest/reads.ts index 94653341a..60741c837 100644 --- a/packages/ecs/src/perftest/reads.ts +++ b/packages/ecs/src/perftest/reads.ts @@ -1,7 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Component } from "../interface/component/component.js"; import type { Entity } from "../interface/entity/entity.js"; -import { Resource } from "../interface/resource/resource.js"; import { workers } from "../interface/resources/workers.js"; import { System } from "../interface/system/system.js"; import type { World } from "../interface/world/world.js"; @@ -23,32 +21,28 @@ declare module "@adobe/ecs" { [marked]: { type: true }; } export interface Resources { - [level]: { type: number }; + [level]: { type: number; default: 3 }; } } -Resource.register(level, { type: { type: "number", default: 3 } }); -Component.register(value, { type: { type: "number" } }); -Component.register(rank, { type: { type: "number" } }); -Component.register(marked, { type: { const: true } }); /** `count` entities with a value and a rank, the first of them marked. */ -const seed = System.create({ name: "reads/seed", input: { type: "object", properties: { count: { type: "integer" } } } }, ({ input, commands }) => { +const seed = System.create({}, ({ commands }, input: { readonly count: number }) => { commands.spawn([value, rank, marked])({ [value]: 0, [rank]: 0, [marked]: true }); for (let i = 1; i < input.count; i++) commands.spawn([value, rank])({ [value]: (i * 7919) % input.count, [rank]: i }); }); /** Writes the marked entity's value. */ -const nudge = System.create({ name: "reads/nudge", components: { has: [marked], write: [value] } }, row => { row[value] += 1; }); +const nudge = System.create({ kernel: false, components: { with: [value, marked] } }, row => { row[value] += 1; }); /** A kernel writing every row's rank, whose stamps it keeps only while something watches ranks change. */ -const advance = System.create({ name: "reads/advance", kernel: "cpu", components: { write: [rank] } }, row => { row[rank] += 1; }); +const advance = System.create({ kernel: true, components: { with: [rank] } }, row => { row[rank] += 1; }); /** The entities `look` and `lookChanged` look up, each run. */ let looked: readonly Entity[] = []; let found = 0; /** Looks up every entity of `looked` by entity, reading its value, after `nudge` where both run. */ -const look = System.create({ name: "reads/look", lookup: { values: { read: [value] } }, after: [nudge] }, ({ lookup }) => { +const look = System.create({ lookup: { values: { with: [value] } }, after: [nudge] }, ({ lookup }) => { for (let i = 0; i < looked.length; i++) { const row = lookup.values(looked[i]); if (row !== null && row[value] >= 0) found++; @@ -56,15 +50,15 @@ const look = System.create({ name: "reads/look", lookup: { values: { read: [valu }); /** The same, with `changed`: counts the entities whose value may have changed since it last ran. */ -const lookChanged = System.create({ name: "reads/lookChanged", lookup: { values: { read: [value], changed: [value] } }, after: [nudge] }, ({ lookup }) => { +const lookChanged = System.create({ lookup: { values: { with: [value], changed: [value] } }, after: [nudge] }, ({ lookup }) => { for (let i = 0; i < looked.length; i++) { const row = lookup.values(looked[i]); if (row !== null && row[value] >= 0 && row.changed) found++; } }); -const nudgeThenLook = System.create({ name: "reads/nudgeThenLook", systems: [nudge, look] }); -const nudgeThenLookChanged = System.create({ name: "reads/nudgeThenLookChanged", systems: [nudge, lookChanged] }); +const nudgeThenLook = System.create({ systems: [nudge, look] }); +const nudgeThenLookChanged = System.create({ systems: [nudge, lookChanged] }); /** A run of `system` over a world of `observedEntities` entities `looked` names, `found` after the last. */ const lookups = async (system: System): Promise<{ readonly best: number; readonly found: number }> => { @@ -84,7 +78,7 @@ const lookups = async (system: System): Promise<{ readonly best: number; readonl /** Every entity a seeded world holds. */ const entitiesOf = async (world: World): Promise => { const found: Entity[] = []; - await world.run(System.create({ observer: true, components: { has: [value] } }, row => { found.push(row.entity); })); + await world.run(System.create({ observer: true, components: { with: [value] } }, row => { found.push(row.entity); })); return found; }; @@ -114,7 +108,7 @@ export const readBenchmarks: readonly Benchmark[] = [ run: async () => { const world = await seeded(10_000); copied = 0; - const best = await fastest(() => world.run(System.create({ resources: { read: [level] } }, ({ resources }) => { copied = resources[level]; })), 300); + const best = await fastest(() => world.run(System.create({}, ({ resources }) => { copied = resources[level]; })), 300); check(copied === 3, "read run: wrong value"); await world.dispose(); return best; @@ -130,7 +124,7 @@ export const readBenchmarks: readonly Benchmark[] = [ run: async () => { const world = await seeded(10_000); copied = 0; - const best = await fastest(() => world.run(System.create({ observer: true, resources: { read: [level] } }, ({ resources }) => { copied = resources[level]; })), 300); + const best = await fastest(() => world.run(System.create({ observer: true }, ({ resources }) => { copied = resources[level]; })), 300); check(copied === 3, "read run observer: wrong value"); await world.dispose(); return best; @@ -149,7 +143,7 @@ export const readBenchmarks: readonly Benchmark[] = [ await world.run(advance); copied = 0; const best = await fastest(async () => { - await world.run(System.create({ components: { read: [rank], changed: [rank] } }, row => { copied += row[rank]; })); + await world.run(System.create({ components: { with: [rank], changed: [rank] } }, row => { copied += row[rank]; })); }, 300); check(copied > 0, "read run changed: nothing read"); await world.dispose(); diff --git a/packages/ecs/src/perftest/remote.ts b/packages/ecs/src/perftest/remote.ts index e3b645497..1cd436c34 100644 --- a/packages/ecs/src/perftest/remote.ts +++ b/packages/ecs/src/perftest/remote.ts @@ -1,8 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. /// -import { Component } from "../interface/component/component.js"; import { Index } from "../interface/indexes/index-type.js"; -import { Resource } from "../interface/resource/resource.js"; import { always } from "../interface/resources/always.js"; import { eachTick } from "./observer.js"; import { workers } from "../interface/resources/workers.js"; @@ -29,25 +27,19 @@ declare module "@adobe/ecs" { } export interface Resources { [counter]: { type: number }; - [sign]: { type: number }; + [sign]: { type: number; default: 1 }; } } -Resource.register(counter, { type: { type: "number", default: 0 } }); -Resource.register(sign, { type: { type: "number", default: 1 } }); -Component.register(rank, { type: { type: "number" } }); -Component.register(order, { type: { type: "number" } }); -Component.register(caption, { type: { type: "string" } }); -Component.register(amount, { type: { type: "number" } }); /** An empty transaction. */ -export const empty = System.create({ name: "remote/empty" }, () => {}); +export const empty = System.create({}, () => {}); /** Changes the observed resource every tick. */ -export const count = System.create({ name: "remote/count", resources: { write: [counter] } }, ({ resources }) => { resources[counter] += 1; }); -export const seedRanks = System.create({ name: "remote/seedRanks", input: { type: "object", properties: { count: { type: "integer" } } } }, ({ input, commands }) => { +export const count = System.create({}, ({ resources }) => { resources[counter] += 1; }); +export const seedRanks = System.create({}, ({ commands }, input: { readonly count: number }) => { for (let i = 0; i < input.count; i++) commands.spawn([rank, order])({ [rank]: i, [order]: i }); }); -const flip = System.create({ name: "remote/flip", resources: { write: [sign] } }, ({ resources }) => { resources[sign] = -resources[sign]; }); -const reverse = System.create({ name: "remote/reverse", kernel: "cpu", components: { read: [rank], write: [order] }, resources: { read: [sign] }, after: [flip] }, (row, { resources }) => { +const flip = System.create({}, ({ resources }) => { resources[sign] = -resources[sign]; }); +const reverse = System.create({ kernel: true, components: { with: [rank, order] }, after: [flip] }, (row, { resources }) => { row[order] = row[rank] * resources[sign]; }); /** Reverses the entities' order every tick. */ @@ -57,19 +49,19 @@ export const reorder = System.create([flip, reverse]); export const observerSeen = { last: 0, notified: 0 }; /** On a joined thread, after each sync point that changed it: the observed resource. */ -export const observerCounter = System.create({ name: "remote/observerCounter", observer: true, resources: { read: [counter], changed: [counter] } }, ({ resources }) => { +export const observerCounter = System.create({ observer: true, resources: { changed: [counter] } }, ({ resources }) => { observerSeen.last = resources[counter]; observerSeen.notified++; }); /** Every entity with a rank, in entity order. */ -const ranked = Index.create({ name: "remote/ranked", key: [], components: { has: [rank, order] } }); +const ranked = Index.create({ key: [], components: { with: [rank, order] } }); const sortedKeys = { keys: new Float64Array(0), entities: new Float64Array(0), positions: new Uint32Array(0) }; /** * On a joined thread, after each sync point: the entities in `order`, sorted from a scan of the index of every ranked * entity into reused arrays. */ -export const observerSelect = System.create({ name: "remote/observerSelect", observer: true, lookup: { all: { index: ranked, read: [order] } } }, ({ lookup }) => { +export const observerSelect = System.create({ observer: true, lookup: { all: { index: ranked } } }, ({ lookup }) => { const list = lookup.all(); const count = list.length; if (sortedKeys.keys.length < count) { @@ -92,13 +84,13 @@ export const observerSelect = System.create({ name: "remote/observerSelect", obs }); /** Rows with a caption and an amount, a todo's label and a number, which `retext` and `reamount` rewrite 1% of a tick. */ -export const seedCaptions = System.create({ name: "remote/seedCaptions", input: { type: "object", properties: { count: { type: "integer" } } } }, ({ input, commands }) => { +export const seedCaptions = System.create({}, ({ commands }, input: { readonly count: number }) => { for (let i = 0; i < input.count; i++) commands.spawn([rank, caption, amount])({ [rank]: i, [caption]: `Buy milk and bread #${i}`, [amount]: i }); }); -const recaption = System.create({ name: "remote/recaption", components: { read: [rank], write: [caption] }, resources: { read: [counter] }, after: [count] }, (row, { resources }) => { +const recaption = System.create({ components: { with: [rank, caption] }, after: [count] }, (row, { resources }) => { if (row[rank] % 100 === resources[counter] % 100) row[caption] = `Buy milk and bread #${row[rank]}.${resources[counter]}`; }); -const reamount = System.create({ name: "remote/reamount", components: { read: [rank], write: [amount] }, resources: { read: [counter] }, after: [count] }, (row, { resources }) => { +const reamount = System.create({ kernel: false, components: { with: [rank, amount] }, after: [count] }, (row, { resources }) => { if (row[rank] % 100 === resources[counter] % 100) row[amount] = row[rank] + resources[counter]; }); /** Rewrites 1% of the captions a tick. */ @@ -107,12 +99,12 @@ export const retext = System.create([count, recaption]); export const reamounted = System.create([count, reamount]); /** On a joined thread, after each sync point: every row's caption, out of its record. */ -export const observerCaptions = System.create({ name: "remote/observerCaptions", observer: true, components: { read: [caption] } }, row => { +export const observerCaptions = System.create({ observer: true, components: { 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({ name: "remote/observerAmounts", observer: true, components: { read: [amount] } }, row => { +export const observerAmounts = System.create({ observer: true, components: { with: [amount] } }, row => { observerSeen.last += row[amount]; observerSeen.notified++; }); @@ -137,7 +129,7 @@ const joinedFile = "./joined.js"; /** The observed resource, read by an observer run. */ const counterOf = async (world: World): Promise => { let value = 0; - await world.run(System.create({ observer: true, resources: { read: [counter] } }, ({ resources }) => { value = resources[counter]; })); + await world.run(System.create({ observer: true }, ({ resources }) => { value = resources[counter]; })); return value; }; diff --git a/packages/ecs/src/perftest/render-order.ts b/packages/ecs/src/perftest/render-order.ts new file mode 100644 index 000000000..ed3034198 --- /dev/null +++ b/packages/ecs/src/perftest/render-order.ts @@ -0,0 +1,86 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Entity } from "../interface/entity/entity.js"; +import { Index } from "../interface/indexes/index-type.js"; +import { gpuDevice } from "../interface/resources/gpu-device.js"; +import { workers } from "../interface/resources/workers.js"; +import { System } from "../interface/system/system.js"; +import { createWorld } from "../implementation/world/create.js"; +import type { Benchmark } from "./benchmark.js"; +import type { F32 } from "../interface/math/index.js"; + +// A 2D editor's paint order: shapes and images, two archetypes, interleaved in depth, drawn as one table in a sorted +// index's order. + +const paintDepth = Symbol("renderOrder/paintDepth"); +const shape = Symbol("renderOrder/shape"); +const image = Symbol("renderOrder/image"); +declare module "@adobe/ecs" { + export interface Components { + [paintDepth]: { type: F32 }; + [shape]: { type: true }; + [image]: { type: true }; + } +} + +const byDepth = Index.create({ key: [paintDepth], sorted: true }); + +const items = 50_000; +let spawned: Entity[] = []; +const spawn = System.create({}, ({ commands }) => { + spawned = []; + for (let i = 0; i < items; i++) { + spawned.push(i % 2 === 0 ? commands.spawn([paintDepth, shape])({ [paintDepth]: i, [shape]: true }) : commands.spawn([paintDepth, image])({ [paintDepth]: i, [image]: true })); + } +}); +/** Moves one item to a new depth, as reordering it does. */ +const restack = System.create({ + components: { with: [paintDepth] }, + target: "item", +}, (row, _context, input: { readonly item: Entity; readonly depth: number }) => { + row[paintDepth] = input.depth; +}); +/** Draws nothing: its run uploads what it reads and its order. */ +const draw = System.render({ order: byDepth, shader: { accessors: { paintDepth } }, after: [restack] }, () => {}); +const frame = System.create({ systems: [restack, draw] }); + +/** Timed per run by `timed`, which waits for the GPU. */ +export const renderOrderBenchmarks = (device: GPUDevice, timed: (run: () => Promise) => Promise): readonly Benchmark[] => [{ + name: "gpu ordered render unchanged 50K", + operation: "A render system with an order over 50K shapes and images, two archetypes interleaved in depth, run with nothing changed: it uploads nothing and writes neither its order nor its record.", + per: "runs", + n: 1, + typical: [1, 1], + best: { bytes: 64, basis: "A run reads at least one cache line of its system's state." }, + run: async () => { + const world = createWorld({ [workers]: 0, [gpuDevice]: device }); + try { + await world.run(spawn); + await world.run(draw); + return await timed(() => world.run(draw)); + } + finally { + await world.dispose(); + } + }, +}, { + name: "gpu ordered render one key 50K", + operation: "A target system moving one of 50K shapes and images to another depth, then a render system ordered by depth, which refreshes the index, lists the 50K in order and writes them, 200 KB.", + per: "runs", + n: 1, + typical: [1, 1], + best: { bytes: 400_000, basis: "The order's 50K 4-byte entries are written on the CPU and again by the upload.", on: "gpu" }, + run: async () => { + const world = createWorld({ [workers]: 0, [gpuDevice]: device }); + try { + await world.run(spawn); + const item = spawned[items / 2]; + let depth = 0.5; + await world.run(frame, { item, depth }); + // Between two others each time, so its place in the order moves. + return await timed(() => world.run(frame, { item, depth: depth = (depth + 1009) % items })); + } + finally { + await world.dispose(); + } + }, +}]; diff --git a/packages/ecs/src/perftest/strings.ts b/packages/ecs/src/perftest/strings.ts index 0873fcbdf..4ae47b699 100644 --- a/packages/ecs/src/perftest/strings.ts +++ b/packages/ecs/src/perftest/strings.ts @@ -1,5 +1,4 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Component } from "../interface/component/component.js"; import { workers } from "../interface/resources/workers.js"; import { System } from "../interface/system/system.js"; import type { World } from "../interface/world/world.js"; @@ -18,25 +17,23 @@ declare module "@adobe/ecs" { [boxed]: { type: { readonly text: string } }; } } -Component.register(text, { type: { type: "string" } }); -Component.register(boxed, { type: { type: "object", properties: { text: { type: "string" } } } }); const rows = 10_000; /** A todo's label, of a typical length, different per row and round. */ const labelOf = (row: number, round: number): string => `Buy milk and bread #${row}.${round}`; -const seed = System.create({ name: "strings/seed", input: { type: "object", properties: { count: { type: "integer" } } } }, ({ input, commands }) => { +const seed = System.create({}, ({ commands }, input: { readonly count: number }) => { for (let i = 0; i < input.count; i++) commands.spawn([text, boxed])({ [text]: labelOf(i, 0), [boxed]: { text: labelOf(i, 0) } }); }); let total = 0; let round = 0; -const readText = System.create({ name: "strings/readText", components: { read: [text] } }, row => { total += row[text].length; }); -const readBoxed = System.create({ name: "strings/readBoxed", components: { read: [boxed] } }, row => { total += row[boxed].text.length; }); -const writeText = System.create({ name: "strings/writeText", components: { write: [text] } }, row => { row[text] = labelOf(row.entity, round); }); -const writeBoxed = System.create({ name: "strings/writeBoxed", components: { write: [boxed] } }, row => { row[boxed] = { text: labelOf(row.entity, round) }; }); +const readText = System.create({ components: { with: [text] } }, row => { total += row[text].length; }); +const readBoxed = System.create({ components: { with: [boxed] } }, row => { total += row[boxed].text.length; }); +const writeText = System.create({ components: { with: [text] } }, row => { row[text] = labelOf(row.entity, round); }); +const writeBoxed = System.create({ components: { with: [boxed] } }, row => { row[boxed] = { text: labelOf(row.entity, round) }; }); /** A seeded world, with a joined thread on this one when `shared`, which keeps records of its strings. */ const seeded = async (shared = false): Promise<{ readonly world: World; readonly dispose: () => Promise }> => { @@ -79,7 +76,7 @@ const read = (system: System, name: string) => measure(system, async world => { /** Each row's text, as the last write left it. */ const written = (name: string) => async (world: World): Promise => { let wrong = 0; - await world.run(System.create({ observer: true, components: { read: [text, boxed] } }, row => { + await world.run(System.create({ observer: true, components: { with: [text, boxed] } }, row => { if (row[text] !== row[boxed].text && row[text] !== labelOf(row.entity, round) && row[boxed].text !== labelOf(row.entity, round)) wrong++; })); check(wrong === 0, `${name}: wrong text`); diff --git a/packages/ecs/src/perftest/structural.ts b/packages/ecs/src/perftest/structural.ts index ae1874190..1aaf4c0d9 100644 --- a/packages/ecs/src/perftest/structural.ts +++ b/packages/ecs/src/perftest/structural.ts @@ -1,8 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Archetype } from "../interface/archetype/archetype.js"; -import { Component } from "../interface/component/component.js"; import type { Entity } from "../interface/entity/entity.js"; import { System } from "../interface/system/system.js"; +import type { F32, U32 } from "../interface/math/index.js"; // Structural changes one kind at a time: spawns, despawns, adds and removes, through main-thread systems and kernels, // each applied by the sync point that ends its run. @@ -22,11 +21,11 @@ const t3 = Symbol("structural/t3"); declare module "@adobe/ecs" { export interface Components { - [index]: { type: number }; - [value]: { type: number }; + [index]: { type: U32 }; + [value]: { type: F32 }; [flag]: { type: true }; - [amount]: { type: number }; - [extra]: { type: number }; + [amount]: { type: F32 }; + [extra]: { type: F32 }; [spawned]: { type: true }; [source1]: { type: true }; [source2]: { type: true }; @@ -37,50 +36,36 @@ declare module "@adobe/ecs" { } } -Component.register(index, { type: { type: "integer", minimum: 0, maximum: 0xFFFFFFFF } }); -Component.register(value, { type: { type: "number", precision: 1 } }); -Component.register(amount, { type: { type: "number", precision: 1 } }); -Component.register(extra, { type: { type: "number", precision: 1 } }); -Component.register(flag, { type: { const: true } }); -Component.register(spawned, { type: { const: true } }); -Component.register(source1, { type: { const: true } }); -Component.register(source2, { type: { const: true } }); -Component.register(t0, { type: { const: true } }); -Component.register(t1, { type: { const: true } }); -Component.register(t2, { type: { const: true } }); -Component.register(t3, { type: { const: true } }); const seeded = [ - Archetype.create([index, value]), - Archetype.create([index, value, source1]), - Archetype.create([index, value, source2]), - Archetype.create([index, value, source1, source2]), + [index, value] as const, + [index, value, source1] as const, + [index, value, source2] as const, + [index, value, source1, source2] as const, ] as const; -const made = Archetype.create([index, value, spawned]); +const made = [index, value, spawned] as const; /** Every set of the four tags `t0` to `t3`, by its bits. */ const tags = [t0, t1, t2, t3] as const; -const sixteen = Array.from({ length: 16 }, (_, bits) => Archetype.create([index, value, spawned, ...tags.filter((_, bit) => (bits & (1 << bit)) !== 0)])); -const several = Archetype.create([flag, amount, extra]); +const sixteen = Array.from({ length: 16 }, (_, bits) => [index, value, spawned, ...tags.filter((_, bit) => (bits & (1 << bit)) !== 0)] as const); +const several = [flag, amount, extra] as const; const counted = { type: "object", properties: { count: { type: "integer" } } } as const; /** `count` entities of one archetype, or with `sources: 4`, interleaved over four by two source tags. */ export const seed = System.create({ - name: "structural/seed", - input: { type: "object", properties: { count: { type: "integer" }, sources: { type: "integer" } } }, -}, ({ input, commands }) => { +}, ({ commands }, input: { readonly count: number; readonly sources: number }) => { for (let i = 0; i < input.count; i++) { commands.spawn(seeded[input.sources === 1 ? 0 : i % 4])({ [index]: i, [value]: i }); } }); /** `count` spawns of one archetype. */ -export const spawnBulk = System.create({ name: "structural/spawnBulk", input: counted }, ({ input, commands }) => { +export const spawnBulk = System.create({}, ({ commands }, input: { readonly count: number }) => { for (let i = 0; i < input.count; i++) commands.spawn(made)({ [index]: i, [value]: i }); }); /** One spawn of each of four archetypes: a run's fixed cost per spawn. */ -export const spawnSingles = System.create({ name: "structural/spawnSingles" }, ({ commands }) => { +export const spawnSingles = System.create({}, ({ commands }) => { commands.spawn(made)({ [index]: 0, [value]: 0 }); commands.spawn(sixteen[1])({ [index]: 1, [value]: 1 }); commands.spawn(sixteen[2])({ [index]: 2, [value]: 2 }); @@ -88,78 +73,78 @@ export const spawnSingles = System.create({ name: "structural/spawnSingles" }, ( }); /** `count` spawns over sixteen archetypes, entity i into archetype i % 16: more component sets than spawns cache. */ -export const spawnSixteen = System.create({ name: "structural/spawnSixteen", input: counted }, ({ input, commands }) => { +export const spawnSixteen = System.create({}, ({ commands }, input: { readonly count: number }) => { for (let i = 0; i < input.count; i++) { commands.spawn(sixteen[i % 16])({ [index]: i, [value]: i }); } }); /** A spawn per row, from a kernel. */ -export const spawnKernel = System.create({ name: "structural/spawnKernel", kernel: "cpu", components: { read: [index], not: [spawned] } }, (row, { commands }) => { +export const spawnKernel = System.create({ kernel: true, components: { with: [index], not: [spawned] } }, (row, { commands }) => { commands.spawn(made)({ [index]: row[index], [value]: 0 }); }); /** Despawns every entity a spawn benchmark made. */ -export const despawnSpawned = System.create({ name: "structural/despawnSpawned", components: { has: [spawned] } }, (row, { commands }) => { +export const despawnSpawned = System.create({ kernel: false, components: { with: [spawned] } }, (row, { commands }) => { commands.despawn(row.entity); }); /** Despawns every seeded entity. */ -export const despawnAll = System.create({ name: "structural/despawnAll", components: { has: [index], not: [spawned] } }, (row, { commands }) => { +export const despawnAll = System.create({ kernel: false, components: { with: [index], not: [spawned] } }, (row, { commands }) => { commands.despawn(row.entity); }); /** The same, from a kernel. */ -export const despawnKernel = System.create({ name: "structural/despawnKernel", kernel: "cpu", components: { has: [index], not: [spawned] } }, (row, { commands }) => { +export const despawnKernel = System.create({ kernel: true, components: { with: [index], not: [spawned] } }, (row, { commands }) => { commands.despawn(row.entity); }); /** Entities `spawnOne` made, for `despawnOne`. */ const pending: Entity[] = []; -export const spawnOne = System.create({ name: "structural/spawnOne" }, ({ commands }) => { +export const spawnOne = System.create({}, ({ commands }) => { pending.push(commands.spawn(made)({ [index]: 0, [value]: 0 })); }); /** Despawns one entity: a run's fixed cost per despawn. */ -export const despawnOne = System.create({ name: "structural/despawnOne" }, ({ commands }) => { +export const despawnOne = System.create({}, ({ commands }) => { const entity = pending.pop(); if (entity !== undefined) commands.despawn(entity); }); -export const addFlag = System.create({ name: "structural/addFlag", components: { has: [index], not: [flag, spawned] } }, (row, { commands }) => { +export const addFlag = System.create({ kernel: false, components: { with: [index], not: [flag, spawned] } }, (row, { commands }) => { commands.add(row.entity, flag); }); -export const removeFlag = System.create({ name: "structural/removeFlag", components: { has: [flag] } }, (row, { commands }) => { +export const removeFlag = System.create({ kernel: false, components: { with: [flag] } }, (row, { commands }) => { commands.remove(row.entity, flag); }); -export const addFlagKernel = System.create({ name: "structural/addFlagKernel", kernel: "cpu", components: { has: [index], not: [flag, spawned] } }, (row, { commands }) => { +export const addFlagKernel = System.create({ kernel: true, components: { with: [index], not: [flag, spawned] } }, (row, { commands }) => { commands.add(row.entity, flag); }); -export const removeFlagKernel = System.create({ name: "structural/removeFlagKernel", kernel: "cpu", components: { has: [flag] } }, (row, { commands }) => { +export const removeFlagKernel = System.create({ kernel: true, components: { with: [flag] } }, (row, { commands }) => { commands.remove(row.entity, flag); }); -export const addAmount = System.create({ name: "structural/addAmount", components: { read: [index], not: [amount, spawned] } }, (row, { commands }) => { +export const addAmount = System.create({ kernel: false, components: { with: [index], not: [amount, spawned] } }, (row, { commands }) => { commands.add(row.entity, amount, row[index]); }); -export const removeAmount = System.create({ name: "structural/removeAmount", components: { has: [amount] } }, (row, { commands }) => { +export const removeAmount = System.create({ kernel: false, components: { with: [amount] } }, (row, { commands }) => { commands.remove(row.entity, amount); }); /** Adds a value every row already has: no archetype moves, only values replaced and marked changed. */ -export const replaceAmount = System.create({ name: "structural/replaceAmount", components: { read: [amount] } }, (row, { commands }) => { +export const replaceAmount = System.create({ kernel: false, components: { with: [amount] } }, (row, { commands }) => { commands.add(row.entity, amount, row[amount] + 1); }); -export const addSeveral = System.create({ name: "structural/addSeveral", components: { read: [index], not: [flag, spawned] } }, (row, { commands }) => { +export const addSeveral = System.create({ kernel: false, components: { with: [index], not: [flag, spawned] } }, (row, { commands }) => { commands.add(row.entity, several, { [amount]: row[index], [extra]: 1 }); }); -export const removeSeveral = System.create({ name: "structural/removeSeveral", components: { has: [flag, amount, extra] } }, (row, { commands }) => { +export const removeSeveral = System.create({ kernel: false, components: { with: [flag, amount, extra] } }, (row, { commands }) => { commands.remove(row.entity, several); }); diff --git a/packages/ecs/src/perftest/suite.perf-browser-test.ts b/packages/ecs/src/perftest/suite.perf-browser-test.ts index a3ff5451a..104f74af7 100644 --- a/packages/ecs/src/perftest/suite.perf-browser-test.ts +++ b/packages/ecs/src/perftest/suite.perf-browser-test.ts @@ -18,6 +18,7 @@ import { indexBenchmarks } from "./indexes.js"; import { structReplace, valueBenchmarks } from "./values.js"; import { observedGpuBenchmarks } from "./observed-gpu.js"; import { uploadBenchmarks } from "./uploads.js"; +import { renderOrderBenchmarks } from "./render-order.js"; /** Every worker's copy bandwidth together, through browser workers running the calibration script. */ const browserThreadsBandwidth = async (): Promise => { @@ -103,6 +104,7 @@ const benchmarks = (device: GPUDevice): readonly Benchmark[] => [ ...transferBenchmarks, ...observedGpuBenchmarks(device, run => gpuFastest(device, run)), ...uploadBenchmarks(device, run => gpuFastest(device, run)), + ...renderOrderBenchmarks(device, run => gpuFastest(device, run)), ...[[100_000, 10, "100K chains of 10"], [1_000, 1_000, "1K chains of 1,000"]].map(([roots, depth, chains]): Benchmark => ({ name: `up depth ${depth} gpu`, operation: upOperation(String(chains)), diff --git a/packages/ecs/src/perftest/suite.ts b/packages/ecs/src/perftest/suite.ts index 62f10769c..90d5d1476 100644 --- a/packages/ecs/src/perftest/suite.ts +++ b/packages/ecs/src/perftest/suite.ts @@ -1,11 +1,10 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Archetype } from "../interface/archetype/archetype.js"; -import { Component } from "../interface/component/component.js"; -import { Resource } from "../interface/resource/resource.js"; import { Entity } from "../interface/entity/entity.js"; +import type { I32 } from "../interface/math/i32/index.js"; import type { Schema } from "../interface/schema/schema.js"; import { System } from "../interface/system/system.js"; import type { World } from "../interface/world/world.js"; +import type { F32, U32 } from "../interface/math/index.js"; // One fixture per optimization area: structural changes, change filters, lookups, commands, dispatch and main-thread // paths. @@ -23,13 +22,13 @@ export const hits = Symbol("suite/hits"); declare module "@adobe/ecs" { export interface Components { - [position]: { type: readonly [number, number, number] }; - [velocity]: { type: readonly [number, number, number] }; + [position]: { type: Schema.Type<{ type: "array"; items: { type: "number"; precision: 1 }; minItems: 3; maxItems: 3 }> }; + [velocity]: { type: Schema.Type<{ type: "array"; items: { type: "number"; precision: 1 }; minItems: 3; maxItems: 3 }> }; [target]: { type: Entity }; - [index]: { type: number }; + [index]: { type: U32 }; [tag]: { type: true }; [unused]: { type: true }; - [hits]: { type: number }; + [hits]: { type: F32 }; } } @@ -41,22 +40,13 @@ declare module "@adobe/ecs" { [highest]: { type: number; reduce: "max" }; } } -Resource.register(indexSum, { type: { type: "number", default: 0 }, reduce: "+" }); -Resource.register(highest, { type: { type: "number", default: 0 }, reduce: "max" }); -Component.register(position, { type: vec3 }); -Component.register(velocity, { type: vec3 }); -Component.register(target, { type: Entity.schema }); -Component.register(index, { type: { type: "integer", minimum: 0, maximum: 0xFFFFFFFF } }); -Component.register(tag, { type: { const: true } }); -Component.register(unused, { type: { const: true } }); -Component.register(hits, { type: { type: "number", precision: 1 } }); -const moving = Archetype.create([position, velocity, index]); -const following = Archetype.create([position, velocity, index, target]); +const moving = [position, velocity, index] as const; +const following = [position, velocity, index, target] as const; /** `count` entities, each targeting the one spawned before it. */ -export const seed = System.create({ name: "suite/seed", input: { type: "object", properties: { count: { type: "integer" } } } }, ({ input, commands }) => { +export const seed = System.create({}, ({ commands }, input: { readonly count: number }) => { let previous = commands.spawn(moving)({ [position]: [0, 0, 0], [velocity]: [0, 0, 0], [index]: 0 }); for (let i = 1; i < input.count; i++) { previous = commands.spawn(following)({ [position]: [i, i * 0.5, -i], [velocity]: [1, 0, 0], [index]: i, [target]: previous }); @@ -64,14 +54,14 @@ export const seed = System.create({ name: "suite/seed", input: { type: "object", }); /** Despawns a tenth of the entities and spawns as many replacements. */ -export const churn = System.create({ name: "suite/churn", input: { type: "object", properties: { group: { type: "integer" } } }, components: { read: [index, position] } }, (row, { input, commands }) => { +export const churn = System.create({ components: { with: [index, position] } }, (row, { commands }, input: { readonly group: number }) => { if (row[index] % 10 !== input.group) return; commands.despawn(row.entity); commands.spawn(moving)({ [position]: row[position], [velocity]: [0, 0, 0], [index]: row[index] }); }); /** Adds the tag to one tenth and removes it from another: a fifth of the entities move archetype. */ -export const retag = System.create({ name: "suite/retag", input: { type: "object", properties: { group: { type: "integer" } } }, components: { read: [index] } }, (row, { input, commands }) => { +export const retag = System.create({ components: { with: [index] } }, (row, { commands }, input: { readonly group: number }) => { const group = row[index] % 10; if (group === input.group) commands.add(row.entity, tag); else if (group === (input.group + 5) % 10) commands.remove(row.entity, tag); @@ -79,11 +69,9 @@ export const retag = System.create({ name: "suite/retag", input: { type: "object /** Writes 1% of positions. */ export const poke = System.create({ - name: "suite/poke", - kernel: "cpu", - input: { type: "object", properties: { group: { type: "integer" } } }, - components: { read: [index], write: [position] }, -}, (row, { input }) => { + kernel: true, + components: { with: [index, position] }, +}, (row, _context, input: { readonly group: I32 }) => { if (row[index] % 100 === input.group) { const [x, y, z] = row[position]; row[position] = [x + 1, y, z]; @@ -92,11 +80,9 @@ export const poke = System.create({ /** Writes 1% of positions, clustered in one contiguous run of rows. */ export const pokeCluster = System.create({ - name: "suite/pokeCluster", - kernel: "cpu", - input: { type: "object", properties: { group: { type: "integer" } } }, - components: { read: [index], write: [position] }, -}, (row, { input }) => { + kernel: true, + components: { with: [index, position] }, +}, (row, _context, input: { readonly group: I32 }) => { const group = row[index] - input.group * 10_000; if (group >= 0 && group < 10_000) { const [x, y, z] = row[position]; @@ -106,9 +92,8 @@ export const pokeCluster = System.create({ /** Sees only what changed since it last ran. */ export const observe = System.create({ - name: "suite/observe", - kernel: "cpu", - components: { read: [position], write: [velocity], changed: [position] }, + kernel: true, + components: { with: [position, velocity], changed: [position] }, }, row => { const [x, y, z] = row[position]; row[velocity] = [x * 0.5, y * 0.5, z * 0.5]; @@ -116,10 +101,9 @@ export const observe = System.create({ /** Steers toward the target: one lookup per row. */ export const follow = System.create({ - name: "suite/follow", - kernel: "cpu", - components: { read: [position, target], write: [velocity] }, - lookup: { targets: { read: [position] } }, + kernel: true, + components: { with: [position, target, velocity] }, + lookup: { targets: { with: [position] } }, }, (row, { lookup: { targets } }) => { const other = targets(row[target]); if (other !== null) { @@ -131,9 +115,8 @@ export const follow = System.create({ /** Issues commands from a kernel: a tenth of the rows record a hit. */ export const emit = System.create({ - name: "suite/emit", - kernel: "cpu", - components: { read: [index, position] }, + kernel: true, + components: { with: [index, position] }, }, (row, { commands }) => { if (row[index] % 10 === 0) { const [x] = row[position]; @@ -142,37 +125,36 @@ export const emit = System.create({ }); /** Six dependent kernels: one tier each, so dispatch cost shows. */ -export const chain1 = System.create({ name: "suite/chain1", kernel: "cpu", components: { write: [velocity] } }, row => { +export const chain1 = System.create({ kernel: true, components: { with: [velocity] } }, row => { const [x, y, z] = row[velocity]; row[velocity] = [x * 0.99, y * 0.99, z * 0.99]; }); -export const chain2 = System.create({ name: "suite/chain2", kernel: "cpu", components: { write: [velocity] }, after: [chain1] }, row => { +export const chain2 = System.create({ kernel: true, components: { with: [velocity] }, after: [chain1] }, row => { const [x, y, z] = row[velocity]; row[velocity] = [x * 0.99, y * 0.99, z * 0.99]; }); -export const chain3 = System.create({ name: "suite/chain3", kernel: "cpu", components: { write: [velocity] }, after: [chain2] }, row => { +export const chain3 = System.create({ kernel: true, components: { with: [velocity] }, after: [chain2] }, row => { const [x, y, z] = row[velocity]; row[velocity] = [x * 0.99, y * 0.99, z * 0.99]; }); -export const chain4 = System.create({ name: "suite/chain4", kernel: "cpu", components: { write: [velocity] }, after: [chain3] }, row => { +export const chain4 = System.create({ kernel: true, components: { with: [velocity] }, after: [chain3] }, row => { const [x, y, z] = row[velocity]; row[velocity] = [x * 0.99, y * 0.99, z * 0.99]; }); -export const chain5 = System.create({ name: "suite/chain5", kernel: "cpu", components: { write: [velocity] }, after: [chain4] }, row => { +export const chain5 = System.create({ kernel: true, components: { with: [velocity] }, after: [chain4] }, row => { const [x, y, z] = row[velocity]; row[velocity] = [x * 0.99, y * 0.99, z * 0.99]; }); -export const chain6 = System.create({ name: "suite/chain6", kernel: "cpu", components: { write: [velocity] }, after: [chain5] }, row => { +export const chain6 = System.create({ kernel: true, components: { with: [velocity] }, after: [chain5] }, row => { const [x, y, z] = row[velocity]; row[velocity] = [x * 0.99, y * 0.99, z * 0.99]; }); -export const chain = System.create({ name: "suite/chain", systems: [chain1, chain2, chain3, chain4, chain5, chain6] }); +export const chain = System.create({ systems: [chain1, chain2, chain3, chain4, chain5, chain6] }); /** Fifty small main-thread systems in one tier, each reading every row. */ let total = 0; export const smalls = System.create({ - name: "suite/smalls", - systems: Array.from({ length: 50 }, (_, i) => System.create({ name: `suite/small${i}`, components: { read: [position] } }, row => { + systems: Array.from({ length: 50 }, (_, i) => System.create({ components: { with: [position] } }, row => { total += row[position][0]; })), }); @@ -204,30 +186,29 @@ export const seeded = async (create: () => World, count: number): Promise }; /** Folds every row's index into a sum and a maximum. */ -export const reduce = System.create({ name: "suite/reduce", kernel: "cpu", components: { read: [index] }, reduce: { [indexSum]: "+", [highest]: "max" } }, (row, { reduce }) => { +export const reduce = System.create({ kernel: true, components: { with: [index] } }, (row, { reduce }) => { reduce[indexSum](row[index]); reduce[highest](row[index]); }); /** A system no entity matches: what a run costs with nothing to do. */ -export const idle = System.create({ name: "suite/idle", components: { has: [unused] } }, () => {}); +export const idle = System.create({ components: { with: [unused] } }, () => {}); /** A system that throws, so each run of it has a report to spawn. */ -export const failing = System.create({ name: "suite/failing" }, () => { throw new Error("suite: failing"); }); +export const failing = System.create({}, () => { throw new Error("suite: failing"); }); /** An observer system no entity matches: a run of it alone has no tiers. */ -export const idleObserver = System.create({ name: "suite/idleObserver", observer: true, components: { has: [unused] } }, () => {}); +export const idleObserver = System.create({ observer: true, components: { with: [unused] } }, () => {}); /** Fifty systems no entity matches, in one tier. */ export const idles = System.create({ - name: "suite/idles", - systems: Array.from({ length: 50 }, (_, i) => System.create({ name: `suite/idle${i}`, components: { has: [unused] } }, () => {})), + systems: Array.from({ length: 50 }, (_, i) => System.create({ components: { with: [unused] } }, () => {})), }); /** Spawns one entity: a run with a sync point that applies one command. */ -export const spawnOne = System.create({ name: "suite/spawnOne" }, ({ commands }) => { +export const spawnOne = System.create({}, ({ commands }) => { commands.spawn(moving)({ [position]: [0, 0, 0], [velocity]: [0, 0, 0], [index]: 0 }); }); /** One main-thread system reading every row. */ -export const small = System.create({ name: "suite/small", components: { read: [position] } }, row => { +export const small = System.create({ components: { with: [position] } }, row => { total += row[position][0]; }); diff --git a/packages/ecs/src/perftest/systems.ts b/packages/ecs/src/perftest/systems.ts index c32a515ca..e92d100fb 100644 --- a/packages/ecs/src/perftest/systems.ts +++ b/packages/ecs/src/perftest/systems.ts @@ -1,9 +1,9 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Component } from "../interface/component/component.js"; import { always } from "../interface/resources/always.js"; import { eachTick, ticksAfter } from "./observer.js"; import { System } from "../interface/system/system.js"; import { backToBack, createWorld } from "../implementation/index.js"; +import { retainedRuns } from "../implementation/world/reports.js"; import type { Benchmark } from "./benchmark.js"; import { idle } from "./suite.js"; @@ -15,22 +15,21 @@ declare module "@adobe/ecs" { [level]: { type: number }; } } -Component.register(level, { type: { type: "number" } }); /** Entities the added systems read. */ const entities = 100; -const seed = System.create({ name: "systems/seed" }, ({ commands }) => { +const seed = System.create({}, ({ commands }) => { for (let i = 0; i < entities; i++) commands.spawn([level])({ [level]: i }); }); let read = 0; /** A new system for each tick: a change filter, which counts as watching `level`, and rows to read. */ -const fresh = (): System => System.create({ components: { read: [level], changed: [level] } }, row => { read += row[level]; }); +const fresh = (): System => System.create({ components: { with: [level], changed: [level] } }, row => { read += row[level]; }); /** One module's system, added and removed again and again. */ -const again = System.create({ name: "systems/again", components: { read: [level], changed: [level] } }, row => { read += row[level]; }); +const again = System.create({ components: { with: [level], changed: [level] } }, row => { read += row[level]; }); /** Ticks in each timed stretch. */ export const churnTicks = 10_000; @@ -57,7 +56,8 @@ const churning = async (churn: (() => System) | null, after: boolean, stretches world.add(again); await ticksAfter(world, 2); world.remove(again); - await ticksAfter(world, 2); + // The world keeps the reports of its last runs and ticks, so it holds what it will hold once it has that many. + await ticksAfter(world, retainedRuns); await world.run(idle); const before = await world.tables(); let added: System | null = null; diff --git a/packages/ecs/src/perftest/tails.ts b/packages/ecs/src/perftest/tails.ts index c673a6795..f922a18ae 100644 --- a/packages/ecs/src/perftest/tails.ts +++ b/packages/ecs/src/perftest/tails.ts @@ -1,7 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. /// -import { Component } from "../interface/component/component.js"; -import { Resource } from "../interface/resource/resource.js"; import { always } from "../interface/resources/always.js"; import { eachTick, ticksAfter } from "./observer.js"; import { workers } from "../interface/resources/workers.js"; @@ -11,6 +9,8 @@ import { backToBack, createWorld, type CreatedWorld } from "../implementation/in import { tailWriters } from "../implementation/world/tails.js"; import type { Benchmark } from "./benchmark.js"; import { reportOf, runReported } from "../implementation/world/reports.test-helper.js"; +import type { Mat4x4, U32, Vec3 } from "../interface/math/index.js"; +import type { Schema } from "../interface/schema/schema.js"; // Tails: terminal systems on a joined worker, which claims "tailsBenchmark", running after their tick's sync point // while the world goes on. The joined thread is `joined.ts`. @@ -26,24 +26,21 @@ declare module "@adobe/ecs" { tailsBenchmark: true; } export interface Components { - [place]: { type: readonly [number, number, number] }; - [pose]: { type: readonly [number, number, number, number, number, number, number, number, number, number, number, number, number, number, number, number] }; + [place]: { type: Vec3 }; + [pose]: { type: Mat4x4 }; } export interface Resources { - [surface]: { type: { readonly name: string }; thread: "tailsBenchmark" }; - [poked]: { type: number }; - [rowWork]: { type: number }; + [surface]: { type: Surface; thread: "tailsBenchmark" }; + [poked]: { type: Schema.Type<{ type: "integer" }>; default: 0 }; + [rowWork]: { type: U32 }; [worldWork]: { type: number }; } } -Component.register(place, { type: { type: "array", items: { type: "number", precision: 1 }, minItems: 3, maxItems: 3 } }); -Component.register(pose, { type: { type: "array", items: { type: "number", precision: 1 }, minItems: 16, maxItems: 16 } }); /** An object of the joined thread, as a canvas or the DOM is, which places the systems reading it there. */ -const surfaceStart = { name: "surface" }; -Resource.register(surface, { type: { type: "object", properties: { name: { type: "string" } }, default: surfaceStart }, thread: "tailsBenchmark" }); -Resource.register(poked, { type: { type: "integer", default: 0 } }); -Resource.register(rowWork, { type: { type: "integer", minimum: 0, maximum: 1_000_000, default: 0 } }); -Resource.register(worldWork, { type: { type: "number", default: 0 } }); +const surfaceStart = { name: "surface" } as const; +/** The surface tails draw to, which starts as `surfaceStart`, so they can tell it apart by identity. */ +type Surface = { readonly name: string }; +const Surface = { default: surfaceStart }; /** What the tails read, so their reads aren't dead. */ export const drawn = { sum: 0 }; @@ -54,36 +51,36 @@ export const lateRows = 10_000; /** Ticks in each timed stretch of the prompt tail. */ export const tailTicks = 1_000; -const spawnPlaces = System.create({ name: "tails/spawnPlaces" }, ({ commands }) => { +const spawnPlaces = System.create({}, ({ commands }) => { for (let k = 0; k < promptRows; k++) commands.spawn([place])({ [place]: [k, 0, 0] }); }); -const spawnPoses = System.create({ name: "tails/spawnPoses" }, ({ commands }) => { +const spawnPoses = System.create({}, ({ commands }) => { for (let k = 0; k < lateRows; k++) commands.spawn([place, pose])({ [place]: [k, 0, 0], [pose]: [k, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1] }); }); -const here = System.create({ name: "tails/here" }, () => {}); +const here = System.create({}, () => {}); /** On the joined thread, terminal: reads every row's place, as a DOM-writing system reads positions. */ -const draw = System.create({ name: "tails/draw", components: { read: [place] }, resources: { read: [surface] } }, (row, { resources }) => { +const draw = System.create({ components: { with: [place] } }, (row, { resources }) => { if (resources[surface] === surfaceStart) drawn.sum += row[place][0]; }); /** A tick of a world system and a 1,000-row tail that finishes before the next tick needs it. */ -export const promptTick = System.create({ name: "tails/promptTick", systems: [here, draw] }); +export const promptTick = System.create({ systems: [here, draw] }); /** On the joined thread, ahead of the late tail: busy for a while, as a main thread is with layout. */ -const busy = System.create({ name: "tails/busy", resources: { read: [surface] } }, ({ resources }) => { +const busy = System.create({}, ({ resources }) => { const until = performance.now() + 4; while (performance.now() < until && resources[surface] === surfaceStart); }); /** On the joined thread, terminal, after `busy`: reads every row's place and pose, and the resource `poke` writes. */ -const copied = System.create({ name: "tails/copied", components: { read: [place, pose] }, resources: { read: [surface, poked] }, after: [busy] }, (row, { resources }) => { +const copied = System.create({ components: { with: [place, pose] }, after: [busy] }, (row, { resources }) => { if (resources[surface] === surfaceStart) drawn.sum += row[place][0] + row[pose][15] + resources[poked]; }); -export const lateTick = System.create({ name: "tails/lateTick", systems: [busy, copied] }); +export const lateTick = System.create({ systems: [busy, copied] }); /** On the world's thread: writes what the late tail reads, so it copies the rest of the tail's reads first. */ -const poke = System.create({ name: "tails/poke", resources: { write: [poked] } }, ({ resources }) => { +const poke = System.create({}, ({ resources }) => { resources[poked] += 1; }); -const nothing = System.create({ name: "tails/nothing" }, () => {}); +const nothing = System.create({}, () => {}); /** Steps of work each row of the overlapping tail does, about 2.5 ms over 10,000 rows; the world's work after its writer. */ export const overlapWork = 40; @@ -92,23 +89,23 @@ export const overlapWorldMilliseconds = 2; export const overlapTicks = 100; /** On the world's thread: writes every row's place, which the last tick's tail still reads. */ -const overlapWrite = System.create({ name: "tails/overlapWrite", components: { write: [place] } }, row => { +const overlapWrite = System.create({ kernel: false, components: { with: [place] } }, row => { row[place] = [row[place][0], 1, 0]; }); /** On the world's thread, after the writer: the rest of the tick's work, as systems reading what it wrote do. */ -const overlapSimulate = System.create({ name: "tails/overlapSimulate", resources: { read: [worldWork] }, after: [overlapWrite] }, ({ resources }) => { +const overlapSimulate = System.create({ after: [overlapWrite] }, ({ resources }) => { const until = performance.now() + resources[worldWork]; while (performance.now() < until); }); /** On the joined thread, terminal: works on every row's place and pose, as a DOM-writing system on main does. */ -const overlapDraw = System.create({ name: "tails/overlapDraw", components: { read: [place, pose] }, resources: { read: [surface, rowWork] }, after: [overlapWrite] }, (row, { resources }) => { +const overlapDraw = System.create({ components: { with: [place, pose] }, after: [overlapWrite] }, (row, { resources }) => { if (resources[surface] !== surfaceStart) return; let sum = row[place][0] + row[pose][15]; for (let k = resources[rowWork]; k > 0; k--) sum = Math.sqrt(sum + k); drawn.sum += sum; }); -export const overlapTick = System.create({ name: "tails/overlapTick", systems: [overlapWrite, overlapSimulate, overlapDraw] }); -const setOverlap = System.create({ name: "tails/setOverlap", resources: { write: [rowWork, worldWork] } }, ({ resources }) => { +export const overlapTick = System.create({ systems: [overlapWrite, overlapSimulate, overlapDraw] }); +const setOverlap = System.create({}, ({ resources }) => { resources[rowWork] = overlapWork; resources[worldWork] = overlapWorldMilliseconds; }); diff --git a/packages/ecs/src/perftest/transfers.ts b/packages/ecs/src/perftest/transfers.ts index 1abfc5c0c..d69a533cf 100644 --- a/packages/ecs/src/perftest/transfers.ts +++ b/packages/ecs/src/perftest/transfers.ts @@ -1,5 +1,4 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Resource } from "../interface/resource/resource.js"; import { workers } from "../interface/resources/workers.js"; import { System } from "../interface/system/system.js"; import { createWorld, type CreatedWorld } from "../implementation/index.js"; @@ -18,24 +17,15 @@ declare module "@adobe/ecs" { } } /** An object of the claimed thread, which places the system reading it there. */ -Resource.register(sink, { thread: "transfersBenchmark" }); /** Bytes `take` took, on the claimed thread. */ export const taken = { bytes: 0 }; -/** - * On the claimed thread: takes a buffer and a bitmap, as an upload would, closing the bitmap. Its input's fields take - * any value, `{ allOf: [] }`, typed `unknown`, which it narrows. - */ -export const take = System.create({ - name: "transfers/take", input: { type: "object", properties: { buffer: { allOf: [] }, bitmap: { allOf: [] } } }, resources: { read: [sink] }, -}, ({ input, resources }) => { +/** On the claimed thread: takes a buffer and a bitmap, as an upload would, closing the bitmap. */ +export const take = System.create({}, ({ resources }, input: { readonly buffer: ArrayBuffer; readonly bitmap: ImageBitmap }) => { if (resources[sink] !== null) return; - if (input.buffer instanceof ArrayBuffer) taken.bytes += input.buffer.byteLength; - if (typeof ImageBitmap === "function" && input.bitmap instanceof ImageBitmap) { - taken.bytes += input.bitmap.width * input.bitmap.height * 4; - input.bitmap.close(); - } + taken.bytes += input.buffer.byteLength + input.bitmap.width * input.bitmap.height * 4; + input.bitmap.close(); }); /** Every system the joined threads run, which they import so the world can place them there. */ diff --git a/packages/ecs/src/perftest/uploads.ts b/packages/ecs/src/perftest/uploads.ts index 4ed04278a..f3b3f26a8 100644 --- a/packages/ecs/src/perftest/uploads.ts +++ b/packages/ecs/src/perftest/uploads.ts @@ -1,45 +1,42 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Component } from "../interface/component/component.js"; import { Entity } from "../interface/entity/entity.js"; import { gpuDevice } from "../interface/resources/gpu-device.js"; import { workers } from "../interface/resources/workers.js"; import { System } from "../interface/system/system.js"; import { createWorld } from "../implementation/world/create.js"; import type { Benchmark } from "./benchmark.js"; +import type { Vec3 } from "../interface/math/index.js"; // A drag: the CPU writes one row of a column a render system holds on the GPU, which uploads what changed. const place = Symbol("uploads/place"); declare module "@adobe/ecs" { export interface Components { - [place]: { type: readonly [number, number, number] }; + [place]: { type: Vec3 }; } } -Component.register(place, { type: { type: "array", items: { type: "number", precision: 1 }, minItems: 3, maxItems: 3 } }); const shapes = 100_000; let spawned: Entity[] = []; -const spawn = System.create({ name: "uploads/spawn" }, ({ commands }) => { +const spawn = System.create({}, ({ commands }) => { spawned = []; for (let i = 0; i < shapes; i++) spawned.push(commands.spawn([place])({ [place]: [i, 0, 0] })); }); const drag = System.create({ - name: "uploads/drag", - components: { write: [place] }, - input: { type: "object", properties: { shape: Entity.schema, x: { type: "number" } } }, + components: { with: [place] }, target: "shape", -}, (row, { input }) => { +}, (row, _context, input: { readonly shape: Entity; readonly x: number }) => { row[place] = [input.x, 1, 0]; }); /** Draws nothing: its run uploads what it reads. */ -const draw = System.render({ name: "uploads/draw", shader: { accessors: { place } }, after: [drag] }, () => {}); -const frame = System.create({ name: "uploads/frame", systems: [drag, draw] }); -const slide = System.create({ name: "uploads/slide", kernel: "cpu", components: { write: [place] } }, row => { +const draw = System.render({ shader: { accessors: { place } }, after: [drag] }, () => {}); +const frame = System.create({ systems: [drag, draw] }); +const slide = System.create({ kernel: true, components: { with: [place] } }, row => { const [x, y, z] = row[place]; row[place] = [x + 1, y, z]; }); -const drawAfterSlide = System.render({ name: "uploads/drawAfterSlide", shader: { accessors: { place } }, after: [slide] }, () => {}); -const slideFrame = System.create({ name: "uploads/slideFrame", systems: [slide, drawAfterSlide] }); +const drawAfterSlide = System.render({ shader: { accessors: { place } }, after: [slide] }, () => {}); +const slideFrame = System.create({ systems: [slide, drawAfterSlide] }); /** Timed per run by `timed`, which waits for the GPU. */ export const uploadBenchmarks = (device: GPUDevice, timed: (run: () => Promise) => Promise): readonly Benchmark[] => [{ diff --git a/packages/ecs/src/perftest/values.ts b/packages/ecs/src/perftest/values.ts index 8555da417..de87d372a 100644 --- a/packages/ecs/src/perftest/values.ts +++ b/packages/ecs/src/perftest/values.ts @@ -1,6 +1,4 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Archetype } from "../interface/archetype/archetype.js"; -import { Component } from "../interface/component/component.js"; import { Finalizer } from "../interface/finalizer/finalizer.js"; import { workers } from "../interface/resources/workers.js"; import { System } from "../interface/system/system.js"; @@ -8,6 +6,7 @@ import type { World } from "../interface/world/world.js"; import { createWorld } from "../implementation/index.js"; import type { Benchmark, Best } from "./benchmark.js"; import { fastest } from "./suite.js"; +import type { Vec2 } from "../interface/math/index.js"; // Benchmarks of struct values, which a row reads into an object and writes back field by field: a 2D transform's five // numbers, as an editor's nodes hold, beside a vec2 size. @@ -21,30 +20,26 @@ declare module "@adobe/ecs" { export interface Components { [pose]: { type: Pose }; [shown]: { type: Pose }; - [extent]: { type: readonly [number, number] }; + [extent]: { type: Vec2 }; [order]: { type: number }; } } const number = { type: "number" } as const; const poseSchema = { type: "object", properties: { tx: number, ty: number, rotation: number, sx: number, sy: number }, required: ["tx", "ty", "rotation", "sx", "sy"] } as const; -Component.register(pose, { type: poseSchema }); -Component.register(shown, { type: poseSchema }); -Component.register(extent, { type: { type: "array", items: { type: "number", precision: 1 }, minItems: 2, maxItems: 2 } }); -Component.register(order, { type: number }); -const Node = Archetype.create([pose, shown, extent, order]); +const Node = [pose, shown, extent, order] as const; const rows = 100_000; -const seed = System.create({ name: "values/seed", input: { type: "object", properties: { count: { type: "integer" } } } }, ({ input, commands }) => { +const seed = System.create({}, ({ commands }, input: { readonly count: number }) => { for (let i = 0; i < input.count; i++) commands.spawn(Node)({ [pose]: { tx: i, ty: 0, rotation: 0, sx: 1, sy: 1 }, [shown]: { tx: 0, ty: 0, rotation: 0, sx: 1, sy: 1 }, [extent]: [1, 1], [order]: i }); }); /** Shows each row's pose: a struct read and written whole per row. */ -const showPose = System.create({ name: "values/showPose", components: { read: [pose], write: [shown] } }, row => { row[shown] = row[pose]; }); +const showPose = System.create({ kernel: false, components: { with: [pose, shown] } }, row => { row[shown] = row[pose]; }); /** Commits a pose and an extent per row as commands, values every row already has. */ -const commitPose = System.create({ name: "values/commitPose", kernel: "cpu", components: { read: [order] } }, (row, { commands }) => { +const commitPose = System.create({ kernel: true, components: { with: [order] } }, (row, { commands }) => { commands.add(row.entity, pose, { tx: row[order], ty: 1, rotation: 0, sx: 1, sy: 1 }); commands.add(row.entity, extent, [2, 2]); }); @@ -82,7 +77,7 @@ export const valueBenchmarks: readonly Benchmark[] = [ best: { bytes: 32, basis: "Each command writes its 40-byte struct or 8-byte vec2 and reads its entity's 8-byte location." }, run: async () => { // Program-wide, so made here, in this benchmark's own process. - Finalizer.changes({ name: "values/history", watch: [pose, extent] }, ({ groups }) => { + Finalizer.changes({ watch: [pose, extent] }, ({ groups }) => { replacedPoses = 0; for (const group of groups) if (group.replaced.includes(pose)) replacedPoses += group.entities.length; }); diff --git a/packages/ecs/src/samples/browser.test.ts b/packages/ecs/src/samples/browser.test.ts new file mode 100644 index 000000000..8c698a8db --- /dev/null +++ b/packages/ecs/src/samples/browser.test.ts @@ -0,0 +1,19 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +/// +import { existsSync, readdirSync, readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { expect, it } from "vitest"; + +const samples = fileURLToPath(new URL(".", import.meta.url)); + +it("the sample browser has a tab for each sample folder, titled as its page", () => { + const page = readFileSync(`${samples}index.html`, "utf8"); + const tabs = [...page.matchAll(/([^<]+)<\/a>/g)].map(([, name, title]) => ({ name, title })); + const folders = readdirSync(samples, { withFileTypes: true }) + .filter(entry => entry.isDirectory() && existsSync(`${samples}${entry.name}/index.html`)) + .map(entry => { + const title = /([^<]+)<\/title>/.exec(readFileSync(`${samples}${entry.name}/index.html`, "utf8"))?.[1]; + return { name: entry.name, title }; + }); + expect(tabs).toEqual(folders); +}); diff --git a/packages/ecs/src/samples/browser.ts b/packages/ecs/src/samples/browser.ts new file mode 100644 index 000000000..b99c475ad --- /dev/null +++ b/packages/ecs/src/samples/browser.ts @@ -0,0 +1,20 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +// The sample browser: a tab per sample, the one the URL's hash names shown in the frame, the first when it names none. + +const tabs = [...document.querySelectorAll<HTMLAnchorElement>('nav a[href^="#"]')]; +const frame = document.querySelector("iframe"); +const alone = document.querySelector<HTMLAnchorElement>("#alone"); + +const show = (): void => { + const chosen = tabs.find(tab => tab.hash === location.hash) ?? tabs[0]; + if (chosen === undefined || frame === null) return; + const page = `./${chosen.hash.slice(1)}/`; + for (const tab of tabs) if (tab === chosen) tab.setAttribute("aria-current", "page"); else tab.removeAttribute("aria-current"); + if (frame.getAttribute("src") !== page) frame.setAttribute("src", page); + if (alone !== null) alone.href = page; + document.title = `${chosen.textContent ?? ""} · ECS Samples`; +}; + +addEventListener("hashchange", show); +show(); diff --git a/packages/ecs/src/samples/index.html b/packages/ecs/src/samples/index.html new file mode 100644 index 000000000..973ce70d3 --- /dev/null +++ b/packages/ecs/src/samples/index.html @@ -0,0 +1,34 @@ +<!doctype html> +<html lang="en"> +<head> + <meta charset="utf-8"> + <meta name="viewport" content="width=device-width, initial-scale=1"> + <title>ECS Samples + + + + + + + + + diff --git a/packages/ecs/src/samples/particles/gpu/main.ts b/packages/ecs/src/samples/particles/gpu/main.ts index 10638d046..b7e0b4cd0 100644 --- a/packages/ecs/src/samples/particles/gpu/main.ts +++ b/packages/ecs/src/samples/particles/gpu/main.ts @@ -59,11 +59,11 @@ const start = async (): Promise => { const world = World.create({ [gpuDevice]: device }); const { draw, ready } = createDraw(device, Gpu.layout(device), context, format); await ready; - const frame = System.create({ name: "gpuParticles/frame", systems: [...moving, draw] }); + const frame = System.create({ systems: [...moving, draw] }); await world.run(spawn, { count, width: canvas.width, height: canvas.height }); /** For automated checks: a few drawn particles' positions, which an observer system reads back from the GPU. */ const probed: (readonly number[])[] = []; - const probe = System.create({ name: "gpuParticles/probe", observer: true, components: { read: [position], has: [visible, enabled] } }, row => { + const probe = System.create({ observer: true, components: { with: [position, visible, enabled] } }, row => { if (probed.length < 3) probed.push(row[position]); }); Reflect.set(globalThis, "sampleProbe", async () => { diff --git a/packages/ecs/src/samples/particles/gpu/particles.ts b/packages/ecs/src/samples/particles/gpu/particles.ts index 27f712357..8dc445e5b 100644 --- a/packages/ecs/src/samples/particles/gpu/particles.ts +++ b/packages/ecs/src/samples/particles/gpu/particles.ts @@ -1,40 +1,33 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Archetype, Component, Gpu, System, Vec3 } from '../../../index.js'; +import { type F32, Gpu, System, type U32, Vec3 } from '../../../index.js'; // The perf particles (src/perftest/particles.ts) on screen: 1M particles over four archetypes by visible and // enabled, with screen-space positions and velocities in pixels per frame. `move` is the perf move kernel, on the GPU. -export const position = Symbol("gpuParticles/position"); -export const velocity = Symbol("gpuParticles/velocity"); -export const color = Symbol("gpuParticles/color"); -export const mass = Symbol("gpuParticles/mass"); -export const visible = Symbol("gpuParticles/visible"); -export const enabled = Symbol("gpuParticles/enabled"); +export const position = Symbol(); +export const velocity = Symbol(); +export const color = Symbol(); +export const mass = Symbol(); +export const visible = Symbol(); +export const enabled = Symbol(); declare module '@adobe/ecs' { interface Components { [position]: { type: Vec3 }; [velocity]: { type: Vec3 }; - [color]: { type: number }; - [mass]: { type: number }; + [color]: { type: U32 }; + [mass]: { type: F32 }; [visible]: { type: true }; [enabled]: { type: true }; } } -Component.register(position, { type: Vec3.schema }); -Component.register(velocity, { type: Vec3.schema }); -Component.register(color, { type: { type: "integer", minimum: 0, maximum: 0xFFFFFFFF } }); -Component.register(mass, { type: { type: "number", precision: 1 } }); -Component.register(visible, { type: { const: true } }); -Component.register(enabled, { type: { const: true } }); - /** Entity i goes to archetype i % 4. */ const archetypes = [ - Archetype.create([color, mass, position, velocity, visible, enabled]), - Archetype.create([color, mass, position, velocity, visible]), - Archetype.create([color, mass, position, velocity, enabled]), - Archetype.create([color, mass, position, velocity]), + [color, mass, position, velocity, visible, enabled] as const, + [color, mass, position, velocity, visible] as const, + [color, mass, position, velocity, enabled] as const, + [color, mass, position, velocity] as const, ] as const; /** A deterministic sequence in [0, 1), so every mode starts from the same particles. */ @@ -48,9 +41,7 @@ const random = (seed: number): (() => number) => { /** The particle count, and the field's width and height in pixels. Entity i goes to archetype i % 4. */ export const spawn = System.create({ - name: "gpuParticles/spawn", - input: { type: "object", properties: { count: { type: "integer" }, width: { type: "number" }, height: { type: "number" } } }, -}, ({ input: { count, width, height }, commands }) => { +}, ({ commands }, { count, width, height }: { readonly count: number; readonly width: number; readonly height: number }) => { const next = random(7); for (let i = 0; i < count; i++) { const p = [next() * width, next() * height, 0] as const; @@ -64,18 +55,16 @@ export const spawn = System.create({ }); export const move = System.create({ - name: "gpuParticles/move", kernel: "gpu", - components: { read: [velocity], write: [position], has: [visible, enabled] }, + components: { with: [velocity, position, visible, enabled] }, }, row => { row[position] = Vec3.add(row[position], row[velocity]); }); /** The same move on the CPU, for comparison. */ export const moveOnCpu = System.create({ - name: "gpuParticles/moveOnCpu", - kernel: "cpu", - components: { read: [velocity], write: [position], has: [visible, enabled] }, + kernel: true, + components: { with: [velocity, position, visible, enabled] }, }, row => { row[position] = Vec3.add(row[position], row[velocity]); }); @@ -98,8 +87,7 @@ struct Point { @builtin(position) position: vec4f, @location(0) color: vec4f } `; const declaration = { - name: "gpuParticles/draw", - components: { has: [visible, enabled] }, + components: { with: [visible, enabled] }, shader: { group: 0, accessors: { particlePosition: position, particleColor: color } }, after: [move, moveOnCpu], } as const; diff --git a/packages/ecs/src/samples/particles/particles.test.ts b/packages/ecs/src/samples/particles/particles.test.ts index e8e184995..5e5eba37b 100644 --- a/packages/ecs/src/samples/particles/particles.test.ts +++ b/packages/ecs/src/samples/particles/particles.test.ts @@ -23,7 +23,7 @@ describe("particles", () => { let positions: readonly (readonly number[])[] = []; for (const seconds of frames) positions = await frame(world, seconds); let spawned = 0; - await world.run(System.create({ observer: true, resources: { read: [count] } }, ({ resources }) => { spawned = resources[count]; })); + await world.run(System.create({ observer: true }, ({ resources }) => { spawned = resources[count]; })); expect(spawned).toBe(100); const expected = reference(frames); expect(positions).toHaveLength(expected.length); diff --git a/packages/ecs/src/samples/particles/particles.ts b/packages/ecs/src/samples/particles/particles.ts index f927712ef..233c4cdea 100644 --- a/packages/ecs/src/samples/particles/particles.ts +++ b/packages/ecs/src/samples/particles/particles.ts @@ -1,10 +1,10 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Component, Resource, System, Vec3, type World } from '../../index.js'; +import { type F32, System, type U32, Vec3, type World } from '../../index.js'; -export const position = Symbol("particles/position"); -export const velocity = Symbol("particles/velocity"); -export const delta = Symbol("particles/delta"); -export const count = Symbol("particles/count"); +export const position = Symbol(); +export const velocity = Symbol(); +export const delta = Symbol(); +export const count = Symbol(); declare module '@adobe/ecs' { interface Components { @@ -12,42 +12,30 @@ declare module '@adobe/ecs' { [velocity]: { type: Vec3 }; } interface Resources { - [delta]: { type: number }; - [count]: { type: number }; + [delta]: { type: F32; default: 0 }; + [count]: { type: U32; default: 100 }; } } -Component.register(position, { type: Vec3.schema }); -Component.register(velocity, { type: Vec3.schema }); -Resource.register(delta, { type: { type: "number", precision: 1, default: 0 } }); -Resource.register(count, { type: { type: "integer", minimum: 0, default: 100 } }); - // Main thread: resource-only, runs when `count` changes, including the first run. export const spawn = System.create({ - name: "particles/spawn", - resources: { read: [count], changed: [count] }, + resources: { changed: [count] }, }, ({ resources, commands }) => { for (let i = 0; i < resources[count]; i++) { commands.spawn([position, velocity])({ [position]: [0, 0, 0], [velocity]: [1, i, 0] }); } }); -// Kernels: linear components and read-only resources, so they can run in chunks across workers. +// Kernels: linear components and read-only resources, so the build compiles them to run in chunks across workers. export const gravity = System.create({ - name: "particles/gravity", - kernel: "cpu", - components: { write: [velocity] }, - resources: { read: [delta] }, + components: { with: [velocity] }, }, (row, { resources }) => { const [x, y, z] = row[velocity]; row[velocity] = [x, y - 9.8 * resources[delta], z]; }); export const integrate = System.create({ - name: "particles/integrate", - kernel: "cpu", - components: { read: [velocity], write: [position] }, - resources: { read: [delta] }, + components: { with: [velocity, position] }, after: [gravity], }, (row, { resources }) => { const [px, py, pz] = row[position]; @@ -57,22 +45,17 @@ export const integrate = System.create({ }); // Transaction carrying frame time into the ECS. -export const setDelta = System.create({ - name: "particles/setDelta", - input: { type: "object", properties: { seconds: { type: "number" } } }, - resources: { write: [delta] }, -}, ({ resources, input }) => { +export const setDelta = System.create({}, ({ resources }, input: { readonly seconds: number }) => { resources[delta] = input.seconds; }); -export const main = System.create({ name: "particles/main", systems: [spawn, gravity, integrate] }); +export const main = System.create({ systems: [spawn, gravity, integrate] }); // Reads every position the frame left, in spawn order. const positions: Vec3[] = []; const read = System.create({ - name: "particles/read", observer: true, - components: { read: [position] }, + components: { with: [position] }, }, row => { positions.push(row[position]); }); diff --git a/packages/ecs/src/samples/particles/particles.type-test.ts b/packages/ecs/src/samples/particles/particles.type-test.ts deleted file mode 100644 index 6b5bef1aa..000000000 --- a/packages/ecs/src/samples/particles/particles.type-test.ts +++ /dev/null @@ -1,8 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Resource } from '../../index.js'; -import { count } from './particles.js'; - -// @ts-expect-error a resource schema requires a default -Resource.register(count, { type: { type: "integer" } }); -// @ts-expect-error the default must match the resource type -Resource.register(count, { type: { type: "integer", default: "none" } }); diff --git a/packages/ecs/src/samples/todo/README.md b/packages/ecs/src/samples/todo/README.md index 04313d52f..449c27dc2 100644 --- a/packages/ecs/src/samples/todo/README.md +++ b/packages/ecs/src/samples/todo/README.md @@ -3,7 +3,7 @@ A todo app whose world drives every change to the DOM through systems, while its elements know nothing about the ECS. It's the experiment for `ecs-dom.md`: vanilla custom elements, no framework, no persistence. -`pnpm sample`, then open `/todo/`. Add, toggle, edit (double-click), delete, move (drag, or Alt+↑ and Alt+↓), mark all +`pnpm dev`, then the Todos tab. Add, toggle, edit (double-click), delete, move (drag, or Alt+↑ and Alt+↓), mark all done or none, clear those done, filter, and generate 10 or 1,000 todos. ## How the DOM is driven @@ -141,17 +141,22 @@ What the current API does and doesn't offer the DOM path, each with the change i to back in an empty world, at most 0.3 µs more with the app's observer systems added, and 2.5 µs in the app with 1K todos, where a task round trip takes 7.6 µs. Node's `run idle` is 0.33 µs. A run made right after a frame, as an event's is, takes - 0.02–0.10 ms, which is the CPU coming back from idle, not the ECS. Toggling one todo of 10K takes 62 µs, of which - the profile gives 64% to `mergeEntries` (`index-hash.ts`), keeping `completion/byDone`'s two buckets of 5K entries in - entity order, and 16% to `forEachPassingRange` (`ranges.ts`), the change filters scanning the 1024 row ticks of the - one chunk that changed, for each observer system with `changed`. The rest is the sync point, the observer pass and - the DOM writes, about 13 µs. *Proposals* in the next two findings. -- **A low-cardinality hash index costs O(bucket) per change:** toggling one todo moves it between `byDone`'s buckets, - and each bucket's entries shift to stay in entity order: 26 µs a toggle at 1K, 62 at 10K, 400 at 100K. *Proposal:* - unordered buckets with swap-remove and a position per entity, sorted only when a list is read in entity order; or - a count-only index for what reads only `length`. -- **A change filter scans a whole chunk for one changed row,** 1024 ticks for each system with `changed`. *Proposal:* - a second level of ticks per 32 or 64 rows, or a list of the rows changed per chunk. + 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. +- **A low-cardinality hash index costs O(1) amortized per change:** toggling one todo moves it between `byDone`'s + buckets, which are left unsorted, its entry leaving a hole in one and joining a tail of the other, until a list is + read by position (`index-hash.ts`). In node, a toggle and a run reading both buckets' lengths, as the summary does, + take 5.5, 5.3 and 5.5 µs at 1K, 10K and 100K todos (`bucket move … length`), against 9.1, 64 and 423 µs with every + bucket kept in entity order; reading each bucket's first rows after it, as `clearCompleted` reads the done ones, + sorts both buckets first: 6.2, 12.7 and 69 µs (`bucket move … in order`), against 9.4, 65 and 418. +- **A change filter finds one changed row by its block:** it reads the chunk's sixteen block ticks and the 64 row ticks + of the block that changed, not the chunk's 1024. In a profile of 40,000 toggles of one todo of 10K, the change + filters take 5.0% of samples, against 6.9% scanning the chunk, most of it the chunk checks of observer systems + filtering on two or three components; in node, eight systems filtering on one changed row of 100K take 5.0 µs a + run, against 34.9 µs scanning the chunk (`changed one 100K`). *No change needed.* - **Nothing awaits the next tick.** Tests watch `clock` changes through an observer, after a first run that passes it regardless, and a world ticks only while a system that isn't an observer is added. *Proposal:* none for consumers; a test helper in `src/observe` would do. diff --git a/packages/ecs/src/samples/todo/app/app.browser.test.ts b/packages/ecs/src/samples/todo/app/app.browser.test.ts index 6c90e9f5e..5d58a73d7 100644 --- a/packages/ecs/src/samples/todo/app/app.browser.test.ts +++ b/packages/ecs/src/samples/todo/app/app.browser.test.ts @@ -182,13 +182,13 @@ describe("the todo app", () => { }); // It runs in the tick that gives the todo its row, alongside createRows. -const failing = System.create({ name: "test/failing", components: { has: [todo] } }, () => { +const failing = System.create({ components: { with: [todo] } }, () => { throw new Error("a tick's system failed"); }); -const failingObserver = System.create({ name: "test/failingObserver", observer: true, components: { has: [row], changed: [row] } }, () => { +const failingObserver = System.create({ observer: true, components: { with: [row], changed: [row] } }, () => { throw new Error("a tick's observer failed"); }); -const pass = System.create({ name: "test/pass", observer: true }, () => {}); +const pass = System.create({ observer: true }, () => {}); describe("the todo app's failures", () => { /** The messages of the failures logged. */ diff --git a/packages/ecs/src/samples/todo/app/log-failures.test.ts b/packages/ecs/src/samples/todo/app/log-failures.test.ts index b5c658f71..91bfcdc69 100644 --- a/packages/ecs/src/samples/todo/app/log-failures.test.ts +++ b/packages/ecs/src/samples/todo/app/log-failures.test.ts @@ -3,13 +3,13 @@ import { afterEach, expect, it, vi } from "vitest"; import { System, World, workers } from "../../../index.js"; import { logFailures } from "./log-failures.js"; -const fails = System.create({ name: "test/fails" }, () => { +const fails = System.create({}, () => { throw new Error("broken"); }); -const failsObserving = System.create({ name: "test/failsObserving", observer: true }, () => { +const failsObserving = System.create({ observer: true }, () => { throw new Error("observer broken"); }); -const nothing = System.create({ name: "test/nothing" }, () => {}); +const nothing = System.create({}, () => {}); afterEach(() => { vi.restoreAllMocks(); diff --git a/packages/ecs/src/samples/todo/app/log-failures.ts b/packages/ecs/src/samples/todo/app/log-failures.ts index 0d2f899e4..8713409ac 100644 --- a/packages/ecs/src/samples/todo/app/log-failures.ts +++ b/packages/ecs/src/samples/todo/app/log-failures.ts @@ -2,7 +2,7 @@ import { Report, System } from "../../../index.js"; /** Logs what reports say failed, a run's an event started and a tick's alike: each report's errors are written once. */ -export const logFailures = System.create({ name: "app/logFailures", observer: true, components: { read: [Report.errors], changed: [Report.errors] } }, row => { +export const logFailures = System.create({ observer: true, components: { with: [Report.errors], changed: [Report.errors] } }, row => { const { failures } = row[Report.errors]; if (failures.length > 0) console.error("A run failed", failures); }); diff --git a/packages/ecs/src/samples/todo/app/profile.perf-browser-test.ts b/packages/ecs/src/samples/todo/app/profile.perf-browser-test.ts index 42a9d0b4d..3e5fd72de 100644 --- a/packages/ecs/src/samples/todo/app/profile.perf-browser-test.ts +++ b/packages/ecs/src/samples/todo/app/profile.perf-browser-test.ts @@ -18,7 +18,7 @@ import { logFailures } from "./log-failures.js"; * asserted: `pnpm test:perf src/samples/todo`. */ -const nothing = System.create({ name: "profile/nothing" }, () => {}); +const nothing = System.create({}, () => {}); const median = (values: readonly number[]): number => [...values].sort((a, b) => a - b)[Math.floor(values.length / 2)] ?? NaN; /** The median of `repeats` runs awaited one at a time, and the mean of as many issued back to back. */ diff --git a/packages/ecs/src/samples/todo/app/todo.perf-browser-test.ts b/packages/ecs/src/samples/todo/app/todo.perf-browser-test.ts index eaabf5b4c..b80a8f04b 100644 --- a/packages/ecs/src/samples/todo/app/todo.perf-browser-test.ts +++ b/packages/ecs/src/samples/todo/app/todo.perf-browser-test.ts @@ -84,11 +84,11 @@ const measure = async (operation: string, n: number, repeats: number, run: (repe results.push({ operation, n, ms: median(times), mutations, tick: median(ticks) }); }; -const nothing = System.create({ name: "perf/nothing" }, () => {}); +const nothing = System.create({}, () => {}); /** The failures of every report the apps measured spawned, which none should. */ const failed: Report.Failure[] = []; -const collectFailures = System.create({ name: "perf/collectFailures", observer: true, components: { read: [Report.errors], changed: [Report.errors] } }, row => { +const collectFailures = System.create({ observer: true, components: { with: [Report.errors], changed: [Report.errors] } }, row => { failed.push(...row[Report.errors].failures); }); @@ -104,7 +104,7 @@ const forceLayout = (): void => { }; /** Marks every other todo done, for filters to hide half the rows. */ -const halfDone = System.create({ name: "perf/halfDone", components: { write: [done] } }, item => { +const halfDone = System.create({ components: { with: [done] } }, item => { item[done] = item.entity % 2 === 0; }); @@ -167,9 +167,8 @@ const benchmarkApp = async (n: number): Promise => { /** The filter not kept: an attribute of the list, which a style sheet selects rows by. */ const markList = System.create({ - name: "perf/markList", observer: true, - resources: { read: [filter], changed: [filter], is: [list] }, + resources: { changed: [filter], is: [list] }, }, ({ resources }) => { resources[list].dataset.filter = resources[filter]; }); diff --git a/packages/ecs/src/samples/todo/features/completion/data.ts b/packages/ecs/src/samples/todo/features/completion/data.ts index e39abcb6c..3e41e3f10 100644 --- a/packages/ecs/src/samples/todo/features/completion/data.ts +++ b/packages/ecs/src/samples/todo/features/completion/data.ts @@ -3,4 +3,4 @@ import { Index } from "../../../../index.js"; import { done, todo } from "../todos/data.js"; /** Todos by whether they're done: its lists' lengths are the counts, kept as todos change. */ -export const byDone = Index.create({ name: "completion/byDone", key: [done], components: { has: [todo] } }); +export const byDone = Index.create({ key: [done], components: { with: [todo] } }); diff --git a/packages/ecs/src/samples/todo/features/completion/dom.ts b/packages/ecs/src/samples/todo/features/completion/dom.ts index d7cef8b2c..dccec882f 100644 --- a/packages/ecs/src/samples/todo/features/completion/dom.ts +++ b/packages/ecs/src/samples/todo/features/completion/dom.ts @@ -1,12 +1,12 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Resource, System } from "../../../../index.js"; +import { System } from "../../../../index.js"; import { byDone } from "./data.js"; import { TodoSummary } from "./elements/todo-summary.js"; import { TodoToggleAll } from "./elements/todo-toggle-all.js"; /** The page's summary and toggle-all checkbox; null until the page provides them. */ -export const summary = Symbol("summary"); -export const toggleAllBox = Symbol("toggleAllBox"); +export const summary = Symbol(); +export const toggleAllBox = Symbol(); declare module "@adobe/ecs" { interface Resources { @@ -15,15 +15,11 @@ declare module "@adobe/ecs" { } } -Resource.register(summary, { thread: "main" }); -Resource.register(toggleAllBox, { thread: "main" }); - /** * Writes the counts, read off the index's lists, and whether all are done, after a pass in which a todo was added, * removed or toggled: the index's entries changing is its only change filter. */ export const bindCompletion = System.create({ - name: "completion/bindCompletion", observer: true, resources: { is: [summary, toggleAllBox] }, lookup: { byDone: { index: byDone, changed: true } }, diff --git a/packages/ecs/src/samples/todo/features/completion/model.test.ts b/packages/ecs/src/samples/todo/features/completion/model.test.ts index f7392c62c..23754fcfb 100644 --- a/packages/ecs/src/samples/todo/features/completion/model.test.ts +++ b/packages/ecs/src/samples/todo/features/completion/model.test.ts @@ -8,7 +8,7 @@ import { clearCompleted, toggleAll } from "./model.js"; let writes = 0; /** Counts the todos written since it last ran. */ -const countWrites = System.create({ name: "test/countWrites", observer: true, components: { has: [done], changed: [done] } }, () => { +const countWrites = System.create({ observer: true, components: { with: [done], changed: [done] } }, () => { writes += 1; }); diff --git a/packages/ecs/src/samples/todo/features/completion/model.ts b/packages/ecs/src/samples/todo/features/completion/model.ts index 68299fbcd..5b1ce1699 100644 --- a/packages/ecs/src/samples/todo/features/completion/model.ts +++ b/packages/ecs/src/samples/todo/features/completion/model.ts @@ -5,16 +5,13 @@ import { byDone } from "./data.js"; /** Marks every todo done, or every one not done. Only todos that change are written. */ export const toggleAll = System.create({ - name: "completion/toggleAll", - input: { type: "object", properties: { done: { type: "boolean" } } }, - components: { write: [done], has: [todo] }, -}, (row, { input }) => { + components: { with: [done, todo] }, +}, (row, _context, input: { readonly done: boolean }) => { if (row[done] !== input.done) row[done] = input.done; }); /** Deletes the todos that are done. */ export const clearCompleted = System.create({ - name: "completion/clearCompleted", lookup: { byDone: { index: byDone } }, }, ({ lookup, commands }) => { const completed = lookup.byDone(true); diff --git a/packages/ecs/src/samples/todo/features/filter/data.ts b/packages/ecs/src/samples/todo/features/filter/data.ts index e03a20631..4ad3f3989 100644 --- a/packages/ecs/src/samples/todo/features/filter/data.ts +++ b/packages/ecs/src/samples/todo/features/filter/data.ts @@ -1,17 +1,13 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Resource } from "../../../../index.js"; export type Filter = "all" | "active" | "completed"; -export const filterSchema = { anyOf: [{ const: "all" }, { const: "active" }, { const: "completed" }] } as const; - /** Which todos the list shows: all, those not done, or those done. */ -export const filter = Symbol("filter"); +export const filter = Symbol(); declare module "@adobe/ecs" { interface Resources { - [filter]: { type: Filter }; + [filter]: { type: Filter; default: "all" }; } } -Resource.register(filter, { type: { ...filterSchema, default: "all" } }); diff --git a/packages/ecs/src/samples/todo/features/filter/dom.ts b/packages/ecs/src/samples/todo/features/filter/dom.ts index c4a0817e0..345d1b331 100644 --- a/packages/ecs/src/samples/todo/features/filter/dom.ts +++ b/packages/ecs/src/samples/todo/features/filter/dom.ts @@ -1,5 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Resource, System } from "../../../../index.js"; +import { System } from "../../../../index.js"; import { done } from "../todos/data.js"; import { row } from "../todos/dom.js"; import { TodoRow } from "../todos/elements/todo-row.js"; @@ -7,7 +7,7 @@ import { filter, type Filter } from "./data.js"; import { TodoFilters } from "./elements/todo-filters.js"; /** The page's filter buttons; null until the page provides them. */ -export const filters = Symbol("filters"); +export const filters = Symbol(); declare module "@adobe/ecs" { interface Resources { @@ -15,8 +15,6 @@ declare module "@adobe/ecs" { } } -Resource.register(filters, { thread: "main" }); - /** Whether the filter leaves a todo out. */ const leavesOut = (chosen: Filter, isDone: boolean): boolean => chosen === "completed" ? !isDone : chosen === "active" && isDone; @@ -40,10 +38,9 @@ const focusNearest = (hiding: Element, chosen: Filter): void => { * README). */ export const filterRows = System.create({ - name: "filter/filterRows", observer: true, - components: { read: [done, row], changed: [done, row] }, - resources: { read: [filter], changed: [filter] }, + components: { with: [done, row], changed: [done, row] }, + resources: { changed: [filter] }, }, (item, { resources }) => { const element = item[row]; const hidden = leavesOut(resources[filter], item[done]); @@ -53,9 +50,8 @@ export const filterRows = System.create({ }); export const bindFilters = System.create({ - name: "filter/bindFilters", observer: true, - resources: { read: [filter], changed: [filter], is: [filters] }, + resources: { changed: [filter], is: [filters] }, }, ({ resources }) => { resources[filters].filter = resources[filter]; }); diff --git a/packages/ecs/src/samples/todo/features/filter/model.test.ts b/packages/ecs/src/samples/todo/features/filter/model.test.ts index 2eb763bad..d1f82a763 100644 --- a/packages/ecs/src/samples/todo/features/filter/model.test.ts +++ b/packages/ecs/src/samples/todo/features/filter/model.test.ts @@ -5,7 +5,7 @@ import { filter, type Filter } from "./data.js"; import { setFilter } from "./model.js"; let seen: Filter | null = null; -const readFilter = System.create({ name: "test/readFilter", observer: true, resources: { read: [filter] } }, ({ resources }) => { +const readFilter = System.create({ observer: true }, ({ resources }) => { seen = resources[filter]; }); diff --git a/packages/ecs/src/samples/todo/features/filter/model.ts b/packages/ecs/src/samples/todo/features/filter/model.ts index 0c3305b0c..009bf7daa 100644 --- a/packages/ecs/src/samples/todo/features/filter/model.ts +++ b/packages/ecs/src/samples/todo/features/filter/model.ts @@ -1,11 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { System } from "../../../../index.js"; -import { filter, filterSchema } from "./data.js"; +import { filter, type Filter } from "./data.js"; -export const setFilter = System.create({ - name: "filter/set", - input: { type: "object", properties: { filter: filterSchema } }, - resources: { write: [filter] }, -}, ({ input, resources }) => { +export const setFilter = System.create({}, ({ resources }, input: { readonly filter: Filter }) => { resources[filter] = input.filter; }); diff --git a/packages/ecs/src/samples/todo/features/generate/model.ts b/packages/ecs/src/samples/todo/features/generate/model.ts index d0ef7b1eb..3739eed5b 100644 --- a/packages/ecs/src/samples/todo/features/generate/model.ts +++ b/packages/ecs/src/samples/todo/features/generate/model.ts @@ -9,10 +9,8 @@ export const generatedLabel = (n: number): string => `${tasks[n % tasks.length]} /** Adds `count` todos with generated labels at the end of the list. */ export const addGenerated = System.create({ - name: "generate/add", - input: { type: "object", properties: { count: { type: "integer", minimum: 0 } } }, - lookup: { ordered: { index: ordered, read: [order] } }, -}, ({ input, lookup, commands }) => { + lookup: { ordered: { index: ordered } }, +}, ({ lookup, commands }, input: { readonly count: number }) => { const all = lookup.ordered(); const first = orderAfter(all); const spawn = commands.spawn(Todo); diff --git a/packages/ecs/src/samples/todo/features/todos/data.ts b/packages/ecs/src/samples/todo/features/todos/data.ts index 8a59ecc61..a491e5a84 100644 --- a/packages/ecs/src/samples/todo/features/todos/data.ts +++ b/packages/ecs/src/samples/todo/features/todos/data.ts @@ -1,12 +1,12 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Archetype, Component, Index } from "../../../../index.js"; +import { Index } from "../../../../index.js"; /** A todo's text. */ -export const label = Symbol("label"); -export const done = Symbol("done"); +export const label = Symbol(); +export const done = Symbol(); /** Where a todo sits in the list: todos are listed by ascending order. */ -export const order = Symbol("order"); -export const todo = Symbol("todo"); +export const order = Symbol(); +export const todo = Symbol(); declare module "@adobe/ecs" { interface Components { @@ -17,15 +17,10 @@ declare module "@adobe/ecs" { } } -Component.register(label, { type: { type: "string" } }); -Component.register(done, { type: { type: "boolean" } }); -Component.register(order, { type: { type: "number" } }); -Component.register(todo, { type: { const: true } }); - -export const Todo = Archetype.create([todo, label, done, order]); +export const Todo = [todo, label, done, order] as const; /** Every todo, in list order. */ -export const ordered = Index.create({ name: "todos/ordered", key: [order], sorted: true, components: { has: [todo] } }); +export const ordered = Index.create({ key: [order], sorted: true, components: { with: [todo] } }); /** The order just after the last of `ordered`'s todos, as a lookup through it gives them: 0 for none. */ export const orderAfter = (todos: { readonly length: number; readonly at: (index: number) => { readonly [order]: number } }): number => diff --git a/packages/ecs/src/samples/todo/features/todos/dom.browser.test.ts b/packages/ecs/src/samples/todo/features/todos/dom.browser.test.ts index 3e7770db2..00c8432a7 100644 --- a/packages/ecs/src/samples/todo/features/todos/dom.browser.test.ts +++ b/packages/ecs/src/samples/todo/features/todos/dom.browser.test.ts @@ -29,7 +29,7 @@ const createDriven = async (): Promise<{ readonly world: World; readonly element const rowsOf = (element: TodoList): TodoRow[] => [...element.querySelectorAll("todo-row")]; let rows = 0; -const countRows = System.create({ name: "test/countRows", observer: true, components: { has: [row] } }, () => { +const countRows = System.create({ observer: true, components: { with: [row] } }, () => { rows += 1; }); diff --git a/packages/ecs/src/samples/todo/features/todos/dom.ts b/packages/ecs/src/samples/todo/features/todos/dom.ts index f240eb22e..bf28bac35 100644 --- a/packages/ecs/src/samples/todo/features/todos/dom.ts +++ b/packages/ecs/src/samples/todo/features/todos/dom.ts @@ -1,14 +1,14 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Component, Finalizer, Index, Resource, System } from "../../../../index.js"; +import { Finalizer, Index, System } from "../../../../index.js"; import { attach } from "../../shared/attach.js"; import { done, label, order, todo } from "./data.js"; import { TodoList } from "./elements/todo-list.js"; import { TodoRow } from "./elements/todo-row.js"; /** The row showing a todo. */ -export const row = Symbol("row"); +export const row = Symbol(); /** The page's list, which rows are placed in; null until the page provides it. */ -export const list = Symbol("list"); +export const list = Symbol(); declare module "@adobe/ecs" { interface Components { @@ -19,19 +19,15 @@ declare module "@adobe/ecs" { } } -Component.register(row, { thread: "main" }); -Resource.register(list, { thread: "main" }); - /** Todos with rows, in list order. */ -const placed = Index.create({ name: "todos/placed", key: [order], sorted: true, components: { has: [todo, row] } }); +const placed = Index.create({ key: [order], sorted: true, components: { with: [todo, row] } }); /** * Gives each new todo a row, attached to it for events from the row, while the page has a list for it. It isn't placed * until the row is the todo's, so a todo gone first never shows one. */ export const createRows = System.create({ - name: "todos/createRows", - components: { has: [todo], not: [row] }, + components: { with: [todo], not: [row] }, resources: { is: [list] }, }, (item, { commands }) => { commands.add(item.entity, row, attach(document.createElement("todo-row"), item.entity)); @@ -39,9 +35,8 @@ export const createRows = System.create({ /** Writes what changed of each todo to its row. */ export const bindRows = System.create({ - name: "todos/bindRows", observer: true, - components: { read: [label, done, row], changed: [label, done, row] }, + components: { with: [label, done, row], changed: [label, done, row] }, }, item => { const element = item[row]; element.label = item[label]; @@ -69,12 +64,11 @@ const place = (parent: Element, element: HTMLElement, before: Element | null): v * its focus. */ export const placeRows = System.create({ - name: "todos/placeRows", observer: true, - components: { read: [row, order], changed: [row, order] }, + components: { with: [row, order], changed: [row, order] }, order: placed, resources: { is: [list] }, - lookup: { placed: { index: placed, read: [row] } }, + lookup: { placed: { index: placed } }, }, (item, { resources, lookup }) => { const element = item[row]; const parent = resources[list]; @@ -83,7 +77,7 @@ export const placeRows = System.create({ if (before !== element) place(parent, element, before); }); -Finalizer.create({ name: "todos/removeRows", components: { read: [row] } }, item => { +Finalizer.create({ components: { with: [row] } }, item => { item[row].remove(); }); diff --git a/packages/ecs/src/samples/todo/features/todos/elements/elements.browser.test.ts b/packages/ecs/src/samples/todo/features/todos/elements/elements.browser.test.ts index fcf27d655..2e70a0fd4 100644 --- a/packages/ecs/src/samples/todo/features/todos/elements/elements.browser.test.ts +++ b/packages/ecs/src/samples/todo/features/todos/elements/elements.browser.test.ts @@ -113,7 +113,6 @@ describe("todo-row", () => { }); }); - describe("todo-list", () => { type Row = HTMLElementTagNameMap["todo-row"]; @@ -180,7 +179,6 @@ describe("todo-list", () => { }); }); - describe("todo-entry", () => { it("dispatches todo-add on Enter and clears itself, ignoring blank text; Escape clears", () => { const entry = document.createElement("todo-entry"); diff --git a/packages/ecs/src/samples/todo/features/todos/model.ts b/packages/ecs/src/samples/todo/features/todos/model.ts index df8ef70ce..c5d015798 100644 --- a/packages/ecs/src/samples/todo/features/todos/model.ts +++ b/packages/ecs/src/samples/todo/features/todos/model.ts @@ -2,48 +2,39 @@ import { Entity, System } from "../../../../index.js"; import { Todo, done, label, order, orderAfter, ordered, todo } from "./data.js"; -const text = { type: "string" } as const; - /** Adds a todo at the end of the list, with its label trimmed; a blank label adds nothing. */ export const addTodo = System.create({ - name: "todos/add", - input: { type: "object", properties: { label: text } }, - lookup: { ordered: { index: ordered, read: [order] } }, -}, ({ input, lookup, commands }) => { + lookup: { ordered: { index: ordered } }, +}, ({ lookup, commands }, input: { readonly label: string }) => { const trimmed = input.label.trim(); if (trimmed === "") return; commands.spawn(Todo)({ [label]: trimmed, [done]: false, [order]: orderAfter(lookup.ordered()) }); }); -const one = { entity: Entity.schema } as const; +/** The todo a system on one acts on. */ +type One = { readonly entity: Entity }; export const toggleTodo = System.create({ - name: "todos/toggle", - input: { type: "object", properties: one }, target: "entity", - components: { write: [done], has: [todo] }, -}, row => { + components: { with: [done, todo] }, +}, (row, _context, _input: One) => { row[done] = !row[done]; }); /** Gives a todo a new label, trimmed; a blank one deletes it. */ export const editTodo = System.create({ - name: "todos/edit", - input: { type: "object", properties: { ...one, label: text } }, target: "entity", - components: { write: [label], has: [todo] }, -}, (row, { input, commands }) => { + components: { with: [label, todo] }, +}, (row, { commands }, input: One & { readonly label: string }) => { const trimmed = input.label.trim(); if (trimmed === "") commands.despawn(row.entity); else row[label] = trimmed; }); export const deleteTodo = System.create({ - name: "todos/delete", - input: { type: "object", properties: one }, target: "entity", - components: { has: [todo] }, -}, (row, { commands }) => { + components: { with: [todo] }, +}, (row, { commands }, _input: One) => { commands.despawn(row.entity); }); @@ -52,10 +43,8 @@ export const deleteTodo = System.create({ * neighbours'. When no number lies between them, every todo is numbered again. */ export const moveTodo = System.create({ - name: "todos/move", - input: { type: "object", properties: { entity: Entity.schema, before: Entity.schema } }, - lookup: { todos: { read: [order], has: [todo] }, ordered: { index: ordered, read: [order] } }, -}, ({ input, lookup, commands }) => { + lookup: { todos: { with: [order, todo] }, ordered: { index: ordered } }, +}, ({ lookup, commands }, input: { readonly entity: Entity; readonly before: Entity }) => { const { entity, before } = input; if (entity === before || lookup.todos(entity) === null) return; const next = before === Entity.none ? null : lookup.todos(before); diff --git a/packages/ecs/src/samples/todo/features/todos/todos.test-helper.ts b/packages/ecs/src/samples/todo/features/todos/todos.test-helper.ts index a4d9bc25c..a0125a462 100644 --- a/packages/ecs/src/samples/todo/features/todos/todos.test-helper.ts +++ b/packages/ecs/src/samples/todo/features/todos/todos.test-helper.ts @@ -7,7 +7,7 @@ import { addTodo } from "./model.js"; export type Listed = { readonly entity: Entity; readonly label: string; readonly done: boolean; readonly order: number }; const listed: Listed[] = []; -const readTodos = System.create({ name: "test/readTodos", observer: true, components: { read: [label, done, order] }, order: ordered }, row => { +const readTodos = System.create({ observer: true, components: { with: [label, done, order] }, order: ordered }, row => { listed.push({ entity: row.entity, label: row[label], done: row[done], order: row[order] }); }); diff --git a/packages/ecs/src/samples/todo/shared/attach.browser.test.ts b/packages/ecs/src/samples/todo/shared/attach.browser.test.ts index 3cbf2f5de..2cb826fa9 100644 --- a/packages/ecs/src/samples/todo/shared/attach.browser.test.ts +++ b/packages/ecs/src/samples/todo/shared/attach.browser.test.ts @@ -11,7 +11,7 @@ afterEach(() => { const twoEntities = async (): Promise => { const spawned: Entity[] = []; await using world = World.create({ [workers]: 0 }); - await world.run(System.create({ name: "attachTest/spawn" }, ({ commands }) => { + await world.run(System.create({}, ({ commands }) => { spawned.push(commands.spawn([])({}), commands.spawn([])({})); })); const [first, second] = spawned; diff --git a/packages/ecs/src/samples/todo/shared/ticks.test-helper.ts b/packages/ecs/src/samples/todo/shared/ticks.test-helper.ts index f155b7966..6adaac99f 100644 --- a/packages/ecs/src/samples/todo/shared/ticks.test-helper.ts +++ b/packages/ecs/src/samples/todo/shared/ticks.test-helper.ts @@ -4,7 +4,7 @@ import { System, clock, type World } from "../../../index.js"; const waiting: (() => void)[] = []; /** Passes after each tick, the only passes that change the clock, and on its first. */ -const ticked = System.create({ name: "test/ticked", observer: true, resources: { changed: [clock] } }, () => { +const ticked = System.create({ observer: true, resources: { changed: [clock] } }, () => { for (const resolve of waiting.splice(0)) resolve(); }); diff --git a/packages/ecs/src/samples/transfers/README.md b/packages/ecs/src/samples/transfers/README.md new file mode 100644 index 000000000..845a9ffcf --- /dev/null +++ b/packages/ecs/src/samples/transfers/README.md @@ -0,0 +1,35 @@ +# Transfers sample + +Shows how values that can't be copied between threads reach the thread that uses them. A canvas and decoded textures +are moved, not copied, to the world's worker, which draws sprites with them every frame. Components and resources +holding such values are registered with `transfer: true`, and a run's input carries them to the world's thread. + +`pnpm dev`, then the Transferred Sprites tab. Numbered discs appear one at a time, 48 in all, and bounce around the canvas; +the overlay counts the sprites and frames. + +## Threads + +- **Main** (`main.ts`) owns the page. It starts both workers, hands each a port to the world, and joins the world. It + moves its `` to the world's thread with `transferControlToOffscreen()`, as the input of a run of + `attachSurface`, and adds `show`, an observer system that writes the counts into the overlay after each frame that + changes them. +- **The world's worker** (`world.ts`) creates the world, which ticks every frame, and shares it on the ports main + sends. The canvas becomes its `surface` resource. +- **The loader's worker** (`loader.ts`) joins the world and makes one image every 120 ms: it draws a disc of its own + hue, encodes it as a PNG, decodes it to an `ImageBitmap` and runs `addSprite` with it. The bitmap is detached in the + loader once the run is posted. + +## Data and systems + +`sprites.ts` holds them, and every thread imports it: + +- `texture`, a component registered with `transfer: true`, holds a sprite's `ImageBitmap`; `position` and `velocity` + are `Vec2`s, in pixels and pixels per second. +- `surface`, a resource registered with `transfer: true`, holds the `OffscreenCanvas`; `stats` counts sprites and + frames. +- `attachSurface` and `addSprite` take what main and the loader move in their input. `frame`, the composite the world + adds, clears the canvas, moves each sprite, bouncing off the edges, and draws each one's texture where it is. + +## Tests + +`build.test.ts`: the sample builds as an application, with the world's worker and the loader's. diff --git a/packages/ecs/src/samples/transfers/main.ts b/packages/ecs/src/samples/transfers/main.ts index d88ef229e..0c10e5bc4 100644 --- a/packages/ecs/src/samples/transfers/main.ts +++ b/packages/ecs/src/samples/transfers/main.ts @@ -15,7 +15,7 @@ const overlay = (text: string): void => { let latest = { sprites: 0, frames: 0 }; // Added on this thread, it runs here after each of the world's frames that changed stats. -const show = System.create({ name: "transferSprites/show", observer: true, resources: { read: [stats], changed: [stats] } }, ({ resources }) => { +const show = System.create({ observer: true, resources: { changed: [stats] } }, ({ resources }) => { const { sprites, frames } = resources[stats]; latest = { sprites, frames }; overlay(`${sprites} sprites, each a texture the loader decoded and moved to the world's worker · frame ${frames}`); diff --git a/packages/ecs/src/samples/transfers/sprites.ts b/packages/ecs/src/samples/transfers/sprites.ts index 33e5f479a..48b4e4b31 100644 --- a/packages/ecs/src/samples/transfers/sprites.ts +++ b/packages/ecs/src/samples/transfers/sprites.ts @@ -1,15 +1,15 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Component, Resource, System, Vec2, clock } from '../../index.js'; +import { clock, System, type U32, Vec2 } from '../../index.js'; // The transfers sample's keys and systems, which every thread imports: the world, on a worker, draws sprites into the // canvas main moved to it, each with a texture a loader worker decoded and moved to it. Values move through runs whose -// input holds them; an input field's schema `{ allOf: [] }` takes any value, typed `unknown`, which bodies narrow. +// input holds them. -export const texture = Symbol("transferSprites/texture"); -export const position = Symbol("transferSprites/position"); -export const velocity = Symbol("transferSprites/velocity"); -export const surface = Symbol("transferSprites/surface"); -export const stats = Symbol("transferSprites/stats"); +export const texture = Symbol(); +export const position = Symbol(); +export const velocity = Symbol(); +export const surface = Symbol(); +export const stats = Symbol(); declare module '@adobe/ecs' { interface Components { @@ -19,26 +19,17 @@ declare module '@adobe/ecs' { } interface Resources { [surface]: { type: OffscreenCanvas | null; transfer: true }; - [stats]: { type: { readonly sprites: number; readonly frames: number } }; + [stats]: { type: { readonly sprites: U32; readonly frames: U32 }; default: { sprites: 0; frames: 0 } }; } } /** A decoded texture, moved to the world's thread, where sprites are drawn. */ -Component.register(texture, { transfer: true }); /** In pixels, and pixels per second. */ -Component.register(position, { type: Vec2.schema }); -Component.register(velocity, { type: Vec2.schema }); /** The canvas main moved to the world's thread. */ -Resource.register(surface, { transfer: true }); -Resource.register(stats, { - type: { type: "object", properties: { sprites: { type: "integer" }, frames: { type: "integer" } }, default: { sprites: 0, frames: 0 } }, -}); - -const any = { allOf: [] } as const; /** From main: the canvas the world draws into. */ -export const attachSurface = System.create({ name: "transferSprites/attachSurface", input: { type: "object", properties: { canvas: any } }, resources: { write: [surface] } }, ({ input, resources }) => { - if (typeof OffscreenCanvas === "function" && input.canvas instanceof OffscreenCanvas) resources[surface] = input.canvas; +export const attachSurface = System.create({}, ({ resources }, input: { readonly canvas: OffscreenCanvas }) => { + resources[surface] = input.canvas; }); /** A deterministic number in [0, 1) for each seed. */ @@ -48,11 +39,9 @@ const random = (seed: number): number => { }; /** From the loader: a sprite with the texture it decoded, somewhere on the canvas, moving. */ -export const addSprite = System.create({ - name: "transferSprites/addSprite", input: { type: "object", properties: { bitmap: any, seed: { type: "integer" } } }, resources: { read: [surface], write: [stats] }, -}, ({ input, resources, commands }) => { +export const addSprite = System.create({}, ({ resources, commands }, input: { readonly bitmap: ImageBitmap; readonly seed: number }) => { const canvas = resources[surface]; - if (canvas === null || typeof ImageBitmap !== "function" || !(input.bitmap instanceof ImageBitmap)) return; + if (canvas === null) return; const angle = random(input.seed) * Math.PI * 2; const speed = 60 + random(input.seed + 1) * 120; commands.spawn([texture, position, velocity])({ @@ -64,7 +53,7 @@ export const addSprite = System.create({ }); /** Clears the canvas, once a frame. */ -const clear = System.create({ name: "transferSprites/clear", resources: { read: [surface], write: [stats] } }, ({ resources }) => { +const clear = System.create({}, ({ resources }) => { const canvas = resources[surface]; canvas?.getContext("2d")?.clearRect(0, 0, canvas.width, canvas.height); resources[stats] = { sprites: resources[stats].sprites, frames: resources[stats].frames + 1 }; @@ -72,7 +61,7 @@ const clear = System.create({ name: "transferSprites/clear", resources: { read: /** Moves each sprite, bouncing off the canvas's edges. */ const move = System.create({ - name: "transferSprites/move", components: { read: [texture], write: [position, velocity] }, resources: { read: [surface, clock] }, + components: { with: [texture, position, velocity] }, }, (row, { resources }) => { const canvas = resources[surface]; if (canvas === null) return; @@ -88,10 +77,10 @@ const move = System.create({ }); /** Draws each sprite's texture where it is. */ -const draw = System.create({ name: "transferSprites/draw", components: { read: [texture, position] }, resources: { read: [surface] }, after: [clear, move] }, (row, { resources }) => { +const draw = System.create({ components: { with: [texture, position] }, after: [clear, move] }, (row, { resources }) => { const [x, y] = row[position]; resources[surface]?.getContext("2d")?.drawImage(row[texture], x, y); }); /** Every frame on the world's thread. */ -export const frame = System.create({ name: "transferSprites/frame", systems: [clear, move, draw] }); +export const frame = System.create({ systems: [clear, move, draw] }); diff --git a/packages/ecs/src/world.test.ts b/packages/ecs/src/world.test.ts index ddece40fc..97c0bfe2b 100644 --- a/packages/ecs/src/world.test.ts +++ b/packages/ecs/src/world.test.ts @@ -1,6 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, expect, it } from "vitest"; -import { Component, Resource, System, World, workers } from "./index.js"; +import { System, World, workers } from "./index.js"; const count = Symbol("worldTest/count"); const total = Symbol("worldTest/total"); @@ -12,12 +12,10 @@ declare module "@adobe/ecs" { [total]: { type: number }; } } -Component.register(count, { type: { type: "number" } }); -Resource.register(total, { type: { type: "number", default: 0 } }); -const spawn = System.create({ name: "worldTest/spawn" }, ({ commands }) => { commands.spawn([count])({ [count]: 1 }); }); -const raise = System.create({ name: "worldTest/raise", resources: { write: [total] } }, ({ resources }) => { resources[total] = 7; }); -const add = System.create({ name: "worldTest/add", components: { write: [count] } }, row => { row[count] += 1; }); +const spawn = System.create({}, ({ commands }) => { commands.spawn([count])({ [count]: 1 }); }); +const raise = System.create({}, ({ resources }) => { resources[total] = 7; }); +const add = System.create({ components: { with: [count] } }, row => { row[count] += 1; }); describe("World.create", () => { it("creates a world from the package root, the one import that names everything else", async () => { @@ -25,7 +23,7 @@ describe("World.create", () => { await world.run(spawn); await world.run(add); let seen = 0; - await world.run(System.create({ observer: true, components: { read: [count] } }, row => { seen = row[count]; })); + await world.run(System.create({ observer: true, components: { with: [count] } }, row => { seen = row[count]; })); expect(seen).toBe(2); }); it("rejects a key that isn't a resource, as worlds the implementation creates do", () => { @@ -41,7 +39,7 @@ describe("World.join", () => { await using joined = World.join(channel.port2); await joined.run(raise); let seen = 0; - await world.run(System.create({ observer: true, resources: { read: [total] } }, ({ resources }) => { seen = resources[total]; })); + await world.run(System.create({ observer: true }, ({ resources }) => { seen = resources[total]; })); expect(seen).toBe(7); }); }); diff --git a/packages/ecs/threads.md b/packages/ecs/threads.md index 2031ffb18..8f9f8268e 100644 --- a/packages/ecs/threads.md +++ b/packages/ecs/threads.md @@ -32,14 +32,16 @@ Referenced from `design.md`. never wait on another thread. - **Objects default to `world`.** A declaration without `thread` is on `world`; one with it names a logical thread. Libraries declare threads on components; the application decides which joined thread fills each. -- **The DOM rule:** a DOM `Node` lives on `main`, the window's thread, and its declaration says so: the types require - `thread: "main"` where the DOM library is loaded, and registration checks it on values it can see. +- **The DOM rule:** a DOM `Node` lives on `main`, the window's thread, and its declaration says so: the build requires + `thread: "main"` for a type that's one of the DOM's classes or extends one, and registration checks it on values it + can see. - **Keys are symbols,** so components, resources and traits never collide, but a symbol is a different value on each thread. Threads agree on keys through the ids the plugin gives top-level `Symbol(...)` declarations, by which the registry already takes the symbols of one id as one key; a key without one can't cross, so a joined thread's observer - system naming one fails to register. Ids name packages, not directories, so threads bundled apart agree on them, and - the plugin adds itself to `worker.plugins`, so worker bundles' keys get them too. -- **Systems, rules and finalizers are their values,** and names are optional labels, so a value is different on each + system naming one fails to register. Ids name packages and their versions, not directories, so threads bundled apart + agree on them, and the plugin adds itself to `worker.plugins`, so worker bundles' keys get them too. Every declared + key has one, since a declaration names only a top-level `Symbol()` constant. +- **Systems, rules and finalizers are their values,** labeled by their bindings, so a value is different on each thread too. Threads agree on them through the ids the plugin gives top-level `System.create`, `System.render`, `Rule.create`, `Finalizer.create` and `Finalizer.changes` bindings, composites included, by which the registry already takes values of one id as one item. A threaded world rejects an item without one where it crosses threads: @@ -51,7 +53,7 @@ Referenced from `design.md`. the id once by message the first time; the world maps (thread, number) to its own. The world holds runs whose slots use a number it can't map yet, in order, until that message arrives. A run of an id the world never imported rejects. -- **Archetypes are lists of keys.** The index `Archetype.create` gives a list is for its own thread's lookups; +- **Archetypes are lists of keys.** The index the build or a world gives a list is for its own thread's lookups; threads agree on an archetype through the world's numbers for its components. Commands and staged spawn batches carry those numbers, never archetype indexes. - **Inline is the same layout.** With every logical thread filled by the world's thread, storage and code paths are @@ -66,20 +68,17 @@ export interface Threads { main: true; // the window's thread, where the DOM is } -// `thread` only on objects: a struct with `thread` fails in types and at registration. +// `thread` only on objects: a struct with `thread` fails at registration. The build registers each declaration, a +// value that isn't data without a schema. declare module "@adobe/ecs" { interface Components { - // A DOM Node lives on "main", and says so; the check resolves to no requirement where the DOM library isn't - // loaded. + // A DOM Node lives on "main", and says so; the build requires it. [domNode]: { type: HTMLElement; thread: "main" }; // `transfer: true` requires a Transferable type: ArrayBuffer, ImageBitmap, VideoFrame, OffscreenCanvas, // MessagePort. It lives on `world`, where the GPU uploads it; a loader thread sets it by moving the value there. [textureSource]: { type: ImageBitmap; transfer: true }; } } -// A value that isn't data is registered without a schema. -Component.register(domNode, { thread: "main" }); -Component.register(textureSource, { transfer: true }); // On the thread that holds the world: const world = World.create(resources?); @@ -101,10 +100,10 @@ of the browser or node, and a joined thread's `dispose` closes only a `MessagePo All derived from declarations and, for terminal systems, the plugin's analysis of their bodies; none is a flag. -- **A system's thread** is the thread of every object component and object resource it names in `read`, `write`, - `added`, `changed`, `lookup`, `up` and resource `is`, and the key parts of the indexes it looks up through or orders - its rows by. Strings and structs don't place it. Components in `has` and `not` are structural, and read a shared - presence word, so they don't place it. With no objects, a kernel runs on the pool and any other system on `world`. +- **A system's thread** is the thread of every object component and object resource its body reads or writes, as + the build infers, of those it filters on by `added` and `changed`, of resource `is`, and the key parts of the + indexes it looks up through or orders its rows by. Strings and structs don't place it. Components it only requires, + in `with`, and those in `not` are structural, and read a shared presence word, so they don't place it. With no objects, a kernel runs on the pool and any other system on `world`. Render systems and `kernel: "gpu"` systems are on `world`, with `gpuDevice`. - **Errors when declared:** objects of two threads in one system or finalizer, in types and when it's created, which registers it. **Errors when joining:** a thread claimed twice; a claimed thread whose systems or finalizers that @@ -181,9 +180,10 @@ All derived from declarations and, for terminal systems, the plugin's analysis o - **Archetype records:** the table holds, by the world's archetype number, the offset of that archetype's record. Each record is allocated once, since an archetype's storages are fixed, and never moves; only the table grows. A record holds `capacity`, the `entities` offset, a version, its flags and its count, and per storage its component number, its - flags (`ticked`, linear, remote with the joined thread's number), its column, tick and chunk-tick offsets and its - chunk count; a remote storage's column is its tags. Offsets are `Uint32`, since memory reaches 4 GiB. The resource - archetype has a record too, flagged, and the header holds the resource entity's index. + flags (`ticked`, linear, remote with the joined thread's number), its column, tick and chunk-tick offsets, its + chunk count and its block-tick offsets, sixteen blocks a chunk; a remote storage's column is its tags. Offsets are + `Uint32`, since memory reaches 4 GiB. The resource archetype has a record too, flagged, and the header holds the + resource entity's index, and a count of flushes that wrote an existing archetype's record again. - **Entity-table record:** the `cursor` offset, which never moves, the free list's offset and capacity, the `archetype`, `row` and `generation` offsets and capacity, and a version. The table's length is `cursor[2]`. Its arrays move whenever they grow, even without memory growing, since a grown array's old block is reused. @@ -195,7 +195,9 @@ All derived from declarations and, for terminal systems, the plugin's analysis o - **Reads:** a thread checks a record's version, one load, before using what it cached for that archetype, and keeps per-archetype column views and kernel columns, rebuilt only when that version moves. The whole-buffer view serves the header and records; columns stay views of their own, as kernels and layouts take them. On an entity-table version - change, a worker updates the `entities` object its spawners hold in place. + change, a worker updates the `entities` object its spawners hold in place. A joined thread's index lookups check + that count once a run instead, with its mirror's archetypes and its own views (memory grown, keys named): rows they + gave stay bound while none moved, and are bound again to the views they find otherwise, a rebuilt mirror's included. - **Messages:** only new components' schemas are posted, none for a value that isn't data, since a message closes the pool's windows. A new archetype is found in the table, its record naming its component numbers, the first time a task names it. @@ -255,14 +257,17 @@ only on node, so browser bundles never load them, and the node worker's url isn' so a browser build bundles one worker (`worker-platform.ts`). Death is a `Worker`'s `error` or `messageerror`, a node worker's `error`, `messageerror` or `exit`, or, for a lent worker, what its owner reports or either `MessagePort`'s `close` where the platform has it, whichever comes first; nothing polls, so a quiet world does no work. A browser -`Worker` ended from outside its port reports nothing, so a lent worker's owner reports the end of one it ends. +`Worker` ended from outside its port reports nothing, so a lent worker's owner reports the end of one it ends, before +ending it. The worker's port closing is reported only once the owner answers a `closed` on its channel, which comes +after anything it reported first, so an owner's report wins over the close its ending causes. - **Platforms:** a world's pool comes from a platform, `{ count, start }`: `count` is what the `workers` resource's `null` resolves to, its hardware threads, `navigator.hardwareConcurrency` or `os.availableParallelism()`, minus the world's thread, and `start` starts one worker, or returns null when it has no more, so a pool takes as many as it gets. The platform's own is browser workers where `Worker` exists, nested ones on a worker, else `worker_threads`, else none. `WorldOptions`' `pool` gives another, or null for none: `lentWorkers(lent)` for workers another thread - lent. + lent. A world starts its pool once an archetype some kernel matches holds half a piece of rows for a worker, as + the fastest kernels split, so a small world starts none. - **Lending:** the owning thread starts each worker, posts it the `MessagePort` it talks on as its first message, and keeps a second channel to the thread it's lent to, on which it reports the worker's death and ends it when asked; a lent worker is started once, so a pool resized past what was lent gets fewer workers. `terminate` asks the owner and @@ -453,8 +458,11 @@ and is reported once in a sync point's failures. Once the thread is gone, they r - **Stop lists** (`stop-lists.ts`): before apply, the world's thread writes one block per sync point with stops for that thread: per finalizer, in the world's finalizer order, a group of its stops as columns: each entity, its pre-apply archetype and row, the tag of each object it reads, and a copy of each linear struct it reads, in that component's - layout, and of each string's record, by its handle, let go once the thread acknowledged the block: it may finalize - sync points after the row's own record was let go and freed. The world copies a column at a time, finding each column's storage once per run of an archetype. Blocks are + layout, and of each string's record, by its handle; then, once per group, the resources it reads that aren't its + thread's objects, as they were before apply: a copy of each string resource's record, by its handle, then each linear + resource's value in its layout, from the resource entity's row; a constant needs none. A finalizer reading no such + resource adds no bytes. The records' copies are let go once the thread acknowledged the block: it may finalize sync + points after the row's or resource's own record was let go and freed. The world copies a column at a time, finding each column's storage once per run of an archetype. Blocks are linked in sync order, the first from the log header's stops word, each block's next offset stored once that block is whole, so a thread reads only whole blocks of published syncs. A block is freed once the thread acknowledged a later one, since the thread reads the next offset from the newest block it ran. The world wakes the thread after each sync @@ -463,14 +471,18 @@ and is reported once in a sync point's failures. Once the thread is gone, they r entry, before the sync's entries apply, or at replay's end for a sync with no entries for it; its finalizers read the linear structs from the block, constants as they are, and its objects by each stop's tag: the start or `null` as tagged, any other at the stop's row in its mirror. After its log lapped, it runs them before it rebuilds, reading a - value it wrote or a settled slot's by entity in its old rows, and a live slot's from its staging array. Resources are - read where they are. Then it stores "finalized through sync N" in the log header. A stop list naming a finalizer + value it wrote or a settled slot's by entity in its old rows, and a live slot's from its staging array. Its thread's + object resources are read as it holds them: at the resource entity's row in its mirror as replayed so far, which may + be behind the entity table, its old rows before a rebuild, and the start where no row holds it, as before its first + rebuild. That's what the rebuild keeps: only its systems write those values, and the world gives them only their start, + when they're added or claimed. Its string and linear resources are read from the group's copies, so every resource + it reads is as of before its stops' sync point applied. Then it stores "finalized through sync N" in the log header. A stop list naming a finalizer whose setup hasn't arrived holds replay before its sync, as a component without a key does; one this thread never imported fails each of its groups. What its finalizers throw is posted, and a later run's report lists it as a `"finalizer"` failure. - **The guarantee:** each such finalizer runs exactly once per stop, on its thread, before that thread's next task or observer post after the sync point and before the entity's index is reused, seeing its declared struct and string - reads and its thread's objects as of before apply. + reads, its thread's objects and every resource it reads as of before apply. - **Held indexes:** the index of an entity despawned with such stops is held, counted by index for each thread that hasn't acknowledged it, apart from `released`, so the leak scan never sees it and the free list never has it; the sync point after a thread acknowledges releases it once no thread holds it. A thread that's gone has its held indexes @@ -583,9 +595,10 @@ its tails before it resolves, with their failures in its report. - **Fallback snapshot.** Sometimes a conflicting writer is ready before the tail has finished its reads, as when main is late. The world's thread then evaluates the tail's head as the tail would, copies the declared linear struct reads of the passing rows from the position the tail reached, with their position, archetype, row and entity, and its - linear resources, a string's handles among them, into a tail buffer, a column at a time; exchanges the progress word for a sentinel; and waits until - the tail is done through what it had claimed, at most one chunk, spinning briefly before it waits. A claim that - returns the sentinel reads the rest from the copy, rows from its own position on. The values are the same either + linear resources, a string's handles among them, into a tail buffer, a column at a time; adds a sentinel to the + progress word, which then still shows whether a claim came first; and waits until the tail is done through what it + had claimed, at most one chunk, spinning briefly before it waits. A claim that returns the sentinel or more reads the + rest from the copy, rows from its own position on. The values are the same either way, so this stays deterministic. A prompt main never copies. A string's handle in the copy names its record as of the copy, which outlives the tail: a tick posts its tails once the last tick's are done, and a run waits for them, so a tail ends before the second sync point after the one it reads, and a record let go is freed only then. The copy lets the world's work after its writer @@ -596,8 +609,9 @@ its tails before it resolves, with their failures in its report. - **The tail's own objects** are read where they are: while a tail is in flight, the thread's replay stops at the sync it names, which the world stores in the log header as it posts it, so the next sync point's moves wait in the log until the tail finishes. Rows read from the copy take their objects from the mirror at the copy's rows, or, once its - log lapped and it rebuilt past that sync, by entity. Objects it writes from the copy go back to the mirror after its - body; the world marks their tags the thread's own when it copies, and stamps them changed at the tail's tick, as the + log lapped and it rebuilt past that sync, by entity. Its object resources stay at the copy's resource row, rebuilt or + not, since the resource entity moves only when resources are added, which waits for tails. Objects it writes from the + copy go back to the mirror after its body; the world marks their tags the thread's own when it copies, and stamps them changed at the tail's tick, as the task's completion names them by entity, before the thread's next task. - **What waits for a tail to finish:** the next task on its thread, and what moves rows a tail may still read outside a sync point: registration, resources added between runs, a GPU device change, and joined threads joining or leaving. @@ -629,8 +643,8 @@ is in the input, and the system, which writes the component, runs on that thread point, and a staged slot of a joined thread's once it replays that sync point, which the world wakes it for. Releasing closes an `ImageBitmap`, `VideoFrame`, `AudioData`, `MessagePort` or `RTCDataChannel`, cancels or aborts a stream, and leaves an `ArrayBuffer`'s or an `OffscreenCanvas`'s memory to the collector. -- **Types:** an input schema can't name a Transferable type yet; a field declared `{ allOf: [] }` takes any value, - typed `unknown`, which the body narrows. +- **Types:** an input field is typed as its class, `{ readonly canvas: OffscreenCanvas }`, whose schema the plugin + gives as one taking any value. ### Without cross-origin isolation @@ -687,7 +701,7 @@ then green type tests, then runtime with red then green runtime tests. Each phas ### Built: threads in declarations, the loop, and the pool -- `Threads`, `thread`, `transfer` and the DOM rule in types, with registration's checks; a system's and finalizer's +- `Threads`, `thread`, `transfer` and the DOM rule, in the build and registration's checks; a system's and finalizer's thread derived, and two threads rejected, when it's created. With every thread filled by `world`, nothing changes at runtime. - Ids: the plugin gives top-level keys and `System.create`, `System.render`, `Rule.create`, `Finalizer.create` and @@ -910,8 +924,7 @@ that's gone rejects; an input no task took is released once its run finishes; a its thread. The sample (`src/samples/transfers`) runs the world on a worker drawing into the OffscreenCanvas main moved to it, main showing its state in the DOM, and a loader worker moving each texture it decoded to the world's thread. -- Interface: none. An input schema can't name a Transferable type, so a run's input can't be typed with one; proposed - for review, and meanwhile a field `{ allOf: [] }` takes one as `unknown`. +- Interface: none; an input field is typed as its Transferable class. - Contract: a run's Transferable input moves, never copied, to the thread its system runs on, and the run lands once it arrived, in call order with that thread's other runs; a run whose input moved to a thread that's gone rejects; a value whose command drops is released on its thread. diff --git a/packages/ecs/tsconfig.typedoc.json b/packages/ecs/tsconfig.typedoc.json new file mode 100644 index 000000000..de43c8b34 --- /dev/null +++ b/packages/ecs/tsconfig.typedoc.json @@ -0,0 +1,14 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "composite": false, + "noEmit": true, + "paths": { + "@adobe/ecs": ["./src/index.ts"], + "@adobe/ecs/*": ["./src/index.ts"] + } + }, + "include": ["src/index.ts", "src/implementation/compiler/index.ts"], + "exclude": [], + "references": [] +} diff --git a/packages/ecs/typedoc.json b/packages/ecs/typedoc.json new file mode 100644 index 000000000..f49063673 --- /dev/null +++ b/packages/ecs/typedoc.json @@ -0,0 +1,14 @@ +{ + "name": "@adobe/ecs", + "entryPoints": ["src/index.ts", "src/implementation/compiler/index.ts"], + "tsconfig": "tsconfig.typedoc.json", + "out": "docs", + "readme": "README.md", + "projectDocuments": ["src/guide/guide.md"], + "plugin": ["./scripts/typedoc-guide-links.mjs"], + "includeVersion": true, + "excludePrivate": true, + "excludeInternal": true, + "treatWarningsAsErrors": true, + "validation": { "notExported": false, "invalidLink": true, "rewrittenLink": true } +} diff --git a/packages/ecs/vite.perf.config.ts b/packages/ecs/vite.perf.config.ts index 414ff150b..5ea09bdf2 100644 --- a/packages/ecs/vite.perf.config.ts +++ b/packages/ecs/vite.perf.config.ts @@ -1,6 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { defineConfig } from "vite"; -import { kernelPlugin } from "./src/implementation/compiler/index.ts"; +import { Compiler } from "./src/implementation/compiler/index.ts"; /** * Bundles the benchmarks for plain node, with kernels compiled into their own chunks as in a production build, and @@ -8,7 +8,7 @@ import { kernelPlugin } from "./src/implementation/compiler/index.ts"; * the thread the joining benchmarks join their worlds from. */ export default defineConfig({ - plugins: [kernelPlugin()], + plugins: [Compiler.plugin()], build: { ssr: true, outDir: "dist/perf", diff --git a/packages/ecs/vite.sample.config.ts b/packages/ecs/vite.sample.config.ts index 6610e6751..fc8449210 100644 --- a/packages/ecs/vite.sample.config.ts +++ b/packages/ecs/vite.sample.config.ts @@ -1,11 +1,13 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { defineConfig } from "vite"; -import { kernelPlugin } from "./src/implementation/compiler/index.ts"; +import { Compiler } from "./src/implementation/compiler/index.ts"; import { isolation, sources } from "./vitest.plugins.ts"; -/** Serves `src/samples`, one folder per sample, cross-origin isolated for shared memory: `pnpm sample` opens /particles/. */ +/** + * Serves `src/samples`, one folder per sample with the sample browser at the root, cross-origin isolated for shared + * memory. It opens no browser. + */ export default defineConfig({ root: "src/samples", - plugins: [kernelPlugin(), isolation, sources], - server: { open: "/particles/" }, + plugins: [Compiler.plugin(), isolation, sources], }); diff --git a/packages/ecs/vitest.config.ts b/packages/ecs/vitest.config.ts index 1f9748369..b5c053c57 100644 --- a/packages/ecs/vitest.config.ts +++ b/packages/ecs/vitest.config.ts @@ -1,7 +1,8 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { playwright } from "@vitest/browser-playwright"; import { defineConfig } from "vitest/config"; -import { kernelPlugin } from "./src/implementation/compiler/index.ts"; +import { Compiler } from "./src/implementation/compiler/index.ts"; +import { pluginWith } from "./src/implementation/compiler/plugin.ts"; import { isolation, sources } from "./vitest.plugins.ts"; /** @@ -10,6 +11,16 @@ import { isolation, sources } from "./vitest.plugins.ts"; */ const threads = "src/implementation/world/threads.test.ts"; +/** + * Suites of what row systems do, run a second time with unflagged systems as written, since the build compiles every + * body in the subset: so both the kernels and the bodies as written of their systems are tested. + */ +const rowPath = [ + "up", "up-tree", "index-order", "index-sorted", "index-hash", "index-keyless", "index-changed", "indexes", "entity-lookups", + "change-blocks", "conditions", "commands", "rows", "target", "remove", "strings", "synchronize", "ticks", "observer", + "schedule", "reductions", "loop", +].map(name => `src/implementation/world/${name}.test.ts`); + /** A test's timeout only guards against a hang, which no test asserts on: long enough for a machine busy elsewhere. */ const timeouts = { testTimeout: 30000, hookTimeout: 30000 }; @@ -26,7 +37,7 @@ export default defineConfig({ test: { projects: [ { - plugins: [kernelPlugin()], + plugins: [Compiler.plugin()], test: { ...timeouts, execArgv, @@ -37,7 +48,17 @@ export default defineConfig({ }, }, { - plugins: [kernelPlugin()], + plugins: [pluginWith({ automatic: false })], + test: { + ...timeouts, + execArgv, + name: "as written", + include: rowPath, + sequence: { groupOrder: 0 }, + }, + }, + { + plugins: [Compiler.plugin()], test: { ...timeouts, execArgv, @@ -49,7 +70,7 @@ export default defineConfig({ }, }, { - plugins: [kernelPlugin(), isolation, sources], + plugins: [Compiler.plugin(), isolation, sources], test: { ...timeouts, name: "browser", diff --git a/packages/ecs/vitest.fixtures.ts b/packages/ecs/vitest.fixtures.ts index ebec940af..bd0d88914 100644 --- a/packages/ecs/vitest.fixtures.ts +++ b/packages/ecs/vitest.fixtures.ts @@ -4,7 +4,7 @@ import { basename, dirname, join, relative, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { build } from "vite"; import type { TestProject } from "vitest/node"; -import { kernelPlugin } from "./src/implementation/compiler/index.ts"; +import { Compiler } from "./src/implementation/compiler/index.ts"; const root = dirname(fileURLToPath(import.meta.url)); /** Where each test run builds its fixtures, in a directory of its own: `run--`. */ @@ -50,7 +50,7 @@ export default async (project: TestProject): Promise<() => void> => { configFile: false, root, logLevel: "warn", - plugins: [kernelPlugin()], + plugins: [Compiler.plugin()], build: { ssr: true, outDir: fixturesDirectory, diff --git a/packages/ecs/vitest.perf.config.ts b/packages/ecs/vitest.perf.config.ts index 261054f1b..cb1f011bd 100644 --- a/packages/ecs/vitest.perf.config.ts +++ b/packages/ecs/vitest.perf.config.ts @@ -1,7 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { playwright } from "@vitest/browser-playwright"; import { defineConfig } from "vitest/config"; -import { kernelPlugin } from "./src/implementation/compiler/index.ts"; +import { Compiler } from "./src/implementation/compiler/index.ts"; import { isolation, sources } from "./vitest.plugins.ts"; // `*.perf-test.ts` runs in node; `*.perf-browser-test.ts` runs in chromium, with real workers. @@ -10,9 +10,9 @@ export default defineConfig({ // `pnpm perf:browser` reads the results from the console lines, which the default reporter drops for passing tests. reporters: "verbose", projects: [ - { plugins: [kernelPlugin()], test: { name: "perf-node", include: ["src/**/*.perf-test.ts"] } }, + { plugins: [Compiler.plugin()], test: { name: "perf-node", include: ["src/**/*.perf-test.ts"] } }, { - plugins: [kernelPlugin(), isolation, sources], + plugins: [Compiler.plugin(), isolation, sources], test: { name: "perf-browser", include: ["src/**/*.perf-browser-test.ts"], diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 530538f3b..db28c9ede 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -669,6 +669,9 @@ importers: playwright: specifier: ^1.64.1 version: 1.64.1 + typedoc: + specifier: ^0.28.9 + version: 0.28.9(typescript@5.8.3) typescript: specifier: ^5.8.3 version: 5.8.3 diff --git a/scripts/check-workspace-conventions.mjs b/scripts/check-workspace-conventions.mjs index 4c0898a76..673dd58d4 100644 --- a/scripts/check-workspace-conventions.mjs +++ b/scripts/check-workspace-conventions.mjs @@ -24,6 +24,7 @@ import { readFileSync, readdirSync, statSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { dirname, join, relative, resolve } from "node:path"; +import { pagesProblems } from "./pages.mjs"; const root = join(dirname(fileURLToPath(import.meta.url)), ".."); const packagesDir = join(root, "packages"); @@ -52,6 +53,7 @@ for (const name of readdirSync(packagesDir)) { errors.push(`${name}: publishable packages hold a LICENSE identical to the monorepo's (copy ../../LICENSE)`); } } + for (const problem of pagesProblems(pkg.pages)) errors.push(`${name}: ${problem}`); if (pkg.scripts?.["publish-public"]) { errors.push(`${name}: remove the "publish-public" script — publishing is governed by the "private" field and \`pnpm -r publish\``); } diff --git a/scripts/collect-pages.mjs b/scripts/collect-pages.mjs new file mode 100644 index 000000000..eab7a4cee --- /dev/null +++ b/scripts/collect-pages.mjs @@ -0,0 +1,47 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +// +// Assembles the GitHub Pages site from every package that declares what it publishes, so the docs workflow never +// names a package. A package publishes with a `pages` field in its package.json: a list of `{ "from", "to" }`, each +// copying a folder of the package (built by its `build` or `docs` script) to a path of the site, "." for the root. +// +// usage: node scripts/collect-pages.mjs + +import { cpSync, existsSync, mkdirSync, readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { normalizedPath, pagesProblems, workspacePackages } from "./pages.mjs"; + +const root = join(dirname(fileURLToPath(import.meta.url)), ".."); +const site = process.argv[2]; +if (site === undefined) { + console.error("usage: node scripts/collect-pages.mjs "); + process.exit(1); +} + +const copies = []; +const errors = []; +for (const name of workspacePackages(root)) { + const pkg = JSON.parse(readFileSync(join(root, name, "package.json"), "utf8")); + const problems = pagesProblems(pkg.pages); + for (const problem of problems) errors.push(`${name}: ${problem}`); + if (problems.length > 0) continue; + for (const { from, to } of pkg.pages ?? []) copies.push({ name, from: join(root, name, normalizedPath(from)), to: normalizedPath(to) }); +} + +// The root first, so a package's own path is never overwritten by it. +copies.sort((a, b) => (a.to === "." ? 0 : 1) - (b.to === "." ? 0 : 1) || a.to.localeCompare(b.to)); +const taken = new Map(); +for (const { name, from, to } of copies) { + if (taken.has(to)) errors.push(`${name}: publishes to "${to}", which ${taken.get(to)} already does`); + taken.set(to, name); + if (!existsSync(from)) errors.push(`${name}: "${from}" doesn't exist; its build or docs script makes it`); +} +if (errors.length > 0) { + for (const error of errors) console.error(error); + process.exit(1); +} +mkdirSync(site, { recursive: true }); +for (const { name, from, to } of copies) { + cpSync(from, join(site, to), { recursive: true }); + console.log(`${name}: ${from} → ${join(site, to)}`); +} diff --git a/scripts/pages.mjs b/scripts/pages.mjs new file mode 100644 index 000000000..20f1564cd --- /dev/null +++ b/scripts/pages.mjs @@ -0,0 +1,41 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +// +// What packages publish to the GitHub Pages site, shared by collect-pages.mjs, which assembles it, and +// check-workspace-conventions.mjs, which validates each package's `pages` field. + +import { readFileSync } from "node:fs"; +import { join, posix } from "node:path"; + +/** The workspace's packages, as pnpm-workspace.yaml lists them: their directories relative to the root. */ +export const workspacePackages = (root) => { + const lines = readFileSync(join(root, "pnpm-workspace.yaml"), "utf8").split("\n"); + const start = lines.findIndex((line) => line.trim() === "packages:"); + const listed = []; + for (const line of lines.slice(start + 1)) { + const item = /^\s+-\s+['"]?([^'"]+?)['"]?\s*$/.exec(line); + if (item === null) break; + listed.push(item[1]); + } + return listed; +}; + +/** A path normalized with forward slashes and no trailing slash, "." for the directory itself. */ +export const normalizedPath = (path) => { + const normalized = posix.normalize(path.replace(/\\/g, "/")).replace(/\/+$/, ""); + return normalized === "" || normalized === "./" ? "." : normalized; +}; + +/** Whether a normalized path stays inside the directory it's relative to. */ +const inside = (path) => !posix.isAbsolute(path) && path !== ".." && !path.startsWith("../"); + +/** What's wrong with a package's `pages` field, one message per problem; none when it's absent or right. */ +export const pagesProblems = (pages) => { + if (pages === undefined) return []; + if (!(Array.isArray(pages) && pages.every((page) => typeof page?.from === "string" && typeof page?.to === "string"))) { + return [`"pages" lists { "from", "to" } folders to publish (see scripts/collect-pages.mjs)`]; + } + return pages.flatMap(({ from, to }) => [ + ...inside(normalizedPath(from)) ? [] : [`"pages" from "${from}" leaves the package`], + ...inside(normalizedPath(to)) ? [] : [`"pages" to "${to}" leaves the site`], + ]); +};