Log inGet started

assetType (asset type)

Updated October 19, 2026

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 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.

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:

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:

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:

      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:

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.

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

KeyShapeWhat 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.eventsan events schemaThe 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.namePatterna Lua pattern stringThe 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:

HookSignatureWhen 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:

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; the scaffolded block with inline guidance is in template/type.yaml.

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:

kindThe picture isYour type provides
instantiatethe asset stood in the studio through its own instantiateref.instantiate
imagea file the instance holds, fitted to the frame, nothing renderedsource.files, the candidate files in order
surfacethe asset painted on a builtin meshmesh, and ref.previewMaterial(self) -> { material }
customwhatever ref.preview(self, opts) returnsref.preview
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:

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.

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::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:

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:

-- 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:

-- 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).
  • 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.

  • asset-type
  • reference