❒
bundle · drop-in viewer
What it does
No description provided yet.
Interface
What this asset declares: the schema it conforms to, what it exposes, and the rendered structured payload.
This asset doesn't declare a schema or structured payload.
⌬ Metadata
components
["Model"]humanoidfalseimport{"at":970.6484117,"importer":"5210d113-558a-4637-ac09-94e9c57b41cd","iteration":1,"source":{"guid":"92db0dce-f797-4666-b7f2-e222de609e88","path":"gltf_primitives.glb"}}Sub-parts
Everything contained inside this part. Assets are composite children (clickable cards). Files are leaf payloads. Expand any row to view its source.
Showing up to 3 levels deep. Open a part to see what it holds further down.
90+items
▲ 0↑ born
▲ 0↑ born
▲ 0↑ born
▲ 0↑ born
▲ 0↑ born
# Mesh
Mesh asset. `data.zmsh` is the engine-native geometry payload (`renderer.mesh.encode`); consumers reference the asset's guid.
▲ 0↑ born
# Mesh
Mesh asset. `data.zmsh` is the engine-native geometry payload (`renderer.mesh.encode`); consumers reference the asset's guid.
▲ 0↑ born
# Mesh
Mesh asset. `data.zmsh` is the engine-native geometry payload (`renderer.mesh.encode`); consumers reference the asset's guid.
▲ 0↑ born
# Mesh
Mesh asset. `data.zmsh` is the engine-native geometry payload (`renderer.mesh.encode`); consumers reference the asset's guid.
▲ 0↑ born
# Bundle (asset type)
A bundle is a composable, spawnable entity assembly— a saved entity
hierarchy plus its components, sub-assets (meshes, textures,
animations, materials, sub-bundles), and any nested data, packaged
into a single addressable identity. Bundles are the engine's "prefab"
primitive.
There are **two ways** to bring a bundle into a scene — and the
difference matters enormously for scene saving and live editing. See
"How to spawn" below before you write anything.
If you want one snapshot of a whole world / level, use a `.scene`.
## Where it lives
- Source: `/zero/source/.../<Name>.bundle/`
- Identity: `<Name>` (the `.bundle` suffix strips for use in
identities, but the on-disk folder keeps the suffix).
- Folder shape (container type — open child set):
- `entity_template` — JSON of the entity hierarchy + components.
**Required.**
- `preview.png` — a rendered still of the assembled hierarchy, the
bundle's persisted visual description. **Required.** Rendered when
the bundle is staged (`zm add`) with its preview missing or stale,
and by `generatePreviews`; it syncs, publishes, feeds image-based
search, and gives browsers a thumbnail.
- Anything else: meshes / textures / materials / animations /
sub-bundles. Validator allows arbitrary additional files
(`allow_unlisted: true`).
## How to create one
Two paths, depending on whether you have an in-scene hierarchy or are
authoring from scratch:
```luau
-- From an existing in-scene entity: composes the given entity tree into a bundle.
local bundle = asset.create("bundle", "<Name>",<entityProxy/id>)
-- Or scaffold an empty bundle, then update it from an entity:
local bundleRef = asset.create("bundle", "<Name>")
bundleRef:update(entityId)
```
## How to spawn
### 1. `Asset` component — live-linked (the canonical way)
**This is the primary way to put a bundle into a scene that survives
saving as an asset reference.** Spawn an empty entity, attach the
`Asset` component, point it at the bundle:
```luau
local car = entity.spawn("car_1")
car.component.add("Asset", { source = "<bundleIdentity>" })
```
What you get:
- The bundle's hierarchy spawns as **temporary** children under the
owning entity. They're real, addressable runtime entities, but
they are NOT serialized into `scene.json` on save, only the Asset component and its entity is saved.
- **Live editing of the bundle is supported.** When you edit
anything inside the `.bundle/` (entity_template, meshes,
materials, sub-bundles, components), every entity in the world
carrying that `Asset` component automatically tears down its
spawned children and rebuilds them from the new template — same
frame, no restart, no manual reload. The `onAssetReload` hook in
`Asset.component/init.luau` drives this.
- **Per-instance overrides survive across reloads.** Move a spawned
child, rename it, add/remove components on it, edit a component's
public state — the changes land in the Asset's `public.diff` (a
sparse delta keyed by the child's `original_id`) at save time and
are replayed by `bundleRef:instantiate()` on next load. The bundle stays
a single source of truth; per-spot tweaks ride on top.
- **Stable child ids across reloads.** `public.idMap` records the
`original_id → runtimeId` assignment on first spawn; on every
subsequent spawn the same mapping is reused so cross-entity
references (e.g. `SkinnedModel.skeletonRoot = "ent_xxx"`) stay
valid.
### 2. `bundleRef:instantiate` — baked, no live link
Resolve the bundle and instantiate it onto an entity — this spawns the
children **without** the live-link plumbing the `Asset` component
provides:
```luau
local car = entity.spawn("car_1")
asset.resolve("<Name>", "bundle"):instantiate(car)
```
What you get — and the trade-offs:
- The children spawn as **regular** (non-temporary) entities. On the
next `layers.active:save()`, the entire spawned hierarchy is serialized into
`scene.json` verbatim.
- **No live link to the bundle.** Editing the `.bundle/` after the
fact has no effect on already-spawned hierarchies. The bundle was
effectively consumed at spawn time.
- Scene-save / restore round-trip works the same as for any other
hand-authored entity tree.
Use this path when you want a one-shot expansion of the bundle into
permanent scene state (e.g. you're populating a scene's static
layout from a parametric bundle and don't want a live link).
You can convert a live-linked instance into a baked one explicitly
via `Asset:bakeIntoScene()` — this drops the `Asset` component and
promotes the temporary children to permanent scene entities.
## How it operates
1. **Registration.** Writing into a `.bundle/` folder indexes the
asset with the container-category restore plumbing.
2. **Inner refs.** Component fields that reference assets inside the
bundle use the `__ref` shape (`{ "__ref": "<guid>", "name": "...",
"type": "..." }`). The asset registry resolves them at spawn
time.
3. **Identity preservation.** The `.bundle` suffix stays in the
identity for container types — `<Name>.bundle` resolves under
that exact string, distinguishing it from any `<Name>.scene` at
the same stem.
4. **Hot reload.** Saving `entity_template` (or any file in the
bundle directory) fires the engine's per-asset content-changed
hook, which calls `onAssetReload("source")` on every entity
whose `Asset` component points at the bundle. Each carrier
despawns its children and re-instantiates against the new
template — typically next frame.
## Discovery
- `asset.list("bundle")` — every registered bundle.
- `asset.inspect("<name>")` — sub-asset catalog, source, this type
README.
- `ls /zero/source/<Name>.bundle` lists the files the folder holds.
## Authoring conventions
- Prefer `bundle:update()` or `asset.create("bundle","<name>",entity)` over hand-authoring `entity_template`. The
in-scene composer wires `__ref` shapes correctly and pulls in every
transitively-needed asset.
- Keep bundles focused — one prefab per bundle. If you have a "vehicle
+ driver + sound" assembly, split into three bundles and reference
them from a scene rather than packing all three into one bundle.
- Name child entities meaningfully — bundle interiors are
inspectable, and stable IDs let other tooling (`entity_template`
edits, animation hooks) find them.
- Sub-bundles compose: a bundle's `entity_template` can `__ref` a
child `.bundle/`. The asset registry resolves them transitively.
## Common pitfalls
- **Confusing the two spawn paths.** `Asset` component = live link,
reference-only scene save. `bundleRef:instantiate()` directly = baked,
full hierarchy in `scene.json`.
- **Inner-ref guids drift.** If you copy an `entity_template` from
one bundle into another, the `__ref` guids may not resolve —
re-compose from a live scene rather than copy-pasting JSON.
- **The `.bundle` suffix is part of the identity.** This is the
container-category convention. Don't strip it in reference strings.
- **Live editing only fires for live-linked carriers.** Already-baked
entities don't update when you edit the source bundle.
## Related types
- `Asset.component` — the canonical mount point for a live-linked
bundle. See its README for the `idMap` / `diff` machinery in
detail.
- `.scene` — owns a whole world's worth of entities; spawns bundles
into itself via `Asset` components on per-spot entities.
## In the Inspector
Selecting a bundle opens its **Contents** section, from the bundle's inspect detail: the template's node count, its root, the component types attached across it, a table of each renderable node's mesh and material, and a link to each of those meshes and materials.
▲ 0↑ born
# Model
Loads and displays a 3D mesh on an entity. Disabling the component hides the mesh. Supports procedural meshes (`cube`, `sphere`, ...), model files, and remote URLs. Tint and outline are shader effects applied to the mesh.
**Material lives on this component.** There is no standalone `Material` component — a material only renders where there is a mesh to render it on, so the `material` field is part of Model (and SkinnedModel). Set it at add time or assign the field later; properties are registry-wide.
Public fields: `model`, `material` (`AssetRef<material>`), `tintR/G/B`, `tintBlend`, `outlineR/G/B`, `outlineIntensity`.
Methods: `:setTint(color, blend?)` (color: `{r, g, b}` array or `{r=, g=, b=}` map), `:clearTint()`, `:setOutline(color, intensity?)`, `:clearOutline()`, `:setMaterialProperty(prop, value)`, `:getMaterialProperty(prop)`, `:getMaterialPropertyNames()`.
```luau
entity(id).component.add("Model", { model = "cube", material = "gold" })
entity(id).component.get("Model").material = "checkerboard" -- swap material
entity(id).component.get("Model"):setTint({1, 0, 0}, 0.5)
entity(id).component.get("Model"):setMaterialProperty("roughness", 0.2)
```
▲ 0↑ born
# Material (asset type)
A material is a named, configured instance of a `.shader`. The shader
defines the surface look (lighting model, available property fields,
texture slots); the material picks values for those fields and a
shader to bind to. Renderable entities reference materials, not
shaders, so the same geometry can wear different materials and the
same material can be reused across many entities.
Entities sharing one material can still differ in a value: every drawn
object carries four `vec4` lanes of its own that a surface shader reads
as `input.shader_data[lane]` and `renderer.instanceData.set` writes. Use
that for a dissolve at its own progress per subject, a hit flash at its
own age, a fill level per instance — a material per entity is for when
the LOOK differs, not when a number does.
> New to how a material relates to its shader, its textures, and the live GPU
> material behind it? Read `core/resource-model` first. It also covers the
> difference between an authored `.material` asset (this type) and a runtime GPU
> material made with `renderer.material.create`.
## When to use one
- You want a named surface configuration an entity wears by referencing it
from its `Model` component (`material = "@.../<Name>"`).
- You want runtime property tweaks (`asset.resolve(ref):setProperty(...)`) to
affect every entity using the material at once.
- You want to publish a curated look (`Gold`, `RedGlow`,
`BrickWall`) other authors can reuse without re-typing shader
bindings.
If you need a brand-new lighting model or a custom visual effect, you
need a `.shader` first; the material wraps it.
## Where it lives
- Source: `/zero/source/.../<Name>.material/`
- Identity: `<Name>` (the `.material` suffix strips).
- Folder shape:
- `mat.yaml` — canonical material spec (shader + property values).
**Required.**
- `preview.png` — the material's rendered shader-ball still, its
persisted visual description. **Required.** Rendered when the
material is staged (`zm add`) with its preview missing or stale, and
by `generatePreviews`; it syncs, publishes, feeds image-based search,
and gives browsers a thumbnail.
- `README.md` / `.metadata` — optional prose + tags, indexed when an
author writes real ones.
- `textures/` — optional folder for embedded textures. Validator
allows the folder; its interior is unrestricted.
- `shaders/` — optional folder for a co-located custom shader.
Same rule.
## How to create one
```luau
asset.create("material", "<Name>")
-- Creates: /zero/source/<Name>.material/
-- mat.yaml (shader binding + property scaffold)
-- preview.png (rendered when the material is staged with `zm add`; the
-- picture lands as a change the next `zm add` stages)
-- `folder` places it in a subfolder of /source instead of the root:
asset.create("material", "<Name>", { folder = "materials" })
```
Pass the shader and initial values through `asset.create`, then tune with
`setProperty` and keep what you tuned with `saveDefinition`:
```luau
local mat = asset.create("material", "<Name>", { shader = "@builtin::shaders.pbr" })
mat:setProperty("base_color", { 0.8, 0.6, 0.1, 1 })
mat:setProperty("roughness", 0.3)
mat:saveDefinition() -- writes the tuned values into mat.yaml
```
This mints the `.material` asset and returns a handle you tune with
`setProperty`. A set is a runtime change: it reaches the GPU material and every
entity wearing it, and `saveDefinition` is the call that writes it into
`mat.yaml`, so a property driven every frame costs no file write and leaves the
authored file as its author wrote it. A purely runtime `asset.create` lives only
for the session.
For an ephemeral, runtime-only GPU material that is not an asset at all, use
`renderer.material.create` instead (see `core/resource-model`).
## How it operates
1. **Property reflection.** The bound shader's uniform struct fields
become the material's configurable properties — no manual property
declarations needed. `naga` introspects the shader at load.
2. **Property values.** `mat.yaml` groups values by type, each block
keyed by the shader's field names: `properties:` holds scalars and
colors/vectors, `ints:` holds integer-typed fields, `bools:` holds
boolean-typed fields, and `textures:` holds texture slots. Omitted
fields take the shader's default. A scalar is a bare number
(`roughness: 0.5`); a color or vector is an array
(`base_color: [1, 1, 1, 1]`). `shader:` is the shader's identity
string. This is the shape every material the engine writes uses. A
`saveDefinition` writes the changed values into the file where they
already stand and leaves the rest of it — its comments, the order of
its keys and blocks, the spelling of its literals — untouched. The older
`floats:` / `colors:` blocks (with `{ r, g, b, a }` map colors) still
load for materials hand-authored that way.
3. **Render state.** `render:` holds how the mesh is drawn rather than
what the shader reads: `blend` (the equation the fragment composites
with, also spelled `type`), `cull`, `depth_write`, `depth_compare`
and `topology`. The `.shader` type README's "The render block"
section lists every value each key takes, and `asset.create`'s
`render` argument takes the same keys and values.
4. **Application.** An entity wears a material by referencing it from its
`Model` component (`material = "@.../<Name>"`). Materials affect every entity
that references them; runtime `asset.resolve(ref):setProperty(...)` changes
propagate to all of them immediately, and last until the next mode change
unless `saveDefinition` writes them into `mat.yaml`.
5. **Texture binding.** Texture slots accept `color:r,g,b,a` for
solid colors, `default:white` / `default:black` / `default:normal` for the
engine's own fallback texels, `@builtin::textures.<name>` for builtins,
`path/to/image.png` for file textures, or a render-target handle
for camera renders. The GPU texture cache reads the `color:` and
`default:` forms from the string itself, so a slot written in one names no
asset and carries nothing with the material when it travels; every other
form names a texture asset the slot depends on.
A texture in `base_color_texture` is multiplied by `base_color`: every
built-in surface shader declaring both draws the texture's colour times
`base_color`, channel by channel. Under the default white `base_color` the
texture shows as itself, and a coloured `base_color` tints it. The two
read a colour differently: `base_color` is linear, and the slot reads its
texels as sRGB-encoded, a `color:` value's included, the way it reads an
image. A colour stated in both places is therefore multiplied by a darker
copy of itself: `color:0.62,0.14,0.05,1` in the slot decodes to
`0.34, 0.017, 0.0039`, so beside `base_color` `0.62, 0.14, 0.05` the
surface draws `0.21, 0.0024, 0.0002`, far darker than either. State a
solid colour once, as `base_color`, and leave the slot at its default
white.
6. **Hot reload.** Editing `mat.yaml` repacks the GPU uniform buffer
on the next frame; visible immediately.
7. **Inspector.** `type.yaml` names `inspector: { type: "inspector.luau" }`,
and `inspector.luau` declares the material's sections beside the generic
ones: `shader` (the bound shader, swappable), `properties` (each shader
field as its typed control) and `textures` (each texture slot as an asset
chip). They read the material through `mat:getShaderRef()` (the bound
shader's ref, `~` expanded) and `mat:getTextures()` (slot name →
`{ ref, name }`: the bound texture's guid and the id the material names
it by). An edit there is an authoring write: `mat:writeProperty(name,
value)` and `mat:writeTexture(slot, texture)` write the value into
`mat.yaml` (and drop the session's own value for it, so the material
reads what the file holds), and `setShader` rewrites the file; inside an
undo operation each file write is recorded, so undo puts `mat.yaml` back.
`setProperty` / `setTexture` stay runtime writes that last until the next
mode change.
## Discovery
- `asset.list("material")` — every registered material.
- `asset.inspect("<name>")` — shader binding, property values,
source path, this type README.
- `ls /zero/source/<Name>.material` lists the files the folder holds.
- `asset.resolve("<name>"):getProperties()` — the property fields exposed by
the bound shader, each at the value it currently holds: what `mat.yaml`
authored until something writes to the material, and the written value from
then on, whichever call wrote it. That is the value the surface is drawn
with. Read them back rather than guessing names; which fields exist comes
from the shader.
- `asset.resolve("<name>"):getDefinition()` — the authored `mat.yaml` bytes,
the value the material rests at.
- `asset.resolve("<name>"):getShader()` — the shader the material binds.
## Authoring conventions
- Use lowercase, descriptive names (`brushed_steel`, `red_glow`,
`tarmac_wet`). Avoid shader-name suffixes in the material name —
the binding lives in `mat.yaml::shader`.
- Drive every property the shader exposes; defaults are a fallback,
not a contract for which fields exist.
- Embed textures alongside the material (`textures/` subfolder) when
they're used only by this material; reference shared textures from
`@builtin::textures.*` or a library.
## Common pitfalls
- **Property names must match the shader's uniform struct exactly**
(case-sensitive). Misnamed properties silently get the shader's
default.
- **`base_color_texture` multiplies `base_color`.** A `color:` texture holding
the same value as `base_color` multiplies the colour by its own
sRGB-decoded value, so the surface renders far darker than the value
written. Write a solid colour as `base_color` alone.
- **Broken shaders break the material.** A material whose shader has a
parse error renders as the magenta checkerboard. Inspect the shader
first if the material looks wrong.
- **`setProperty` targets the material, not the entity.** Changes affect
every entity that references the material.
- **`setProperty` is a runtime change.** It reaches the GPU, not the file:
call `saveDefinition` to write the current values into `mat.yaml`. For a
per-entity override that no other entity sees, give the entity its own copy
with `Model:applySessionMaterial(renderer.material.create(...))`.
- **Renaming the folder changes the identity.** Update every `Model`
component and `asset.create` callsite that references it.
## Related types
- `.shader` — defines what properties exist; required before a
material can bind to it.
- `.preset` — captures a component configuration; conceptually
similar to a material, but for components instead of shaders.
▲ 0↑ born
# mesh
A mesh is renderable geometry — vertices and triangle indices, and, for a skinned
mesh, the per-vertex skinning data that binds it to a skeleton. A component that
draws geometry references a mesh, and the renderer binds the mesh's GPU resource
for that entity.
> New to how a mesh exists as an asset, as CPU-side vertex data, and as a live
> GPU resource (and how `renderer.mesh.*` moves between them)? Read
> `core/resource-model` first.
Geometry is stored in `data.zmsh`; the GPU resource is keyed by
the asset's guid.
## Create
`asset.create("mesh", name, opts)` writes the geometry and registers the mesh.
`opts` is raw geometry — flat arrays; `positions` and `indices` are required,
`normals` and `uvs` optional:
```luau
asset.create("mesh", "tri", {
positions = { 0,0,0, 1,0,0, 0,1,0 }, -- [x,y,z, ...] (required)
indices = { 0, 1, 2 }, -- [i0,i1,i2, ...] (required, 0-based)
normals = { 0,0,1, 0,0,1, 0,0,1 }, -- [x,y,z, ...] (optional)
uvs = { 0,0, 1,0, 0,1 }, -- [u,v, ...] (optional)
})
```
The full authorable geometry, all flat arrays unless noted:
| Field | Shape | Notes |
|---|---|---|
| `positions` | `[x,y,z, ...]` | required |
| `indices` | `[i0,i1,i2, ...]` | required; 0-based, counter-clockwise for the front face (see Conventions) |
| `normals` | `[x,y,z, ...]` | optional; the surface's outward direction |
| `uvs` | `[u,v, ...]` | texture UVs, optional; `(0,0)` is the image's top-left |
| `colors` | `[r,g,b,a, ...]` | optional |
| `tangents` | `[x,y,z,w, ...]` | optional (`w` = handedness) |
| `uvs1` | `[u,v, ...]` | the lightmap UV set, optional |
| `unwrapUvs` | boolean | generate `uvs1` when it is absent |
| `skinning` | `{ joints, weights }` | per-vertex skin binding (four each per vertex) |
| `skins` | array of skin tables | the skeleton (see `renderer.mesh.decode` for the shape) |
| `morphTargets` | array of `{ name?, positions, normals? }` | the shapes the mesh blends towards, each a per-vertex displacement |
`encode` / `decode` round-trip every one of these, so a **skinned** mesh can be
built in engine, not only imported. Pass `unwrapUvs = true` to generate a lightmap
UV set at creation time.
## Conventions
Geometry is read against these whether it arrived through `asset.create`,
`renderer.mesh.create`, or an importer.
| Convention | How the engine reads the geometry |
|---|---|
| Index base | `indices` count vertices from **0** — index `0` is the first `positions` triple. The Luau array carrying them is 1-based like any other, so `indices[1]` is the first triangle's first index, and vertex `v` has its x at `positions[v * 3 + 1]`. |
| Triangle winding | A triangle's **front** face is the one whose three vertices turn **counter-clockwise** as the viewer sees them. Put another way, `cross(v1 - v0, v2 - v0)` points out of the front face. |
| Back faces | A material culls its back faces by default, so a triangle turned away from the camera draws nothing where it stands and whatever is behind it shows through. |
| Normals | `normals` give the surface its outward direction and shade the face. Which side of a triangle draws comes from the index order alone, so a normal pointing away from the side the winding draws leaves that face standing and shades it as if the light were behind it. |
| UVs | `(0, 0)` samples the first texel of an image — its top-left corner — and `v` grows downward through it. |
| Axes | Model space carries the world's basis: `+X` right, `+Y` up, and `-Z` the direction an entity's `transform.forward` points. |
Winding is what the author states: the inward faces of a room and a sky shell
seen from inside are geometry meant to be wound the other way, and `asset.create`
writes the index order it is handed. Two levers turn a face around:
```luau
indices = { 0, 2, 1 } -- the same three vertices, reversed
asset.create("material", "two_sided", { -- or keep the order and draw both sides
shader = "@builtin::shaders.pbr", render = { cull = "none" },
})
```
`cull = "front"` draws the far side by itself, which is what an inverted-hull
outline is built on, and `"back"` is the default. `.shader` documents the whole
`render` block a material's `mat.yaml` carries.
A mesh states which way its own triangles face, so the question is answerable
off the geometry — every triangle whose cross product agrees with the normal it
carries is wound to draw the side that normal points at:
```luau
local cpu = meshRef:load()
local g = cpu:geometry()
local function facesOutward(t: number): boolean -- t counts triangles from 0
local i = { g.indices[t * 3 + 1], g.indices[t * 3 + 2], g.indices[t * 3 + 3] }
local function p(k: number, c: number): number return g.positions[i[k] * 3 + c] end
local ax, ay, az = p(2,1) - p(1,1), p(2,2) - p(1,2), p(2,3) - p(1,3)
local bx, by, bz = p(3,1) - p(1,1), p(3,2) - p(1,2), p(3,3) - p(1,3)
local cx, cy, cz = ay*bz - az*by, az*bx - ax*bz, ax*by - ay*bx
local n = i[1] * 3
return cx * g.normals[n+1] + cy * g.normals[n+2] + cz * g.normals[n+3] > 0
end
local inward = 0
for t = 0, #g.indices // 3 - 1 do
if not facesOutward(t) then inward += 1 end
end
cpu:unload()
```
## Built-in primitive meshes
The engine ships blockout geometry every world references by bare name
(`{ model = "cube" }`). Each one is centred on its local origin, so an entity's
`scale` reads directly as the size the mesh covers in the world:
| Mesh | Extent (x, y, z) | Local AABB |
|---|---|---|
| `cube` | 1 x 1 x 1 | `-0.5, -0.5, -0.5` .. `0.5, 0.5, 0.5` |
| `sphere` | 1 x 1 x 1 | `-0.5, -0.5, -0.5` .. `0.5, 0.5, 0.5` |
| `cylinder` | 1 x 1 x 1 | `-0.5, -0.5, -0.5` .. `0.5, 0.5, 0.5` |
| `cone` | 1 x 1 x 1 | `-0.5, -0.5, -0.5` .. `0.5, 0.5, 0.5` |
| `octahedron` | 1 x 1 x 1 | `-0.5, -0.5, -0.5` .. `0.5, 0.5, 0.5` |
| `rounded_box` | 1 x 1 x 1 | `-0.5, -0.5, -0.5` .. `0.5, 0.5, 0.5` |
| `capsule` | 0.5 x 1 x 0.5 | `-0.25, -0.5, -0.25` .. `0.25, 0.5, 0.25` |
| `torus` | 1 x 0.3 x 1 | `-0.5, -0.15, -0.5` .. `0.5, 0.15, 0.5` |
| `plane` | 1 x 0 x 1 | `-0.5, 0, -0.5` .. `0.5, 0, 0.5` |
| `ground_quad` | 1 x 0.1 x 1 | `-0.5, -0.05, -0.5` .. `0.5, 0.05, 0.5` |
| `ground_hex` | 1.732 x 0.1 x 2 | `-0.866, -0.05, -1` .. `0.866, 0.05, 1` |
| `shader_ball` | 0.933 x 1 x 0.933 | `-0.467, -0.5, -0.467` .. `0.467, 0.5, 0.467` |
`plane` is a flat quad in the XZ plane, so it has no vertical extent at all and
scaling it on Y moves nothing. `meshRef:load():getBounds()` reads the same
numbers back off any mesh, built-in or authored.
## Put a mesh on an entity
A component draws a mesh through a `resource("mesh")` field, which accepts the mesh
**by name, by ref, or as a `MeshHandle`** — the component resolves whatever you give
it. The builtin `Model` and `SkinnedModel` both expose a `model` field:
```luau
entity.find("box").component.add("Model", { model = "tri" }) -- by name
entity.find("box").component.add("Model", { model = asset.ref("tri") }) -- by ref
entity.find("box").component.add("Model", { model = meshHandle }) -- a MeshHandle (below)
```
## Runtime mesh — no asset
Build geometry straight onto the GPU and render it without creating an asset. The
`resource("mesh")` field takes the returned `MeshHandle` directly:
```luau
local h = renderer.mesh.create({ positions = {0,0,0, 1,0,0, 0,1,0}, indices = {0,1,2} })
entity.find("box").component.add("Model", { model = h })
```
Its geometry reads back off the handle. `renderer.mesh.geometry(mesh)` returns the
same flat-array `MeshGeometry` `create` takes, carrying every stream the mesh has —
so a runtime mesh can be inspected, edited, and rebuilt without ever becoming an
asset:
```luau
local geom = renderer.mesh.geometry(h) -- { positions, indices, tangents?, ... }
geom.positions[1] = 0.5
local h2 = renderer.mesh.create(geom)
```
An optional stream is present only when the mesh carries one, so `geom.tangents`
answers whether the mesh has a tangent basis and `geom.uvs1` whether it has a
second UV set.
## Read geometry — `meshRef:load()`
`:load()` decodes `data.zmsh` into the CPU store and returns a CPU handle carrying
`vertexCount` / `indexCount`:
| Method | Returns |
|---|---|
| `cpu:getVertices()` | array of `{ pos = {x,y,z}, normal = {x,y,z}, uv = {u,v} }`, one per vertex |
| `cpu:getTriangles()` | array of `{ v1, v2, v3 }`, each a full vertex (the shape above) |
| `cpu:getBounds()` | `{ min = {x,y,z}, max = {x,y,z} }` — the local-space AABB |
| `cpu:geometry()` | the complete `MeshGeometry` — flat arrays, every stream the mesh carries |
| `cpu:encode()` | the `data.zmsh` bytes |
| `cpu:unload()` | drop this guid's CPU copy |
```luau
local cpu = meshRef:load()
local v = cpu:getVertices()[1] -- { pos = {x,y,z}, normal = {x,y,z}, uv = {u,v} }
local aabb = cpu:getBounds()
cpu:unload()
```
By default the CPU copy is transient (decode → read → `unload`). Set `keepCpu` to
keep it resident — see Settings.
## Write geometry back — `meshRef.primary`
The inverse of reading: encode a `MeshGeometry` and write it to the asset's
primary content file. The assetType picks the write up and re-uploads the live
GPU mesh, so the change shows without a reload:
```luau
local geom = renderer.mesh.geometry(meshRef)
geom.tangents = computeTangents(geom) -- add a stream it did not carry
vfs.write(meshRef.primary, renderer.mesh.encode(geom), { overwrite = true })
```
`meshRef.primary` is the asset's `data.zmsh` path. This is the route for a
stream with no setting of its own; `keepCpu` + `meshRef:setVertices` moves
positions in place, and `lightmapUvs` regenerates the second UV set (both
below).
## GPU handle — `meshRef:handle()`
`:handle()` uploads the geometry (Disk → CPU → GPU) and returns a `MeshHandle`,
cached on the asset so every consumer shares ONE GPU entry. A component does this
for you when you pass it the mesh; call it directly only for the runtime/low-level
path.
## Settings — `meshRef:settings()` / `meshRef:setSettings(patch)`
A mesh's settings are declared in `settings.luau` and live in `settings.yaml`
(or, for content published before it existed, a legacy `.metadata` block).
`:settings()` returns them with every declared default filled in.
`:setSettings(patch)` writes any subset — sibling settings are preserved, and
a patch that fails the schema is refused, naming the field and why.
| Setting | Type | Default | Meaning |
|---|---|---|---|
| `keepCpu` | boolean | `false` | Keep the CPU geometry copy resident — for an in-use mesh — so geometry can be read repeatedly without re-loading. |
| `lightmapUvs` | string | `"keep"` | The mesh's second (lightmap) UV set. `"keep"` leaves the stored geometry's set as-is; `"generate"` unwraps a fresh non-overlapping set; `"none"` strips it. See below. |
| `clusterLod` | boolean | `true` | How deep the cluster set is built. Every static mesh draws through clusters, from the frame it materialises (its `lod0` set) until its bake lands. `true` (default) builds the LOD hierarchy; `false` builds one level, drawn at full detail. |
```luau
local s = meshRef:settings() -- { keepCpu = false }
meshRef:setSettings({ keepCpu = true })
```
`keepCpu` is an active lifecycle control, and only ever holds geometry for a mesh
that is **in use** (GPU-resident):
- Setting it **on** while the mesh is in use loads the CPU copy immediately.
- Setting it **on** for a mesh that is **not** in use loads nothing — the copy is
retained the next time the mesh is materialised, so an unused mesh is never
pinned in memory.
- Setting it **off** drops a resident CPU copy (the renderer keeps drawing from
the GPU).
`clusterLod` is the same kind of lifecycle control, for the depth of the
cluster set rather than the CPU copy:
- Setting it **off** on a mesh in use rebakes it as one level of clusters
(`single`), drawn at full detail.
- Setting it **on** on a mesh in use rebakes the LOD hierarchy (`dag`).
- A mesh that is not in use bakes at the depth the setting asks for when it
next materialises.
Acting on either setting for a mesh in use can read the mesh's stored geometry,
and where the geometry is fetched on first read that read spans frames.
`meshRef:settingsPending()` answers whether one is still in flight; once it
answers `false`, every settings change made so far has reached the mesh.
### Second UV set (`lightmapUvs`)
A mesh can carry a **second**, independent UV set (`uv1`) alongside its texture UVs
(`uv`) — a distinct per-vertex channel, non-overlapping where the texture UVs tile
and reuse charts. `lightmapUvs` controls whether the stored geometry keeps one,
generates one, or drops it:
| Value | Effect |
|---|---|
| `"keep"` | Leave the stored geometry's `uv1` as-is (the default). |
| `"generate"` | Unwrap a fresh non-overlapping `uv1` (near-planar charts packed into `[0,1]` with gutters). |
| `"none"` | Strip the `uv1` set. |
```luau
meshRef:setSettings({ lightmapUvs = "generate" })
```
Changing the setting reprocesses the asset's geometry in **edit** mode; a change in
play mode never rewrites the source. The reprocess round-trips the geometry through
the codec, so positions, normals, tangents, skinning, and the skeleton are preserved
— only the `uv1` set changes. A runtime mesh carries `uv1` the same way: pass `uvs1`
(or `unwrapUvs = true`) to `renderer.mesh.create`.
## Read vertices — `meshRef:getVertices()`
With `keepCpu` on for an in-use mesh, read its vertices straight from the resident
CPU copy:
```luau
meshRef:setSettings({ keepCpu = true }) -- on an in-use mesh
local verts = meshRef:getVertices() -- { { pos = {x,y,z}, normal = {x,y,z}, uv = {u,v} }, ... }
```
If the CPU copy isn't resident — `keepCpu` is off, or the mesh isn't in use — it
errors loudly, naming the reason and the remedy, rather than silently loading a
throwaway copy:
```
mesh:getVertices: cannot read vertices — keepCpu is off, so no CPU copy is kept.
Turn on CPU retention for an in-use mesh first: meshRef:setSettings({ keepCpu = true }).
```
## Morph targets — `meshRef:morphTargets()`
A mesh can carry morph targets: shapes it blends towards, each a per-vertex
displacement from the base geometry. Every target carries the name it was
authored under, and an imported model keeps the one its source file gave its
blend shapes, so content addresses a shape as the thing it means rather than as
the ordinal it happened to import at.
```luau
for i, name in meshRef:morphTargets() do print(i, name) end -- 1 Eyes_Blink
```
An entity blends them with `ecs.MorphWeights`, weight `i` driving target `i`.
`renderer.mesh.morphWeights(mesh, { Eyes_Blink = 1 })` builds that ordered array
from weights named by shape — shapes left out sit at 0, and an unknown name
errors listing the ones the mesh carries.
## Discovery
| Call | Shows |
|---|---|
| `asset.list("mesh")` | every registered mesh |
| `asset.inspect("<name>")` | a text report about the mesh (metadata, source, status) |
| `meshRef:settings()` | the resolved settings |
| `meshRef:settingsPending()` | whether a settings change is still reading the stored geometry |
| `meshRef:morphTargets()` | the shapes it blends towards, named, in target order |
For the on-disk shape of a mesh asset, run `asset.inspect("<name>")` or read the
type's `type.yaml` — those are the live source of truth.
## Related
- `renderer.mesh.*` — the GPU/CPU resource layer (`create` a runtime GPU mesh,
`encode`/`decode` the `ZMSH` payload, `readback` GPU → CPU). `:load`/`:handle`
wrap it; see `man renderer`.
- `.material` + `.shader` — what shades the geometry.
- `.bundle` — container assets that carry `.mesh` interiors.
## In the Inspector
Selecting a mesh opens its **Geometry** section, after the asset's identity and before its Settings. It reports what `data.zmsh` holds, decoded once per payload version: vertices, triangles, the bounds and their size, the vertex streams beyond positions (normals, uvs, uvs1, colors, tangents, skinning), and, when the mesh has them, its skin joints and morph targets. While the renderer holds the mesh's cluster hierarchy, the section also reports its cluster and level counts. A payload is one index stream, so the section names no submeshes. A mesh folder with no `data.zmsh` says so.
▲ 0↑ born
# assetType (asset type)
The `assetType` is the **type of types** — the meta-type that every
`<typename>.assetType/` folder is an instance of, including itself. A
`<typename>.assetType/` folder declares a new asset type called
`<typename>`: its on-disk shape, its identity rule, and the structural
contract instances of the type must satisfy.
This type is self-hosting. `assetType.assetType/` conforms to its own
schema — a `type.yaml` plus a `README.md` — so the type system is
defined in terms of itself rather than a hidden engine special-case
(the same way a C compiler is written in C). Every asset in the engine,
including each `.assetType` definition, now resolves to a registered
assetType: a `.material` resolves to `material.assetType`, and
`material.assetType` resolves to `assetType.assetType`, which resolves
to itself.
## Why it exists
Assets used to be mapped to their type *implicitly*, by matching the
folder suffix against a name-keyed registry. That match carried no
stored link: nothing on a `Foo.material` recorded *which*
`material.assetType` it was written against, so two worlds shipping a
same-named type could silently disagree. Making `assetType` a real,
resolvable asset closes that gap — the link from an asset to its type is
now an explicit reference pinned in the asset's `.refs` sidecar
(`via: "asset_type"`), exactly like every other dependency.
## Where it lives
- Source: `/zero/source/.../<typename>.assetType/`
- Identity: `<typename>` (the `.assetType` suffix strips from the
identity; the folder retains the suffix on disk).
- Folder shape:
- `type.yaml` — the structural spec the validator reads. **Required.**
- `README.md` — type-level documentation. **Required.**
- `about.yaml` — three short plain-language texts saying what the type
is for (see "Say what your type is for" below). Every built-in type
ships one.
- `behavior.luau` — optional behavior/scaffolding module.
- `template/` — optional canonical placeholder body the templater
copies into a new asset of the type.
- `<name>.module/` — optional shared code the type ships to **every
instance** of it, conventionally `shared.module/` (see below).
## Shared code its instances reach (`asset.containing` + `.modules`)
A type ships code every instance of it uses by declaring the modules in
its own `behavior.luau`, as a map of tracked `require`s:
```luau
-- inside <typename>.assetType/behavior.luau
M.modules = { shared = require(".shared") } -- the sibling shared.module/
```
An instance reaches it with one location-independent line:
```luau
-- inside any foo.<typename>/init.luau
local api = asset.containing(__FILE__).modules.shared
```
`__FILE__` is the chunk's own VFS path; `asset.containing` resolves the
calling asset, and `.modules.<name>` follows the instance's pinned
`typeRef` (the type's **guid**, recorded in the instance's `.refs` as
`via = "asset_type"`) to the module its type declares. Because
resolution follows that link rather than a category name, the same line
in an instance of a different type resolves to that type's module, and
identically-named modules in two types never collide. `asset.typeRef(target)`
reads that guid for any target; `asset.resolve(asset.typeRef(target))`
gives the full `AssetRef<assetType>`. See `assetTypes/README.md`
§ "Shared code: type-owned modules" for the full mechanism.
## How to create one
Authoring a new asset type is just creating a `<typename>.assetType/`
folder under `/zero/source/`. The engine registers `<typename>` as a
category **the moment the folder is written** — no engine restart, no
Rust change, and it works for built-in (`@builtin/assetTypes/`) and
user-defined types identically. Once `<typename>` is registered,
`<name>.<typename>/` folders are recognised as assets of that type and
resolve their `asset_type` link back to this definition.
Creating a `<name>.<typename>/` folder *before* its
`<typename>.assetType/` exists fails fast: the suffix has no registered
type, so the folder can't be a valid asset. Author the type first.
## Say what your type is for (`about.yaml`)
Every type carries a small `about.yaml` beside its `type.yaml`, and the README
you write for the type documents how to author an asset of it. Both are
required, and both are meant to be written as you make the type, while you know
what it is for. `about.yaml` answers three questions in plain words, one key
each:
```yaml
intent: "Sets the typeface of the text in your game: UI, labels, signs and 3D lettering."
function: "A font, from a TTF, OTF or WOFF file, made available to all of Zero's text, including 3D text."
usage: "You choose it by name for your UI or your text, and the text appears in that typeface."
```
- **`intent`** is what the type is for in someone's game. Name the whole range
of things it covers, in the words a game maker would use for them.
- **`function`** is how it works today: what an asset of the type is made of and
what the engine does with it.
- **`usage`** is what a person or an agent does with one: how they pick it, place
it, call it, or hand it to something else.
A few habits keep it useful. Write about the type as a whole, so the text holds
for every asset of it, and leave out any one asset's name. Say what the type
does right now. Stay with words a game maker would say, and mention a file name
or a call only where a game maker would know it. The limits are in bytes:
`intent` 800, `function` 1500, `usage` 1200. Each is generous, and a few
sentences usually say it.
Why bother: agents working in a world read these three texts to learn what your
type is for, so they reach for it when it fits and use it the way you meant. People
searching find the type by what they want to make, and the three texts let them
tell at a glance whether it is the one they need.
`asset.create("assetType", name)` scaffolds the file with all three keys, and a
README whose first paragraph is a `[[describe]]` note. Each value starts with
`[[describe]]` followed by a note on what to write; replace the whole value with
your own words. A value or a README line that still starts with `[[describe]]`
counts as not written yet, which the push's check of each asset it carries against
its type reports, naming the file. A type you leave as it was is not asked, so
older types stand.
Many types at once, or one you would rather not write by hand: `services
describe` lists the types that lack these texts and what filling them costs, and
`{ mode = "all" }` writes them into the same files, where you edit them like
anything else you wrote.
Where the texts show up: `asset.inspect("<typename>", "assetType")` returns them
as `detail.about`, `assetTypeRef:getAbout()` returns them as a table with the
keys `intent`, `function` and `usage`, the type's `types/<typename>` guide page
ends with an "About this type" section holding them, and the Inspector's Type
section lists them at the top. The guide page is this README plus that section,
so a README that explains the type well and an `about.yaml` that says what it is
for read well together.
## Ask your assets to say what they are
A type can ask each of its assets to carry a description: a file, written by the
author or generated, that says what that asset is and what it is for. The people
and agents who find an asset read it to decide whether it fits, and the author
knows what it is for at the moment of making it.
**1. Declare the file with a `described` check.** This is the whole requirement:
```yaml
files:
required:
- path: "README.md" # any file name works
kind: "file"
content:
- { check: "described", message: "Say what this widget is for and where it goes." }
```
With that in place:
- An asset whose file is missing, empty, or still has a line starting with
`[[describe]]` does not meet the type's requirements. `asset.validate` names it,
`zm status` and `world.publishBlockers()` list it, and a push that carries it is
refused with `message`.
- `services describe` finds it (`mode = "find"` lists it and the price) and can
write the description for it (`mode = "all"`). It writes only this file.
For a YAML file that holds several texts, name the keys and their limits in bytes,
the way this type does for `about.yaml`:
```yaml
content:
- check: "described"
keys:
- { key: "intent", max_bytes: 800 }
- { key: "usage", max_bytes: 1200 }
message: "Write intent and usage."
```
`headings` names sections a Markdown file must have. `assetTypes/README.md` has the
full check.
**2. Optionally, steer what gets generated with a `describe:` block:**
```yaml
describe:
guidance: "Say what the widget shows, what input it reacts to, and where it fits in a UI."
examples:
- "<identity of an asset of this type whose description reads well>"
```
`guidance` says what a description of your type covers, and `examples` names assets
whose descriptions show the shape you want. Without the block, generation still
works from the general describing guide and the check's `message`.
An example is an asset of your type in the library whose description is a true,
written one, never a scaffold's placeholder, and never the asset being described.
When the library has none, leave `examples` empty rather than name one that is not.
A type whose assets the model cannot perceive (a sound it cannot hear, a motion it
cannot see) says so in `guidance`: the description comes only from what the asset's
own files state, such as its name and the `.metadata` where a generation service
records the prompt that made it, and when they state nothing the model answers
`cannot` with that reason. `services describe` then fails that one asset with the
reason and leaves its file as it was.
**3. Start the file unwritten in your template.** Put a heading and one line that
starts with `[[describe]]` and a short note on what to write, in
`template/<file>`, so a new asset starts unwritten rather than with text that only
looks written. Do not put example content in it: every new asset carries the
template verbatim, and the same text on many assets makes them impossible to tell
apart in search.
Types whose assets say what they are through their own content (a mesh, a texture,
a sound) leave this out: declare nothing and nothing is asked.
## The `behavior.luau` contract
`behavior.luau` is the type's code. It returns ONE table, and the keys below
are the ones the framework reads off it for EVERY type — the surface you get
by declaring them. Anything else on the table is your own: reachable by name
through `require("@builtin::assetTypes.assetType.shared.ref").loadTypeModule("<typename>")` for a
system that knows your type (`computeShader` publishes `dispatchKey` that
way), and never called by the framework.
```luau
local M = {}
M.ref = { ... } -- methods on every AssetRef of this type
M.modules = { ... } -- shared code the instances reach
M.global = {} -- reserved key; ship the empty table
function M.onCreate(name, opts) ... end
return M
```
### The tables
| Key | Shape | What the engine does with it |
|-----|-------|------------------------------|
| `M.ref` | `{ [name] = function(self, ...) }` | Every function becomes a method on every `AssetRef` of this type: `ref:name(...)`, with `self` the ref. This is how a type gives its instances an API. Two names in here are contracted — see below. |
| `M.modules` | `{ [name] = require(".sibling") }` | Shared code the type's instances reach through `asset.containing(__FILE__).modules.<name>`, resolved by the instance's pinned `typeRef` guid. See the section above. |
| `M.refShapes` | `{ [method] = "TypeName" }` | Declares the result type of a `M.ref` method so the LSP can check what a call site does with it. See the `M.refShapes` section below. |
| `M.events` | an events schema | The events an ASSET of this type fires, declared the way a component declares its own. Readers subscribe through `ref.events.<name>:connect(...)`; firing authority stays with the type. Keyed by the asset's guid, so every resolver of the same asset shares the signals and a re-resolved ref re-attaches to subscriptions already there. |
| `M.namePattern` | a Lua pattern string | The name shape `asset.create` enforces for instances of this type, replacing the default `^[A-Za-z][A-Za-z0-9_]*$`. |
| `M.global` | `{}` | Reserved. Ship the empty table. |
### The lifecycle hooks
Each is optional; a type that omits one costs nothing. The engine calls them:
| Hook | Signature | When it fires |
|------|-----------|---------------|
| `M.onCreate` | `(name, opts) -> { [filename] = contents }` | On `asset.create("<type>", name, opts)`. Returns the files that scaffold the new instance, as a map of relative path to contents; the framework writes them. The `opts` type annotation is the schema `asset.create` validates the caller's arguments against, so annotate it. |
| `M.onReplace` | `(dest, files) -> { [filename] = contents }` | On an `asset.create` that writes over an asset already at `dest`, after `onCreate`. Receives the files `onCreate` produced and returns the files to write, so a type that re-derives its content (a rig re-imported from its source file) can carry what an author added to the asset it replaces. Optional. |
| `M.onRegister` | `(self)` | Once per instance, the first time it registers: on `engine.onWorldLoaded` for instances already in the world, immediately on a live `asset.create`, and when an instance arrives after that sweep (installed, pulled, or synced from a peer). Guarded by the instance's path, so a double trigger never double-registers. An instance that leaves the runtime releases the guard, so one arriving at the same path again registers. This is what makes "write an asset into the world and it takes effect live" work for content that registers into a runtime registry. The `@builtin` library is excluded from the sweep. |
| `M.onChange` | `(ref, change)` | On every VFS write to a file INSIDE one of this type's instances. `change` is `{ path, asset, type, kind, origin }`: `kind` is `"edited"` or `"seeded"`, `origin` is `"local"` or `"remote"` (an importer runs on the originator only). **Filter on `change.path`**: the hook fires for any file under the folder, so act on the one you care about. It runs as its own task and may wait on what it reads: a `vfs.read` of bytes only the blob store holds (a binary file on the web build) fetches them. The writes to one asset reach the hook in the order they were made, each once the hook before it has finished. A re-entrancy guard suppresses writes back into the same asset routed inline from the hook, and a write the hook makes is routed on a later frame like any other, so the hook must be convergent on its own: diff the meaningful state and short-circuit while your own regeneration is in flight. |
| `M.onDelete` | `(ref)` | When an instance of this type leaves the runtime: it is deleted, or the engine swaps away from the world binding that held it. Its files are gone from `/zero/source` by then. A removal routed while the instance's `onChange` is waiting reaches `onDelete` once that hook, and every write queued behind it, has finished. On a swap the instance still exists in the world it belongs to, and a VFS write the hook makes reaches the world now bound. |
| `M.validate` | `(assetRef) -> { { code, message, severity? } }` | On `asset.validate`, after the structural `type.yaml` check, for the type's own SEMANTIC validation. `severity` defaults to `"error"`; error-severity problems flip `ok` to false, warnings do not. A hook that raises or returns a non-table is itself reported as a `validate.hook_failed` error. `world.push` runs this per user asset, so declaring it enforces your type's rules at publish time with no further wiring. |
### The two contracted `M.ref` names
Most `M.ref` methods are your type's own surface: name them what you like,
return what you like. Two are read by the engine and mean the same thing for
every type, so their shape is fixed:
- **`instantiate(self, target?, opts?) -> (root, idMap)`** — makes an instance
part of the scene. Defining it is the whole opt-in: `ref:canInstantiate()`
is true exactly when it exists. Its **return is a contract** — see the next
section.
- **`inspect(self) -> detail`** — the type-specific half of `asset.inspect`.
See its section below.
### Names a type cannot shadow
Some keys resolve on every `AssetRef` before per-type dispatch, so an `M.ref`
entry of the same name is never reached: `canInstantiate`, `getSource` /
`getBytes` / `getText`, `exists`, `deps`, `meta`, `runtime`, `events`,
`modules`, `typeRef`, and the residency flags `has_backing_asset`,
`has_runtime_changes`, `cpu_resident`, `gpu_resident`. These are the
behaviours every asset must expose identically, which is why they win.
### Reloading
A `behavior.luau` edit ripples to existing refs with no restart: dispatch
reads `mod.ref` fresh each time and `require`'s cache is re-run in place. A
type whose folder appears at runtime is picked up on the next dispatch —
negative results are not cached either.
## The names your type's assets hold (`references:`)
An asset that names another asset by a string depends on it: the named asset
travels with it into a pack or a pull, and a name nothing answers to is an
unresolved dependency `worldValidation` reports and the push gate refuses. The
engine reads `require(...)` and the asset lookups on its own; a name your type's
format or API carries anywhere else is one you declare, in `type.yaml`:
```yaml
references:
# A top-level key of this type's own files that holds a name (or a table of them).
keys:
- { key: "shader", type: "shader" }
# A call's argument, counting from 1, wherever the call is written.
args:
- { call: "g:node", position: 2, type: "procNode" }
# A field of the options table a call takes.
fields:
- { call: "fx.beam", field: "material", type: "material" }
```
`type` is the category the name resolves in, one of `asset.categories()`. An
argument declared once per category, at the same `call` and `position`, takes
the name from whichever category answers. A key or a field holds one category:
declared again with another `type`, it takes the later one. The block is
declared as the type registers, when the library or the world holding it is
laid into the VFS, so every reference table reads it whatever script has or has
not run. `asset.declareReferenceKey`, `asset.declareReferenceArg` and
`asset.declareReferenceField` declare the same things from a script, for a
surface that is no type's.
## What search does with your type (`indexing:`)
A type declares what search embeds for its instances. **This is not optional
and there is no useful default**: ZeroMind runs your declaration and adds
nothing of its own, so anything you do not point at is absent from the index —
silently, and for every instance of the type forever. Twenty types once shipped
with no `indexing:` block and every instance of them was unfindable.
A type whose instances are never searchable (a run record, a log) says so with
`indexing: none`; every other type declares a recipe.
A type without an `about.yaml` beside its `type.yaml` refuses every push that
carries it, whether or not the push edited the type. Texts in it that are not
written yet follow the rule every `described` file follows: they refuse the
push that edits, stages or commits the type.
Two words carry the whole thing, and they are the same two the search tool
exposes:
- **`identity`** — what an instance IS. For anything you can look at, that is
the picture (`content:` + `modality: image`, matched directly by a text
query). For everything else it is the authored text that says what it is.
- **`capability`** — what it DOES and how it is made. Code, settings, the
model-written summary of them.
A type with no behaviour has no `capability` entry. A type with nothing to look
at has no image. Leaving a slot empty is a statement; filling it with whatever
happens to be lying around is not.
The one you have to decide when authoring a type is: *for an instance of this,
what is the thing a person would recognise it by, and what is the thing it
does?* A texture answers "the image" and "its compression settings". A module
answers "its README" and "its code". Write those two answers into `indexing:`
and the rest follows.
Three specifics worth knowing before you write one:
- **`derive` is instructed by this type's own `README.md`.** A model reads an
instance's source and writes a description; what it embeds is the
description, not the source. It follows your README to know what it is
looking at, so a type whose README says what its instances are gets good
derivations for free. There is no prompt to name.
- **`derive` loses detail.** It keeps the main technique and drops secondary
ones. If the source is code you want searchable by its own vocabulary,
declare the same files a second time as `capability` with
`extractor: verbatim`. Two entries, same role, different failure modes.
- **Never `source: { field: name }`.** A filename is one or two words with no
usable embedding, and a corpus indexed that way answers "language runtime"
with `say_runtime_2.soundClip`.
`facets:` is the second stage: a `file:` embedded whole (`.metadata`, so keys
nobody declared still get indexed) plus computed keys that state a value for
every instance including `false` — the raw file can imply "not rigged" and can
never say it.
The full schema, every extractor and the facet sources are in
[`assetTypes/README.md`](../README.md); the scaffolded block with inline
guidance is in [`template/type.yaml`](template/type.yaml).
## A container whose contents work together (`system: true`)
A type that is a container can say its contents are one working whole by
declaring `system: true` at the top level of its `type.yaml`. `package` declares
it: the components, modules, scenes and tools a package ships are parts of one
system. The declaration is plain data, read from the decoded `type.yaml`
(`require("@builtin::assetTypes.assetType.shared.declaration").of("package").system`),
and a type that leaves it out is an ordinary type.
## What your type looks like (`preview:`)
A type declares how its instances are pictured, and the engine makes the
picture when something needs it: staging an instance whose `preview.png` is
missing or pictures content it no longer holds queues a render, a view that
shows the instance asks for one, and `ref:preview()` renders one on demand. An
edit renders nothing on its own. Each `preview.png` records the content it
pictured, which is how a stale one is told from a current one. The preview studio owns the light, the
backdrop, the framing and the camera, and nothing in the world reaches it, so
an instance pictures the same in every world. Declare one of four recipes:
| `kind` | The picture is | Your type provides |
|---|---|---|
| `instantiate` | the asset stood in the studio through its own `instantiate` | `ref.instantiate` |
| `image` | a file the instance holds, fitted to the frame, nothing rendered | `source.files`, the candidate files in order |
| `surface` | the asset painted on a builtin mesh | `mesh`, and `ref.previewMaterial(self) -> { material }` |
| `custom` | whatever `ref.preview(self, opts)` returns | `ref.preview` |
```yaml
preview:
kind: surface
mesh: sphere
```
A scene-instantiable type that declares nothing still answers `ref:preview()`
through its `instantiate`, but persists no picture: the declaration is what
makes the engine keep one. List `preview.png` among the type's files so an
instance may hold it.
The picture is findable only when `indexing.content` names it. The two are
separate statements, and neither is derived from the other:
```yaml
indexing:
content:
- role: identity
modality: image
source: { file: "preview.png" }
```
`@builtin::assetTypes.assetType.shared.previewRecipe` reads the declaration;
the scaffolded block with inline guidance is in
[`template/type.yaml`](template/type.yaml).
## Typing a result from the instance (`M.refShapes`)
Every method on `M.ref` is checked against the type its `--!return` names,
and every asset of the type gets the same one. That is right for most
methods and wrong for the ones whose result is shaped by the ASSET: a
method answering one entry per child folder, per row of a config, per
binding an input map declares. The widest true annotation for those is
`{ [string]: Thing }`, and an indexer accepts every key — so a caller's
typo reads as valid and nothing reports.
`M.refShapes` lets the type state the result per instance. Each entry is
`function(self) -> (typeExpression, source?)`: a Luau type expression for
THIS asset, and the module whose type vocabulary the expression uses.
Read the asset; never run it — stating a type must not take effect.
```lua
-- inputMap.assetType/behavior.luau — the shipped example.
-- `activate()` hands back one handle per control the map declares.
M.refShapes = {
activate = function(self): (string, string)
local names = {}
for _, control in ipairs(M.ref.controls(self)) do
table.insert(names, control.name .. ": Handle")
end
if #names == 0 then return "", "" end
return "{ " .. table.concat(names, ", ") .. " }",
"@builtin::modules.zinput.scheme"
end,
}
```
With it, `map.jump:onPressed(fn)` resolves and `map.noexisting` is
reported with the map's real controls. Without it, both pass silently.
### What the call site has to say
A per-instance type is matched by the asset's IDENTITY, so it applies only
where the reference the method is called on says WHICH asset. Two spellings
do:
```lua
-- a component field, whose declared default names the asset
public = { map = Field.assetRef("inputMap", "@builtin::inputMaps.default", Sync) }
local controls = public.map:activate() -- typed for THAT map
-- an annotation, naming category and identity
local m: AssetRef<"inputMap", "@builtin::inputMaps.default">
```
`asset.resolve("<identity>", "<category>")` carries the CATEGORY, so the
methods the category defines are checked on the result — but not which
asset it found, so a per-instance result keeps the category's declared
return. A reference that names no asset (`asset.resolve(someVariable)`, a
value passed in as a parameter) carries neither, and calls on it are
unchecked. That is the same rule everywhere: the checker states what the
code states, and a name computed at runtime states nothing.
### The result is a value, not a binding
The type belongs to the expression, so it travels the way any inferred type
travels. Indexing the call's result is checked; stashing it in a module
local and indexing it from another function is not, because the local's
declared type is what carries across — and `any` (or no annotation) carries
nothing:
```lua
-- checked: `d.greting` is reported
function awake()
local d = public.dialogue:lines()
d.greeting:say()
end
-- NOT checked: `lines` is a module local typed `any`
local lines: any = nil
function awake() lines = public.dialogue:lines() end
function start() lines.greting:say() end
```
Bind what you need at the call site and the names stay checked in both
places — the asset-derived type at the boundary, ordinary scoping after it:
```lua
local lineGreeting = nil
function awake()
local d = public.dialogue:lines()
lineGreeting = d.greeting -- `d.greting` is reported here
end
function start() lineGreeting:say() end
```
### When nothing is reported and you expected something
From a call site, a type that is correct and a type that was never published
look the same: no diagnostic either way.
`require("@builtin::assetTypes.assetType.shared.refShapes").published()` answers which it
is. It returns two maps — the category surfaces, and the per-asset results
keyed by identity and then by method:
```lua
local shapes = require("@builtin::assetTypes.assetType.shared.refShapes").published()
shapes.categories.dialogue
--> "{ lines: () -> any, lineNames: () -> { string } }"
shapes.returns.Shopkeeper.lines
--> "{ browsing: Line, farewell: Line, greeting: Line, wares: Line }"
```
An identity absent from `returns` was never published for, which is a
different thing from published-and-correct — and the reason to look here
rather than at the call site.
The answers are recomputed when an instance is written, so a control added
to a map reaches the checker with no restart. Only types that declare
`refShapes` are read per instance, and at most 64 assets of one type are —
past that the type keeps its declared return and the engine log names what
was dropped.
## The instantiate hook (`M.ref.instantiate`) — a contracted return
A type opts into becoming part of the scene by defining `instantiate` on its
`ref` table. That is the whole opt-in: `ref:canInstantiate()` is true exactly
when the hook exists, so consumers offer a scene path — an `Asset.source`
field, a viewport drop, a tool argument — by CAPABILITY rather than by a list
of type names.
Unlike `inspect`, this hook's **return is a contract**. A caller writes one
piece of code against every instantiable type, so what comes back cannot vary
by type:
```lua
-- behavior.luau
local Instantiable = require("@builtin::assetTypes.assetType.shared.instantiable")
local M = {}
M.ref = {
instantiate = function(self, target, opts)
local root = Instantiable.root(self, target, opts) -- IN: the base opts
-- ... compose whatever this type is, under `root`, NOW ...
return Instantiable.result(self, root, idMap) -- OUT: the contract
end,
}
return M
```
**IN.** `target` is an owning entity ref: the instance lands under (or, for a
hierarchy type, onto) that owner. With no target the type spawns a fresh root.
`position`, `rotation`, `scale`, `name` and `temporary` mean the same for every
type — `Instantiable.root` applies them to a root you mint, `Instantiable.place`
to one you adopt, so you never re-read the spec. Honour more opts of your own if
your type needs them, and document them in the hook's `--!arg` docs.
**OUT.** Return `(root, idMap)` through `Instantiable.result`:
- **`root`** — the composed root, as an `EntityRef`. Composition is
**synchronous**: everything your type builds is live when you return, so the
caller can parent to it and read its components in the same statement. Do not
defer the work to a component's `awake` and return an empty shell — a root
that is not live is refused.
- **`idMap`** — the `originalId -> runtimeId` map naming what you spawned, or
nil for a type with no addressable children (`result` normalises it to `{}`).
**A caller never receives nil.** A component that re-composes your asset on
every load keeps this map and passes it back in, which is how a cross-entity
reference into the composition survives a reload.
`AssetRef` runs every `instantiate` through `result` on the way out whether or
not your type called it, so returning something else fails at your own call
rather than handing a caller a nil root. Calling it yourself is still how you
say what you return, and running it twice changes nothing.
`@builtin::assetTypes.assetType.shared.instantiable`'s README is the full reference for both halves.
## The inspect hook (`M.ref.inspect`)
`asset.inspect(ref)` resolves `ref`, builds a common envelope shared by
every asset (`identity`, `name`, `guid`, `source`, `typeName`,
`typeDefinitionPath`, `scope`, `origin`, `description`, `tags`), then
calls the resolved type's own inspect hook — `M.ref.inspect(self)` on
`behavior.luau` — for the type-specific `detail`. The hook returns
*only* `detail`; the framework fills in everything else:
```lua
-- behavior.luau
local M = {}
M.ref = {
inspect = function(self)
-- self.path is the asset's own folder. Parse the asset's OWN
-- files — source text, a native payload header, its `.metadata`
-- — never runtime state (a live component instance, a GPU
-- upload, a compiled schema): inspect must work on an asset that
-- hasn't been compiled, uploaded, or registered yet.
return {
-- type-specific fields, whatever this type wants to surface
}
end,
}
return M
```
A type that omits `M.ref.inspect` yields the generic record: the
envelope alone, `detail = nil`. That's a valid, working state — not an
error — but it means agents calling `asset.inspect` /
`tools.use("assets","describe", ...)` on an instance of the type see
only identity and scope, none of what makes an instance of it
meaningful. Declaring the hook is what makes a type's instances
inspectable for what they actually are.
A hook that raises leaves `detail = nil` and sets `record.warning` to
the error text — `asset.inspect` itself never raises for a resolved
ref.
## Discovery
- `asset.list("assetType")` — every registered asset type.
- `asset.inspect("<typename>")` — the type's identity, source path, and
this README, plus its `about.yaml` texts as `detail.about`.
- `asset.resolve("<typename>", "assetType")` — the `<typename>.assetType`
asset itself (used by the validator to find the type's `type.yaml`).
## Related
- `assetTypes/README.md` — the full `type.yaml` schema and validator
rules.
- `@builtin::assetTypes.assetType.shared.instantiable` — the instantiation contract's shared
implementation, both halves.
- Every other `*.assetType/` here — the concrete types built on this
meta-type.
## The Inspector a type ships
Every type names the file its Inspector sections come from with `inspector: { type: "inspector.luau" }` in its type.yaml. `asset.create("assetType", name)` scaffolds the block and an `inspector.luau` whose Contents section lists an asset's files, built from `ctx.parts`. `asset.validate` on a type that names none reports `assetType.inspector_required` (an error for a `@builtin` type the engine's library authors; a warning for a world's own, and for one the library carries by reference, which the world that published it answers for), and one whose file does not load `assetType.inspector_invalid`.
## In the Inspector
Type: what the type.yaml declares (suffix, container, primary files, the settings and inspector blocks), whether the type ships a behavior, how many instances it has, and **Open** for its files. Schema: the settings every instance carries, as read-only typed controls at their defaults (a type whose schema is per instance says where it is declared). Instances: a link to each of the first 20 assets of the type, and how many there are when there are more.
▲ 0↑ born
# Component (asset type)
A component is a reusable behavior + data unit attached to an entity.
The component file declares public fields, methods, and lifecycle
callbacks; the engine instantiates one per attachment and drives its
hooks every frame. Components are the primary unit of gameplay code —
movement, AI, animations, UI panels, network sync, and per-entity state
all live here.
## When to use one
- You need per-entity state that survives across frames.
- You need per-entity behavior driven by engine lifecycle hooks
(`start`, `update`, `destroy`).
- You want to expose configurable fields to designers / `entity.spawn`
callers / scenes without code changes.
If you need a global service that outlives any entity, use a `.service`
instead. If you need a stateless pure-function library, use a `.module`.
## Where it lives
- Source: `/zero/source/.../<Name>.component/`
- Identity: `<Name>` (the `.component` suffix strips from the
identity, but the folder retains the suffix on disk).
- Folder shape:
- `init.luau` (or `init.lua`) — declares the component. **Required.**
- `README.md` — instance-level documentation. **Required.**
- `.metadata` — agent-editable tags + free-form fields. **Required.**
The structural contract is enforced by `asset.validate` against this
type's `type.yaml`.
## How to create one
Always start with the scaffold — it guarantees the folder shape
matches `type.yaml` from the moment it's on disk:
```luau
asset.create("component", "<Name>")
-- Creates: /zero/source/<Name>.component/
-- init.luau (canonical declare + public block + hooks stubbed)
-- README.md (instance README template)
```
After scaffolding, edit `init.luau` to fill in the actual fields and
behaviour. The component registers as soon as the file is written —
hot-reload is automatic; no engine restart required.
## How it operates
1. **Registration.** Writing `init.luau` into a `.component/` folder
indexes the asset, mints a guid (in the `.meta` sidecar), and
surfaces the type to the engine's component registry. Same-frame
visible to `entity.component.add`.
2. **Attachment.** `entity(id).component.add(name, data)` (or the
batch form `tools.use("entityOps", "addComponents", targets,
{ [name] = data })`) attaches an instance.
The `data` table's fields override the component's declared
defaults; omitted fields take the declared default. The `data`
values are copied into `public.*` BEFORE `awake()` runs.
3. **Lifecycle.** The hooks run in this order:
- `awake()` — same frame as attach, immediately after the data
copy. Initialise state, set up native bridges, register with
other systems. Other components on the same entity may not have
awoken yet.
- `start()` — the next frame. Every component on the entity has
completed `awake()` by then, so cross-component setup is safe
here.
- `update(dt)` — same frame as `start()`, then every frame after,
for as long as the gameplay clock is running. `fixedUpdate(dt)`
runs beside it on the fixed timestep; `editorUpdate(dt)` runs
while the clock is held. Which of them ticks is decided by
`engine.mode` AND `engine.paused` together — see **Per-frame
hooks and the two axes** below, and `man modes`.
- `onDestroy()` — when the component is removed via
`component.remove` or when the owning entity is despawned.
Release resources, drop native ECS components.
For the full list (`onEnable`, `onDisable`, `fixedUpdate`,
`editorUpdate`, `onPropertyChanged`, `onAssetReload`,
`onModuleReload`,
`onBeforeSave`, …) and the timing details, **read `man components`
— that is the canonical lifecycle reference. Generic component
patterns (declare blocks, public fields, sync, ECS bindings,
typed methods) all live there.**
4. **Public surface.** Fields declared in `public` are inspectable,
editable in the inspector, and persist with the entity. Methods are
declared on that same table with a colon —
`typed function public:method(args)` — and every one of them takes
the component ref as `self`, so a caller invokes it with a colon
too: `entity(id).component.get(name):method(args)`. That is the one
calling convention a component ref answers to: the reflection
methods `ref:fields()` / `ref:getField(name)` /
`ref:setField(name, value)` take the same spelling. A function the
chunk declares at file scope (`function helper()`) is the
component's own, reachable from its hooks under its bare name;
`public` is what the ref carries.
5. **Settings and the Inspector card.** `type.yaml` names the component's
settings schema as `settings: { schema: { instance: "init.luau",
export: "public" } }`: the `public` table IS the schema, and its values
live on each entity the component is on, so `asset.schema(componentRef)`
answers the Field descriptors and no values file exists. On an entity
the Inspector shows the component as a card whose generic `settings`
section edits those fields as typed controls, each edit checked against
the schema and written to the live component as one undoable operation.
`type.yaml` also names `inspector: { instance: "inspector.luau" }`: a
component that ships an `inspector.luau` beside its `init.luau` shapes
its card — the file returns `{ sections, replace, order }` (see
`assetTypes/README.md`, "`inspector:`"), each section's `build(ctx)`
reading `ctx.component` (the live component) and `ctx.selection.id` (the
entity). `SceneModule.component/inspector.luau` replaces `settings` with
the bound module's declared inputs.
6. **Events.** An `events` block declares typed moments the component
fires from inside — `events = { onHit = Event({ dmg =
Field.number(0, NoSync) }) }`. The owner fires with
`events.onHit:fire({ dmg = 10 })`; outside code subscribes through
the proxy's subscribe-only facade,
`entity(id).component.get(name).events.onHit:connect(fn)`
(`:once` / `:wait` too). Pass `Sync` as the event's second argument
to replicate a fire to other peers. Events let one component react
to another's moments without polling. Full reference: **`man
components`**, Events section.
7. **Hot reload.** Editing `init.luau` reloads the component
definition; live instances are reconciled with the new declaration
in-place, and each crosses one lifecycle boundary: `onDestroy()`
ends the running life, then `awake()` and `start()` construct the
one that replaces it, and `update()` resumes on the next frame.
`public` and `private` values carry across; module-level `local`s
belong to the chunk, so the new life starts with them at their
initial values and its `awake`/`start` are what fill them in.
Whatever the old life handed elsewhere — a subscription, a shared
registration, a spawned entity — is released in `onDestroy`, the
call that pairs with the construction about to run. An edit that
leaves the source byte-identical crosses no boundary at all.
A field carries its value across when something stated it: a key of
the `data` table the instance was attached with (a scene record's
data is that table), or a write made since. Every other field takes
the edited source's declared default, so changing a default reaches
every instance that left the field to it. An instance that states
its own value for a field whose default the edit changed keeps that
value, and the reload logs a `[HotReload]` warning naming the field,
the entity, the value kept and both defaults.
## Discovery
- `asset.list("component")` — every registered component (engine +
world + libraries).
- `asset.inspect("<name>")` — identity, scope, public fields,
lifecycle methods, source path, and this type README. `detail`
carries the component's declared `fields`, `methods`, and `events`,
parsed from its source — `tools.use("assets","describe", "<name>")`
renders it as markdown, so a component's events are visible without
opening its source.
- `ls /zero/source/<Name>.component` lists the files the folder holds.
- `entity(id).component.list()` — components attached to an entity.
## Per-frame hooks and the two axes
`engine.mode` (`"edit"` / `"play"`) and `engine.paused` (the gameplay
clock) are independent, and the per-frame hooks read them separately:
- `update(dt)` / `fixedUpdate(dt)` tick while **the gameplay clock is
running** — whichever mode the engine is in.
- `editorUpdate(dt)` ticks while **the clock is held** — because the
mode is `edit`, or because play is paused — and also alongside
`update` whenever edit is running.
Which gives four states, and this table is the contract:
| `engine.mode` | `engine.paused` | hooks that tick |
|---|---|---|
| `edit` | `true` | `editorUpdate` |
| `edit` | `false` | `update`, `fixedUpdate`, `editorUpdate` |
| `play` | `false` | `update`, `fixedUpdate` |
| `play` | `true` | `editorUpdate` |
Entering edit sets `paused = true` and entering play sets it back to
`false`, so a session that never writes `engine.paused` only ever sees
rows 1 and 3 — which is where the shorthand "`update` is the play tick,
`editorUpdate` is the edit tick" comes from. It is the two default rows
of a four-row table, not the rule.
**Row 2 is the one that surprises.** With `engine.paused = false` in
edit — set by hand, or by a tool that advances the gameplay clock to
sample it, such as a `capture.collage` given a `duration` — `update` and `editorUpdate`
both fire, once each, every frame. A component that declares both and
routes them to one body therefore runs that body **twice per frame**:
```luau
local function drive(dt) ... end
function update(dt) drive(dt) end
function editorUpdate(dt) drive(dt) end -- twice per frame in row 2
```
Nothing reports the doubling; it reads as an animation running at twice
its intended speed. To get exactly one tick per frame in every row, ask
which axis you meant and answer it in the body:
```luau
function update(dt) drive(dt) end
function editorUpdate(dt) if engine.paused then drive(dt) end end
```
A scene `entrypoint.luau` declaring the same two hook names is dispatched
by the scene loader, not by this table: there `update` needs play AND
running, `editorUpdate` needs edit, and play-paused ticks neither. Read
`man scenes` before carrying a component's rule to an entrypoint.
## Authoring conventions
- Annotate public fields with `--!desc` so the inspector + LSP show
documentation alongside the field name.
- Declare lifecycle hooks (`start`, `update`, `destroy`) explicitly
rather than relying on engine defaults — readers can see at a glance
what runs when.
- Keep `update(dt)` cheap — it runs every frame per instance. Move
heavy work to `start`, to a service, or behind a job (`man jobs`).
- For multi-entity batching, prefer
`tools.use("entityOps", "addComponents", targets, components)` over
a loop of `entity(id).component.add` — it takes many targets and
many components in one call.
## Common pitfalls
- **Naming.** The component identity is the folder stem with
`.component` stripped (e.g. `<Spinner>.component/` → identity
`Spinner`). Don't repeat the suffix in the identity passed to
`component.add`.
- **`init.lua` vs `init.luau`.** Either is accepted (it's a
`one_of_group: "entrypoint"`), but `.luau` is canonical. Mixing both
in one folder is invalid.
- **`update` in edit mode.** It runs whenever the gameplay clock is
running, and entering edit stops that clock — so `update` is idle in
a freshly-entered edit session and ticks again the moment anything
sets `engine.paused = false`. Use `tools.use("wld", "play")` to test
update logic under the mode it ships in; `tools.use("wld", "edit")`
to return. See **Per-frame hooks and the two axes** above.
- **Renaming.** Renaming the folder changes the identity. Update
every `component.add(<oldname>, ...)` call and the scene JSON
entries that reference it.
## Related types
- `.module` — pure-function library, no per-entity state.
- `.service` — globally-scoped behavior, no per-entity state.
- `.preset` — a captured component configuration, replayable into
`component.add` without re-typing fields.
- `.bundle` — entity hierarchies that may carry component attachments
as part of the bundle.
## In the Inspector
Fields: its execution order, hooks, and how many methods and events it declares, then the fields its `public` table declares as read-only typed controls holding their defaults, with **Check** and **Open** for its entry file. Instances: a link to every entity carrying it. What its card shows on an entity comes from the component's own `inspector.luau`.
▲ 0↑ born
# pbr
The engine's standard physically-based material — the full glTF metallic-roughness
workflow lit by the shared `@builtin::shaderModules.pbr_shading` model. This is the default surface shader a
model uses when its material doesn't specify another, and it is also registered
under the legacy alias **`standard`** (the name many materials reference).
It is a `surface()` shader: it fills a `PbrSurface` and the engine generates every
pipeline entry point and lights it. Every parameter defaults to a neutral value, so
a material that sets nothing renders as a clean dielectric; the advanced lobes only
cost anything when a material opts into them.
## Properties
| Property | Type | Default | Meaning |
|---|---|---|---|
| `base_color` | color | `[1,1,1,1]` | Albedo tint, multiplied by `base_color_texture` and vertex colour. |
| `emissive` | color | `[0,0,0,1]` | Emissive tint, multiplied by `emissive_texture` and `emissive_intensity`. |
| `emissive_intensity` | float | `1.0` | HDR multiplier on the emissive output. |
| `roughness` | range | `0.5` | Perceptual roughness, multiplied by the green channel of `roughness_metallic_texture`. |
| `metallic` | range | `0.0` | Metalness, multiplied by the blue channel of `roughness_metallic_texture`. |
| `reflectance` | range | `0.5` | Dielectric reflectance knob (neutral 0.5 → 4% F0). |
| `ior` | float | `1.5` | Index of refraction → dielectric F0 and the refraction direction. |
| `normal_scale` | float | `1.0` | Strength of the tangent-space `normal_texture`. |
| `occlusion_strength` | range | `1.0` | Blend of the `occlusion_texture` (R) into the ambient term. |
| `clearcoat` | range | `0.0` | Strength of a second dielectric clearcoat specular lobe. |
| `clearcoat_roughness` | range | `0.0` | Roughness of the clearcoat lobe. |
| `anisotropy` | float | `0.0` | Anisotropic highlight stretch along the tangent ([-1,1]). |
| `sheen_color` | color | `[0,0,0,1]` | Tint of the sheen lobe — the retroreflective rim of velvet, satin and brushed fabric. Black leaves the base untouched. |
| `sheen_roughness` | range | `0.3` | Width of the sheen lobe. |
| `iridescence` | range | `0.0` | Blend toward the thin-film response, the hue that shifts with view angle. |
| `iridescence_thickness` | float | `400.0` | Film thickness in nanometres — which wavelengths cancel and which reinforce. |
| `iridescence_ior` | float | `1.3` | Index of refraction of the film. |
| `subsurface_color` | color | `[0,0,0,1]` | Tint of the light that entered the surface, scattered beneath it and left again. Black leaves the base untouched. |
| `subsurface_radius` | float | `0.5` | How far the diffuse falloff wraps past the terminator, taken per channel against `subsurface_color`. |
| `fibre_color` | color | `[0,0,0,1]` | Tint of the second of the two highlights a hair or fur strand gives. Black leaves the base untouched. |
| `fibre_specular` | range | `0.35` | Strength of the first strand highlight, in the light's own colour. |
| `fibre_shift` | float | `0.06` | How far apart the two strand highlights sit along the fibre. |
| `fibre_roughness` | range | `0.1` | Width of the first strand highlight. |
| `transmission` | range | `0.0` | Specular transmission — refracts the environment through the surface. |
| `thickness` | float | `0.0` | Volume thickness for Beer–Lambert transmission absorption. |
| `attenuation_color` | color | `[1,1,1,1]` | Transmission absorption tint. |
| `attenuation_distance` | float | large | Beer–Lambert absorption distance. |
| `uv_scale` | float2 | `[1,1]` | UV tiling applied before every texture sample. |
| `uv_offset` | float2 | `[0,0]` | UV offset applied before every texture sample. |
| `alpha_cutoff` | range | `0.0` | Mask threshold — fragments with `alpha <` this are discarded (0 = no cutoff). |
| `double_sided` | bool | `false` | Flip the shading normal on back faces. |
| `receives_fog` | bool | `true` | Whether the screen-space fog passes (height fog, aerial perspective) reach this surface. `false` leaves an opaque deferred draw at the colour it shaded to, however deep in the medium it stands — see below. |
### Emissive authoring
The glow output is `emissive × emissive_intensity` (× the `emissive_texture`). The glow
**colour** is `emissive`, a vec; the **intensity** over it is `emissive_intensity`, a
scalar. So `{ emissive = {0.1, 0.9, 1, 1}, emissive_intensity = 5 }` is a cyan glow at 5×,
and it is these two names that `getProperties()` lists and `setProperty` writes.
A spelling another engine's convention uses for the same role — `emissive_color`,
`emission`, `emissive_tint` — is resolved onto `emissive` when the material is authored,
so content carried in from glTF, Unity or Godot reaches the property under the name this
shader declares. Where two such spellings meet on one of the two names, the shape of each
value says which of them it is: a vec is the colour and a number is the intensity, so
`{ emissive_color = {0.2, 0.8, 1, 1}, emissive = 5 }` is the same cyan glow at 5x. Two
values of one shape are one property's, and the one written under the name the shader
declares applies while the other is named in a warning.
A bare positive scalar `emissive` with no colour beside it is the "turn emission on"
shorthand and means a white glow at that intensity, so `emissive = 20` on a white material
lights up.
### The lobes over the base layer
Beyond metallic-roughness, the shading model carries six layers this material
declares. Each is gated on its own neutral value, so a material that sets none of
them shades as a plain dielectric and each one costs only where it is asked for.
| Layer | Turned on by | What it looks like |
|---|---|---|
| Clearcoat | `clearcoat > 0` | A second dielectric specular lobe on top — car paint, lacquer, a varnished surface. |
| Anisotropy | `anisotropy ~= 0` | The highlight stretches along the tangent — brushed metal, a vinyl record. |
| Sheen | `sheen_color` above black | A retroreflective rim that brightens toward grazing angles — velvet, satin, dusty cloth. |
| Iridescence | `iridescence > 0` | The specular hue shifts with view angle — an oil slick, a soap film, anodised metal, a beetle shell. Thickness picks the colours. |
| Subsurface | `subsurface_color` above black | Light wraps past the terminator and glows through thin geometry lit from behind — skin, wax, marble, a leaf. `thickness` gates how much reaches the far side. |
| Fibre | `fibre_color` above black | The two offset highlights a strand gives instead of the one a surface gives — hair, fur. Taken around the world tangent as the fibre axis. |
All six are evaluated inside the engine's own light loop, once for every light
that reaches the surface, so each one is shadowed, attenuated and cone-masked
with the base layer it sits over.
An oil-slicked wet surface is `iridescence = 1`, an `iridescence_thickness`
around 300-500 nm and a low `roughness` over a dark `base_color`. Skin is a warm
`subsurface_color` with a `subsurface_radius` near 0.5. Velvet is a `sheen_color`
at the fabric's own hue over a dark base.
### Standing outside the fog
`receives_fog = 0` declares that the screen-space fog passes — `heightFog` and
`aerialPerspective` in `@builtin::systems.atmosphere` — leave this surface at the
colour it shaded to.
The declaration travels to those passes as a per-pixel surface flag written into
the alpha of the G-buffer normal (`@builtin::shaderModules.surface_flags`). Only
the deferred G-buffer fragment entry writes that channel, so the declaration
reaches the fog from an **opaque** draw on the **deferred** path. A draw the
renderer sends through the forward fragment entry — every draw on the forward
path, and the transparent phase on either path — writes no G-buffer channel to
carry it, and its pixels take the fog like any other.
## Textures (glTF metallic-roughness set)
| Slot | Default | Channels |
|---|---|---|
| `base_color_texture` | white | RGBA albedo (sRGB). |
| `roughness_metallic_texture` | white | G = roughness, B = metallic (glTF packing). |
| `emissive_texture` | white | RGB emissive (sRGB). |
| `normal_texture` | flat-normal | Tangent-space normal map. |
| `occlusion_texture` | white | R = ambient occlusion. |
Because the engine holds the full `PbrSurface`, the capture tool's debug passes
(albedo / roughness / metallic / AO / emissive / world-normal / shadow / depth /
motion) all read real material data.
▲ 0↑ born
▲ 0↑ born
# handles
Loads the native components a saved record lists. A record (a scene entity, a bundle `entity_template` entry) states its native components beside its script components as `ecs = { { type = "<public name>", data = <row> } }`: `type` is the name the component answers to under `ecs`, and `data` is the row `ecs.snapshot` answers for it. Every field that holds a GPU handle is written as the guid of the ASSET the handle came from, because the guid outlives the session and the handle does not.
Loading resolves each of those guids to the handle the component holds (`ref:handle()`, the call a `Model` makes, which puts the asset on the GPU) and inserts one value into every entity that states the same entry.
## Exports
- `M.handleFields(typeName) -> { { rust, category } }?` - the fields of a native component that hold a GPU handle, each with the asset category its guid names (`mesh`, `material` or `texture`). nil for a name `ecs` does not hold.
- `M.value(entry) -> value` - the component value one `{ type, data }` entry states, ready for `ecs.insert`. A guid that names no asset of the field's category raises, naming the component and field.
- `M.insertAll(specs) -> failures` - inserts `{ id, type, data }` specs, one `ecs.insertMany` per distinct `(type, data)`. Answers one `{ type, ids, message }` per entry that did not land.
## Usage
```luau
local handles = require("modules.api.engine.ecs.handles")
local failed = handles.insertAll({
{ id = a, type = "Mesh", data = { mesh = meshGuid } },
{ id = b, type = "Mesh", data = { mesh = meshGuid } },
{ id = a, type = "Material", data = { id = materialGuid } },
})
for _, failure in ipairs(failed) do
log.error(failure.type .. ": " .. failure.message)
end
```
The scene loader and `bundle:instantiate` call `insertAll` after a record's script components are added.
▲ 0↑ born
# entity_records
Walks a live entity hierarchy into the flat record array the engine's entity
templates use — `{ name, original_id, parent_id, position, rotation, scale,
hidden, components }` per entity, root first, parents before children.
`bundle` composes its `entity_template` through this module; scene builds
compose their baked output through it. One record shape, one implementation.
`compose(rootId)` walks one hierarchy. `composeMany(rootIds)` walks several,
which is what a build needs — a builder may leave more than one unparented root.
Temporary descendants are pruned; an explicitly-named temporary root is kept.
▲ 0↑ born
# scene_instantiable (module)
The instantiation contract's shared implementation. An asset type opts into
scene instantiation by defining `instantiate(self, target?, opts?)` on its
behaviour `ref` table; this module carries the parts that mean the same thing
for every type, so a caller writes ONE piece of code against all of them.
```lua
local root, idMap = ref:instantiate(target?, opts?)
```
## The contract
**IN — the base opts.** `target` is an owning entity ref: the instance lands
under (or, for a hierarchy type like a bundle, onto) that owner. With no
target the type spawns a fresh root. `position`, `rotation`, `scale`, `name`
and `temporary` place that root and mean the same for every type. `rotation`
takes three numbers as pitch/yaw/roll in DEGREES, or four as a quaternion. A
type may honour more opts of its own — `params` for a type built from declared
inputs, `idMap` / `diff` / `sourceTag` for the bundle override contract — and
says so in its own `instantiate` docs.
**OUT — the two returned values.** Every type returns the same pair:
- **`root`** — the composed root, as an `EntityRef`. Composition is
**synchronous**: the root and everything the type built under it are live
the moment the call returns, so a caller can parent to it, read its
components and hand it on in the same statement. There is no frame to wait
for and no callback.
- **`idMap`** — the `originalId -> runtimeId` map naming what the composition
spawned, `{}` for a type with no addressable children. **Never nil.** A
component that re-composes the asset on every load (`Asset`) keeps this map
and passes it back in, which is how a cross-entity reference into the
composition — `SkinnedModel.skeletonRoot` pointing at a bone — survives a
reload.
The return is enforced, not merely described: `AssetRef` dispatches every
`ref:instantiate(...)` through `result` on the way out, whether or not the
type called it. A type that returns something else fails at its own call
rather than handing its caller a nil root or a map that is sometimes absent.
## Exports
- `M.root(self, target?, opts?) -> root` — stand the root entity for a type
that spawns one: parented to `target`, born temporary when
`opts.temporary`, named `opts.name` else the asset's own name, placed.
- `M.place(root, opts?) -> root` — apply the placement opts to a root the type
already has (the adopt path: a bundle exploding onto its target, a
sceneModule reconciling under one).
- `M.result(self, root, idMap?) -> (root, idMap)` — return through the
contract. Checks `root` is a live entity ref, normalises a missing map to
`{}`, and errors naming the asset type when either is something else.
- `M.isOwned(opts?) -> boolean` — whether a component drives this call. The
`Asset` / `SceneModule` components tag their own calls with `sourceTag`;
they hold the reference and re-compose on every load. An untagged call came
straight from `ref:instantiate(...)` and has no such owner.
- `M.own(root, self, idMap?) -> root` — hand an already-composed root to an
`Asset` component pointing at `self`, so the reference and its map persist
and the composition is rebuilt on the next load. The component adopts the
live composition rather than building a second one.
- `M.check(value, constraint) -> ok, reason?` — the `sceneInstantiable`
field-constraint validator, also registered under that kind on load.
## The field constraint
`Field.instantiableRef` emits `constraint = { kind = "sceneInstantiable" }`,
and this module registers the validator for it on load. Given a written value:
1. `nil` passes — the field is optional (no asset assigned).
2. Reads the value's identity — a bare string, or a table's `__ref` / `guid` /
`identity` / `name`.
3. Resolves it (`asset.resolve(identity)` — any category) and asks the
resolved ref `ref:canInstantiate()`: true iff the asset's type defines an
`instantiate` method.
This is what lets `Field.instantiableRef` accept by CAPABILITY rather than a
hardcoded type list — a new scene-instantiable asset type is accepted the
moment it defines the hook, with no edit to the field or its consumers.
▲ 0↑ born
# Asset
Promote the bundle's spawned children from temporary to permanent scene state and remove the Asset component, "baking" the bundle's contents directly into the owning scene. Subsequent saves persist the baked entities verbatim and stop replaying the bundle template.
Location: `src/lua/lib/components/Asset.component`
▲ 0↑ born
# defaults
The Preset Manager: the world's ordered `[[presets]]` rows in
`/zero/source/.world_settings`. A row names one declarer (`target`), one
preset for that declarer (`preset`) and a path glob (`filter`). When something
a declarer governs is created — an asset of an assetType, an import an
importer runs, a component added to an entity — the first row naming that
declarer whose filter matches the new thing's path gives a layer of its
settings. Rows are read only at creation.
A component row's filter reads the entity's hierarchy path (names from the root
down, joined with `/`). What a scene build's capture scope
adds takes the rows in any mode, whoever runs the build; outside a build, a
component takes a row when it is added in edit mode. A component put back from
existing content takes none: one added inside `materializing` (a saved scene, a
peer's record, a template clone, a bundle's hierarchy, a build's records), and
one a component's own code adds outside a build, on any coroutine that code
runs. A scene build records which fields the rows gave each component, and a
rebuild restates a component only when the code states it differently, so a
row edit alone changes nothing a build already placed.
Rows are written as guid envelopes and checked as a whole. A row is refused
when its target does not resolve, is consulted at no creation, or declares no
settings; when its preset does not resolve, is not a preset, or targets
another declarer; or when its filter is not a string. A write replaces the
`[[presets]]` block and keeps the rest of the file byte for byte.
## The row format
```toml
[[presets]]
target = { __ref = "8cc28fc8-ed63-48ee-83a6-15f4192e27e1", type = "importer", name = "texture" }
preset = { __ref = "0b6c1f0e-5d0c-4d5e-9d64-3c1b7f2a9e11", type = "preset", name = "pixel_art" }
filter = "sprites/**"
```
`target` and `preset` are guid envelopes: `__ref` is the guid, and `type`
and `name` tell a person reading the file what it names. A row written by
hand may name either by identity or guid; the next write rewrites it as
envelopes. `.world_settings`'s `.refs` pins each row's target and preset by
guid.
## Matching
For a new thing a declarer governs, the rows are read in order and the
first naming that declarer whose filter matches the thing's path applies;
the rest are not consulted. A filter is a glob (`preset.glob`) over the
path relative to `/source` with forward slashes, or an entity's hierarchy
path for a component row; the empty filter matches everything, and a
filter that is not a string matches nothing. A matching row whose preset
does not resolve, or targets another declarer, is the match and contributes
nothing, with one warning. A preset value the declarer's schema rejects is
left out and the rest apply.
## Sync
The rows are part of `.world_settings`, so they sync with the world like
any other file and every peer reads the same rows. A row applies where the
creation happens, when it happens: a peer's edit reaches only what is
created after it arrives, and what was created before keeps the settings it
was stamped with. A runtime asset lives for the engine process; a peer that
started before a row edit keeps its old settings on a re-created runtime
identity until it restarts.
## Exports
- `Defaults.PATH`, `Defaults.BLOCK` — the file and the array-of-tables name.
- `Defaults.SCHEMA` — the block's Field schema, `{ presets = Field.list(row) }`.
- `Defaults.rows() -> { Row }` — every row, resolved, with its problems.
- `Defaults.write(rows)` — normalise, check, and replace the rows.
- `Defaults.normalize(input) -> StoredRow`
- `Defaults.rowProblems(row) -> (problems, targetRef, presetRef)`
- `Defaults.consultedAt(declarer) -> "asset.create" | "import" | "component.add" | nil`
- `Defaults.relativePathOf(path) -> string?` — the path a filter reads.
- `Defaults.match(declarerGuid, relPath) -> (Row?, index?)`
- `Defaults.layerFor(declarerGuid, relPath) -> (values?, Match?)`
- `Defaults.check(value, constraint) -> (ok, reason)` — the `presetDefaultRow` constraint.
- `Defaults.removeAt(index)` — remove one row, writing every other row back as the file holds it.
- `Defaults.importerSettings(importerRef, sourcePath) -> (settings, Match?)`
- `Defaults.materializing(fn, ...) -> ...` — run `fn` with no row reaching a component add.
- `Defaults.entityPath(id) -> string` — the hierarchy path a component row's filter reads.
- `Defaults.forComponent(entityId, typeName, data?) -> data?` — the data a component add takes.
- `Defaults.batchLayers(targets, typeName, data?) -> { { targets, data } }` — a batch add split into runs.
- `Defaults.resolveValue(v) -> AssetRef?` — a row value as the asset it names.
- `Defaults.recordingStamps(fn, ...) -> (stamps, ...)` — run `fn` and return what the rows gave the components a capture scope added meanwhile, per `stampKey`.
- `Defaults.stampKey(entityId, componentGuid, instanceName?) -> string`
The `preset` global exposes the rows as `preset.defaults`,
`preset.setDefaults`, `preset.addDefault`, `preset.removeDefault` and
`preset.defaultFor`.
▲ 0↑ born
# json
JSON encode/decode library for Luau. Encodes Lua values to JSON
strings and decodes JSON strings back to Lua values. Used for
communication with the Rust side of the engine, the VFS read/write
bridge, and any wire-format that needs JSON.
Pure Luau, no engine dependencies. Compact and pretty-printed
encoders, plus a hand-rolled decoder that streams the input by position
so it works under WASM as well as native.
## Exports
- `Json.encode(value: any, indent?: string, currentIndent?: string) -> string` — compact encode. Functions / unknown types and NaN/Inf encode as `null`.
- `Json.encodePretty(value: any, indentStr?: string) -> string` — pretty-printed encode with sorted object keys (diff-friendly).
- `Json.encodeArgs(...: any) -> string` — encode varargs as a JSON array.
- `Json.decode(str: string) -> any` — decode a JSON string. Returns the decoded value, or `nil` + error message on failure.
## Usage
```luau
local Json = require("@builtin::modules.json")
local widget = { type = "button", text = "Click Me" }
local compact = Json.encode(widget) -- '{"text":"Click Me","type":"button"}'
local pretty = Json.encodePretty(widget, " ")
local decoded = Json.decode(compact)
local v, err = Json.decode("oops") -- v = nil, err = error message
```
## Notes
- Object keys are sorted alphabetically in both encoders for consistent
output across runs.
- Numeric keys on objects are stringified at encode time (JSON has no
numeric keys). Pure-integer key sets get detected as arrays via
`isArray` and encoded with brackets.
- NaN, +Inf, -Inf encode as `null` — JSON has no representation. Round
trips through `decode` recover `null` (Lua `nil`), so they don't
preserve.
- Unicode `\uXXXX` escapes decode to UTF-8 by hand to stay WASM-safe.
Only the BMP is covered; supplementary planes via surrogate pairs
are not.
- Functions encode as `null`.
- Decode is character-streamed — no regex, no `string.match` patterns
on the whole input — so the line-and-column information needs to be
reconstructed from the position offset.
▲ 0↑ born
# Transform
Position, rotation, and scale for an entity, as the authoring face of the transform the
entity holds. Add it to an entity to reach these fields and methods.
Public fields: `positionX/Y/Z`, `rotationX/Y/Z/W` (quaternion), `scaleX/Y/Z`. They hold
the entity's LOCAL transform — where it stands relative to its parent — and an entity
with no parent stands at those coordinates in the world.
The entity's transform is what the component reports, and the fields mirror it. Adding
the component to an entity that already stands somewhere reads that placement into the
fields and leaves the entity where it is. A field the init table names is written onto the
entity instead, so `component.add("Transform", { positionX = 1, positionY = 2, positionZ =
3 })` moves the entity to `(1, 2, 3)`; every axis the table says nothing about keeps the
value the entity already holds, so adding it with `{ scaleY = 2 }` alone stretches the
entity on Y and leaves it standing where it stood.
Writing one field moves the entity on the axis that field names. The other axes of the
same vector are read off the entity first, so a position written after something else
moved the entity keeps the move on the axes it says nothing about.
Methods take one table each and are called with a colon:
| Method | Argument |
|---|---|
| `t:setPosition({x, y, z})` | position table, `{x, y, z}` or `{x =, y =, z =}` |
| `t:addPosition({dx, dy, dz})` | offset table, added to where the entity stands now |
| `t:setRotation({x, y, z, w})` | quaternion table, `{x, y, z, w}` or `{x =, y =, z =, w =}` |
| `t:rotateAxisAngle({ax, ay, az}, angle)` | axis table, and an angle in radians |
| `t:setScale(2)` or `t:setScale({x, y, z})` | one number for a uniform scale, or a table |
| `t:lookAt(target)` | a position table, or an entity id / name / proxy |
| `t:getPosition()` / `t:getRotation()` / `t:getScale()` | none |
`setPosition`, `addPosition`, `setRotation`, `rotateAxisAngle` and `setScale` write the
entity's local transform. `rotateAxisAngle` composes onto the rotation the entity
already holds, and reads its axis in the frame that rotation is relative to — the
parent's, and the world's for an entity with no parent.
The three getters report what the entity's transform holds at the moment they are
called, so a rotation written by `lookAt` — or a position written by another script —
reads back through them.
`lookAt` is the one method on a WORLD position. It takes one target: a position table
(`{x, y, z}` or `{x =, y =, z =}`), or an entity — an id string, an entity name, or an
entity proxy — whose world position is aimed at. It answers whether the rotation was
written, and the reason when it was not. The `Camera` component's `lookAt` reads the
same slot.
```luau
local t = entity(id).component.get("Transform")
t:setPosition({ 0, 5, 0 })
t:setScale({ 2, 2, 2 })
t:rotateAxisAngle({ 0, 1, 0 }, math.pi / 2)
local pos = t:getPosition()
```
▲ 0↑ born
# assetType runtime
The machinery that reads a `<typename>.assetType/behavior.luau` and turns its
declarations into live behaviour.
A `behavior.luau` is a table of declarations — `ref` methods, `modules` shared
code, an `events` schema, a `namePattern`, `refShapes`, and the `onCreate` /
`onRegister` / `onChange` / `onDelete` / `validate` / `settingsDefaults` /
`settingsCorrections` hooks. None of it runs itself. This is the code that
runs it, and it lives here because reading a type's declarations is the
**assetType type's own behaviour** — the same way `material.assetType` owns
what a `.material` does.
## Sub-modules
| Module | Reads | Responsibility |
|---|---|---|
| `ref.module` | `ref`, `modules` | Builds every `AssetRef` envelope, attaches the shared metatable, and dispatches per-type methods and shared modules onto it. Also owns edit-mode write-behind persistence. |
| `create.module` | `onCreate`, `namePattern`, `validate` | Backs `asset.create`: resolves the type, checks the name against its pattern, runs `onCreate` (or clones `template/`), and writes the instance. `validate.module` beside it checks a creation `opts` table against the type's `createSchema`. Before running `onCreate`, resolves the type's settings chain (below) and hands the result to the hook as `opts.settings`. |
| `settings.module` | `settings`, `settingsDefaults`, `settingsCorrections` | A type's declared settings schema and where its values live (`type.yaml`'s `settings:` block). `Settings.resolveForCreate` layers the schema's own defaults, the type's own `settingsDefaults(name, opts, asked)` hook (a DEFAULT the schema can't express — a name-derived convention, a fact read off a payload the caller handed it, a value following a field `asked` shows a higher layer naming; any later layer overrides it), the Preset Manager, an importer's settings, the caller's `settings` table, then the type's own `settingsCorrections(values, name, opts)` hook (a CROSS-FIELD CONSTRAINT the schema can't express, applied last over the fully merged values). A correction over a default/preset/importer value lands silently; a correction that would override what the caller's own `settings` table explicitly asked for refuses the creation instead, naming the field and why. Neither hook is ever handed a table to mutate; each returns what it wants changed (`settingsCorrections` may also return a `{ [field]: reason }` table for that refusal message). `Settings.cloneValues` deep-copies a values table — `create.module` hands `onCreate` a copy, never the table it stamps. `Settings.valuesOf` / `writeValues` read and write an asset's own values file. |
| `typeYaml.module` | `type.yaml` | The folder a type lives in and its parsed `type.yaml`, decoded once per content version of the file. `settings.module` and `inspector.module` read their blocks through it. `about` / `aboutAt` read a type's `about.yaml` (`intent`, `function`, `usage`), leaving out any key that is empty or still starts with `[[describe]]`; `aboutRows` and `aboutMarkdown` lay the texts out for the Inspector and the `types/<name>` guide. |
| `inspector.module` | `type.yaml`'s `inspector:` block | Which inspector a type declares for its assets (`inspector: { type: ... }` or `{ instance: ... }`), and the declaration that file returns — the sections it adds, the generic ones it replaces, and their order — or the problem that kept it from loading. |
| `onRegister.module` | `onRegister` | Fires an instance's once-only registration hook — on arrival, on world load, and for `@builtin` library instances. |
| `changeDispatch.module` | `onChange`, `onDelete`, `refShapes` | Routes a VFS source write or a folder delete to the enclosing typed asset's type hooks. |
| `refShapes.module` | `refShapes`, `ref` | Publishes what every `AssetRef<category>` answers to, so a member read on an asset-typed value is checked against that category's real surface. |
| `events.module` | `events` | The per-asset event runtime behind `ref.events`. |
| `instantiable.module` | `ref.instantiate` | The instantiation contract an asset type opts into: `root` stands the root entity, `place` applies the base placement opts to one the type adopted, and `result` shapes the `(root, idMap)` every `instantiate` returns. Also registers the `sceneInstantiable` field-constraint validator. |
| `declaration.module` | `type.yaml` | The type's decoded declaration, read once and held until a write inside an `.assetType` folder; what every question about a type as a whole reads. |
| `previewRecipe.module` | `type.yaml` `preview:` | How an instance's `preview.png` is produced: which of the four recipes (`instantiate`, `image`, `surface`, `custom`) the type declares, and what that recipe needs. |
## Reaching it
```luau
local rt = require("@builtin::assetTypes.assetType.shared")
rt.ref.loadTypeBehavior("material")
```
Fields resolve lazily. The prelude requires the sub-modules directly, in
dependency order (`ref`, then `changeDispatch`, `onRegister.install()`,
`refShapes.install()`) — a table that required all six on load would take that
ordering away from it.
▲ 0↑ born
backing path · models/GltfPrimitives/gltf_primitives.bundle
Problems
Everything affecting this asset right now: its own problems, anything wrong inside it, and problems on its direct dependencies.
0problems
No problems reported on this asset or on the parts this page shows. Parts it leaves out are not counted here.
⌬ZeroMind agent review · awaiting first pass
Findings
Reviewer findings (handle · model · tag · quoted note) appear here once the per-pass review log lands. Today only the rolled-up
agent_score is exposed.usability—
did it work as advertised
quality—
authoring polish + cohesion
performance—
frame & memory budget held
agent review score
—
/ 100
awaiting first pass
usability × 0.40
+ quality × 0.35
+ performance × 0.25
± compat factor
+ quality × 0.35
+ performance × 0.25
± compat factor
Usability ratings
Did the part work as advertised when consumers tried to drop it in. Separate from upvotes: those are taste; this is "did it function".
—%no reports yet
Sign in to report whether this part worked for you.
Discussion
Scoped to this part · feeds back into the world's score.
0comments
Sign in to post.sign in
No comments yet. Be the first.