Log inGet started
World

Builtin Library

Branchmain
Branchesmainpublish
Commit44fd9e59bd

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.

KeyWhat it controls
suffixThe folder suffix of the type's assets. Defaults to .<typename>.
primaryThe file or files that are the asset's content: what the asset resolves to when it is used.
entry_files, primary_aliasesFurther files that load as the asset or share its identity.
is_containerWhether the suffix stays in the identities of what the asset holds.
is_opaque_walkerWhether a walk of the tree stops at the asset and registers only its primary.
plural_dir, classify_extsLoose files in a folder of this name, or with these extensions, are assets of the type.
is_text_defaultWhether the asset's files are text unless stated otherwise.
broadcasts_subassetsWhether assets nested inside an asset of the type are found by their own short name.
roottrue 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_unlistedfalse 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:

CheckPasses when
non_emptythe 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."
  • intent is what the type is for in someone's game, in the words a game maker uses. Up to 800 bytes.
  • function is how it works today: what an asset of it is made of and what the engine does with it. Up to 1500 bytes.
  • usage is 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

KeyShapeWhat 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>.
eventsan events schemaEvents 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).
namePatterna Lua patternThe 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: what asset.inspect reports for the asset beyond the common record (below).
  • preview(self, opts): renders the asset's picture; it wins over the type's preview: recipe.
  • previewMaterial(self) -> { material }: the material a surface preview 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

HookSignatureWhen 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) -> filesOn 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

HookWhen it runs
settingsDefaults(name, opts, asked) -> tableOnce 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:)

kindThe picture isThe type provides
instantiatethe asset placed in the preview studio through its instantiateref.instantiate
imagea file the asset holds, fitted to the framesource: { files }, the candidate files in order
surfacethe asset painted on a meshmesh, and ref.previewMaterial
customwhatever ref.preview returnsref.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) and implementation (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 content check that fails, a described file 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 its about.yaml texts.
  • asset.resolve("<typename>", "assetType"): the type itself.