@el4cteo/rbx-studio-mcp 0.1.6 → 0.2.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +67 -5
  2. package/dist/bridge/api.js +29 -0
  3. package/dist/bridge/api.js.map +1 -1
  4. package/dist/bridge/remote.js +35 -0
  5. package/dist/bridge/remote.js.map +1 -1
  6. package/dist/bridge/rpc.js +81 -2
  7. package/dist/bridge/rpc.js.map +1 -1
  8. package/dist/bridge/server.js +60 -3
  9. package/dist/bridge/server.js.map +1 -1
  10. package/dist/index.js +86 -2
  11. package/dist/index.js.map +1 -1
  12. package/dist/lib/protocol.js +23 -0
  13. package/dist/lib/protocol.js.map +1 -1
  14. package/dist/tools/discover.js +7 -1
  15. package/dist/tools/discover.js.map +1 -1
  16. package/dist/tools/input.js +63 -1
  17. package/dist/tools/input.js.map +1 -1
  18. package/dist/tools/instances.js +9 -1
  19. package/dist/tools/instances.js.map +1 -1
  20. package/dist/tools/perf.js +73 -16
  21. package/dist/tools/perf.js.map +1 -1
  22. package/dist/tools/playtest.js +8 -1
  23. package/dist/tools/playtest.js.map +1 -1
  24. package/dist/tools/session.js +32 -6
  25. package/dist/tools/session.js.map +1 -1
  26. package/dist/tools/world.js +9 -3
  27. package/dist/tools/world.js.map +1 -1
  28. package/package.json +2 -2
  29. package/plugin/src/Config.luau +1 -1
  30. package/plugin/src/Console.luau +204 -4
  31. package/plugin/src/Net.luau +23 -1
  32. package/plugin/src/Phrase.luau +158 -5
  33. package/plugin/src/Transport.luau +84 -19
  34. package/plugin/src/Visuals.luau +217 -20
  35. package/plugin/src/handlers/Debug.luau +63 -6
  36. package/plugin/src/handlers/Geometry.luau +20 -1
  37. package/plugin/src/handlers/Input.luau +41 -1
  38. package/plugin/src/handlers/Perf.luau +662 -645
  39. package/plugin/src/handlers/World.luau +19 -0
  40. package/plugin/src/init.server.luau +48 -11
  41. package/scripts/check-plugin.mjs +29 -1
  42. package/scripts/test-bridge.mjs +104 -0
  43. package/scripts/test-transport.mjs +58 -0
@@ -1,645 +1,662 @@
1
- --!strict
2
- --[[
3
- Output and performance: the Developer Console and Script Performance windows,
4
- readable by an agent.
5
-
6
- These are the panels a developer opens when something is slow or broken, and
7
- no other Studio MCP server exposes them. Being able to say "which script is
8
- eating the frame" without the user reading a graph and describing it back is
9
- the difference between diagnosing a problem and guessing at one.
10
-
11
- `Stats` time properties are in seconds and reported here in milliseconds,
12
- because every frame budget a developer reasons about is quoted in ms.
13
- ]]
14
-
15
- local ScriptContext = game:GetService("ScriptContext")
16
- local RunService = game:GetService("RunService")
17
- local SceneAnalysisService = game:GetService("SceneAnalysisService")
18
- local ScriptProfilerService = game:GetService("ScriptProfilerService")
19
- local Stats = game:GetService("Stats")
20
-
21
- local Dispatch = require(script.Parent.Parent.Dispatch)
22
- local LogBuffer = require(script.Parent.Parent.LogBuffer)
23
- local Paths = require(script.Parent.Parent.Paths)
24
-
25
- -- The log holds thousands of lines in a busy session; an unbounded tail would
26
- -- swamp any context window.
27
- local MAX_LOG_ENTRIES = 500
28
- local DEFAULT_LOG_ENTRIES = 100
29
-
30
- -- Profiling is a blocking wait, so the ceiling is what a caller will sit through
31
- -- rather than what the profiler can manage.
32
- local MAX_PROFILE_SECONDS = 30
33
- local DEFAULT_PROFILE_SECONDS = 5
34
- local PROFILE_DATA_TIMEOUT = 5
35
-
36
- -- Uncovered line numbers are listed, not just counted, but a script that never
37
- -- ran at all would otherwise list every line it has.
38
- local MAX_UNCOVERED_LINES = 50
39
-
40
- local Perf = {}
41
-
42
- local function round(value: number, places: number): number
43
- local scale = 10 ^ places
44
- return math.floor(value * scale + 0.5) / scale
45
- end
46
-
47
- --[[
48
- Reads a Stats property without assuming it exists. The set has changed across
49
- engine versions, and one missing counter should not fail the whole snapshot.
50
- ]]
51
- local function stat(name: string): number?
52
- local ok, value = pcall(function()
53
- return (Stats :: any)[name]
54
- end)
55
- if ok and typeof(value) == "number" then
56
- return value
57
- end
58
- return nil
59
- end
60
-
61
- local function millis(name: string): number?
62
- local seconds = stat(name)
63
- return if seconds then round(seconds * 1000, 3) else nil
64
- end
65
-
66
- local function flag(name: string): boolean?
67
- local ok, value = pcall(function()
68
- return (Stats :: any)[name]
69
- end)
70
- if ok and typeof(value) == "boolean" then
71
- return value
72
- end
73
- return nil
74
- end
75
-
76
- local function rounded(name: string, places: number): number?
77
- local value = stat(name)
78
- return if value then round(value, places) else nil
79
- end
80
-
81
- --[[
82
- The output log, newest last.
83
-
84
- Ordering matters: an agent reading a tail wants the most recent lines at the
85
- bottom, the same way the Output window reads, so an error and the lines that
86
- led to it stay in the order they happened.
87
- ]]
88
- function Perf.console(params: { [string]: any }): { [string]: any }
89
- local limit = math.clamp(tonumber(params.limit) or DEFAULT_LOG_ENTRIES, 1, MAX_LOG_ENTRIES)
90
- local level = params.level
91
- local pattern = params.pattern
92
-
93
- -- Read from our own buffer, not LogService:GetLogHistory(). See LogBuffer for
94
- -- why: the engine's history stops growing after startup in an editor session
95
- -- and is empty outright in a playtest, without reporting either as a failure.
96
- local history, evicted = LogBuffer.all()
97
-
98
- local entries: { { [string]: any } } = {}
99
- local matched = 0
100
-
101
- for _, item in history do
102
- local kind = item.level
103
- if level and kind ~= level then
104
- continue
105
- end
106
-
107
- local message = item.message
108
- if pattern then
109
- -- An invalid pattern raises rather than failing to match, so it is
110
- -- reported as a pattern problem instead of an empty result.
111
- local valid, position = pcall(string.find, message, pattern)
112
- if not valid then
113
- Dispatch.fail(
114
- "BAD_PATTERN",
115
- string.format("%s is not a valid Lua pattern: %s", pattern, tostring(position)),
116
- "Lua patterns escape with %, not backslash. Omit `pattern` to see everything."
117
- )
118
- end
119
- if position == nil then
120
- continue
121
- end
122
- end
123
-
124
- matched += 1
125
- table.insert(entries, {
126
- level = kind,
127
- message = message,
128
- timestamp = item.timestamp,
129
- stack = item.stack,
130
- source = item.source,
131
- })
132
- end
133
-
134
- -- Trimmed from the front: the newest lines are the ones worth keeping.
135
- local dropped = 0
136
- if #entries > limit then
137
- dropped = #entries - limit
138
- entries = table.move(entries, dropped + 1, #entries, 1, {})
139
- end
140
-
141
- return {
142
- items = entries,
143
- total = matched,
144
- dropped = dropped,
145
- -- Lines lost to the buffer's own capacity, as opposed to ones trimmed to
146
- -- satisfy this call's limit. Only the first is a reason to worry.
147
- evicted = if evicted > 0 then evicted else nil,
148
- recordingSeconds = LogBuffer.recordingSeconds(),
149
- }
150
- end
151
-
152
- --[[
153
- A snapshot of the engine's own counters -- what the Developer Console shows on
154
- its Performance and Memory tabs.
155
- ]]
156
- function Perf.snapshot(): { [string]: any }
157
- --[[
158
- Read one tag at a time, not with `GetMemoryUsageMbAllCategories`.
159
-
160
- The aggregate call is the obvious one and is closed to us: it now requires
161
- the InternalTest capability, so from a plugin it only ever raises "the
162
- current thread cannot call 'GetMemoryUsageMbAllCategories'". Measured, not
163
- assumed -- and the same refusal comes back in an editor session and in a
164
- playtest, so there is no context in which waiting for it pays off.
165
-
166
- `GetMemoryUsageMbForTag` carries no such restriction, and walking
167
- `Enum.DeveloperMemoryTag` reaches every category the Developer Console's
168
- Memory tab shows. Zero-valued tags are dropped so the list stays readable.
169
- ]]
170
- local memory: { { [string]: any } } = {}
171
- local memoryProblem: string? = nil
172
-
173
- for _, tag in Enum.DeveloperMemoryTag:GetEnumItems() do
174
- local ok, value = pcall(function()
175
- return Stats:GetMemoryUsageMbForTag(tag)
176
- end)
177
- if not ok then
178
- -- One refusal means the whole route is shut, not that this tag is
179
- -- special; stop rather than repeat the same failure for every item.
180
- memoryProblem = string.format("Stats refused the call: %s", tostring(value))
181
- break
182
- end
183
- if typeof(value) == "number" and value > 0 then
184
- table.insert(memory, { category = tag.Name, megabytes = round(value, 2) })
185
- end
186
- end
187
-
188
- table.sort(memory, function(a, b)
189
- return a.megabytes > b.megabytes
190
- end)
191
-
192
- local okTotal, total = pcall(function()
193
- return Stats:GetTotalMemoryUsageMb()
194
- end)
195
-
196
- --[[
197
- A playtest server has no renderer, so every drawing counter reads zero --
198
- zero draw calls, zero triangles, no frame time. Left unexplained that is
199
- the most flattering possible misreading of a place's performance, so the
200
- snapshot says outright that those numbers are absent rather than good.
201
- ]]
202
- local headless = RunService:IsRunning() and RunService:IsServer() and not RunService:IsClient()
203
-
204
- return {
205
- renderless = headless or nil,
206
- frame = {
207
- frameTimeMs = millis("FrameTime"),
208
- heartbeatTimeMs = millis("HeartbeatTime"),
209
- physicsStepTimeMs = millis("PhysicsStepTime"),
210
- renderCpuMs = millis("RenderCPUFrameTime"),
211
- renderGpuMs = millis("RenderGPUFrameTime"),
212
- },
213
- scene = {
214
- instances = stat("InstanceCount"),
215
- parts = stat("PrimitivesCount"),
216
- movingParts = stat("MovingPrimitivesCount"),
217
- contacts = stat("ContactsCount"),
218
- drawcalls = stat("SceneDrawcallCount"),
219
- triangles = stat("SceneTriangleCount"),
220
- },
221
- network = {
222
- receiveKbps = rounded("DataReceiveKbps", 1),
223
- sendKbps = rounded("DataSendKbps", 1),
224
- },
225
- memory = {
226
- totalMb = if okTotal and typeof(total) == "number" then round(total, 1) else nil,
227
- -- Studio only accumulates the per-category breakdown while this is on,
228
- -- so an empty list means "not measured" rather than "nothing used".
229
- trackingEnabled = flag("MemoryTrackingEnabled"),
230
- categories = memory,
231
- problem = if #memory == 0 then memoryProblem else nil,
232
- },
233
- }
234
- end
235
-
236
- --[[
237
- Runs the script profiler for a while and returns what it collected.
238
-
239
- This is the Script Performance window. The profiler samples at `frequency` Hz
240
- and reports back through an event rather than a return value, so the call
241
- starts it, waits, asks for the data, and stops -- blocking for the duration,
242
- which is why the ceiling is short.
243
-
244
- The payload's shape is Roblox's own and undocumented, so it is passed through
245
- rather than reshaped into something that might quietly misreport which script
246
- is expensive.
247
- ]]
248
- function Perf.profile(params: { [string]: any }): { [string]: any }
249
- local seconds = math.clamp(
250
- tonumber(params.seconds) or DEFAULT_PROFILE_SECONDS,
251
- 1,
252
- MAX_PROFILE_SECONDS
253
- )
254
- local frequency = math.clamp(tonumber(params.frequency) or 1000, 100, 10000)
255
-
256
- local received: string? = nil
257
- local connection = ScriptProfilerService.OnNewData:Connect(function(_player, jsonString)
258
- received = jsonString
259
- end)
260
-
261
- local started, startError = pcall(function()
262
- ScriptProfilerService:ServerStart(frequency)
263
- end)
264
- if not started then
265
- connection:Disconnect()
266
- Dispatch.fail(
267
- "PROFILER_UNAVAILABLE",
268
- string.format("Could not start the script profiler: %s", tostring(startError)),
269
- "The profiler is only available in Studio, and only one session at a time."
270
- )
271
- end
272
-
273
- task.wait(seconds)
274
-
275
- pcall(function()
276
- ScriptProfilerService:ServerRequestData()
277
- end)
278
-
279
- -- The data arrives on the event, so the request is followed by a bounded wait
280
- -- rather than assuming it has already landed.
281
- local deadline = os.clock() + PROFILE_DATA_TIMEOUT
282
- while received == nil and os.clock() < deadline do
283
- task.wait(0.1)
284
- end
285
-
286
- pcall(function()
287
- ScriptProfilerService:ServerStop()
288
- end)
289
- connection:Disconnect()
290
-
291
- if received == nil then
292
- Dispatch.fail(
293
- "NO_PROFILE_DATA",
294
- string.format("The profiler ran for %ds but returned no data.", seconds),
295
- "Nothing ran during the sample. Start a playtest first, or profile for longer."
296
- )
297
- end
298
-
299
- local okParse, parsed = pcall(function()
300
- return ScriptProfilerService:DeserializeJSON(received)
301
- end)
302
-
303
- return {
304
- seconds = seconds,
305
- frequency = frequency,
306
- data = if okParse then parsed else nil,
307
- raw = if okParse then nil else received,
308
- }
309
- end
310
-
311
- --[[
312
- Which scripts to instrument the moment a session starts.
313
-
314
- `EnableCoverage` applies to the DataModel it is called in, and a playtest is a
315
- different DataModel, built when Play is pressed. So enabling from the editor
316
- and then playing -- the sequence this tool used to instruct -- instruments the
317
- editor and measures the playtest, which is why it reported nothing at all: the
318
- editor never ran the code, and the playtest was never instrumented.
319
-
320
- Nothing can be enabled from outside in time either, because a playtest's
321
- scripts start with the DataModel. The request has to be waiting before the
322
- session exists, so it is remembered here and replayed by whichever session
323
- loads next.
324
-
325
- Kept per place. Plugin settings live in one file shared by every Studio
326
- process, so a path remembered while working on one game would otherwise be
327
- resolved against a different one.
328
- ]]
329
- local COVERAGE_SETTING = "coverageTargets"
330
- local pluginRef: Plugin? = nil
331
- local carriedOver: { string } = {}
332
- local carriedFailed: { { [string]: any } } = {}
333
-
334
- local function rememberCoverage(paths: { string })
335
- local host = pluginRef
336
- if host == nil then
337
- return
338
- end
339
- pcall(function()
340
- (host :: Plugin):SetSetting(COVERAGE_SETTING, {
341
- placeId = game.PlaceId,
342
- paths = paths,
343
- })
344
- end)
345
- end
346
-
347
- local function rememberedCoverage(): { string }
348
- local host = pluginRef
349
- if host == nil then
350
- return {}
351
- end
352
- local ok, stored = pcall(function()
353
- return (host :: Plugin):GetSetting(COVERAGE_SETTING)
354
- end)
355
- if not ok or typeof(stored) ~= "table" then
356
- return {}
357
- end
358
- local record = stored :: { [string]: any }
359
- if record.placeId ~= game.PlaceId or typeof(record.paths) ~= "table" then
360
- return {}
361
- end
362
- return record.paths :: { string }
363
- end
364
-
365
- --[[
366
- Line coverage: which lines of which scripts actually ran.
367
-
368
- Coverage has to be switched on for a script before that script executes, so a
369
- call that only reads the stats reports nothing on a first run. `enable` turns
370
- it on for named scripts and remembers them for the next session to switch on
371
- for itself; the caller then plays, and reads back from the playtest.
372
- ]]
373
- function Perf.coverage(params: { [string]: any }): { [string]: any }
374
- local enabled: { string } = {}
375
- -- Distinguished from absent on purpose: an explicit empty list is how a
376
- -- caller stops instrumenting every future session for this place.
377
- local requested = params.enable
378
- for _, path in (requested or {}) :: { string } do
379
- local target = Paths.resolve(path)
380
- local ok = pcall(function()
381
- ScriptContext:EnableCoverage(target)
382
- end)
383
- if ok then
384
- table.insert(enabled, Paths.of(target))
385
- end
386
- end
387
- if requested ~= nil then
388
- rememberCoverage(enabled)
389
- end
390
-
391
- local ok, stats = pcall(function()
392
- return ScriptContext:GetCoverageStats()
393
- end)
394
- if not ok or typeof(stats) ~= "table" then
395
- return { enabled = enabled, scripts = {}, problem = tostring(stats) }
396
- end
397
-
398
- --[[
399
- The payload is undocumented, so this is the shape measured from a real
400
- capture rather than a guess: each entry is `{ Script = Instance, GetHits =
401
- function }`, and `GetHits()` returns an array indexed by line number.
402
-
403
- The values carry three distinct meanings, and conflating them is what an
404
- earlier attempt here did:
405
- -1 the line cannot be instrumented at all -- blank, a comment, an `end`
406
- 0 instrumented and never executed: the lines worth reporting
407
- >0 the number of times it ran
408
-
409
- Counting every element as instrumented drags the denominator up by every
410
- blank line and comment in the file, which understates coverage on exactly
411
- the well-commented code most likely to be measured.
412
- ]]
413
- local scripts: { { [string]: any } } = {}
414
- local unrecognised: any = nil
415
-
416
- for _, entry in stats :: { any } do
417
- if typeof(entry) ~= "table" then
418
- unrecognised = stats
419
- break
420
- end
421
-
422
- local record = entry :: { [string]: any }
423
- local target = record.Script or record.script
424
- local getHits = record.GetHits
425
-
426
- if typeof(getHits) ~= "function" then
427
- unrecognised = stats
428
- break
429
- end
430
-
431
- local gotHits, hits = pcall(getHits, record)
432
- if not gotHits or typeof(hits) ~= "table" then
433
- unrecognised = stats
434
- break
435
- end
436
-
437
- local instrumented = 0
438
- local covered = 0
439
- -- The lines that never ran are the entire point of asking, so they are
440
- -- named rather than left to be inferred from a percentage.
441
- local missed: { number } = {}
442
-
443
- for line, count in hits :: { number } do
444
- if typeof(count) ~= "number" or count < 0 then
445
- continue
446
- end
447
- instrumented += 1
448
- if count > 0 then
449
- covered += 1
450
- elseif #missed < MAX_UNCOVERED_LINES then
451
- table.insert(missed, line)
452
- end
453
- end
454
-
455
- table.sort(missed)
456
-
457
- table.insert(scripts, {
458
- path = if typeof(target) == "Instance" then Paths.of(target) else tostring(target),
459
- instrumentedLines = instrumented,
460
- coveredLines = covered,
461
- uncoveredLines = missed,
462
- percent = if instrumented > 0
463
- then math.floor(covered / instrumented * 1000 + 0.5) / 10
464
- else 0,
465
- --[[
466
- Zero instrumented lines does not mean an empty script; it means
467
- this one was already compiled when coverage was switched on, and
468
- Luau keeps the bytecode it first built.
469
-
470
- Measured: a script re-run from scratch, after enabling, still
471
- reported every line as uninstrumentable -- it printed its output
472
- twice and reported 0 both times. So the distinction matters and
473
- cannot be recovered later. Without saying so, 0/0 reads as "this
474
- never ran", which is the opposite of what happened.
475
- ]]
476
- notMeasurable = if instrumented == 0 then true else nil,
477
- })
478
- end
479
-
480
- return {
481
- enabled = enabled,
482
- scripts = scripts,
483
- raw = unrecognised,
484
- -- What this session switched on for itself as it loaded, which is the
485
- -- only thing that can instrument code running at startup.
486
- carriedOver = if #carriedOver > 0 then carriedOver else nil,
487
- carriedFailed = if #carriedFailed > 0 then carriedFailed else nil,
488
- remembered = rememberedCoverage(),
489
- }
490
- end
491
-
492
- function Perf.register(host: Plugin?)
493
- pluginRef = host
494
-
495
- --[[
496
- Applied at load, which is the only moment early enough to matter. A
497
- playtest's scripts run as its DataModel starts, so a session that waited
498
- for a tool call to tell it what to instrument would already have missed
499
- the run it was meant to measure.
500
-
501
- Paths that no longer resolve are skipped rather than reported: the set is
502
- remembered across sessions, and a script deleted in between is a stale
503
- entry, not a failure worth surfacing here.
504
- ]]
505
- for _, path in rememberedCoverage() do
506
- local ok, err = pcall(function()
507
- ScriptContext:EnableCoverage(Paths.resolve(path))
508
- end)
509
- if ok then
510
- table.insert(carriedOver, path)
511
- else
512
- -- Reported rather than swallowed. When this silently did nothing the
513
- -- symptom was identical to the bug it was written to fix -- an empty
514
- -- coverage read -- and there was no way to tell which half had failed.
515
- table.insert(carriedFailed, { path = path, error = tostring(err) })
516
- end
517
- end
518
-
519
- --[[
520
- What this place is made of, and where its weight sits.
521
-
522
- `SceneAnalysisService` answers questions the counters cannot. A snapshot says
523
- memory is 2.4GB; this says which assets hold it and which instances are
524
- responsible, broken down by category rather than by process.
525
-
526
- The return shapes are undocumented -- the reference page lists the six
527
- methods and names their result types without describing a single field -- so
528
- what follows was read off a live session. Every one of them returns the same
529
- recursive node: `{ Name, Size, Children }`, with two exceptions.
530
- `GetTriangleCompositionAsync` carries `Sizes`, a dictionary of Triangles and
531
- Drawcalls, in place of the single `Size`; and animation nodes add `AssetId`
532
- and `Owners`, each owner being `{ Name, ClassName }`.
533
-
534
- Sizes are counts for instance composition and bytes for the memory readings,
535
- which the engine does not label either -- so this file labels them.
536
- ]]
537
- local function flatten(node: any, depth: number, into: { { [string]: any } })
538
- if typeof(node) ~= "table" then
539
- return
540
- end
541
- local children = (node :: any).Children
542
- if typeof(children) ~= "table" then
543
- return
544
- end
545
- for _, child in children do
546
- local entry: { [string]: any } = {
547
- name = tostring((child :: any).Name),
548
- depth = depth,
549
- }
550
- local size = (child :: any).Size
551
- if typeof(size) == "number" then
552
- entry.size = size
553
- end
554
- local sizes = (child :: any).Sizes
555
- if typeof(sizes) == "table" then
556
- for key, value in sizes :: { [string]: any } do
557
- entry[string.lower(tostring(key))] = value
558
- end
559
- end
560
- local assetId = (child :: any).AssetId
561
- if assetId ~= nil then
562
- entry.assetId = tostring(assetId)
563
- end
564
- local owners = (child :: any).Owners
565
- if typeof(owners) == "table" then
566
- local names: { string } = {}
567
- for _, owner in owners :: { any } do
568
- if typeof(owner) == "table" then
569
- table.insert(names, string.format("%s (%s)", tostring(owner.Name), tostring(owner.ClassName)))
570
- end
571
- end
572
- entry.owners = names
573
- end
574
- table.insert(into, entry)
575
-
576
- -- Two levels is the useful depth: "3D Objects -> MeshPart" answers the
577
- -- question, while a third level is per-instance detail that `find` gives
578
- -- better and on demand.
579
- if depth < 2 then
580
- flatten(child, depth + 1, into)
581
- end
582
- end
583
- end
584
-
585
- local SCENE_SECTIONS = {
586
- { key = "composition", method = "GetInstanceCompositionAsync", unit = "instances" },
587
- { key = "triangles", method = "GetTriangleCompositionAsync", unit = "triangles" },
588
- { key = "scriptMemory", method = "GetScriptMemoryAsync", unit = "bytes" },
589
- { key = "animationMemory", method = "GetAnimationMemoryAsync", unit = "bytes" },
590
- { key = "audioMemory", method = "GetAudioMemoryAsync", unit = "bytes" },
591
- { key = "unparented", method = "GetUnparentedInstancesAsync", unit = "instances" },
592
- }
593
-
594
- function Perf.scene(params: { [string]: any }): { [string]: any }
595
- local only = if typeof(params.section) == "string" and params.section ~= "" then params.section else nil
596
- local sections: { [string]: any } = {}
597
-
598
- for _, spec in SCENE_SECTIONS do
599
- if only ~= nil and only ~= spec.key then
600
- continue
601
- end
602
- local ok, root = pcall(function()
603
- return (SceneAnalysisService :: any)[spec.method](SceneAnalysisService)
604
- end)
605
- if not ok then
606
- sections[spec.key] = { error = tostring(root) }
607
- continue
608
- end
609
-
610
- local rows: { { [string]: any } } = {}
611
- flatten(root, 1, rows)
612
-
613
- local total = (root :: any).Size
614
- local totals = (root :: any).Sizes
615
- sections[spec.key] = {
616
- total = if typeof(total) == "number" then total else nil,
617
- totals = if typeof(totals) == "table" then totals else nil,
618
- unit = spec.unit,
619
- entries = rows,
620
- }
621
- end
622
-
623
- if only ~= nil and sections[only] == nil then
624
- Dispatch.fail(
625
- "BAD_PARAMS",
626
- string.format("unknown scene section %q", only),
627
- "Sections are: composition, triangles, scriptMemory, animationMemory, audioMemory, unparented."
628
- )
629
- end
630
-
631
- return sections
632
- end
633
-
634
- Dispatch.registerAll("perf", {
635
- console = Perf.console,
636
- snapshot = function()
637
- return Perf.snapshot()
638
- end,
639
- profile = Perf.profile,
640
- coverage = Perf.coverage,
641
- scene = Perf.scene,
642
- })
643
- end
644
-
645
- return Perf
1
+ --!strict
2
+ --[[
3
+ Output and performance: the Developer Console and Script Performance windows,
4
+ readable by an agent.
5
+
6
+ These are the panels a developer opens when something is slow or broken, and
7
+ no other Studio MCP server exposes them. Being able to say "which script is
8
+ eating the frame" without the user reading a graph and describing it back is
9
+ the difference between diagnosing a problem and guessing at one.
10
+
11
+ `Stats` time properties are in seconds and reported here in milliseconds,
12
+ because every frame budget a developer reasons about is quoted in ms.
13
+ ]]
14
+
15
+ local ScriptContext = game:GetService("ScriptContext")
16
+ local RunService = game:GetService("RunService")
17
+ local SceneAnalysisService = game:GetService("SceneAnalysisService")
18
+ local ScriptProfilerService = game:GetService("ScriptProfilerService")
19
+ local Stats = game:GetService("Stats")
20
+
21
+ local Dispatch = require(script.Parent.Parent.Dispatch)
22
+ local LogBuffer = require(script.Parent.Parent.LogBuffer)
23
+ local Paths = require(script.Parent.Parent.Paths)
24
+
25
+ -- The log holds thousands of lines in a busy session; an unbounded tail would
26
+ -- swamp any context window.
27
+ local MAX_LOG_ENTRIES = 500
28
+ local DEFAULT_LOG_ENTRIES = 100
29
+
30
+ -- Profiling is a blocking wait, so the ceiling is what a caller will sit through
31
+ -- rather than what the profiler can manage.
32
+ local MAX_PROFILE_SECONDS = 30
33
+ local DEFAULT_PROFILE_SECONDS = 5
34
+ local PROFILE_DATA_TIMEOUT = 5
35
+
36
+ -- Uncovered line numbers are listed, not just counted, but a script that never
37
+ -- ran at all would otherwise list every line it has.
38
+ local MAX_UNCOVERED_LINES = 50
39
+
40
+ local Perf = {}
41
+
42
+ local function round(value: number, places: number): number
43
+ local scale = 10 ^ places
44
+ return math.floor(value * scale + 0.5) / scale
45
+ end
46
+
47
+ --[[
48
+ Reads a Stats property without assuming it exists. The set has changed across
49
+ engine versions, and one missing counter should not fail the whole snapshot.
50
+ ]]
51
+ local function stat(name: string): number?
52
+ local ok, value = pcall(function()
53
+ return (Stats :: any)[name]
54
+ end)
55
+ if ok and typeof(value) == "number" then
56
+ return value
57
+ end
58
+ return nil
59
+ end
60
+
61
+ local function millis(name: string): number?
62
+ local seconds = stat(name)
63
+ return if seconds then round(seconds * 1000, 3) else nil
64
+ end
65
+
66
+ local function flag(name: string): boolean?
67
+ local ok, value = pcall(function()
68
+ return (Stats :: any)[name]
69
+ end)
70
+ if ok and typeof(value) == "boolean" then
71
+ return value
72
+ end
73
+ return nil
74
+ end
75
+
76
+ local function rounded(name: string, places: number): number?
77
+ local value = stat(name)
78
+ return if value then round(value, places) else nil
79
+ end
80
+
81
+ --[[
82
+ The output log, newest last.
83
+
84
+ Ordering matters: an agent reading a tail wants the most recent lines at the
85
+ bottom, the same way the Output window reads, so an error and the lines that
86
+ led to it stay in the order they happened.
87
+ ]]
88
+ function Perf.console(params: { [string]: any }): { [string]: any }
89
+ local limit = math.clamp(tonumber(params.limit) or DEFAULT_LOG_ENTRIES, 1, MAX_LOG_ENTRIES)
90
+ local level = params.level
91
+ local pattern = params.pattern
92
+
93
+ -- Read from our own buffer, not LogService:GetLogHistory(). See LogBuffer for
94
+ -- why: the engine's history stops growing after startup in an editor session
95
+ -- and is empty outright in a playtest, without reporting either as a failure.
96
+ local history, evicted = LogBuffer.all()
97
+
98
+ local entries: { { [string]: any } } = {}
99
+ local matched = 0
100
+
101
+ for _, item in history do
102
+ local kind = item.level
103
+ if level and kind ~= level then
104
+ continue
105
+ end
106
+
107
+ local message = item.message
108
+ if pattern then
109
+ -- An invalid pattern raises rather than failing to match, so it is
110
+ -- reported as a pattern problem instead of an empty result.
111
+ local valid, position = pcall(string.find, message, pattern)
112
+ if not valid then
113
+ Dispatch.fail(
114
+ "BAD_PATTERN",
115
+ string.format("%s is not a valid Lua pattern: %s", pattern, tostring(position)),
116
+ "Lua patterns escape with %, not backslash. Omit `pattern` to see everything."
117
+ )
118
+ end
119
+ if position == nil then
120
+ continue
121
+ end
122
+ end
123
+
124
+ matched += 1
125
+ table.insert(entries, {
126
+ level = kind,
127
+ message = message,
128
+ timestamp = item.timestamp,
129
+ stack = item.stack,
130
+ source = item.source,
131
+ })
132
+ end
133
+
134
+ -- Trimmed from the front: the newest lines are the ones worth keeping.
135
+ local dropped = 0
136
+ if #entries > limit then
137
+ dropped = #entries - limit
138
+ entries = table.move(entries, dropped + 1, #entries, 1, {})
139
+ end
140
+
141
+ return {
142
+ items = entries,
143
+ total = matched,
144
+ dropped = dropped,
145
+ -- Lines lost to the buffer's own capacity, as opposed to ones trimmed to
146
+ -- satisfy this call's limit. Only the first is a reason to worry.
147
+ evicted = if evicted > 0 then evicted else nil,
148
+ recordingSeconds = LogBuffer.recordingSeconds(),
149
+ }
150
+ end
151
+
152
+ --[[
153
+ A snapshot of the engine's own counters -- what the Developer Console shows on
154
+ its Performance and Memory tabs.
155
+ ]]
156
+ function Perf.snapshot(): { [string]: any }
157
+ --[[
158
+ Read one tag at a time, not with `GetMemoryUsageMbAllCategories`.
159
+
160
+ The aggregate call is the obvious one and is closed to us: it now requires
161
+ the InternalTest capability, so from a plugin it only ever raises "the
162
+ current thread cannot call 'GetMemoryUsageMbAllCategories'". Measured, not
163
+ assumed -- and the same refusal comes back in an editor session and in a
164
+ playtest, so there is no context in which waiting for it pays off.
165
+
166
+ `GetMemoryUsageMbForTag` carries no such restriction, and walking
167
+ `Enum.DeveloperMemoryTag` reaches every category the Developer Console's
168
+ Memory tab shows. Zero-valued tags are dropped so the list stays readable.
169
+ ]]
170
+ local memory: { { [string]: any } } = {}
171
+ local memoryProblem: string? = nil
172
+
173
+ for _, tag in Enum.DeveloperMemoryTag:GetEnumItems() do
174
+ local ok, value = pcall(function()
175
+ return Stats:GetMemoryUsageMbForTag(tag)
176
+ end)
177
+ if not ok then
178
+ -- One refusal means the whole route is shut, not that this tag is
179
+ -- special; stop rather than repeat the same failure for every item.
180
+ memoryProblem = string.format("Stats refused the call: %s", tostring(value))
181
+ break
182
+ end
183
+ if typeof(value) == "number" and value > 0 then
184
+ table.insert(memory, { category = tag.Name, megabytes = round(value, 2) })
185
+ end
186
+ end
187
+
188
+ table.sort(memory, function(a, b)
189
+ return a.megabytes > b.megabytes
190
+ end)
191
+
192
+ local okTotal, total = pcall(function()
193
+ return Stats:GetTotalMemoryUsageMb()
194
+ end)
195
+
196
+ --[[
197
+ A playtest server has no renderer, so every drawing counter reads zero --
198
+ zero draw calls, zero triangles, no frame time. Left unexplained that is
199
+ the most flattering possible misreading of a place's performance, so the
200
+ snapshot says outright that those numbers are absent rather than good.
201
+ ]]
202
+ local headless = RunService:IsRunning() and RunService:IsServer() and not RunService:IsClient()
203
+
204
+ return {
205
+ renderless = headless or nil,
206
+ frame = {
207
+ frameTimeMs = millis("FrameTime"),
208
+ heartbeatTimeMs = millis("HeartbeatTime"),
209
+ physicsStepTimeMs = millis("PhysicsStepTime"),
210
+ renderCpuMs = millis("RenderCPUFrameTime"),
211
+ renderGpuMs = millis("RenderGPUFrameTime"),
212
+ },
213
+ scene = {
214
+ instances = stat("InstanceCount"),
215
+ parts = stat("PrimitivesCount"),
216
+ movingParts = stat("MovingPrimitivesCount"),
217
+ contacts = stat("ContactsCount"),
218
+ drawcalls = stat("SceneDrawcallCount"),
219
+ triangles = stat("SceneTriangleCount"),
220
+ },
221
+ network = {
222
+ receiveKbps = rounded("DataReceiveKbps", 1),
223
+ sendKbps = rounded("DataSendKbps", 1),
224
+ },
225
+ memory = {
226
+ totalMb = if okTotal and typeof(total) == "number" then round(total, 1) else nil,
227
+ -- Studio only accumulates the per-category breakdown while this is on,
228
+ -- so an empty list means "not measured" rather than "nothing used".
229
+ trackingEnabled = flag("MemoryTrackingEnabled"),
230
+ categories = memory,
231
+ problem = if #memory == 0 then memoryProblem else nil,
232
+ },
233
+ }
234
+ end
235
+
236
+ --[[
237
+ Runs the script profiler for a while and returns what it collected.
238
+
239
+ This is the Script Performance window. The profiler samples at `frequency` Hz
240
+ and reports back through an event rather than a return value, so the call
241
+ starts it, waits, asks for the data, and stops -- blocking for the duration,
242
+ which is why the ceiling is short.
243
+
244
+ The payload's shape is Roblox's own and undocumented, so it is passed through
245
+ rather than reshaped into something that might quietly misreport which script
246
+ is expensive.
247
+ ]]
248
+ function Perf.profile(params: { [string]: any }): { [string]: any }
249
+ local seconds = math.clamp(
250
+ tonumber(params.seconds) or DEFAULT_PROFILE_SECONDS,
251
+ 1,
252
+ MAX_PROFILE_SECONDS
253
+ )
254
+ local frequency = math.clamp(tonumber(params.frequency) or 1000, 100, 10000)
255
+
256
+ local received: string? = nil
257
+ local connection = ScriptProfilerService.OnNewData:Connect(function(_player, jsonString)
258
+ received = jsonString
259
+ end)
260
+
261
+ local started, startError = pcall(function()
262
+ ScriptProfilerService:ServerStart(frequency)
263
+ end)
264
+ if not started then
265
+ connection:Disconnect()
266
+ Dispatch.fail(
267
+ "PROFILER_UNAVAILABLE",
268
+ string.format("Could not start the script profiler: %s", tostring(startError)),
269
+ "The profiler is only available in Studio, and only one session at a time."
270
+ )
271
+ end
272
+
273
+ task.wait(seconds)
274
+
275
+ pcall(function()
276
+ ScriptProfilerService:ServerRequestData()
277
+ end)
278
+
279
+ -- The data arrives on the event, so the request is followed by a bounded wait
280
+ -- rather than assuming it has already landed.
281
+ local deadline = os.clock() + PROFILE_DATA_TIMEOUT
282
+ while received == nil and os.clock() < deadline do
283
+ task.wait(0.1)
284
+ end
285
+
286
+ pcall(function()
287
+ ScriptProfilerService:ServerStop()
288
+ end)
289
+ connection:Disconnect()
290
+
291
+ if received == nil then
292
+ Dispatch.fail(
293
+ "NO_PROFILE_DATA",
294
+ string.format("The profiler ran for %ds but returned no data.", seconds),
295
+ "Nothing ran during the sample. Start a playtest first, or profile for longer."
296
+ )
297
+ end
298
+
299
+ local okParse, parsed = pcall(function()
300
+ return ScriptProfilerService:DeserializeJSON(received)
301
+ end)
302
+
303
+ return {
304
+ seconds = seconds,
305
+ frequency = frequency,
306
+ data = if okParse then parsed else nil,
307
+ raw = if okParse then nil else received,
308
+ }
309
+ end
310
+
311
+ --[[
312
+ Which scripts to instrument the moment a session starts.
313
+
314
+ `EnableCoverage` applies to the DataModel it is called in, and a playtest is a
315
+ different DataModel, built when Play is pressed. So enabling from the editor
316
+ and then playing -- the sequence this tool used to instruct -- instruments the
317
+ editor and measures the playtest, which is why it reported nothing at all: the
318
+ editor never ran the code, and the playtest was never instrumented.
319
+
320
+ Nothing can be enabled from outside in time either, because a playtest's
321
+ scripts start with the DataModel. The request has to be waiting before the
322
+ session exists, so it is remembered here and replayed by whichever session
323
+ loads next.
324
+
325
+ Kept per place. Plugin settings live in one file shared by every Studio
326
+ process, so a path remembered while working on one game would otherwise be
327
+ resolved against a different one.
328
+ ]]
329
+ local COVERAGE_SETTING = "coverageTargets"
330
+ local pluginRef: Plugin? = nil
331
+ local carriedOver: { string } = {}
332
+ local carriedFailed: { { [string]: any } } = {}
333
+
334
+ local function rememberCoverage(paths: { string })
335
+ local host = pluginRef
336
+ if host == nil then
337
+ return
338
+ end
339
+ pcall(function()
340
+ (host :: Plugin):SetSetting(COVERAGE_SETTING, {
341
+ placeId = game.PlaceId,
342
+ paths = paths,
343
+ })
344
+ end)
345
+ end
346
+
347
+ local function rememberedCoverage(): { string }
348
+ local host = pluginRef
349
+ if host == nil then
350
+ return {}
351
+ end
352
+ local ok, stored = pcall(function()
353
+ return (host :: Plugin):GetSetting(COVERAGE_SETTING)
354
+ end)
355
+ if not ok or typeof(stored) ~= "table" then
356
+ return {}
357
+ end
358
+ local record = stored :: { [string]: any }
359
+ if record.placeId ~= game.PlaceId or typeof(record.paths) ~= "table" then
360
+ return {}
361
+ end
362
+ return record.paths :: { string }
363
+ end
364
+
365
+ --[[
366
+ Line coverage: which lines of which scripts actually ran.
367
+
368
+ Coverage has to be switched on for a script before that script executes, so a
369
+ call that only reads the stats reports nothing on a first run. `enable` turns
370
+ it on for named scripts and remembers them for the next session to switch on
371
+ for itself; the caller then plays, and reads back from the playtest.
372
+ ]]
373
+ function Perf.coverage(params: { [string]: any }): { [string]: any }
374
+ local enabled: { string } = {}
375
+ -- Distinguished from absent on purpose: an explicit empty list is how a
376
+ -- caller stops instrumenting every future session for this place.
377
+ local requested = params.enable
378
+ for _, path in (requested or {}) :: { string } do
379
+ local target = Paths.resolve(path)
380
+ local ok = pcall(function()
381
+ ScriptContext:EnableCoverage(target)
382
+ end)
383
+ if ok then
384
+ table.insert(enabled, Paths.of(target))
385
+ end
386
+ end
387
+ if requested ~= nil then
388
+ rememberCoverage(enabled)
389
+ end
390
+
391
+ local ok, stats = pcall(function()
392
+ return ScriptContext:GetCoverageStats()
393
+ end)
394
+ if not ok or typeof(stats) ~= "table" then
395
+ return { enabled = enabled, scripts = {}, problem = tostring(stats) }
396
+ end
397
+
398
+ --[[
399
+ The payload is undocumented, so this is the shape measured from a real
400
+ capture rather than a guess: each entry is `{ Script = Instance, GetHits =
401
+ function }`, and `GetHits()` returns an array indexed by line number.
402
+
403
+ The values carry three distinct meanings, and conflating them is what an
404
+ earlier attempt here did:
405
+ -1 the line cannot be instrumented at all -- blank, a comment, an `end`
406
+ 0 instrumented and never executed: the lines worth reporting
407
+ >0 the number of times it ran
408
+
409
+ Counting every element as instrumented drags the denominator up by every
410
+ blank line and comment in the file, which understates coverage on exactly
411
+ the well-commented code most likely to be measured.
412
+ ]]
413
+ local scripts: { { [string]: any } } = {}
414
+ local unrecognised: any = nil
415
+
416
+ for _, entry in stats :: { any } do
417
+ if typeof(entry) ~= "table" then
418
+ unrecognised = stats
419
+ break
420
+ end
421
+
422
+ local record = entry :: { [string]: any }
423
+ local target = record.Script or record.script
424
+ local getHits = record.GetHits
425
+
426
+ if typeof(getHits) ~= "function" then
427
+ unrecognised = stats
428
+ break
429
+ end
430
+
431
+ local gotHits, hits = pcall(getHits, record)
432
+ if not gotHits or typeof(hits) ~= "table" then
433
+ unrecognised = stats
434
+ break
435
+ end
436
+
437
+ local instrumented = 0
438
+ local covered = 0
439
+ -- The lines that never ran are the entire point of asking, so they are
440
+ -- named rather than left to be inferred from a percentage.
441
+ local missed: { number } = {}
442
+
443
+ for line, count in hits :: { number } do
444
+ if typeof(count) ~= "number" or count < 0 then
445
+ continue
446
+ end
447
+ instrumented += 1
448
+ if count > 0 then
449
+ covered += 1
450
+ elseif #missed < MAX_UNCOVERED_LINES then
451
+ table.insert(missed, line)
452
+ end
453
+ end
454
+
455
+ table.sort(missed)
456
+
457
+ --[[
458
+ A destroyed script keeps its coverage record for the life of the
459
+ session, and `Paths.of` on something with no ancestry returns a bare
460
+ name -- so the report named "MCPProbeB" with no path, for a script
461
+ that no longer existed and could not be looked up. Measured by
462
+ destroying an instrumented ModuleScript and reading coverage back:
463
+ the row was still there, and still there on the call after it.
464
+
465
+ Dropped rather than flagged. Coverage answers "which lines of my code
466
+ ran", and a script that is gone has no lines anyone can go and read.
467
+ ]]
468
+ if typeof(target) == "Instance" and not (target :: Instance):IsDescendantOf(game) then
469
+ continue
470
+ end
471
+
472
+ table.insert(scripts, {
473
+ path = if typeof(target) == "Instance" then Paths.of(target) else tostring(target),
474
+ instrumentedLines = instrumented,
475
+ coveredLines = covered,
476
+ uncoveredLines = missed,
477
+ percent = if instrumented > 0
478
+ then math.floor(covered / instrumented * 1000 + 0.5) / 10
479
+ else 0,
480
+ --[[
481
+ Zero instrumented lines is not "already compiled when coverage
482
+ was switched on". That was the earlier reading here and it is
483
+ wrong: probed against ScriptContext, a script that ran before
484
+ being enabled gets NO record at all and never reaches this loop.
485
+
486
+ What it IS has not been pinned down. The session that produced
487
+ 0-line records could not be made to produce them again -- the
488
+ same create/enable/require sequence instrumented 6 of 6 lines on
489
+ the next attempt -- so no cause is claimed here. The flag exists
490
+ because 0/0 and 0/many render identically and mean opposite
491
+ things: no data, versus nothing ran.
492
+ ]]
493
+ notMeasurable = if instrumented == 0 then true else nil,
494
+ })
495
+ end
496
+
497
+ return {
498
+ enabled = enabled,
499
+ scripts = scripts,
500
+ raw = unrecognised,
501
+ -- What this session switched on for itself as it loaded, which is the
502
+ -- only thing that can instrument code running at startup.
503
+ carriedOver = if #carriedOver > 0 then carriedOver else nil,
504
+ carriedFailed = if #carriedFailed > 0 then carriedFailed else nil,
505
+ remembered = rememberedCoverage(),
506
+ }
507
+ end
508
+
509
+ function Perf.register(host: Plugin?)
510
+ pluginRef = host
511
+
512
+ --[[
513
+ Applied at load, which is the only moment early enough to matter. A
514
+ playtest's scripts run as its DataModel starts, so a session that waited
515
+ for a tool call to tell it what to instrument would already have missed
516
+ the run it was meant to measure.
517
+
518
+ Paths that no longer resolve are skipped rather than reported: the set is
519
+ remembered across sessions, and a script deleted in between is a stale
520
+ entry, not a failure worth surfacing here.
521
+ ]]
522
+ for _, path in rememberedCoverage() do
523
+ local ok, err = pcall(function()
524
+ ScriptContext:EnableCoverage(Paths.resolve(path))
525
+ end)
526
+ if ok then
527
+ table.insert(carriedOver, path)
528
+ else
529
+ -- Reported rather than swallowed. When this silently did nothing the
530
+ -- symptom was identical to the bug it was written to fix -- an empty
531
+ -- coverage read -- and there was no way to tell which half had failed.
532
+ table.insert(carriedFailed, { path = path, error = tostring(err) })
533
+ end
534
+ end
535
+
536
+ --[[
537
+ What this place is made of, and where its weight sits.
538
+
539
+ `SceneAnalysisService` answers questions the counters cannot. A snapshot says
540
+ memory is 2.4GB; this says which assets hold it and which instances are
541
+ responsible, broken down by category rather than by process.
542
+
543
+ The return shapes are undocumented -- the reference page lists the six
544
+ methods and names their result types without describing a single field -- so
545
+ what follows was read off a live session. Every one of them returns the same
546
+ recursive node: `{ Name, Size, Children }`, with two exceptions.
547
+ `GetTriangleCompositionAsync` carries `Sizes`, a dictionary of Triangles and
548
+ Drawcalls, in place of the single `Size`; and animation nodes add `AssetId`
549
+ and `Owners`, each owner being `{ Name, ClassName }`.
550
+
551
+ Sizes are counts for instance composition and bytes for the memory readings,
552
+ which the engine does not label either -- so this file labels them.
553
+ ]]
554
+ local function flatten(node: any, depth: number, into: { { [string]: any } })
555
+ if typeof(node) ~= "table" then
556
+ return
557
+ end
558
+ local children = (node :: any).Children
559
+ if typeof(children) ~= "table" then
560
+ return
561
+ end
562
+ for _, child in children do
563
+ local entry: { [string]: any } = {
564
+ name = tostring((child :: any).Name),
565
+ depth = depth,
566
+ }
567
+ local size = (child :: any).Size
568
+ if typeof(size) == "number" then
569
+ entry.size = size
570
+ end
571
+ local sizes = (child :: any).Sizes
572
+ if typeof(sizes) == "table" then
573
+ for key, value in sizes :: { [string]: any } do
574
+ entry[string.lower(tostring(key))] = value
575
+ end
576
+ end
577
+ local assetId = (child :: any).AssetId
578
+ if assetId ~= nil then
579
+ entry.assetId = tostring(assetId)
580
+ end
581
+ local owners = (child :: any).Owners
582
+ if typeof(owners) == "table" then
583
+ local names: { string } = {}
584
+ for _, owner in owners :: { any } do
585
+ if typeof(owner) == "table" then
586
+ table.insert(names, string.format("%s (%s)", tostring(owner.Name), tostring(owner.ClassName)))
587
+ end
588
+ end
589
+ entry.owners = names
590
+ end
591
+ table.insert(into, entry)
592
+
593
+ -- Two levels is the useful depth: "3D Objects -> MeshPart" answers the
594
+ -- question, while a third level is per-instance detail that `find` gives
595
+ -- better and on demand.
596
+ if depth < 2 then
597
+ flatten(child, depth + 1, into)
598
+ end
599
+ end
600
+ end
601
+
602
+ local SCENE_SECTIONS = {
603
+ { key = "composition", method = "GetInstanceCompositionAsync", unit = "instances" },
604
+ { key = "triangles", method = "GetTriangleCompositionAsync", unit = "triangles" },
605
+ { key = "scriptMemory", method = "GetScriptMemoryAsync", unit = "bytes" },
606
+ { key = "animationMemory", method = "GetAnimationMemoryAsync", unit = "bytes" },
607
+ { key = "audioMemory", method = "GetAudioMemoryAsync", unit = "bytes" },
608
+ { key = "unparented", method = "GetUnparentedInstancesAsync", unit = "instances" },
609
+ }
610
+
611
+ function Perf.scene(params: { [string]: any }): { [string]: any }
612
+ local only = if typeof(params.section) == "string" and params.section ~= "" then params.section else nil
613
+ local sections: { [string]: any } = {}
614
+
615
+ for _, spec in SCENE_SECTIONS do
616
+ if only ~= nil and only ~= spec.key then
617
+ continue
618
+ end
619
+ local ok, root = pcall(function()
620
+ return (SceneAnalysisService :: any)[spec.method](SceneAnalysisService)
621
+ end)
622
+ if not ok then
623
+ sections[spec.key] = { error = tostring(root) }
624
+ continue
625
+ end
626
+
627
+ local rows: { { [string]: any } } = {}
628
+ flatten(root, 1, rows)
629
+
630
+ local total = (root :: any).Size
631
+ local totals = (root :: any).Sizes
632
+ sections[spec.key] = {
633
+ total = if typeof(total) == "number" then total else nil,
634
+ totals = if typeof(totals) == "table" then totals else nil,
635
+ unit = spec.unit,
636
+ entries = rows,
637
+ }
638
+ end
639
+
640
+ if only ~= nil and sections[only] == nil then
641
+ Dispatch.fail(
642
+ "BAD_PARAMS",
643
+ string.format("unknown scene section %q", only),
644
+ "Sections are: composition, triangles, scriptMemory, animationMemory, audioMemory, unparented."
645
+ )
646
+ end
647
+
648
+ return sections
649
+ end
650
+
651
+ Dispatch.registerAll("perf", {
652
+ console = Perf.console,
653
+ snapshot = function()
654
+ return Perf.snapshot()
655
+ end,
656
+ profile = Perf.profile,
657
+ coverage = Perf.coverage,
658
+ scene = Perf.scene,
659
+ })
660
+ end
661
+
662
+ return Perf