@el4cteo/rbx-studio-mcp 0.1.0

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 (97) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +203 -0
  3. package/dist/bridge/rpc.js +243 -0
  4. package/dist/bridge/rpc.js.map +1 -0
  5. package/dist/bridge/server.js +281 -0
  6. package/dist/bridge/server.js.map +1 -0
  7. package/dist/index.js +104 -0
  8. package/dist/index.js.map +1 -0
  9. package/dist/lib/apidump.js +269 -0
  10. package/dist/lib/apidump.js.map +1 -0
  11. package/dist/lib/errors.js +38 -0
  12. package/dist/lib/errors.js.map +1 -0
  13. package/dist/lib/format.js +191 -0
  14. package/dist/lib/format.js.map +1 -0
  15. package/dist/lib/pluginbuild.js +83 -0
  16. package/dist/lib/pluginbuild.js.map +1 -0
  17. package/dist/lib/png.js +84 -0
  18. package/dist/lib/png.js.map +1 -0
  19. package/dist/lib/protocol.js +22 -0
  20. package/dist/lib/protocol.js.map +1 -0
  21. package/dist/lib/tool.js +27 -0
  22. package/dist/lib/tool.js.map +1 -0
  23. package/dist/resources.js +70 -0
  24. package/dist/resources.js.map +1 -0
  25. package/dist/tools/api.js +78 -0
  26. package/dist/tools/api.js.map +1 -0
  27. package/dist/tools/character.js +94 -0
  28. package/dist/tools/character.js.map +1 -0
  29. package/dist/tools/debug.js +211 -0
  30. package/dist/tools/debug.js.map +1 -0
  31. package/dist/tools/device.js +74 -0
  32. package/dist/tools/device.js.map +1 -0
  33. package/dist/tools/discover.js +217 -0
  34. package/dist/tools/discover.js.map +1 -0
  35. package/dist/tools/exec.js +191 -0
  36. package/dist/tools/exec.js.map +1 -0
  37. package/dist/tools/input.js +96 -0
  38. package/dist/tools/input.js.map +1 -0
  39. package/dist/tools/instances.js +261 -0
  40. package/dist/tools/instances.js.map +1 -0
  41. package/dist/tools/perf.js +367 -0
  42. package/dist/tools/perf.js.map +1 -0
  43. package/dist/tools/playtest.js +153 -0
  44. package/dist/tools/playtest.js.map +1 -0
  45. package/dist/tools/screenshot.js +75 -0
  46. package/dist/tools/screenshot.js.map +1 -0
  47. package/dist/tools/scripts.js +316 -0
  48. package/dist/tools/scripts.js.map +1 -0
  49. package/dist/tools/session.js +152 -0
  50. package/dist/tools/session.js.map +1 -0
  51. package/dist/tools/world.js +281 -0
  52. package/dist/tools/world.js.map +1 -0
  53. package/package.json +62 -0
  54. package/plugin/default.project.json +6 -0
  55. package/plugin/src/Config.luau +59 -0
  56. package/plugin/src/Console.luau +657 -0
  57. package/plugin/src/Context.luau +35 -0
  58. package/plugin/src/Dispatch.luau +90 -0
  59. package/plugin/src/Editor.luau +142 -0
  60. package/plugin/src/Emulation.luau +151 -0
  61. package/plugin/src/LogBuffer.luau +277 -0
  62. package/plugin/src/Net.luau +102 -0
  63. package/plugin/src/Paths.luau +255 -0
  64. package/plugin/src/Phrase.luau +465 -0
  65. package/plugin/src/Png.luau +238 -0
  66. package/plugin/src/Scope.luau +78 -0
  67. package/plugin/src/ScriptEdit.luau +100 -0
  68. package/plugin/src/Serialize.luau +287 -0
  69. package/plugin/src/TextEdit.luau +296 -0
  70. package/plugin/src/Transport.luau +328 -0
  71. package/plugin/src/Undo.luau +72 -0
  72. package/plugin/src/Visuals.luau +710 -0
  73. package/plugin/src/handlers/Api.luau +242 -0
  74. package/plugin/src/handlers/Assets.luau +145 -0
  75. package/plugin/src/handlers/Capture.luau +187 -0
  76. package/plugin/src/handlers/Character.luau +361 -0
  77. package/plugin/src/handlers/Debug.luau +391 -0
  78. package/plugin/src/handlers/Device.luau +119 -0
  79. package/plugin/src/handlers/Discover.luau +289 -0
  80. package/plugin/src/handlers/Exec.luau +270 -0
  81. package/plugin/src/handlers/Geometry.luau +261 -0
  82. package/plugin/src/handlers/Input.luau +287 -0
  83. package/plugin/src/handlers/Instances.luau +389 -0
  84. package/plugin/src/handlers/Perf.luau +645 -0
  85. package/plugin/src/handlers/Playtest.luau +205 -0
  86. package/plugin/src/handlers/Scripts.luau +387 -0
  87. package/plugin/src/handlers/Session.luau +168 -0
  88. package/plugin/src/handlers/Viewport.luau +302 -0
  89. package/plugin/src/handlers/World.luau +176 -0
  90. package/plugin/src/init.server.luau +317 -0
  91. package/scripts/build-plugin.mjs +157 -0
  92. package/scripts/check-plugin.mjs +97 -0
  93. package/scripts/install-plugin.mjs +39 -0
  94. package/scripts/latency.mjs +201 -0
  95. package/scripts/locate-luau.mjs +51 -0
  96. package/scripts/sourcemap.mjs +58 -0
  97. package/scripts/test-plugin.mjs +82 -0
@@ -0,0 +1,645 @@
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