# Module (asset type)
A module is a reusable, stateless (or globally-stateful) Luau library
that other code pulls in with `require()`. Modules are the unit of
factored-out logic in a world: helpers, math libraries, format
parsers, registries, shared algorithms.
## When to use one
- You have logic you want to share across multiple components,
services, scenes, or tools.
- You have a registry, lookup table, or shared cache that callers all
read from / write to.
- You want a stable API surface (`M.fn`) other code can depend on.
If the logic is bound to one entity, use a `.component`. If you want a
named UI of fields rather than functions, use a `.preset`.
## Where it lives
- Source: `/zero/source/.../<name>.module/`
- Identity: the source path with `/` written as `.` and each segment's
type suffix dropped — `/zero/source/demo/beats.module` is
`demo.beats`, and `/zero/source/kit.package/beats.module` is
`kit.beats`, the container's name kept and its `.package` suffix
gone. `asset.create` returns this string, `asset.resolve(...).identity`
reads it back, and the `require` forms below write it as
`<identity>`.
- Folder shape:
- `init.luau` (or `init.lua`) — module body. **Required.** Returns
a table (conventionally named `M`).
- `README.md` — instance-level documentation. **Required.**
- `.metadata` — agent-editable tags + free-form fields. **Required.**
## How to create one
```luau
asset.create("module", "<name>")
-- Creates: /zero/source/<name>.module/
-- init.luau (canonical `local M = {} … return M`)
-- README.md (instance README template)
-- `folder` places it in a subfolder of /source, and that subfolder
-- leads the identity: `{ folder = "lib" }` produces `lib.<name>`.
-- `into` authors it inside a resolved container (a package, a toolbox),
-- whose own identity leads the same way:
asset.create("module", "<name>", { folder = "lib" })
asset.create("module", "<name>", { into = asset.resolve("myPkg.package") })
```
## How it operates
1. **Registration.** Writing `init.luau` into a `.module/` folder
indexes the asset and registers the module path with the resolver.
2. **Loading.** The first `require("<identity>")` runs
the file's body and caches the returned table. Subsequent requires
from anywhere return the **same table** — this is the
shared-state lever for "module-local" registries.
3. **Hot reload.** Editing the file re-runs the module body and folds
what it returns into the table already loaded, so that table keeps
its identity: a caller that captured it with `require` reads the new
source's functions and values through the reference it already
holds, and a later `require` returns that same table. A key the new
source drops goes away; a key something set at runtime stays. The
body's own `local`s start again from their initial values, so state
that must outlive an edit lives in `modules.state()`
(`guides { path: "core/scripting-and-tasks" }`).
4. **Identity resolution.** A module is reached by its `<identity>`,
and where the caller lives decides which spelling of it resolves.
For a module in a **world**, from a caller in that same world —
another module, a component, a scene entrypoint, or an `execute`
chunk:
- `require("<identity>")` — the identity on its own, resolved
against the caller's own root. It carries every folder segment,
so a module created with `{ folder = "demo" }` is
`require("demo.beats")`.
- `require("@root::<identity>")` — the same root-relative
resolution, written out.
- `require(".sibling")` — a module in the same folder, by its bare
name; `..name` steps up one folder, `...name` two.
Logs, stack traces and hot-reload notices name that same module
`@local.source.<identity>` — its key under the world source root.
The `<identity>` tail of that key is the form above, so a trace
reading `@local.source.demo.beats` is `require("demo.beats")`.
For a module in a **library** — content under
`/zero/source/libs/@<lib>/` — `@<lib>::` addresses that library's
root from anywhere: `require("@builtin::modules.transform")`.
Mounting a world as a library moves its files under such a root and
flips the caller root to `@<lib>::`, which is what keeps the
root-relative forms above resolving across the move.
## Discovery
- `asset.list("module")` — every registered module.
- `asset.inspect("<name>")` — public functions, source path, this
type README.
- `ls /zero/source/<name>.module` lists the files the folder holds.
## Authoring conventions
- Return a single `M` table from `init.luau`. Top-level statements
with side effects run on first require — useful for one-time
initialization, dangerous if they touch the engine before it's
ready.
- Write exported functions as `typed function`, not `function`: the
argument types are enforced at the call and the checker knows the
signature, so a caller's mistake is reported where they made it.
- Annotate exported functions with `--!desc` / `--!arg` / `--!return`
/ `--!example` so the LSP, `tools.list`, and `asset.inspect`
surface them. A function with no `--!desc` is reachable only by
someone already reading this file.
- Declare a VALUE with `Field.<kind>(default, mode, description)`, and
a table whose members are reached through a metatable with
`--!members <TypeName>` — the latter gives the surface its accepted
member set, its type, and its documentation from one table. See
`guides { path: "core/authoring" }` for all four rules and
`modules/api/engine/entity.module/members.module` for a worked
member table.
- Keep module state in module-local upvalues or in `modules.state()`.
Globals leak across reloads. Module-locals start again from their
initial values with each hot reload; the table `modules.state()`
returns is the same table on both sides of every reload.
- Prefer focused modules over kitchen-sink modules. If two halves of a
module have no shared state, split them.
## Common pitfalls
- **Cyclic requires.** A `require` chain that loops will return the
partial table (the half built before the cycle was detected).
Design for one-way dependencies.
- **Engine-time side effects.** Don't `entity.spawn` at module
top-level — the engine may not be ready. Expose an `M.init` and
call it from a scene entrypoint.
- **Hot-reload + module-local cache.** A module that caches
expensive computation in upvalues loses that cache on reload —
fine for dev, but be aware in performance work.
- **`init.lua` vs `init.luau`.** Either works, `.luau` is canonical.
- **Writing one while play runs.** A `/source` write made while play is
running lands on the play shadow, and a guarded play-exit discards it
unless it is kept. One `.module` write shadows `init.luau`, the
`README.md` and the `.metadata` together.
`vfs.write(path, bytes, { durable = true })` skips the shadow, and
`vfs.promotePlayShadow` keeps what is already on it — the `core/vfs`
guide has the model and what each route costs.
## Related types
- `.component` — for entity-bound state + lifecycle hooks.
- `.service` — generates content you don't have yet (a mesh, sound, texture, …) via a metered provider.
- `.tool` — for a single agent-callable function with a YAML schema.
- `.package` — to group several modules + components + scenes into
one shippable folder.
## In the Inspector
Exports: what the module exports (name, kind, signature), parsed from its source without running it, with **Check** (the LSP's diagnostics for its entry file) and **Open**.
# proc
Code-authored, graph-backed procedural generation for the Zero engine.
A graph is one function of `g`: `proc.define(function(g) ... end)`. Nodes are called by
family and name (`g.<family>.<name>({ ... })`), inputs are declared with `g:input(name, default, opts)`,
the returned table is the graph's outputs, and `io.output` is how the graph stands in the world.
`builder.module/README.md` holds every rule of that form.
This module owns the whole procedural model: the graph builder + data model,
the operation registry, the type system, the lazy cached evaluator, deterministic scoped hashing, and the node-family op
implementations. Authored `.procGraph` graphs are persisted via
`procGraph.assetType`; the `procgen.preview` / `procgen.spawn` operations live in
`procgen.toolbox` and are invoked as tools: `use_tool` from an agent, or
`tools.use("procgen", "preview", args)` from a script. Generated
meshes/materials/textures/bundles are persisted through their own `.assetType`.
An op family can also come from outside this package: a `.procPack` asset
registers its ops in the same registry, called as `g.op.<id>(...)` exactly like the ones
shipped here (`proc.registerPack(name, ops)` is what it registers through, and
what a test or a tool calls to hold a family for the life of one VM).
`terrain.*` is one such pack, shipped in `terrainSystem.package/procPacks/`
and covering a whole landscape; `guides { path =
"systems/terrainSystem/documentation/terrain" }` is its guide.
To find out what the registry holds, use the `procgen` toolbox: `searchNodes`
searches it and `describeNode` describes one node or op. `proc.search_ops(query)` and
`proc.schema(id)` are the same answers in-process, for a graph builder looking
something up while it runs.
# compute_abi Module
The kernel ABI of a `.computeShader`. A `bindings.yaml` that uses the ABI
extensions is lowered to the plain bindings and WGSL the engine compiles; this
module parses, lowers, compiles variants, allocates outputs, packs column tables
and dispatches.
## Purpose
A kernel is one asset that serves many shapes of data. `bindings.yaml` states:
```yaml
bindings:
- { name: cols, kind: columns, access: read }
- { name: src, kind: buffer, access: read, element: T }
- { name: dst, kind: buffer, access: read_write, element: T, count: "len(src)" }
- { name: hits, kind: buffer, access: read_write, element: vec4<f32>, capacity: "n * 4", append: true }
specialize:
- { name: T, options: [f32, i32, vec2<f32>, vec3<f32>, vec4<f32>] }
- { name: FOLD, options: [add, min, max, mean, set] }
- { name: BODY, kind: source }
determinism: exact
```
- `columns`: a table of columns in one storage binding (header: the column count, then four words a column: first word, lanes, count, kind). WGSL reads it through `NAME_count(c)`, `NAME_lanes(c)`, `NAME_f32/i32/u32(c, i, lane)`, `NAME_vec2/3/4(c, i)`; `access: read_write` adds `NAME_set_*`. Columns named at specialization are constants `NAME_col_<column>`.
- `count` / `capacity`: the size, in elements, of an output the host allocates. The expression uses `n` (the dispatch size), the shader's params, `len(binding)`, `max`, `min`, `ceil`, `floor` and `+ - * / ( )`.
- `append: true`: the output carries a live counter, `NAME_count`, bound right after it. `NAME_store(slot, v)`, `NAME_set_count(n)`, `NAME_count_value()`, `NAME_capacity()` are generated. `NAME_append(v)` (unordered, atomic) is generated for `determinism: float_order` only.
- `specialize`: compile-time parameters. Type options become `alias NAME = type;`, named options become `const NAME: u32` plus `NAME_<option>`, and `kind: source` parameters replace the identifier with text.
- `when` on a binding: a condition over the specialization options, `NAME=option` or `NAME=option|option`. The binding exists in the variants whose option holds and is left out of the others. Terms separated by spaces all hold (`COUNT=device STAGE=table`); alternatives separated by `;` hold when any one does (`COUNT=plain; COUNT=device STAGE=table`). Two bindings may share a name when their conditions never hold together.
- `// @when NAME option option` ... `// @end` in the WGSL: the lines between them stay in the variants where the specialization `NAME` takes one of the options and are blanked in the others (the lines keep their numbers). Blocks may nest. Code that reads a binding a variant leaves out sits in such a block.
- `bits: true` on a binding states that its words are raw bits (an f32 held in a u32 word, an atomic word): a column of another element type bound to a read binding that states it is bound as it is, where an i32, u32 or f32 read binding takes a column of another element type cast by value.
- `determinism`: `exact` or `float_order`. An `exact` shader that calls `NAME_append` is refused.
- `workgroup`: the workgroup size the body declares (default 64).
Every scalar or vector `buffer` binding gets `NAME_get(i)`, `NAME_len()` and, unless `access: read`, `NAME_set(i, v)`. A `vec3` element is stored as a flat scalar array, matching a column of vec3 that is 12 bytes an element. `zero_n()` is the dispatch size.
## Usage
```luau
local abi = require("@builtin::modules.compute_abi")
local schema = abi.parse(bindingsYaml)
local program = abi.program({ key = "my_kernel", schema = schema, wgsl = body })
local variant = program:variant({ specialize = { T = "vec3<f32>" } })
local run = variant:run({ n = 4, inputs = { src = srcBuffer } })
local values = abi.read(run.outputs.dst.buffer)
```
A computeShader asset reference exposes the same through `ref:program()`.
## API
| Function | Does |
|---|---|
| `abi.parse(text)` | `bindings.yaml` to `{ bindings, params, specialize, determinism, workgroup }`; a text parses to one schema, the same table each time it is read |
| `abi.isExtended(schema)` | whether the schema uses an extension |
| `abi.lower(schema, wgsl, spec)` | the lowered source, bindings, params and host slots |
| `abi.compile(key, lowered, label)` | compile a lowered variant once, answering its dispatch key |
| `abi.program(def)` | a program; `program:variant(spec)` lowers and compiles a variant. A program over the schema and source of the one standing under its key is that program, with the variants it has lowered |
| `variant:run(env)` | allocate outputs, bind, dispatch; answers the outputs and live counters. `env.outputs[binding]` is a buffer (or `{ buffer, count?, capacity?, counter? }`) the dispatch writes in place of an allocation; an appended output given one keeps the count its `counter` holds. The first run of a variant waits for its program to be built, so a read of an output asked for after the run sees what the dispatch wrote |
| `abi.table(columns, opts)` | pack columns into one buffer for a `columns` binding |
| `abi.evaluate(text, vars, lengths)` | evaluate a count expression |
| `abi.read(buffer, asU32?)` | read a buffer back, waiting for the frames it takes |
# prefab (nodebox)
Prefabs: the entity records a spawned hierarchy is made of, as record and list values. prefab.empty makes a root, prefab.entity, prefab.parent, prefab.add_component and prefab.link shape the hierarchy (each calls prefab.edit, which holds the record-list edits and the name lookup that prefab.find answers), and prefab.from_geometry, prefab.from_geometries, prefab.from_instances, prefab.from_population and prefab.from_asset make prefabs from what a graph built. The output node shows a prefab and the export node saves it as a bundle.
prefab.record and prefab.find are the parts the others are built from: one entity record, and a lookup of an entity by name.
# field.distance (procNode)
How far each element's position is from a target, as a Float field. It makes the target the wired `b` field or,
when `b` is left unwired, the fixed `point` as a constant field (`value.fallback`, `field.constant`), then
measures with `field.vector_math` in `distance` mode from `a` to that target. The result compiles to the device with
the rest of the field chain.
## Inputs
- `a` (`Field<Vec3> | Vec3`): the positions being measured, one per element.
- `b` (`Field<Vec3> | Vec3`): what each element is measured to, per element, so the target can move with the
element. Wired, it stands in for `point`.
- `point` (`Vec3`, default `{ 0, 0, 0 }`): one fixed position every element is measured to when `b` is unwired. It is
how a radial falloff around a place is built.
## Outputs
- `field`: the distance, a Float field.
# instances (nodebox)
Instances: pack a geometry into a piece (instances.pack), place a piece set on target points (instances.to_points) and take a population or piece set apart (instances.unpack). A piece is one point whose `Mesh` holds a geometry whole; `geometry.merge` with `pack` puts several together into a piece set. The copies `instances.to_points` makes are a population, which the output node draws as instanced draws, one per piece and material, and export saves as a population asset; any other points stand as one entity each.
instances.pack, instances.to_points and instances.unpack are the nodes of that chain, with `points.pose` composing the instance attributes (`P`, `orient`, `scale`, `N`, `v`, `up`, `pscale`, `rot`, `trans`, `pivot`) the copies stand at.
Earlier nodes of the nodebox stand a source on points (instances.on_points), deal one of several sources to each point (instances.pick_random, instances.pick_by_attribute), vary and move the copies (instances.randomize_transform, instances.transform, instances.set_material) and merge them into one geometry (instances.realize). The points they make carry a `Mesh` and stand as entities through the output node.
instances.place, instances.variants, instances.turn and instances.sources are the parts the others are built from: a transform written onto points, a variant index written onto points, the product of two rotations, and the geometries a population stands (the ones given, else the ones its points carry).
# part (nodebox)
part.merge packs several values into one part, each piece keeping what it is; part.unpack gives one piece back by its name.
# math
One node per operator and function of the authoring surface: add, sub, mul, div, mod, pow, neg, abs, min, max, floor, ceil, sqrt, sin, cos, tan, acos, atan, log, sign, deg, rad, clamp, lerp, smoothstep, length, normalize, dot, cross, vec2, vec3, vec4, the comparisons (lt, le, gt, ge, eq, ne, and `compare` over them), `where`, the integer functions (to_int, to_float, bor, bxor, shl, floordiv), and `concat` for text.
Operators and `math.*` over sockets resolve to these nodes. Each calls an operation of `proc.fieldc.operators`: over plain numbers and vectors it answers the plain result, over a Column or a Field it answers a Field (a fieldc template), so a chain of math nodes is one expression that the node consuming it evaluates in a single dispatch (`fieldc.evaluate`).
# core
The parts the other procgen nodes are built from: connectivity (edges, boundary, adjacency, islands, owner, corners), normals, weld, select, stitch, curve measure and resample, sampling, neighbours, realize, the raster set (`raster.new`, `raster.read`, `raster.stencil`, `raster.sample`, `raster.add_layer`, `raster.remove_layer`, `raster.reduce`, `raster.column`, `raster.resolve`, `raster.blend`, and `raster.cells` under them), `column`, and `entity.read`, which brings scene entities in (assets come in through `io.import`).
Each is a graph of the four ops, the kernels of `kernels/` and other core nodes. `join` holds the module that places the parts of a join, called by `concat` and `realize`.
A graph calls them as `g.core.<name>`, for instance `g.core.edges`. A node with several outputs answers a record: `local faces = g.core.normals.face(...)` then `faces.geometry`, `faces.scaled`. Every node's README states its inputs and outputs.
# Scatter (node)
A scattering node: its `init.luau` holds the graph that does the work, so the declared inputs are this node's inputs
and the declared output is this node's output. The node is its pipeline, in one self-contained asset.
It makes points on the surface (`points.on_surface`, area-weighted), gives every point its own random yaw, lean and
scale (`instances.randomize_transform`), and places the source on those points (`instances.on_points`). The
transform is read from the same point cloud the instances sit on, so each copy takes the pose of its own point.
A node is findable by what it does: the `procgen` toolbox's `ops` with the query `scatter` lists it beside
`points.on_surface` and `instances.on_points`. Editing this node's `init.luau` cooks again every graph that calls it.
## Inputs
- `surface` (Geometry): what the copies land on.
- `source` (Geometry): the shape being copied.
- `count` (Int, default 200): how many.
- `seed` (Int, default 1): changes the arrangement.
- `scaleMin` / `scaleMax` (Float, defaults 0.8 and 1.4): per-copy size range.
- `tilt` (Float, default 0.15): radians of random lean.
## Output
- `instances`: a population, the points each carrying the source as its `Mesh`.
## Authoring a node
A node is a graph returned from a `.procNode`'s `init.luau` (`proc.define`). `asset.create("procNode", name)`
scaffolds that shape, and `procNode.assetType/README.md` describes what a node states and how graphs call it.
# field.ramp (procNode)
A ramp read at every element by a value it carries: a particle's colour, size or opacity following its age. It takes
the value each element reads by (the wired `t`, else the element's own `by` attribute, through `value.fallback` and
`field.attribute`), maps it from `range` onto 0..1 (`field.map_range`, the ends read off `range` by `value.get`), and
reads the ramp there with `ramp.sample`, compiled into the element's dispatch. A value past either end of `range` reads
that end of the ramp.
## Inputs
- `ramp` (`Ramp`): the ramp to read.
- `t` (`Field<Float> | Float`): where along the ramp each element reads. Unwired, it is the element's own `by`
attribute.
- `by` (`String`, default `"life"`): the Float attribute each element reads the ramp by when `t` is unwired. A particle
solver's particles carry `life`, 0 at birth to 1 at death.
- `range` (`Vec2`, default `{ 0, 1 }`): the values of `t` that land at 0 and at 1 on the ramp, so a speed from 0 to 20
or a temperature from 300 to 3000 reads the whole ramp.
## Outputs
- `field`: the ramp's value at each element.
# debug (nodebox)
debug.dump summarizes any graph value into a record; debug.validate checks a Geometry's structure and reports every issue it finds.
# geometry.recalculate_normals (procNode)
Every vertex's normal averaged, by area, over the faces meeting it. The node is `geometry.smooth_normals` with a
crease angle of 180 degrees and area weighting, so no vertex splits: a vertex shared by two faces is smooth across
them, and one already split between two copies stays a hard edge.
## Inputs
- `geometry` (`Geometry`): whose surface the normals are read off. Attributes and material carry to the result.
## Outputs
- `geometry`: the geometry with recomputed per-vertex normals.
# material (nodebox)
Materials as records: material.pbrMat is the standard metallic-roughness material made from its PBR maps (base colour, metallic-roughness, normal, occlusion, emissive) and their factors, and material.shader a material of any surface shader, holding every property and slot to what the shader declares. Each answers one element holding the record of the material kind (`records/material.procRecord`), which geometry.set_material, instances.set_material and the other inputs that take a material put on what draws; the output node shows it and the export node saves it as a `.material`.
# instances.cull (procNode)
Keeps the instances a test says to. An instance survives when the value it is judged by is strictly above
`threshold`; `invert` keeps the rest instead. The value is the `mask` field when it is wired, else the named point
`attribute` (`field.attribute`). `elements.keep` on the points carries every attribute onto the instances that stay, so
a culled set realizes as the whole one would have. With nothing to judge by, every instance stays, and inverted, none
does.
## Inputs
- `instances` (`Points`): the set to thin out, points carrying a Mesh.
- `mask` (`Field`): the value each instance is judged by. Wired, it outranks `attribute`.
- `threshold` (`Float`, default 0.5): an instance survives when its value reads strictly above this. Raising it thins
the set, lowering it fills it back in.
- `attribute` (`String`): a point attribute on the set to judge by, read while `mask` is unwired. A name the points do
not carry is refused.
- `invert` (`Bool`, default false): true keeps the complement, every instance the test dropped.
## Outputs
- `instances`: the surviving instances.
# points.box (procNode)
A 3D lattice of points filling a box centred on the origin. Each count is at least 1, and the extent the lattice spans
is the counts times `spacing` (`value.math`). `grid.box` lays out the cells, x fastest, then y, then z, on the device,
and each cell's centre is one point. `attribute.remove` takes the cell layout off the detail so the lattice reads as
Points, and each point carries its own number as `id`.
## Inputs
- `countX` (`Int`, default 3): how many points along X. The lattice holds countX x countY x countZ points and stays
centred on the origin on every axis. Below 1 reads as 1.
- `countY` (`Int`, default 3): how many points along Y, the layers the lattice is stacked in. 1 collapses it to a single
flat sheet at y = 0. Below 1 reads as 1.
- `countZ` (`Int`, default 3): how many points along Z. Below 1 reads as 1.
- `spacing` (`Float`, default 1.0): distance between neighbouring points, the same on all three axes. The lattice spans
(count - 1) x spacing per axis.
## Outputs
- `points`: the lattice, with `id` numbering each point.
# component (nodebox)
component.set gives every element of a class a component with the fields given.
# texture (nodebox)
Textures: images drawn on the device as Texture values, a width, a height and one colour per texel. texture.noise and texture.gradient draw from a formula, texture.from_shader from an author's compute shader and texture.from_raster from the layers of a raster. A material takes a Texture in a slot, and the output node shows one.
texture.cells is the part the first two are built from: one point per texel carrying its column and row.
# geometry
Nodes that move, join, cut, round, thicken and sample meshes. A graph transforms a geometry (`geometry.transform`, `geometry.set_position`, `geometry.merge`), welds and shades it (`geometry.weld`, `geometry.smooth_normals`), models it (`geometry.extrude`, `geometry.solidify`, `geometry.bevel`, `geometry.subdivide`, `geometry.decimate`), builds surfaces from outlines (`geometry.sweep`, `geometry.revolve`, `geometry.loft`, `geometry.ribbon`) and reads it into volumes (`geometry.voxelize`, `geometry.to_sdf`). Each is built from the core nodes and the kernels of the library.
## Nodes
- `geometry.transform`, `geometry.set_position`, `geometry.set_material`, `geometry.merge`, `geometry.triangulate`, `geometry.measure`: the basic edits and the measurement of a geometry.
- `geometry.weld` (with `match = "position+normal"` it keeps hard edges), `geometry.smooth_normals`: merging points and shading normals.
- `geometry.extrude`, `geometry.solidify`, `geometry.bevel`, `geometry.subdivide`, `geometry.decimate`: modelling.
- `geometry.sweep`, `geometry.revolve`, `geometry.loft`, `geometry.ribbon`: surfaces from outlines and paths.
- `geometry.voxelize`, `geometry.to_sdf`: a mesh as cells and as a distance field.
- `geometry.boolean`: union, subtract or intersect of two closed meshes, over the `intersect` kernels.
- `geometry.selected`, `geometry.flip`, `geometry.profile`, `geometry.tangents`, `geometry.sides`, `geometry.shared`, `geometry.bvh`, `geometry.rings`, `geometry.first`: the parts the nodes above are built from, each usable on its own.
- `geometry.unit`, `geometry.perpendicular`, `geometry.rotate`, `geometry.transport`: vector arithmetic over sockets that the nodes which orient points and normals share.
## Conventions
- Each node's folder name is its call name inside the box: `geometry.<folder>`.
- Inputs and outputs carry the names of the op each node replaces.
- A node that answers a Data table from numbers read back off the device holds the Luau for it in a `readings.module` inside its own folder.
- A node with one output answers that output's value; a node with several answers a record by output name.
- A fold's index runs from 1: the first pass is 1, so a pass that works on level, chunk or step `n` reads `index - 1` when it counts from 0 (`geometry.bvh`, `geometry.to_sdf`, `geometry.sweep`, `geometry.loft`).
- A field bound to a kernel takes the length of that dispatch, so a node writes a value out as a column (a gather with an explicit `count`) before it binds one whose length differs from the dispatch's.
# sdf (nodebox)
Signed distance field nodes: shapes, smooth combines, round and shell, space warps (transform, twist, bend, taper, elongate, mirror, repeat), displacement, grids and fields in and out, and sdf.mesh, which contours a shape into a closed mesh.
# points.grid (procNode)
A flat lattice of points in the XZ plane, centred on the origin. Each count is at least 1, and the extent the lattice
spans is the counts times `spacing` (`value.math`). `grid.box`, one cell thick at height `y`, lays out the cells, x
fastest, then z, on the device, and each cell's centre is one point. `attribute.remove` takes the cell layout off the
detail so the lattice reads as Points, and each point carries its own number as `id`.
## Inputs
- `countX` (`Int`, default 4): how many points along X. The grid holds countX x countZ points and stays centred on the
origin, so each one added widens it by `spacing`, half of that to either side. Below 1 reads as 1.
- `countZ` (`Int`, default 4): how many points along Z. A count of 1 leaves a single row running along X. Below 1 reads
as 1.
- `spacing` (`Float`, default 1.0): distance between neighbouring points, the same on both axes. The grid spans
(countX - 1) x spacing by (countZ - 1) x spacing.
- `y` (`Float`, default 0.0): the height the whole lattice sits at. Every point takes this as its y.
## Outputs
- `points`: the lattice, with `id` numbering each point.
# points.filter (procNode)
Keeps the points a test says to. The value each point is tested by is the `mask` field when it is wired, else the named
`attribute`'s values (`attribute.labels`). The test is a comparison (`field.compare`): above `threshold`, or, when
`equals` is given, equal to the index that value or label names. `elements.keep` on the points carries every attribute
and the detail onto the points that stay, counted on the device. Given neither a mask nor an attribute, the points come
back as they are.
## Inputs
- `points` (`Geometry`): the cloud to thin. Every point attribute and every detail value is carried onto the survivors,
so a colour or an index still belongs to the point it started on.
- `mask` (`Field`): the per-point value tested; a point stays when it is above `threshold`. Wired, it decides, and
`attribute` is read only when this is empty.
- `threshold` (`Float`, default 0.5): a point stays when its value is strictly above this. Raising it keeps fewer
points.
- `attribute` (`String`): a point attribute to test instead of wiring `mask`. A name the points do not carry is refused,
naming the ones they do.
- `equals` (`String | Float`): keeps the points whose `attribute` equals this instead of testing it against
`threshold`. It is a number, or a label: one of the texts a text attribute holds, or one of the names the points'
detail `labels` gives a numeric one (how one solver's particles are split back by the `stream` their source named).
## Outputs
- `points`: the points that stay.
# geometry.delete (procNode)
Deletes the triangles a test selects. A triangle is selected when its value is strictly above `threshold`. The value
each triangle is tested by is the `mask` field when it is wired, else the named primitive `attribute`
(`field.attribute`), else 0. `invert` deletes the triangles the test did not select. `elements.keep` on the primitives
does the removal: every point stays, and the triangles that remain keep their corners and attributes, counted on the
device.
## Inputs
- `geometry` (`Curves | Surface`): whose triangles are thinned. Point and detail attributes carry over as they are;
primitive and vertex attributes stay with the triangles that survive.
- `mask` (`Field`): read once per triangle for whether it goes. Wired, it is what the selection reads, and `attribute`
is read only when this is empty.
- `threshold` (`Float`, default 0.5): a triangle is selected when its value is strictly above this. Raising it deletes
less.
- `attribute` (`String`): a primitive attribute on the geometry to read the selection from, instead of a wired mask. A
name the triangles do not carry is refused.
- `invert` (`Bool`, default false): true deletes every triangle the test did not select, so a mask naming what to keep
cuts away the rest.
## Outputs
- `geometry`: the geometry with the selected triangles removed.
# shader (nodebox)
An author's own compute shaders as nodes. shader.compute runs a .computeShader over the elements of a geometry through the device.kernel op: each buffer binding is a column of the geometry, each param a knob of the node, and what the shader writes comes out as columns.
# scene (nodebox)
scene.query finds entity ids by name, component and hierarchy, scene.fetch brings entities in as points carrying their pose and components, scene.input is the entity the graph stands on, and scene.water is the water the scene holds.
# geometry.separate (procNode)
Splits a geometry's triangles in two by a test. The value each triangle is tested by is the `mask` field when it is
wired, else the named primitive `attribute` (`field.attribute`), else 0. Two comparisons against `threshold` follow
(`field.compare`): strictly above, and at or below. `elements.keep` on the primitives runs once for each, so `a` holds
the triangles above and `b` the rest. Both keep every point, and between them they hold every triangle once.
## Inputs
- `geometry` (`Curves | Surface`): whose triangles are divided. Point and detail attributes carry to both halves as they
are; primitive and vertex attributes stay with each half's own triangles.
- `mask` (`Field`): read once per triangle for which half it joins. Wired, it is what the split reads, and `attribute`
is read only when this is empty.
- `threshold` (`Float`, default 0.5): a triangle goes to `a` when its value is strictly above this, and to `b`
otherwise.
- `attribute` (`String`): a primitive attribute on the geometry to read the split from, instead of a wired mask. A name
the triangles do not carry is refused.
## Outputs
- `a`: the triangles whose value is strictly above `threshold`.
- `b`: the rest. A call answers both as a record by output name.
# sim (nodebox)
Simulation nodes: sim.spawn, sim.force, sim.collider, sim.scene_colliders, sim.particles and sim.shader, built on the runtime's g:state and g:step.
# abiBox
A node for the procgenAbi suite: it is called through the builder and written out in a graph.
# mesh
The primitive solids and sheets a graph starts from: `mesh.box`, `mesh.sphere`, `mesh.cylinder`, `mesh.cone`, `mesh.capsule`, `mesh.octahedron`, `mesh.torus`, `mesh.rounded_box`, `mesh.plane` and `mesh.grid`. Each is made on the device from lattices and fans of the `make` kernel, with positions, normals and texture coordinates written as formulas over the lattice, and answers triangles centred on the origin that fill the box its parameters name.
## Conventions
- Each node's folder name is its call name inside the box: `mesh.<folder>`.
- Parameters keep the names and defaults of the op each node replaces.
- `mesh.lattice` makes the lattices and fans the presets share, `mesh.spherical` the point of a sphere and `mesh.cube_faces` the faces of a cube; a preset writes its positions, normals and texture coordinates as formulas over the lattice.
# io
The ways in and out of a graph: `luau` runs a function of a Luau module on the CPU, `output` shows a value in the world and answers a Handle, `export` saves a value as an asset, and `engine` calls the engine bridge the core reading nodes and `export` use. `output` holds `show.module` and `engine` holds `engine.module`, the functions they call.
# entity (nodebox)
entity.transform and entity.bounds read one entity's pose and bounds as values, entity.points reads a set of entities as points, and entity.geometry reads the mesh an entity's Model draws as a Geometry.
# entity_resolve
The id behind an entity reference, whatever shape the reference took.
An entity is written down three ways: as an **id string**
(`"ent_5d305b4377f8d505"`), as an **entity proxy** (the table carrying
`.position`, `.component`, `.name`), and — by a person or an agent typing a
call — as the **display name** the entity carries in its Name component.
`entity(ref)` answers for the first two. A call that hands it a name gets nil
back, and code that reads that nil as "the entity is gone" either indexes it or
writes somewhere else entirely. This module is the one place that turns any of
the three into the id every engine call takes.
## Surface
```
local resolve = require("modules.entity_resolve")
resolve.id(ref) -> string? -- the id, or nil when nothing matches
resolve.idOrError(ref, slot) -> string -- the id, or a raise naming the miss
```
`ref` is an id string, a display name, or an entity proxy. `slot` names the
argument the reference arrived in, so the raise reads as the call the caller
wrote rather than as a line number inside the callee.
A proxy or an `{ id = ... }` record answers the id written on it. A string is
the shape the scene has to answer for, since a lookup is the only thing that
can say whether it is a name or an id — and both probes stay silent for one
that matches nothing: a name is tested with `entity.find`, which answers for an
id as well, and then as an id with `entity.exists`, which raises for a string
naming a live entity and is therefore probed under `pcall`. A string matching
nothing reaches the caller as `nil`, with nothing already written to the log on
its behalf.
## Examples
Accept whichever shape the caller wrote, then act on the id:
```lua
local resolve = require("modules.entity_resolve")
local function raise(target, dy)
local id = resolve.idOrError(target, "target")
local e = entity(id)
e.localPosition = { e.localPosition.x, e.localPosition.y + dy, e.localPosition.z }
end
raise("crate_07", 2) -- a display name
raise("ent_5d305b4377f8d505", 2) -- an id
raise(entity.find("crate_07"), 2) -- a proxy
```
Answer for a reference that may match nothing, without raising:
```lua
local id = resolve.id(maybeGone)
if id ~= nil then
entity(id).despawn()
end
```
# display_writers
The ordered record of what is setting the chain's display transform.
`__display__` carries one curve and one exposure for the whole frame. Several
things write them — every `global` `PostProcess` region with
`setsDisplay = true`, a scene's opening state, an agent's own `setProperty`
call — and this module records the live ones, newest last.
```lua
local DisplayWriters = require("@builtin::modules.display_writers")
DisplayWriters.push("look", { curve = 2, exposure = 1.5 }) -- ACES, +2/3 stop
DisplayWriters.remove("look") -- back to what stood
```
- `read()` — what `__display__` holds now: the value the last write left, or the
default the renderer declares for a property nothing has written.
- `push(key, { curve, exposure })` — show those values and record `key` as the
writer holding them. A key already recorded moves to the newest position.
- `remove(key)` — drop that writer. The display then shows the newest remaining
writer's values, or what stood before the first writer arrived when none
remain.
- `count()` — how many writers are live.
The record is one shared list, so the writers come off in any order: removing
the older of two leaves the newer one's values standing, and removing the newer
leaves the older's.
The exposure package drives `__display__.exposure` alone, from a GPU readback,
every tick it meters, and keeps that outside this list — so a region that
arrives or leaves sets the exposure and the package's next readback sets it
again. The package holds its own record of the exposure that stood before it
started metering and hands that back when it stops.
# vfs Module
Public Luau surface over the `__vfs` Internal FFI namespace — the
engine's virtual filesystem.
## Purpose
Wrap the raw `__vfs.*` FFI namespace in a typed Luau table that gets
auto-injected as `_G.vfs` via the prelude. The standard read / write /
move / remove / mkdir / list / exists / readAsync / reload / evict
surface, plus trusted-only `watch` / `unwatch` for the bootstrap VM.
`vfs.read` yields on a byte-cache miss: the module's last step installs
the `vfs_async_read` fallback over the synchronous read, so every VM that
holds this namespace, the trusted VM included, reads a path through the
same two steps and gets the same bytes for it.
## Usage
```luau
-- Read / write
local src = vfs.read("@builtin/components/Camera.luau")
vfs.write("/zero/source/notes.md", body)
-- What a write landed: where the bytes went, and what the static passes
-- read in the Luau among them
local ok, report = vfs.write("/zero/source/game/Vent.component/init.luau", code)
if report.diagnostics then
for _, d in report.diagnostics do
print(d.severity, d.path, d.line, d.message)
end
end
if not report.durable then
log.warn(report.warning)
end
-- Listing
for _, e in ipairs(vfs.list("/zero/source")) do
print(e.isDirectory and "[d] " or " ", e.name)
end
-- Async fetch
local png = task.await(vfs.readAsync("/zero/runtime/screenshots/last.png"))
-- Reclaim RAM after processing a big binary
vfs.evict("/zero/source/textures/imported_huge.png")
-- Reload a module after editing its source on disk
if vfs.reload("@mylib/utils.helpers") then
local m = require("@mylib/utils.helpers") -- sees the new source
end
```
`vfs.reload(identity)` answers whether a module was cached under that name
and has now been dropped. A `false` says the name matched nothing — either a
misspelled identity or a module this VM never required — so the next
`require()` returns whatever it would have returned anyway.
A module that caches state derived from OTHER files keeps serving the old
value when those files are rewritten, since only its own source is watched.
Reload it by identity to make the next `require()` recompute.
## Exports
- `vfs.read(path, opts?) -> string?`
- `vfs.write(path, content, opts?) -> (boolean, WriteReport | string)`
- `vfs.move(src, dst, opts?) -> (boolean, WriteReport | string)`
- `vfs.copy(src, dst) -> (boolean, WriteReport | string)`
- `vfs.remove(path, opts?) -> (boolean, string?)`
- `vfs.mkdir(path, opts?) -> boolean`
- `vfs.list(path?) -> { VfsListEntry }`
- `vfs.exists(path, opts?) -> boolean`
- `vfs.readAsync(path, opts?) -> promiseId`
- `vfs.reload(modulePath?) -> boolean`
- `vfs.evict(path, opts?) -> boolean`
- `vfs.mutationSeq() -> number`
- Trusted-only: `vfs.watch(path, callback) -> number` /
`vfs.unwatch(watcherId) -> boolean`.
# debugViz_driver
The ambient edit-mode debug-visualization driver. Requiring it registers the
built-in providers (bounds, frustum, bones, colliders, icons, wireframe) with
`@builtin::modules.debugViz`; the editor entrypoint requires it and calls
`tick(dt)` every frame.
```lua
local DebugVizDriver = require("@builtin::modules.debugViz_driver")
-- In the editor entrypoint's per-frame hook:
DebugVizDriver.tick(dt)
```
- `register()` — register the built-in providers. Idempotent: `registerProvider`
replaces by name and a guard makes a re-require a no-op, so a category toggled
off in the panel survives a module reload. Called on require and by `tick`.
- `tick(dt)` — per-frame hook for both modes. Edit mode always rebuilds the
overlay; play mode rebuilds only while a debug session is active
(`debugViz.setPlaySession`) and otherwise clears the batch, so play mode
carries no debug-draw cost by default.
There is no per-entity opt-in — the overlay is editor tooling, driven here for
the whole scene. Toggle categories through the Debug → Visualization panel or the
`debug` toolbox.
# postReference_focusBars
Four rows of bar pairs, 8 pairs across the top row and doubling on each row below: the focus chart a depth-of-field reading is taken off.
Drawn unlit by `@builtin::shaders.referenceChart`, so a picture of it is measured against the light its properties state. Part of the post-verification reference scenes (`@builtin::scenes.postReference.*`); `@builtin::modules.postMetrics` reads them.
·computeshader · born here
❒asset # loops_cut
Finds where each ring and chain of linked edges starts and ends. Stage 0 marks
the first edge of every list (a chain's head, a ring's smallest edge); stage 1
makes the edge before each first edge the end of its list. The lists that result
are what `jump` ranks. See `loops` for the whole sequence.
Over a live count (`COUNT=device`, see the abi README) the edges are the first
`domain_count` slots of a capacity `n`, and the `n` param is not read. A slot past
the live edges is no head (`head` 0) and ends its own list (`cut` is the slot itself).
It binds 7 storage buffers (6 in the plain variant).
# lumen_player
A UI template screen.
# logs Module
Public Luau surface over the `__logs` Internal FFI namespace —
read-only, filterable query API over the engine's in-memory log ring.
## Purpose
Wrap the raw `__logs.*` FFI namespace in a typed Luau table that gets
auto-injected as `_G.logs` via the prelude. The store is the single
in-engine source of truth for everything the engine has logged
recently — Rust `console_log!`, bridged third-party `log::*` events
(wgpu validation, etc.), and Rust panics. The same store backs the
`/engine/logs` HTTP endpoint and `/zero/runtime/logs/engine`.
## Usage
```luau
-- Did anything break?
for _, e in ipairs(logs.errors(10)) do print(e.timestamp, e.message) end
-- Filtered query
for _, e in ipairs(logs.query({ level = "warn", type = "RENDERER", limit = 50 })) do
print(e.message)
end
-- Case-insensitive grep
local hits = logs.find("MY_MARKER")
-- Aggregate counters (lifetime, survive ring eviction)
local c = logs.count()
print(c.errors, "errors,", c.warnings, "warnings,", c.dropped, "dropped")
```
## Exports
- `logs.query(opts?) -> { LogEntry }`
- `logs.errors(limit?) -> { LogEntry }`
- `logs.warnings(limit?) -> { LogEntry }`
- `logs.find(text, limit?) -> { LogEntry }`
- `logs.tail(limit?) -> { LogEntry }`
- `logs.count(opts?) -> LogCounts`
- `logs.template(message) -> string`
- `logs.clear() -> boolean`
## Origin
A line a script logged carries `script` and `entity` fields, so
`logs.query({ entity = "ent_9f3c" })` asks for one entity's output rather than
guessing at what its messages look like. Engine lines carry neither.
## Context
`logs.query({ level = "error", context = 3 })` also returns the three lines
either side of each match, marked `context = true`. What led up to a failure is
made of lines the filter rejected, so a filtered view cannot otherwise show it.
## The other ways in
The `logs` toolbox reads the same ring through the tool surface — its `search`,
`tail`, `errors`, `summary` and `clear` tools — and adds grouping of repeated
messages. This module is the direct Luau path to that ring.
The ring is also mounted as plain files: `/zero/runtime/logs/engine` for every
level, `/zero/runtime/logs/errors` for the ERROR-and-above subset, and
`/zero/runtime/logs/script` for what scripts logged. Those render the most
recent 500 lines with tool-call records included, where a query here reaches
the whole ring and leaves them out unless asked for.
The ring starts empty at every boot. `/zero/runtime/logs/previous` holds the
previous session of the world the engine booted on: how it ended, then the last
lines it wrote.
# postReference_skyRamp
A sky-blue ramp of light from 0.02 to 0.6 across: a clear sky's gradient.
Drawn unlit by `@builtin::shaders.referenceChart`, so a picture of it is measured against the light its properties state. Part of the post-verification reference scenes (`@builtin::scenes.postReference.*`); `@builtin::modules.postMetrics` reads them.
·computeshader · born here
❒asset # hash
The first step of a spatial hash over a run of points: the bucket of the cell
each point stands in. The sort kernel orders the points by bucket, and
`hash_starts` then states where each bucket's points start and end in that
order. The keys, the order and the bucket spans, with the cell size, are the
index `hash_count`, `hash_nearest` and `hash_gather` search. Exact: the same
points give the same keys.
Over a live count (`COUNT=device`, see the abi README) `n` is the capacity and the
first `domain_count` slots are live. A slot at or past the live count holds the key
`0x7fffffff`, which is past every bucket: the sort (over the same live count, or
over the capacity) places it after every live key, and `hash_starts` over the
capacity states every bucket's span over the live points only. It binds 3 storage
buffers (2 in the plain variant).
# http Module
Public Luau surface over the `__http` Internal FFI namespace — async
HTTP GET / POST returning JSON or raw bytes.
## Purpose
Wrap the raw `__http.*` FFI namespace in a typed Luau table that gets
auto-injected as `_G.http` via the prelude. Every method returns a
promise handle: pass it to `task.await(handle)` to block the
coroutine until the request completes.
## Usage
```luau
-- GET JSON
local data = task.await(http.get_json("https://api.example.com/info"))
-- POST JSON
local resp = task.await(http.post_json(
"https://api.example.com/users",
{ Authorization = "Bearer " .. token },
{ name = "Alice", age = 42 }
))
-- Download a binary asset
local bytes = task.await(http.get_bytes("https://example.com/sound.ogg"))
vfs.write("/zero/source/audio/sound.ogg", bytes)
```
## The bound a request carries
Every method takes a deadline as its last argument: how long the request is
given to be answered, in milliseconds. Left out, the engine's own bound
applies, which is what settles a call against a peer that accepts the
connection and answers nothing. Passed as 0, no bound applies, which a
transfer whose length the caller knows better than the engine does names.
A request given up on resolves like any other failure, with the engine's error
envelope: `{ "__http_error": true, "error": "Network error: no answer within
<n>ms" }`.
```luau
-- Give this call three seconds to be answered
local credential = task.await(http.post_json(tokenUrl, headers, nil, 3000))
-- A download whose length the caller knows better than the engine does
local bytes = task.await(http.get_bytes(assetUrl, nil, 0))
```
## Exports
- `http.get_json(url, headers?, timeoutMs?) -> promiseId`
- `http.get_bytes(url, headers?, timeoutMs?) -> promiseId`
- `http.post_json(url, headers?, body?, timeoutMs?) -> promiseId`
- `http.post_bytes(url, headers?, body?, timeoutMs?) -> promiseId`
- `http.request(method, url, headers?, body?, timeoutMs?) -> promiseId`
- `http.request_raw(method, url, headers?, body?, timeoutMs?) -> promiseId`
# audio Module
Public Luau surface over the `__audio` Internal FFI namespace — the
engine-native CPU audio codec for the canonical ZAUD payload (backed by
`zero_audio::blob`), and the engine's audio observation surface. Auto-injected
as `_G.audio` via the prelude.
## Observing what is audible
`audio.observe()` answers "what is this engine sounding right now, and why is a
source not" in one read. It reports the MIXER's own bookkeeping, which is what
makes it different from reading the `Audio` component's fields back: those carry
what content asked for, and `AudioSource.state` carries the value `:play()` wrote
there. Every voice report carries both — `mixerState` beside `componentState` —
so the two are compared rather than confused.
- `audio.observe() -> AudioObservation` — the whole surface in one call:
`{ available, sampleRate, channels, sampledAtMs, voices, audibleCount,
listener, voiceAccounting, levels, profile }`.
- `audio.voices() -> { AudioVoice }` — every live source with the mixer's
opinion of it: the clip, the channel, whether its bytes are resident, the
mixer's playback state, the position in the clip and the clip's duration, the
gain it reaches the mix at stage by stage, and for a spatial source its
emitter, the listener distance, `maxDistance` and `rolloff`.
- `audio.voice(entityId) -> AudioVoice?` — the voice on one entity.
- `audio.whySilent(entityId) -> reason?, detail?` — why a source makes no sound,
from a closed set: `noBackend`, `noDevice`, `notResident`, `neverStarted`,
`refused`, `paused`, `ended`, `gainZero`, `channelSilent`, `masterSilent`,
`outOfRange`. `nil` while it IS sounding; the second return carries the
mixer's own words when it refused the source.
- `audio.device() -> AudioDeviceStatus` — the output device the mix reaches:
`state` (`"open"` / `"silent"`), the device an open stream runs on, the rate
and channel count it runs at, and the counters for what the engine has been
through keeping a stream open — `faults`, `changes`, `reopens`, `failedOpens`,
`glitches` and `lastError`. An output device that goes away leaves the mixer running and
every voice reporting `noDevice` until the engine opens a stream again.
- `audio.levels() -> AudioLevels` — the master mix's peak and RMS over the
meter's most recent closed window, with no recording involved. `windows`
counts the windows the mixer has closed since it started, so a silent mix
reads differently from a mixer that is not running.
- `audio.peakSince(window) -> number?` — the loudest peak the master mix
reached across the windows that closed after `audio.levels().windows` stood
at `window`. `levels()` carries the window that closed last, so a reader sees
the windows its own frames land on; this spans all of them, which is what
measuring a sound shorter than the gap between two reads takes. `nil` when
the meter holds no peak for the span — nothing has closed since `window`, or
it reaches further back than the meter's history of recent windows.
- `audio.listener() -> AudioListenerState` — where the scene is heard from, and
how many active listeners exist.
- `audio.voiceAccounting() -> AudioVoiceAccounting` — voice capacity, in use
and free read off the mixer's own tracks, so `free` is what the next play
call is granted or refused against. The mixer keeps two pools and each is
reported apart: `capacity` / `inUse` / `free` are the main track, carrying
the NON-spatial voices, while a spatial voice plays through its own
sub-track and is counted by `spatialCapacity` / `spatialInUse`.
`sourcesHolding` counts both pools from the sources that own them, so it
equals `inUse + spatialInUse` while every voice the mixer carries answers
to a source — and a mixer carrying voices no source reaches is the
difference between those numbers.
- `audio.profile() -> AudioProfile` / `audio.resetProfile()` — what the audio
subsystem costs: the streaming pump, clip decode and encode, voice starts, and
building the observation. Every total is a sum across a window rather than a
per-frame figure: the window runs from the last `resetProfile` — or from
engine start when nothing has reset it — and its length comes back as
`windowMs` and `frames`. Reset, let frames pass, then divide a total by
`frames` for what a frame costs.
The same document is served at `/runtime/active_audio`, with one file per source
under `/runtime/active_audio/voices/<entity>`, as the audio systems published it
after the last frame; `audio.observe()` leaves out the voices of entities a
script has despawned since. It answers in edit mode as well as play mode.
## Setting what the mix is heard at
Every voice names a channel — the `Audio` component's `channel` field, `"sfx"`
unless the source says otherwise — and is heard at its own `volume` scaled by
the level of that channel and by the master level of the mix. Both levels reach
voices that are already playing, so a settings-screen slider is heard as it
moves, and both come back per voice as `gain.channel` and `gain.master`.
- `audio.mixer() -> AudioMixerLevels` — `{ master, muted, channels }`. A
channel absent from `channels` has had no level set and plays at unity, so a
scene that invented its own channel names is heard exactly as authored until
something turns one of them down.
- `audio.setChannelVolume(channel, volume)` — set one channel's level. Scales
every voice on that channel and nothing else.
- `audio.setMasterVolume(volume)` — set the level of the whole mix.
- `audio.setMuted(muted)` — silence the mix whatever the levels read; unmuting
hands them back untouched.
```luau
audio.setChannelVolume("music", 0.3) -- music at 30%, sfx untouched
audio.setMasterVolume(0.8)
audio.setMuted(true) -- every voice reports masterSilent
```
## Codec functions
- `audio.encode(sourceBytes, opts?) -> bytes?, err?` — decode container
audio (ogg / mp3 / wav / flac) and re-encode it into a ZAUD payload.
- `audio.encodePcm(pcm, sampleRate, channels, opts?) -> bytes?, err?` —
encode raw interleaved f32 PCM (a `buffer` or a binary string of
little-endian f32, OR a flat number array) into a ZAUD payload.
- `audio.decode(zaud) -> pcm?, sampleRate?, channels?` — decode a ZAUD
payload to interleaved f32 PCM (`pcm` is a binary little-endian-f32 string).
- `audio.info(zaud) -> table?, err?` — read a ZAUD header:
`{ sampleRate, channels, frames, durationMs, codec, loopStart, loopEnd, packets }`.
- `audio.loopSeam(zaud) -> table?, err?` — measure what a clip's samples do
where a whole-clip loop wraps:
`{ ratio, step, meanStep, maxStep, channel, seamless, threshold, channels }`.
- `audio.peaks(zaud, buckets) -> { number }?, err?` — the loudest sample in
each of `buckets` (an integer from 1 to 4096) equal spans of a clip, across
its channels, held to 1: the shape a waveform is drawn from. A span is
`frames // buckets` frames; the payload is decoded a block at a time, so the
work never holds the clip's whole PCM. A payload that is not a clip, or stops
short, answers `(nil, err)`.
Every sample an encode is handed must be a finite number. A NaN or an infinity
carries no signal, and both codecs turn one into a payload whose header reads as
a healthy clip and whose samples sound as nothing — so `audio.encode` and
`audio.encodePcm` return `(nil, err)` naming how many samples fail and where the
first one sits, and `audio.info` reads a PCM payload's samples the same way and
answers for what the payload carries rather than for what its header claims.
That makes a divergent filter, a `0/0`, or a `math.log(0)` in a bake visible at
the call that would otherwise have written it.
```lua
local zaud, err = audio.encodePcm(samples, 22050, 1, { codec = "pcm" })
if not zaud then
print(err) -- "…: 3 of 176400 samples are not finite, first at index 88214"
end
```
Whole-clip repeat is the loop the runtime plays, so the join between a clip's
last frame and its first is heard once a lap, and a bed whose head does not
meet its tail ticks for as long as it plays. `audio.loopSeam` is the engine's
reading of that join: the step the wrap makes (`|x[1] - x[frames]|`) against
the step the signal ordinarily makes between neighbouring samples, as
`ratio = step / meanStep`. Reading it as a ratio puts the figure in the units
the signal itself moves in, so a generated ambience travelling a thousandth of
a unit per sample and a synth drone travelling a hundredth are judged the same
way. A bed whose partials complete a whole number of cycles across the buffer
reads near 1; one carrying a percussive strike at its head and silence at its
tail reads in the tens. `seamless` is `ratio <= threshold`, and `threshold` is
the ratio `asset.create("soundClip", ...)` warns past.
```lua
local seam = audio.loopSeam(clipRef:getBytes())
print(seam.ratio, seam.seamless, seam.threshold) -- 41.0 false 8
-- `ratio` is the worst channel's; `channel` names it and `channels` carries
-- each one's own { ratio, step, meanStep, maxStep }.
print(seam.channel, seam.channels[seam.channel].step)
```
The reading is taken on the DECODED samples, so it answers for what the codec
left behind rather than for the buffer that was handed to the encoder, and for
a clip that arrived already encoded and whose source buffer nobody holds. That
costs a decode of the whole payload — `audio.info` reads a header without one.
A clip is counted in frames — `channels` samples that sound together — and a
header's `frames * channels` is the sample count `audio.decode` hands back. So
the buffer `audio.encodePcm` takes is a whole number of frames, and one whose
length leaves a tail over comes back as `(nil, err)` naming the whole frames it
holds and the samples past them.
```lua
local zaud, err = audio.encodePcm(fiveSamples, 22050, 2, { codec = "pcm" })
if not zaud then
print(err) -- "…: 5 samples is not a whole number of 2-channel frames — 2 whole frames and 1 left over"
end
```
A byte payload carries a sample per 4-byte lane, so `audio.encodePcm` takes the
`buffer` `microphone.samples()` hands back and the string `audio.decode` returns
as they are, and recording a capture into a clip is those two calls:
```lua
local status = microphone.status()
local zaud = audio.encodePcm(microphone.samples(), status.sampleRate, 1)
```
A byte length that stops partway through a lane describes a sample the caller
holds only part of, and comes back as `(nil, err)` naming the whole samples it
holds and the bytes past them:
```lua
local zaud, err = audio.encodePcm(twentySevenBytes, 22050, 1, { codec = "pcm" })
if not zaud then
print(err) -- "…: 27 bytes is not a whole number of f32 samples — 6 whole samples and 3 bytes left over"
end
```
`opts` (all optional): `codec` (`"opus"` default / `"pcm"`), `bitrateKbps`
(default 96), `vbr` (default true), `sampleRate` (resample target, default
0 = keep source), `forceMono` (default false), `loopStart` / `loopEnd`
(frame indices, default 0). Pure CPU — runs on native, WASM, and headless.
# entity
The `entity` global: spawning, despawning, finding, and the reference you get
back when you call it.
`entity` is a table you can also call. `entity.spawn(...)` and its siblings act
on the scene; `entity(id)` hands back an **entityRef** — the per-entity surface
carrying `.position`, `.component`, `:despawn()` and the rest.
```luau
local id = entity.spawn("Crate")
local e = entity(id)
e.position = { 0, 1, 0 }
e.component.add("Model", { mesh = "@builtin::meshes.cube" })
```
## What lives here
| File | What it is |
|------|-----------|
| `init.luau` | The `entity` global — typed wrappers over the Internal `__entity` namespace, plus the coercions and `entity.hierarchy`. |
| `members.module/` | The entityRef's member set: every member's kind, type and description, in one table. |
| `ref.module/` | The entityRef metatable — what `entity(id)` returns and how a member access dispatches. |
## Where the entityRef's surface is defined
`members.module` is the single description of it. The runtime's accepted-member
set derives from that table, the LSP's `entityRef` type is generated from it,
and each member's documentation is published from it. Nothing describes the
entityRef anywhere else — a second description could only drift from this one.
To add a member: declare it in `members.module` with its `kind`, `type` and
`doc`, and implement it in `ref.module`. A member missing from the table is
refused by the runtime; a member in the table with no implementation answers
nil.
## Related
- `entity.hierarchy` — swap a blockout entity for a generated asset.
- `ecs.*` — the ECS storage surface. **ECS components are not script
components**; `entity(id).component` is the script-component surface and the
two are separate systems.
# volumetrics
Volumetric (3D-texture) rendering, end to end, in one package. Built
entirely on the engine's generic `compute.*` GPU primitives — no
Rust changes are needed to author new volumetric content.
## Layout
| Asset | Identity | Role |
|---|---|---|
| `Volume.component` | `@builtin::systems.volumetrics.Volume` | Attach a 3D voxel texture to an entity; raymarches it through the entity's transform AABB. |
| `VolumetricSky.component` | `@builtin::systems.volumetrics.VolumetricSky` | Singleton procedural cloud layer with TAA. |
| `ParticipatingMedium.component` | `@builtin::systems.volumetrics.ParticipatingMedium` | Mark an entity as fog/smoke/dust that attenuates the light passing through it. |
| `mediaTransmittance.module` | `@builtin::systems.volumetrics.mediaTransmittance` | Light-space optical-depth map: the registry, the settings, and the basis the map is built in. |
| `mediaShadow.renderFeature` | `@builtin::systems.volumetrics.mediaShadow` | Builds the optical-depth map and attenuates each surface by the density in front of it. |
| `volume.module` | `@builtin::systems.volumetrics.volume` | Builders (`noise`/`sphere`/`box`/`wrap`) + the `.zvol` header reader + occupancy + brick paging + the per-entity raymarch dispatch. |
| `VolumeSequence.component` | `@builtin::systems.volumetrics.VolumeSequence` | Play an open volume sequence through an entity's transform AABB. |
| `volumeSequence.module` | `@builtin::systems.volumetrics.volumeSequence` | A run of `.zvol` frames streamed through a fixed ring of resident volumes. |
| `volumeDemos.module` | `@builtin::systems.volumetrics.volumeDemos` | Shared pipeline for the showcase scenes (fill + emissive raymarch + composite). |
| `volumetricSky.module` | `@builtin::systems.volumetrics.volumetricSky` | Cloud volume + raymarch + TAA + composite orchestration. |
| `shaders/*.shader` | `@builtin::systems.volumetrics.shaders.*` | `volume_raymarch`, `volume_pages`, `volume_pack`, `cloud_noise[_v3/_v5]`, `cloud_raymarch[_v3/_v4/_v5]`, `cloud_taa`. |
| `importers/vdb_to_zvol.importer` | (auto-discovered) | `.vdb` → dense `.zvol` via the `openvdb` WASM plugin (procedural fallback when the plugin isn't loaded). |
| `presets/cumulus_default.preset` | `@builtin::systems.volumetrics.cumulus_default` | Canonical full `VolumetricSky` weather preset. |
| `demo/*.scene` | `@builtin::systems.volumetrics.demo.*` | `sdf_solids`, `smoke_plume`, `nebula`, `godrays`, `zvol_viewer`, `sky`. |
## Path convention — fully-qualified builtin identities
Every intra-package reference uses the asset's fully-qualified builtin
identity, the same convention every other builtin package follows:
```luau
local Volume = require("@builtin::systems.volumetrics.volume")
local Sky = require("@builtin::systems.volumetrics.volumetricSky")
local V = require("@builtin::systems.volumetrics.volumeDemos")
-- shader identities, e.g. asset.resolve("@builtin::systems.volumetrics.shaders.volume_raymarch")
```
Qualified identities are caller-independent: they resolve identically
from a module, a scene entrypoint, or a component. Caller-relative
(`.sibling`) requires are deliberately avoided here because the
component runtime caller identity is `@builtin::components.<Name>`, so a
`.sibling` require from a component resolves against the wrong root.
(An earlier revision of this package used a `~volumetrics` package-root
alias for relocatability; that alias is not resolvable on current main,
so the package uses qualified identities throughout.)
## Usage
```luau
-- Per-entity volume. The component resolves the name through `Volume.get`,
-- so a volume paged into a brick pool renders from the pool under the same
-- name:
local Volume = require("@builtin::systems.volumetrics.volume")
local cloud = Volume.noise({ name = "cloud", size = 64, scale = 4 })
cloud:buildSparse({ brickSize = 8 })
cloud:releaseDense()
entity(id).component.add("Volume", { volumeName = "cloud", density = 6.0 })
-- Procedural sky:
local sky = entity.spawn("sky")
sky.component.add("VolumetricSky", {})
preset.apply("cumulus_default", { entity = sky.id, component = "VolumetricSky" })
-- VDB import: drop a `.vdb` into the VFS; the importer writes a `.zvol`
-- alongside it. Load it with the zvol_viewer demo scene.
-- Animated run: one `.zvol` per frame, played through a bounded ring.
local Seq = require("@builtin::systems.volumetrics.volumeSequence")
Seq.open({ name = "plume", frames = framePaths, resident = 3, fps = 24 })
entity(id).component.add("VolumeSequence", { sequenceName = "plume", density = 6.0 })
```
## Plugin dependency
The VDB importer delegates real OpenVDB parsing to the `openvdb` plugin
in the builtin library, `@builtin::plugins.openvdb`. Without the plugin
the importer falls back to a deterministic procedural torus so the
pipeline stays functional.
# channel Module
Public Luau surface over the `__channel` Internal FFI namespace —
keyframe-channel sampling primitives.
## Purpose
Wrap the raw `__channel.*` FFI namespace in a typed Luau table that
gets auto-injected as `_G.channel` via the prelude. Channels are the
keyframe-track substrate: one per animation curve (translation x,
rotation, blend weight, …). Sampling writes the interpolated
value(s) into a buffer at a specified offset — no per-sample
allocation, no per-bone FFI round-trip.
## Usage
```luau
local h = channel.create({
times = { 0.0, 0.5, 1.0 },
values = { 0, 1, 0, 1, 1, 0, 0, 0, 1 },
stride = 3,
interp = "linear",
})
channel.sampleInto(h, 0.25, buf, 0)
channel.sampleManyInto(h, 0.25, buf, { 0, 9, 18 })
local x, y, z = channel.sampleVec3(h, 0.25)
channel.destroy(h)
```
## Exports
- `channel.create(opts) -> number?`
- `channel.destroy(handle) -> boolean`
- `channel.sampleInto(ch, time, buf, offset) -> boolean`
- `channel.sampleManyInto(ch, time, buf, offsets) -> boolean`
- `channel.sampleVec3(ch, time) -> (x, y, z) | nil`
- `channel.sampleQuat(ch, time) -> (x, y, z, w) | nil`
# icons
Phosphor icon constants. The phosphor font is registered as a named
family `phosphor` in the engine, so render icons by setting
`style.fontFamily = "phosphor"` on the label that contains them.
The font is ALSO appended to the proportional family as a fallback, but
the fallback path is unreliable for PUA codepoints in egui 0.34 (see
issue #2191) — always pass `fontFamily = "phosphor"` for icons.
Curated set covering DAW transport, media controls, file management,
desktop shell, editor chrome, and common state indicators. For icons
not listed here, see https://phosphoricons.com — pass the codepoint as
a UTF-8 string literal directly.
## Exports
- `Icon.styled(extra: LabelOpts?) -> LabelOpts` — build a `UI.label` opts table with `style.fontFamily = "phosphor"` applied. Merges with the supplied `extra`.
- `Icon.<Name>: string` — UTF-8 codepoint constants. Categories:
- Transport / media: `Play`, `Pause`, `Stop`, `Record`, `FastForward`, `Rewind`, `SkipForward`, `SkipBack`, `Repeat`, `Shuffle`.
- Audio / DAW: `Microphone`, `Headphones`, `Equalizer`, `Sliders`, `Metronome`, `MusicNote`, `Waveform`, `SpeakerHigh`, `SpeakerLow`, `SpeakerNone`, `SpeakerSimpleHigh`, `SpeakerMuted`.
- File / folder: `Folder`, `FolderOpen`, `File`, `FileText`, `FileAudio`, `FileVideo`, `FileImage`, `FileCode`, `FloppyDisk`, `Trash`, `UploadSimple`, `DownloadSimple`.
- Desktop / shell: `Desktop`, `DesktopTower`, `Monitor`, `Keyboard`, `Mouse`, `House`, `Sidebar`, `Cpu`.
- Editor: `Pencil`, `Plus`, `Minus`, `Check`, `MagnifyingGlass(Plus|Minus)?`, `Gear`, `Power`, `Eye`, `EyeSlash`, `Lock`, `LockOpen`, `Bell`, `Palette`, `Funnel`, `Clipboard`.
- Code / dev: `Code`, `Terminal`, `Bug`, `GitBranch`, `GitCommit`, `GitMerge`, `Database`, `Cloud`.
- Arrows / nav: `ArrowUp/Down/Left/Right`, `ArrowsClockwise`, `ArrowsIn`, `ArrowsOut`, `CaretUp/Down/Left/Right`.
- Layout / list: `List`, `GridFour`, `DotsThree`, `DotsThreeVertical`.
- State / status: `Info`, `Warning`, `WarningCircle`, `CheckCircle`, `XCircle`, `Question`, `Heart`, `Star`, `Tag`, `Hash`, `At`.
- Shapes: `Circle`, `Square`, `Triangle`.
- Productivity / shell apps: `Calculator`, `Notepad`, `NotePencil`, `Note`.
- Games / cards: `GameController`, `Bomb`, `Cards`, `Diamond`, `Spade`, `Club`.
Types:
- `LabelStyle = { fontFamily?: string, fontSize?: number, color?: string, [string]: any }`
- `LabelOpts = { style?: LabelStyle, [string]: any }`
## Usage
```luau
local Icon = require("@builtin::modules.icons")
UI.label(Icon.Play, { style = { fontFamily = "phosphor", fontSize = 24 } })
UI.label(Icon.Folder .. " Documents", {
style = { fontFamily = "phosphor", fontSize = 14 },
})
-- Or use the helper to enforce the phosphor family:
UI.label(Icon.Play, Icon.styled({ style = { fontSize = 24, color = "#fff" } }))
```
## Notes
- The constants are plain string values — each is a single UTF-8 Private
Use Area codepoint. Concatenate freely with surrounding text.
- `Icon.styled` mutates the supplied opts table (`extra.style` is
forced). Pass a fresh table per call site if you need to reuse opts.
- Curated subset only — for icons outside this set, paste the codepoint
from https://phosphoricons.com directly.
# serviceCall
The user-scope entry point for credit-metered service calls. A `.service`
handler calls `Service.invoke(offering, endpoint, opts)` instead of touching
the `service` primitive directly, getting one `(response, err)` contract with
the HTTP status already classified.
```lua
local Service = require("modules.serviceCall")
local resp, err = Service.invoke("origozero/mesh_gen", "create_preview", {
body = { prompt = "a wooden chest" },
})
if not resp then
handle:fail(err) -- "out of credits …", "not signed in …", "unknown service …"
return
end
if resp.status >= 300 then -- upstream relayed a non-2xx (charged 0)
handle:fail("upstream " .. resp.status)
return
end
local task = resp.body -- the upstream payload
```
`Service.invoke` returns the InvokeResponse `{ status, headers, body, charged,
balance }` on a successful round-trip, or `(nil, errMsg)` when the call could
not be charged or forwarded. `Service.balance()` returns the caller's integer
credit balance.
ZeroMind injects the provider secret and meters credits server-side; the
platform JWT and the real upstream URL are never visible to this script.
·computeshader · born here
❒asset # search
Binary search of a run of queries in a sorted column: the pick of a value from
a cumulative distribution, the segment of an arc length.
| Specialize | Options | Meaning |
|------------|---------|---------|
| `T` | `f32`, `i32` | the scalar the column and the queries hold |
| `SIDE` | `lower`, `upper` | which boundary a query answers |
`lower` answers the first place whose value is not below the query, `upper` the
first place whose value is above it, and both answer the length of the column
when no place qualifies. The greatest place whose value is not above a query is
`upper - 1`; with the cumulative sums of a run of weights, `upper` of a draw in
`[0, total)` is the index of the weight it lands in.
The column bound as `sorted` is the sorted run, every element of it. One thread
per query; the dispatch size `n` is the number of queries.
Exact: a search reads the column and nothing else, so the same inputs give the
same answers on every dispatch.
# numberSequence
Roblox-shaped scalar keyframe-curve value type. A `NumberSequence` is
an immutable curve of up to 64 keypoints, each carrying
`(time, value, envelope)`. The envelope is a per-keypoint random
range half-width applied at `:sample(t)` time; `:evaluate(t)` is
always deterministic.
Exposed as the `NumberSequence` Luau global via `--!global`. Authored
scripts call `NumberSequence.new(...)` directly without a `require`.
The Rust FFI lives at `__sequences.*` (registered by
`crates/zero_scripting/src/ffi/bindings/curves.rs`); this module is
the typed Luau wrapper.
## Exports
- `NumberSequence.new(...) -> NumberSequenceObj` — three signatures:
- `NumberSequence.new(v)` — constant value across `[0, 1]`.
- `NumberSequence.new(v0, v1)` — two-point lerp from `v0` to `v1`.
- `NumberSequence.new({ { time, value, envelope? }, ... })` —
explicit keypoints. `envelope` defaults to `0`. First key must
anchor at `time = 0`, last at `time = 1`. NaN / Inf rejected.
- `NumberSequence.deserialize(payload) -> NumberSequenceObj` —
rebuild from a `{ kind = "NumberSequence", keypoints = {...} }`
payload produced by `:serialize()`.
## Methods (called via `:`)
- `seq:evaluate(t) -> number` — deterministic linear interpolation at
`t`. `t` clamps to `[0, 1]`; NaN coerces to `0`.
- `seq:sample(t) -> number` — `evaluate(t) + (math.random() − 0.5)·2·envelope(t)`.
Per-VM `math.randomseed` controls jitter reproducibility.
- `seq:keypoints() -> { NumberKeypoint }` — array snapshot, sorted
ascending by time.
- `seq:duration() -> number` — always `1.0` for a well-formed
sequence.
- `seq:serialize() -> { kind, keypoints }` — scene-save payload.
- `seq:destroy()` — drop the FFI handle. Luau removed `__gc` on
tables, so eager cleanup is the caller's responsibility for
per-frame-rebuild patterns; otherwise the entry dies with the VM.
## Substrate
Backed by `zero_curves::Channel` (Linear interp). Each sequence
builds the value and envelope channels once at construct time and
reaches into them on every sample — zero per-call allocation, safe
for hot particle loops. Validation errors name the specific rule
(`"first keypoint must anchor at time = 0"`,
`"too many keypoints (max 64)"`, …).
Foundation for VFX property-over-lifetime: declarative particles
(#2572), GPU emitter (#873), beams, trails. Issue: #2673.
# material_remap
Canonical property/texture-role dictionary used when a material **swaps
shaders**. The new shader exposes a different vocabulary than the old one —
one shader's `MAIN_TEX` is another's `albedo` is a third's
`base_color_texture` — so values would be lost on a naive swap. This module
maps any known alias onto a canonical **role** so floats, colors, and texture
links carry across the swap to the best of our ability.
Consumed by `material.assetType`'s `setShader` ref method (and, transitively,
by the `appearance` toolbox's `swapShader`).
## API
```luau
local remap = require("modules.material_remap")
remap.propertyRole("MAIN_COLOR") -- "base_color" (scalar/color side)
remap.textureRole("MAIN_TEX") -- "base_color_texture" (texture side)
-- Remap current property values onto the new shader's accepted names.
local mapped, carried, dropped =
remap.remapProperties(currentProps, shaderRef:getProperties())
-- Canonicalize texture slot names so links bind on the new shader.
local texMapped, texCarried = remap.canonicalizeTextures(currentTextures)
```
## Why two tables
`albedo` as a **color** is the base-color factor; `albedo` as a **texture** is
the base-color map. `mat.yaml` keeps `colors:` / `floats:` separate from
`textures:`, so the caller always knows which side it's remapping. Splitting
`PROPERTY_ROLES` from `TEXTURE_ROLES` removes the ambiguity instead of guessing
from the value shape.
## Property remap vs texture remap
- **Properties** are remapped against the target shader's *real* reflected
field names (`shaderRef:getProperties()`, naga reflection). Direct name match
wins; otherwise role-to-role. Properties the new shader doesn't expose are
dropped.
- **Textures** are canonicalized to the engine's builtin on-disk slot
convention (`base_color_texture`, `normal_texture`, …) because the engine
does not yet surface a target shader's reflected texture-slot list to Luau.
Unknown slots pass through unchanged.
The alias lists are deliberately broad (glTF / Unity / Unreal / Godot /
hand-rolled WGSL conventions). Add new aliases here rather than special-casing
a shader at a call site.
# viewport_select
Turns a viewport pointer gesture into an editor selection change, applying the
standard modifier semantics, and drives the per-frame bridge that reads real
pointer + modifier input. Selection is written into the shared
`modules.api.editor.selection` service's `entity` scope, so the viewport, the
selection outline, and the outliner stay in lockstep.
A left press+release within a few pixels is a click-select; a larger left drag
is a **marquee** (rubber-band) that selects every entity whose screen position
falls inside the band. The band is drawn by `modules.api.editor.marquee_overlay`.
## Exports
- `M.applyClick(Sel, scope, hitId, mods)` — pure decision: `hitId == nil` clears
(unless a modifier is held); ctrl toggles the hit; shift adds it; neither
replaces. Entity ids become `{ kind = "entity", id }` refs.
- `M.applyMarquee(Sel, scope, ids, mods)` — pure decision for a completed
marquee: an empty band with no modifier clears, an empty band with a modifier
leaves the selection unchanged; ctrl toggles each id, shift adds the set,
neither replaces with the set.
- `M.worldToScreen(m, w, h, p)` — project a world point through a 16-float
column-major view-projection matrix and viewport size to viewport pixels
(`sx, sy`), or nil when the point is at/behind the camera. Mirrors the
engine's own `world_to_screen_px`.
- `M.pointInRect(rect, sx, sy)` — inclusive screen-rect hit test.
- `M.nearestIcon(cands, cx, cy, thresholdPx)` — the nearest icon candidate
`{ id, sx, sy }` to the cursor within a pixel threshold, or nil.
- `M.entitiesInRect(rect)` — engine-driven: the selectable entity ids whose
world position projects inside `rect` (viewport-pixel space) this frame.
- `M.iconPick(cx, cy)` — engine-driven: the entity whose meshless icon is
nearest the cursor within `ICON_PICK_PX`, or nil.
- `M.tick(dt?)` — call once per editor frame; prunes dead selection refs, reads
pointer press/drag/release + modifiers, and routes a click or a marquee
through the pure decisions above.
## Notes
- The decision functions are pure so they can be unit-tested against the real
selection service.
- **Icon-pick fallback.** A click that hits no mesh/collider (the engine pointer
pick returns nil) falls back to selecting the nearest meshless-entity icon
(light / camera / audio source / reflection probe) within `ICON_PICK_PX`, so
lights and cameras — invisible to the raycast — are selectable by their
billboard. The engine pointer pick wins whenever it hits.
- **Gizmo precedence.** A marquee only starts on a frame where the pointer has
moved past the click slop AND the transform gizmo is not dragging. `tick` runs
before the gizmo's tick in the editor entrypoint, so by the time a drag passes
the slop the gizmo has already had a frame to claim a grabbed handle.
·computeshader · born here
❒asset # gather
Builds a column from another column and a run of indices: element `i` of the
result is element `index[i]` of `src`. An index below 0 or past the end of
`src` gives zeros. The element type is the `T` specialization (`f32`, `i32`,
and two to four lanes of either); a three-lane column is read and written
tightly packed, twelve bytes an element.
Dispatch over `n`, the number of indices. Exact: each result element has one
writer.
Over a live count (`COUNT=device`, see the abi README) `n` is the capacity and the
first `domain_count` slots are live: a slot at or past it holds zeros. It binds 4
storage buffers (3 in the plain variant), and `dst` holds `n` elements.
# highlight
Syntax-highlighting dispatch + per-language modules. Each language
module exports `(text: string) -> { Segment }` where
`Segment = { text, color, monospace?, italic? }`. `Z.codeEditor` calls
into `dispatch` at update time and passes the resulting segments to
TextInput, which builds the egui LayoutJob in pure Luau. Library authors
can ship their own `@author/zui_highlight_<lang>` modules and slot them
into `Z.codeEditor` via the same `language` opt.
## Exports
- `M.dispatch(language: string, text: string) -> { Segment }` — pick a per-language highlighter (with `yml` / `md` aliases) and tokenize; unknown languages fall through to a single plain segment.
- `M.lua: Highlighter` — Lua highlighter.
- `M.json: Highlighter` — JSON highlighter.
- `M.yaml: Highlighter` — YAML highlighter.
- `M.wgsl: Highlighter` — WGSL highlighter.
- `M.markdown: Highlighter` — Markdown highlighter.
Types:
- `Segment = { text: string, color: string, monospace: boolean?, italic: boolean? }`
- `Highlighter = (string) -> { Segment }`
## Usage
```luau
local Highlight = require("@builtin::modules.zui.highlight")
local segs = Highlight.dispatch("lua", source)
-- segs = { { text = "local", color = "#...", monospace = true }, ... }
-- Or call a specific language directly:
local segs2 = Highlight.json(jsonText)
```
## Notes
- Per-language colors come from the active theme's `code.*` tokens
(`ui.getToken("code.keyword")` etc.). Switching themes
(`ui.setTheme("light")`) automatically re-colors on the next call.
- A single-slot LRU cache keyed by `(language, text, theme)` makes
same-text re-emits free (focus-loss redraws, no-op editor pushes).
Cache returns a shared array reference — callers must not mutate it.
- Aliases: `"yml"` → `yaml`, `"md"` → `markdown`. Unknown languages
return a single default-colored monospace segment so user input never
crashes the dispatcher.
## Roles the Lua highlighter reads
`code.keyword`, `code.string`, `code.number`, `code.comment`, `code.bool`
(`true` / `false` / `nil`), `code.attribute` (`--!` directives),
`code.type` (a name after `:`, `->`, `::` or `type`), `code.function` (a name
a call is made through, or one `function` declares), `code.property` (a
member reached by `.` that is not called), `code.builtin` (the standard
library's globals), and `code.default` for the rest.
# material_utils
High-level wrappers around the `__material` FFI global. Convenience
helpers for the common material-authoring operations: create a named
material, apply it to an entity, query / set material properties, and
bind textures.
Exposed as the `Material` Luau global via `--!global` (auto-injected by
the prelude). Material lives ON the Model / SkinnedModel component — there
is no standalone `Material` component, because a material only renders where
there is a mesh. To apply a material, use `Material.apply()` (which errors if
the entity has no model) or set the model's `material` field directly:
`entity(id).component.add("Model", { model = "cube", material = "gold" })`.
PascalCase aliases (`Material.Create`, `Material.Apply`, etc.) are kept
for back-compat with the wider codebase — 88+ call sites across demos,
scenes, components, guides, and the engine test suite use the legacy
names. The lowercase forms are canonical.
## Exports
Canonical (lowercase) surface:
- `Material.create(name: string, opts: MaterialOpts?) -> MaterialRef?` — register a named material; returns the AssetRef envelope.
- `Material.apply(entityId: string, materialRef: MaterialRefOrName) -> boolean` — set the `material` field on the entity's Model / SkinnedModel. Errors if neither is present.
- `Material.exists(name: MaterialRefOrName) -> boolean` — registry predicate.
- `Material.getPropertyNames(name: MaterialRefOrName) -> { string }?` — list property names exposed by a material.
- `Material.getProperty(name: MaterialRefOrName, prop: string) -> any` — read a property value.
- `Material.setProperty(entityId: string, property: string, value: any)` — set a property on the material assigned to an entity's Model / SkinnedModel.
- `Material.setTexture(name: MaterialRefOrName, slot: string, textureRef: string) -> boolean` — bind a texture slot.
PascalCase back-compat aliases:
- `Material.Create(name, opts_or_shader, props?) -> MaterialRef?` — accepts the legacy `(name, shaderString, opts)` 3-arg form OR the canonical `(name, opts)` form.
- `Material.Apply` / `Material.Exists` / `Material.GetPropertyNames` / `Material.GetProperty` / `Material.SetTexture` — direct aliases for the lowercase counterparts.
- `Material.SetProperty(materialName, property, value)` — writes to the REGISTERED material by name (NOT on a component). Different from lowercase `setProperty`.
Types:
- `Color = { number } | { r: number, g: number, b: number, a?: number }`
- `Textures = { [string]: string }`
- `MaterialOpts = { shader?, color?, roughness?, metallic?, emissive?, textures?, [string]: any }`
- `MaterialRef = { __ref?, type?, name?, guid? }`
- `MaterialRefOrName = MaterialRef | string`
## Usage
```luau
local mat = Material.create("gold", {
color = { 1, 0.85, 0.2 },
metallic = 1,
roughness = 0.15,
})
-- entityId must already carry a Model / SkinnedModel (a material only
-- renders on a mesh); apply errors otherwise.
Material.apply(entityId, mat) -- AssetRef envelope
Material.apply(entityId, "gold") -- bare name also works
Material.setProperty(entityId, "roughness", 0.3)
Material.setTexture("gold", "albedo", "@builtin::textures.gold")
```
## Notes
- `Material.apply` accepts either an AssetRef envelope or a bare name —
the model's `material` field auto-wraps strings.
- Material is a field on the Model / SkinnedModel component (declared as
`material`, an `AssetRef<material>`). There is no standalone Material
component — `apply` / `setProperty` route to whichever model is present
and error when neither is.
- `Material.setProperty` writes the property on the material assigned to
the entity's model; `Material.SetProperty` writes on the REGISTERED
material by name. They behave differently — keep them straight.
- All argument validation raises on bad input rather than silently
no-oping, so call sites surface mistakes early.
# colorSequence
Roblox-shaped RGB keyframe-curve value type. A `ColorSequence` is an
immutable curve of up to 64 keypoints, each carrying
`(time, value, envelope)` where `value` is a `{r, g, b}` triple and
`envelope` is a per-channel random range half-width. The per-channel
envelope is a notable improvement over Roblox, where ColorSequence
ships with no envelope at all — a long-standing community wishlist
item.
Exposed as the `ColorSequence` Luau global via `--!global`. Authored
scripts call `ColorSequence.new(...)` directly without a `require`.
The Rust FFI lives at `__sequences.*` (registered by
`crates/zero_scripting/src/ffi/bindings/curves.rs`); this module is
the typed Luau wrapper.
## Exports
- `ColorSequence.new(...) -> ColorSequenceObj`:
- `ColorSequence.new({r, g, b})` — constant color (accepts a
3-array or `{r=, g=, b=}` record).
- `ColorSequence.new(c0, c1)` — two-point lerp from `c0` to `c1`.
- `ColorSequence.new({ keypoint, ... })` — explicit keypoints.
`envelope` may be omitted (= `{0,0,0}`), a single number
(broadcast to all channels), or a 3-array. First key must anchor
at `time = 0`, last at `time = 1`. NaN / Inf rejected.
- `ColorSequence.deserialize(payload) -> ColorSequenceObj` — rebuild
from a `{ kind = "ColorSequence", keypoints = {...} }` payload
produced by `:serialize()`.
### Keypoint shapes
An entry of a keypoint list takes any of three written shapes, and the
shapes mix within one list:
| Written | Read as |
|---|---|
| `{ time = 0.5, value = {1,0,0}, envelope = 0.05 }` | the named record |
| `{ 0.5, {1,0,0}, 0.05 }` | time, then colour, then envelope |
| `{ 1, 0, 0 }` | a bare colour, timed by its place in the list |
A bare colour takes its time from its place: the entries spread evenly
across `[0, 1]`, so `{ {1,0.85,0.35}, {1,0.35,0.05} }` is a ramp from
the first colour at `t = 0` to the second at `t = 1`, and a list of one
colour holds that colour across the whole domain.
## Methods (called via `:`)
- `seq:evaluate(t) -> (r, g, b)` — deterministic linear interpolation
at `t` (multiret). `t` clamps to `[0, 1]`; NaN coerces to `0`.
- `seq:sample(t) -> (r, g, b)` — `evaluate(t)` plus per-channel
`(math.random() − 0.5)·2·envelope(t)` jitter. Per-VM
`math.randomseed` controls jitter reproducibility.
- `seq:keypoints() -> { { time, value = {r,g,b}, envelope = {r,g,b} } }`
— array snapshot, sorted ascending by time.
- `seq:duration() -> number` — always `1.0` for a well-formed
sequence.
- `seq:serialize() -> { kind, keypoints }` — scene-save payload.
- `seq:destroy()` — drop the FFI handle. Luau removed `__gc` on
tables, so eager cleanup is the caller's responsibility for
per-frame-rebuild patterns; otherwise the entry dies with the VM.
## Substrate
Backed by `zero_curves::Channel` (Linear interp). Each sequence
builds the value and envelope channels once at construct time and
reaches into them on every sample — zero per-call allocation, safe
for hot particle loops. Validation errors name the specific rule
(`"first keypoint must anchor at time = 0"`,
`"too many keypoints (max 64)"`, …).
Foundation for VFX property-over-lifetime: declarative particles
(#2572), GPU emitter (#873), beams, trails. Issue: #2673.
# Mesh
Mesh asset. `data.zmsh` is the engine-native geometry payload (`renderer.mesh.encode`); consumers reference the asset's guid.
# utils
Small stateless helpers used across the builtin module set: parse a
vec3-shaped value into the canonical `{ x, y, z }` map, normalise an
id list from string or table input, and coerce a checkbox-shaped value
into a strict boolean. No engine dependencies, no module state.
## Exports
- `M.parseVec3(value: Vec3Like) -> Vec3Map?` — parse vec3 from array or map form, returns `nil` if unrecognised.
- `M.normalizeIds(value: IdListInput) -> IdList?` — turn a comma/space-separated string or array into `{ string }`.
- `M.parseCheckboxValue(data: CheckboxValue, fallback: boolean) -> boolean` — coerce bool/string/number/nil to a boolean.
Types:
- `Vec3Map = { x: number, y: number, z: number }`
- `Vec3Like = Vec3Map | { number }` — accepted input shape for `parseVec3`.
- `IdList = { string }`
- `IdListInput = string | IdList` — accepted input shape for `normalizeIds`.
- `CheckboxValue = boolean | string | number | nil`
## Usage
```luau
local Utils = require("@builtin::modules.utils")
local v = Utils.parseVec3({ 1, 2, 3 }) -- { x = 1, y = 2, z = 3 }
local ids = Utils.normalizeIds("a, b, c") -- { "a", "b", "c" }
local on = Utils.parseCheckboxValue("true", false) -- true
```
## Notes
- Pure functions — no side effects, no engine calls. Safe to call at
module load time or from any context.
- `parseVec3` always returns the map form (`{ x, y, z }`) regardless of
whether the input was an array or a map. Callers downstream that
expect a particular shape should rely on the map form.
- `normalizeIds` returns the input table unchanged when it is already a
table — it does not validate that each entry is a string.
# plugin
Compiled code in a world, used the way a module is.
A plugin is a WebAssembly component in a `<name>.plugin/` asset. Code that
uses one `require`s it and calls it like any module: the calls answer values,
and a refusal raises. Nothing about the call says compiled code ran, the way
calling into a native library from Python does not.
```luau
local counter = require("counter")
local total = await(counter.add(2)) -- a number
print(await(counter.total())) -- 2
```
It is for work Luau cannot do fast enough, or cannot do at all: a simulation
step, a decoder, a solver, a model.
## Getting one into a world
Write the component into the world as a `.wasm` file (upload it). It becomes
a plugin asset of the same name, with the component moved inside, and it
loads. The asset starts with:
```
counter.plugin/
├── plugin.wasm the component
├── plugin.yaml what it offers, and where it loads
├── surface.module/ the module a `require` of the asset lands on
├── README.md this plugin's own description
└── .metadata
```
Nothing is callable until `plugin.yaml` lists it. Edit it to name the methods
the component offers, and the plugin reloads with them.
## plugin.yaml
```yaml
profiles: # where it loads; required
- editor
- runtime
storage: none # none | private; none when left out
events: private # private | exposed; private when left out
memory: 512MiB # the most memory it may hold; 4GiB when left out
exposed: # the methods a caller may run; none when left out
- name: add
- name: total
```
- **profiles** says where the plugin loads. A `runtime` plugin is part of the
game: components and scene entrypoints can call it, and a player loads it.
An `editor` plugin is for authoring, and a call to it from a component or an
entrypoint is refused, because the shipped game would not have it.
- **storage** `private` gives the plugin a store of its own, in this world,
that survives a restart. With `none` what it stores lasts until it is
reloaded.
- **events** `exposed` lets code in the world listen to the events the plugin
raises. With `private` nothing in the world hears them.
- **memory** is the most memory the plugin may hold, written with `KiB`,
`MiB` or `GiB`, up to `4GiB`, which is also what a plugin that says nothing
gets. A plugin that would grow past it stops, and the log says it went past
its `memory:`.
- **exposed** is the plugin's whole callable surface. A method not listed
cannot be called, whatever the component contains, and a listing that names
a method the component does not have is refused when the plugin loads.
## Using it
The asset's `surface.module` is its Luau face, and `require("<name>")`, with
the asset's name, lands on it. Its code is `surface.module/init.luau`, and
each of its functions calls the plugin through the asset it belongs to:
```luau
local self = asset.containing(__FILE__)
local me = self.modules.shared.forAsset(self)
local M = {}
function M.add(amount: number)
return me.call("add", amount)
end
return M
```
- Arguments are numbers, strings, booleans, nil, buffers, handles, and tables
of them. A whole number arrives as an integer. A string has to be text; send
bytes as a `buffer`, which arrives whole, as bytes.
- A file or a folder goes as a handle, which the plugin reads or writes by
itself, without a copy through the call:
```luau
local weights = vfs.open(self.path .. "/weights.bin") -- read a file
local model = vfs.openDir(self.path .. "/model") -- read a folder
local out = vfs.sink("/source/out/sphere.zvol", { maxBytes = 64 * 1024 * 1024 })
local frames = vfs.dirSink("/source/frames", { maxFiles = 240, maxBytes = 200 * 1024 * 1024 })
me.call("render", model, frames)
```
A handle lets the plugin do what this script could do at that path, and
nothing more: a plugin has no filesystem of its own. A sink's file appears
when the plugin closes the sink or lets go of it, and only the world's own
files take a sink. The `plugin-development` guide has the rules.
- Entities go as a binding: `ecs.bindEntities(ids):forPlugin()`. The plugin
reads and writes their fields as packed floats and never learns an id; a
write it makes answers to the same locks, owners and builds this script's
own `entity.batchWrite` does.
- A GPU buffer goes as `buf:forPlugin()` (a `"gpu"` buffer from
`substrate.createBuffer`), and a compute shader as `shaderRef:forPlugin()`.
The plugin writes and reads the buffer's floats and dispatches the shader
over buffers the same script handed it. It never learns a buffer's name or
a shader's key, so it reaches no buffer it was not given.
- The answer is the value the plugin returned: a table as a table, a number as
a number, bytes as a `buffer`.
- A refusal raises where it is awaited: a method not exposed, a plugin that is
not loaded, or the plugin's own error.
- `me.on(function(eventType, payload) ... end)` listens to the plugin's
events, waiting for it to load first. The payload is a value, as an answer
is. It needs `events: exposed`.
- A refusal is an error like any other, and `pcall` catches it.
- `plugins.isLoaded("<name>")` answers whether it is running, and never
raises.
## More than one part
A plugin can carry more than its surface: modules the surface builds on, and
an editor panel. They sit beside `surface.module` inside the asset:
```
counter.plugin/
├── plugin.wasm
├── plugin.yaml
├── surface.module/ what `require("counter")` lands on
├── history.module/ a module the surface uses
└── panel.editorPanel/ a dock tab
```
The parts reach each other with the dot form, which means the same thing
wherever the asset is installed:
- From a module, `require(".history")` is the module beside it.
- From the panel, `require(".")` is the plugin (its surface), and
`require(".history")` the module beside the panel.
The surface IS the plugin: it has no name of its own, so no part requires
`.surface`. Don't write a path (`./history.module`) or a name that says where
the asset is installed (`require("counter.history")`): moved into a library,
the asset stops finding its own parts.
## While it runs
- **A new build replaces it in place.** Upload the component again under the
same name and the running plugin is replaced, with no restart. Uploading the
same bytes again changes nothing.
- **Editing plugin.yaml reloads it.**
- **Removing the asset stops it.**
- **A new build or an edit stops a call in progress.** A call the plugin is
in the middle of answers that the plugin was stopped, however long it would
have run, and the new build starts.
- **A crash stops it once.** If the component traps, the plugin stops, the log
says so in one line, and every later call raises that it stopped and why,
until it loads again: a new build, or an edit to `plugin.yaml`.
- **`plugins.status()`** lists each plugin: whether it is loaded, why not, and
its component: the SHA-256, the size, and what built it.
The same holds in the desktop engine and in a browser.
## Building one
The `plugin-development` guide covers writing a component: the interface in
`zero-plugin.wit` beside this file, a Rust project that builds one, how calls
are encoded, what a plugin may use, and the development loop.
·computeshader · born here
❒asset # contour_cells
Whether each cell of a grid of samples straddles the surface: its eight corner
samples are not all on one side of zero. `flag[c]` is 1 for a straddling cell
and 0 otherwise, the keep mask `compact` turns into the list of cells that own a
vertex.
Dispatch over the cells. Exact: each flag has one writer.
# Chrome
A polished chrome surface built on the `pbr` shader — fully metallic (`metallic: 1.0`) with a bright near-white base colour (`0.95, 0.95, 0.97`) and very low roughness (`0.05`) for mirror-sharp reflections. Opaque, back-face culled, instanced, with the `_METALLICSPECGLOSSMAP` keyword enabled.
## Inputs
- `colors.base_color` (`0.95, 0.95, 0.97, 1.0`) — bright, faintly cool chrome tint.
- `floats.metallic` (`1.0`) — fully metallic.
- `floats.roughness` (`0.05`) — near-mirror sharpness.
- `textures.base_color_texture` (`default:white`): the white base texture the colour multiplies against, so the surface renders `base_color` alone.
# pbr_layered
A surface composed from two complete PBR layers: a base, and a top layer laid
over it wherever coverage says so.
Rust over painted metal, snow over rock, mud over a vehicle, wear over a finish.
Pre-blending those into one texture set bakes the coverage in — it can no longer
change at runtime, and every entity wearing the material wears it identically.
Here both layers keep their own shading inputs and coverage is evaluated per
fragment, so the same material can be pristine on one entity and ruined on the
next.
## Coverage
Coverage is the product of three terms, each neutral by default:
| term | property | what it is for |
|---|---|---|
| mask | `top_mask_texture`, `top_mask_channel`, `top_mask_contrast` | *where* the top layer gathers — creases, edges, upward faces |
| material | `top_coverage` | *how far* the look has progressed, for every entity sharing the material |
| instance | `top_instance_lane`, `top_instance_component` | how far for *this* entity |
`top_coverage` defaults to 0, so a material that sets nothing renders as its base
layer alone.
`top_mask_contrast` shapes the mask: 1 is a linear ramp, higher values pull the
top layer back into the mask's brightest areas, which is what makes rust and snow
gather in creases instead of washing evenly over everything.
## Driving it per instance
Point the material at an `instanceData` lane, then write that lane per entity:
```lua
-- material: top_instance_lane = 0, top_instance_component = 0
local clean, rusted = entity.find("barrel_a"), entity.find("barrel_b")
if clean and rusted then
renderer.instanceData.set(clean, 0, 0.1) -- nearly clean
renderer.instanceData.set(rusted, 0, 0.9) -- heavily rusted
end
```
Both entities share one material and one draw path; only the lane differs.
## Texture slot budget
WebGPU guarantees 16 sampled textures per fragment stage and the engine's
group(0) already holds several. This shader declares **9**, which leaves room for
those plus a margin. Two full five-map layers would not fit, so the top layer
carries the three maps that make it read as a different material — albedo,
roughness/metallic, normal — and takes occlusion and emissive from the base.
Layer count is **two**. The cost is fixed and predictable: both layers are always
sampled, and coverage selects between their inputs rather than branching.
## Why the inputs blend rather than the results
Lighting each layer separately and adding puts more energy back out than arrived,
so a half-covered surface would read brighter than either layer alone.
Interpolating the shading inputs and lighting once cannot do that.
# debris
Timed entity despawn — Roblox `Debris:AddItem(instance, lifetime)`
analog. Wraps the internal `__debris.*` FFI with a typed Luau surface
and installs it as the `debris` global via the prelude.
Collapses the common `task.delay + entity.despawn` pattern into one
centrally-introspectable call. Use for transient entities — bullet
tracers, hit FX, dropped pickups, tween-completion cleanups, ragdoll
cleanup.
## API
| Call | Effect |
|---|---|
| `debris.add(id, lifetime?) -> handle` | Schedule despawn after `lifetime` seconds (default 10). Returns a cancel handle, or 0 if the id can't be resolved. Calling again on the same entity replaces the prior deadline. Negative or zero lifetime despawns immediately. |
| `debris.cancel(handleOrId) -> boolean` | Cancel a pending despawn. Accepts a handle or an entity id / name / proxy. Returns true if a record was removed. |
| `debris.pending(id) -> number?` | Seconds remaining before despawn, or nil if not scheduled. |
| `debris.list() -> { { id, remainingSecs, handle } }` | Snapshot every pending entry as a flat array. Order is not stable. |
| `debris.count() -> number` | Number of currently pending entries. |
| `entity(id):AddDebris(lifetime?)` | Entity-proxy convenience; equivalent to `debris.add(id, lifetime)`. Accepts both colon and dot call styles. |
## Semantics
- **Idempotent**: a second `add(id, lifetime)` replaces the prior
deadline. The latest call's handle is the only handle that
resolves to a live entry.
- **Despawn through any other path drops the pending record**
silently — registered at the `despawn_entity_now` chokepoint so
`entity.despawn`, parent cascade, and `world.load` reset all
clean up automatically. No "despawn(invalid)" errors.
- **Single tick per frame**: `tick(dt)` runs alongside
`task_scheduler::tick` in `world_context::post_update`. No new
Bevy schedule node.
- **Save / load round-trip**: pending entries are snapshotted into
world settings as `debris.pending` at `world.save` and restored at
`world.load`. The saved value is seconds-remaining, so timers
resume from where they paused regardless of wall-clock gap between
save and load.
## Examples
```lua
-- Bullet tracer
local bullet = entity.spawn("Bullet")
debris.add(bullet.id, 2.0)
-- Cancel before deadline
local handle = debris.add(target.id, 5)
debris.cancel(handle)
-- Entity-proxy convenience
entity.spawn("Pickup"):AddDebris(30)
-- Introspection
for _, info in debris.list() do
print(entity(info.id).name, "despawns in", info.remainingSecs)
end
```
See `docs/specs/scripting-api.md` for the canonical reference.
# Snow Particle
A soft, cold-white flake surface for falling-snow particle effects — a near-white base colour (`0.95, 0.97, 1.0`) with a cool blue-grey emissive glow (`0.25, 0.3, 0.4`) so flakes stay faintly visible in shadow. Transparent, double-sided (`cull: none`), and drawn in the transparent queue (`3000`). Built on `@builtin::shaders.snow_material`.
## Inputs
- `colors.base_color` — the cold-white flake tint.
- `colors.emissive` (`0.25, 0.3, 0.4`) — cool self-illumination that keeps flakes readable in dark scenes.
- `floats.roughness` (`0.8`) — matte, non-glossy flakes.
- `floats.metallic` (`0.0`) — fully dielectric.
·computeshader · born here
❒asset # abi_scale
Multiplies every element of src by the scale param into dst, one element per invocation.
# `referenceChart` surface shader
A reference chart drawn on a surface, unlit: each point holds light its
properties and its UV state, so a picture of the surface is measured against
known input. The post-verification reference scenes
(`@builtin::scenes.postReference.*`) are built from its materials
(`@builtin::materials.postReference.*`).
| `pattern` | Draws |
|---|---|
| 0 | A grey wedge: one column per whole stop from `evMin` to `evMax`, each middle grey (0.18) at that stop. |
| 1 | The 24 ColorChecker Classic patches, 6 x 4, each its published sRGB value read as linear reflectance. |
| 2 | A ramp from `low` at u = 0 to `high` at u = 1. |
| 3 | A Siemens star of `frequency` spokes, `high` on `low`. |
| 4 | A checker `frequency` cells across, `low` and `high`. |
| 5 | Focus bars: four rows of bar pairs, `frequency` pairs across the top row, doubling on each row below. |
Every pattern's light is multiplied by `color` and `gain`. UV (0, 0) is the
surface's top-left, as the front face of `@builtin::meshes.cube` maps it.
# debug_bounds
The "bounds" provider for `@builtin::modules.debugViz`: appends one amber
wireframe box per scene mesh's world-space AABB into the shared
debug-visualization batch, every edit-mode tick.
```lua
debugViz.registerProvider("bounds", require("@builtin::modules.debug_bounds").tick)
```
`DebugViz.component` registers this provider automatically — attach it to one
entity to draw a box around every mesh in the scene alongside whatever other
providers are registered.
## How it works
`tick(emit)` queries every entity carrying a Mesh (skipping
`debugViz.anchorId()`, the core's own hidden draw-anchor, which itself carries
an invisible Mesh), reads its `bounds()`, and appends a box via `emit.box`.
The core owns the anchor, material, buffers, and render feature that actually
draw the batch — this module only contributes geometry.
## API
- `debug_bounds.tick(emit)` — append one box per scene mesh's current
world-space AABB into the shared batch.
# range_bounds (module)
The `range` field-constraint validator. Registers itself with
`field_constraints.module`'s registry on load so any component field
carrying `constraint = { kind = "range", min = ..., max = ... }` — which
is what `Field.range` emits — is checked against it.
## What it does
Given a field's written value and its constraint:
1. Passes `nil` — a ranged field may be left unset.
2. Rejects a constraint carrying neither bound, since there is no
interval to check against.
3. Rejects a non-number value, naming the interval and the type it got.
4. Rejects a NaN or an infinity, which sits in no interval.
5. Answers whether the value is inside `constraint.min` ..
`constraint.max`, either of which may be absent to leave that side
open.
Every rejection names the interval, so the message tells the caller what
they may write instead of only what they may not.
## What a range is for
A field whose value means something only inside an interval — a
normalized amplitude, a fraction, a probability, a count that starts at
zero — declares that interval once, and every write is held to it. What
the field reads back is then a value the system consuming it can use,
and a number that lands outside is reported where it was written rather
than wherever it is eventually read.
## Where the bounds come from
`Field.range(min, max, default, mode)` validates the interval at
construction: at least one bound is required, both must be numbers when
given, `min` may not sit above `max`, and a non-nil default must be
inside. A field whose interval is wrong is therefore a load-time error
rather than a runtime surprise, and this validator only ever sees a
well-formed interval.
The engine carries `constraint` verbatim and never reads inside it, so
the bounds live entirely in Luau.
## Exports
- `M.check(value, constraint) -> ok, reason?` — the validator function,
also registered under the `range` kind.
# BlankAppLayout
The layout module for the `blank_app` scaffold. It builds a single UI screen
from the engine's raw, CSS-parity widget tree and wires up one interaction: a
`+1` button that increments a counter and re-renders.
This is the file you edit. Replace the `build` tree with your own widgets.
## The contract
A `UiPanel` component points its `source` at this module and calls
`mount(ctx)`, where `ctx = { entity, entityId, data, state? }`. `mount` returns
the handlers the panel drives each frame:
```lua
function M.mount(ctx)
local state = ctx.state or { count = 0 } -- 1. state is the source of truth
local function build() return { --[[ widget tree ]] } end
ui.registerScreen("blank_app", build()) -- 2. register the tree as a screen
return {
state = state,
update = function(dt) end, -- 3. per-frame work (no-op here)
onCallback = function(id, data) -- 4. an interaction lands here
if id == "blank:inc" then
state.count = state.count + 1
ui.updateScreen("blank_app", build()) -- mutate, then re-render
end
end,
destroy = function() ui.unregisterScreen("blank_app") end,
}
end
```
- `state` is returned so the panel hands it back on a live-reload — your count
survives an edit.
- `update(dt)` runs every frame; leave it a no-op when nothing animates.
- `onCallback(id, data)` receives every widget's callback id. Mutate state, then
call `ui.updateScreen(name, build())` to publish the new tree.
- `destroy()` unregisters the screen when the panel goes away.
## The raw widget tree
A node is a table: `{ type = "...", props = {...}, style = {...}, children = {...} }`.
- **Containers** lay out their children: `vertical` (column), `horizontal` (row).
- **Leaves** carry content: `label` (text in `props`), `button` (text + `onClick`
in `props`).
- **`props`** is the widget's data and behaviour — a label's `text`, a button's
`onClick` callback id.
- **`style`** is CSS: `padding`, `gap`, `background`, `color`, `fontSize`,
`fontWeight`, `borderRadius`, `borderWidth`, `borderColor`, `minWidth`,
`minHeight`, `boxShadow`, and the rest of the box model.
The tree is a pure function of `state`: to change what shows, rebuild the tree
and call `ui.updateScreen`. There is no retained widget object to mutate.
## Interactivity
An interactive widget carries a **string callback id** in `props` (this button's
`onClick = "blank:inc"`). When the user clicks, the engine delivers that id to
`onCallback` here. `onClick` works on any widget — a `label`, a `horizontal`
row, an `image` — not just a `button`.
Interaction runs in **play**, not edit: enter play and click the button to see
the count rise.
## Discover the surface
- The `gui` toolbox's **widgetTypes** tool lists every `type`.
- Its **widgetProps** tool lists one type's `props`.
- `@builtin::examples.ui.*` are complete CSS-parity screens (dashboard, sci-fi
HUD, RPG inventory) in this exact model — read one before building.
- The UI and styling guides cover the model and the full style surface.
·computeshader · born here
❒asset # compute_fft_scale
Inverse-FFT `1/N` scale pass: divides every complex sample by the per-line N.
Run after the inverse FFT stages. Used by `nx.gpu.fft*`.
Bindings (see bindings.yaml): `data` (read_write `array<vec2<f32>>`), `params`
(read `array<f32>` — `[total_elements, inv_scale]`).
Usage: dispatch by identity with `compute.dispatch("@builtin::shaders.compute_fft_scale", ...)` —
it resolves to the shader's stable guid and auto-compiles on first use (no setup).
# Gold
Polished gold — a fully metallic PBR surface with a warm yellow base colour
(`1.0, 0.84, 0.0`) and low roughness (`0.25`) for bright, sharp reflections.
Built on `@builtin::shaders.pbr`.
## Inputs
- `colors.base_color` — the gold tint.
- `floats.metallic` (`1.0`) — fully metallic.
- `floats.roughness` (`0.25`) — sharpness of the reflection; raise for a
brushed look.
·computeshader · born here
❒asset # abi_inout
Writes src doubled into dst, or adds src to the dst it is bound when mode is 1.
# Checkerboard
A procedural checkerboard pattern rendered by `@builtin::shaders.checkerboard`, alternating a red base colour (`1.0, 0.2, 0.2`) with a blue emissive colour (`0.2, 0.2, 1.0`). Opaque and non-metallic (`metallic 0.0`) at mid roughness (`0.5`) — useful as a UV / scale test surface.
## Inputs
- `colors.base_color` — the red square colour.
- `colors.dark_color` — the blue square colour.
- `floats.roughness` (`0.5`) — diffuse spread on the lit squares.
- `floats.metallic` (`0.0`) — dielectric surface.
# ui_observe
What the last UI frame painted, which widget owns a point, and why a widget is
not on screen.
Reached through the `ui` namespace — `ui.observe`, `ui.diagnose`, `ui.hitTest`,
`ui.pointerWidget`, `ui.paintOrder`, `ui.invisibilityReasons` — and served to a
non-Luau reader at `/zero/runtime/ui/<screen>/paint.json`.
## The reading
`ui.observe()` returns one document. Its `widgets` rows cover every widget any
registered screen's tree holds, each carrying:
| field | what it is |
|---|---|
| `parent` | the nearest ancestor in its screen's tree carrying an id |
| `rect` | the box layout gave the widget |
| `clip` | the clip it painted under — every `overflow: hidden`/`clip`/`scroll` box above it, intersected down from the viewport |
| `visible` | the part of `rect` that survived `clip` and the viewport; `w`/`h` are zero when nothing did |
| `viewport` | the region the pass that recorded it paints into: the whole window for a screen on the window, the rect a Scene panel presents the camera's picture in for a screen in that picture |
| `paintIndex` | order within the frame, so two identical rects still separate |
| `painted` | whether the pass that recorded it is the one drawing the viewport |
| `opaqueFill` | whether it filled its whole box at full alpha |
| `hovered` | whether the pointer was over it on the recorded frame |
| `reason` | its verdict, from the closed set below |
| `detail` | a sentence naming what the verdict is about |
| `occludedBy` | the widget covering it, when `reason` is `occluded` |
`generation` advances once per re-rendered frame. Two calls reporting the same
number describe the same frame.
Every rect, the document's own `viewport` (the window) and its `pointer` are in
logical points on the window, the space `ui.getLayoutInfo` and `gui.bounds`
answer in, wherever a widget's screen draws. A screen in the camera's picture
lays out on the camera's canvas (the size `ui.screenSize()` reports); while a
Scene panel presents that picture, its rects are mapped onto the window through
the panel. Times `ui.pixelRatio()` a point is in the window's physical pixels,
where `Zin.test`'s mouse primitives and the `inputSim` toolbox's `click` /
`mouseMove` with target `"ui"` place a pointer.
## The verdicts
`ui.invisibilityReasons()` lists them:
| reason | what the engine found |
|---|---|
| `painted` | it reached the frame |
| `noSuchWidget` | no registered screen's element tree carries the id |
| `hiddenByAuthor` | `props.visible = false` on it or an ancestor kept it out of the render |
| `screenNotShown` | its screen is registered and not shown |
| `neverLaidOut` | its screen is shown and the frame laid out no box |
| `zeroSize` | its own box has no area |
| `offViewport` | its box sits entirely outside the viewport of the pass that drew it (the row's `viewport`) |
| `clippedByAncestor` | an ancestor's clip removed the whole box |
| `occluded` | a later widget covered it with an opaque fill |
| `screenNotOnViewport` | its box is fine and the camera holding the viewport excludes its screen's render layer, so the frame measured it without drawing it |
The resolution reports the nearest cause among them — a widget both clipped
away and covered reports the clip — and `painted` when nothing kept the widget
out.
The camera's render-layer mask decides the screens in the camera's picture.
With no Scene panel presenting the game viewport, the window is that picture
too, and the mask decides every screen on it. While a Scene panel presents the
game viewport, the window around it is the editor's own picture: a screen
placed on the window (`ui.setScreenSpace`, and the default for a screen on
`EditorUI`) paints there whatever the camera's mask holds, and reads `painted`.
A screen placed in the camera's picture reads `screenNotOnViewport` under a
camera that excludes its layer, with or without a Scene panel.
## The pointer
`ui.hitTest(x, y)` names the widget a pointer at that point reaches and the
stack beneath it, topmost first, in logical points on the window. Where
several boxes take the point in, the one that paints last is the one that gets
it, and the containers holding them follow — nearest first, by `parent`.
`ui.pointerWidget()` reports where the pointer sat, whether the UI claimed it,
and which widget the frame reported it over.
# entity_signals
Per-entity destroy signals, surfaced on the entity proxy as
`entity(id).onDestroying` and `entity(id).onDestroyed`, plus the dispatch
the despawn pipeline calls to fire them.
```lua
local boss = entity.find("Boss")
boss.onDestroying:Connect(function()
-- runs BEFORE teardown — final state is still readable
saveBossStats(boss.component.get("Combatant").public)
end)
boss.onDestroyed:Connect(function()
log.info("boss fully cleaned up")
end)
entity.despawn(boss.id) -- onDestroying fires, then teardown, then onDestroyed
```
## Order
`onDestroying` and `onDestroyed` are `Signal`s (see `signal.module`),
created on first access. The despawn pipeline drives the order. For a
cascade rooted at one `entity.despawn(root)` call:
1. `onDestroying` fires across the whole subtree, root first — before any
component teardown, so handlers read final state.
2. components tear down (`onDestroy(self)` per component), leaves first.
3. `onDestroyed` fires per entity as it is removed, leaves first.
`onDestroying` handlers must not yield, and the entity is frozen against
reparenting / re-mutation during the destroying window.
An entity nobody observes costs nothing and fires nothing.
## Entity-keyed tables
A module that keeps per-entity state for something to read every frame (a
registry a render feature walks, a set of marked entities) declares its table
with `entityKeyed`. When an entity is despawned, by `entity.despawn`, a
parent's cascade, a scene rebuild or reload, or a mode flip, its id leaves
every such table, so the reader only ever meets entities in the world and its
cost follows them.
```lua
local EntitySignals = require("@builtin::modules.entity_signals")
local marked = EntitySignals.entityKeyed({})
function M.mark(id: string)
marked[id] = true
end
```
The table is one of the engine's own records: a module-state restore leaves it
as it stands, so an id that left it stays out.
# Custom Textured
A textured opaque surface built on `@builtin::shaders.custom_material`, sampling the `array_texture` builtin over a warm peach base colour (`1.0, 0.8, 0.6`). Non-metallic (`metallic 0.0`) at mid roughness (`0.5`) — a worked example of pairing a custom shader with an array texture.
## Inputs
- `textures.base_color_texture` (`@builtin/textures/array_texture`) — the sampled array texture.
- `colors.base_color`: the warm peach colour the texture is multiplied by.
- `floats.roughness` (`0.5`) — diffuse spread.
- `floats.metallic` (`0.0`) — dielectric surface.
# entity_reflect
High-level entity introspection and manipulation utilities. Wraps the
`__reflect` FFI global with a cleaner API — no per-script component
declaration needed; every method goes through the reflection layer so
it works on any entity that carries the relevant component.
Exposed as the `Entity` global via the `--!global Entity` directive.
## Exports
- `Entity.getField(entityId, component, field) -> any` / `Entity.setField(entityId, component, field, value) -> boolean` — generic reflected read/write.
- `Entity.getPosition(id) -> Vec3?` / `Entity.setPosition(id, pos)` — `Transform.position` shortcuts.
- `Entity.getRotation(id) -> Quat?` — `Transform.rotation` shortcut.
- `Entity.getScale(id) -> Vec3?` / `Entity.setScale(id, scale)` — `Transform.scale` shortcuts.
- `Entity.getName(id) -> string?` — `Name` component read.
- `Entity.isVisible(id) -> boolean` — `Visible` component read; missing component is treated as visible.
- `Entity.getComponents(id) -> { string }?` — list reflected components on this entity.
- `Entity.getSchema(componentName) -> { ComponentFieldSchema }?` — list field names + types for a component type.
- `Entity.snapshot(id) -> any` — full `{ ComponentName = { field = value, ... }, ... }` dump.
- `Entity.patch(id, component, fields) -> boolean` — multi-field update.
- `Entity.distance(idA, idB) -> number?` — straight-line distance between two entities' `Transform.position`.
- `Entity.allTypes() -> { string }` — list every registered reflectable component type.
Types:
- `Vec3 = { x: number, y: number, z: number }`
- `Quat = { x: number, y: number, z: number, w: number }`
- `ComponentFieldSchema = { name: string, type: string }`
## Usage
```luau
-- `Entity` is a global (--!global Entity); no require needed.
local pos = Entity.getPosition("player")
Entity.setPosition("player", { x = 0, y = 1, z = 0 })
local snap = Entity.snapshot("player")
Entity.patch("player", "Transform", {
position = { x = 0, y = 1, z = 0 },
scale = { x = 1, y = 1, z = 1 },
})
```
## Notes
- Every method goes through the `__reflect` FFI — `nil` returns mean the
component or field is missing, not that the entity is gone. Use
`Entity.getComponents(id)` to enumerate what's there.
- `isVisible` treats a missing `Visible` component as visible
(`v == nil` returns `true`).
- `Entity.distance` returns `nil` when either entity lacks a position;
callers should branch on `nil` before using the result.
- The `--!global Entity` directive is what makes `Entity` the global name —
the module's local `E` variable is just an implementation detail.
# field_constraints (module)
Generic field-constraint dispatch. Installs
`_G.__zero_check_field_constraint(value, constraint)`, the Luau half of
the opaque field-constraint primitive: a component field descriptor
may carry a `constraint` value (an arbitrary table with a `kind`
string), and the engine calls this hook whenever a constrained field's
default resolves or a write lands. The engine copies `constraint`
verbatim — it never reads inside it — and treats the hook's first
return as accept/reject, the second as the rejection reason.
## What it does
1. Reads `constraint.kind` and looks up a registered validator for
that kind.
2. Built-in kinds (currently `dataContract`) resolve their validator
module lazily on first use, so component registration never depends
on load order.
3. Calls the validator with `(value, constraint)` and returns its
`(ok, reason?)` result.
A malformed constraint (not a table, or missing `kind`) and a missing
validator both reject — the primitive fails closed rather than
silently accepting an unconstrained value.
## Exports
- `M.register(kind, fn)` — register the validator for a constraint
kind. `fn(value, constraint) -> ok, reason?`. One validator per kind;
re-registering a kind errors.
- `M.check(value, constraint) -> ok, reason?` — run the check directly
(same function installed as the engine hook).
## Consumers
`Field.dataRef` (`field.module`) emits `constraint = { kind =
"dataContract", contract = ... }`; `data_contract.module` registers
the `dataContract` validator against this registry.
# Sky Procedural
A fully procedural atmospheric sky with a day/night cycle, built on `@builtin::shaders.procedural_sky`. Renders with no culling and no depth write behind all geometry (queue `1000`), computing gradient, sun, moon and stars analytically from its parameters — no texture required.
## Inputs
- `properties.zenith_color` / `horizon_color` / `ground_color` — the atmospheric gradient from overhead (`0.1, 0.3, 0.6`) through the horizon (`0.5, 0.7, 1.0`) to the ground (`0.3, 0.25, 0.2`).
- `properties.sun_intensity` (`20`) / `sun_size` (`0.02`) — brightness and disc size of the sun.
- `properties.turbidity` (`4`) — atmospheric haze / scattering density.
- `properties.stars_intensity` (`1`) / `moon_size` (`0.03`) — night-sky star brightness and moon disc size.
# mathx Module
Public Luau surface over the `__mathx` Internal FFI namespace — batch
math kernels that operate on buffer slices.
## Purpose
Wrap the SIMD-friendly batch ops exposed by Rust (damp, lerp, slerp,
normalize, accumulate, transform) in a typed Luau table that gets
auto-injected as `_G.mathx` via the prelude. Single-value forms are
out of scope — Luau handles those at sub-µs cost and an FFI hop would
dominate.
For element-wise NumPy-style ops on typed buffers, prefer the `nx`
module; `mathx` is the slice-and-target form for specific
position / orientation / quaternion shapes.
## Usage
```luau
-- Critically-damped scalar follow
mathx.dampScalar(buf, 0, 16, 0.0, 0.16, dt)
-- Lerp every vec3 in a slice toward a target
mathx.lerpVec3(positions, 0, n, 0, 1, 0, 0.5)
-- Slerp every quat toward a target rotation
mathx.slerpQuat(rotations, 0, n, 0, 0, 0, 1, 0.25)
-- particle integration: pos += vel * dt
mathx.addScaledVec3(positions, velocities, n, dt)
-- transform vec3 positions by a 4x4 world matrix
mathx.transformVec3(positions, 0, n, worldMatrix)
```
## Exports
- `mathx.dampScalar(buf, off, count, target, smoothTime, dt)`
- `mathx.lerpVec3(buf, off, count, tx, ty, tz, t)`
- `mathx.slerpQuat(buf, off, count, tx, ty, tz, tw, t)`
- `mathx.normalizeQuat(buf, off, count)`
- `mathx.addScaledVec3(dstBuf, srcBuf, count, scale)`
- `mathx.transformVec3(buf, off, count, mat16)`
# `fog` post-process shader
Depth-keyed distance haze. `pp.add(name, "fog", { density = ..., color = { r, g, b } })`
registers it, and `asset.resolve("@builtin::shaders.post.fog", "shader"):getProperties()`
reads the knobs below before it is added.
The pass reads the scene depth already drawn at a pixel, turns it into a distance
in metres with `zero_linear_depth(d, near, far)`, and mixes the frame toward
`fogColor` by `1 - exp(-distance * density)` — an exponential falloff, so density
sets how many metres of air it takes to reach the fog colour.
`near` and `far` are the planes the frame was projected through. They are what a
stored depth is turned back into metres by, so they belong to the camera the
scene was drawn with rather than to the look: setting them to something the
camera does not use moves every surface's distance and with it the whole haze.
| Property | Meaning |
|---|---|
| `density` | Falloff rate. 0 leaves the frame untouched. |
| `fogColor` | The colour a fully fogged pixel reaches. |
| `near` | The scene camera's near plane, in metres. |
| `far` | The scene camera's far plane, in metres. |
# Sky Cubemap
A cubemap sky/environment background built on `@builtin::shaders.cubemap_sky`. Renders with no culling and no depth write behind all geometry (queue `1000`), sampling the built-in `Ryfjallet_cubemap` with adjustable rotation and exposure.
## Inputs
- `textures.sky_texture` — the cubemap sampled as the sky (`Ryfjallet_cubemap`).
- `floats.rotation` (`0.0`) — yaw rotation of the sky around the vertical axis.
- `floats.exposure` (`1.0`) — brightness multiplier applied to the sampled colour.
# dotbot
What this plugin does and who it is for, in a paragraph.
## Use
```luau
local dotbot = require("dotbot")
local answer = await(dotbot.doSomething("x"))
```
## Methods
- `doSomething(input)`: what it does, and what it answers.
# marquee_overlay
A screen-space translucent rectangle with a 1px border, drawn while a viewport
rubber-band (marquee) selection is dragging and hidden otherwise. It owns one
dedicated UI screen tagged `editor`, so it rides the `EditorUI` render layer and
the editor's F1 chrome toggle alongside the rest of the editor overlays.
`viewport_select` drives it.
## Exports
- `M.show(ax, ay, bx, by)` — show the band filling the rectangle spanned by the
two corners, given in UI-logical points (the `ui.screenSize()` space). Corners
may be passed in any order.
- `M.hide()` — hide the band. Safe to call when nothing is shown.
## Notes
- Callers work in viewport pixels; convert to UI-logical points (multiply by
`ui.screenSize().width / viewport.w` and the height ratio) before calling
`show`, since absolute `left`/`top`/`width`/`height` resolve in the logical
space, not render pixels.
- Coordinates are rounded to whole points so a value in the open `(0, 1)` range
is never mistaken for a viewport-axis fraction by the size decoder.
# Baseline Visual
Validation harness scene for the [player-camera-unification PR](../../../../docs/plans/2026-05-01-player-camera-unification.md).
## Purpose
Visual smoke target for Steps 4–9 of the §17 migration plan. Those steps
land *before* §17 step 10 (the generic `camera_spawner` / `player_spawner`
that auto-populate scenes with a viewport camera). Without this scene,
those intermediate commits have no working viewport to validate against
and the boot path produces a single bare `Player` entity that renders
nothing.
`layers.load("@builtin::scenes.baseline_visual")` brings in:
- A `Camera`-bearing entity (`baseline_camera`) at a fixed third-person
framing of the origin. Renders to viewport.
- A static-collider ground plane (`baseline_ground`) so the character
controller has something to stand on.
- The engine's default `Player` entity (spawned by `ensure_default_player`)
with the two canonical tagged bundles applied:
- `@builtin::players.humanoid_visual` — mesh + skeleton + `Locomotion`
- `@builtin::players.standard_controller` — `MovementState` + `CharacterController`
All spawned entities are `temporary` so `layers.active:save()` doesn't
bake them into a persisted `.scene/` file.
## Cleanup path
| Step that lands | What changes here |
|---|---|
| §17 step 10 (`camera_spawner` / `player_spawner`) | The hardcoded camera spawn becomes redundant — drop it; the spawner takes over. Bundle application becomes a no-op smoke check. |
| §17 step 3 (`.player/` retirement) | No effect — this scene already references the new tagged bundles. |
| End of PR | Either kept as the canonical engine smoke target or deleted in Step 16's test-suite extensions. |
# mcpLog Module
Public Luau surface over the `__mcpLog` Internal FFI namespace —
read-only ring buffer of MCP tool-call entries.
## Purpose
Wrap the raw `__mcpLog.*` FFI namespace in a typed Luau table that gets
auto-injected as `_G.mcpLog` via the prelude. The in-engine "MCP" panel
polls `mcpLog.query()` every frame and renders the returned array of
structured tool-call rows.
## Usage
```luau
-- Pull the most recent 50 calls
for _, entry in ipairs(mcpLog.query(50)) do
print(entry.tool_name, entry.status, entry.duration_ms)
end
-- Full ring (up to 500 entries)
local all = mcpLog.query()
-- Drop everything
mcpLog.clear()
```
## Exports
- `mcpLog.query(limit?) -> { McpLogEntry }` — newest-last array of entries.
- `mcpLog.clear() -> boolean` — drop all entries.
# component_field_route
One rule for which access route serves a component's fields, shared by every
API that names an entity, a component and a field: `Entity.getField`,
`Entity.setField`, `Entity.patch`, `entity.batchWrite` and `entity.batchRead`.
Two routes exist, because two kinds of component do:
- **reflect route** — a component backed by an engine struct (`Transform`,
`MeshInstance`, `Physics`). `__reflect` resolves its fields through the
reflect registry.
- **component route** — a component declared in Luau (`Model`, `Text3D`,
and every other `.component` folder). Its field values
live on the component instance, so the component ref
(`entity(id).component.get(name)`) is what reaches them.
The reflect registry holds engine structs only, so a component declared in Luau
resolves on the second route and nowhere else. Dispatching on which route
serves the name is what lets one call site write `Model.tintBlend` and
`Transform.position` the same way.
## Exports
- `M.isReflected(component: string) -> boolean` — whether the reflect registry holds this component.
- `M.requireComponentType(api: string, component: string)` — raise unless the name is one this engine knows, either as an engine struct or as a declared `.component`.
- `M.componentRef(entityId: string, component: string) -> ComponentRef?` — the component ref serving `component` on that entity, or `nil`.
- `M.declaredFields(ref: ComponentRef) -> { [string]: boolean }` — the public and private field names the ref declares.
- `M.reportMissingField(api, component, field, outcome)` — report a field the component does not declare.
- `M.reportEntityWithoutComponent(api, entityId, component, outcome)` — report one entity that does not carry it.
- `M.reportEntitiesWithoutComponent(api, component, missing, total, outcome)` — report a call's entities that do not carry it, as one line.
- `M.readField(api, entityId, component, field) -> any` — read one field through whichever route serves it.
- `M.writeField(api, entityId, component, field, value) -> boolean` — write one field through whichever route serves it.
## Usage
```luau
local Route = require("@builtin::modules.component_field_route")
Route.writeField("entity.batchWrite", id, "Model", "tintBlend", 0.5)
local blend = Route.readField("entity.getField", id, "Model", "tintBlend")
```
## What a caller is told
| situation | result |
| --- | --- |
| a component name nothing declares | raises, naming the component |
| a component the entity does not have | the neutral value, reported by the route |
| a field the component does not have | the neutral value, reported at error level |
`api` is the surface the caller used; it is named in every diagnostic so the
line points at the call rather than at the machinery underneath.
A report repeats at most once every five seconds per `(api, component, field)`,
the budget the reflect route spends on the same mistake, so a per-frame loop
over a wrong name costs one line rather than one per frame — and a batch names
the entities that do not carry the component once for the call rather than once
each.
# [scene_name]
One-paragraph description of what this scene is — the level, screen or
area it represents, and when the world loads it. This README describes
*this* scene; what a scene is and how loading works live in the scene
assetType README.
## Contents
- What the entity hierarchy holds — the terrain, props, lights and
spawn points a reader should expect to find.
- Which player intent `scene.json` declares (`"spawns"` places the local
player and its camera on join; `"none"` leaves both to the entrypoint).
## Entry point
- `entrypoint.luau` — what it sets up when this scene loads, and
anything a caller has to do before loading it.
·computeshader · born here
❒asset # compute_add_vectors
Reference compute shader that adds two float arrays element-wise into a third —
the multi-buffer pattern with read-only inputs.
Bindings (see bindings.yaml): `a` (read), `b` (read), `result` (read_write) — all
`array<f32>`.
Usage: dispatch by identity — it resolves to the shader's stable guid and
auto-compiles on first use (no setup):
`compute.dispatch("@builtin::shaders.compute_add_vectors", { buffers = { "a", "b", "result" }, workgroups = { 1, 1, 1 } })`.
·computeshader · born here
❒asset # map
The per-element kernel: `dst[id] = TEMPLATE` over `n` elements. The template is
a WGSL expression over `p` (the element's position), `id`, the numbers `u_get(k)`
and the columns of the `cols` table; the preludes `rand`, `noise` and `color`
supply the functions a template calls. `fieldc.mapSpec` writes a chain of field
templates as one function, so a chain of any length is one dispatch.
Specialize `T` (f32, i32, vec2, vec3 or vec4 of f32), `TEMPLATE`, and optionally
`PRELUDE` and `POSITION`. Bind `cols` (a table from `compute_abi.table`) and `u`.
The output `dst` holds `n` elements of `T`, tightly packed.
```luau
local program = ref:program()
local variant = program:variant({ specialize = { T = "f32", TEMPLATE = "zero_rand(7u, id)",
PRELUDE = '#include "@builtin::systems.procgen.kernels.rand"' } })
local run = variant:run({ n = 64, inputs = { cols = emptyTable, u = numbers } })
```
`fieldc.evaluate(field, domain)` runs a Field through this kernel and answers the Column it wrote. Leaves of i32 runs are read through `cols_i32` and a read past a run's end reads its last element.
# lightCookies
Projects an authored image through a spot light's cone — a gobo, window blinds,
a headlight cutoff, a projector pattern.
## How it divides up
The renderer already projects a texture through a spot's frustum. At each shaded
point it reads `zero_feature_texture(uv, cookieLayer)`, with `uv` from that
point's position in the light's own clip space, and multiplies the cone by what
it finds. `SpotLight.cookieLayer = -1` means no cookie.
What the renderer does not decide is what goes in the layer. That is this
package: resample an authored image into one full layer of the shared
feature-texture array, and point a spot at that layer.
## Using it
```luau
local lightCookies = require("@builtin::systems/lightCookies.package/lightCookies")
-- Size the array once for every layer the scene will use.
lightCookies.configure({ resolution = 256, layers = 4 })
lightCookies.project(lampId, "/textures/cookies/window_blinds.png", { layer = 0 })
```
Or attach a `LightCookie` component next to the `SpotLight` and author
`texture` / `layer` / `gain` / `tint` on it.
`lightCookies.clear(lampId)` stops the projection; the layer keeps its contents.
## When a fill lands
`fill` and `project` queue the fill and return its layer at once. The module's
own loop writes it over the frames that follow: the rasterize is issued on the
next frame and the copy into the layer on the frame after, and the fill is
written once `renderer.featureTexture.state().fills` reports the copy landed in
its layer, one fill at a time in the order they were asked for. The loop belongs to the module, so a fill
lands even when the code that asked for it (a one-shot script, a task scope, a
component that is destroyed) has ended.
`lightCookies.status(layer)` reports where the last fill of a layer stands:
```luau
local s = lightCookies.status(0)
-- s.state: "queued" | "rasterizing" | "copying" | "written" | "refused"
-- s.reason: why a refused fill was refused, in the engine's words
-- s.filled: whether renderer.featureTexture.state().filled lists the layer
```
`status` reads a fill `"written"` from the first reading of the array that
reports its copy landed, so `s.state` and `s.filled` agree whichever of the
module's loop and the reader runs first in a frame.
A fill names a layer below the count `configure` sized the array to; one past
it is refused at the call, which returns nil and the reason. A fill that cannot
land once it is on its way (the GPU refuses its rasterize because its image was
given back first, a later `configure` left the array without its layer, or its
copy never reached the array) reads `"refused"` with the reason, and is also
logged as a warning.
A `LightCookie` component whose fill is refused at the call, such as one
authored before the scene's `configure` has sized the array, asks again each
time `configure` sizes it. `lightCookies.observe().configurations` counts
those calls for code of its own that does the same.
`lightCookies.observe()` reports how many fills are waiting, the one in
progress, and the loop's own counters.
## An empty layer puts the light out
The cone is multiplied by what it reads from its layer, so a spot pointed at a
layer nothing has filled renders black rather than plain. `project` fills before
it points the light, which is the order that keeps the cone lit; if you set
`SpotLight.cookieLayer` by hand, fill that layer first.
## The array is shared, and sizing it is explicit
The feature-texture array is one resource that every feature using
`zero_feature_texture` draws layers from, and it has no allocator: sizing it
zeroes every layer, including layers another feature owns. So this package never
sizes it as a side effect of setting a cookie, and `layer` is authored rather
than assigned. `lightCookies.configure` is the one call that resizes, made once
by whoever knows what else in the scene wants layers.
A scene that also bakes global illumination shares this array with the lightmap
atlas. Size it for both, and give cookies layers above the ones the bake fills.
## A cookie fills a whole layer
The cone's lookup runs over the layer's entire `uv` range, so an image is
resampled to the layer's extent rather than placed in a corner of it. One layer
carries one cookie.
## Alpha
The source's alpha is how much of the pattern applies. At alpha 0 the cone
passes through unmodulated, so a fully transparent image is a no-op rather than
a black cone.
# editorRegistry
The single registry every editor surface is built from — builtin tabs and world panels alike. The engine's own tools register through the same public API a world uses. Two surfaces consume the registry: the dock service (`system_tools.module`) renders whatever panels are registered, sorted by `order`, and the top bar (`EditorTopBar.component`) builds its menu bar from the registered menus and each panel's `menu`/`order`. Both subscribe to `onChanged` so add/remove is live.
Registrations run through two paths, both ending in `addPanel`: builtin tabs register from `modules.api.editor.system_tools.panels` (the require IS the registration), and world panels register via an `editorPanel` asset whose `onRegister(self)` hook calls `addPanel`. State lives in a fixed `_G` slot so it survives hot-reload and edit↔play mode flips. A duplicate id replaces the prior registration, which is how a world overrides a builtin tab and how hot-reload re-registers cleanly.
## Panels and menus
- `M.addPanel(spec) -> boolean` — add (or replace) a dock panel. `spec`: `id` (required, unique), `build` (required, `(state) -> widget`), `label?`, `menu?` (default `"window"`), `order?`, `refresh?`, `onCallback?`, `onMount?`, `tick?`, `float?`.
- `M.removePanel(id) -> boolean` — remove a registered panel by id.
- `M.addMenuItem(spec) -> boolean` — add (or replace) a clickable action item that runs `onClick`, with no associated panel. `spec`: `id` (required), `onClick` (required), `label?`, `menu?`, `order?`.
- `M.removeMenuItem(id) -> boolean` — remove a registered action item.
- `M.addMenu(spec) -> boolean` — add (or replace) a top-level menu in the bar. `spec`: `id` (required), `label?`, `order?`.
- `M.removeMenu(id) -> boolean` — remove a top-level menu and cascade-drop every item/panel that targeted it.
## Introspection and subscription
- `M.list() -> { panels, items, menus }` — snapshot copies of current registrations for introspection.
- `M.onChanged(fn) -> handle` — subscribe to any add/remove mutation; `fn` runs with no args.
- `M.offChanged(handle) -> boolean` — unsubscribe.
## Host accessors (dock service + top bar)
- `M.setDock(controller?)` — publish (or clear with `nil`) the live `DockController`.
- `M.dock() -> DockController?` — the live dock controller, or `nil` when no dock is mounted.
- `M.panelsSorted() -> {spec}` — live dock panels sorted by `(order, id)`, including `build`.
- `M.menuModel() -> {menu}` — the full top-bar menu model: every menu in bar order, each carrying its entries (`{kind="panel", id, label}` or `{kind="action", id, label}`) in `order`.
The `DockController` type is `{ open, close, toggle, isOpen }` plus optional `getLayout` / `restoreLayout` for layout persistence.
## Usage
```luau
local editor = require("@builtin::modules.api.editor.registry")
editor.addMenu({ id = "mygame", label = "My Game", order = 70 })
editor.addPanel({
id = "my_tools", label = "My Tools", menu = "scene", order = 50,
build = function(state) return Z.lbl("hello") end,
})
editor.addMenuItem({ id = "act", menu = "scene", label = "Go",
onClick = function() end })
```
# preview
Render an asset's preview in the preview studio: an isolated camera, lit only
by its own three-point rig, over a neutral backdrop, that nothing in the scene
reaches. `ref:preview()` renders any asset by the recipe its type declares under
`preview:` in its `type.yaml`, and these are the primitives it calls: they spawn
temporary entities, frame an offscreen camera over them, render one frame,
read it back as a PNG, and tear the entities down.
## Surface
- `preview.fromEntities(makeFn, opts?) -> PreviewResult` — run `makeFn` to
instantiate entities, frame a camera to their bounds, render one offscreen
frame, and return the still PNG.
- `preview.fromRecipe(ref, recipe, opts?) -> PreviewResult` — render `ref` by a
declared recipe: `instantiate` stands it through its own `instantiate`,
`surface` paints the material its type's `ref.previewMaterial` returns on the
recipe's mesh, and `image` fits a file the asset holds to the frame on the
studio backdrop without rendering anything.
- `preview.backdrop() -> { r, g, b }` — the studio backdrop's colour in a
finished preview, 0-255.
`PreviewResult` is `{ available, imageBase64?, rendered?, width?, height?,
bounds?, stats?, reason? }` — `imageBase64` is the rendered PNG when `available` is true, and
`reason` explains why when it is false. `opts` is `{ size? = { width, height },
angle? = { yaw, pitch } }`.
## Example
local result = asset.resolve("gold", "material"):preview({ size = { width = 512, height = 512 } })
-- result.imageBase64 holds the rendered PNG (base64)
# scifi_hud
A UI template screen.
# future
A future is a promise held as a value: store it in a table, return it from a
function, hand it to another task, and await it whenever the value is needed.
Every promise-returning call (`http.get_json`, `delay`, `scene.save`,
`asset.load`, ...) already hands back one, a `PromiseHandle`; this module makes
new ones, runs work as one, chains them, waits for several at once, bounds one
in time and cancels them.
```lua
local future = require("@builtin::modules.future")
task.spawn(function()
-- Start both loads; they run at the same time.
local pending: { Future<any> } = {}
table.insert(pending, http.get_json("https://example.com/a.json"))
table.insert(pending, http.get_json("https://example.com/b.json"))
-- Collect them. Each await returns once its load has settled.
local results = {}
for i, f in pending do
results[i] = f:await()
end
end)
```
## Members of a future
| Member | What it answers |
|---|---|
| `f:await()` | Yields until the future settles and returns its value; a future that has already settled returns at once, with no yield. A rejection raises its error at this line. The same call as `await(f)` and `task.await(f)`. |
| `f:state()` | `"pending"`, `"resolved"`, `"rejected"` or `"cancelled"`, read without taking the value. |
| `f:rejection()` | Why the future was rejected: `{ message, origin?, stack? }`, the reason as text, the `chunk:line` the rejection came from and, when Luau rejected it, the stack there. `nil` while it is pending or once it resolved. |
| `f:source()` | The `chunk:line` whose call created the promise, for finding what a hung await is waiting on. |
| `f.handle` | The promise's id string. `tostring(f)` reads the same. |
| `f:andThen(onResolve, onReject?)` | A future of what the handler returns; a future it returns is awaited and its value delivered. Without `onReject` a rejection passes through. A handler that raises rejects it. |
| `f:catch(onReject)` | A future of `f`'s value, or of what `onReject` returns given the error. |
| `f:finally(fn)` | A future settled the way `f` was, once `fn()` has run, whichever way `f` settled, cancelled included. |
| `f:tap(fn)` | A future of `f`'s value, once `fn(value)` has seen it. |
| `f:timeout(seconds, reason?)` | A future of `f`'s value that rejects with `reason` (a message naming the time by default) when `seconds` of real time pass first; `f` is then let go of. |
| `f:cancel()` | Ends a pending `f` as cancelled: `true`, or `false` and the reason. |
The chaining methods come with the module, which every world script VM loads,
so a future an engine call hands back chains with no require. A handler runs in
a task (it may wait) inside a `scopes.run`: the chained future settles once
everything the handler started is over.
Every task that awaits a future receives its value: the ones waiting when it
settles and any that await it later, for as long as the future is held.
## Module functions
| Call | Result |
|---|---|
| `future.wrap(promise)` | The future naming a promise, from its future or its id string. An id naming no promise the engine keeps raises. |
| `future.new(onCancel?)` | A pending future plus `resolve(value)` and `reject(error)`, which settle it once. `error` is any value but nil. `onCancel()` runs when the future is cancelled. |
| `future.resolved(value)` | A future already resolved with `value`. |
| `future.rejected(err)` | A future already rejected with `err`. |
| `future.after(seconds)` | A future resolving with `true` after `seconds` of real time (the promise `delay(seconds)` hands back). |
| `future.spawn(fn, ...)` | Runs `fn(...)` as a task; the future resolves with what it returns (awaiting a future it returns) and rejects with what it raises. |
| `future.all(futures)` | Every value, in the order given. The first rejection rejects it, the first cancel cancels it, and the rest are let go of. `{}` for none. |
| `future.allSettled(futures)` | `{ status, value?, error? }` for each, in order, once all have settled. Never rejects. |
| `future.race(futures)` | Settles the way the first to settle did; the rest are let go of. |
| `future.any(futures)` | The first value resolved; the rest are let go of. When none resolves it rejects with `{ message, errors }`. |
| `future.retry(fn, attempts, delaySeconds?)` | Calls `fn()` until it succeeds, at most `attempts` times, with `delaySeconds` of real time between. |
| `future.fromEvent(event, predicate?)` | The first value of the next fire of `event` (a Signal, a component event, anything offering `:Connect(fn)` / `:connect(fn)`) that `predicate` accepts. Disconnects once settled or cancelled, and is cancelled when what started it ends. |
```lua
local future = require("@builtin::modules.future")
task.spawn(function()
-- Both loads at once; the first failure fails the lot.
local maps = future.all({ http.get_json(urlA), http.get_json(urlB) }):await()
-- Whichever search answers first; the other is cancelled.
local nearest = future.race({ future.spawn(searchNear), future.spawn(searchFar) }):await()
-- No level within five seconds rejects, and the load is cancelled.
local ok, err = pcall(function()
return future.spawn(loadLevel, "forest"):timeout(5):await()
end)
end)
```
## Cancellation
`f:cancel()` ends a pending future as cancelled. Awaiting it raises the error
`task.isCancellation(err)` recognises, and `f:state()` reads `"cancelled"`. A
cancellation is never written to the error log.
- **The work stops.** The task running a `future.spawn` or a handler is
cancelled, and so is everything it started; each unwinds, so the `pcall` or
cleanup around its wait runs.
- **Downstream.** A future chained from a cancelled one (`andThen`, `catch`,
`tap`, `timeout`, `all`, `race`, ...) is cancelled too, without calling its
handler; `finally` runs its function first.
- **Upstream.** A future counts the chains and combinators waiting on it. One
whose last such consumer is cancelled, or settled without it (a `race`
another input won, a `timeout` that ran out, an `all` another input
rejected), is cancelled. A direct `await` is not a consumer.
- **What started it.** The task running a handler belongs to what the calling
code belongs to: a component instance, a scene load, a task scope. When that
ends, the future is cancelled.
A future an engine call handed back (`http.get_json`, `delay`) runs to its end
whatever waits on it; its `cancel()` answers false and the reason.
## Rejections
A rejection is kept apart from any value, so a future resolved with a table of
any shape delivers that table. Awaiting a rejected future raises its error: a
string at the await's line, any other value (an error table `reject` was
given) as itself. Catch it with `pcall`, and read where it came from on the
future:
```lua
local f, _, reject = future.new()
task.delay(1, function()
reject({ code = 404, message = "no such level" })
end)
local ok, err = pcall(f.await, f)
if not ok then
print(err.code) -- 404: the error value reject was given
local why = f:rejection()
print(why.origin) -- the line that called reject
end
```
A rejection nothing sees (no await receives it and nothing reads
`rejection()`) is written to the error log once the future is gone, so a
future you hold and await later reports nothing on its own. A chain or
combinator sees every rejection its inputs hold when it settles, so the second
rejection of an `all`, or a `race` input already rejected when another won,
writes nothing.
## Identity
A VM holds one future object per promise. Every call that hands a promise to
the VM while its future is alive, `future.wrap` included, hands back that same
object: `future.wrap(f) == f`, `rawequal(future.wrap(f.handle), f)`, and a
future is one table key.
## Types
`Future<T>` is a future whose await delivers a `T`; `PromiseHandle` is a
future whose value has no stated type. They name the same value, so either
is accepted where the other is declared, and `f:await()` on a `Future<T>`
checks as a `T`. `f:andThen(fn)` checks as a `Future` of what `fn` returns (a
`Future<V>` it returns reads as `V`), `f:catch(fn)` as the value or what `fn`
returns, `finally`, `tap` and `timeout` as `f`'s own type, and
`future.all({ Future<T> })` as a `Future<{ T }>`.
·computeshader · born here
❒asset # compute_snow_terrain
GPU heightmap generation for a snowy mountain landscape. One thread per `(x, z)`
column, emitting a single `f32` height per cell. Uses multi-octave FBM for hills,
ridge noise for peaks, and a radial bowl to frame a mountain valley.
Bindings (see bindings.yaml): `params` (read `array<f32>` — `[gridSize,
worldExtent, seed, mountainScale, baseHeight, ridgeStrength, ...]`), `heights`
(read_write `array<f32>`, indexed `[z * gridSize + x]`).
Usage: dispatch by identity with `compute.dispatch("@builtin::shaders.compute_snow_terrain", ...)` —
it resolves to the shader's stable guid and auto-compiles on first use (no setup).
# Default Unlit
A flat, unlit surface that renders its base colour directly with no lighting response, built on the `unlit` shader. Uses a white base colour (`1.0, 1.0, 1.0`) over the `default:white` texture with `emissive` (`1.0`) for full brightness — the go-to default for UI, gizmos, and debug geometry that must stay legible regardless of scene lighting.
## Inputs
- `colors.base_color` — the flat colour shown directly.
- `textures.base_color_texture` (`default:white`) — the white base texture the colour multiplies against.
- `floats.emissive` (`1.0`) — full, lighting-independent brightness.
# tween
Tween / easing library — smooth property interpolation with chaining,
timelines, and 30+ easing curves. This file is the public surface; the
implementation lives in three submodules: `core.module/` (lifecycle and
interpolation), `easing.module/` (easing curves and name lookup), and
`timeline.module/` (keyframe sequencer).
**Important**: callers MUST call `tween.update(dt)` every frame from a
component's `update` function. Without this, tweens will not advance.
## Exports
Property animation:
- `tween.to(entityId, property, target, durationOrOpts, easing?) -> Tween` — animate an entity property to a target value.
- `tween.fromTo(fromVal, toVal, durationOrOpts, easing?) -> Tween` — animate raw values via `onUpdate`.
- `tween.create(opts: TweenOpts) -> Tween` — create without auto-starting (for use in chains / timelines).
- `tween.update(dt: number)` — advance all active tweens; call every frame.
Global controls:
- `tween.setGlobalSpeed(speed: number)` / `tween.getGlobalSpeed() -> number`.
- `tween.cancelAll()`, `tween.cancelEntity(entityId)`.
- `tween.pauseAll()`, `tween.resumeAll()`.
- `tween.getActiveCount() -> number`.
Timelines and easing:
- `tween.timeline() -> Timeline` — keyframe-based orchestration.
- `tween.easing` — the easing namespace (re-export of the easing module).
- `tween.easings() -> { string }` — list of available easing names.
Types:
- `TweenOpts = { entityId?, property?, from?, to?, duration?, delay?, easing?, loops?, yoyo?, onStart?, onUpdate?, onComplete?, onCancel?, onLoop? }`
## Usage
```luau
local tween = require("@builtin::modules.tween")
-- Drive the tween system every frame from a component update:
function update(dt)
tween.update(dt)
end
-- Property tween on an entity:
tween.to("box", "position", { x = 0, y = 5, z = 0 }, {
duration = 1.0,
easing = "easeOutBounce",
onComplete = function() print("done") end,
})
-- Raw value tween via callback:
tween.fromTo(0, 1, 0.4, "easeInOutQuad"):andThen(
tween.create({ from = 1, to = 0, duration = 0.4, easing = "easeOutCubic" })
)
-- Timeline sequencer:
local tl = tween.timeline()
tl:add(0.0, tween.create({ entityId = e, property = "position", to = pos, duration = 1 }))
tl:add(0.5, tween.create({ entityId = e, property = "tint", to = color, duration = 0.3 }))
tl:play()
function update(dt)
tween.update(dt)
tl:update(dt)
end
```
## Notes
- Properties supported by `tween.to`: `"position"`, `"rotation"`,
`"scale"`, `"tint"`. See `core.module/init.luau` `ENTITY_PROPS` for
the full accessor map.
- Duration argument is permissive — pass a number (seconds) or a full
`TweenOpts` table with `duration`, `easing`, `delay`, `loops`,
`yoyo`, and the callback hooks.
- Tween / Timeline objects are returned as the underlying class
instances from the core / timeline submodules; the public `M.timeline()`
surface exposes them as plain `TimelineObj` and `TweenObj` opaque
handles to keep `--!global` registration unencumbered.
- The submodules are also independently `require`-able for callers
that need direct access to easing curves or timeline internals.
# frameStream Module
Public Luau surface over the `__framestream` Internal FFI namespace — an
image the GPU drew carried out to another program, frame after frame.
## Purpose
Wrap the raw `__framestream.*` FFI namespace in a typed Luau table that
gets auto-injected as `_G.frameStream` via the prelude. A session binds
one texture to one stream: make a render target the size you want to
emit, point a Camera at it, open a stream to whoever is consuming the
pixels, and attach the two. From then on the engine reads that texture
back every time the session is due a frame, packs the pixels, and writes
each frame to the stream. The image sits in GPU memory, where nothing
outside the renderer can reach it; the session is what puts it on the
wire.
The readback runs through a ring of staging buffers allocated once and
reused for the life of the session, so the frame the GPU is finishing is
read back while the next one renders and nothing waits on the render
thread. The write refuses rather than waits, so a consumer reading
slower than the engine renders falls behind by frames — visible on
`status().droppedBackpressure` — instead of holding the renderer back.
## Usage
```luau
-- A 128x32 render target, and a Camera that draws into it.
local rt = renderer.texture.create({ width = 128, height = 32 })
entity.spawn({
name = "panelCam",
position = { 0, 0, 6 },
components = {
Camera = { textureHandle = rt.guid, postProcessing = false },
},
})
-- Somewhere to send the pixels.
local out = task.await(stream.open("tcp://127.0.0.1:9000"))
-- Carry the camera's frames out at 30 fps.
local session = frameStream.attach(rt.guid, out, { fps = 30 })
-- Later.
local s = frameStream.status(session)
print(s.frames, s.dropped, s.achievedFps)
frameStream.detach(session)
stream.close(out)
```
## What reaches the stream
One frame's pixels, then the next frame's, with nothing between them. A
frame is `width * height * bytesPerPixel` bytes, rows top-first, and is
written in a single call — so a consumer that has counted its bytes
reads a whole frame or none of it, never half of one.
`format` picks the layout: `"rgb24"` (3 bytes per pixel, the default) or
`"rgba8"` (4) — a call naming any other format raises, naming both.
`flipY = true` writes the last texture row first, for a consumer whose
display is scanned bottom-up.
A consumer at the far end of a byte stream that can drop out and be
reopened usually wants more than the pixels — a magic to resynchronise
on, a length, a sequence number. That belongs to the consumer's own
protocol, so it is written by whoever speaks it rather than baked in
here.
## Framing the bytes yourself
To put a header of your own ahead of each frame, run the frames through
a `loopback` stream and write them on from there. The session fills the
loopback with whole frames; you drain one, prepend whatever your
consumer expects, and write the result to the real stream:
```luau
local FRAME = 128 * 32 * 3
local pixels = task.await(stream.open("loopback://panel", {
inboundCapacity = FRAME * 4,
outboundCapacity = FRAME * 4,
}))
local out = task.await(stream.open("tcp://127.0.0.1:9000"))
local session = frameStream.attach(rt.guid, pixels, { fps = 30 })
local counter = 0
task.spawn(function()
while true do
local frame = stream.read(pixels, FRAME)
if #frame == FRAME then
stream.write(out, myHeader(counter) .. frame)
counter += 1
end
task.wait()
end
end)
```
That costs one in-memory copy per frame and keeps the protocol in
content, where it can change without an engine build. Attaching straight
to the destination stream skips the copy, and is the path to take when
the consumer wants the pixels bare.
## Rate
`fps` caps how often a frame is taken and is independent of the rate the
engine renders at: a session at `fps = 30` in an engine running at 144
takes every fifth frame or so, and one at `fps = 30` in an engine
running at 20 takes every frame it can. `status().achievedFps` is what
it reached — compare it against `requestedFps` to tell a display that is
running slow from one that is running as asked.
## Reading the status
`frameStream.status()` separates the three ways a frame fails to arrive,
because they call for different fixes:
- **`droppedBackpressure`** — the consumer is reading slower than the
session is producing. Either the consumer is too slow, or the stream's
`outboundCapacity` is too small to absorb its jitter.
- **`stalledReadbacks`** — the frame came due while every staging buffer
still held a copy on its way from the GPU. The session is asking for
frames faster than the GPU is handing them back.
- **`lastOutcome == "tooLarge"`** — a frame is bigger than the stream's
whole outbound capacity, so every frame of that size will be refused
too. Open the stream with an `outboundCapacity` that holds one.
## Which frame a stream holds
A frame is copied when it comes due and reaches the stream once its copy
has come back from the GPU, so how many frames a stream holds at a given
moment depends on how fast those copies come back. Two sessions on one
texture can therefore hold different numbers of frames when they are
read, the newest frame of one showing a later image than the newest of
the other. `status().lastCopyFrame` names the engine frame, in
`time.frameCount()` numbering, that the copy of the newest accepted frame
was taken on. Every copy one engine frame takes of a texture holds the
same image, so read it in the same step as draining the stream, and two
sessions whose readings agree hold the same image as their last frame.
Two different readings can still name one image: an engine frame on
which nothing drew into the texture copies the image it already held.
## Platforms
Frame egress runs on native and wasm alike: the readback is submitted
and polled without blocking on either, and the packing is plain byte
work. What differs between them is which transports a stream
can be opened over — `stream.transports()` is the probe for that, and
`tcp` names its wasm absence there.
## Exports
- `frameStream.attach(texture, stream, opts?) -> string?, string?` — session handle, or nil + reason.
- `frameStream.detach(handle) -> boolean`
- `frameStream.status(handle) -> FrameStreamStatus?` — `nil` when handle names no live session.
- `frameStream.list() -> { string }`
# modules.api.engine.dirty_hot_reload
Picks up dirty-overlay changes from disk while a scene is loaded in
edit mode, applies them to the live entities, and keeps the in-memory
representation in lockstep with `scene_dirty/` files. Pairs with
`scene_saver` (writes deltas) + `scene_loader` (composes canonical +
overlay at load time).
## Parents
A record's `parent` names the entity it stands under: the entity whose id is
that text, else the entity whose display name is exactly that text. A `*` or
`?` in it is a character of the name, never a pattern.
A record whose parent no entity provides waits. An entity that does not stand
yet is not built, one that already stands keeps its state, the layer logs the
record once, and `debugState(guid).waiting` maps its id to the parent it
names. While a record waits, the scene saver leaves it as it stands rather
than writing a standing entity's older state over it, until an author edits
that entity, which the saver then records as it stands. When a record builds
an entity, the records waiting on that entity's id or display name are built
under it, so a child record that arrives before its parent's record lands
where a load of both places it. Each waiting record is built as its overlay
file states it at that moment, so a record rewritten while it waited,
including by this session's own saver, builds from its current bytes. A load
gives the same account of a record whose parent stays missing: the loader
abandons it with `parentMissing`.
# video Module
Public Luau surface over the `__video` Internal FFI namespace — video
playback via render-target-backed players.
## Purpose
Wrap the raw `__video.*` FFI namespace (backed by `zero_video`) in a
typed Luau table that gets auto-injected as `_G.video` via the
prelude. Players are backed by standard render targets — the handle
returned by `video.create` is an `"rt:video_N"` string usable anywhere
a render target works (materials, UI, post-process inputs).
## Usage
```luau
-- Stream a video onto a material
local rt = video.create("http://example.com/clip.mp4", {
loop = true, autoplay = true, rate = 1.0,
})
material.setTexture(myMat, "baseColor", rt)
-- Transport
video.pause(rt); video.seek(rt, 30.5); video.play(rt)
-- Inspect
local info = video.getInfo(rt)
print(info.width, info.height, info.currentTime, "/", info.duration)
-- Ask whether this host decodes the stream before waiting on a frame.
-- `codecSupported` is nil until the answer arrives, false where the host has
-- no decoder for `info.codec`, and then `info.state` is `"unsupported"`.
if info.codecSupported == false then
print(info.error)
end
-- Frames handed to the texture against frames the playback clock reached,
-- and how many samples decoded on the frame thread (a native stream decodes
-- on a thread of its own; a browser stream in the browser's decoder).
print(info.framesPresented, "/", info.framesExpected, info.frameThreadDecodes)
video.destroy(rt)
```
## Exports
- `video.create(url, options?) -> rtHandle`
- `video.play(handle)` / `video.pause(handle)` / `video.stop(handle)`
- `video.seek(handle, time)`
- `video.setRate(handle, rate)` / `video.setLoop(handle, loop)`
- `video.getInfo(handle) -> VideoInfo?`
- `video.destroy(handle)`
# yaml.module
YAML decode + encode for authored engine content. Pure Luau, no engine
dependencies — a general parsing library, not data-system plumbing.
```luau
local Yaml = require("@builtin::modules.yaml")
local doc = Yaml.decode(vfs.read(path))
vfs.write(path, Yaml.encode(doc))
```
## Supported subset
- Block mappings (`key: value`) and block sequences (`- item`), nested
to any depth.
- Compact sequence-of-mapping entries (`- path: x` / ` kind: y`).
- Flow collections — `{ a: 1, b: 2 }` and `[1, 2, 3]` — single line.
- Plain scalars with type inference: `~` / `null` → nil, `true` /
`false` → boolean, numeric literals → number, everything else →
string.
- Single- and double-quoted strings (`''` escapes a literal quote
inside a single-quoted string; `\n` `\t` `\r` `\\` `\"` inside a
double-quoted one). A quote opens a quoted scalar where a scalar
begins — after `key:`, after a `- ` entry marker, or inside a flow
collection — so a plain scalar carries an apostrophe as data
(`note: the world's rules`). Quoted keys are read in flow mappings.
- `#` comments — honored outside quotes, so a `#` inside a quoted
string is kept as data.
- Literal (`|`) and folded (`>`) block scalars — the bare indicators
only. A block scalar's content is literal text: quotes (balanced or
not), `#`, `key: value` and `---` inside it are data.
- An optional leading `---` document marker.
## Loud-error contract
Constructs outside the subset above raise instead of misparsing:
anchors/aliases (`&`, `*`), tags (`!`), directives (`%`),
multi-document streams (a second `---`), block-scalar
chomping/indentation indicators (`|-`, `|+`, `>-`, `|2`, ...), tab
indentation, and inconsistent indentation (both block structure and
block-scalar bodies). Every error carries the offending line number
(`yaml: line N: ...`), so a bad config points straight at the line to
fix.
Tab indentation is rejected everywhere — including block-scalar body
lines whose leading whitespace contains a tab. A tab after the first
non-space character of a line is content and passes through.
## API
- `Yaml.decode(text: string): any` — decode a YAML document into a
Luau value (table, scalar, or nil for an empty document). Raises on
malformed input or an unsupported construct.
- `Yaml.decodeWithComments(text: string): (any, boolean)` — decode as
`decode` does, and report whether the text carries a comment by the
decoder's own rule: a `#` at a line's start or after whitespace, outside
a quoted scalar and outside a block scalar. Re-encoding the value drops
such comments, so an editor that writes a document back reads this first.
- `Yaml.encode(value: table): string` — encode a Luau table as YAML
(block style, two-space indent, alphabetically sorted keys). Raises
on values YAML can't represent (functions, userdata, non-string
mapping keys).
A top-level empty table encodes to an empty document, which decodes
back to `nil` (the YAML empty-document ambiguity).
·computeshader · born here
❒asset # concat
Joins one attribute of several geometries into one column. `sources` is a column
table with one column per geometry, in order; `desc` says where each geometry's
elements start in the result, how many it has, how far to shift each word of a
linkage column, and whether the geometry holds the attribute at all. Where it
does not, its elements take the default stated in the first four words of
`desc`.
Linkage is a shift: `vertex.pt` takes the number of points before the geometry,
`primitive.vstart` the number of vertices before it. Any other attribute shifts
by 0. A node joins a whole geometry by running the kernel once per attribute of
the union of the geometries' attributes.
Dispatch over `n`, the elements of the joined domain. Exact: each result
element has one writer.
# debug_frustum
The "frustum" provider for `@builtin::modules.debugViz`: appends one cyan
wireframe frustum per scene Camera into the shared debug-visualization batch,
every edit-mode tick.
```lua
debugViz.registerProvider("frustum", require("@builtin::modules.debug_frustum").tick)
```
`DebugViz.component` registers this provider automatically — attach it to one
entity to draw every enabled camera's view frustum alongside whatever other
providers are registered.
## How it works
`tick(emit)` queries every entity carrying a Camera and, for each enabled one,
builds its 8 near/far corners in the camera's local space:
- half-height at a given distance is `tan(fov / 2) * distance`; half-width is
`half-height * aspect`.
- the camera looks down its local **-Z** axis
(`entity(id).transform.forward` is documented as "local -Z axis in world
space"), so the near/far planes sit at local `z = -near` / `z = -far`.
- each corner is `position + forward * dist + right * (±halfWidth) + up *
(±halfHeight)`, using the entity's world position and world-space
forward/right/up axes — no separate quaternion math needed.
`Camera` has no `aspect` field, so the aspect ratio is read live from
`getViewportSize()` (the window's real aspect, matching what the viewport
camera sees), falling back to a 16:9 default if the viewport size isn't
readable. The 12 edges (4 near, 4 far, 4 connecting) are appended via
`emit.line`.
## API
- `debug_frustum.tick(emit)` — append one wireframe frustum per enabled scene
camera into the shared batch.
# restirLighting
Direct light from many small sources at a cost that does not grow with how many
there are.
A shading pass that loops every light pays for every light at every pixel, which
is why the number of lights has to be capped. Resampled importance sampling
breaks that link: each pixel draws a few candidates, keeps ONE of them with a
probability proportional to what it would contribute, and carries a weight that
makes the survivor stand for the whole set. What it keeps is then reused — by its
neighbours this frame, and by itself on the next one — so the set a pixel
effectively samples keeps growing while the number of candidates it draws stays
where you put it.
A cave of glowing crystals, a street of practicals, a control room of indicator
lamps: hundreds of sources, and each pixel looks at about a dozen.
## Using it
Put an `EmissiveSource` on the geometry that glows:
```lua
local id = entity.spawn("crystal", { position = { 2, 1, 0 } }).id
entity(id).component.add("EmissiveSource", {
color = { 0.3, 0.8, 1.0 },
intensity = 8.0,
radius = 6.0,
size = 0.4,
})
```
Or register sources from a script, which is how a generated scene does it:
```lua
local restir = require("@builtin::systems.restirLighting.restirLighting")
for i = 1, 400 do
restir.set(`lamp{i}`, {
position = { math.random() * 60 - 30, 1.5, math.random() * 60 - 30 },
color = { math.random(), math.random(), math.random() },
intensity = 4.0,
radius = 5.0,
})
end
```
The pass starts with the first source and stops with the last. Settings are on
`restir.configure`, or on a `ResampledLighting` component for a scene to author:
```lua
restir.configure({ candidates = 16, spatialSamples = 6 })
```
## What a pixel pays
`candidates + spatialSamples + 1` evaluations, reported by
`restir.perPixelCost()`. None of those terms is the source count.
The estimate is formed on a grid of one cell per 2x2 pixels (`resolution =
"full"` gives every pixel its own cell), across four passes:
| Pass | Runs over | Does |
|---|---|---|
| `restir_sample` | grid cells | draws `candidates` sources at random, resamples to one, merges the cell's own history |
| `restir_spatial` | grid cells | borrows from `spatialSamples` neighbours, each evaluated against this cell's surface |
| `restir_resolve` | pixels | shades the chosen source with the pixel's own normal, roughness and albedo |
| `restir_denoise` | pixels | averages that light over the cells around the pixel and composites it |
Which source to look at is a low-frequency decision a cell of pixels can share.
What the surface does with it is not, and is computed per pixel.
One source per cell is correct on average and different from its neighbour,
which is the grain a single sample carries. `restir_denoise` averages across the
cells around a pixel, weighing each by how nearly its surface is this one, so the
estimate keeps its value and loses its variance.
`mode = "reference"` replaces the reservoir lookup with a loop over every
registered source at every pixel. That is the picture the resampled mode is
estimating and the cost it is measured against — the same shading, the same
filter and the same composite, so the two are comparable directly.
## Temporal reuse
A cell keeps its reservoir across frames and merges it with the fresh
candidates, so a good find keeps paying after the frame that made it. The stored
world position is what says the cell is still looking at the same surface; a
camera move or an occluder passing in front fails that test and the history is
dropped rather than smeared.
`historyCap` bounds how many candidates the history is allowed to stand for.
Without it a converged cell would outvote every new candidate forever and a
lighting change would never work its way out. At the default of 20 a change is
absorbed over roughly twenty frames.
Removing a source, or turning its `intensity` down to 0, releases it; the pass
stops with the last one.
## Where it sits
Sources registered here are additional to the lights the engine holds rows for.
The passes run in `afterLighting` at orders 80-84 and add to the scene as it was
drawn, reading position, normal, albedo and metallic-roughness back out of the
G-buffer.
A source lights every surface within `radius` that faces it, along the straight
line between them.
## One frame, several targets
A render feature's `render` runs once a frame, and the passes it enqueues run
once per render target — the window, an offscreen capture, a camera rendering to
a texture. Each pass therefore reads the frame size out of the textures it is
handed and works out its own cell grid, so every target resamples at its own
resolution. A reservoir carries the grid width it was written under, which is
what stops one target's history being read as another's.
The reservoir buffers are sized to the widest grid any target has asked for. A
target wider than they have yet been sized for reports the grid it needed, and
the frame after that covers it.
## Reading what it did
`restir.stats()` returns the counters the passes keep on the GPU for the most
recent frame a read-back has landed for, carrying that frame's number so a
repeated read is distinguishable from a fresh one. `meanWeight` is counted in
sixteenths with each cell capped at 16 so the total fits a 32-bit counter;
`peakWeight` alongside it is exact to a thousandth, and is where an estimator
that has come loose shows first.
# MaterialAuthor
Stand up new materials and textures fast — the go-to surface for making materials at design time, each in one call instead of a hand-rolled asset-create-and-wire dance.
Every tool writes a source material asset and hands back a live `AssetRef<material>` you can apply straight to a Model. Wrap a texture (a proxy, a `.texture` asset, or an image path) into a new material with its PBR slot inferred from the texture name; make a flat-colour material from an RGB(A) value; clone an existing material under a new name with property or texture overrides; or scan a folder of textures — loose images and `.texture` assets — inferring each one's slot from its name and composing them into a single material. It pairs with the SVG → texture importer: drop a `.svg`, get a `.texture`, wrap it.
**This is authoring, not runtime.** Every tool creates source assets, which is a design-time action — never call it from gameplay code (components, controllers, per-frame updates, spawn logic). At runtime you consume an already-authored material by name or ref through the lower-level `Material` surface (create / apply / get / set), which is the gameplay-safe layer this one is built on. Reach here to *make* materials; reach for `Material` to *apply and tweak* them.
## Material properties
Beyond the keys a tool reads itself (`name`, `shader`, `overwrite`, and `slot` on `fromTexture`), every option names a property of the shader the material is authored against — so anything `pbr` declares is reachable in one call: `roughness`, `metallic`, `clearcoat`, `transmission`, `anisotropy`, `ior`, `uv_scale`, and the texture slots. A custom shader's own properties work the same way, since the vocabulary is read from the shader.
Spell a property in `snake_case` or `camelCase` (`uv_scale` or `uvScale`); `tiling` names `uv_scale` and `color` names `base_color`. A single number fills a vector property's components, so `tiling = 300` tiles 300×300. An option the shader doesn't declare is refused, naming the closest accepted spelling — the material is not written.
```lua
tools.use("MaterialAuthor", "fromTexture", "cliff_diff.texture",
{ name = "Cliff", tiling = 300, roughness = 0.9 })
```
## Slot inference
When no slot is given, the texture stem picks the PBR slot: `albedo` / `basecolor` / `base_color` / `diffuse` / `col` → base colour; `normal` / `nrm` → normal; `roughness` / `metallic` / `metalness` / `metallic_roughness` / `orm` → roughness-metallic; `emissive` / `emission` / `emit` → emissive; `ao` / `occlusion` → occlusion. Pass an explicit slot to override.
·computeshader · born here
❒asset # grid
Reads and writes a field of cells in one kernel. The field is a Grid (one tile,
no apron, x fastest, `lanes` values per cell) or a Raster (tiles with aprons,
one batched dispatch) through the layout in `tile_layout`.
- sample (`MODE sample`): the field at positions, nearest or trilinear, held to the
edge cell past the field. Reads each cell from the tile that owns it.
- write (`MODE write`): a patch of values blended into the field (replace, add,
max, min, multiply, optionally weighted per cell). Every tile that holds a cell
as core or apron receives it, so apron copies agree. The patch window is stated
in field cells and may reach past the field (negative origin, or a size beyond
the cell counts): a stored cell a border tile holds past the field takes the
patch where the window covers it.
- stencil (`MODE stencil`): per stored cell, a neighbour at an offset, slope in
degrees, curvature (the laplacian over x and z) or the gradient, with neighbour
reads held to the tile's own stored edge. Slope and curvature are the terrain
mask's.
`MODE`, `SAMPLING` (nearest, trilinear), `STENCIL` (offset, slope, curvature, gradient) and `BLEND` (replace, add, max, min, multiply) are specializations.
`determinism: exact`: each output element has one writer. The bindings a mode
does not use take any buffer that is not bound elsewhere in the dispatch.
# material_records
The material GPU records this session has released and nothing has filed again.
A `.material` asset files its GPU record at its point of use — `matRef:handle()`
— and memoises the handle it filed on the ref's runtime, so a second bind of the
same material costs a table read where the first cost a parse and an upload.
That memo names a record the renderer's registry holds, and
`renderer.material.destroy` takes the record away while the asset and its memo
stand. The asset is what files the next record, so it has to know which names
that applies to.
This set is that knowledge: a release puts the key in, filing a record under the
key takes it out, and the point of use reads it for the cost of one table index.
Written by `renderer.material.destroy` and `renderer.material.create`; read by
the `.material` assetType's `handle()`.
## API
```luau
local MaterialRecords = require("modules.material_records")
-- A point of use holding a memo asks whether the record it names still stands.
if MaterialRecords.released[identity] ~= nil then
-- The record is gone: file another one, which takes the mark off.
end
MaterialRecords.noteReleased(key) -- the record under `key` has been released
MaterialRecords.noteFiled(key) -- a record stands under `key` again
```
## Lifetime
The set lives in the session store, so it holds for as long as the GPU records
it answers for: a VM reload leaves those records standing and leaves this
standing with them, and both die with the engine process.
# enum_values (module)
The `enum` field-constraint validator. Registers itself with
`field_constraints.module`'s registry on load so any component field
carrying `constraint = { kind = "enum", values = { ... } }` — which is
what `Field.enum` emits — is checked against it.
## What it does
Given a field's written value and its constraint:
1. Passes `nil` — an enum field may be left unset.
2. Rejects a constraint carrying no members, since there is nothing to
check against.
3. Rejects a non-string value, naming the members and the type it got.
4. Answers whether the value is one of `constraint.values`.
Every rejection lists the whole member set, so the message tells the
caller what they may write instead of only what they may not.
## Where the members come from
`Field.enum(values, default, mode)` validates the member list at
construction: the members must be non-empty, distinct strings, and a
non-nil default must be one of them. A field whose members are wrong is
therefore a load-time error rather than a runtime surprise, and this
validator only ever sees a well-formed set.
The engine carries `constraint` verbatim and never reads inside it, so
the member list lives entirely in Luau.
## Exports
- `M.check(value, constraint) -> ok, reason?` — the validator function,
also registered under the `enum` kind.
·computeshader · born here
❒asset # topology
Mesh edit operations over a geometry's linkage (`vertex.pt`, `primitive.vstart`,
`primitive.vcount`), selected by the `OP` specialization (`split_edge`, `collapse_edge`, `insert_face`, `delete_face`, `split_vertex`), each stage by `STAGE` (`count`, `write`): split an edge (insert a point),
collapse an edge (merge two points and drop the faces that degenerate), insert a
face, delete a face, split a vertex (give a group of corners a point of their own).
Each operation runs in two stages with an exclusive prefix sum between them. Stage 0 writes
`cnt`, how many output vertices each primitive contributes (each vertex, for split
vertex). The prefix sum of `cnt` is `off`. Stage 1 writes the output vertices'
points (`o_pt`) and where each came from (`o_src`). The output primitives are
`off` as run starts and `cnt` as run lengths; a primitive with an empty run is gone,
and the select node removes it. The shader's header states the records each
operation reads.
The operation's records come from the edge table: for split edge, the point
for each split edge gathered to the vertex slots through `edges`' side slots
(both faces of an edge name the same point); for collapse, the point each
point becomes, from an independent set of edges. The point an inserted
vertex or a split vertex stands for is carried by the output maps: `o_src` names the vertex
whose attributes an output vertex takes (negative when it is new, with ~v the
vertex a split inserted after), and the compaction of the records names each
new point's source. Integers are 32-bit words, exact.
# grass_material.shader
Grass, shaded root to tip with per-blade colour variation and a soft
half-lambert wrap.
The colouring reads `uv.y` as height along a blade — 0 at the root, 1 at the tip
— and darkens the roots while warming the tips. Per-blade variation comes from a
hash of the fragment's world xz on a 10 cm grid, so every fragment of one blade
shares a seed and neighbouring blades drift apart in hue and brightness. The
lighting is a diffuse half-lambert wrap kept below saturation, so this shader
returns its own final colour instead of a PBR surface.
`sway` is how far the vertex stage bends each vertex along its blade, and it is
what tells this shader that its mesh is a blade mesh: the bend scales with
`uv.y²`, so a vertex at the root holds still while the tip swings hardest. It
defaults to 0, at which every vertex arrives where the mesh put it — a material
that leaves it there shades a surface without moving it. `@builtin::materials.grass`
is that ground surface; `@builtin::materials.grass_blades` sets `sway` to 1 for a
blade mesh.
## Properties
- `base_color` — the grass green the root→tip gradient is built from.
- `sway` (`0`..`4`, default `0`) — how far each vertex bends along its blade.
# api
Discover the engine's own documentation when you don't yet know the path — browse the docs tree and search it by keyword.
Browse the documentation under `/zero/docs`: with no argument it lists the top-level categories (api, tools, guides, components), a partial path lists a directory's entries, and a full path returns the document. Searching matches a keyword against entry names across the whole docs tree. When you already know the path, reading it directly is quicker; these are for finding your way when you don't.
# asset_tag (module)
The `assetTag` field-constraint validator. Registers itself with
`field_constraints.module`'s registry on load so any component field
carrying `constraint = { kind = "assetTag", tag = ... }` — which is
exactly what `Field.taggedRef` emits — is checked against it.
## What it does
Given a field's written value and its constraint:
1. Passes `nil` — an unset slot holds nothing to check.
2. Reads the value's identity — a bare string, or a table's `__ref` /
`guid` / `identity` / `name`.
3. Resolves it as an asset of any type (`asset.resolve(identity)`), since
the tag is the whole gate.
4. Answers `asset.has_tag(identity, constraint.tag)`.
Acceptance rides on the tag rather than the asset's type, so an asset
becomes assignable to a tagged slot the moment it is tagged — no edit to
the field or to anything that reads it.
A rejection names the tag the slot wants, the tags the asset carries
instead, and the `asset.list({ fields = { tags = ... } })` call that
enumerates what would fit, so the message carries its own next step.
## Exports
- `M.check(value, constraint) -> ok, reason?` — the validator function,
also registered under the `assetTag` kind.
# world_sync
Grafts sync-durability status and per-item conflict resolution onto the
`world` namespace. In a live world every `/source` write is synced to
the backend; a write that has not yet reached the backend is a *pending*
write, and a write that collides with a change another collaborator made
since you last saw the path is a *conflict*. This module surfaces both
and lets you resolve conflicts one path at a time.
## `world.syncStatus()`
Returns a snapshot of the world's sync state:
```lua
local s = world.syncStatus()
-- s.subscribed : bool: attached to a live world
-- s.content_synced : bool: incoming: the world's content is applied to this client
-- s.outgoing_synced : bool: outgoing: every /source change this session made reached the world
-- s.uploads_outstanding : number: upload jobs queued, in flight, or held for another attempt
-- s.progress : { applied: number, expected: number? }
-- s.pending_writes : number: local /source writes and removals not yet round-tripped whose durable record stored
-- s.unrecorded_writes : { string }: owed /source paths whose durable record did not store, which a restart loses
-- s.owed_paths : { string }: /source paths written or removed that the manifest does not reflect yet (the list vfs.pendingWrites() returns)
-- s.unsaved_writes : { string }: /source paths whose write or removal the server never took
-- s.held_uploads : { { path, reason } }: owed changes whose upload failed and is being tried again
-- s.uploads_abandoned : number — uploads the queue stopped carrying
-- s.conflicts : { { path, base_sha, local_sha, backend_sha, isBinary } }
```
Content sync runs in two directions, and the status answers each apart.
`content_synced` is the incoming direction: the world's content has been
applied to this client. A survey uses it to tell "still syncing" from
"empty world": a freshly attached session reads `content_synced = false`
until the backend content has landed. It reads nothing about this
session's own changes reaching the world.
`outgoing_synced` is the outgoing direction: every `/source` change this
session made has reached the world. It reads `false` while any write or
removal is queued, uploading, held for another attempt, not yet reflected
by the manifest, kept in a durable record (`pending_writes`,
`unrecorded_writes`, a conflict among them), or refused by the server, and
`uploads_outstanding` counts the upload jobs it is still waiting on, each
once. It is the field that answers "is my work saved?". Both booleans read
`false` while no world is subscribed.
`pending_writes` moves as the queue drains, so it reads the same for a
queue working through a backlog and for one that has given content up.
`unsaved_writes` and `uploads_abandoned` separate them: the first names
the `/source` paths whose bytes are on this client only, the second counts
the uploads the queue stopped carrying after they failed on it repeatedly
while the connection reported itself live. A path listed there stays on
this client until another session's reconcile carries its durable record.
`held_uploads` names the owed changes whose last upload attempt failed and
went back to the queue, each with the reason that attempt gave: a blob
transfer that ran past its deadline, a refused connection. The queue tries
each again on its own schedule until it lands or is given up on. A held
upload holds nothing else up: a stage or commit of other paths goes ahead,
and a bulk stage passes over it. A call that names the held path waits on
it and, when it does not land, reports the path and the reason. Removing
the path ends the hold, since the removal is what the path owes from then
on.
`unrecorded_writes` names the owed `/source` paths whose durable record
this machine could not store, because the pending-write store or the local
blob store could not be written. This session still carries them to the
world; a restart before they arrive loses them. `pending_writes` counts the
owed records that did store, so it leaves these out.
## `world.resolveConflict(path, mode, content?)`
Resolves one conflicted path. `mode` is one of:
- `"merge"` — for text, runs a 3-way merge of the common ancestor, your
bytes, and the current backend bytes, and returns `{ merged, clean }`.
A clean merge produces ready-to-apply text; a conflicted merge returns
the text with `<<<<<<<` / `=======` / `>>>>>>>` markers to resolve.
Merge is unavailable for binary content or a path with no common
ancestor.
- `"apply"` — writes `content` (the finalized bytes you pass) to the
path and clears the conflict.
- `"take-local"` — keeps your bytes.
- `"take-backend"` — keeps the backend's bytes.
- `"discard"` — drops your pending bytes, keeping the backend's.
Retained conflict bytes are also readable under
`/zero/runtime/sync/conflicts/`, so you can inspect a conflict before
choosing how to resolve it.
# layers
Top-level scene-management global + Scene proxy. § 17 step 9 of
`docs/plans/2026-05-01-player-camera-unification.md`. Renamed from the
legacy `scene.*` global to match `/runtime/layers/` and to give
scene-management state (active root, additives, callback registries)
a single anchor distinct from per-scene authoring.
## Global surface
| Symbol | Type | Notes |
|---|---|---|
| `layers.active` | `Scene \| nil` | Current root non-additive scene as a proxy. Live read; updates on every load/unload. |
| `layers.list()` | `{ Scene }` | Every loaded scene (root + additives) as proxies. |
| `layers.find(ref)` | `Scene \| nil` | Lookup by `AssetRef<scene>` or layer-id string. |
| `layers.is_loaded(ref)` | boolean | |
| `layers.load(ref, opts?)` | — | Loads ref. `opts.additive = true` layers on top; default = non-additive (replaces root). |
| `layers.unload(ref?)` | — | Unloads ref; nil = active root. |
| `layers.reload(ref?)` | — | Equivalent to unload + load with same ref. |
| `layers.onLoad(cb) -> handle` | function | Subscribe to any scene-load event. Returns handle for `offLoad`. |
| `layers.offLoad(handle)` | function | |
| `layers.onUnload(cb) -> handle` / `offUnload(h)` | function | Same shape for unload events. |
| `layers.observe()` | `SceneObservation` | What every load did and what each loaded scene costs, in one read. |
| `layers.lastLoad()` | `SceneLoadReport \| nil` | The last load's report; nil when nothing has loaded. |
| `layers.lastUnload()` | `SceneUnloadReport \| nil` | The last unload's, under the name the layer was loaded with. |
| `layers.loadHistory()` | `{ SceneLoadReport }` | The completed load reports still held, oldest first. |
| `layers.problems(ref?)` | `{ SceneLoadFailure }` | What a layer failed to produce, and why. |
| `layers.whyPartial(ref?)` | `(reason?, detail?)` | The nearest cause a layer is not whole, from a closed set. |
| `layers.inventory()` | `{ SceneLayerInventory }` | What the engine attributes to each loaded layer. |
| `layers.cost()` | `{ SceneLayerCost }` | Each layer's per-frame entrypoint tick, summed across the window. |
| `layers.resetCostWindow()` | — | Open a new cost window. |
## Scene proxy surface
A `Scene` proxy is a sealed table with fields and `:method` callables.
| Field / method | Notes |
|---|---|
| `s.asset` | `AssetRef<scene>` |
| `s.name`, `s.path` | informational (logging, debug) |
| `s.guid` | the scene asset's guid — the layer's identity |
| `s.sceneIdentity` | the identity of the scene asset the layer was loaded from (e.g. `@builtin::scenes.test_arena`), whichever VM loaded it |
| `s.additive` | boolean — additive overlay vs root scene |
| `s.parent`, `s.children` | additive ownership chain |
| `s:root()` | walks `.parent` to the non-additive root; returns self for roots |
| `s.players`, `s.camera` | populated by §17 step 10 (player + camera spawners) |
| `s.entrypoint` | `AssetRef<script> \| nil` |
| `s.visible` | boolean — read via field, toggle via `s:set_visible(b)` |
| `s.state` | `"loading"` / `"loaded"` / `"ready"` / `"partial"` / `"unloading"` — the layer's lifecycle state. Both `"ready"` and `"partial"` are terminal: the load has settled. |
| `s.ready`, `s.loaded` | booleans in lock-step with `state`. `ready` is true once the layer has settled, in either terminal state — it is the flag to wait on. |
| `s.ok` | boolean — whether everything the scene declared was produced and the layer has run clean since. False whenever `state` is `"partial"`, including when the tick starts raising long after the load settled. |
| `s.failures` | `{ SceneLoadFailure }` — every distinct failure the load and the layer's per-frame tick produced, each with the `count` of how often it repeated. |
| `s:unload()` | unload this scene; additives use `layers.unload(layerId)`, roots use `layers.active:reload()` |
| `s:reload()` | unload + reload same ref |
| `s:set_visible(b)` | show/hide without unloading |
| `s:load_additive(ref)` | load a sibling additive overlay; returns the new proxy |
## Example
```lua
-- Switch root scene
layers.load(asset.ref("@builtin::scenes.baseline_visual"))
-- Subscribe to all loads
local h = layers.onLoad(function(scene)
print("loaded:", scene.name)
end)
-- Layer an HUD overlay on top
layers.load(asset.ref("@builtin::scenes.hud"), { additive = true })
-- Iterate every loaded scene
for _, scn in ipairs(layers.list()) do
print(scn.name, scn.additive and "(additive)" or "(root)")
end
layers.unload() -- unload the active root
layers.offLoad(h)
```
## What a load did, and what a scene costs
Every load writes one record at its own completion point; the readings below
compose that record, so a caller that did not make the load can still ask what
it did. A load that produced failures settles into `"partial"` rather than
`"ready"`, and names the nearest cause from a closed set: `loaderRaised`,
`entrypointCompileFailed`, `entrypointBodyRaised`, `entrypointRaised`,
`buildRaised`, `entityFailed`, `parentMissing`, `parentRefused`,
`parentAbandoned`, `componentUnresolved`, `componentRefused`,
`subscriberRaised`, `updateRaised`.
```lua
layers.load(asset.ref("scenes.arena", "scene"))
-- Did it come up whole?
if not layers.active.ok then
local reason, detail = layers.whyPartial()
print(reason, detail)
for _, f in layers.problems() do
-- `count` rises when the same failure happens again, so a per-frame
-- tick that keeps raising stays one line.
print(f.phase, f.reason, f.entity, f.component, f.hook, f.count)
end
end
-- What did the swap do?
local r = layers.lastLoad()
print(r.name, r.outcome, r.durationMs)
print("replaced", r.replaced and r.replaced.name) -- the root that went
print("cascaded", #r.cascaded) -- the overlays with it
print(r.entities.added, "arrived,", r.entities.removed, "left")
print(r.phases.teardown, r.phases.instantiate, r.phases.settle)
-- Which loaded scene is expensive? `totalMs` is a SUM across the window, so
-- reset it first and read `avgMs` for the per-tick figure.
layers.resetCostWindow()
task.wait(1)
for _, c in layers.cost() do
print(c.name, c.avgMs, "ms/tick over", c.calls, "ticks")
end
-- What does each layer hold, and what does no layer claim?
for _, l in layers.inventory() do print(l.name, l.entities, l.ok) end
print(layers.observe().totals.unattributed)
```
The `scene` toolbox carries the same two readings as `scene.observe` and
`scene.whyPartial`.
## Authoring surface
`layers.*` is the sole public scene-management surface. It accepts
AssetRefs (not paths), drives a callback registry, and constructs
Scene proxies that carry players / camera / hooks. The previous
`scene.*` global has been removed — its low-level FFI bindings now
live behind `layers.*` and the Scene proxy methods.
# renderError
Shared "this render is broken" marker. `visibleError(entityId)` swaps an entity
to the builtin ERROR text model + error material, so a failed render reads as an
unmistakable 3D "ERROR" sign on screen instead of empty pixels — empty silently
reads as "fine" and a broken object gets mistaken for working.
Every render component (built-in `Model` / `SkinnedModel`, or a user-authored
one) calls this, so the error model is defined in one place: change it here and
every render component updates.
# preset
A preset holds typed values for one declarer's settings schema: an
assetType's, an importer's, a component's, or any asset that declares its
own settings. `preset.capture` saves the settings something has now,
`preset.apply` writes a preset's values into something its target governs,
`preset.values` reads them checked against the target's current schema, and
`preset.setValues` replaces them, checked first. Exposed as the `preset`
global via `--!global preset`.
A preset's `preset.yaml` names its target as a guid envelope,
`target: { __ref: <guid>, type: <type>, name: <name> }`, which the
preset's `.refs` pins by guid with the target's checksum. A target written
as an identity string is accepted on read, resolves by identity, reads as
unpinned, and is rewritten as the envelope on the preset's next local
edit.
## Exports
- `preset.values(source: AssetRef<preset>) -> ({ [string]: any }, Report)` — the values that pass the target's current schema, and a report naming every one that does not.
- `preset.apply(source: AssetRef<preset>, into: AssetRef | ComponentTarget) -> ApplyResult` — write the valid values into an asset the target governs, or into a component on an entity whose component is the target. Struct groups merge field by field. Raises when another declarer governs `into`.
- `preset.capture(from: AssetRef | ComponentTarget, name: string, opts?: CaptureOpts) -> AssetRef<preset>` — save an asset's settings, or a component's live values on an entity, as a new preset targeting the declarer that governs them. A bare name lands in `/source/presets/`; an absolute `.preset` path lands where it says.
- `preset.setValues(source: AssetRef<preset>, values: { [string]: any })` — replace the whole values table; any violation refuses the write, naming every one.
- `preset.defaults() -> { DefaultRow }` — the world's Preset Manager rows, in order, each resolved with its problems.
- `preset.setDefaults(rows: { DefaultRowInput })` — replace every row; every row is checked first and any problem refuses the write, naming every one.
- `preset.addDefault(source: AssetRef<preset>, opts?: { filter: string?, at: number? }) -> number` — add a row for the declarer the preset targets; returns its index.
- `preset.removeDefault(index: number)` — remove one row, keeping every other row as written.
- `preset.defaultFor(declarer: AssetRef | string, path: string) -> ({ [string]: any }?, Match?)` — the values a new thing at `path` would take from the rows, and the row that gives them.
Types:
- `ComponentTarget = { entity: string, component: string }` — a component on an entity.
- `Report = { format: string, target: string, targetGuid: string?, pinned: boolean, targetChanged: boolean, rejected: { Violation } }` — `target` is the target's identity; `pinned` is true when `.refs` pins the target's guid; `targetChanged` is true while the pinned checksum differs from the target's current one.
- `Violation = { path: string, message: string }`
- `ApplyResult = { applied: { string }, rejected: { Violation }, targetChanged: boolean }`
- `CaptureOpts = { overwrite: boolean? }` — `overwrite = true` re-authors an existing preset, keeping its guid.
- `DefaultRowInput = { preset: any, target: any?, filter: string? }` — a preset, its declarer (the preset's own target when omitted) and a filter (empty matches everything).
- `DefaultRow = { index, target, preset, filter, targetGuid, presetGuid, targetName, presetName, problems }`
- `Match = { index, target, preset, path, rejected, problem? }` — the row a creation took and the path it read.
## Usage
```luau
-- Capture a texture's settings, then apply them to another texture
local p = preset.capture("hero_sprite", "pixel_art")
preset.apply(p, "enemy_sprite")
-- Apply a component preset to a component on an entity
preset.apply("synty", { entity = id, component = "Locomotion" })
-- Capture a component's live values
local tint = preset.capture({ entity = id, component = "Model" }, "red_tint")
-- Read the values and what the target rejects
local values, report = preset.values("pixel_art")
for _, v in ipairs(report.rejected) do print(v.path, v.message) end
-- Replace the values
preset.setValues("pixel_art", { filter = "nearest", generateMipmaps = false })
```
## Notes
- Every read re-checks the values against the target's current schema, so a value a later schema drops or retypes is reported by path rather than applied.
- `asset.create("preset", name, { target = <identity or guid>, values = { ... } })` authors a preset directly, with the same checks as `preset.capture`.
- The Preset Manager rows are documented in `preset.defaults`; the filter grammar in `preset.glob`.
- Submodules: `preset.document` (the `preset.yaml` format), `preset.target` (target resolution and checking), `preset.heal` (rewriting an older preset document in the current form on a local edit).
# p5_menu
A UI template screen.
# outline
The public outline API — mark entities to draw with a coloured silhouette rim.
Its registry (entity id → `{ color, thickness }`) is read every frame by the
`@builtin::renderFeatures.outline` render feature, which draws a screen-space
jump-flood rim hugging each marked entity's silhouette (uniform width, no gaps on
hard-edge or multi-part meshes). The feature is ensured live on first use, so
`outline.set(entity)` is all a caller needs.
An entity's outline lasts as long as the entity: when it is despawned, by
`entity.despawn`, a parent's cascade, a scene rebuild or reload, its id leaves
the registry, so the feature's per-frame cost follows the outlined entities in
the world. A `Model` or `SkinnedModel` drawing an outline from its outline fields
clears it when the component is removed.
```luau
local outline = require("modules.outline")
outline.set(entity, { color = { 1, 0.5, 0, 1 }, thickness = 3 })
outline.clear(entity)
outline.clearAll()
```
·computeshader · born here
❒asset # contour_triangles
Each kept quad as two triangles: `pt` holds, per quad, the vertex numbers of
its corners (`a b c`, `a c d`), and `vstart` the position of each triangle's
first corner in `pt`. Slot `n` of the dispatch is the `n`-th quad `edges[n]`
names, so the quads are numbered in edge order.
Dispatch over the kept quads. Exact: each slot has one writer.
# editor_ops
Delete and duplicate the current editor selection, each as one undo step. Delete
despawns every selected entity and clears the selection; duplicate clones each
selected entity via `entity.duplicate` — a full component, attribute, visual,
and material clone that includes descendants — and selects the copies. Both wrap
the batch in a multiplayer undo operation so a single Ctrl+Z reverts the whole
action. The editor scene entrypoint binds these to Delete and Ctrl+D.
# lighting
The canonical scene-lighting toolbox — ambient light, directional sun, sky, clear color, and point / spot / area lights, each with modify-or-spawn semantics: every call leaves the scene in the requested lighting state, spawning the canonical `sun` / `ambient` entities (or switching the renderer-level sky) when the scene has none.
Set the whole environment in one call (sun, ambient, sky, and clear color, all optional), or set each independently: the directional sun, the ambient light, or the sky — swapped from a preset word, a project material matched by name, or a material ref. Add a point light as an entity at a position. Update any point, spot, or area light by name — its color, intensity, reach, shadows, and its shape controls (spot cone, area rect). Read the full lighting state back — every light with its values, clear color, authored sky, live sky config — in the same shape the whole-scene setup accepts.
These are the interactive authoring surface, for setting a scene's lighting from `execute()`. The behaviour lives in the runtime lighting capability (`modules.api.engine.lighting`); a component, entrypoint, or the scene saver that adjusts lighting at runtime uses that capability directly, not a tool.
# toolExec
Run a JavaScript script that drives the engine's toolbox catalogue.
This is the engine half of `tool_exec`. A harness hands over a script; this runs it on ZeroJS with exactly two globals — `zero.<toolbox>.<tool>(...)` to call a tool, and `tools.toolboxes()` / `list()` / `describe()` / `search()` to find one. No engine globals, no VM.
**It is not "execute, but JavaScript."** A raw runtime with the engine's globals is `execute` with different syntax. What a shell command and a tool-calling loop both lack is the ability to hold an intermediate result: you cannot list 200 entities, filter them in-process and act on the 6 that matched without the whole list passing through the model. That is the gap — composition, not capability. Every tool reachable here is reachable from `zero` in bash and `tools.use` in Luau, under the same names.
**Every binding is async, and that is not a style choice.** A tool may yield — `zeromind.search` makes an HTTP round trip — and a yield cannot cross the JS→Luau call boundary. The failure is silent: the call returns and its value is lost. So no tool is ever called from inside the interpreter. `__start` spawns a Luau task that may yield freely and hands back a ticket; `__poll` reads the settled result without yielding; the prelude turns the pair into a promise. The script says `await zero.scene.list()` and the yielding happens outside the VM, where it is allowed.
The caller's source is wrapped in an async function, so `await` works on the first line — ZeroJS has no top-level await, and an agent should not have to know that.
```js
const boxes = await tools.toolboxes();
const camTools = await tools.list("camera");
const spec = await tools.describe("camera.lookAt");
const hits = await tools.search("low poly tree");
const scenes = await zero.scene.list();
return scenes.filter(s => s.name.startsWith("level")).length;
```
# toml
Pure-Luau TOML parser and emitter. Use whenever a script needs to read
or write a TOML file — settings, importer rules, tool configs, or any
other authored config the user touches by hand. Auto-injected as the
global `toml` by the prelude — no `require` in user code.
`vfs.read` returns bytes. JSON has built-in parsing via `json`, but TOML
— the format used for `.world_settings`, `pyproject`-style configs, and
any human-friendly key=value file — needs a parser. `toml` provides one
with no native dependency, so it works the same on native and WASM.
## Exports
- `toml.parse(src: string) -> { [string]: any }` — TOML bytes to nested Luau table. Throws with the line number on syntax errors.
- `toml.encode(root: { [string]: any }) -> string` — Luau table to canonical TOML bytes (alphabetical section/key order; deterministic output).
- `toml.replaceArrayTables(src: string, name: string, rows: { { [string]: any } }) -> string` — replace every `[[name]]` block of a document with `rows` (appended at the end, one block each) and keep every other byte.
## Usage
```luau
-- Parse
local body = vfs.read("/zero/source/myconfig.toml")
local config = toml.parse(body)
print(config.render.culling_mode)
-- Mutate + write back
config.render.culling_mode = "cpu"
vfs.write("/zero/source/myconfig.toml", toml.encode(config))
```
## Supported TOML
- Sections, including dotted (`[a.b.c]`)
- Arrays of tables (`[[name]]`, `[[a.b]]`), with sub-tables of a row
(`[name.sub]` after `[[name]]`); read as a sequence of tables and
written back as one block per element
- Key/value pairs with dotted keys (`a.b.c = 1`)
- Strings: `"..."` (escaped) and `'...'` (literal); triple-quoted
variants for multi-line bodies
- Integers (with `_` digit separators) and floats (incl. `inf`,
`-inf`, `nan`, exponents)
- Booleans (`true` / `false`)
- Inline arrays (`[1, 2, 3]`)
- Inline tables (`{ a = 1, b = 2 }`)
- `#` comments
## Not implemented
These are rare in settings/config files; add when a real call site
needs them rather than carrying dead code.
- Hex / octal / binary integer literals (`0xff`, `0o77`, `0b1010`)
- Date / time literals
## Notes
- `--!global toml` directive promotes the module's typed functions
onto the runtime universe's globals bucket, so `toml.parse` /
`toml.encode` are available without any per-source `require`.
- The encoder is fully deterministic: sections and keys are sorted
alphabetically, integer-shaped numbers are emitted without a
decimal point (`2` not `2.0`), and nested tables become dotted
section headers (`[a.b]`).
- Parser errors carry the line number for fast diagnosis.
# camera
Author and manage the cameras in a scene: create one, list every camera, read its full parameters, change them, aim it, frame a subject, or make it the primary camera. A physical camera on the machine (a webcam, a phone camera) is `cameraInput`, covered by the `topics/camera-input` guide.
## Create & inspect
- **create** — spawn a camera entity at a position, with any starting parameters in one call: fov, near/far, priority, render layers, a follow target, a behavior, a `lookAt`, and `target` (render-to-texture — `true` or `{ width, height, name }` renders the camera offscreen instead of to the viewport). Returns the new camera's full parameter record.
- **list** — every camera in the scene, enumerated from the live ECS so a just-spawned, hidden, or runtime camera is never missed, sorted by descending priority, each row marking whether it is the on-screen (`active`) camera, whether it is the `player` camera (the one the PlayerPrototype uses), and its main/editor role.
- **get** — the full parameter record for one camera (position, rotation, fov, near, far, priority, render layers, debug channel, render target, behavior, follow). Defaults to the active/main camera when you name none.
## Modify
- **set** — change any of these camera parameters in one call: `fov`, `near`, `far`, `priority`, `debugPass` (the diagnostic view this camera renders, by name), `debugChannel` (the same as a raw numeric channel), `renderLayers` (the layer-name spec, e.g. `"all !ui"`), `behavior` (a `cameraBehavior`-tagged component), and `follow` (the entity the behavior tracks). Pass no camera to modify the active one. A camera's render target is set when it is created (`create` with `opts.target`).
- **lookAt** — aim a camera at a world position or at another entity.
- **frame** — position and aim a camera to frame a target entity from a distance and angle in one step.
- **bindToPlayer** — make the joining player use a camera in play by binding it to the scene's PlayerPrototype: it re-parents the camera into the prototype (so it is cloned with the player) and sets the prototype's `camera` role to it. Pair it with a static behavior (`set { behavior = "@builtin::controller.menu" }`) for a fixed PS1-style camera the player moves within, or a follow behavior (`orbital_follow`, `third_person_follow`, `first_person`, …) for a tracking one. A player-less scene has no prototype — there the rendering camera is simply the highest-priority one, ordered with `set { priority = N }`.
## Related surfaces
- Capturing an image from a specific camera is the `capture` toolbox (`capture` with a camera, or `oneshot`) — it renders any camera's authored pose offscreen without changing what is on screen.
- Putting geometry ON a render layer is the `renderLayer` toolbox (`renderLayer.set`); `renderLayer.camera` is the layer-system-side way to set a camera's include filter, the same field `camera.set { renderLayers = ... }` writes.
- The scene's primary camera is normally spawned for you from the scene's PlayerPrototype; `camera.create` adds additional cameras (a security view, a cutscene angle, a render-texture feed).
# kanban_planner
A UI template screen.
# Emissive White
A bright white light-emitting PBR surface built on the `pbr` shader with the `_EMISSION` keyword enabled. A white base colour (`1.0, 1.0, 1.0`) paired with a strong `emissive` (`20.0`) makes it glow and drive bloom — well suited to light fixtures, emitters, and glowing accents. Non-metallic (`metallic 0.0`) at mid roughness (`0.5`).
## Inputs
- `floats.emissive` (`20.0`) — high emission strength that produces the glow.
- `keywords: _EMISSION` — enables the emissive path in the PBR shader.
- `colors.base_color` — the white surface and emission colour.
- `floats.roughness` (`0.5`) — diffuse spread on the lit surface.
# json_utils
Safe JSON decoding helpers and a WASM-safe number formatter. Wraps
`Json.decode` with `pcall` so engine-provided JSON strings can't
propagate parse errors into call sites. Also handles the engine
convention of writing the literal string `"null"` when a JSON value is
absent.
## Exports
- `M.decodeOrNil(jsonStr: string?) -> any` — safe decode; returns `nil` on empty input, `"null"`, or parse failure.
- `M.decodeOrEmptyTable(jsonStr: string?) -> table` — safe decode; returns `{}` on any failure or non-table result.
- `M.safeDecode(jsonStr: string?) -> any` — historical alias for `decodeOrNil`.
- `M.formatNumber(n: number, decimals?: number) -> string` — truncate a number to N decimal places using only string ops. WASM-safe.
## Usage
```luau
local JsonUtils = require("@builtin::modules.json_utils")
local payload = JsonUtils.decodeOrNil(engineJsonString)
local table = JsonUtils.decodeOrEmptyTable(maybeMissing)
local label = JsonUtils.formatNumber(3.14159, 2) -- "3.14"
```
## Notes
- `decodeOrNil` short-circuits on `nil`, empty string, and the literal
string `"null"`. The Json.decode call itself is wrapped in `pcall`, so
malformed input is never observable to callers.
- `decodeOrEmptyTable` is intentionally strict: non-table decode results
(numbers, strings, booleans, nil) collapse to `{}`. Use `decodeOrNil`
if you need to distinguish those.
- `formatNumber` uses `tostring` + `string.sub` so it avoids the
`string.format` numeric specifiers that don't work reliably under
WASM in Luau. Pass `decimals = 0` to drop the fractional part
entirely; a count below `0` reads the same as `0`, and a fractional
count counts the whole places in it.
- `formatNumber` truncates the mantissa of a value `tostring` renders in
exponent form (`1.5e-07`) and keeps the exponent, so the result still
reads back through `tonumber`.
# gpuSort
A stable least-significant-digit radix sort, run entirely on the GPU, over a
caller-owned array of u32 keys.
The three passes here are the body of one radix digit. They read a key array and
a permutation and write a new permutation; they never touch whatever the keys
describe, so any subsystem with a key per item can drive them. The caller
supplies the keys, the identity seed, the per-pass constants and the dispatch
sequence.
## One pass
| Order | Shader | Dispatch |
|-------|--------|----------|
| 1 | `radix_hist` | one workgroup of 256 per 256 items |
| 2 | `radix_scan` | `{ 1, 1, 1 }` |
| 3 | `radix_scatter` | one workgroup of 256 per 256 items |
Each pass sorts on one 8-bit digit and keeps the relative order of items sharing
that digit, so `ceil(bits / 8)` passes over the low digits upward compose into a
total order on `(key, item index)`.
## Driving it
The caller owns six buffers: the keys, two permutation buffers the passes
ping-pong between, the digit histogram (`ceil(count / 256) * 256` entries) and
one constants buffer per pass. A pass reads its constants as
| Word | Meaning |
|------|---------|
| `[0]` | item count |
| `[1]` | block count, `ceil(count / 256)` |
| `[2]` | this pass's digit shift — `0`, `8`, `16`, `24` |
| `[3]` | `1` on the last pass, `0` otherwise |
| `[4]` | the key a dropped item carries |
Every value is fixed for the life of the sorted array, so the constants are
written once rather than re-uploaded per frame.
Each pass flips which buffer holds the permutation, so the parity of the pass
count decides where the caller seeds the identity for the last scatter to land
in the buffer the consumer reads.
An item the caller wants dropped takes a key at or past `[4]` — one past the
last real key — which parks it in the tail of the permutation; the last pass
resolves it to `0xffffffff`, and the consumer reads that as "this slot draws
nothing". A fixed dispatch size with a degenerate tail is what lets a consumer
issue a constant draw with no read-back and no indirect arguments.
# modules.api.engine.player_lifecycle
Wires the UserIdentity component's avatar-bind events into the
`localPlayerReady` lifecycle hook. Owns the local-avatar-bound latch
(fires once after the avatar entity is bound + has settled past the
bundle.instantiate deferred-mutation pipeline) and the opt-out path for
legacy v6 scenes that declare the avatar slot as `""`.
Exposes the `__layers_local_avatar_bound` + `__layers_local_avatar_opt_out`
dispatch channels wired in `install()`; the UserIdentity component's
lifecycle and the legacy v6 player_spawner route through them.
# asset_residency
Compute backends for the **lazy** residency properties on every `AssetRef`
(`has_backing_asset`, `has_runtime_changes`, `cpu_resident`, `gpu_resident`).
The AssetRef
metatable's `__index` dispatcher (`assetType.shared.ref`) calls these only when a
caller actually reads a flag — nothing is computed at resolve time, so an asset
costs nothing for residency until it is observed.
The three backing/runtime states:
| `has_backing_asset` | `has_runtime_changes` | Meaning |
|---|---|---|
| true | false | USED, UNMODIFIED |
| true | true | USED, MODIFIED (copy-on-write fork) |
| false | true | RUNTIME-GENERATED |
`cpu_resident` and `gpu_resident` are orthogonal to those three, and to each
other. `cpu_resident` asks *"is this asset referenced from a live
script-component context, so its bytes are warmed in memory?"* (the
`/runtime/assets/` CPU layer); `gpu_resident` asks whether the device holds a
texture or mesh under the asset's guid. They are separate pools and an asset
can be in one and not the other — `asset.observe()` lists both. See
`docs/specs/runtime-asset-copying.md` — "CPU residency vs GPU residency".
The backing/runtime flags are derived from where the asset lives — source under
`/zero/source/…` (backed), a copy-on-write fork / runtime-generated asset under
`/zero/runtime/assets/<identity>.<category>/` (runtime change) — using the
canonical `<identity>.<category>/` suffix shape, generic over any category.
`cpu_resident` and `gpu_resident` are live queries through `asset.cpuResident`
and `asset.gpuResident`.
# services
Generate real content — 3D meshes, textures, audio — from a prompt, by spending credits. This is the whole surface for it: discover what you can make, start a generation, track it, and find what you've made. You never touch the underlying asset type or task plumbing.
When a build needs a recognizable thing — a treasure chest, a snowboard, a character, a sound — you **generate** it. You don't assemble it from boxes and spheres.
## The loop
Discover what you can make: each service lists its operations, and each operation is self-describing: its inputs, the kind of asset it produces, and its credit cost. Check your credit balance if you want. Start a generation with a service and a prompt; this returns immediately with a handle and runs in the background, because a generation takes minutes and blocking would trip the execute time limit. When the run ends it posts a notice, `generation finished` naming its id and the job's `asset` (the spawnable asset path, which you can drop straight into a blocked-out slot) or `generation failed` naming its id and the error, and the notice reaches you on your next tool call; `services.status` reads a run's progress by its id meanwhile. Alongside the live per-session job view, a durable outputs record survives restarts by reading the provenance stamped on each generated asset.
## Describing your assets
`describe` is the one tool here that makes text, not content. It finds the assets in your world that lack the description their type asks for (for the built-in types that ask, a README, or the `about.yaml` texts of a type) and fills them in: `{ mode = "find" }` lists what is missing and what filling it costs, `{ mode = "all" }` fills it, and `{ assets = { ... } }` fills the assets you name. A call costs 1 credit and covers many assets, and `find` reports the number of calls before anything is spent. What it writes is an ordinary file in the asset, there for you to edit.
·computeshader · born here
❒asset # rt_ao_apply
Multiplies the denoised occlusion into the scene colour.
## Bindings
| name | kind |
|------|------|
| `scene_color` | `texture2d` |
| `ao_clean` | `texture2d` |
| `applied` | `storage2d (rgba16f)` |
·computeshader · born here
❒asset # sort
A stable radix sort of a column's keys into a permutation: place `s` of `perm`
holds the index of the element that sorts `s`-th, so a gather by `perm` puts
any column in key order.
| Specialize | Options | Meaning |
|------------|---------|---------|
| `KEY` | `f32`, `i32` | the scalar a key holds |
Elements with equal keys keep their run order, in both directions
(`params.descending` 1 sorts from the greatest key down), and `-0` and `+0` are
one key. The sort works 4 bits of the key at a time from the least significant
up, and a pass whose digit is the same in every key is skipped, so a run of keys
inside a narrow range takes fewer passes. `work` is the sort's second
permutation; its contents after a dispatch are not an answer.
The dispatch size `n` is the number of keys. One workgroup of 128 works the whole
run; the other workgroups of a larger dispatch return.
Exact: the sort is a function of the keys and their order, so the same keys give
the same permutation on every dispatch.
Over a live count (`COUNT=device`, see the abi README) `n` is the capacity and the
first `domain_count` keys are live: they are sorted, and a slot at or past the live
count holds its own index in `perm`. A live count of 0 sorts nothing and `perm` is
the identity. It binds 4 storage buffers (3 in the plain variant).
·computeshader · born here
❒asset # compute_ocean
Ocean wave simulation. Displaces a mesh's vertex buffer in place by a sum of
Gerstner waves, recomputing each normal analytically from the wave derivatives.
`steepness` sets how far the surface travels along each wave's direction as well
as up. At `0` every vertex stays on its rest column and the result is a plain
sine height field; raising it sharpens crests and broadens troughs, which is the
shape that reads as a wave rather than a ripple. Values above roughly `1 /
(frequency * amplitude * wave_count)` make the surface self-intersect.
Bindings (see bindings.yaml): `vertices` (read_write `array<u32>` — the mesh
vertex buffer, 76 bytes/vertex, bitcast to f32), `params` (read `array<f32>` —
`[time, grid_size, amplitude, frequency, wind_x, wind_z, steepness, pad]`).
Usage: dispatch by identity with `compute.dispatch("@builtin::shaders.compute_ocean", ...)` each frame —
it resolves to the shader's stable guid and auto-compiles on first use (no setup).
# water_surfaces
The water surfaces a scene holds, gathered from the systems that make them.
A system with water — a terrain's lakes, rivers and seas, an ocean — registers
a provider here, and whatever stands things on water asks every provider at
once. A procedural graph reads them all through `scene.water`.
A provider is a table of two functions:
- `version()` names what its water is now. A surface that moves every frame
answers differently every frame, which is what makes a reader stand its
points again.
- `stand(request)` puts points on its water on the device. `request` holds:
- `run`: the points' GPU positions, in the graph's space;
- `count`: how many points there are;
- `out`: the `P`, `normal`, `slope` and `wet` GPU columns to write;
- `place` / `unplace`: graph to world and back, 16 numbers each;
- `offset`: metres above the surface;
- `keepY`: whether a point's own height rides on the surface's.
It writes the points over its water, leaves the rest as they are, and
returns the names of the kernels it dispatched.
```luau
local water = require("@builtin::modules.water_surfaces")
water.register("myWater", { version = versionNow, stand = standOnMyWater })
for _, p in water.list() do
print(p.name, p.provider.version())
end
```
·computeshader · born here
❒asset # compute_fft_copy
Copies a `vec2<f32>` (re, im) buffer element-wise. Used by `nx.gpu.fft*` to seed
the ping-pong scratch buffer from the caller's input on stage 0 without an extra
ping-pong dispatch.
Bindings (see bindings.yaml): `src` (read), `dst` (read_write) — both
`array<vec2<f32>>`.
Usage: dispatch by identity with `compute.dispatch("@builtin::shaders.compute_fft_copy", ...)` —
it resolves to the shader's stable guid and auto-compiles on first use (no setup).
# Water
An animated water surface with a deep blue base colour (`0.1, 0.3, 0.8`) and low roughness (`0.2`) for glossy, reflective highlights, driven by a scrolling normal map for wave detail. Opaque, back-face culled, instanced, in the opaque queue (`2000`). Built on `@builtin::shaders.water_material`, sampling `@builtin/textures/water_normals`.
## Inputs
- `colors.base_color` (`0.1, 0.3, 0.8`) — the water body colour.
- `floats.roughness` (`0.2`) — low roughness for sharp specular reflections.
- `floats.metallic` (`0.0`) — dielectric surface.
- `textures.normal_texture` — the wave normal map (`@builtin/textures/water_normals`).
# Importer (asset type)
An importer is a Luau function that converts a source file (GLB,
PNG, CSV, custom binary) into one or more derived assets registered
in the engine. Importers extend the engine's file-format knowledge —
adding a new importer means "the engine now understands `.foo`
files". Importers run automatically on VFS writes that match their
claimed extensions.
## When to use one
- You're bringing a new file format into the engine and want to
derive engine-native assets from it (meshes, textures, animations,
data tables).
- You want one VFS write to fan out into multiple derived assets
(e.g. a GLB write derives mesh, texture, and animation assets).
- You want format ingestion to be idempotent + hot-reloadable —
importers re-run on source-file changes.
If you only need to consume an existing engine asset type, you don't
need an importer; just resolve it via `asset.resolve`. If you're
building an authoring tool that emits assets, that's a `.tool`, not
an importer.
## Where it lives
- Source: `/zero/source/.../<name>.importer/`
- Identity: `<name>` (the `.importer` suffix strips).
- Folder shape:
- `init.luau` (or `init.lua`) — exports `canImport` and `import`, and
`extensions`: the file extensions it claims, lower-case without the dot.
An importer that makes one asset beside its source also exports
`produces`, the type of that asset (`"texture"` makes `<stem>.texture`).
**Required.**
- `README.md` — instance documentation. **Required.**
- `.metadata` — agent-editable tags + free-form fields. **Required.**
- `settings.luau` — the importer's own settings schema, declared with
`Field` descriptors. Optional; an importer without one takes no settings.
- `settings.yaml` — the importer's settings values. Optional.
## Settings
An importer that takes settings declares them in its own `settings.luau` and
receives the resolved values as `ctx.settings` in `import(ctx)`.
`asset.schema(importerRef)` reports the schema, `asset.settings(importerRef)`
the values, and `asset.setSettings(importerRef, patch)` changes them. What an
importer stamps onto the assets it creates is typically one of these: the
texture, SVG and model importers each declare `textureSettings =
Field.settingsOf("@builtin::assetTypes.texture", NoSync)` — the texture type's
own fields — and pass it to `asset.create` as `importerSettings`, the importer
layer of each texture's settings. A change to an importer's settings counts as
a change to what an import is made from, so an unchanged source is imported
again after one.
## How to create one
```luau
asset.create("importer", "<name>")
-- Creates: /zero/source/<name>.importer/
-- init.luau (canImport / import stubs)
-- README.md (instance README template)
-- `folder` places it in a subfolder of /source instead of the root:
asset.create("importer", "<name>", { folder = "importers" })
```
## How it operates
1. **Registration.** Writing `init.luau` into a `.importer/` folder
indexes the importer with the importer registry.
2. **Dispatch.** When a file is written to VFS, the engine asks every
registered importer `canImport(path, bytes)` (cheap predicate).
The first importer that accepts dispatches to its `import`
function.
3. **Derivation.** `import(path, bytes)` returns a list of
`{ path, kind }` pairs naming the assets to derive. The engine
instantiates each derived asset against its registered category.
4. **Idempotency.** `import` MUST be deterministic — importing the
same source bytes twice produces identical derived assets. The
importer pipeline uses content-hashing to short-circuit re-imports
when bytes haven't changed.
5. **Hot reload.** Editing the importer reloads it; subsequent writes
use the new logic. Already-imported assets stay imported with the
old derivation until their source is imported again — writing the
source path re-dispatches the importers, and
`tools.use("importers", "run", <path>)` re-imports a target, many
targets, or a whole folder on demand.
6. **Play.** A source written while play runs lands on the play shadow
and imports there, so the source and its asset are kept or discarded
together at play-exit. A source play holds where it stands (written
before play, or by a durable write during it; `vfs.playHoldsInPlace`)
cannot be moved into a new asset while play runs, so its import is
owed: the job reads `owed` (reason `play-lock`), a notice names it,
and it imports when play ends or pauses. `tools.use("importers",
"queue")` lists owed imports and `importers.cancel` drops one.
## Re-importing over an asset an import made
An import keeps its source inside the asset it makes: `art/plate.svg` becomes
`art/plate.texture/` holding `plate.svg`. `import(ctx)` reads where to write
from `ctx.output` (`<dir>/<stem>.<produces>` on a first import) and keeps the
source with `ctx.retainSource(dest, opts?)`, which moves it to `dest` and
returns where it is now. `import` returns `{ output, source }`, `source` being
the file the asset keeps, which the asset's `import` provenance records as its
source.
- **The source written again beside the asset.** Writing `art/plate.svg` once
more re-imports it into the same `plate.texture`: the new bytes replace the
svg the texture retains (which keeps its guid), the written file leaves, and
the asset is made again from the new bytes. Its `import` provenance counts
the iteration on.
- **The same bytes written again.** When the source's checksum and the
importer's settings match the import that made the asset, no import runs:
the asset already holds those bytes, so the written copy is folded into it,
and `writeOutcome` answers `skipped` with reason `unchanged-content`, naming
the asset and the file that holds the bytes.
- **The asset itself.** `tools.use("importers", "run", <asset>)` re-imports a
produced asset in place from the source it retains (`ctx.container` names
it), so rewriting `plate.texture/plate.svg` and running it re-bakes the
texture. A run that makes nothing says why: each skipped result carries a
`reason` (`missing`, `no-source`, `no-claimant`, `unclaimed`).
## Discovery
- `asset.list("importer")` — every registered importer.
- `asset.inspect("<name>")` — claimed extensions, derivation
conventions, source, this type README.
- `ls /zero/source/<name>.importer` lists the files the folder holds.
- `tools.use("importers", "list")` — every registered importer, with
its name and identity.
- `tools.use("importers", "explain", <path>)` — which importers claim
a given source and which gate would block the import.
## Where a write's bytes end up
An importer that claims a written source may move it into the asset it
makes (the texture importer turns `shots/a.png` into `shots/a.texture/`
holding `a.png`), so the path the write named stops holding the bytes.
`require("@builtin::assetTypes.importer.shared").writeOutcome(path,
sinceFrame, timeoutSecs)` answers where they went, for a write made on
engine frame `sinceFrame` (`time.frameCount()` read before it): nil when
no importer claims them, else the import's `state`, the `importer`, the
asset made (`output`), the file the bytes are in now (`source`) and a
`note` saying all of it. Every write that fires its side effects is
offered to the importers by the same rule, whichever route made it, and
the MCP `write_file` and `upload_file` answers carry this outcome as
`import`. A quiet write (`{ quiet = true }`, and every `capture` save)
is offered to no importer, and its bytes stay where they were written;
`writeOutcome` answers `not-started` for one an importer would claim.
A write whose import the running play session holds back (a fresh
import moves its source into the asset it makes, and play holds a source
that predates it, or that a durable write made canonical, where it
stands) answers `owed` at once, with `reason` `play-lock`: the bytes stay
at the written path, and the import runs when play ends or pauses.
`writeOutcomes(paths, sinceFrame, timeoutSecs)` answers a run of writes
made together (a model, then its buffer and textures) once all of them
have landed, within one wait: a folder `write_file` or `upload_file`
lands every file without waiting on any import, then answers each this
way. `write_file { awaitImport = false }` lands one file of such a run
without waiting on its import.
## Authoring conventions
- Keep `canImport` cheap — it's called for every VFS write. Match on
extension or a quick header probe; defer real parsing to
`import`.
- Declare the extensions the importer claims as `M.extensions` and have
`canImport` claim by that list. The Inspector lists them, and
`require("@builtin::assetTypes.importer.shared").extensionsOf(importerRef)`
answers them (lower-case, without the dot; nil for an importer that
declares none).
- Name derived assets predictably (e.g.
`imported/<source-stem>/<asset-name>`). Predictable paths let
callers reference derived assets directly.
- Make `import` pure relative to its inputs. Don't read mutable
globals or side-channel state.
- Log derivations via `print` / the script log so authors can see
what got produced from each source.
## Common pitfalls
- **Expensive `canImport`.** It runs on every VFS write; an
expensive predicate slows down the entire authoring loop.
- **Non-idempotent `import`.** Re-importing produces different
output → content hashing falsely caches stale results. Use only
deterministic transforms.
- **Conflicting importers.** Two importers claiming the same
extension causes ambiguous dispatch. First-registered wins, but
this is fragile — coordinate ownership of file extensions.
- **Derived-asset suffixes.** Make sure derived paths end in the
right `.<category>` suffix; otherwise they won't register as
recognized assets.
## Related types
- `.module` — for plain ingestion helpers that don't need the
importer dispatch path.
- `.tool` — for one-off conversions an agent invokes manually.
- `.service` — if your importer needs persistent state (e.g. a
cache); the service owns it, the importer reads from it.
## In the Inspector
Imports: the extensions the importer claims (`M.extensions`), each file it imported in this session with the state its job reached, and **Find assets it produced**, which lists the assets in the world whose import provenance names this importer.
# postReference_chrome
A polished metal: its highlight of a bright light is a sub-pixel specular, the firefly a bloom and a temporal resolve are read on in the post-verification reference scenes.