--!strict --[[ Output and performance: the Developer Console and Script Performance windows, readable by an agent. These are the panels a developer opens when something is slow or broken, and no other Studio MCP server exposes them. Being able to say "which script is eating the frame" without the user reading a graph and describing it back is the difference between diagnosing a problem and guessing at one. `Stats` time properties are in seconds and reported here in milliseconds, because every frame budget a developer reasons about is quoted in ms. ]] local ScriptContext = game:GetService("ScriptContext") local RunService = game:GetService("RunService") local SceneAnalysisService = game:GetService("SceneAnalysisService") local ScriptProfilerService = game:GetService("ScriptProfilerService") local Dispatch = require(script.Parent.Parent.Dispatch) local ClientRelay = require(script.Parent.Parent.ClientRelay) local LogBuffer = require(script.Parent.Parent.LogBuffer) local Paths = require(script.Parent.Parent.Paths) local Scope = require(script.Parent.Parent.Scope) local Snapshot = require(script.Parent.Parent.PerfSnapshot) -- The log holds thousands of lines in a busy session; an unbounded tail would -- swamp any context window. local MAX_LOG_ENTRIES = 500 local DEFAULT_LOG_ENTRIES = 100 -- Characters of log rows one console read picks. See `take` in Perf.console. local CONSOLE_BUDGET = 16_000 -- Profiling is a blocking wait, so the ceiling is what a caller will sit through -- rather than what the profiler can manage. local MAX_PROFILE_SECONDS = 30 local DEFAULT_PROFILE_SECONDS = 5 local PROFILE_DATA_TIMEOUT = 5 -- Uncovered line numbers are listed, not just counted, but a script that never -- ran at all would otherwise list every line it has. local MAX_UNCOVERED_LINES = 50 local Perf = {} --[[ The output log, newest last. Ordering matters: an agent reading a tail wants the most recent lines at the bottom, the same way the Output window reads, so an error and the lines that led to it stay in the order they happened. ]] function Perf.console(params: { [string]: any }): { [string]: any } local limit = math.clamp(tonumber(params.limit) or DEFAULT_LOG_ENTRIES, 1, MAX_LOG_ENTRIES) local level = params.level local pattern = params.pattern local target = params.target or "studio" if target ~= "studio" and target ~= "client" then Dispatch.fail("BAD_PARAMS", "target must be studio or client") end local player = if target == "client" then ClientRelay.playerFor(params.player) else nil -- Read from our own buffer, not LogService:GetLogHistory(). See LogBuffer for -- why: the engine's history stops growing after startup in an editor session -- and is empty outright in a playtest, without reporting either as a failure. local history, evicted, token, latest, recordingSeconds = LogBuffer.stream(player) local since: number? = nil if params.since ~= nil then local supplied = params.since local cursorToken: string? = nil local cursorSequence: string? = nil if typeof(supplied) == "string" then cursorToken, cursorSequence = string.match(supplied, "^([^:]+):(%d+)$") end local parsed = tonumber(cursorSequence) if cursorToken ~= token or parsed == nil or parsed > latest then Dispatch.fail("BAD_CURSOR", "This console cursor belongs to another session or is invalid.", "Start a fresh read without `since`.") end since = parsed end local oldest = if #history > 0 then history[1].sequence or 1 else latest + 1 local missed = if since ~= nil then math.max(0, oldest - since - 1) else 0 local mode = params.mode or "tail" if mode ~= "tail" and mode ~= "drain" then Dispatch.fail("BAD_PARAMS", string.format("%q is not a console mode.", tostring(mode)), "Use tail or drain.") end local matches: { LogBuffer.Entry } = {} for _, item in history do if since ~= nil and (item.sequence or 0) <= since then continue end if level and item.level ~= level then continue end if pattern then -- An invalid pattern raises rather than failing to match, so it is -- reported as a pattern problem instead of an empty result. local valid, position = pcall(string.find, item.message, pattern) if not valid then Dispatch.fail( "BAD_PATTERN", string.format("%s is not a valid Lua pattern: %s", pattern, tostring(position)), "Lua patterns escape with %, not backslash. Omit `pattern` to see everything." ) end if position == nil then continue end end table.insert(matches, item) end --[[ Rows are picked against a size budget as well as `limit`. One noisy script printing long lines used to fill a read with a few hundred of them, which the server then cut down to fit its response limit -- dropping lines this side had already reported as delivered. A budget here means what is picked is what is shown. The first row is always taken, whatever its size, so a single huge entry cannot stall a drain. ]] local selected: { { [string]: any } } = {} local budget = CONSOLE_BUDGET local function take(item: LogBuffer.Entry): boolean local stack = item.stack local cost = #item.message + 300 + (if stack then #stack + #string.split(stack, "\n") * 8 else 0) + (if item.source then #item.source else 0) + (if item.context then #item.context else 0) if #selected >= limit or (cost > budget and #selected > 0) then return false end budget -= cost table.insert(selected, { sequence = item.sequence, updated = item.updated, level = item.level, message = item.message, timestamp = item.timestamp, stack = stack, source = item.source, context = item.context, }) return true end --[[ `tail` answers "what just happened": the newest matches, and a cursor at the end of the log, so a following read sees only what comes after. `drain` answers "what happened, all of it": the oldest unread matches, and a cursor just before the first one not shown, so reading on in a loop delivers every match exactly once however busy the log is. That only holds with the same filters each time -- the cursor is a position, and it does not remember what the filter skipped. ]] local watermark = latest if mode == "drain" then for _, item in matches do if not take(item) then watermark = (item.sequence or latest + 1) - 1 break end end else for index = #matches, 1, -1 do if not take(matches[index]) then break end end -- Picked newest first; read oldest first, the way the Output window does. local ordered: { { [string]: any } } = {} for index = #selected, 1, -1 do table.insert(ordered, selected[index]) end selected = ordered end local consumed = #selected -- Identical lines collapse into one row with a count, after the cursor is -- settled: grouping changes how rows are shown, never which were consumed. if params.group == true then local grouped: { { [string]: any } } = {} local byKey: { [string]: { [string]: any } } = {} for _, item in selected do local key = table.concat({ item.level, item.message, item.source or "", item.stack or "", item.context or "", if item.updated then "u" else "", }, "\0") local group = byKey[key] if group then group.count += 1 group.lastTimestamp = item.timestamp else item.count = 1 item.firstTimestamp = item.timestamp item.lastTimestamp = item.timestamp byKey[key] = item table.insert(grouped, item) end end selected = grouped end return { items = selected, total = #matches, dropped = if mode == "tail" then #matches - consumed else 0, hasMore = mode == "drain" and consumed < #matches, -- Lines lost to the buffer's own capacity, as opposed to ones trimmed to -- satisfy this call's limit. Only the first is a reason to worry. evicted = if since ~= nil then (if missed > 0 then missed else nil) else (if evicted > 0 then evicted else nil), recordingSeconds = recordingSeconds, nextCursor = token .. ":" .. tostring(watermark), player = if player then player.Name else nil, capturing = if player then LogBuffer.clientReady(player) else true, } end --[[ A snapshot of the engine's own counters -- what the Developer Console shows on its Performance and Memory tabs. ]] function Perf.snapshot(): { [string]: any } return Snapshot.snapshot() end --[[ Runs the script profiler for a while and returns what it collected. This is the Script Performance window. The profiler samples at `frequency` Hz and reports back through an event rather than a return value, so the call starts it, waits, asks for the data, and stops -- blocking for the duration, which is why the ceiling is short. The payload's shape is Roblox's own and undocumented, so it is passed through rather than reshaped into something that might quietly misreport which script is expensive. ]] function Perf.profile(params: { [string]: any }): { [string]: any } local seconds = math.clamp( tonumber(params.seconds) or DEFAULT_PROFILE_SECONDS, 1, MAX_PROFILE_SECONDS ) local frequency = math.clamp(tonumber(params.frequency) or 1000, 100, 10000) local received: string? = nil local connection = ScriptProfilerService.OnNewData:Connect(function(_player, jsonString) received = jsonString end) local started, startError = pcall(function() ScriptProfilerService:ServerStart(frequency) end) if not started then connection:Disconnect() Dispatch.fail( "PROFILER_UNAVAILABLE", string.format("Could not start the script profiler: %s", tostring(startError)), "The profiler is only available in Studio, and only one session at a time." ) end task.wait(seconds) pcall(function() ScriptProfilerService:ServerRequestData() end) -- The data arrives on the event, so the request is followed by a bounded wait -- rather than assuming it has already landed. local deadline = os.clock() + PROFILE_DATA_TIMEOUT while received == nil and os.clock() < deadline do task.wait(0.1) end pcall(function() ScriptProfilerService:ServerStop() end) connection:Disconnect() if received == nil then Dispatch.fail( "NO_PROFILE_DATA", string.format("The profiler ran for %ds but returned no data.", seconds), "Nothing ran during the sample. Start a playtest first, or profile for longer." ) end local okParse, parsed = pcall(function() return ScriptProfilerService:DeserializeJSON(received) end) return { seconds = seconds, frequency = frequency, data = if okParse then parsed else nil, raw = if okParse then nil else received, } end --[[ Which scripts to instrument the moment a session starts. `EnableCoverage` applies to the DataModel it is called in, and a playtest is a different DataModel, built when Play is pressed. So enabling from the editor and then playing -- the sequence this tool used to instruct -- instruments the editor and measures the playtest, which is why it reported nothing at all: the editor never ran the code, and the playtest was never instrumented. Nothing can be enabled from outside in time either, because a playtest's scripts start with the DataModel. The request has to be waiting before the session exists, so it is remembered here and replayed by whichever session loads next. Kept per place. Plugin settings live in one file shared by every Studio process, so a path remembered while working on one game would otherwise be resolved against a different one. ]] local COVERAGE_SETTING = "coverageTargets" local pluginRef: Plugin? = nil local carriedOver: { string } = {} local carriedFailed: { { [string]: any } } = {} local function rememberCoverage(paths: { string }) local host = pluginRef if host == nil then return end pcall(function() (host :: Plugin):SetSetting(COVERAGE_SETTING, { placeId = game.PlaceId, paths = paths, }) end) end local function rememberedCoverage(): { string } local host = pluginRef if host == nil then return {} end local ok, stored = pcall(function() return (host :: Plugin):GetSetting(COVERAGE_SETTING) end) if not ok or typeof(stored) ~= "table" then return {} end local record = stored :: { [string]: any } if record.placeId ~= game.PlaceId or typeof(record.paths) ~= "table" then return {} end return record.paths :: { string } end --[[ Line coverage: which lines of which scripts actually ran. Coverage has to be switched on for a script before that script executes, so a call that only reads the stats reports nothing on a first run. `enable` turns it on for named scripts and remembers them for the next session to switch on for itself; the caller then plays, and reads back from the playtest. ]] function Perf.coverage(params: { [string]: any }): { [string]: any } local enabled: { string } = {} -- Distinguished from absent on purpose: an explicit empty list is how a -- caller stops instrumenting every future session for this place. local requested = params.enable for _, path in (requested or {}) :: { string } do local target = Paths.resolve(path) local ok = pcall(function() ScriptContext:EnableCoverage(target) end) if ok then table.insert(enabled, Paths.of(target)) end end if requested ~= nil then rememberCoverage(enabled) end local ok, stats = pcall(function() return ScriptContext:GetCoverageStats() end) if not ok or typeof(stats) ~= "table" then return { enabled = enabled, scripts = {}, problem = tostring(stats) } end --[[ The payload is undocumented, so this is the shape measured from a real capture rather than a guess: each entry is `{ Script = Instance, GetHits = function }`, and `GetHits()` returns an array indexed by line number. The values carry three distinct meanings, and conflating them is what an earlier attempt here did: -1 the line cannot be instrumented at all -- blank, a comment, an `end` 0 instrumented and never executed: the lines worth reporting >0 the number of times it ran Counting every element as instrumented drags the denominator up by every blank line and comment in the file, which understates coverage on exactly the well-commented code most likely to be measured. ]] local scripts: { { [string]: any } } = {} local unrecognised: any = nil for _, entry in stats :: { any } do if typeof(entry) ~= "table" then unrecognised = stats break end local record = entry :: { [string]: any } local target = record.Script or record.script local getHits = record.GetHits if typeof(getHits) ~= "function" then unrecognised = stats break end local gotHits, hits = pcall(getHits, record) if not gotHits or typeof(hits) ~= "table" then unrecognised = stats break end local instrumented = 0 local covered = 0 -- The lines that never ran are the entire point of asking, so they are -- named rather than left to be inferred from a percentage. local missed: { number } = {} for line, count in hits :: { number } do if typeof(count) ~= "number" or count < 0 then continue end instrumented += 1 if count > 0 then covered += 1 elseif #missed < MAX_UNCOVERED_LINES then table.insert(missed, line) end end table.sort(missed) --[[ A destroyed script keeps its coverage record for the life of the session, and `Paths.of` on something with no ancestry returns a bare name -- so the report named "MCPProbeB" with no path, for a script that no longer existed and could not be looked up. Measured by destroying an instrumented ModuleScript and reading coverage back: the row was still there, and still there on the call after it. Dropped rather than flagged. Coverage answers "which lines of my code ran", and a script that is gone has no lines anyone can go and read. ]] if typeof(target) == "Instance" and not (target :: Instance):IsDescendantOf(game) then continue end table.insert(scripts, { path = if typeof(target) == "Instance" then Paths.of(target) else tostring(target), instrumentedLines = instrumented, coveredLines = covered, uncoveredLines = missed, percent = if instrumented > 0 then math.floor(covered / instrumented * 1000 + 0.5) / 10 else 0, --[[ Zero instrumented lines is not "already compiled when coverage was switched on". That was the earlier reading here and it is wrong: probed against ScriptContext, a script that ran before being enabled gets NO record at all and never reaches this loop. What it IS has not been pinned down. The session that produced 0-line records could not be made to produce them again -- the same create/enable/require sequence instrumented 6 of 6 lines on the next attempt -- so no cause is claimed here. The flag exists because 0/0 and 0/many render identically and mean opposite things: no data, versus nothing ran. ]] notMeasurable = if instrumented == 0 then true else nil, }) end return { enabled = enabled, scripts = scripts, raw = unrecognised, -- What this session switched on for itself as it loaded, which is the -- only thing that can instrument code running at startup. carriedOver = if #carriedOver > 0 then carriedOver else nil, carriedFailed = if #carriedFailed > 0 then carriedFailed else nil, remembered = rememberedCoverage(), } end --[[ Every reference in the place that points at nothing. A dead asset id is the quietest failure Roblox has. A Sound whose id was deleted, made private, or mistyped plays silence; a Decal shows nothing; an Animation does nothing. None of them errors, none of them warns, and the instance looks perfectly healthy in the Explorer -- the id is a string, and it is still a string. The only way to find them today is to play the game and notice something missing, which nobody does reliably for the twentieth sound effect. `ContentProvider:PreloadAsync` settles it. Measured: a real id came back `IsLoaded=true, TimeLength=0.632`, a dead one `IsLoaded=false, TimeLength=0`. So this collects every asset-bearing property in the place, asks the engine to fetch them, and reports the ones that did not arrive. One honest cost: preloading a dead id writes a line to Studio's Output window. That is the engine talking, not this tool, and it corroborates the finding rather than contradicting it -- but it is the user's Output window, so the tool says up front that it will happen. ]] --[[ Assets fetched in one audit. Preloading is network work, and a place with two thousand sounds would spend minutes on it while Studio sat still. The cap is generous enough to cover most places whole and small enough that the worst case is seconds, and the reply says when it stopped short rather than implying it checked everything. ]] local MAX_ASSETS = 200 --[[ How long the whole fetch may take before this gives up on it. Long enough for two hundred ordinary assets over a slow connection, short enough that a stuck fetch is a slow answer rather than a hung Studio. ]] local PRELOAD_SECONDS = 20 --[[ Which property on which class holds a content id. Written out rather than discovered from the API dump, because "a string property whose name sounds like an asset" catches `Name` and misses `MeshContent`. The list is short, and being wrong here means reporting a healthy place as broken. ]] local ASSET_FIELDS: { { className: string, property: string } } = { { className = "Sound", property = "SoundId" }, { className = "Decal", property = "Texture" }, { className = "Texture", property = "Texture" }, { className = "ImageLabel", property = "Image" }, { className = "ImageButton", property = "Image" }, { className = "Animation", property = "AnimationId" }, { className = "Sky", property = "SkyboxUp" }, { className = "ParticleEmitter", property = "Texture" }, { className = "Beam", property = "Texture" }, { className = "Trail", property = "Texture" }, } function Perf.audit(params: { [string]: any }): { [string]: any } local ContentProvider = game:GetService("ContentProvider") local unset: { { [string]: any } } = {} local candidates: { { instance: Instance, id: string, property: string } } = {} local disabled: { string } = {} local duplicates: { string } = {} local seenNames: { [string]: boolean } = {} for _, descendant in game:GetDescendants() do if Scope.isNoisy(descendant) then continue end for _, field in ASSET_FIELDS do if not descendant:IsA(field.className) then continue end local ok, value = pcall(function() return (descendant :: any)[field.property] end) if not ok or typeof(value) ~= "string" then continue end if value == "" then table.insert(unset, { path = Paths.of(descendant), property = field.property }) elseif #candidates < MAX_ASSETS then table.insert(candidates, { instance = descendant, id = value, property = field.property }) end break end --[[ `Disabled` exists on BaseScript, not on ModuleScript -- reading it off the wrong one throws, which is how this check failed the first time it was written. Narrowed to the class that has it. ]] if descendant:IsA("BaseScript") and (descendant :: any).Disabled == true then if #disabled < 25 then table.insert(disabled, Paths.of(descendant)) end end --[[ Same-named siblings, which is what breaks `WaitForChild`: it returns whichever one the engine reaches first, and that is not stable. Geometry is excluded, and that exclusion is the whole value of the check. Measured on a real place: the first version reported 25 duplicates and every one of them was a wall bar, a bench or a table leg in a blockout -- decorative parts nobody ever looks up by name. The real findings were buried under them. A duplicated script, RemoteEvent, value or GUI element is a different matter: those exist to be found by name, and two of them means code somewhere is reaching for whichever the engine happened to index first. ]] local parent = descendant.Parent if parent ~= nil and not descendant:IsA("BasePart") and not descendant:IsA("Attachment") then local key = tostring(parent:GetDebugId(8)) .. "/" .. descendant.Name if seenNames[key] and #duplicates < 25 then table.insert(duplicates, Paths.of(descendant)) end seenNames[key] = true end end --[[ Preloaded in one call rather than one per asset. `PreloadAsync` takes the whole list and fetches in parallel; asking for them one at a time would serialise two hundred network round trips into something that looks like a hang. ]] local toLoad: { Instance } = {} for _, entry in candidates do table.insert(toLoad, entry.instance) end --[[ Given a deadline, because `PreloadAsync` has none of its own. It blocks until every id in the list resolves, and an id that resolves slowly -- or a network that has stopped answering -- blocks it for as long as that takes. Measured: an audit against a running playtest sat still long enough for the request to time out and the playtest to end with it. A tool that can stop Studio responding is worse than one that reports less. Run on its own thread and waited on here, so the wait can end without the work being cancelled -- there is no way to cancel it, and pretending otherwise would leave a thread writing into a finished request. ]] local preloaded = true if #toLoad > 0 then local done = false task.spawn(function() pcall(function() ContentProvider:PreloadAsync(toLoad) end) done = true end) local waited = 0 while not done and waited < PRELOAD_SECONDS do waited += task.wait(0.05) end preloaded = done end local dead: { { [string]: any } } = {} for _, entry in candidates do local instance = entry.instance local loaded = true if instance:IsA("Sound") then -- A Sound that fetched has a length; one that did not is still zero. loaded = (instance :: Sound).TimeLength > 0 else local ok, value = pcall(function() return (instance :: any).IsLoaded end) if ok and typeof(value) == "boolean" then loaded = value end end --[[ Nothing is called dead once the fetch has been abandoned. An id that simply had not arrived yet is indistinguishable here from one that points at nothing, and "this sound is broken" about a sound that is fine is the one answer this check must never give. ]] if not loaded and not preloaded then continue end if not loaded and #dead < 50 then table.insert(dead, { path = Paths.of(instance), property = entry.property, id = entry.id, }) end end return { checked = #candidates, -- Reported beside the fetched count, because "3 checked" on a place with -- fourteen blank ids reads as if the audit barely ran. A blank id needs -- no fetch to be judged; it is still a reference that points nowhere. scanned = #candidates + #unset, truncated = #candidates >= MAX_ASSETS, -- Said out loud, because an empty `dead` list from an abandoned fetch -- reads exactly like a clean place. incomplete = if not preloaded then true else nil, dead = dead, deadCount = #dead, unset = unset, unsetCount = #unset, disabledScripts = disabled, duplicateNames = duplicates, } end function Perf.register(host: Plugin?) pluginRef = host --[[ Applied at load, which is the only moment early enough to matter. A playtest's scripts run as its DataModel starts, so a session that waited for a tool call to tell it what to instrument would already have missed the run it was meant to measure. Paths that no longer resolve are skipped rather than reported: the set is remembered across sessions, and a script deleted in between is a stale entry, not a failure worth surfacing here. ]] for _, path in rememberedCoverage() do local ok, err = pcall(function() ScriptContext:EnableCoverage(Paths.resolve(path)) end) if ok then table.insert(carriedOver, path) else -- Reported rather than swallowed. When this silently did nothing the -- symptom was identical to the bug it was written to fix -- an empty -- coverage read -- and there was no way to tell which half had failed. table.insert(carriedFailed, { path = path, error = tostring(err) }) end end --[[ What this place is made of, and where its weight sits. `SceneAnalysisService` answers questions the counters cannot. A snapshot says memory is 2.4GB; this says which assets hold it and which instances are responsible, broken down by category rather than by process. The return shapes are undocumented -- the reference page lists the six methods and names their result types without describing a single field -- so what follows was read off a live session. Every one of them returns the same recursive node: `{ Name, Size, Children }`, with two exceptions. `GetTriangleCompositionAsync` carries `Sizes`, a dictionary of Triangles and Drawcalls, in place of the single `Size`; and animation nodes add `AssetId` and `Owners`, each owner being `{ Name, ClassName }`. Sizes are counts for instance composition and bytes for the memory readings, which the engine does not label either -- so this file labels them. ]] local function flatten(node: any, depth: number, into: { { [string]: any } }) if typeof(node) ~= "table" then return end local children = (node :: any).Children if typeof(children) ~= "table" then return end for _, child in children do local entry: { [string]: any } = { name = tostring((child :: any).Name), depth = depth, } local size = (child :: any).Size if typeof(size) == "number" then entry.size = size end local sizes = (child :: any).Sizes if typeof(sizes) == "table" then for key, value in sizes :: { [string]: any } do entry[string.lower(tostring(key))] = value end end local assetId = (child :: any).AssetId if assetId ~= nil then entry.assetId = tostring(assetId) end local owners = (child :: any).Owners if typeof(owners) == "table" then local names: { string } = {} for _, owner in owners :: { any } do if typeof(owner) == "table" then table.insert(names, string.format("%s (%s)", tostring(owner.Name), tostring(owner.ClassName))) end end entry.owners = names end table.insert(into, entry) -- Two levels is the useful depth: "3D Objects -> MeshPart" answers the -- question, while a third level is per-instance detail that `find` gives -- better and on demand. if depth < 2 then flatten(child, depth + 1, into) end end end local SCENE_SECTIONS = { { key = "composition", method = "GetInstanceCompositionAsync", unit = "instances" }, { key = "triangles", method = "GetTriangleCompositionAsync", unit = "triangles" }, { key = "scriptMemory", method = "GetScriptMemoryAsync", unit = "bytes" }, { key = "animationMemory", method = "GetAnimationMemoryAsync", unit = "bytes" }, { key = "audioMemory", method = "GetAudioMemoryAsync", unit = "bytes" }, { key = "unparented", method = "GetUnparentedInstancesAsync", unit = "instances" }, } function Perf.scene(params: { [string]: any }): { [string]: any } local only = if typeof(params.section) == "string" and params.section ~= "" then params.section else nil local sections: { [string]: any } = {} for _, spec in SCENE_SECTIONS do if only ~= nil and only ~= spec.key then continue end local ok, root = pcall(function() return (SceneAnalysisService :: any)[spec.method](SceneAnalysisService) end) if not ok then sections[spec.key] = { error = tostring(root) } continue end local rows: { { [string]: any } } = {} flatten(root, 1, rows) local total = (root :: any).Size local totals = (root :: any).Sizes sections[spec.key] = { total = if typeof(total) == "number" then total else nil, totals = if typeof(totals) == "table" then totals else nil, unit = spec.unit, entries = rows, } end if only ~= nil and sections[only] == nil then Dispatch.fail( "BAD_PARAMS", string.format("unknown scene section %q", only), "Sections are: composition, triangles, scriptMemory, animationMemory, audioMemory, unparented." ) end return sections end Dispatch.registerAll("perf", { audit = Perf.audit, console = Perf.console, snapshot = function() return Perf.snapshot() end, profile = Perf.profile, coverage = Perf.coverage, scene = Perf.scene, }) end return Perf