module AssetRef
AssetRef proxy builder. Attaches the default method metatable to
every `{ __ref, type, name, guid, identity, path }` envelope produced
by `asset.resolve` / `asset.ref`, and dispatches type-specific methods
from `<typename>.assetType/behavior.luau` so a `.material` ref carries
material-only methods (and so on).
require modules/asset_ref
about
The Rust side (`crates/zero_scripting/src/ffi/bindings/asset.rs`)
used to ship a C metatable with a fixed `__index` that knew about
`getSource` / `getBytes` / `getText` / `exists` / `inspect` / `meta`.
That shape couldn't grow without an FFI change, which meant the
per-asset-type behaviour required to make refs *useful* (read a
material's properties, instantiate a bundle, run a tool) had no
hook.
This module owns that responsibility now. It exposes one entry
point — `M.build(envelope)` — that the Rust factory invokes via
the `_G.__build_asset_ref_proxy` global the prelude installs. The
build call attaches a shared metatable whose `__index` first
serves the default method table, then falls through to the type's
own `ref` table (loaded lazily from `@builtin::assetTypes.<type>.behavior`).
The default method surface is fixed and engine-required —
per-type `ref` tables CANNOT shadow `getSource` / `getBytes` /
`getText` / `exists` or the lazy `meta` property, matching the
contract in gh#1889. `inspect` is NOT a fixed default — it falls
through to the type's own `ref.inspect(self)` (returning the
type-specific `detail` for `asset.inspect`'s dispatch), and is nil
when the type defines none.
ensureModeSubscription( ) → void
ensureDeviceSubscription( ) → void
getSource(self: ?, filename: string?) → string
| arg | type | description |
|---|
| self | ? | |
| filename | string? | |
exists(self: ?) → boolean
deps(self: ?) → void
Return this asset's outbound reference table, aggregated across
every file inside it (for composite asset folders) — same data
`asset.deps(ref)` returns. `deps` holds the references that
resolved (`{ asset_guid, origin, literal, via, line?, checksum?,
... }`), `unresolved_deps` the literals nothing answered
(`{ literal, via, reason, line? }`), and `problems` the findings
attached to the asset. An asset that references nothing returns all
three empty.
require_type_behavior(identity: string?, path: string?) → void
`<typename>.assetType/behavior.luau` returns `{ ref = { ... }, global = { ... } }`.
The `ref` table's methods get exposed on every AssetRef whose `type`
matches `<typename>`.
Discovery is registry-driven so user-defined asset types Just Work —
the assetType folder might live anywhere (`@builtin::assetTypes.<X>`,
`@<world>::types.<X>`, `/zero/source/<X>.assetType/`, inside an
imported library, …). We resolve the typename through
`asset.resolve(<typename>, "assetType")` so the lookup picks up the
canonical folder regardless of where it was registered, then
`require <identity>.behavior` to load its behaviour module.
Caching is intentionally absent at this layer:
* `asset.resolve` is a registry lookup; the registry is the cache.
When a new `<typename>.assetType/` folder lands the registry
updates and the next dispatch picks it up.
* `require` uses `_LOADED` as its cache; the engine's
`invalidate_require_cache_for_state` re-runs dirty modules and
mutates the cached table in place when a `behavior.luau` is edited.
Reading `mod.ref` on every dispatch (rather than caching a
sub-pointer) means edits to a `<typename>.assetType/behavior.luau`
ripple to existing AssetRefs automatically — no Rust-side
invalidation hook into this module is required.
* Negative results (a `<typename>` with no `behavior.luau`) are NOT
cached either. A negative cache would go stale the moment a
user drops a new `behavior.luau` into a previously-empty
`<typename>.assetType/` folder; the dispatch path is already
two `O(1)` lookups (`asset.resolve` + `require`) so paying it
each time on the rare miss is cheaper than the invalidation
dance.
Require a type's `behavior.luau` behaviour module given the type folder's
resolved identity + path. Returns its table (`{ ref?, modules?, onChange?, … }`)
or nil when the type ships no `behavior.luau`.
The entry is reachable two ways depending on where the type lives:
* Built-ins (`@builtin::assetTypes.<X>/`) register at build time as
`@builtin::assetTypes.<X>.behavior` — the identity is already
`@`-scoped, so `<identity>.behavior` is the key verbatim.
* World-defined / library-vendored types written via `vfs.write`
report a SCOPE-LESS identity (`retro.emulatorCore`). The resolver
routes bare requires through the CALLER's namespace — and this
module is `@builtin`-scoped, so a bare `<identity>.behavior` would
resolve to a `@builtin::…` key that never exists for world content.
Their behavior registers under the world pseudo-scope instead
(`@local.<identity>.behavior`, alongside the path-shaped
`@local/<path-with-/zero/-stripped>/behavior`), and `@local`
spellings resolve caller-independently — so a scope-less identity
is spelled `@local.<identity>.behavior` here, with the path form
as a fallback for types whose registry path is already known.
The behavior file is `behavior.luau`: it is named distinctly from the
type's `type.yaml` schema so the two never collapse to one identity (the
old `type.luau` shared `type.yaml`'s stem, which forced same-stem
collision handling — see gh#3529).
Both are O(1) reads against `_LOADED` / `MODULE_SOURCES`. Accept any of
the documented top-level keys — `ref` (per-instance methods),
`modules` (shared code), `onChange` (the type-level change hook from
`modules/asset_change_dispatch`), `onCreate` / `onRegister` (lifecycle
hooks), or `validate` (the semantic-validation hook `asset.validate`
layers on the structural result) — so a type that ships only one of
them still loads.
| arg | type | description |
|---|
| identity | string? | |
| path | string? | |
attempt(key: string) → void
| arg | type | description |
|---|
| key | string | |
cached_type_behavior(asset_type: string) → void
Per-instance method table (`behavior.luau`'s `ref`) for `asset_type`,
resolved by NAME via the registry. Drives the per-type methods exposed
on every `AssetRef<typename>` (see `__index`).
NOTE: name resolution is adequate for built-ins in a single project,
but is the same name-not-guid shape `ref.modules` deliberately avoids;
migrating this dispatch to the pinned `typeRef` guid is a follow-up.
The WHOLE behavior module (`{ ref, modules, events, onChange, … }`) for
`asset_type`, resolved by NAME via the registry and cached. Every reader
of a top-level behavior key goes through here so one resolve serves them
all, and a `behavior.luau` hot-reload — which mutates the cached table in
place through `_LOADED` — is picked up by re-reading the key rather than
by invalidating the cache.
| arg | type | description |
|---|
| asset_type | string | |
load_type_methods(asset_type: string) → void
Per-instance method table (`behavior.luau`'s `ref`) for `asset_type`.
| arg | type | description |
|---|
| asset_type | string | |
canInstantiate(self: ?) → boolean
Whether this asset can be instantiated into a scene — true iff its
asset type defines an `instantiate` method. Generic capability query
(no type allowlist); consumers gate on it before offering a scene path
(an `Asset.source` field, a viewport drop, a tool argument).
examples
if asset.resolve("Golem","dynamicAsset"):canInstantiate() then ... endgated_instantiate(impl: any) → any
| arg | type | description |
|---|
| impl | any | |
w(self: ?, target: ?, opts: ?) → void
| arg | type | description |
|---|
| self | ? | |
| target | ? | |
| opts | ? | |
load_type_modules_by_guid(type_guid: string) → void
The shared-module map (`behavior.luau`'s `modules`) for the type identified
by the PINNED guid `type_guid` — the `typeRef` carried on the instance's
envelope (read from its `.refs`). Resolving by guid, not the category
name, means two same-named types from different sources never collide:
each instance reaches the exact type version it was authored against.
| arg | type | description |
|---|
| type_guid | string | |
load_type_modules_by_name(asset_type: string) → void
Name-based fallback for the shared-module map. `@builtin` assets are
baked into the binary and don't ship a `.refs` sidecar — their pinned
`typeRef` is therefore nil, but the asset's `type` field (the category
name, always present on the envelope) is enough to find the canonical
type folder. Same risk profile as `load_type_methods`: two same-named
user types could shadow, but for engine built-ins that ship the type
in-tree this is deterministic.
| arg | type | description |
|---|
| asset_type | string | |
load_type_module(asset_type: string) → void
Load the FULL `behavior.luau` module table for `asset_type` (the `{ ref?,
modules?, global?, onChange?, ... }` shape) — by NAME via the registry.
Returns nil when the type ships no `behavior.luau`. This is the entry point
the asset-change dispatcher (`modules/asset_change_dispatch`) uses to
reach a type's `onChange` hook; the load_type_methods / by_guid / by_name
helpers above each return just one piece (`ref` or `modules`), but the
dispatcher needs to read `mod.onChange`, so it gets the whole table. The
second return names the `behavior.luau` that exists and raised while loading.
| arg | type | description |
|---|
| asset_type | string | |
read_meta_sidecar(ref: ?) → void
The original C metatable returned a freshly parsed `.meta` table on
every `ref.meta` access so callers always see the current
guid+checksum. Mirror that shape in Luau by routing through the
existing `asset.meta` binding — `vfs.read` of a `.meta` path is
gated off (`is_sidecar_path` filter in vfs.rs hides sidecars from
the user-facing read surface), so reading the bytes directly from
Luau is not an option. `asset.meta(ref)` is the public-API path
that bypasses that gate and parses the YAML for us.
__tostring(self: ?) → void
A ref stringifies to its identity — so `tostring(ref)`, string interpolation,
logs, and error messages read as the identity the resolved ref stands for
(`@builtin::materials.pbr`) instead of `table: 0x…`. Code that treats a
resolved ref the way it treated the identity string it replaced keeps working.
Reads the interned fields with `rawget` so it never re-enters `__index`.
__index(self: ?, key: ?) → void
| arg | type | description |
|---|
| self | ? | |
| key | ? | |
takeWriteToken(key: string, now: number) → boolean
Bring `key`'s allowance up to date and spend a write from it, answering
whether there was one to spend. An asset first seen here has its whole burst
available, which is what makes a newly-touched asset behave as it would with
no rate limit at all.
| arg | type | description |
|---|
| key | string | |
| now | number | |
persistKey(self: any) → string
| arg | type | description |
|---|
| self | any | |
persistNow(key: string, ref: any) → void
| arg | type | description |
|---|
| key | string | |
| ref | any | |
flushDuePersists( ) → void
Pay out every asset whose allowance has a write in it. Runs once per frame
while anything is pending, so what a caller held back reaches disk on the
next frame whenever the asset has been changed at a rate its allowance
covers, and at the refill rate when it has not. An asset with nothing left
to spend stays pending and is paid by the first tick after a write refills.
Clearing a key inside `pairs` is what lets the pump retire entries as it
walks them.
reportPersistFailure(err: any) → void
A type's own `saveDefinition` runs here rather than under the caller that
made the change, so a raise inside one asset's write is reported and the
remaining assets still get theirs. `persistNow` takes the key out of the
pending set before it calls, so the asset that raised is already retired.
flushPendingPersists( ) → void
ensurePersistPump( ) → boolean
The pump owns the trailing edge: it lives exactly as long as there is state
owed to disk, and returns once nothing is. `task.spawnSystem`
makes it system-owned so it keeps running while the engine is paused, which
is the state an editor spends most of its time in. Returns whether a pump is
carrying the work — a VM with no system scheduler gets `false` and the
caller writes through immediately instead.
persistInEditMode(self: any) → void
Generic edit-mode persistence hook an assetType calls when a change of
its own is meant to reach the file. In EDIT mode, flush a ref's transient
runtime overlay (`ref.runtime`) to its backing asset file by invoking the
type's own `saveDefinition(self)`, so the change syncs to peers and is
saved. Works for any assetType that defines a `saveDefinition`; whether a
given type's runtime writes route through here is that type's own
contract. The write-through is
rate-limited per asset: an asset carries an allowance of 8 writes that
refills at one per 250ms. Changes made in one frame are coalesced onto a
single re-emit, and a caller that changes a value and moves on has it on
disk a frame or two later. A caller that keeps changing the same asset
runs the allowance down to its refill rate, so over any span the asset
costs at most that allowance plus one write per 250ms, whatever cadence
the changes arrive at.
In PLAY mode this is a deliberate no-op: runtime overlays stay transient
(frame-fast) and are persisted back to the source asset on demand. An
assetType opts in simply by exposing `ref.saveDefinition`; no per-type
branching lives here.
| arg | type | description |
|---|
| self | any | Any AssetRef. |
examples
require("modules.asset_ref").persistInEditMode(matRef)flushPendingPersists( ) → void
Write out every asset whose edit-mode persistence is still coalesced,
spending no allowance and waiting on no refill. The runtime-state wipe on a
mode flip calls this first, so a change made in the last window before the
flip reaches the asset instead of being cleared with the overlay it lives
in. Call it before reading an asset's file for a value a runtime write may
have just changed.
examples
require("modules.asset_ref").flushPendingPersists()forgetRuntime(guid: string) → boolean
Forget everything a type derived from ONE asset's content — the values
it cached in `ref.runtime` off the bytes that asset used to hold. Called
when an asset's content is REPLACED under a guid live consumers already
hold: a type memoizes its parse, its GPU handle, its settings against the
content it read, and each of those describes the previous bytes the moment
the new ones land. Emptying the table in place rather than replacing it is
what makes the clear reach every holder — the runtime table is shared by
every resolver of the guid, and a type may be holding it directly.
| arg | type | description |
|---|
| guid | string | The asset's stable guid. |
examples
require("modules.asset_ref").forgetRuntime(ref.guid)loadTypeModule(asset_type: string) →
Load the full `behavior.luau` module table for an asset type
(`{ ref?, global?, onChange? }`), or nil when the type ships no
`behavior.luau`. Registry-driven resolution — same path the per-type
`ref` dispatch uses. Exposed so the asset-change dispatcher
(`modules/asset_change_dispatch`) can reach a type's `onChange`
hook without duplicating the resolution logic.
| arg | type | description |
|---|
| asset_type | string | The type name (e.g. `"dynamicAsset"`, `"material"`). |
examples
local m = require("modules.asset_ref").loadTypeModule("dynamicAsset")loadTypeBehavior(asset_type: string) →
Load an asset type's `behavior.luau` module table, reporting a
behavior that raised while loading. The first return is the module (nil
when the type ships no `behavior.luau`); the second is set when the type
HAS a `behavior.luau` that raised, and carries the require key plus the
error it raised.
A caller that runs the type's hooks — `asset.create` runs `onCreate` —
reads the second return to tell "this type declares no behavior" from
"this type's behavior is broken", which are opposite situations for the
asset it is about to write.
| arg | type | description |
|---|
| asset_type | string | The type name (e.g. `"dynamicAsset"`, `"material"`). |
examples
local mod, err = require("modules.asset_ref").loadTypeBehavior("dialogue")build(envelope: any) → any
Attach the AssetRef method metatable to an envelope table. Invoked
by the Rust factory (`push_asset_ref_handle` →
`_G.__build_asset_ref_proxy`) immediately after the six envelope
fields (`__ref`, `type`, `name`, `guid`, `identity`, `path`) have
been set, so the metatable's `__index` only ever fires for method /
property lookups, never for the literal envelope fields.
rather than relying on side-effects makes the Rust factory's
pcall-then-replace flow simpler.
| arg | type | description |
|---|
| envelope | any | The freshly-built envelope table. |
examples
local r = require("modules.asset_ref").build({ type = "material", path = "/zero/source/Gold.material", ... })