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

xr

The tracked devices of an XR session and the hand controllers: where each part of the person is, what the controllers' sticks, triggers and buttons read, the hands and their fingers (held controllers or tracked bare hands), what the hands point at, vibration, and the comfort sett…

by◐lumi·posted 7h ago
What it does

xr

The tracked devices of an XR session and the hand controllers: where each part of the person is, what the controllers' sticks, triggers and buttons read, the hands and their fingers (held controllers or tracked bare hands), what the hands point at, vibration, and the comfort settings of the person wearing the headset.

local right = xr.controller("right_hand")
if right and right.trigger > 0.8 then
    xr.pulse("right_hand", 0.6, 0.1)
end

local head = xr.pose("head")
local ray = xr.pointerRay()
if ray then
    print(ray.role, ray.origin.x, ray.direction.z)
end

Poses are read by role

A tracked pose is read by the part of the person it follows: "head", "left_hand" and "right_hand". xr.roles() lists the roles whose device the runtime locates now, xr.tracked(role) asks about one, and xr.pose(role, kind?) returns the pose in world space, or nil while the device is untracked. For a hand the pose is where it holds the controller, and kind "aim" is where the controller points. A device that follows another part of the body takes a role of its own and is read the same way.

Hands

A hand is driven by one source at a time: the controller held in it (xr.handSource(hand) reads "controller"), or, while it holds none and the runtime tracks it, hand tracking ("hand_tracking"). Otherwise the source is "none". A controller always wins over hand tracking.

local pose = xr.hand("right_hand")        -- palm, finger directions, pinch
local joints = xr.joints("right_hand")    -- the 26 joints by name
if joints then print(joints.joints.index_tip.position.y) end
print(xr.handTracking().available, xr.handTracking().reason)

xr.joints(hand) returns the 26 joints in world space: palm, wrist, then thumb_metacarpal, thumb_proximal, thumb_distal, thumb_tip, and <finger>_metacarpal, _proximal, _intermediate, _distal and _tip for index, middle, ring and little. Each joint states its position, rotation, radius and whether the runtime locates it (tracked); a joint's -Z points along its bone toward the next joint and its +Y out of the back of the hand. Where the runtime does not locate a hand that holds a controller, the joints are the model's around the controller's grip, with the fingers curled by the trigger and grip travel (modelled is true).

xr.hand(hand) is the same hand reduced to what moves an avatar's hand: the palm (the frame whose -Z points along the fingers and +Y out of the back of the hand), the wrist position and directions, 45 numbers giving the unit direction each finger bone points along in the palm's frame (finger by finger, thumb first, three bones each). pinch is the strength of the pinch of thumb and index finger, 0 apart to 1 touching, and pinching whether it is held: it begins when the gap between the fingertips' surfaces is 12 mm or less and ends at 30 mm, so it holds through the tremor of the fingers.

A hand tracked without a controller drives the game like a controller does. It is active, its pinch is its trigger (xr.controller(hand).trigger is 1 while the pinch is held), so a laser press on a panel button, a select in the editor and the pad's trigger bindings all answer it, and it points the laser along its aim pose. The aim comes from the runtime where it states one (XR_EXT_hand_interaction) and is derived from the hand's joints otherwise, a ray from an estimated shoulder through the base of the index finger; xr.hand(hand).aimSource says which, and xr.handTracking().aim says which the runtime offers. Where the runtime offers no hand tracking, xr.handTracking().reason says why and the controllers are the only hands.

Without a body the hands are drawn over each eye as a jointed model (a block for the palm, one along each bone and one at each joint), so the person always sees their hands. When a body draws a hand with its own arm (see xr.perspective) the body's hand is that hand.

A game needs nothing from this module

While a session holds input focus the controllers drive the game's input on their own:

ControllerPad
left stickleft stick (move)
right stick, horizontalright stick (look), as snap or smooth turning
right stick, verticalright stick, which look ignores in a headset
A / B (right)south / east (jump / crouch)
X / Y (left)west / north (interact)
left menu buttonstart
gripsleft and right shoulders
triggersleft and right triggers
stick clicksleft and right stick clicks (sprint on the left)

Every input map's gamepad bindings answer them unchanged, Zin.surface reports the device class it reported before the session began, and the touch overlay does not appear. The pad is connected while a controller is held and disconnected when the session loses input focus.

Turning

The look action reads the right stick's horizontal axis as discrete steps of xr.snapTurnDegrees() (45 degrees) by default: one flick of the stick is one step, and the stick has to return to rest before the next flick. Smooth turning is the opt-in comfort setting xr.setTurnMode("smooth"). The game turns its own camera through the look action and the headset follows the camera's yaw; the head tracks fully free, and the stick never tilts the view.

A step is 45 degrees at the look sensitivity every shipped camera rig defaults to (0.005 radians per look unit); a rig authored with another sensitivity turns in proportion.

Pointing

The engine's pointer hit (getPointerHitTable(), the hover and selection a mouse produces on desktop) is cast along the dominant hand's aim pose while the session holds input focus. xr.pointerRay() returns the same ray. The laser ends where that cast hits; a script that carries something to a point the cast does not know of calls xr.setPointerReach(distance) each frame, and xr.setPointerReach(nil) hands the beam back.

The laser is the game's mouse. While the session holds input focus, input.snapshot().mouse_position is the point the laser rests on (where its cast hit, else far along the ray) projected into the view the game renders from, in the game's viewport pixels, every frame and with no press: whatever reacts to the mouse hovering reacts to the laser resting on it, and mouse-move events follow it. A ray the game builds from that position (screenToRay(x, y) at the mouse, physics.raycastFromScreen) is the laser's own ray, so a hit on a mesh gives the entity, the point and the UV the laser visibly touches. A laser on a panel, or pointing out of the game's view, leaves the mouse just outside the viewport (mouse_in_viewport reads false). The press is the controller's trigger, read as before through the pad.

Panels

A panel is a surface standing in the world that shows UI and takes the controller's laser as its pointer. It shows screens of the UI, drawn into a picture of its own that the engine puts over both eyes after the world has been tonemapped, so the interface stays sharp and keeps exactly the colours its author gave it. A panel is flat or bent around the person (curve is the radius in metres of the cylinder), and its pose is measured in the world, in the room, or in the person's view. With the "tracking" anchor a panel stays where the person put it and follows the rig when the game moves or turns the player. With the "view" anchor the pose is stated in the frame at the head, turned by the head's yaw and pitch and not its roll ({ position = {x = 0, y = -0.05, z = -2.4} } is 2.4 metres ahead of where the person looks and 5 centimetres below), and the panel follows the head: it eases towards that place with a time constant of 0.12 seconds, so a small tremor of the head does not shake it, and it stands there at once when the person recenters, when the session begins and when a script states its pose or anchor. The laser points at a view-anchored panel where it is drawn, and a view-anchored panel held by its handle keeps the place it is put in the view.

xr.panels.create({
    id = "stats",
    screens = { "stats_hud" },
    width = 1.2, height = 0.7, curve = 1.5,
    pose = xr.panels.placement({ distance = 1.2, yaw = 25 }),
    anchor = "tracking",
    handle = { 0, 0, 0.6, 0.08 },
})

A panel's corners are rounded by cornerRadius, in metres along the surface (0.03 unless the panel states another; 0 leaves them square), and the picture, the frame around it and the laser's hits all stop at that outline.

The world's HUD, the shown screens its game authored (space = "content": every screen of either the "window" or the "camera" space that is not the editor's chrome, so a menu placed on the window shows in both eyes like a camera-space screen), appears with no script on the curved panel "hud", with the "view" anchor, 2.4 metres in front of where the person looks; xr.panels.autoHud(false) turns that off. The game lays its screens out for that panel: while a session shows them with no window beside it, ui.screenSize() is the HUD panel's logical size, 1280 points across, and a screen sized from it fills the panel. The HUD panel passes the laser through wherever its UI has nothing to act on.

While the laser points at a panel the world receives no pointer: the engine's pointer hit is empty and the pointing hand's trigger and vertical stick belong to the panel. The trigger presses what the laser is on, and the stick scrolls it. A press that began in the world stays the world's when the laser crosses a panel. xr.panels.hover() names the panel under the laser and the uv on it, and xr.panels.state(id) reads one panel's hover, grab and drop.

A press on a panel always reaches its UI, the handle included, and a press is a click. A panel's handle is a rectangle of its picture ({ left, top, right, bottom }, 0,0 the top left) that moves the panel by a deliberate drag: with the trigger held (or a hand's pinch), the panel is held once the laser has turned more than 6 degrees from where the press began, which is more than a controller's trigger pull turns it. At that moment the UI's press ends outside the panel, so no widget takes it for a click, and the panel follows the laser at the distance it was held, turns to face the person, and is pulled and pushed by the stick, until the trigger is released. It stays where it is put. A press on the handle that is released with the laser turned less than that clicks the widget under the point where it began, wherever the laser swayed to, and counts in xr.panels.state(id).taps, which is how a small movable panel (a point) is also a button. xr.panels.state(id) reads grabAngle, the turn in degrees that makes a press a grab, and pressTurn, how many degrees the laser has turned since the press held on the panel began (nil while none is held). A press off the handle follows the laser for the UI however far it turns. A panel created with acceptsDrops is a drop target: xr.panels.state(id).droppedOn names it when the grab ended with the laser on it, and dropTarget is true on it, and its frame is drawn green, while another panel is held over it. A panel that accepts no drops is never named.

xr.panels.grab(id, { distance?, offset? }) holds a panel with the pointing hand while its trigger is held, as if the trigger had been pressed on its handle: a list entry that was pressed and dragged past the grab angle makes the panel it stands for and grabs it, and the panel is under the laser from the next frame to the release. The press on the entry ends there without a click.

A panel created with resize = { corner?, minWidth?, minHeight?, maxWidth?, maxHeight?, corners? } (flat panels, corners "bottom" or "all") is resized by pulling the trigger on a corner's zone: the dragged corner follows the laser on the panel's plane, the opposite corner stays, and the size stops at the limits. The panel keeps its points per metre, so a larger panel lays out more content rather than larger content. xr.panels.state(id) reads onCorner, resizing and resizes; the engine marks each resize corner with three dots that light while the laser is on the corner.

A widget with a dragPayload on a panel is dragged with the trigger as it is with the mouse. xr.panels.drag() reads the drag in flight (panel, payload, label, overPanel and the pointing hand) for as long as the trigger is held, wherever the laser points, so a script that carries the payload into the world (an asset placed under the laser) follows overPanel and the release, when the reading turns nil.

A dockArea drawn on a panel hands a tab dragged out of it and released over empty space to its owner (ui.takeDockTearOffs(dockId)) instead of opening a window inside itself.

The view

The camera the game moves relative to is the rig: the game camera's position and yaw, written as the camera entity's own transform. While a session draws frames the camera entity that owns the viewport keeps the game's own pose: entity(id).position, .rotation, .eulerAngles, .worldMatrix() and the local reads are the values the game wrote, so a script that reads the camera's pose, changes it and writes it back moves the camera exactly as it does on a desktop. Its facing is the person's: .transform.forward, .right and .up are the head's directions (camera.facing(id, axis) is the same read), and the Camera component's forward, right and up are the same values. A game that aims, picks up or prompts along its camera's forward acts where the person looks, with no code for a headset. Everything parented under the camera (a carried prop, a viewmodel) is drawn and read in the world under the head. Movement written relative to the camera follows where the person looks; xr.pose("head") is the head's full pose.

The client setting xr.aim (xr.aim(), xr.setAim(aim)) chooses what the camera faces. "head" (the default) is the head's orientation. With "controller" the camera's facing (transform.forward, right and up, and the Camera component's) is the dominant hand's aim direction with the head's up, so a game that aims, picks up or prompts along its camera's forward acts where the controller points; the eyes still draw from the head, and the setting applies on the next frame.

What the game writes stays the base. Its pose, local or world, is the game's own and the rig is built from it, so a first-person look, a follow camera or a script turning or moving the camera turns and moves the base, and the head composes on top of it, with its yaw counted once. The game's view for worldToScreen, screenToRay and the laser's mouse is the game camera's lens from the head's pose, so they agree with what the person sees. When the session stops, the camera entity is drawn at the game's own pose the same frame. getLookHitTable() casts from the eye point along the camera's facing.

The body and the perspective

In play mode a headset sits in the local player's body whenever the game has one. xr.body() returns { entity, reason }: the body is the avatar the spawn system bound to the player (avatar), else the entity the game's camera follows (cameraFollow), else the entity the player's character controller moves (controller). A candidate is the body only when the game uses it: the game's camera follows it, or a character controller the local player drives moves it. An avatar neither followed nor driven (a game that plays from its own fixed camera) is not a body, and xr.perspectiveStatus().why says so. A game with no such entity (a strategy camera over many units) has no body and the game's camera is the view. The search repeats, so a character the game starts following or driving later becomes the body then.

xr.perspective() is "embodied" or "game":

  • "embodied" puts the eyes at the body's eye height straight over its root (never over an animated bone, so a walk cycle does not bob the view), with the real head free on top. The yaw is the game camera's, so turning works as it does without a headset, and the game's camera entity keeps running untouched. The body's head and what is attached to it draw for every view but the person's own eyes (other players, the desktop window under editor and a capture from another camera still see it), and its arms follow the hands.
  • "game" is the view from the game's own camera with the head free.

A world whose camera is a first-person camera is embodied and offers no choice. Any other world with a body is embodied until the person chooses: a click of the right thumbstick switches, and so does the See from setting in the client settings (clientSettings.open("vr")). The choice is the person's client setting xr.perspective, the same in every world: a world reads it (clientSettings.get("xr.perspective"), xr.perspective()) and does not change it, so xr.setPerspective is refused from world code. xr.perspectiveStatus() says which perspective is in force and why, the body found, where the eyes stand, and how much of the body is hidden from the eyes or reaching for the hands.

The body's head is hidden by drawing the body's skinned meshes a second time for the eyes alone, with the head bone and everything below it collapsed (SkinnedModel.collapsedBones), while the meshes themselves draw on the layer xrSelf, which the eyes do not draw (renderer.setLocalLayers). Parts attached below the head bone (a hat, hair) and the player's name label draw on xrSelf as well. A camera that names the render layers it draws sees the body only if it names xrSelf.

Reading the controllers

xr.controller(hand) returns a controller's inputs as of the frame last drawn: its sticks, trigger, grip and buttons. Poses come from xr.pose; they pass through the same rig as the eyes, so they move and turn with the game's camera.

Vibration

xr.pulse(hand, amplitude, seconds, frequency?) vibrates a controller. It returns false and plays nothing while no session holds input focus.

Taking the headset off

A session stays standing while the headset is off: the person can test in VR, take the headset off and work at the desktop, and put it on again to be back in VR, with no step in between. While the runtime does not show the session and reports that nobody wears the headset, the engine begins no XR frame: a runtime holds each frame of a session it does not show for its own throttle (SteamVR holds every frame's end for about 100 ms), so the frame loop pauses and the engine's own frames run at the desktop's pace. The session keeps listening to the runtime, and when the headset is worn again the frame loop runs, the runtime shows the session, and the eyes are drawn again. Leaving VR from the menu (or xr.exit()) still ends the session.

The xr observation (/zero/runtime/observations/xr) states it: reading.userPresent is what the runtime reports about the person wearing the headset (null for a runtime that reports nothing, whose session never pauses), and reading.paused is true while the frame loop is paused. xr.focused() is false while the headset is off.

Testing without hands

input.simulateXrController(hand, control, ...) and the inputSim toolbox's xr tool inject controller state where the runtime's action set enters the engine, so the pad, the turning, the pointer ray and xr.controller follow it exactly as they follow held controllers. The injected state takes effect while the session holds input focus. input.simulateXrHead(...) and the xrHead tool inject the head pose in the tracking space; the eyes move with it.

input.simulateXrSession(true) (or the engine's --xr-sim boot flag, which a headless engine takes too) starts a simulated session: the engine runs its XR frame loop over a session that needs no headset. It holds input focus from its first frame, locates a head standing at the tracking space's origin 1.6 m up and facing -Z (input.simulateXrHead moves it) with two eyes 64 mm apart through symmetric 90 degree frusta, and reports the controllers and hands input.simulateXrController states. The eyes are drawn at 1024 by 1024 pixels each and shown to no compositor. xr.focused(), xr.pose, xr.pointerRay and the panels read it as they read a headset. input.simulateXrSession(false) stops it and every read answers as it does with no session. The session starts and stops on an engine frame, so wait for xr.focused() after starting it. It runs on native engines; a headset's session that already stands is left as it is.

input.simulateXrWorn(false) takes the simulated session's headset off: the session reports it not worn and stands in the synchronized state, its runtime holds each begin and end of a frame, and the engine begins none. input.simulateXrWorn(true) puts it on again; the frame loop runs and the session takes focus once a frame has ended.

Interface

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

conforms to

zero/source-extract/v2

global xr global-types module xr The tracked devices of an XR session and the hand controllers: where each part of the person is, what the controllers' sticks, triggers and buttons read, what they point at, vibration, and the comfort settings of the person wearing the headset. Public Luau surface over the `__xr` Internal FFI namespace. require modules/api/engine/xr

roles( ) →

The roles whose tracked device the runtime locates now, in the order `"head"`, `"left_hand"`, `"right_hand"`. The head is listed while a session draws frames; a hand is listed while the session holds input focus and its controller is located.

examples

for _, role in xr.roles() do print(role) end

tracked(role: Role) → boolean

Whether the runtime locates the device holding `role` now.

argtypedescription
roleRole`"head"`, `"left_hand"` or `"right_hand"`.

examples

if xr.tracked("left_hand") then showLeftHandHint() end

pose(role: Role, kind: PoseKind?) → Pose

The pose of the device holding `role`, in world space, as of the frame last drawn: `position` in metres and `rotation` a quaternion. The pose passes through the same rig as the eyes, so it moves and turns with the game's camera. For a hand it is where the hand holds the controller, and `kind` `"aim"` is where the controller points, along its -Z axis.

argtypedescription
roleRole`"head"`, `"left_hand"` or `"right_hand"`.
kindPoseKind?`"pose"` (the default) or `"aim"`.

examples

local head = xr.pose("head"); if head then print(head.position.y) end
local aim = xr.pose("right_hand", "aim")

handSource(hand: Hand) → HandSource

What drives a hand now: `"controller"` while a controller is held in it, `"hand_tracking"` while the runtime tracks the bare hand, `"none"` otherwise and while no session holds input focus.

argtypedescription
handHand`"left_hand"` or `"right_hand"`.

examples

if xr.handSource("right_hand") == "hand_tracking" then showPinchHint() end

hand(hand: Hand) → HandPose

The pose of a driven hand: its palm in world space and the direction each finger bone points along in the palm's frame, with its pinch and where its aim comes from. A controller's hand is modelled around its grip.

argtypedescription
handHand`"left_hand"` or `"right_hand"`.

examples

local pose = xr.hand("left_hand"); if pose and pose.pinching then grab() end

joints(hand: Hand) → HandJoints

The 26 joints of a driven hand in world space, by name. They are the runtime's wherever it locates the hand (`modelled` false) and the model's around the controller's grip otherwise (`modelled` true).

argtypedescription
handHand`"left_hand"` or `"right_hand"`.

examples

local tip = xr.joints("right_hand").joints.index_tip.position

gestures( ) → Gestures

What the gestures of the bare hands read. The left palm facing the person with a pinch holds the menu button (`xr.controller("left_hand").menu`), a right-hand pinch-flick to the left or the right is one snap turn, and the left hand pointing with a pinch held aims a teleport that lands when the pinch opens. A pinch with the laser on an object grips it in the editor. Hand tracking drives the gestures; a hand holding a controller makes none.

examples

local g = xr.gestures(); if g.teleport.aiming then print(g.teleport.seconds) end

handTracking( ) → HandTracking

What the runtime offers for tracking bare hands. Where it offers none, `reason` says why and the controllers are the only hands.

examples

if not xr.handTracking().available then print(xr.handTracking().reason) end

focused( ) → boolean

Whether an XR session holds input focus, which is when the hand controllers drive the game's input. False with no session, while the system menu holds input, and while the headset is not worn.

examples

if xr.focused() then showHandHints() end

controller(hand: Hand) → Controller

What a hand controller's inputs read as of the frame last drawn. A hand tracked without a controller is active, and its `trigger` is its pinch (1 while held). The sticks run -1 to 1 with `y` positive pushed away from the person; `trigger` and `squeeze` run 0 to 1. `primary` is A on the right controller and X on the left, `secondary` is B and Y, and `menu` is the left controller's menu button. Where the controller is comes from `xr.pose(hand)`.

argtypedescription
handHand`"left_hand"` or `"right_hand"`.

examples

local c = xr.controller("right_hand"); if c and c.trigger > 0.8 then fire() end

pointerRay( ) → PointerRay

The ray the person points along, in world space: the aim pose of the dominant hand, or of the other hand while the dominant one cannot be located. The engine's pointer hit (`getPointerHitTable()`, what a mouse hover resolves on desktop) is cast along this ray while the session holds input focus, and the game's mouse (`input.snapshot().mouse_position`) is the point it rests on, seen from the game's camera; `screenToRay` at that position returns this ray.

examples

local ray = xr.pointerRay(); if ray then print(ray.role, ray.origin.x) end

setPointerReach(distance: number?) → void

Say how far along the pointer ray the laser is drawn while a script carries something to a point the engine's own cast does not know of (an asset held under the laser): the beam ends `distance` metres out. The laser over a panel still ends on the panel. Call it each frame it applies; nil hands the beam back to the engine's cast.

argtypedescription
distancenumber?Metres along the ray, above 0, or nil.

examples

xr.setPointerReach(where.distance)

pulse(hand: Hand, amplitude: number, seconds: number, frequency: number?) → boolean

Vibrate a hand controller. The pulse plays on the next frame. A request that is not a valid pulse raises; one that cannot play because no session holds input focus (or too many pulses are waiting) returns false and plays nothing.

argtypedescription
handHand`"left_hand"` or `"right_hand"`.
amplitudenumberStrength, 0 to 1.
secondsnumberLength in seconds, above 0 and up to 10.
frequencynumber?Hertz; 0 or nil lets the runtime choose.

examples

xr.pulse("right_hand", 0.6, 0.1) -- a short tap on the right hand

turnMode( ) → TurnMode

How the right stick turns the view in a headset. `"snap"` (the default) turns by one step of `xr.snapTurnDegrees()` per flick of the stick; `"smooth"` turns continuously in proportion to the stick's deflection. Either way the game turns its own camera through its look action and the headset follows the camera's yaw; the stick never tilts the view, because the head owns pitch.

examples

print(xr.turnMode())

setTurnMode(mode: TurnMode) → TurnMode

Choose how the right stick turns the view in a headset. A comfort setting of this client: it holds across scene loads, and a settings screen offering it is this call.

argtypedescription
modeTurnMode`"snap"` or `"smooth"`.

examples

xr.setTurnMode("smooth")

snapTurnDegrees( ) → number

The angle one snap-turn step turns the view by, in degrees: the person's snap angle (client setting `xr.snapAngle`), 45 by default.

examples

print(xr.snapTurnDegrees())

setSnapTurnDegrees(degrees: number) → number

Choose the angle one snap-turn step turns the view by, held to 10..180 degrees. The client settings apply the person's snap angle through it.

argtypedescription
degreesnumberDegrees per step.

examples

xr.setSnapTurnDegrees(30)

aim( ) → CameraAim

Where the game camera faces in a headset: `"head"` by default, or `"controller"`.

examples

print(xr.aim())

setAim(aim: CameraAim) → CameraAim

Choose where the game camera faces in a headset. `"head"` faces where the head does; `"controller"` faces along the dominant hand's aim direction with the head's up, so a game that aims, picks up or prompts along its camera's forward does it where the controller points. A setting of this client (`xr.aim` in the client settings): it holds across scene loads and applies on the next frame.

argtypedescription
aimCameraAim`"head"` or `"controller"`.

examples

xr.setAim("controller")

dominantHand( ) → Hand

The role of the hand whose controller points: `"right_hand"` by default.

examples

print(xr.dominantHand())

setDominantHand(hand: Hand) → Hand

Choose the hand whose controller points. A setting of this client: it holds across scene loads.

argtypedescription
handHand`"left_hand"` or `"right_hand"`.

examples

xr.setDominantHand("left_hand")

vignette( ) → number

How dark the comfort vignette grows at the edge of the view while the game moves or turns the person, 0 (none) to 1. How much of it shows follows how fast the person is moved or turned; a snap turn or a cut darkens nothing.

examples

print(xr.vignette())

setVignette(strength: number) → number

Choose how dark the comfort vignette grows while the game moves the person. A comfort setting of this client.

argtypedescription
strengthnumber0 (none) to 1.

examples

xr.setVignette(0.5)

recenter( ) → void

Centre the headset's world on where the person stands and faces now, on the next frame drawn; `xr.recenters()` rises by one.

examples

xr.recenter()

entry( ) → Entry

How this engine comes to draw to a headset: its `policy` (`"off"`, `"auto"` or `"always"`), whether an OpenXR runtime is `installed`, its `runtime` name and `kind`, whether it is `running`, and whether the person left VR (`declined`).

examples

local e = xr.entry(); if e.installed and e.running ~= "running" then print("the VR button starts", e.runtime) end

enter( ) → void

Start a session now, on the active runtime, starting the runtime when it is not running: what the VR button does. On an engine whose policy is `"off"` it does nothing. The person's to do: a call from world or agent code raises.

examples

xr.enter()

exit( ) → void

Leave VR: the session ends on the next frame. On an engine with a flat view to return to (`xr.returnsToFlat()`) the window or the page is the view again, and the engine does not enter VR by itself again until `xr.enter()` or the runtime starts anew; on a standalone headset app the runtime ends the app, as the headset's own Quit does. The person's to do: a call from world or agent code raises.

examples

xr.exit()

returnsToFlat( ) → boolean

Whether this engine has a flat view to return to when its session ends: a desktop window or a browser page. A standalone headset app (the Android engine in a headset) has none, so its menus offer Quit where a desktop offers Exit VR.

examples

if xr.returnsToFlat() then print("Exit VR goes back to the window") end

fade(opacity: number, seconds: number?, color: { number }?) → void

Run the veil over the person's whole view toward `opacity` (0 open, 1 closed) over `seconds`, in `color` (`{ r, g, b }`, 0..1). The world panels stay readable over it, so a world can be taken down behind a closed view while a panel says what comes next.

argtypedescription
opacitynumber0 (open) to 1 (closed).
secondsnumber?How long the fade runs; 0 when omitted.
color{ number }?`{ r, g, b }`; the colour the veil has when omitted.

examples

xr.fade(1, 0.35)

fadeState( ) → Fade

The veil now: its `opacity`, the `target` it runs to, whether it has arrived (`done`), its `color` and how many fades were asked for.

examples

if xr.fadeState().done then print("arrived") end

create(description: PanelDescription) → void

Make a panel. `screens` lists the screens it shows, or `space` names a space whose shown screens it shows; `width` and `height` are metres; `curve` is the radius in metres of the cylinder it is bent around (0 is flat); `pose` is where it stands, in the `anchor` space (`"world"`; `"tracking"` for a pose fixed to the room that follows the rig when the game moves or turns the player; or `"view"` for a pose in the frame at the head turned by the head's yaw and pitch and not its roll, so the panel stands that far in front of where the person looks, easing towards that place with a time constant of 0.12 seconds and standing there at once on a recenter, when the session begins and when a script states its `pose` or `anchor`); `handle` is `{ left, top, right, bottom }` in the panel's uv (0,0 top left): a press there reaches the panel's UI like any press, and holds the panel once the laser has turned more than 6 degrees from where the press began with the trigger held; the panel then follows the laser until the trigger is released, turning to face the person, and the stick pulls and pushes it. A press on the handle that is released before the laser turned that far is a click, counted in the state's `taps`. `resize` makes a flat panel resizable: the trigger pulled on a corner's zone drags that corner with the laser while the opposite corner stays, within the stated minimum and maximum size, and the panel keeps its points per metre, so a larger panel lays out more content. `passThrough` lets the laser through a panel except over something its UI can act on. Raises when a field is invalid or the id is taken.

argtypedescription
descriptionPanelDescriptionThe panel's `id` and fields.

examples

xr.panels.create({ id = "stats", screens = { "stats_hud" }, width = 1.2, height = 0.7, pose = xr.panels.placement({ distance = 1.2 }), anchor = "tracking" })

update(id: string, fields: PanelFields) → void

Change fields of a panel. A field that is invalid raises and leaves the panel as it was.

argtypedescription
idstringThe panel's id.
fieldsPanelFieldsThe fields to change; those of `xr.panels.create`.

examples

xr.panels.update("stats", { visible = false })

remove(id: string) → boolean

Remove a panel.

argtypedescription
idstringThe panel's id.

examples

xr.panels.remove("stats")

list( ) →

Every panel's description, in the order the panels were made.

examples

for _, panel in xr.panels.list() do print(panel.id, panel.width, panel.height) end

state(id: string) → PanelState

What the laser and the hand are doing with a panel: whether it is `world` (the pose the panel is drawn at in the world, nil before the first frame has been routed and, for a panel anchored to the view, until a frame that located the head has placed it), `hovered` (with the `uv` and `distance` of the laser on it), whether the laser is `onHandle`, whether it is `grabbed`, how many grabs have ended (`grabs`), how many presses on the handle ended as a click (`taps`), the turn in degrees that makes a press on the handle a grab (`grabAngle`), how many degrees the laser has turned since the press held on the panel began (`pressTurn`, nil while none is held), whether the laser is on a resize corner (`onCorner`) or a resize is running (`resizing`, with `resizes` counting those that ended), the panel the laser pointed at when the last grab ended (`droppedOn`), and `dropTarget`, true while another panel is held and the laser points at this one.

argtypedescription
idstringThe panel's id.

examples

local s = xr.panels.state("dock"); if s and s.hovered then print(s.uv.u, s.uv.v) end

hover( ) → PanelHover

The panel the laser points at: its `id`, the `uv` on it (0,0 is the top left), the `distance` along the laser, whether it is on the panel's grab handle (`onHandle`) or a resize corner (`onCorner`), and the `point` in the world.

examples

local h = xr.panels.hover(); if h then print(h.id, h.uv.u) end

drag( ) → PanelDrag

The UI drag in flight on the panels: a widget with a `dragPayload` that the trigger pulled. It reads the `panel` whose UI holds the drag, the `payload` the source declared, the `label` its ghost reads, whether the laser points at a panel now (`overPanel`) and the pointing `hand`. The reading holds for as long as the trigger is held, wherever the laser points, and is nil after the release, so a script places what the payload names where the laser points at the world. A drag in the window reads through `ui.dragState()`.

examples

local d = xr.panels.drag(); if d and not d.overPanel then print("carrying", d.payload) end

grab(id: string, options: PanelGrabOptions?) → void

Hold a panel with the pointing hand, which must be holding its trigger: the panel follows the laser `distance` metres along it (default 0.8), its centre `offset` from the held point in the panel's own axes, turning to face the person, until the trigger is released; the stick pulls and pushes it. A press the hand began on another panel ends there without a click. This is how a panel made from a press on a widget (an entry in a list) is dragged out and placed where the trigger is released. Raises when no panel has the id, the trigger is not held, or a panel is already held.

argtypedescription
idstringThe panel's id.
optionsPanelGrabOptions?`{ distance?, offset? }`.

examples

xr.panels.grab("console", { distance = 0.8, offset = { x = 0, y = -0.2, z = 0 } })

placement(options: PanelPlacementOptions?) → Pose

A pose for a `"tracking"` anchored panel in front of the person: `distance` metres ahead (default 1.5), `drop` metres below eye height (default 0.15), turned `yaw` degrees to the left (default 0), facing the person. `from` names the head it is measured from: `"recenter"` (the default), the head as it stood when the view was last recentered, or `"head"`, the head as it stands now. With `atPointer = true` the pose is `distance` metres along the laser instead, facing the person.

argtypedescription
optionsPanelPlacementOptions?`{ distance?, drop?, yaw?, from?, atPointer? }`.

examples

local pose = xr.panels.placement({ distance = 1.2, yaw = 25 })

autoHud(enabled: boolean?) → boolean

Whether the engine shows the world's HUD on the panel `"hud"`. Pass a boolean to turn that off or on.

argtypedescription
enabledboolean?Choose whether the HUD panel is made.

examples

xr.panels.autoHud(false)

embodiment( ) → any

body( ) → Body

The local player's body and how the engine found it, in order: the avatar the spawn system bound to the player (`avatar`), the entity the game's camera follows (`cameraFollow`), the entity the player's character controller moves (`controller`), whichever the game uses: the game's camera follows it or a character controller the local player drives moves it. The eyes sit in this body while the perspective is `"embodied"`. Nil when the game has no body to find (a game that plays from its own fixed camera, a strategy or top-down camera over many units), where the game's camera is the view. Found while a session draws frames in play mode.

examples

local body = xr.body(); if body then print(body.entity, body.reason) end

perspective( ) → Perspective

What the person sees from in a headset: `"embodied"` seats the eyes in the local player's body (at its head height, over its root, the real head free on top, the body's head hidden from the eyes alone and its arms following the hands), and `"game"` shows the game's own camera with the head free. A world whose camera is first person is embodied; a world with no body to find shows the game's camera; any other world is embodied unless the person chose otherwise. The right thumbstick's click switches.

examples

if xr.perspective() == "embodied" then print("in the body") end

setPerspective(perspective: Perspective) → Perspective

Choose what the person sees from: the person's client setting `xr.perspective`, the same in every world. A first-person world stays embodied and a world with no body stays on the game's camera; the choice is kept and applies where it can. The choice is the person's, made in the client settings or by the right thumbstick's click: a call from world code raises, and a world reads the choice with `xr.perspective()`.

argtypedescription
perspectivePerspective`"embodied"` or `"game"`.

examples

xr.setPerspective("game")

perspectiveStatus( ) → PerspectiveStatus

Everything about the perspective: the one in force, the kept choice, whether the person can choose, why it is what it is, the body found, where the eyes stand, and how much of the body is hidden from the eyes or reaching for the hands.

examples

print(xr.perspectiveStatus().why)
⌬ Types
Hand = "left_hand" | "right_hand"Role = "head" | "left_hand" | "right_hand"TurnMode = "snap" | "smooth"CameraAim = "head" | "controller"Vec3 = { x: number, y: number, z: number }Quat = { x: number, y: number, z: number, w: number }Pose = {PoseKind = "pose" | "aim"HandSource = "controller" | "hand_tracking" | "none"AimSource = "controller" | "runtime" | "derived" | "none"Joint = {HandJoints = {HandPose = {Gestures = {HandTracking = {Controller = {PointerRay = {Perspective = "embodied" | "game"BodyReason = "avatar" | "cameraFollow" | "controller"Body = {PerspectiveStatus = {EntryPolicy = "off" | "auto" | "always"Entry = {Fade = {PanelAnchor = "world" | "tracking" | "view"PanelResize = {PanelDescription = {PanelFields = {PanelState = {PanelHover = {PanelDrag = {PanelGrabOptions = {PanelPlacementOptions = {

Sub-parts

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

Showing up to 3 levels deep. Open a part to see what it holds further down.

34+items
·
assettype · born here
❒asset
# Module (asset type) A module is a reusable, stateless (or globally-stateful) Luau library that other code pulls in with `require()`. Modules are the unit of factored-out logic in a world: helpers, math libraries, format parsers, registries, shared algorithms. ## When to use one - You have logic you want to share across multiple components, services, scenes, or tools. - You have a registry, lookup table, or shared cache that callers all read from / write to. - You want a stable API surface (`M.fn`) other code can depend on. If the logic is bound to one entity, use a `.component`. If you want a named UI of fields rather than functions, use a `.preset`. ## Where it lives - Source: `/zero/source/.../<name>.module/` - Identity: the source path with `/` written as `.` and each segment's type suffix dropped — `/zero/source/demo/beats.module` is `demo.beats`, and `/zero/source/kit.package/beats.module` is `kit.beats`, the container's name kept and its `.package` suffix gone. `asset.create` returns this string, `asset.resolve(...).identity` reads it back, and the `require` forms below write it as `<identity>`. - Folder shape: - `init.luau` (or `init.lua`) — module body. **Required.** Returns a table (conventionally named `M`). - `README.md` — instance-level documentation. **Required.** - `.metadata` — agent-editable tags + free-form fields. **Required.** ## How to create one ```luau asset.create("module", "<name>") -- Creates: /zero/source/<name>.module/ -- init.luau (canonical `local M = {} … return M`) -- README.md (instance README template) -- `folder` places it in a subfolder of /source, and that subfolder -- leads the identity: `{ folder = "lib" }` produces `lib.<name>`. -- `into` authors it inside a resolved container (a package, a toolbox), -- whose own identity leads the same way: asset.create("module", "<name>", { folder = "lib" }) asset.create("module", "<name>", { into = asset.resolve("myPkg.package") }) ``` ## How it operates 1. **Registration.** Writing `init.luau` into a `.module/` folder indexes the asset and registers the module path with the resolver. 2. **Loading.** The first `require("<identity>")` runs the file's body and caches the returned table. Subsequent requires from anywhere return the **same table** — this is the shared-state lever for "module-local" registries. The body runs in an environment of the module's own: a name it declares without `local` is readable by the module's own functions and by no other script, so an export reaches its callers through the returned table and nothing else. Requiring a component's folder runs its chunk the same way, once, and its `update`, `start` and `public` stay in that environment; a component runs on an entity through `entity(id).component.add(name)`. 3. **Hot reload.** Editing the file re-runs the module body and folds what it returns into the table already loaded, so that table keeps its identity: a caller that captured it with `require` reads the new source's functions and values through the reference it already holds, and a later `require` returns that same table. A key the new source drops goes away; a key something set at runtime stays. The body's own `local`s start again from their initial values, so state that must outlive an edit lives in `modules.state()` (`guides { path: "core/scripting-and-tasks" }`). 4. **Identity resolution.** A module is reached by its `<identity>`, and where the caller lives decides which spelling of it resolves. For a module in a **world**, from a caller in that same world — another module, a component, a scene entrypoint, or an `execute` chunk: - `require("<identity>")` — the identity on its own, resolved against the caller's own root. It carries every folder segment, so a module created with `{ folder = "demo" }` is `require("demo.beats")`. - `require("@root::<identity>")` — the same root-relative resolution, written out. - `require(".sibling")` — a module in the same folder, by its bare name; `..name` steps up one folder, `...name` two. Logs, stack traces and hot-reload notices name that same module `@local.source.<identity>` — its key under the world source root. The `<identity>` tail of that key is the form above, so a trace reading `@local.source.demo.beats` is `require("demo.beats")`. For a module in a **library** — content under `/zero/source/libs/@<lib>/` — `@<lib>::` addresses that library's root from anywhere: `require("@builtin::modules.transform")`. Mounting a world as a library moves its files under such a root and flips the caller root to `@<lib>::`, which is what keeps the root-relative forms above resolving across the move. ## Discovery - `asset.list("module")` — every registered module. - `asset.inspect("<name>")` — public functions, source path, this type README. - `ls /zero/source/<name>.module` lists the files the folder holds. ## Authoring conventions - Return a single `M` table from `init.luau`. Top-level statements with side effects run on first require — useful for one-time initialization, dangerous if they touch the engine before it's ready. - Write exported functions as `typed function`, not `function`: the argument types are enforced at the call and the checker knows the signature, so a caller's mistake is reported where they made it. - Annotate exported functions with `--!desc` / `--!arg` / `--!return` / `--!example` so the LSP, `tools.list`, and `asset.inspect` surface them. A function with no `--!desc` is reachable only by someone already reading this file. - Declare a VALUE with `Field.<kind>(default, mode, description)`, and a table whose members are reached through a metatable with `--!members <TypeName>` — the latter gives the surface its accepted member set, its type, and its documentation from one table. See `guides { path: "core/authoring" }` for all four rules and `modules/api/engine/entity.module/members.module` for a worked member table. - Keep module state in module-local upvalues or in `modules.state()`. A name the body declares without `local` lives in the module's environment, which every run of the module shares: a function the previous run left in place reads what the reload assigned. Module-locals start again from their initial values with each hot reload; the table `modules.state()` returns is the same table on both sides of every reload. - Prefer focused modules over kitchen-sink modules. If two halves of a module have no shared state, split them. ## Common pitfalls - **Cyclic requires.** A `require` chain that loops will return the partial table (the half built before the cycle was detected). Design for one-way dependencies. - **Engine-time side effects.** Don't `entity.spawn` at module top-level — the engine may not be ready. Expose an `M.init` and call it from a scene entrypoint. - **Hot-reload + module-local cache.** A module that caches expensive computation in upvalues loses that cache on reload — fine for dev, but be aware in performance work. - **`init.lua` vs `init.luau`.** Either works, `.luau` is canonical. - **Writing one while play runs.** A `/source` write made while play is running lands on the play shadow, and a guarded play-exit discards it unless it is kept. One `.module` write shadows `init.luau`, the `README.md` and the `.metadata` together. `vfs.write(path, bytes, { durable = true })` skips the shadow, and `vfs.promotePlayShadow` keeps what is already on it — the `core/vfs` guide has the model and what each route costs. ## Related types - `.component` — for entity-bound state + lifecycle hooks. - `.service` — generates content you don't have yet (a mesh, sound, texture, …) via a metered provider. - `.tool` — for a single agent-callable function with a YAML schema. - `.package` — to group several modules + components + scenes into one shippable folder. ## In the Inspector Exports: what the module exports (name, kind, signature), parsed from its source without running it, with **Check** (the LSP's diagnostics for its entry file) and **Open**.
▲ 0↑ born
▣
module · born here
❒asset
# xr_embodiment The local player's body in a headset: which entity it is and why, the eyes seated in it, its head hidden from the eyes alone, its arms reaching for the hands, and the choice between that and the game's own camera. The public surface is `xr.body`, `xr.perspective`, `xr.setPerspective` and `xr.perspectiveStatus`; this module is what they call, and it runs the presentation while a session draws frames in play mode. ```luau local Embodiment = require("@builtin::modules.api.engine.xr_embodiment") local body = Embodiment.findBody() -- { entity, reason } or nil Embodiment.setPerspective("game") -- the person's client setting `xr.perspective` print(Embodiment.status().why) ``` ## What it does each frame 1. Finds the body in order: the avatar bound to the local player, the entity the game's camera follows, the entity the local player's character controller moves. A candidate is the body only when the game uses it (`usedByGame`): the game's camera follows it or a character controller the local player drives moves it. The search repeats every `BODY_RECHECK_SECONDS`. 2. States the eyes' point to the engine (`__xr.setRigPosition`): the body's root, the offset the eyes keep from it, and the body's eye height straight up. 3. Draws the body's skinned meshes a second time for the eyes with the head bone collapsed (`SkinnedModel.collapsedBones`), on the layer `xrEyes`; the meshes themselves draw on `xrSelf`, which the eyes do not draw. Parts below the head bone (a hat, hair) and name labels draw on `xrSelf` alone. 4. Adds an `IKRig` to the body with a hand target for each hand, and follows each hand (`xr.hand`) with the matching arm: the target stands at the wrist and turns to the palm, so the arm reaches the wrist and the hand bone takes the hand's orientation. A hand holding a controller turns with the controller's grip; a tracked bare hand turns with its palm. 5. States the hand's finger directions (`xr.hand(role).directions`) to the rig, which points each finger bone of the avatar's hand along its direction. A hand holding a controller curls its fingers around the grip by the trigger and grip travel. The targets are synced entities and the rig's fields sync, so the players who see the body see the same hands; a still hand states nothing new. 6. Follows the head with the body (`xr_body`): real steps carry it through the character controller, the torso turns toward the head's way, the spine bends and the hips move to the head, and the legs step so the feet stay planted. The eyes stay where the real head is. 7. Reads a click of the right thumbstick as the switch between the two perspectives. ## Testing without a headset `setInput(source)` replaces the tracked devices with a table of `drawing()`, `pose(role)`, `controller(hand)` and optionally `hand(role)` (the shape of `xr.hand`); `tick(dt)` runs one frame. `setInput(nil)` reads the session again. ## What the person sees of their hands `tickHands(dt)` runs in every mode while a session draws frames. It shows the hand and controller models (`xr_models`; a hand the body draws with its own arm has no model), draws the arc of a teleport (`xr_gestures`) and states the layers the eyes draw: `xrSelf` is never drawn by the eyes and `xrEyes` always is. `bodyHands()` says which hands the body draws.
▲ 0↑ born
·
assettype · born here
❒asset
# 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. | Key | What it controls | |---|---| | `suffix` | The folder suffix of the type's assets. Defaults to `.<typename>`. | | `primary` | The file or files that are the asset's content: what the asset resolves to when it is used. | | `entry_files`, `primary_aliases` | Further files that load as the asset or share its identity. | | `is_container` | Whether the suffix stays in the identities of what the asset holds. | | `is_opaque_walker` | Whether a walk of the tree stops at the asset and registers only its primary. | | `plural_dir`, `classify_exts` | Loose files in a folder of this name, or with these extensions, are assets of the type. | | `is_text_default` | Whether the asset's files are text unless stated otherwise. | | `broadcasts_subassets` | Whether assets nested inside an asset of the type are found by their own short name. | | `root` | `true` 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_unlisted` | `false` 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: | Check | Passes when | |---|---| | `non_empty` | the 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: ```luau 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: ```yaml 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: ```yaml 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: ```yaml 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 | Key | Shape | What 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>`. | | `events` | an events schema | Events 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). | | `namePattern` | a Lua pattern | The 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 | Hook | Signature | When 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) -> files` | On 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 | Hook | When it runs | |---|---| | `settingsDefaults(name, opts, asked) -> table` | Once 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:`) | `kind` | The picture is | The type provides | |---|---|---| | `instantiate` | the asset placed in the preview studio through its `instantiate` | `ref.instantiate` | | `image` | a file the asset holds, fitted to the frame | `source: { files }`, the candidate files in order | | `surface` | the asset painted on a mesh | `mesh`, and `ref.previewMaterial` | | `custom` | whatever `ref.preview` returns | `ref.preview` | ```yaml 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: ```yaml 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`. ```yaml 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. ```lua -- 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: ```lua 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`: ```lua 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.
▲ 0↑ born
▣
module · born here
❒asset
# luau_introspect (module) Parses Luau/asset source TEXT into structured declarations. Pure string/pattern parsing (`string.find` / `string.match` / `string.gmatch`) over the literal source — no compiling, no VM spawn, no runtime state, no engine FFI. Given source text, it extracts what is written in the file. This is the shared parser every per-assetType `M.ref.inspect` hook uses to turn a component/module/tool's own source into the structured record `asset.inspect` returns. ## Exports - `docstrings(src) -> DocMap` — scans `--!desc` / `--!arg` / `--!return` / `--!example` comment runs and binds each run to the name of the declaration on the next non-comment line (the identifier after `function`, `public:`, `local function`, or `M.`). - `publicFields(src) -> { FieldEntry }` — parses `public = { name = Field.<kind>(default, mode), ... }` table-literal entries and module-scope `public.<name> = Field.<kind>(...)` assignments. - `events(src) -> { EventEntry }` — parses `events = { name = Event(payloadSchema?, syncMode?), ... }` table-literal entries. - `methods(src) -> { MethodEntry }` — parses `function public:<name>(...)` / `typed function public:<name>(...)` declarations (params + optional return type). - `lifecycleHooks(src, catalog) -> { string }` — top-level `function <name>(` declarations whose name is in the caller-supplied `catalog` array. - `moduleExports(src) -> { ExportEntry }` — parses `M.<name> = function` assignments, `function M.<name>(...)` declarations, and a trailing `return { foo = foo, ... }` export table. - `forSource(src, computeFn) -> any` — memoises `computeFn(src)` keyed on `src` itself in a module-local table. The same source text returns the cached result without re-running `computeFn`; different source text recomputes. Keyed on the source content (not an asset checksum) because composite/container assets (component folders, scene folders, etc.) all hash to the empty-string checksum — a checksum-keyed cache collapses every such asset's detail onto whichever one was inspected first. ## Usage ```luau local Introspect = require("modules.luau_introspect") local src = self:getInitScript() local detail = Introspect.forSource(src, function(s) return { fields = Introspect.publicFields(s), events = Introspect.events(s), methods = Introspect.methods(s), hooks = Introspect.lifecycleHooks(s, lifecycleCatalog), } end) ``` ## Notes - Every function is a pure string→table transform: same source text in, same structured table out, every time. No reads of live component state, no entity/world queries. - Structural scanning runs over a comment- and string-aware **mask** of the source (a same-length copy where comment bodies and string-literal contents are blanked to spaces, delimiters and newlines preserved). Pattern matches and brace/paren balancing run on the mask — so a brace, `Field.`, `Event(`, or a whole entry that appears inside a comment or a string literal never registers — while captured values are sliced from the original source at the same (aligned) indices. This makes the parser robust to apostrophes in comments, commented-out entries, string defaults containing `}` / `Field.` / `Event(`, and `[[ ]]` long strings. - `default` / `category` are raw trimmed source-text slices (with any trailing comment stripped), not evaluated values. - For a **ref constructor** (`Field.assetRef` / `dataRef` / `resource` / `componentRef`) the FIRST positional argument is the category / contract / componentType selector, captured under `category`; the SECOND argument is captured under `default`. - For every **other** Field kind the first argument is the `default` and `category` is nil. - A trailing `Sync` / `NoSync` identifier is captured as `sync`, tolerating a trailing comma or a trailing comment after the last argument. - Field/Event entries whose key is a computed or indirect expression (`[expr] = Field.number(...)`) are omitted — only literal identifier keys are recognised. - `methods` captures a `...` variadic parameter (as `name = "..."`), with its `: <type>` annotation when present. - A `--!desc` written above a field/event declaration (`<name> = Field.<kind>(...)` / `<name> = Event(...)`) or a `public.<name> =` assignment binds to that name in `docstrings`, and is folded into the corresponding `publicFields` / `events` entry's `desc`. - `lifecycleHooks` never hardcodes the lifecycle-callback catalog; the caller passes the list of names to match against. - `forSource`'s memo is bounded (oldest-key eviction) so a long session touching many distinct asset versions can't grow it without limit.
▲ 0↑ born
▣
module · born here
❒asset
# 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 ```lua 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 methods** — `getSource` / `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: ```lua 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.
▲ 0↑ born
▣
module · born here
❒asset
# yaml.module YAML decode + encode for authored engine content. Pure Luau, no engine dependencies — a general parsing library, not data-system plumbing. ```luau local Yaml = require("@builtin::modules.yaml") local doc = Yaml.decode(vfs.read(path)) vfs.write(path, Yaml.encode(doc)) ``` ## Supported subset - Block mappings (`key: value`) and block sequences (`- item`), nested to any depth. - Compact sequence-of-mapping entries (`- path: x` / ` kind: y`). - Flow collections — `{ a: 1, b: 2 }` and `[1, 2, 3]` — single line. - Plain scalars with type inference: `~` / `null` → nil, `true` / `false` → boolean, numeric literals → number, everything else → string. - Single- and double-quoted strings (`''` escapes a literal quote inside a single-quoted string; `\n` `\t` `\r` `\\` `\"` inside a double-quoted one). A quote opens a quoted scalar where a scalar begins — after `key:`, after a `- ` entry marker, or inside a flow collection — so a plain scalar carries an apostrophe as data (`note: the world's rules`). Quoted keys are read in flow mappings. - `#` comments — honored outside quotes, so a `#` inside a quoted string is kept as data. - Literal (`|`) and folded (`>`) block scalars, with a chomping indicator (`-` strips the final newline, `+` keeps every trailing blank line, none keeps one newline) and an indentation indicator (`1`-`9`: the content's indent past the parent's), in either order (`|-`, `>+`, `|2`, `>1-`). A block scalar's content is literal text: quotes (balanced or not), `#`, `key: value` and `---` inside it are data. - An optional leading `---` document marker. ## Loud-error contract Constructs outside the subset above raise instead of misparsing: anchors/aliases (`&`, `*`), tags (`!`), directives (`%`), multi-document streams (a second `---`), a block-scalar header it cannot read (`|x`, `|-+`, `|0`), tab indentation, and inconsistent indentation (both block structure and block-scalar bodies). Every error carries the offending line number (`yaml: line N: ...`), so a bad config points straight at the line to fix. Tab indentation is rejected everywhere — including block-scalar body lines whose leading whitespace contains a tab. A tab after the first non-space character of a line is content and passes through. ## API - `Yaml.decode(text: string): any` — decode a YAML document into a Luau value (table, scalar, or nil for an empty document). Raises on malformed input or an unsupported construct. - `Yaml.decodeWithComments(text: string): (any, boolean)` — decode as `decode` does, and report whether the text carries a comment by the decoder's own rule: a `#` at a line's start or after whitespace, outside a quoted scalar and outside a block scalar. Re-encoding the value drops such comments, so an editor that writes a document back reads this first. - `Yaml.encode(value: table): string` — encode a Luau table as YAML (block style, two-space indent, alphabetically sorted keys). Raises on values YAML can't represent (functions, userdata, non-string mapping keys). A top-level empty table encodes to an empty document, which decodes back to `nil` (the YAML empty-document ambiguity).
▲ 0↑ born
▣
module · born here
❒asset
# json JSON encode/decode library for Luau. Encodes Lua values to JSON strings and decodes JSON strings back to Lua values. Used for communication with the Rust side of the engine, the VFS read/write bridge, and any wire-format that needs JSON. Pure Luau, no engine dependencies. Compact and pretty-printed encoders, plus a hand-rolled decoder that streams the input by position so it works under WASM as well as native. ## Exports - `Json.encode(value: any, indent?: string, currentIndent?: string) -> string` — compact encode. Functions / unknown types and NaN/Inf encode as `null`. - `Json.encodePretty(value: any, indentStr?: string) -> string` — pretty-printed encode with sorted object keys (diff-friendly). - `Json.encodeArgs(...: any) -> string` — encode varargs as a JSON array. - `Json.decode(str: string) -> any` — decode a JSON string. Returns the decoded value, or `nil` + error message on failure. ## Usage ```luau local Json = require("@builtin::modules.json") local widget = { type = "button", text = "Click Me" } local compact = Json.encode(widget) -- '{"text":"Click Me","type":"button"}' local pretty = Json.encodePretty(widget, " ") local decoded = Json.decode(compact) local v, err = Json.decode("oops") -- v = nil, err = error message ``` ## Notes - Object keys are sorted alphabetically in both encoders for consistent output across runs. - Numeric keys on objects are stringified at encode time (JSON has no numeric keys). Pure-integer key sets get detected as arrays via `isArray` and encoded with brackets. - NaN, +Inf, -Inf encode as `null` — JSON has no representation. Round trips through `decode` recover `null` (Lua `nil`), so they don't preserve. - Unicode `\uXXXX` escapes decode to UTF-8 by hand to stay WASM-safe. Only the BMP is covered; supplementary planes via surrogate pairs are not. - Functions encode as `null`. - Decode is character-streamed — no regex, no `string.match` patterns on the whole input — so the line-and-column information needs to be reconstructed from the position offset.
▲ 0↑ born
▣
module · born here
❒asset
# assetType runtime The machinery that reads a `<typename>.assetType/behavior.luau` and turns its declarations into live behaviour. A `behavior.luau` is a table of declarations — `ref` methods, `modules` shared code, an `events` schema, a `namePattern`, `refShapes`, and the `onCreate` / `onRegister` / `onChange` / `onDelete` / `validate` / `settingsDefaults` / `settingsCorrections` hooks. None of it runs itself. This is the code that runs it, and it lives here because reading a type's declarations is the **assetType type's own behaviour** — the same way `material.assetType` owns what a `.material` does. ## Sub-modules | Module | Reads | Responsibility | |---|---|---| | `ref.module` | `ref`, `modules` | Builds every `AssetRef` envelope, attaches the shared metatable, and dispatches per-type methods and shared modules onto it. Also owns edit-mode write-behind persistence. | | `create.module` | `onCreate`, `namePattern`, `validate` | Backs `asset.create`: resolves the type, checks the name against its pattern, runs `onCreate` (or clones `template/`), and writes the instance. `validate.module` beside it checks a creation `opts` table against the type's `createSchema`. Before running `onCreate`, resolves the type's settings chain (below) and hands the result to the hook as `opts.settings`. | | `settings.module` | `settings`, `settingsDefaults`, `settingsCorrections` | A type's declared settings schema and where its values live (`type.yaml`'s `settings:` block). `Settings.resolveForCreate` layers the schema's own defaults, the type's own `settingsDefaults(name, opts, asked)` hook (a DEFAULT the schema can't express — a name-derived convention, a fact read off a payload the caller handed it, a value following a field `asked` shows a higher layer naming; any later layer overrides it), the Preset Manager, an importer's settings, the caller's `settings` table, then the type's own `settingsCorrections(values, name, opts)` hook (a CROSS-FIELD CONSTRAINT the schema can't express, applied last over the fully merged values). A correction over a default/preset/importer value lands silently; a correction that would override what the caller's own `settings` table explicitly asked for refuses the creation instead, naming the field and why. Neither hook is ever handed a table to mutate; each returns what it wants changed (`settingsCorrections` may also return a `{ [field]: reason }` table for that refusal message). `Settings.cloneValues` deep-copies a values table — `create.module` hands `onCreate` a copy, never the table it stamps. `Settings.valuesOf` / `writeValues` read and write an asset's own values file. | | `typeYaml.module` | `type.yaml` | The folder a type lives in and its parsed `type.yaml`, decoded once per content version of the file. `settings.module` and `inspector.module` read their blocks through it. `about` / `aboutAt` read a type's `about.yaml` (`intent`, `function`, `usage`), leaving out any key that is empty or still starts with `[[describe]]`; `aboutRows` and `aboutMarkdown` lay the texts out for the Inspector and the `types/<name>` guide. | | `inspector.module` | `type.yaml`'s `inspector:` block | Which inspector a type declares for its assets (`inspector: { type: ... }` or `{ instance: ... }`), and the declaration that file returns — the sections it adds, the generic ones it replaces, and their order — or the problem that kept it from loading. | | `onRegister.module` | `onRegister` | Fires an instance's once-only registration hook — on arrival, on world load, and for `@builtin` library instances. | | `changeDispatch.module` | `onChange`, `onDelete`, `refShapes` | Routes a VFS source write or a folder delete to the enclosing typed asset's type hooks. | | `refShapes.module` | `refShapes`, `ref` | Publishes what every `AssetRef<category>` answers to, so a member read on an asset-typed value is checked against that category's real surface. | | `events.module` | `events` | The per-asset event runtime behind `ref.events`. | | `instantiable.module` | `ref.instantiate` | The instantiation contract an asset type opts into: `root` stands the root entity, `place` applies the base placement opts to one the type adopted, and `result` shapes the `(root, idMap)` every `instantiate` returns. Also registers the `sceneInstantiable` field-constraint validator. | | `declaration.module` | `type.yaml` | The type's decoded declaration, read once and held until a write inside an `.assetType` folder; what every question about a type as a whole reads. | | `previewRecipe.module` | `type.yaml` `preview:` | How an instance's `preview.png` is produced: which of the four recipes (`instantiate`, `image`, `surface`, `custom`) the type declares, and what that recipe needs. | ## Reaching it ```luau local rt = require("@builtin::assetTypes.assetType.shared") rt.ref.loadTypeBehavior("material") ``` Fields resolve lazily. The prelude requires the sub-modules directly, in dependency order (`ref`, then `changeDispatch`, `onRegister.install()`, `refShapes.install()`) — a table that required all six on load would take that ordering away from it.
▲ 0↑ born
backing path · modules/api/engine/xr.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 on this asset or on the parts this page shows. Parts it leaves out are not counted here.
⌬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.