Log inGet started
module · drop-in viewer
asset⌬ modulemoduleprimary: init.luau·part ofmodule shared.module·originates fromworld 07158574-5…

ref

Builds the metatable every `AssetRef` (the `{ __ref, type, name, guid, identity, path }` envelope returned by `asset.resolve` / `asset.ref`) carries. Replaces the per-call C metatable that used to live in `crates/zero_scripting/src/ffi/bindings/asset.rs::attach_asset_ref_metatabl…

byzero-proxy @ DESKTOP-DB3UJOJ·posted 2mo ago
What it does

assetType.shared.ref

Builds the metatable every AssetRef (the { __ref, type, name, guid, identity, path } envelope returned by asset.resolve / asset.ref) carries. Replaces the per-call C metatable that used to live in crates/zero_scripting/src/ffi/bindings/asset.rs::attach_asset_ref_metatable.

Public API

local assetRef = require("@builtin::assetTypes.assetType.shared.ref")

-- Called by the Rust factory (push_asset_ref_handle) via
-- _G.__build_asset_ref_proxy after the envelope fields are set.
assetRef.build(envelope)        -- attaches AssetRefMT, returns the table

The module exposes no invalidation surface — discovery is registry-driven and hot-reload is transparent (see "Discovery and hot-reload" below).

Method dispatch order

ref.foo is resolved in this order:

  1. Default methodsgetSource / getBytes / getText / exists / inspect and the lazy meta property. Per-type definitions cannot override these (engine contract from gh#1889).
  2. Per-type ref table — discovered through asset.resolve(<typename>, "assetType") + require(<identity>.behavior). The lookup resolves to whichever <typename>.assetType/ folder is currently registered for the typename (built-in, user, or library-vendored) and pulls its ref methods.
  3. Nil — unknown key, same shape as the original C metatable.

Adding methods for a new asset type

Drop a behavior.luau into the type folder:

src/lua/lib/assetTypes/<typename>.assetType/
├── type.yaml      -- structural spec (existing)
├── behavior.luau      -- ref + global behaviour (new)
└── template/      -- placeholder body (existing)

behavior.luau returns:

return {
    ref = {
        --!desc Per-instance method on every `<typename>` AssetRef.
        myMethod = function(self, ...) ... end,
    },
    global = {
        -- Reserved for the asset.<typename>.* surface — not consumed
        -- yet; included so the contract from gh#1889 lands cleanly.
    },
}

Within ref methods, self is the AssetRef envelope: self.path, self.identity, self.guid, self.type, self.name are available. Engine-required fields (guid, path, identity, type) must not be redefined.

Discovery and hot-reload

Type discovery uses the asset registry, so it doesn't matter where a <typename>.assetType/ folder lives — engine built-ins under @builtin::assetTypes.<X>, user types dropped under /zero/source/<X>.assetType/, or types vendored inside an imported library all resolve identically. The dispatcher walks asset.resolve(<typename>, "assetType") → canonical identity → require <identity>.behavior on every miss; the asset registry is the cache for the resolve half and Luau's _LOADED is the cache for the require half.

Hot-reload is transparent. When a <typename>.assetType/behavior.luau is rewritten through VFS, the engine's invalidate_require_cache_for_state re-runs the module and mutates the cached _LOADED[<identity>.behavior] table in place. Dispatch reads mod.ref on every access (rather than caching a sub-pointer), so existing AssetRefs see the new methods on the very next access — no Rust-side hook into this module is needed, and no invalidation API is exposed for callers to call.

The same registry-driven path also handles type registration correctness: dropping a new <typename>.assetType/behavior.luau updates the asset registry, the next asset.resolve(<typename>, "assetType") returns the new ref, and AssetRefs of that type immediately gain the new methods.

Why Luau, not Rust

The asset surface needed per-category behaviour (material:getProperties(), bundle:instantiate(entityId), tool:run(args)) and the old shape couldn't grow without an FFI change per method. Owning the proxy in Luau means:

  • Type-specific methods ship as content (the <typename>.assetType/ folder), not engine code.
  • Adding a new asset type drops in a new folder; no Rust rebuild.
  • Methods can call any other Luau global (vfs, asset, Material, entity, etc.) directly, instead of going through narrow FFI primitives.

Consumers

  • crates/zero_scripting/src/ffi/bindings/asset.rs::attach_asset_ref_metatable — the Rust factory that pushes envelopes invokes _G.__build_asset_ref_proxy once per push.
  • Every Luau call site of asset.resolve / asset.ref / entity(id).component.X.material / any binding declared AssetRef<...> — they all receive envelopes built through this module.

Interface

What this asset declares: the schema it conforms to, what it exposes, and the rendered structured payload.

conforms to

zero/source-extract/v2

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.

wipeRuntime( ) → void

ensureModeSubscription( ) → void

ensureDeviceSubscription( ) → void

getSource(self: ?, filename: string?) → string

argtypedescription
self?
filenamestring?

exists(self: ?) → boolean

argtypedescription
self?

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.

argtypedescription
self?

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.

argtypedescription
identitystring?
pathstring?

accept(m: any) → boolean

argtypedescription
many

attempt(key: string) → void

argtypedescription
keystring

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.

argtypedescription
asset_typestring

load_type_methods(asset_type: string) → void

Per-instance method table (`behavior.luau`'s `ref`) for `asset_type`.

argtypedescription
asset_typestring

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

argtypedescription
self?

examples

if asset.resolve("Golem","dynamicAsset"):canInstantiate() then ... end

instantiable( ) → any

gated_instantiate(impl: any) → any

argtypedescription
implany

w(self: ?, target: ?, opts: ?) → void

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

argtypedescription
type_guidstring

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.

argtypedescription
asset_typestring

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.

argtypedescription
asset_typestring

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.

argtypedescription
ref?

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

argtypedescription
self?

__index(self: ?, key: ?) → void

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

argtypedescription
keystring
nownumber

persistKey(self: any) → string

argtypedescription
selfany

persistNow(key: string, ref: any) → void

argtypedescription
keystring
refany

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.

argtypedescription
errany

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.

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

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

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

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

argtypedescription
envelopeanyThe freshly-built envelope table.

examples

local r = require("modules.asset_ref").build({ type = "material", path = "/zero/source/Gold.material", ... })

Sub-parts

Everything contained inside this part. Assets are composite children (clickable cards). Files are leaf payloads. Expand any row to view its source.

2items
This part has no composite children. See the Files segment for its leaf payloads.
backing path · assetTypes/assetType.assetType/shared.module/ref.module

Problems

Everything affecting this asset right now: its own problems, anything wrong inside it, and problems on its direct dependencies.

0problems
No problems reported. This asset, its contents, and its direct deps are clean as of the latest commit.
ZeroMind agent review · awaiting first pass
Findings
Reviewer findings (handle · model · tag · quoted note) appear here once the per-pass review log lands. Today only the rolled-up agent_score is exposed.
usability
did it work as advertised
quality
authoring polish + cohesion
performance
frame & memory budget held
agent review score
/ 100
awaiting first pass
usability × 0.40
+ quality × 0.35
+ performance × 0.25
± compat factor

Usability ratings

Did the part work as advertised when consumers tried to drop it in. Separate from upvotes: those are taste; this is "did it function".

%no reports yet
Sign in to report whether this part worked for you.
Discussion

Scoped to this part · feeds back into the world's score.

0comments
Sign in to post.sign in
No comments yet. Be the first.