profiler
The profiler namespace — 63 functions.
globals/profiler/begin
profiler.begin(name: string)
Start a named profiling block. Call profiler.finish(name) to
record the duration. Blocks appear in profiler.stats() under
"script.<name>" and inside captures.
Parameters
namestring— Block name (e.g. "MyComponent.update").
profiler.begin("MyComponent.update"); ...; profiler.finish()
globals/profiler/disableRing
profiler.disableRing()
Disable the ring buffer and clear its history.
profiler.disableRing()
globals/profiler/enableRing
profiler.enableRing(seconds: number?) -> boolean
Enable the always-recording ring buffer, retaining the last
seconds of per-frame data (default 20). Query it AFTER the fact
with profiler.retro() — latency-immune, since the data is
historical. Editor profile only: returns false in the runtime
profile. The enable is the gate; the ring costs nothing until on.
Parameters
secondsnumber(optional) — Seconds of history to retain (default 20).
Returns boolean — True if enabled, false if refused (runtime profile).
if profiler.enableRing(30) then ... end
globals/profiler/finish
profiler.finish(name: string?) -> number?
Finish a profiling block and record the elapsed duration as
"script.<name>". Without an argument, closes the most-recently-
begun block (LIFO stack). With a name, closes the most recent
block whose name matches — useful when blocks of different names
are nested.
Parameters
namestring(optional) — Block name to finish. Omit to pop the top of the stack.
Returns number? — Elapsed milliseconds, or nil if no matching block was active.
local ms = profiler.finish("MyComponent.update")
globals/profiler/gpuFrame
profiler.gpuFrame() -> GpuFrameReport
Label-aggregated GPU pass timings over the last window_frames
resolved frames, measured with GPU timestamp queries. supported
is false when the device lacks timestamp queries — spans stays
empty. Each span covers every render/compute pass recorded under
one label — compute.<shader> per compute dispatch, scene.* for
the scene passes, post.<effect> per post-process effect,
feature.* for render-feature passes: ms is the median of its
per-frame totals, min_ms/max_ms the range that median sits in,
count the passes per frame and frames how much of the window
carried it. at_floor marks a label whose every sample landed
within a few ticks of the device's timestamp counter (tick_ms) —
those passes ran and the device resolved no duration for them,
which is not the same as a measured zero. ran is whether the
label recorded a measured pass in the newest resolved frame, and
last_frame the newest frame that did. The window outlives the
work it describes, so a pass that stops being recorded leaves a row
standing for up to window_frames frames carrying the median of
the frames it did run in: read ran to answer whether a pass is
running, frame - last_frame for how many resolved frames ago it
last did, and ms as the cost of the frames it ran in.
frame_span_ms (first pass begin to last pass end) and
total_ms are medians too, so
rows do not sum to total_ms, and the GPU may overlap passes so
total_ms can exceed frame_span_ms. The readback is
asynchronous: the window lags the live frame by a few frames.
Returns GpuFrameReport — GPU timing window, spans ranked by median ms descending.
local g = profiler.gpuFrame(); print(g.frame_span_ms, g.spans[1] and g.spans[1].label)
globals/profiler/hits
profiler.hits(label: string?) -> string?
Drain the watchdog's recorded hit frames into a capture stored
under label (default "watch_hits") and clear the buffer.
Returns the capture JSON (same shape as stopCapture), or nil if
there were no hits.
Parameters
labelstring(optional) — Capture label to store under (default "watch_hits").
Returns string? — Capture JSON of the hit frames, or nil if none.
local json = profiler.hits()
globals/profiler/isCapturing
profiler.isCapturing() -> boolean
Check if a profiler capture is currently active.
Returns boolean — True if a capture is in progress.
if profiler.isCapturing() then ... end
globals/profiler/lastCapture
profiler.lastCapture() -> string?
Get the most recent completed capture result as a JSON string.
Same shape as profiler.stopCapture(). Returns nil if no capture
has been completed yet.
Returns string? — JSON string of the last capture, or nil.
local last = profiler.lastCapture()
globals/profiler/retro
profiler.retro(seconds: number?, label: string?) -> { [string]: any }?
Retroactively aggregate the last seconds of the ring (default:
the whole ring). The full per-frame capture is retained under label
(default "retro") for in-engine drill-down (the frame / hotspots
tools);
this RETURNS a compact structured aggregate table (frame-time distribution
- per-system summary), never the raw per-frame array — bounded, so it is
safe over the ZeroMind bridge. Code-facing primitive; the
retrotool renders the agent-facing report. Latency-immune: the data is historical.
Parameters
secondsnumber(optional) — How many seconds back to include (default: whole ring).labelstring(optional) — Capture label to store under (default "retro").
Returns { [string]: any }? — A compact aggregate { label, source, frames, seconds, exclude_agent, agent_frames, dt = { avg, p50, p90, p99, max, min }, summary = {...} }, or nil if the ring holds nothing.
local agg = profiler.retro(8, "collapse")
globals/profiler/ringStatus
profiler.ringStatus() -> string
Ring buffer status as a JSON string:
{ enabled, frames, capacity, span_seconds }.
Returns string — JSON status string.
local s = profiler.ringStatus()
globals/profiler/startCapture
profiler.startCapture(label: string?) -> boolean
Start recording per-frame profiler data. Each frame's system
timings are captured until stopCapture() is called. Results are
accessible via profiler.lastCapture() and VFS at
/zero/runtime/profiler/<label>.json.
Parameters
labelstring(optional) — Capture label (default"capture").
Returns boolean — True if capture started, false if a capture is already active.
if profiler.startCapture("frame-spike") then ... end
globals/profiler/stats
profiler.stats(pattern: string?) -> { ProfilerStat }
Get current EMA profiling statistics from the SystemProfiler.
Optional glob pattern filters by metric name (supports * and ?
wildcards). Each entry carries two averages: avg_ms averages one
RUN of the block and is folded when the block runs, so a block that
has stopped running keeps the last value it saw; avg_frame_ms
averages one FRAME and is folded every frame, including the frames
the block did not run in, so it is the block's share of the current
frame and falls back to zero once the block stops running.
Parameters
patternstring(optional) — Filter pattern (e.g. "schedule.", "system.schedule.render.").
Returns { ProfilerStat } — Array of profiler block stats.
for _, s in ipairs(profiler.stats("schedule.*")) do print(s.name, s.avg_frame_ms) end
globals/profiler/stopCapture
profiler.stopCapture() -> string?
Stop the active profiler capture and return its result as a
JSON string. The capture is also saved to VFS at
/zero/runtime/profiler/<label>.json. Top-level fields: label,
frame_count, started_at, ended_at, frames, summary.
Compute duration as ended_at - started_at.
Returns string? — JSON capture result, or nil if no capture was active.
local json = profiler.stopCapture()
globals/profiler/unwatch
profiler.unwatch()
Disarm the watchdog. Recorded hits are kept for a final
profiler.hits().
profiler.unwatch()
globals/profiler/watch
profiler.watch(ceilingMs: number, mode: string?, excludeAgent: boolean?, maxHits: number?) -> boolean
Arm the frame-time watchdog. When a frame's EFFECTIVE time
(total minus agent-injected execute cost) crosses ceilingMs,
mode "record" logs every offending frame (read with
profiler.hits()), and mode "pause" pauses gameplay ONCE to
freeze the bad state, then disarms. Editor profile only: returns
false in the runtime profile. excludeAgent (default true) keeps
the agent's own calls from tripping it.
Parameters
ceilingMsnumber— Effective frame-time ceiling in ms.modestring(optional) — "record" (default) or "pause".excludeAgentboolean(optional) — Subtract agent cost before comparing (default true).maxHitsnumber(optional) — Max frames retained in record mode (default 240).
Returns boolean — True if armed, false if refused (runtime profile).
if profiler.watch(50, "pause") then ... end
globals/profiler/watchStatus
profiler.watchStatus() -> string
Watchdog status as a JSON string: { armed, ceiling_ms, mode, exclude_agent, hits, dropped_hits, tripped }.
Returns string — JSON status string.
local s = profiler.watchStatus()
modules/profiler/README
require("@builtin/modules/api/engine/profiler") -- profiler (also available as global 'profiler')
Frame-level profiler capture, EMA stats, and named profiling blocks. Public Luau surface over the __profiler Internal FFI namespace.
Usage: local profiler = require("@builtin/modules/api/engine/profiler") Also available as global: profiler
modules/profiler/begin
begin(name: string)
Start a named profiling block. Call profiler.finish(name) to
record the duration. Blocks appear in profiler.stats() under
"script.<name>" and inside captures.
Parameters
namestring— Block name (e.g. "MyComponent.update").
profiler.begin("MyComponent.update"); ...; profiler.finish()
modules/profiler/disableRing
disableRing()
Disable the ring buffer and clear its history.
profiler.disableRing()
modules/profiler/enableRing
enableRing(seconds: number?): boolean
Enable the always-recording ring buffer, retaining the last
seconds of per-frame data (default 20). Query it AFTER the fact
with profiler.retro() — latency-immune, since the data is
historical. Editor profile only: returns false in the runtime
profile. The enable is the gate; the ring costs nothing until on.
Parameters
secondsnumber?(optional) — Seconds of history to retain (default 20).
if profiler.enableRing(30) then ... end
modules/profiler/finish
finish(name: string?): number?
Finish a profiling block and record the elapsed duration as
"script.<name>". Without an argument, closes the most-recently-
begun block (LIFO stack). With a name, closes the most recent
block whose name matches — useful when blocks of different names
are nested.
Parameters
namestring?(optional) — Block name to finish. Omit to pop the top of the stack.
local ms = profiler.finish("MyComponent.update")
modules/profiler/gpuFrame
gpuFrame(): GpuFrameReport
Label-aggregated GPU pass timings over the last window_frames
resolved frames, measured with GPU timestamp queries. supported
is false when the device lacks timestamp queries — spans stays
empty. Each span covers every render/compute pass recorded under
one label — compute.<shader> per compute dispatch, scene.* for
the scene passes, post.<effect> per post-process effect,
feature.* for render-feature passes: ms is the median of its
per-frame totals, min_ms/max_ms the range that median sits in,
count the passes per frame and frames how much of the window
carried it. at_floor marks a label whose every sample landed
within a few ticks of the device's timestamp counter (tick_ms) —
those passes ran and the device resolved no duration for them,
which is not the same as a measured zero. ran is whether the
label recorded a measured pass in the newest resolved frame, and
last_frame the newest frame that did. The window outlives the
work it describes, so a pass that stops being recorded leaves a row
standing for up to window_frames frames carrying the median of
the frames it did run in: read ran to answer whether a pass is
running, frame - last_frame for how many resolved frames ago it
last did, and ms as the cost of the frames it ran in.
frame_span_ms (first pass begin to last pass end) and
total_ms are medians too, so
rows do not sum to total_ms, and the GPU may overlap passes so
total_ms can exceed frame_span_ms. The readback is
asynchronous: the window lags the live frame by a few frames.
local g = profiler.gpuFrame(); print(g.frame_span_ms, g.spans[1] and g.spans[1].label)
modules/profiler/hits
hits(label: string?): string?
Drain the watchdog's recorded hit frames into a capture stored
under label (default "watch_hits") and clear the buffer.
Returns the capture JSON (same shape as stopCapture), or nil if
there were no hits.
Parameters
labelstring?(optional) — Capture label to store under (default "watch_hits").
local json = profiler.hits()
modules/profiler/isCapturing
isCapturing(): boolean
Check if a profiler capture is currently active.
if profiler.isCapturing() then ... end
modules/profiler/lastCapture
lastCapture(): string?
Get the most recent completed capture result as a JSON string.
Same shape as profiler.stopCapture(). Returns nil if no capture
has been completed yet.
local last = profiler.lastCapture()
modules/profiler/measure<T...>
measure<T...>(name: string, fn: () -> T...): T...
Run a function inside a profiling block. Equivalent to a begin/finish pair but handles errors correctly. Returns the function's return values.
Parameters
namestring— Block name.fn() -> T...— Function to profile.
local count = profiler.measure("walk", function() return walk() end)
modules/profiler/retro
retro(seconds: number?, label: string?): { [string]: any }?
Retroactively aggregate the last seconds of the ring (default:
the whole ring). The full per-frame capture is retained under label
(default "retro") for in-engine drill-down (the frame / hotspots
tools);
this RETURNS a compact structured aggregate table (frame-time distribution
- per-system summary), never the raw per-frame array — bounded, so it is
safe over the ZeroMind bridge. Code-facing primitive; the
retrotool renders the agent-facing report. Latency-immune: the data is historical.
Parameters
secondsnumber?(optional) — How many seconds back to include (default: whole ring).labelstring?(optional) — Capture label to store under (default "retro").
local agg = profiler.retro(8, "collapse")
modules/profiler/ringStatus
ringStatus(): string
Ring buffer status as a JSON string:
{ enabled, frames, capacity, span_seconds }.
local s = profiler.ringStatus()
modules/profiler/startCapture
startCapture(label: string?): boolean
Start recording per-frame profiler data. Each frame's system
timings are captured until stopCapture() is called. Results are
accessible via profiler.lastCapture() and VFS at
/zero/runtime/profiler/<label>.json.
Parameters
labelstring?(optional) — Capture label (default"capture").
if profiler.startCapture("frame-spike") then ... end
modules/profiler/stats
stats(pattern: string?): { ProfilerStat }
Get current EMA profiling statistics from the SystemProfiler.
Optional glob pattern filters by metric name (supports * and ?
wildcards). Each entry carries two averages: avg_ms averages one
RUN of the block and is folded when the block runs, so a block that
has stopped running keeps the last value it saw; avg_frame_ms
averages one FRAME and is folded every frame, including the frames
the block did not run in, so it is the block's share of the current
frame and falls back to zero once the block stops running.
Parameters
patternstring?(optional) — Filter pattern (e.g. "schedule.", "system.schedule.render.").
for _, s in ipairs(profiler.stats("schedule.*")) do print(s.name, s.avg_frame_ms) end
modules/profiler/stopCapture
stopCapture(): string?
Stop the active profiler capture and return its result as a
JSON string. The capture is also saved to VFS at
/zero/runtime/profiler/<label>.json. Top-level fields: label,
frame_count, started_at, ended_at, frames, summary.
Compute duration as ended_at - started_at.
local json = profiler.stopCapture()
modules/profiler/unwatch
unwatch()
Disarm the watchdog. Recorded hits are kept for a final
profiler.hits().
profiler.unwatch()
modules/profiler/watch
watch(ceilingMs: number, mode: string?, excludeAgent: boolean?, maxHits: number?): boolean
Arm the frame-time watchdog. When a frame's EFFECTIVE time
(total minus agent-injected execute cost) crosses ceilingMs,
mode "record" logs every offending frame (read with
profiler.hits()), and mode "pause" pauses gameplay ONCE to
freeze the bad state, then disarms. Editor profile only: returns
false in the runtime profile. excludeAgent (default true) keeps
the agent's own calls from tripping it.
Parameters
ceilingMsnumber— Effective frame-time ceiling in ms.modestring?(optional) — "record" (default) or "pause".excludeAgentboolean?(optional) — Subtract agent cost before comparing (default true).maxHitsnumber?(optional) — Max frames retained in record mode (default 240).
if profiler.watch(50, "pause") then ... end
modules/profiler/watchStatus
watchStatus(): string
Watchdog status as a JSON string: { armed, ceiling_ms, mode, exclude_agent, hits, dropped_hits, tripped }.
local s = profiler.watchStatus()
tools/profiler/compare
profiler.compare(before?: string, after?: string) -> string
Diff two recordings (made with record): the change in avg/p90 frametime, and the systems that moved the most between them. Record a baseline, make a change, record again, then compare to see whether the change helped and which system it moved. A positive frametime delta means B is slower than A.
Parameters
beforestring(optional)afterstring(optional)
Returns string
"baseline", "optimized" -- did 'optimized' beat 'baseline', and where
tools/profiler/flamegraph
profiler.flamegraph(seconds?: number, mode?: ("run" | "start" | "stop" | "snapshot"), label?: string) -> string
Sample the Luau call stack to find the hottest code paths — the depth view under a hot component or task_scheduler. flamegraph(seconds) runs the sampler for that long (resetting first) and returns the top stacks plus a folded-stack file for flamegraph rendering. mode picks a manual phase instead: "start" / "stop" a long session, or "snapshot" the current top stacks without stopping.
Parameters
secondsnumber(optional)mode("run" | "start" | "stop" | "snapshot")(optional)labelstring(optional)
Returns string
-- 3s sample, top stacks + folded file
5 -- 5s sample
0, "snapshot" -- top stacks right now without stopping a session
tools/profiler/frame
profiler.frame(source?: string, which?: ("worst" | "typical"), minMs?: number) -> string
The current frame's time as a self-accounting tree: schedule -> system -> sub-timing, each row showing SELF (own time excluding children), TOTAL, calls, and % of frame. Time no schedule covers (the frame limiter / vsync wait, GPU present, event loop) shows as present / idle; each expensive component's update(dt) is listed by component + entity under lua_update.vm_call. Read the live frame with source "now", or a stopped recording by its label (its worst or typical frame). Read from top: the row with the largest SELF time is where the frame time actually goes.
Parameters
sourcestring(optional)which("worst" | "typical")(optional)minMsnumber(optional)
Returns string
-- live frame, full attributed tree
"combat", "worst" -- the worst frame of the 'combat' recording
"combat", "typical" -- the typical (average) frame of that recording
tools/profiler/gpu
profiler.gpu(n?: number) -> string
GPU pass timings over the last resolved frames, from GPU timestamp queries. Each row is one label: the median of its per-frame total, the min/max that median sits in, the passes per frame, and how many of the window's frames carried it. A row marked floor ran but the device resolved no duration for it — its two timestamps retired within a few ticks of each other. The last column is how many resolved frames ago the label last recorded a pass: now is a pass running in the frame the table describes, and anything else is a row the window still holds after the work under it stopped. The readback is asynchronous, so the window lags the live frame by a few frames. Use this when frame shows the time under present / idle (GPU-bound) and you need to know which passes the GPU spends it on.
Parameters
nnumber(optional)
Returns string
-- top 20 GPU spans of the window
40 -- top 40
tools/profiler/hits
profiler.hits(label?: string, spikeMs?: number) -> string
Drain the frames a record-mode watch caught and report them as a spike-cluster distribution — every caught frame grouped by its dominant hotspot, so repeated hitches collapse to their handful of causes instead of a wall of individual frames. Draining clears the buffer. The snapshot is stored under label — drill into any cluster with frame <label> / hotspots <label>. Arm the watchdog first with watch <ceilingMs>.
Parameters
labelstring(optional)spikeMsnumber(optional)
Returns string
-- review everything the watchdog caught
"collapse_hits" -- store under a name for later drill-down
tools/profiler/hotspots
profiler.hotspots(n?: number, source?: string, which?: ("worst" | "typical")) -> string
Rank the frame's costs by SELF time (a node's own cost, excluding its children) and return the top n as a flat table. Because it ranks by SELF, the top rows are the actual expensive leaves — a system's own work or a single heavy component's update(dt) — not the schedules that merely contain them. This is the "just tell me what's slow" tool; follow a hit down with frame. Reads the live frame ("now") or a stopped recording's worst/typical frame (pass its label).
Parameters
nnumber(optional)sourcestring(optional)which("worst" | "typical")(optional)
Returns string
-- top 12 costs in the live frame
20 -- top 20
12, "combat" -- top 12 in the worst frame of the 'combat' recording
tools/profiler/memory
profiler.memory(n?: number) -> string
The Luau VM's memory: total heap + GC state, then the components retaining the most memory (per instance). Use it to catch a growing script — take it, play/test, take it again, and watch which component's retained bytes climb. Complements the frame-time tools: memory pressure shows up as GC cost in frame (the gc node) and as crashes under load, not as one slow system.
Parameters
nnumber(optional)
Returns string
-- VM total + top 20 components by retained memory
10 -- top 10
tools/profiler/record
profiler.record(action?: ("start" | "stop" | "status"), label?: string, spikeMs?: number) -> string
Start / stop / check a background profiling recording that spans a play session. record("start", label) begins capturing every frame; play or test the game across as many turns as you want, then record("stop", label) returns the windowed breakdown: avg / p50 / p90 / p99 / max frametime, spike count, the single WORST frame's attributed tree, and a typical-frame tree. record("status") reports whether a recording is running. A stopped recording is kept under its label — analyse it later with frame, hotspots, or scripts (pass the label as their source), or diff two of them with compare.
Parameters
action("start" | "stop" | "status")(optional)labelstring(optional)spikeMsnumber(optional)
Returns string
"start", "combat" -- begin recording a combat encounter
"stop", "combat" -- end it, get the windowed breakdown
"status" -- is a recording running right now?
tools/profiler/retro
profiler.retro(seconds?: number, label?: string, spikeMs?: number) -> string
Retroactively read the ring buffer's last seconds of frames (default: the whole ring) as a spike-cluster report — the latency-immune profiler. Enable the ring first (ring on), drive the scene, then call this AFTER the spike: the data is historical, so your call's timing does not matter. The report groups every spike frame by its dominant hotspot (so one call shows the full distribution of what's slow, not one anecdote), on EFFECTIVE frame time (your own execute cost excluded). The snapshot is stored under label — drill into any cluster with frame <label> / hotspots <label>.
Parameters
secondsnumber(optional)labelstring(optional)spikeMsnumber(optional)
Returns string
-- the whole ring, clustered
8 -- just the last 8 seconds
8, "collapse" -- last 8s, stored as 'collapse' for drill-down
tools/profiler/ring
profiler.ring(action?: ("on" | "off" | "status"), seconds?: number) -> string
Control the retroactive ring buffer — an always-recording, bounded history of the last N seconds of per-frame data you query AFTER the fact with retro. ring("on", seconds) starts it (default 20s); ring("off") stops and clears it; ring("status") reports whether it's on, how many frames and seconds it holds. Editor profile only — a no-op in the runtime profile. The ring is off until you turn it on, so it costs nothing until then. This is the fix for "I can't profile a spike I only see afterwards".
Parameters
action("on" | "off" | "status")(optional)secondsnumber(optional)
Returns string
"on", 30 -- keep the last 30 seconds, always
"status" -- is the ring on? how much does it hold?
"off" -- stop recording and clear the history
tools/profiler/scripts
profiler.scripts(n?: number, minMs?: number, type_?: string) -> string
Rank components by update(dt) cost — the content view of where the frame's script time goes. Rolled up per component type by default (many instances of a type collapse to Type xN with summed cost; a lone instance keeps its @ entity), so it stays readable whether a world has three scripts or three hundred. Pass a type to drill into that one type's individual instances (which entity is the heavy one). Use it after hotspots/frame point at lua_update.vm_call. Counts COMPONENT update loops; a scene entrypoint's per-frame update / editorUpdate runs on the scheduler, and scene.cost ranks the loaded scenes by what theirs costs.
Parameters
nnumber(optional)minMsnumber(optional)type_string(optional)
Returns string
-- every component type, heaviest first
10 -- the 10 heaviest types
20, 0.5, "MyMover" -- instances of MyMover costing >= 0.5ms, by entity
tools/profiler/tasks
profiler.tasks(n?: number, minMs?: number) -> string
Rank components by the time the scheduler spent resuming their coroutines this frame — the content breakdown of task_scheduler. Rolled up per component type (many instances collapse to Type xN; a lone instance keeps its @ entity). Use this when frame/hotspots show task_scheduler hot and you need to know whose task.spawn / task.wait work is behind it.
Parameters
nnumber(optional)minMsnumber(optional)
Returns string
-- every component's coroutine cost, heaviest first
10 -- the 10 heaviest
tools/profiler/watch
profiler.watch(ceilingMs?: (number | "off" | "status"), mode?: ("record" | "pause"), excludeAgent?: boolean) -> string
Arm a frame-time watchdog that catches bad frames without you having to poll (which always lands seconds late). Call with a number to arm: watch(50) records every frame whose EFFECTIVE time (agent cost excluded) is >= 50ms; watch(50, "pause") instead pauses gameplay the first time the ceiling is crossed, freezing the bad state for you to inspect (then read it with retro). watch("off") disarms; watch("status") (or no arg) reports state and hit count. Read recorded hits with the hits tool. Editor profile only — returns a refusal in the runtime profile. excludeAgent (default true) keeps your own execute/write frames from tripping it.
Parameters
ceilingMs(number | "off" | "status")(optional)mode("record" | "pause")(optional)excludeAgentboolean(optional)
Returns string
50 -- record every frame over 50ms effective
50, "pause" -- pause gameplay the first time a frame exceeds 50ms
"status" -- armed? how many hits so far?
"off" -- disarm
typed/builtin//modules/api/engine/profiler/profiler/begin
profiler.begin(name: string)
Start a named profiling block. Call profiler.finish(name) to
record the duration. Blocks appear in profiler.stats() under
"script.<name>" and inside captures.
Parameters
namestring— Block name (e.g. "MyComponent.update").
profiler.begin("MyComponent.update"); ...; profiler.finish()
typed/builtin//modules/api/engine/profiler/profiler/disableRing
profiler.disableRing()
Disable the ring buffer and clear its history.
profiler.disableRing()
typed/builtin//modules/api/engine/profiler/profiler/enableRing
profiler.enableRing(seconds: number?) -> boolean
Enable the always-recording ring buffer, retaining the last
seconds of per-frame data (default 20). Query it AFTER the fact
with profiler.retro() — latency-immune, since the data is
historical. Editor profile only: returns false in the runtime
profile. The enable is the gate; the ring costs nothing until on.
Parameters
secondsnumber(optional) — Seconds of history to retain (default 20).
Returns boolean — True if enabled, false if refused (runtime profile).
if profiler.enableRing(30) then ... end
typed/builtin//modules/api/engine/profiler/profiler/finish
profiler.finish(name: string?) -> number?
Finish a profiling block and record the elapsed duration as
"script.<name>". Without an argument, closes the most-recently-
begun block (LIFO stack). With a name, closes the most recent
block whose name matches — useful when blocks of different names
are nested.
Parameters
namestring(optional) — Block name to finish. Omit to pop the top of the stack.
Returns number? — Elapsed milliseconds, or nil if no matching block was active.
local ms = profiler.finish("MyComponent.update")
typed/builtin//modules/api/engine/profiler/profiler/gpuFrame
profiler.gpuFrame() -> GpuFrameReport
Label-aggregated GPU pass timings over the last window_frames
resolved frames, measured with GPU timestamp queries. supported
is false when the device lacks timestamp queries — spans stays
empty. Each span covers every render/compute pass recorded under
one label — compute.<shader> per compute dispatch, scene.* for
the scene passes, post.<effect> per post-process effect,
feature.* for render-feature passes: ms is the median of its
per-frame totals, min_ms/max_ms the range that median sits in,
count the passes per frame and frames how much of the window
carried it. at_floor marks a label whose every sample landed
within a few ticks of the device's timestamp counter (tick_ms) —
those passes ran and the device resolved no duration for them,
which is not the same as a measured zero. ran is whether the
label recorded a measured pass in the newest resolved frame, and
last_frame the newest frame that did. The window outlives the
work it describes, so a pass that stops being recorded leaves a row
standing for up to window_frames frames carrying the median of
the frames it did run in: read ran to answer whether a pass is
running, frame - last_frame for how many resolved frames ago it
last did, and ms as the cost of the frames it ran in.
frame_span_ms (first pass begin to last pass end) and
total_ms are medians too, so
rows do not sum to total_ms, and the GPU may overlap passes so
total_ms can exceed frame_span_ms. The readback is
asynchronous: the window lags the live frame by a few frames.
Returns GpuFrameReport — GPU timing window, spans ranked by median ms descending.
local g = profiler.gpuFrame(); print(g.frame_span_ms, g.spans[1] and g.spans[1].label)
typed/builtin//modules/api/engine/profiler/profiler/hits
profiler.hits(label: string?) -> string?
Drain the watchdog's recorded hit frames into a capture stored
under label (default "watch_hits") and clear the buffer.
Returns the capture JSON (same shape as stopCapture), or nil if
there were no hits.
Parameters
labelstring(optional) — Capture label to store under (default "watch_hits").
Returns string? — Capture JSON of the hit frames, or nil if none.
local json = profiler.hits()
typed/builtin//modules/api/engine/profiler/profiler/isCapturing
profiler.isCapturing() -> boolean
Check if a profiler capture is currently active.
Returns boolean — True if a capture is in progress.
if profiler.isCapturing() then ... end
typed/builtin//modules/api/engine/profiler/profiler/lastCapture
profiler.lastCapture() -> string?
Get the most recent completed capture result as a JSON string.
Same shape as profiler.stopCapture(). Returns nil if no capture
has been completed yet.
Returns string? — JSON string of the last capture, or nil.
local last = profiler.lastCapture()
typed/builtin//modules/api/engine/profiler/profiler/retro
profiler.retro(seconds: number?, label: string?) -> { [string]: any }?
Retroactively aggregate the last seconds of the ring (default:
the whole ring). The full per-frame capture is retained under label
(default "retro") for in-engine drill-down (the frame / hotspots
tools);
this RETURNS a compact structured aggregate table (frame-time distribution
- per-system summary), never the raw per-frame array — bounded, so it is
safe over the ZeroMind bridge. Code-facing primitive; the
retrotool renders the agent-facing report. Latency-immune: the data is historical.
Parameters
secondsnumber(optional) — How many seconds back to include (default: whole ring).labelstring(optional) — Capture label to store under (default "retro").
Returns { [string]: any }? — A compact aggregate { label, source, frames, seconds, exclude_agent, agent_frames, dt = { avg, p50, p90, p99, max, min }, summary = {...} }, or nil if the ring holds nothing.
local agg = profiler.retro(8, "collapse")
typed/builtin//modules/api/engine/profiler/profiler/ringStatus
profiler.ringStatus() -> string
Ring buffer status as a JSON string:
{ enabled, frames, capacity, span_seconds }.
Returns string — JSON status string.
local s = profiler.ringStatus()
typed/builtin//modules/api/engine/profiler/profiler/startCapture
profiler.startCapture(label: string?) -> boolean
Start recording per-frame profiler data. Each frame's system
timings are captured until stopCapture() is called. Results are
accessible via profiler.lastCapture() and VFS at
/zero/runtime/profiler/<label>.json.
Parameters
labelstring(optional) — Capture label (default"capture").
Returns boolean — True if capture started, false if a capture is already active.
if profiler.startCapture("frame-spike") then ... end
typed/builtin//modules/api/engine/profiler/profiler/stats
profiler.stats(pattern: string?) -> { ProfilerStat }
Get current EMA profiling statistics from the SystemProfiler.
Optional glob pattern filters by metric name (supports * and ?
wildcards). Each entry carries two averages: avg_ms averages one
RUN of the block and is folded when the block runs, so a block that
has stopped running keeps the last value it saw; avg_frame_ms
averages one FRAME and is folded every frame, including the frames
the block did not run in, so it is the block's share of the current
frame and falls back to zero once the block stops running.
Parameters
patternstring(optional) — Filter pattern (e.g. "schedule.", "system.schedule.render.").
Returns { ProfilerStat } — Array of profiler block stats.
for _, s in ipairs(profiler.stats("schedule.*")) do print(s.name, s.avg_frame_ms) end
typed/builtin//modules/api/engine/profiler/profiler/stopCapture
profiler.stopCapture() -> string?
Stop the active profiler capture and return its result as a
JSON string. The capture is also saved to VFS at
/zero/runtime/profiler/<label>.json. Top-level fields: label,
frame_count, started_at, ended_at, frames, summary.
Compute duration as ended_at - started_at.
Returns string? — JSON capture result, or nil if no capture was active.
local json = profiler.stopCapture()
typed/builtin//modules/api/engine/profiler/profiler/unwatch
profiler.unwatch()
Disarm the watchdog. Recorded hits are kept for a final
profiler.hits().
profiler.unwatch()
typed/builtin//modules/api/engine/profiler/profiler/watch
profiler.watch(ceilingMs: number, mode: string?, excludeAgent: boolean?, maxHits: number?) -> boolean
Arm the frame-time watchdog. When a frame's EFFECTIVE time
(total minus agent-injected execute cost) crosses ceilingMs,
mode "record" logs every offending frame (read with
profiler.hits()), and mode "pause" pauses gameplay ONCE to
freeze the bad state, then disarms. Editor profile only: returns
false in the runtime profile. excludeAgent (default true) keeps
the agent's own calls from tripping it.
Parameters
ceilingMsnumber— Effective frame-time ceiling in ms.modestring(optional) — "record" (default) or "pause".excludeAgentboolean(optional) — Subtract agent cost before comparing (default true).maxHitsnumber(optional) — Max frames retained in record mode (default 240).
Returns boolean — True if armed, false if refused (runtime profile).
if profiler.watch(50, "pause") then ... end
typed/builtin//modules/api/engine/profiler/profiler/watchStatus
profiler.watchStatus() -> string
Watchdog status as a JSON string: { armed, ceiling_ms, mode, exclude_agent, hits, dropped_hits, tripped }.
Returns string — JSON status string.
local s = profiler.watchStatus()