Builtin Library
- ..
- README.md22.6 KB
- about.yaml704 B
- behavior.luau11.8 KB
- inspector.luau6.6 KB
- shared.module
- template
- type.yaml7.4 KB
README.md
assetType (asset type)
An asset type says what a kind of asset is: the files an asset of the kind is
made of, the code that gives it behaviour, the interface a person uses to work
with it, and how it is found, pictured and checked. A <typename>.assetType/
folder declares a type called <typename>, and every <name>.<typename>/
folder is an asset of it. assetType is itself a type, so a type is an asset
like any other: it is created, versioned, installed and contributed the same way.
Making a type
asset.create("assetType", "<typename>") scaffolds a type. The type registers
the moment its folder is written, anywhere under /zero/source/, and from then
on <name>.<typename>/ folders are its assets. An asset of a type that is not
registered yet is not an asset, so make the type first.
A type folder is a root: ~.x in any file inside it names the type's own x,
wherever the type is installed. Keep the type's whole implementation inside its
folder and reach it that way.
What a type folder must hold is set by this type's own type.yaml, the same way
every type's type.yaml sets what its assets hold. asset.validate on a type
reports anything missing, and the scaffold produces a folder that passes.
What sets your type's schema (type.yaml)
type.yaml is the structural contract every asset of the type is checked
against. The keys below are the ones the engine reads; a key not listed here is
ignored.
| Key | What it controls |
|---|---|
suffix | The folder suffix of the type's assets. Defaults to .<typename>. |
primary | The file or files that are the asset's content: what the asset resolves to when it is used. |
entry_files, primary_aliases | Further files that load as the asset or share its identity. |
is_container | Whether the suffix stays in the identities of what the asset holds. |
is_opaque_walker | Whether a walk of the tree stops at the asset and registers only its primary. |
plural_dir, classify_exts | Loose files in a folder of this name, or with these extensions, are assets of the type. |
is_text_default | Whether the asset's files are text unless stated otherwise. |
broadcasts_subassets | Whether assets nested inside an asset of the type are found by their own short name. |
root | true makes every asset of the type a root, so ~.x inside one names that asset's own x. |
contract_ref: { field, category } | A key of the primary file that names another asset the asset is built on. |
files: { required, optional } | The files an asset holds (below). |
allow_unlisted | false reports every file the type does not declare. |
aliases_from: [{ file, key }] | Further names an asset answers to, read from its own files. |
settings: | Typed per-asset settings (below). |
inspector: | The interface people use for the type and its assets (below). |
preview: | How an asset is pictured (below). |
references: | Names of other assets the type's files carry (below). |
indexing: | What search reads for each asset (below). |
describe: | How a missing description is generated (below). |
The files an asset holds
Each entry under files.required or files.optional is
{ path, kind: file | folder, one_of_group?, content? }. * in a path matches
within one segment. Entries that share a one_of_group are satisfied by any one
of them. content lists checks on the file's bytes:
| Check | Passes when |
|---|---|
non_empty | the file has content |
json_field { key, type?, non_empty?, accept? } | the JSON holds the key (a dotted path) with that shape |
yaml_field { key, type?, non_empty?, accept? } | the same for YAML |
described { keys?, headings?, message? } | the file says what the asset is (see "Ask your assets to say what they are") |
Each check takes a message, which is what the author is told when it fails.
Settings
settings: { values, schema: { type | instance, export? } } gives every asset
typed settings. values names the file an asset's values live in; schema.type
declares one schema for the whole type, and schema.instance names a file each
asset declares its own in. asset.create writes the resolved values, and a write
to the values file reaches onChange with what changed.
The human side (inspector.luau)
A type is used by people through its Inspector, and without one the type is usable only from code. The Inspector is the type's interface for a person: for a procedural graph it is the graph editor, for a kit it is the tracking view of what the kit holds and what is still missing. Writing it is part of writing the type.
inspector: { type, instance? } in type.yaml names the files. type is the
interface for the type itself and for its assets; instance names a file an
asset may ship to add its own. Each returns:
return {
sections = { { id = "graph", title = "Graph", build = function(ctx) ... end } },
replace = { contents = { ... } }, -- replace a default section by id
order = { "graph", "contents" }, -- section order
}
build(ctx) receives the asset and builds the section. A section's title, and
the text its rows carry, is a string or an i18n.text value read from a catalog
(i18n.text("inspectors.mesh.title")), which follows the editor's language. asset.create("assetType", ...) scaffolds a working inspector.luau to start from. asset.validate on a
type that names no inspector, or names one that does not load, reports it.
Say what your type is for (about.yaml)
Every type carries an about.yaml that says what the type is for, in three
plain-language texts:
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."
intentis what the type is for in someone's game, in the words a game maker uses. Up to 800 bytes.functionis how it works today: what an asset of it is made of and what the engine does with it. Up to 1500 bytes.usageis what a person or an agent does with one. Up to 1200 bytes.
Write about the type as a whole, not one asset of it, and say what it does now.
Agents read these texts to learn when to reach for the type and how; people
searching find the type by them. The scaffold starts each text with
[[describe]] and a note on what to write; a text that still starts that way
counts as not written. services describe can write them.
They show up in asset.inspect as detail.about, in
assetTypeRef:getAbout(), at the end of the type's types/<typename> guide,
and at the top of the type's Inspector.
Ask your assets to say what they are
A type can require each asset to carry a description: a file, written by the
author or generated, saying what the asset is and what it is for. Declare it with
a described check:
files:
required:
- path: "README.md"
kind: "file"
content:
- { check: "described", message: "Say what this widget is for and where it goes." }
A file that is missing, empty or still has a line starting with [[describe]]
fails the check, and so does a YAML file whose named keys are missing or over
their max_bytes, or a Markdown file missing a named heading. asset.validate
names the asset, and a push that carries it is refused with message.
services describe finds such assets and writes their descriptions.
describe: steers what is generated:
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>"
An example is an asset of your type whose description is a true, written one,
never a placeholder and never the asset being described; with none, leave
examples empty. A type whose assets the model cannot perceive (a sound, a
motion) says so in guidance, and the description is drawn only from what the
asset's own files state.
Start the file unwritten in your template/: a heading and one line starting
with [[describe]], never example content, which every new asset would carry
verbatim. A type whose assets say what they are through their own content (a
mesh, a texture) declares no check.
The type's code (behavior.luau)
behavior.luau returns one table. These are the keys the engine reads from it;
anything else on the table is the type's own.
What the type's assets can do
| Key | Shape | What the engine does with it |
|---|---|---|
ref | { [name] = function(self, ...) } | Every function becomes a method on every asset of the type: ref:name(...). |
modules | { [name] = require(".x") } | Code the type's assets reach with asset.containing(__FILE__).modules.<name>. |
events | an events schema | Events an asset fires. Readers subscribe through ref.events.<name>:connect(...); the type fires them. |
refShapes | { [method] = function(self) -> (typeExpr, source?) } | The result type of a ref method for one particular asset (below). |
namePattern | a Lua pattern | The names asset.create accepts for the type's assets. Default ^[A-Za-z][A-Za-z0-9_]*$. |
derive | { output, module, compute, fingerprint, matches?, required?, timeout? } | A file computed from the asset when it is committed; a required one holds the commit until it lands. |
Some ref names mean the same for every type, and the engine calls them:
instantiate(self, target?, opts?) -> (root, idMap): puts the asset in a scene. Defining it is what makes the type scene-instantiable. Its return is a contract (below).inspect(self) -> detail: whatasset.inspectreports for the asset beyond the common record (below).preview(self, opts): renders the asset's picture; it wins over the type'spreview:recipe.previewMaterial(self) -> { material }: the material asurfacepreview paints.saveDefinition(self): writes the asset's in-memory edits back to its files in edit mode.
Some names are answered for every asset before the type is asked, so a ref
entry of the same name is never reached: getSource, getBytes, getText,
exists, deps, canInstantiate, canPreview, preview (which calls yours
when you define one), meta, runtime, events, typeRef, modules, and the
residency flags has_backing_asset, has_runtime_changes, cpu_resident,
gpu_resident.
When the engine calls the type
| Hook | Signature | When it runs |
|---|---|---|
onCreate | (name, opts) -> { [path] = contents } | On asset.create. Returns the files of the new asset, laid over the type's template/. Annotate opts: its type is the schema asset.create checks the caller's arguments against. A "" key writes a single loose file. |
onReplace | (dest, files) -> files | On an asset.create over an asset already at dest, after onCreate, so what the author added can be carried into the replacement. |
onRegister | (self) | Once per asset, when it first registers: when the world loads, on a live asset.create, and when an asset arrives later (installed, pulled, synced). This is what makes an asset take effect the moment it is written. |
onChange | (ref, change) | On every write to a file inside an asset of the type. change is { path, asset, type, kind, origin }, plus settings and settingsChanged for a write to the settings values file; kind is edited or seeded, origin is local or remote. Filter on change.path. Writes to one asset arrive in order, each after the hook for the one before has finished, and the hook may wait on what it reads. A write the hook makes inside its own asset arrives later like any other, so keep the hook convergent. |
onDependencyChange | (ref, change) | When a module the asset's code requires is re-run, or a name the asset carries starts resolving. change.kind is dependency. |
onDelete | (ref) | When an asset of the type leaves the world: deleted, or the world it is in is swapped away. |
validate | (ref) -> { { code, message, severity?, path? } } | On asset.validate, after the type.yaml checks, for the type's own rules. severity defaults to error. Runs on every push, so a type's rules hold at publish. A hook that fails reports validate.hook_failed. |
Settings hooks
| Hook | When it runs |
|---|---|
settingsDefaults(name, opts, asked) -> table | Once per asset.create, for values the schema's defaults do not give. |
settingsCorrections(values, name, opts) -> (corrections?, reasons?) | On every settings write. A correction to a field the writer named refuses the write, giving the reason. |
settingsDerivedDefaults(values, explicit, changed) | On a settings write, for fields derived from others. |
normalizeSettings(values, stored?) | On every read and write, to map an earlier spelling of a field to the current one. |
settingsHealed(values, stored) | On a read, to repair stored values. |
settingsSchemaProblem(schema, ref) -> string? | When an asset's own schema is read; a returned string refuses the schema. |
creationInputs ({ [opt] = true | selectorOpt }) names asset.create options
that are inputs to the type rather than settings.
Reloading
An edit to behavior.luau reaches existing assets with no restart.
How an asset is pictured (preview:)
kind | The picture is | The type provides |
|---|---|---|
instantiate | the asset placed in the preview studio through its instantiate | ref.instantiate |
image | a file the asset holds, fitted to the frame | source: { files }, the candidate files in order |
surface | the asset painted on a mesh | mesh, and ref.previewMaterial |
custom | whatever ref.preview returns | ref.preview |
preview:
kind: surface
mesh: sphere
angle: [yaw, pitch] turns the camera. The studio owns the light, the backdrop
and the framing, so an asset pictures the same in every world. A type with no
preview: that defines instantiate still answers ref:preview().
A picture is rendered when something asks to view it (the editor showing the
asset, ref:preview()), when previews are generated on request
(assets.generatePreviews), and when the asset is staged. An edit alone
renders nothing. The picture records what it was made
from, so a stale one is told from a current one.
The names your type's assets hold (references:)
Where your type's own format or API carries the name of another asset, declare it here:
references:
keys: # a top-level key of the type's files that holds a name, or a table of them
- { key: "shader", type: "shader" }
args: # an argument of a call, counting from 1
- { call: "g:node", position: 2, type: "procNode" }
fields: # a field of the options table a call takes
- { call: "fx.beam", field: "material", type: "material" }
type is the category the name resolves in. asset.declareReferenceKey,
asset.declareReferenceArg and asset.declareReferenceField declare the same
from a script.
What search reads (indexing:)
indexing: is how assets are found. Search reaches an asset only through what
its type's indexing: names, and adds nothing of its own. That includes you: an
asset indexed badly is one you cannot find later, when you are making something
else and need it. A type whose assets are never looked for says indexing: none.
indexing:
data: # text, read from the asset's files
- role: identity
extractor: verbatim
source: { file: "README.md" }
- role: interface
extractor: symbols
source: { files: ["init.luau"] }
- role: implementation
extractor: verbatim
source: { files: ["init.luau"] }
content: # the picture
- role: identity
modality: image
source: { file: "preview.png" }
facets: # what a query filters and re-ranks by
file: ".metadata"
rigged: { type: bool, from: { components: { contains: SkinnedModel } } }
roughness: { type: number, filter: range, from: { file: "mat.yaml", field: "floats.roughness" } }
Roles. Each data and content entry has a role, and a query matches in
two slots:
identity: what an asset is. For anything you look at, the picture; for anything else, the text that says what it is.capability: what it does and how it is made.interface(the signatures or shape a caller uses) andimplementation(the body) both count as capability, and a type with code usually declares both.
Ask what a person would recognise an asset of the type by, and what it does, and
point each role at the file that says it. For text, that is the asset's
description: the file your template starts with a [[describe]] block, which the
author writes or services describe fills in (see "Ask your assets to say what
they are").
data. extractor: verbatim embeds a file's text as it is; symbols embeds
the declared signatures of code. source is { file } or { files: [...] }.
Never index a file name (source: { field: name }): one
or two words carry nothing to match.
content. modality: image embeds the picture itself, so a text query
matches what the asset looks like. A type whose assets are looked at makes the
picture its identity.
facets. What a query narrows and re-ranks the matches by. file is embedded
whole, so keys nobody declared in it are still found. Each further key is
computed for every asset and states a value for every one, including false: a
file can imply "not rigged" but never say it, and a query separates rigged from
unrigged only because the unrigged ones say rigged: false. A key is
{ type: bool | number | enum, filter?: range, from }, where from is
{ file, field } (a field of one of the asset's files, dotted for nesting),
{ metadata: key }, or { components: { contains | any_outside } } (what the
asset's entities carry).
What blocks a push
A push checks every asset it carries against its type. These refuse it:
- a required file that is missing, a
contentcheck that fails, adescribedfile not written yet; - an error from the type's
validate.
Unlisted files and warnings do not. A described file blocks only a push that
changes the asset. When a push is refused, the message names each asset and what
it lacks. world.publishBlockers() and zm status read the same list without
pushing.
Typing a result from the asset (refShapes)
A ref method whose result depends on the asset (one entry per child folder, per
row of a config, per control a map declares) can only be annotated as
{ [string]: Thing }, which accepts any key. refShapes states the result for
each asset instead: a function returning a Luau type expression for that asset,
and the module whose types the expression uses. Read the asset; never run it.
-- inputMap.assetType/behavior.luau
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) is checked and map.noexisting is reported.
A per-asset type applies where the call site names which asset: a component
field whose default names it, or an annotation AssetRef<"inputMap", "<identity>">.
asset.resolve(name, category) carries only the category, and a name computed
at runtime carries nothing, so calls on those keep the declared return. The type
belongs to the call's result: index it there, or bind what you need from it
there, and the names stay checked.
require("@builtin::assetTypes.assetType.shared.refShapes").published() shows
what was published: categories (each type's method surface) and returns
(per-asset results by identity, then method). An identity missing from
returns was never published. Results are recomputed when an asset is written;
at most 64 assets of one type are read.
Putting an asset in a scene (ref.instantiate)
A caller writes one piece of code against every instantiable type, so what
instantiate returns is fixed:
local Instantiable = require("@builtin::assetTypes.assetType.shared.instantiable")
M.ref = {
instantiate = function(self, target, opts)
local root = Instantiable.root(self, target, opts)
-- compose the asset under `root`, now
return Instantiable.result(self, root, idMap)
end,
}
In. target is the entity the asset lands under, or onto for a hierarchy
type; with none, the type spawns a new root. position, rotation (three
numbers in degrees, or a quaternion), scale, name and temporary mean the
same for every type: Instantiable.root applies them to a root you make,
Instantiable.place to one you adopt. Document any options of your own in the
hook's --!arg docs.
Out. (root, idMap). root is the composed root, live when you return:
build synchronously, never defer to a component's awake. idMap maps each
original id to the spawned one, so a component that composes the asset again on
every load can pass it back in and keep references into the composition. Every
call goes through Instantiable.result, which refuses a root that is not live
and turns a missing idMap into {}.
Inspecting an asset (ref.inspect)
asset.inspect(ref) builds a record every asset shares (identity, name,
guid, source, typeName, typeDefinitionPath, scope, origin,
description, tags), then calls ref.inspect(self) for the type's own
detail:
M.ref = {
inspect = function(self)
-- read the asset's own files, never runtime state: inspect works on an asset
-- that has not been compiled, uploaded or registered
return { --[[ what makes this asset what it is ]] }
end,
}
Without it, the record is the shared part alone, and an agent inspecting the
asset learns nothing of what it is. A hook that raises leaves detail empty and
sets record.warning.
Discovery
asset.list("assetType"): every registered type.asset.inspect("<typename>"): the type, its README and itsabout.yamltexts.asset.resolve("<typename>", "assetType"): the type itself.