assetType (asset type)
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.assetTypesuffix 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.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, conventionallyshared.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 requires:
-- inside <typename>.assetType/behavior.luau
M.modules = { shared = require(".shared") } -- the sibling shared.module/
An instance reaches it with one location-independent line:
-- 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.
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("modules.asset_ref").loadTypeModule("<typename>") for a
system that knows your type (computeShader publishes dispatchKey that
way), and never called by the framework.
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.onRegister | (self) | Exactly once per instance, the first time it registers — on engine.onWorldLoaded for instances already in the world, and immediately on a live asset.create. Guarded by the ref's shared runtime table, so a double trigger never double-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 SYNCHRONOUSLY and a re-entrancy guard suppresses writes back into the same asset inline — but the guard does not span asynchronous work, 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 is deleted. |
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 ofasset.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.
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.
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:
deriveis instructed by this type's ownREADME.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.deriveloses 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 ascapabilitywithextractor: 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" withsay_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; the scaffolded block with inline
guidance is in 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.
-- 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:
-- 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:
-- 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:
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::modules.asset_ref_shapes").published() answers which it
is. It returns two maps — the category surfaces, and the per-asset results
keyed by identity and then by method:
local shapes = require("@builtin::modules.asset_ref_shapes").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:
-- behavior.luau
local Instantiable = require("modules.scene_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 anEntityRef. 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'sawakeand return an empty shell — a root that is not live is refused.idMap— theoriginalId -> runtimeIdmap naming what you spawned, or nil for a type with no addressable children (resultnormalises 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.
modules/scene_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:
-- 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.asset.resolve("<typename>", "assetType")— the<typename>.assetTypeasset itself (used by the validator to find the type'stype.yaml).
Related
assetTypes/README.md— the fulltype.yamlschema and validator rules.modules/scene_instantiable— the instantiation contract's shared implementation, both halves.- Every other
*.assetType/here — the concrete types built on this meta-type.