@el4cteo/rbx-studio-mcp 0.7.7 → 0.7.8

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.
@@ -1,889 +1,913 @@
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
- local Scope = require(script.Parent.Parent.Scope)
25
-
26
- -- The log holds thousands of lines in a busy session; an unbounded tail would
27
- -- swamp any context window.
28
- local MAX_LOG_ENTRIES = 500
29
- local DEFAULT_LOG_ENTRIES = 100
30
-
31
- -- Profiling is a blocking wait, so the ceiling is what a caller will sit through
32
- -- rather than what the profiler can manage.
33
- local MAX_PROFILE_SECONDS = 30
34
- local DEFAULT_PROFILE_SECONDS = 5
35
- local PROFILE_DATA_TIMEOUT = 5
36
-
37
- -- Uncovered line numbers are listed, not just counted, but a script that never
38
- -- ran at all would otherwise list every line it has.
39
- local MAX_UNCOVERED_LINES = 50
40
-
41
- local Perf = {}
42
-
43
- local function round(value: number, places: number): number
44
- local scale = 10 ^ places
45
- return math.floor(value * scale + 0.5) / scale
46
- end
47
-
48
- --[[
49
- Reads a Stats property without assuming it exists. The set has changed across
50
- engine versions, and one missing counter should not fail the whole snapshot.
51
- ]]
52
- local function stat(name: string): number?
53
- local ok, value = pcall(function()
54
- return (Stats :: any)[name]
55
- end)
56
- if ok and typeof(value) == "number" then
57
- return value
58
- end
59
- return nil
60
- end
61
-
62
- local function millis(name: string): number?
63
- local seconds = stat(name)
64
- return if seconds then round(seconds * 1000, 3) else nil
65
- end
66
-
67
- local function flag(name: string): boolean?
68
- local ok, value = pcall(function()
69
- return (Stats :: any)[name]
70
- end)
71
- if ok and typeof(value) == "boolean" then
72
- return value
73
- end
74
- return nil
75
- end
76
-
77
- local function rounded(name: string, places: number): number?
78
- local value = stat(name)
79
- return if value then round(value, places) else nil
80
- end
81
-
82
- --[[
83
- The output log, newest last.
84
-
85
- Ordering matters: an agent reading a tail wants the most recent lines at the
86
- bottom, the same way the Output window reads, so an error and the lines that
87
- led to it stay in the order they happened.
88
- ]]
89
- function Perf.console(params: { [string]: any }): { [string]: any }
90
- local limit = math.clamp(tonumber(params.limit) or DEFAULT_LOG_ENTRIES, 1, MAX_LOG_ENTRIES)
91
- local level = params.level
92
- local pattern = params.pattern
93
-
94
- -- Read from our own buffer, not LogService:GetLogHistory(). See LogBuffer for
95
- -- why: the engine's history stops growing after startup in an editor session
96
- -- and is empty outright in a playtest, without reporting either as a failure.
97
- local history, evicted = LogBuffer.all()
98
-
99
- local entries: { { [string]: any } } = {}
100
- local matched = 0
101
-
102
- for _, item in history do
103
- local kind = item.level
104
- if level and kind ~= level then
105
- continue
106
- end
107
-
108
- local message = item.message
109
- if pattern then
110
- -- An invalid pattern raises rather than failing to match, so it is
111
- -- reported as a pattern problem instead of an empty result.
112
- local valid, position = pcall(string.find, message, pattern)
113
- if not valid then
114
- Dispatch.fail(
115
- "BAD_PATTERN",
116
- string.format("%s is not a valid Lua pattern: %s", pattern, tostring(position)),
117
- "Lua patterns escape with %, not backslash. Omit `pattern` to see everything."
118
- )
119
- end
120
- if position == nil then
121
- continue
122
- end
123
- end
124
-
125
- matched += 1
126
- table.insert(entries, {
127
- level = kind,
128
- message = message,
129
- timestamp = item.timestamp,
130
- stack = item.stack,
131
- source = item.source,
132
- })
133
- end
134
-
135
- -- Trimmed from the front: the newest lines are the ones worth keeping.
136
- local dropped = 0
137
- if #entries > limit then
138
- dropped = #entries - limit
139
- entries = table.move(entries, dropped + 1, #entries, 1, {})
140
- end
141
-
142
- return {
143
- items = entries,
144
- total = matched,
145
- dropped = dropped,
146
- -- Lines lost to the buffer's own capacity, as opposed to ones trimmed to
147
- -- satisfy this call's limit. Only the first is a reason to worry.
148
- evicted = if evicted > 0 then evicted else nil,
149
- recordingSeconds = LogBuffer.recordingSeconds(),
150
- }
151
- end
152
-
153
- --[[
154
- A snapshot of the engine's own counters -- what the Developer Console shows on
155
- its Performance and Memory tabs.
156
- ]]
157
- function Perf.snapshot(): { [string]: any }
158
- --[[
159
- Read one tag at a time, not with `GetMemoryUsageMbAllCategories`.
160
-
161
- The aggregate call is the obvious one and is closed to us: it now requires
162
- the InternalTest capability, so from a plugin it only ever raises "the
163
- current thread cannot call 'GetMemoryUsageMbAllCategories'". Measured, not
164
- assumed -- and the same refusal comes back in an editor session and in a
165
- playtest, so there is no context in which waiting for it pays off.
166
-
167
- `GetMemoryUsageMbForTag` carries no such restriction, and walking
168
- `Enum.DeveloperMemoryTag` reaches every category the Developer Console's
169
- Memory tab shows. Zero-valued tags are dropped so the list stays readable.
170
- ]]
171
- local memory: { { [string]: any } } = {}
172
- local memoryProblem: string? = nil
173
-
174
- for _, tag in Enum.DeveloperMemoryTag:GetEnumItems() do
175
- local ok, value = pcall(function()
176
- return Stats:GetMemoryUsageMbForTag(tag)
177
- end)
178
- if not ok then
179
- -- One refusal means the whole route is shut, not that this tag is
180
- -- special; stop rather than repeat the same failure for every item.
181
- memoryProblem = string.format("Stats refused the call: %s", tostring(value))
182
- break
183
- end
184
- if typeof(value) == "number" and value > 0 then
185
- table.insert(memory, { category = tag.Name, megabytes = round(value, 2) })
186
- end
187
- end
188
-
189
- table.sort(memory, function(a, b)
190
- return a.megabytes > b.megabytes
191
- end)
192
-
193
- local okTotal, total = pcall(function()
194
- return Stats:GetTotalMemoryUsageMb()
195
- end)
196
-
197
- --[[
198
- A playtest server has no renderer, so every drawing counter reads zero --
199
- zero draw calls, zero triangles, no frame time. Left unexplained that is
200
- the most flattering possible misreading of a place's performance, so the
201
- snapshot says outright that those numbers are absent rather than good.
202
- ]]
203
- local headless = RunService:IsRunning() and RunService:IsServer() and not RunService:IsClient()
204
-
205
- return {
206
- renderless = headless or nil,
207
- frame = {
208
- frameTimeMs = millis("FrameTime"),
209
- heartbeatTimeMs = millis("HeartbeatTime"),
210
- physicsStepTimeMs = millis("PhysicsStepTime"),
211
- renderCpuMs = millis("RenderCPUFrameTime"),
212
- renderGpuMs = millis("RenderGPUFrameTime"),
213
- },
214
- scene = {
215
- instances = stat("InstanceCount"),
216
- parts = stat("PrimitivesCount"),
217
- movingParts = stat("MovingPrimitivesCount"),
218
- contacts = stat("ContactsCount"),
219
- drawcalls = stat("SceneDrawcallCount"),
220
- triangles = stat("SceneTriangleCount"),
221
- },
222
- network = {
223
- receiveKbps = rounded("DataReceiveKbps", 1),
224
- sendKbps = rounded("DataSendKbps", 1),
225
- },
226
- memory = {
227
- totalMb = if okTotal and typeof(total) == "number" then round(total, 1) else nil,
228
- -- Studio only accumulates the per-category breakdown while this is on,
229
- -- so an empty list means "not measured" rather than "nothing used".
230
- trackingEnabled = flag("MemoryTrackingEnabled"),
231
- categories = memory,
232
- problem = if #memory == 0 then memoryProblem else nil,
233
- },
234
- }
235
- end
236
-
237
- --[[
238
- Runs the script profiler for a while and returns what it collected.
239
-
240
- This is the Script Performance window. The profiler samples at `frequency` Hz
241
- and reports back through an event rather than a return value, so the call
242
- starts it, waits, asks for the data, and stops -- blocking for the duration,
243
- which is why the ceiling is short.
244
-
245
- The payload's shape is Roblox's own and undocumented, so it is passed through
246
- rather than reshaped into something that might quietly misreport which script
247
- is expensive.
248
- ]]
249
- function Perf.profile(params: { [string]: any }): { [string]: any }
250
- local seconds = math.clamp(
251
- tonumber(params.seconds) or DEFAULT_PROFILE_SECONDS,
252
- 1,
253
- MAX_PROFILE_SECONDS
254
- )
255
- local frequency = math.clamp(tonumber(params.frequency) or 1000, 100, 10000)
256
-
257
- local received: string? = nil
258
- local connection = ScriptProfilerService.OnNewData:Connect(function(_player, jsonString)
259
- received = jsonString
260
- end)
261
-
262
- local started, startError = pcall(function()
263
- ScriptProfilerService:ServerStart(frequency)
264
- end)
265
- if not started then
266
- connection:Disconnect()
267
- Dispatch.fail(
268
- "PROFILER_UNAVAILABLE",
269
- string.format("Could not start the script profiler: %s", tostring(startError)),
270
- "The profiler is only available in Studio, and only one session at a time."
271
- )
272
- end
273
-
274
- task.wait(seconds)
275
-
276
- pcall(function()
277
- ScriptProfilerService:ServerRequestData()
278
- end)
279
-
280
- -- The data arrives on the event, so the request is followed by a bounded wait
281
- -- rather than assuming it has already landed.
282
- local deadline = os.clock() + PROFILE_DATA_TIMEOUT
283
- while received == nil and os.clock() < deadline do
284
- task.wait(0.1)
285
- end
286
-
287
- pcall(function()
288
- ScriptProfilerService:ServerStop()
289
- end)
290
- connection:Disconnect()
291
-
292
- if received == nil then
293
- Dispatch.fail(
294
- "NO_PROFILE_DATA",
295
- string.format("The profiler ran for %ds but returned no data.", seconds),
296
- "Nothing ran during the sample. Start a playtest first, or profile for longer."
297
- )
298
- end
299
-
300
- local okParse, parsed = pcall(function()
301
- return ScriptProfilerService:DeserializeJSON(received)
302
- end)
303
-
304
- return {
305
- seconds = seconds,
306
- frequency = frequency,
307
- data = if okParse then parsed else nil,
308
- raw = if okParse then nil else received,
309
- }
310
- end
311
-
312
- --[[
313
- Which scripts to instrument the moment a session starts.
314
-
315
- `EnableCoverage` applies to the DataModel it is called in, and a playtest is a
316
- different DataModel, built when Play is pressed. So enabling from the editor
317
- and then playing -- the sequence this tool used to instruct -- instruments the
318
- editor and measures the playtest, which is why it reported nothing at all: the
319
- editor never ran the code, and the playtest was never instrumented.
320
-
321
- Nothing can be enabled from outside in time either, because a playtest's
322
- scripts start with the DataModel. The request has to be waiting before the
323
- session exists, so it is remembered here and replayed by whichever session
324
- loads next.
325
-
326
- Kept per place. Plugin settings live in one file shared by every Studio
327
- process, so a path remembered while working on one game would otherwise be
328
- resolved against a different one.
329
- ]]
330
- local COVERAGE_SETTING = "coverageTargets"
331
- local pluginRef: Plugin? = nil
332
- local carriedOver: { string } = {}
333
- local carriedFailed: { { [string]: any } } = {}
334
-
335
- local function rememberCoverage(paths: { string })
336
- local host = pluginRef
337
- if host == nil then
338
- return
339
- end
340
- pcall(function()
341
- (host :: Plugin):SetSetting(COVERAGE_SETTING, {
342
- placeId = game.PlaceId,
343
- paths = paths,
344
- })
345
- end)
346
- end
347
-
348
- local function rememberedCoverage(): { string }
349
- local host = pluginRef
350
- if host == nil then
351
- return {}
352
- end
353
- local ok, stored = pcall(function()
354
- return (host :: Plugin):GetSetting(COVERAGE_SETTING)
355
- end)
356
- if not ok or typeof(stored) ~= "table" then
357
- return {}
358
- end
359
- local record = stored :: { [string]: any }
360
- if record.placeId ~= game.PlaceId or typeof(record.paths) ~= "table" then
361
- return {}
362
- end
363
- return record.paths :: { string }
364
- end
365
-
366
- --[[
367
- Line coverage: which lines of which scripts actually ran.
368
-
369
- Coverage has to be switched on for a script before that script executes, so a
370
- call that only reads the stats reports nothing on a first run. `enable` turns
371
- it on for named scripts and remembers them for the next session to switch on
372
- for itself; the caller then plays, and reads back from the playtest.
373
- ]]
374
- function Perf.coverage(params: { [string]: any }): { [string]: any }
375
- local enabled: { string } = {}
376
- -- Distinguished from absent on purpose: an explicit empty list is how a
377
- -- caller stops instrumenting every future session for this place.
378
- local requested = params.enable
379
- for _, path in (requested or {}) :: { string } do
380
- local target = Paths.resolve(path)
381
- local ok = pcall(function()
382
- ScriptContext:EnableCoverage(target)
383
- end)
384
- if ok then
385
- table.insert(enabled, Paths.of(target))
386
- end
387
- end
388
- if requested ~= nil then
389
- rememberCoverage(enabled)
390
- end
391
-
392
- local ok, stats = pcall(function()
393
- return ScriptContext:GetCoverageStats()
394
- end)
395
- if not ok or typeof(stats) ~= "table" then
396
- return { enabled = enabled, scripts = {}, problem = tostring(stats) }
397
- end
398
-
399
- --[[
400
- The payload is undocumented, so this is the shape measured from a real
401
- capture rather than a guess: each entry is `{ Script = Instance, GetHits =
402
- function }`, and `GetHits()` returns an array indexed by line number.
403
-
404
- The values carry three distinct meanings, and conflating them is what an
405
- earlier attempt here did:
406
- -1 the line cannot be instrumented at all -- blank, a comment, an `end`
407
- 0 instrumented and never executed: the lines worth reporting
408
- >0 the number of times it ran
409
-
410
- Counting every element as instrumented drags the denominator up by every
411
- blank line and comment in the file, which understates coverage on exactly
412
- the well-commented code most likely to be measured.
413
- ]]
414
- local scripts: { { [string]: any } } = {}
415
- local unrecognised: any = nil
416
-
417
- for _, entry in stats :: { any } do
418
- if typeof(entry) ~= "table" then
419
- unrecognised = stats
420
- break
421
- end
422
-
423
- local record = entry :: { [string]: any }
424
- local target = record.Script or record.script
425
- local getHits = record.GetHits
426
-
427
- if typeof(getHits) ~= "function" then
428
- unrecognised = stats
429
- break
430
- end
431
-
432
- local gotHits, hits = pcall(getHits, record)
433
- if not gotHits or typeof(hits) ~= "table" then
434
- unrecognised = stats
435
- break
436
- end
437
-
438
- local instrumented = 0
439
- local covered = 0
440
- -- The lines that never ran are the entire point of asking, so they are
441
- -- named rather than left to be inferred from a percentage.
442
- local missed: { number } = {}
443
-
444
- for line, count in hits :: { number } do
445
- if typeof(count) ~= "number" or count < 0 then
446
- continue
447
- end
448
- instrumented += 1
449
- if count > 0 then
450
- covered += 1
451
- elseif #missed < MAX_UNCOVERED_LINES then
452
- table.insert(missed, line)
453
- end
454
- end
455
-
456
- table.sort(missed)
457
-
458
- --[[
459
- A destroyed script keeps its coverage record for the life of the
460
- session, and `Paths.of` on something with no ancestry returns a bare
461
- name -- so the report named "MCPProbeB" with no path, for a script
462
- that no longer existed and could not be looked up. Measured by
463
- destroying an instrumented ModuleScript and reading coverage back:
464
- the row was still there, and still there on the call after it.
465
-
466
- Dropped rather than flagged. Coverage answers "which lines of my code
467
- ran", and a script that is gone has no lines anyone can go and read.
468
- ]]
469
- if typeof(target) == "Instance" and not (target :: Instance):IsDescendantOf(game) then
470
- continue
471
- end
472
-
473
- table.insert(scripts, {
474
- path = if typeof(target) == "Instance" then Paths.of(target) else tostring(target),
475
- instrumentedLines = instrumented,
476
- coveredLines = covered,
477
- uncoveredLines = missed,
478
- percent = if instrumented > 0
479
- then math.floor(covered / instrumented * 1000 + 0.5) / 10
480
- else 0,
481
- --[[
482
- Zero instrumented lines is not "already compiled when coverage
483
- was switched on". That was the earlier reading here and it is
484
- wrong: probed against ScriptContext, a script that ran before
485
- being enabled gets NO record at all and never reaches this loop.
486
-
487
- What it IS has not been pinned down. The session that produced
488
- 0-line records could not be made to produce them again -- the
489
- same create/enable/require sequence instrumented 6 of 6 lines on
490
- the next attempt -- so no cause is claimed here. The flag exists
491
- because 0/0 and 0/many render identically and mean opposite
492
- things: no data, versus nothing ran.
493
- ]]
494
- notMeasurable = if instrumented == 0 then true else nil,
495
- })
496
- end
497
-
498
- return {
499
- enabled = enabled,
500
- scripts = scripts,
501
- raw = unrecognised,
502
- -- What this session switched on for itself as it loaded, which is the
503
- -- only thing that can instrument code running at startup.
504
- carriedOver = if #carriedOver > 0 then carriedOver else nil,
505
- carriedFailed = if #carriedFailed > 0 then carriedFailed else nil,
506
- remembered = rememberedCoverage(),
507
- }
508
- end
509
-
510
- --[[
511
- Every reference in the place that points at nothing.
512
-
513
- A dead asset id is the quietest failure Roblox has. A Sound whose id was
514
- deleted, made private, or mistyped plays silence; a Decal shows nothing; an
515
- Animation does nothing. None of them errors, none of them warns, and the
516
- instance looks perfectly healthy in the Explorer -- the id is a string, and
517
- it is still a string. The only way to find them today is to play the game and
518
- notice something missing, which nobody does reliably for the twentieth sound
519
- effect.
520
-
521
- `ContentProvider:PreloadAsync` settles it. Measured: a real id came back
522
- `IsLoaded=true, TimeLength=0.632`, a dead one `IsLoaded=false, TimeLength=0`.
523
- So this collects every asset-bearing property in the place, asks the engine
524
- to fetch them, and reports the ones that did not arrive.
525
-
526
- One honest cost: preloading a dead id writes a line to Studio's Output
527
- window. That is the engine talking, not this tool, and it corroborates the
528
- finding rather than contradicting it -- but it is the user's Output window,
529
- so the tool says up front that it will happen.
530
- ]]
531
-
532
- --[[
533
- Assets fetched in one audit.
534
-
535
- Preloading is network work, and a place with two thousand sounds would spend
536
- minutes on it while Studio sat still. The cap is generous enough to cover
537
- most places whole and small enough that the worst case is seconds, and the
538
- reply says when it stopped short rather than implying it checked everything.
539
- ]]
540
- local MAX_ASSETS = 200
541
-
542
- --[[
543
- How long the whole fetch may take before this gives up on it.
544
-
545
- Long enough for two hundred ordinary assets over a slow connection, short
546
- enough that a stuck fetch is a slow answer rather than a hung Studio.
547
- ]]
548
- local PRELOAD_SECONDS = 20
549
-
550
- --[[
551
- Which property on which class holds a content id.
552
-
553
- Written out rather than discovered from the API dump, because "a string
554
- property whose name sounds like an asset" catches `Name` and misses
555
- `MeshContent`. The list is short, and being wrong here means reporting a
556
- healthy place as broken.
557
- ]]
558
- local ASSET_FIELDS: { { className: string, property: string } } = {
559
- { className = "Sound", property = "SoundId" },
560
- { className = "Decal", property = "Texture" },
561
- { className = "Texture", property = "Texture" },
562
- { className = "ImageLabel", property = "Image" },
563
- { className = "ImageButton", property = "Image" },
564
- { className = "Animation", property = "AnimationId" },
565
- { className = "Sky", property = "SkyboxUp" },
566
- { className = "ParticleEmitter", property = "Texture" },
567
- { className = "Beam", property = "Texture" },
568
- { className = "Trail", property = "Texture" },
569
- }
570
-
571
- function Perf.audit(params: { [string]: any }): { [string]: any }
572
- local ContentProvider = game:GetService("ContentProvider")
573
-
574
- local unset: { { [string]: any } } = {}
575
- local candidates: { { instance: Instance, id: string, property: string } } = {}
576
- local disabled: { string } = {}
577
- local duplicates: { string } = {}
578
-
579
- local seenNames: { [string]: boolean } = {}
580
-
581
- for _, descendant in game:GetDescendants() do
582
- if Scope.isNoisy(descendant) then
583
- continue
584
- end
585
-
586
- for _, field in ASSET_FIELDS do
587
- if not descendant:IsA(field.className) then
588
- continue
589
- end
590
- local ok, value = pcall(function()
591
- return (descendant :: any)[field.property]
592
- end)
593
- if not ok or typeof(value) ~= "string" then
594
- continue
595
- end
596
- if value == "" then
597
- table.insert(unset, { path = Paths.of(descendant), property = field.property })
598
- elseif #candidates < MAX_ASSETS then
599
- table.insert(candidates, { instance = descendant, id = value, property = field.property })
600
- end
601
- break
602
- end
603
-
604
- --[[
605
- `Disabled` exists on BaseScript, not on ModuleScript -- reading it off
606
- the wrong one throws, which is how this check failed the first time it
607
- was written. Narrowed to the class that has it.
608
- ]]
609
- if descendant:IsA("BaseScript") and (descendant :: any).Disabled == true then
610
- if #disabled < 25 then
611
- table.insert(disabled, Paths.of(descendant))
612
- end
613
- end
614
-
615
- --[[
616
- Same-named siblings, which is what breaks `WaitForChild`: it returns
617
- whichever one the engine reaches first, and that is not stable.
618
-
619
- Geometry is excluded, and that exclusion is the whole value of the
620
- check. Measured on a real place: the first version reported 25
621
- duplicates and every one of them was a wall bar, a bench or a table
622
- leg in a blockout -- decorative parts nobody ever looks up by name.
623
- The real findings were buried under them.
624
-
625
- A duplicated script, RemoteEvent, value or GUI element is a different
626
- matter: those exist to be found by name, and two of them means code
627
- somewhere is reaching for whichever the engine happened to index
628
- first.
629
- ]]
630
- local parent = descendant.Parent
631
- if parent ~= nil and not descendant:IsA("BasePart") and not descendant:IsA("Attachment") then
632
- local key = tostring(parent:GetDebugId(8)) .. "/" .. descendant.Name
633
- if seenNames[key] and #duplicates < 25 then
634
- table.insert(duplicates, Paths.of(descendant))
635
- end
636
- seenNames[key] = true
637
- end
638
- end
639
-
640
- --[[
641
- Preloaded in one call rather than one per asset.
642
-
643
- `PreloadAsync` takes the whole list and fetches in parallel; asking for
644
- them one at a time would serialise two hundred network round trips into
645
- something that looks like a hang.
646
- ]]
647
- local toLoad: { Instance } = {}
648
- for _, entry in candidates do
649
- table.insert(toLoad, entry.instance)
650
- end
651
- --[[
652
- Given a deadline, because `PreloadAsync` has none of its own.
653
-
654
- It blocks until every id in the list resolves, and an id that resolves
655
- slowly -- or a network that has stopped answering -- blocks it for as
656
- long as that takes. Measured: an audit against a running playtest sat
657
- still long enough for the request to time out and the playtest to end
658
- with it. A tool that can stop Studio responding is worse than one that
659
- reports less.
660
-
661
- Run on its own thread and waited on here, so the wait can end without
662
- the work being cancelled -- there is no way to cancel it, and pretending
663
- otherwise would leave a thread writing into a finished request.
664
- ]]
665
- local preloaded = true
666
- if #toLoad > 0 then
667
- local done = false
668
- task.spawn(function()
669
- pcall(function()
670
- ContentProvider:PreloadAsync(toLoad)
671
- end)
672
- done = true
673
- end)
674
-
675
- local waited = 0
676
- while not done and waited < PRELOAD_SECONDS do
677
- waited += task.wait(0.05)
678
- end
679
- preloaded = done
680
- end
681
-
682
- local dead: { { [string]: any } } = {}
683
- for _, entry in candidates do
684
- local instance = entry.instance
685
- local loaded = true
686
- if instance:IsA("Sound") then
687
- -- A Sound that fetched has a length; one that did not is still zero.
688
- loaded = (instance :: Sound).TimeLength > 0
689
- else
690
- local ok, value = pcall(function()
691
- return (instance :: any).IsLoaded
692
- end)
693
- if ok and typeof(value) == "boolean" then
694
- loaded = value
695
- end
696
- end
697
- --[[
698
- Nothing is called dead once the fetch has been abandoned.
699
-
700
- An id that simply had not arrived yet is indistinguishable here from
701
- one that points at nothing, and "this sound is broken" about a sound
702
- that is fine is the one answer this check must never give.
703
- ]]
704
- if not loaded and not preloaded then
705
- continue
706
- end
707
- if not loaded and #dead < 50 then
708
- table.insert(dead, {
709
- path = Paths.of(instance),
710
- property = entry.property,
711
- id = entry.id,
712
- })
713
- end
714
- end
715
-
716
- return {
717
- checked = #candidates,
718
- -- Reported beside the fetched count, because "3 checked" on a place with
719
- -- fourteen blank ids reads as if the audit barely ran. A blank id needs
720
- -- no fetch to be judged; it is still a reference that points nowhere.
721
- scanned = #candidates + #unset,
722
- truncated = #candidates >= MAX_ASSETS,
723
- -- Said out loud, because an empty `dead` list from an abandoned fetch
724
- -- reads exactly like a clean place.
725
- incomplete = if not preloaded then true else nil,
726
- dead = dead,
727
- deadCount = #dead,
728
- unset = unset,
729
- unsetCount = #unset,
730
- disabledScripts = disabled,
731
- duplicateNames = duplicates,
732
- }
733
- end
734
-
735
- function Perf.register(host: Plugin?)
736
- pluginRef = host
737
-
738
- --[[
739
- Applied at load, which is the only moment early enough to matter. A
740
- playtest's scripts run as its DataModel starts, so a session that waited
741
- for a tool call to tell it what to instrument would already have missed
742
- the run it was meant to measure.
743
-
744
- Paths that no longer resolve are skipped rather than reported: the set is
745
- remembered across sessions, and a script deleted in between is a stale
746
- entry, not a failure worth surfacing here.
747
- ]]
748
- for _, path in rememberedCoverage() do
749
- local ok, err = pcall(function()
750
- ScriptContext:EnableCoverage(Paths.resolve(path))
751
- end)
752
- if ok then
753
- table.insert(carriedOver, path)
754
- else
755
- -- Reported rather than swallowed. When this silently did nothing the
756
- -- symptom was identical to the bug it was written to fix -- an empty
757
- -- coverage read -- and there was no way to tell which half had failed.
758
- table.insert(carriedFailed, { path = path, error = tostring(err) })
759
- end
760
- end
761
-
762
- --[[
763
- What this place is made of, and where its weight sits.
764
-
765
- `SceneAnalysisService` answers questions the counters cannot. A snapshot says
766
- memory is 2.4GB; this says which assets hold it and which instances are
767
- responsible, broken down by category rather than by process.
768
-
769
- The return shapes are undocumented -- the reference page lists the six
770
- methods and names their result types without describing a single field -- so
771
- what follows was read off a live session. Every one of them returns the same
772
- recursive node: `{ Name, Size, Children }`, with two exceptions.
773
- `GetTriangleCompositionAsync` carries `Sizes`, a dictionary of Triangles and
774
- Drawcalls, in place of the single `Size`; and animation nodes add `AssetId`
775
- and `Owners`, each owner being `{ Name, ClassName }`.
776
-
777
- Sizes are counts for instance composition and bytes for the memory readings,
778
- which the engine does not label either -- so this file labels them.
779
- ]]
780
- local function flatten(node: any, depth: number, into: { { [string]: any } })
781
- if typeof(node) ~= "table" then
782
- return
783
- end
784
- local children = (node :: any).Children
785
- if typeof(children) ~= "table" then
786
- return
787
- end
788
- for _, child in children do
789
- local entry: { [string]: any } = {
790
- name = tostring((child :: any).Name),
791
- depth = depth,
792
- }
793
- local size = (child :: any).Size
794
- if typeof(size) == "number" then
795
- entry.size = size
796
- end
797
- local sizes = (child :: any).Sizes
798
- if typeof(sizes) == "table" then
799
- for key, value in sizes :: { [string]: any } do
800
- entry[string.lower(tostring(key))] = value
801
- end
802
- end
803
- local assetId = (child :: any).AssetId
804
- if assetId ~= nil then
805
- entry.assetId = tostring(assetId)
806
- end
807
- local owners = (child :: any).Owners
808
- if typeof(owners) == "table" then
809
- local names: { string } = {}
810
- for _, owner in owners :: { any } do
811
- if typeof(owner) == "table" then
812
- table.insert(names, string.format("%s (%s)", tostring(owner.Name), tostring(owner.ClassName)))
813
- end
814
- end
815
- entry.owners = names
816
- end
817
- table.insert(into, entry)
818
-
819
- -- Two levels is the useful depth: "3D Objects -> MeshPart" answers the
820
- -- question, while a third level is per-instance detail that `find` gives
821
- -- better and on demand.
822
- if depth < 2 then
823
- flatten(child, depth + 1, into)
824
- end
825
- end
826
- end
827
-
828
- local SCENE_SECTIONS = {
829
- { key = "composition", method = "GetInstanceCompositionAsync", unit = "instances" },
830
- { key = "triangles", method = "GetTriangleCompositionAsync", unit = "triangles" },
831
- { key = "scriptMemory", method = "GetScriptMemoryAsync", unit = "bytes" },
832
- { key = "animationMemory", method = "GetAnimationMemoryAsync", unit = "bytes" },
833
- { key = "audioMemory", method = "GetAudioMemoryAsync", unit = "bytes" },
834
- { key = "unparented", method = "GetUnparentedInstancesAsync", unit = "instances" },
835
- }
836
-
837
- function Perf.scene(params: { [string]: any }): { [string]: any }
838
- local only = if typeof(params.section) == "string" and params.section ~= "" then params.section else nil
839
- local sections: { [string]: any } = {}
840
-
841
- for _, spec in SCENE_SECTIONS do
842
- if only ~= nil and only ~= spec.key then
843
- continue
844
- end
845
- local ok, root = pcall(function()
846
- return (SceneAnalysisService :: any)[spec.method](SceneAnalysisService)
847
- end)
848
- if not ok then
849
- sections[spec.key] = { error = tostring(root) }
850
- continue
851
- end
852
-
853
- local rows: { { [string]: any } } = {}
854
- flatten(root, 1, rows)
855
-
856
- local total = (root :: any).Size
857
- local totals = (root :: any).Sizes
858
- sections[spec.key] = {
859
- total = if typeof(total) == "number" then total else nil,
860
- totals = if typeof(totals) == "table" then totals else nil,
861
- unit = spec.unit,
862
- entries = rows,
863
- }
864
- end
865
-
866
- if only ~= nil and sections[only] == nil then
867
- Dispatch.fail(
868
- "BAD_PARAMS",
869
- string.format("unknown scene section %q", only),
870
- "Sections are: composition, triangles, scriptMemory, animationMemory, audioMemory, unparented."
871
- )
872
- end
873
-
874
- return sections
875
- end
876
-
877
- Dispatch.registerAll("perf", {
878
- audit = Perf.audit,
879
- console = Perf.console,
880
- snapshot = function()
881
- return Perf.snapshot()
882
- end,
883
- profile = Perf.profile,
884
- coverage = Perf.coverage,
885
- scene = Perf.scene,
886
- })
887
- end
888
-
889
- 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 ClientRelay = require(script.Parent.Parent.ClientRelay)
23
+ local LogBuffer = require(script.Parent.Parent.LogBuffer)
24
+ local Paths = require(script.Parent.Parent.Paths)
25
+ local Scope = require(script.Parent.Parent.Scope)
26
+
27
+ -- The log holds thousands of lines in a busy session; an unbounded tail would
28
+ -- swamp any context window.
29
+ local MAX_LOG_ENTRIES = 500
30
+ local DEFAULT_LOG_ENTRIES = 100
31
+
32
+ -- Profiling is a blocking wait, so the ceiling is what a caller will sit through
33
+ -- rather than what the profiler can manage.
34
+ local MAX_PROFILE_SECONDS = 30
35
+ local DEFAULT_PROFILE_SECONDS = 5
36
+ local PROFILE_DATA_TIMEOUT = 5
37
+
38
+ -- Uncovered line numbers are listed, not just counted, but a script that never
39
+ -- ran at all would otherwise list every line it has.
40
+ local MAX_UNCOVERED_LINES = 50
41
+
42
+ local Perf = {}
43
+
44
+ local function round(value: number, places: number): number
45
+ local scale = 10 ^ places
46
+ return math.floor(value * scale + 0.5) / scale
47
+ end
48
+
49
+ --[[
50
+ Reads a Stats property without assuming it exists. The set has changed across
51
+ engine versions, and one missing counter should not fail the whole snapshot.
52
+ ]]
53
+ local function stat(name: string): number?
54
+ local ok, value = pcall(function()
55
+ return (Stats :: any)[name]
56
+ end)
57
+ if ok and typeof(value) == "number" then
58
+ return value
59
+ end
60
+ return nil
61
+ end
62
+
63
+ local function millis(name: string): number?
64
+ local seconds = stat(name)
65
+ return if seconds then round(seconds * 1000, 3) else nil
66
+ end
67
+
68
+ local function flag(name: string): boolean?
69
+ local ok, value = pcall(function()
70
+ return (Stats :: any)[name]
71
+ end)
72
+ if ok and typeof(value) == "boolean" then
73
+ return value
74
+ end
75
+ return nil
76
+ end
77
+
78
+ local function rounded(name: string, places: number): number?
79
+ local value = stat(name)
80
+ return if value then round(value, places) else nil
81
+ end
82
+
83
+ --[[
84
+ The output log, newest last.
85
+
86
+ Ordering matters: an agent reading a tail wants the most recent lines at the
87
+ bottom, the same way the Output window reads, so an error and the lines that
88
+ led to it stay in the order they happened.
89
+ ]]
90
+ function Perf.console(params: { [string]: any }): { [string]: any }
91
+ local limit = math.clamp(tonumber(params.limit) or DEFAULT_LOG_ENTRIES, 1, MAX_LOG_ENTRIES)
92
+ local level = params.level
93
+ local pattern = params.pattern
94
+ local target = params.target or "studio"
95
+ if target ~= "studio" and target ~= "client" then Dispatch.fail("BAD_PARAMS", "target must be studio or client") end
96
+ local player = if target == "client" then ClientRelay.playerFor(params.player) else nil
97
+
98
+ -- Read from our own buffer, not LogService:GetLogHistory(). See LogBuffer for
99
+ -- why: the engine's history stops growing after startup in an editor session
100
+ -- and is empty outright in a playtest, without reporting either as a failure.
101
+ local history, evicted, token, latest, recordingSeconds = LogBuffer.stream(player)
102
+ local since: number? = nil
103
+ if params.since ~= nil then
104
+ local supplied = params.since
105
+ local cursorToken: string? = nil
106
+ local cursorSequence: string? = nil
107
+ if typeof(supplied) == "string" then
108
+ cursorToken, cursorSequence = string.match(supplied, "^([^:]+):(%d+)$")
109
+ end
110
+ local parsed = tonumber(cursorSequence)
111
+ if cursorToken ~= token or parsed == nil or parsed > latest then
112
+ Dispatch.fail("BAD_CURSOR", "This console cursor belongs to another session or is invalid.", "Start a fresh read without `since`.")
113
+ end
114
+ since = parsed
115
+ end
116
+ local oldest = if #history > 0 then history[1].sequence or 1 else latest + 1
117
+ local missed = if since ~= nil then math.max(0, oldest - since - 1) else 0
118
+
119
+ local entries: { { [string]: any } } = {}
120
+ local matched = 0
121
+
122
+ for _, item in history do
123
+ if since ~= nil and (item.sequence or 0) <= since then continue end
124
+ local kind = item.level
125
+ if level and kind ~= level then
126
+ continue
127
+ end
128
+
129
+ local message = item.message
130
+ if pattern then
131
+ -- An invalid pattern raises rather than failing to match, so it is
132
+ -- reported as a pattern problem instead of an empty result.
133
+ local valid, position = pcall(string.find, message, pattern)
134
+ if not valid then
135
+ Dispatch.fail(
136
+ "BAD_PATTERN",
137
+ string.format("%s is not a valid Lua pattern: %s", pattern, tostring(position)),
138
+ "Lua patterns escape with %, not backslash. Omit `pattern` to see everything."
139
+ )
140
+ end
141
+ if position == nil then
142
+ continue
143
+ end
144
+ end
145
+
146
+ matched += 1
147
+ table.insert(entries, {
148
+ level = kind,
149
+ message = message,
150
+ timestamp = item.timestamp,
151
+ stack = item.stack,
152
+ source = item.source,
153
+ })
154
+ end
155
+
156
+ -- Trimmed from the front: the newest lines are the ones worth keeping.
157
+ local dropped = 0
158
+ if #entries > limit then
159
+ dropped = #entries - limit
160
+ entries = table.move(entries, dropped + 1, #entries, 1, {})
161
+ end
162
+
163
+ return {
164
+ items = entries,
165
+ total = matched,
166
+ dropped = dropped,
167
+ -- Lines lost to the buffer's own capacity, as opposed to ones trimmed to
168
+ -- satisfy this call's limit. Only the first is a reason to worry.
169
+ evicted = if since ~= nil then (if missed > 0 then missed else nil) else (if evicted > 0 then evicted else nil),
170
+ recordingSeconds = recordingSeconds,
171
+ nextCursor = token .. ":" .. tostring(latest),
172
+ player = if player then player.Name else nil,
173
+ capturing = if player then LogBuffer.clientReady(player) else true,
174
+ }
175
+ end
176
+
177
+ --[[
178
+ A snapshot of the engine's own counters -- what the Developer Console shows on
179
+ its Performance and Memory tabs.
180
+ ]]
181
+ function Perf.snapshot(): { [string]: any }
182
+ --[[
183
+ Read one tag at a time, not with `GetMemoryUsageMbAllCategories`.
184
+
185
+ The aggregate call is the obvious one and is closed to us: it now requires
186
+ the InternalTest capability, so from a plugin it only ever raises "the
187
+ current thread cannot call 'GetMemoryUsageMbAllCategories'". Measured, not
188
+ assumed -- and the same refusal comes back in an editor session and in a
189
+ playtest, so there is no context in which waiting for it pays off.
190
+
191
+ `GetMemoryUsageMbForTag` carries no such restriction, and walking
192
+ `Enum.DeveloperMemoryTag` reaches every category the Developer Console's
193
+ Memory tab shows. Zero-valued tags are dropped so the list stays readable.
194
+ ]]
195
+ local memory: { { [string]: any } } = {}
196
+ local memoryProblem: string? = nil
197
+
198
+ for _, tag in Enum.DeveloperMemoryTag:GetEnumItems() do
199
+ local ok, value = pcall(function()
200
+ return Stats:GetMemoryUsageMbForTag(tag)
201
+ end)
202
+ if not ok then
203
+ -- One refusal means the whole route is shut, not that this tag is
204
+ -- special; stop rather than repeat the same failure for every item.
205
+ memoryProblem = string.format("Stats refused the call: %s", tostring(value))
206
+ break
207
+ end
208
+ if typeof(value) == "number" and value > 0 then
209
+ table.insert(memory, { category = tag.Name, megabytes = round(value, 2) })
210
+ end
211
+ end
212
+
213
+ table.sort(memory, function(a, b)
214
+ return a.megabytes > b.megabytes
215
+ end)
216
+
217
+ local okTotal, total = pcall(function()
218
+ return Stats:GetTotalMemoryUsageMb()
219
+ end)
220
+
221
+ --[[
222
+ A playtest server has no renderer, so every drawing counter reads zero --
223
+ zero draw calls, zero triangles, no frame time. Left unexplained that is
224
+ the most flattering possible misreading of a place's performance, so the
225
+ snapshot says outright that those numbers are absent rather than good.
226
+ ]]
227
+ local headless = RunService:IsRunning() and RunService:IsServer() and not RunService:IsClient()
228
+
229
+ return {
230
+ renderless = headless or nil,
231
+ frame = {
232
+ frameTimeMs = millis("FrameTime"),
233
+ heartbeatTimeMs = millis("HeartbeatTime"),
234
+ physicsStepTimeMs = millis("PhysicsStepTime"),
235
+ renderCpuMs = millis("RenderCPUFrameTime"),
236
+ renderGpuMs = millis("RenderGPUFrameTime"),
237
+ },
238
+ scene = {
239
+ instances = stat("InstanceCount"),
240
+ parts = stat("PrimitivesCount"),
241
+ movingParts = stat("MovingPrimitivesCount"),
242
+ contacts = stat("ContactsCount"),
243
+ drawcalls = stat("SceneDrawcallCount"),
244
+ triangles = stat("SceneTriangleCount"),
245
+ },
246
+ network = {
247
+ receiveKbps = rounded("DataReceiveKbps", 1),
248
+ sendKbps = rounded("DataSendKbps", 1),
249
+ },
250
+ memory = {
251
+ totalMb = if okTotal and typeof(total) == "number" then round(total, 1) else nil,
252
+ -- Studio only accumulates the per-category breakdown while this is on,
253
+ -- so an empty list means "not measured" rather than "nothing used".
254
+ trackingEnabled = flag("MemoryTrackingEnabled"),
255
+ categories = memory,
256
+ problem = if #memory == 0 then memoryProblem else nil,
257
+ },
258
+ }
259
+ end
260
+
261
+ --[[
262
+ Runs the script profiler for a while and returns what it collected.
263
+
264
+ This is the Script Performance window. The profiler samples at `frequency` Hz
265
+ and reports back through an event rather than a return value, so the call
266
+ starts it, waits, asks for the data, and stops -- blocking for the duration,
267
+ which is why the ceiling is short.
268
+
269
+ The payload's shape is Roblox's own and undocumented, so it is passed through
270
+ rather than reshaped into something that might quietly misreport which script
271
+ is expensive.
272
+ ]]
273
+ function Perf.profile(params: { [string]: any }): { [string]: any }
274
+ local seconds = math.clamp(
275
+ tonumber(params.seconds) or DEFAULT_PROFILE_SECONDS,
276
+ 1,
277
+ MAX_PROFILE_SECONDS
278
+ )
279
+ local frequency = math.clamp(tonumber(params.frequency) or 1000, 100, 10000)
280
+
281
+ local received: string? = nil
282
+ local connection = ScriptProfilerService.OnNewData:Connect(function(_player, jsonString)
283
+ received = jsonString
284
+ end)
285
+
286
+ local started, startError = pcall(function()
287
+ ScriptProfilerService:ServerStart(frequency)
288
+ end)
289
+ if not started then
290
+ connection:Disconnect()
291
+ Dispatch.fail(
292
+ "PROFILER_UNAVAILABLE",
293
+ string.format("Could not start the script profiler: %s", tostring(startError)),
294
+ "The profiler is only available in Studio, and only one session at a time."
295
+ )
296
+ end
297
+
298
+ task.wait(seconds)
299
+
300
+ pcall(function()
301
+ ScriptProfilerService:ServerRequestData()
302
+ end)
303
+
304
+ -- The data arrives on the event, so the request is followed by a bounded wait
305
+ -- rather than assuming it has already landed.
306
+ local deadline = os.clock() + PROFILE_DATA_TIMEOUT
307
+ while received == nil and os.clock() < deadline do
308
+ task.wait(0.1)
309
+ end
310
+
311
+ pcall(function()
312
+ ScriptProfilerService:ServerStop()
313
+ end)
314
+ connection:Disconnect()
315
+
316
+ if received == nil then
317
+ Dispatch.fail(
318
+ "NO_PROFILE_DATA",
319
+ string.format("The profiler ran for %ds but returned no data.", seconds),
320
+ "Nothing ran during the sample. Start a playtest first, or profile for longer."
321
+ )
322
+ end
323
+
324
+ local okParse, parsed = pcall(function()
325
+ return ScriptProfilerService:DeserializeJSON(received)
326
+ end)
327
+
328
+ return {
329
+ seconds = seconds,
330
+ frequency = frequency,
331
+ data = if okParse then parsed else nil,
332
+ raw = if okParse then nil else received,
333
+ }
334
+ end
335
+
336
+ --[[
337
+ Which scripts to instrument the moment a session starts.
338
+
339
+ `EnableCoverage` applies to the DataModel it is called in, and a playtest is a
340
+ different DataModel, built when Play is pressed. So enabling from the editor
341
+ and then playing -- the sequence this tool used to instruct -- instruments the
342
+ editor and measures the playtest, which is why it reported nothing at all: the
343
+ editor never ran the code, and the playtest was never instrumented.
344
+
345
+ Nothing can be enabled from outside in time either, because a playtest's
346
+ scripts start with the DataModel. The request has to be waiting before the
347
+ session exists, so it is remembered here and replayed by whichever session
348
+ loads next.
349
+
350
+ Kept per place. Plugin settings live in one file shared by every Studio
351
+ process, so a path remembered while working on one game would otherwise be
352
+ resolved against a different one.
353
+ ]]
354
+ local COVERAGE_SETTING = "coverageTargets"
355
+ local pluginRef: Plugin? = nil
356
+ local carriedOver: { string } = {}
357
+ local carriedFailed: { { [string]: any } } = {}
358
+
359
+ local function rememberCoverage(paths: { string })
360
+ local host = pluginRef
361
+ if host == nil then
362
+ return
363
+ end
364
+ pcall(function()
365
+ (host :: Plugin):SetSetting(COVERAGE_SETTING, {
366
+ placeId = game.PlaceId,
367
+ paths = paths,
368
+ })
369
+ end)
370
+ end
371
+
372
+ local function rememberedCoverage(): { string }
373
+ local host = pluginRef
374
+ if host == nil then
375
+ return {}
376
+ end
377
+ local ok, stored = pcall(function()
378
+ return (host :: Plugin):GetSetting(COVERAGE_SETTING)
379
+ end)
380
+ if not ok or typeof(stored) ~= "table" then
381
+ return {}
382
+ end
383
+ local record = stored :: { [string]: any }
384
+ if record.placeId ~= game.PlaceId or typeof(record.paths) ~= "table" then
385
+ return {}
386
+ end
387
+ return record.paths :: { string }
388
+ end
389
+
390
+ --[[
391
+ Line coverage: which lines of which scripts actually ran.
392
+
393
+ Coverage has to be switched on for a script before that script executes, so a
394
+ call that only reads the stats reports nothing on a first run. `enable` turns
395
+ it on for named scripts and remembers them for the next session to switch on
396
+ for itself; the caller then plays, and reads back from the playtest.
397
+ ]]
398
+ function Perf.coverage(params: { [string]: any }): { [string]: any }
399
+ local enabled: { string } = {}
400
+ -- Distinguished from absent on purpose: an explicit empty list is how a
401
+ -- caller stops instrumenting every future session for this place.
402
+ local requested = params.enable
403
+ for _, path in (requested or {}) :: { string } do
404
+ local target = Paths.resolve(path)
405
+ local ok = pcall(function()
406
+ ScriptContext:EnableCoverage(target)
407
+ end)
408
+ if ok then
409
+ table.insert(enabled, Paths.of(target))
410
+ end
411
+ end
412
+ if requested ~= nil then
413
+ rememberCoverage(enabled)
414
+ end
415
+
416
+ local ok, stats = pcall(function()
417
+ return ScriptContext:GetCoverageStats()
418
+ end)
419
+ if not ok or typeof(stats) ~= "table" then
420
+ return { enabled = enabled, scripts = {}, problem = tostring(stats) }
421
+ end
422
+
423
+ --[[
424
+ The payload is undocumented, so this is the shape measured from a real
425
+ capture rather than a guess: each entry is `{ Script = Instance, GetHits =
426
+ function }`, and `GetHits()` returns an array indexed by line number.
427
+
428
+ The values carry three distinct meanings, and conflating them is what an
429
+ earlier attempt here did:
430
+ -1 the line cannot be instrumented at all -- blank, a comment, an `end`
431
+ 0 instrumented and never executed: the lines worth reporting
432
+ >0 the number of times it ran
433
+
434
+ Counting every element as instrumented drags the denominator up by every
435
+ blank line and comment in the file, which understates coverage on exactly
436
+ the well-commented code most likely to be measured.
437
+ ]]
438
+ local scripts: { { [string]: any } } = {}
439
+ local unrecognised: any = nil
440
+
441
+ for _, entry in stats :: { any } do
442
+ if typeof(entry) ~= "table" then
443
+ unrecognised = stats
444
+ break
445
+ end
446
+
447
+ local record = entry :: { [string]: any }
448
+ local target = record.Script or record.script
449
+ local getHits = record.GetHits
450
+
451
+ if typeof(getHits) ~= "function" then
452
+ unrecognised = stats
453
+ break
454
+ end
455
+
456
+ local gotHits, hits = pcall(getHits, record)
457
+ if not gotHits or typeof(hits) ~= "table" then
458
+ unrecognised = stats
459
+ break
460
+ end
461
+
462
+ local instrumented = 0
463
+ local covered = 0
464
+ -- The lines that never ran are the entire point of asking, so they are
465
+ -- named rather than left to be inferred from a percentage.
466
+ local missed: { number } = {}
467
+
468
+ for line, count in hits :: { number } do
469
+ if typeof(count) ~= "number" or count < 0 then
470
+ continue
471
+ end
472
+ instrumented += 1
473
+ if count > 0 then
474
+ covered += 1
475
+ elseif #missed < MAX_UNCOVERED_LINES then
476
+ table.insert(missed, line)
477
+ end
478
+ end
479
+
480
+ table.sort(missed)
481
+
482
+ --[[
483
+ A destroyed script keeps its coverage record for the life of the
484
+ session, and `Paths.of` on something with no ancestry returns a bare
485
+ name -- so the report named "MCPProbeB" with no path, for a script
486
+ that no longer existed and could not be looked up. Measured by
487
+ destroying an instrumented ModuleScript and reading coverage back:
488
+ the row was still there, and still there on the call after it.
489
+
490
+ Dropped rather than flagged. Coverage answers "which lines of my code
491
+ ran", and a script that is gone has no lines anyone can go and read.
492
+ ]]
493
+ if typeof(target) == "Instance" and not (target :: Instance):IsDescendantOf(game) then
494
+ continue
495
+ end
496
+
497
+ table.insert(scripts, {
498
+ path = if typeof(target) == "Instance" then Paths.of(target) else tostring(target),
499
+ instrumentedLines = instrumented,
500
+ coveredLines = covered,
501
+ uncoveredLines = missed,
502
+ percent = if instrumented > 0
503
+ then math.floor(covered / instrumented * 1000 + 0.5) / 10
504
+ else 0,
505
+ --[[
506
+ Zero instrumented lines is not "already compiled when coverage
507
+ was switched on". That was the earlier reading here and it is
508
+ wrong: probed against ScriptContext, a script that ran before
509
+ being enabled gets NO record at all and never reaches this loop.
510
+
511
+ What it IS has not been pinned down. The session that produced
512
+ 0-line records could not be made to produce them again -- the
513
+ same create/enable/require sequence instrumented 6 of 6 lines on
514
+ the next attempt -- so no cause is claimed here. The flag exists
515
+ because 0/0 and 0/many render identically and mean opposite
516
+ things: no data, versus nothing ran.
517
+ ]]
518
+ notMeasurable = if instrumented == 0 then true else nil,
519
+ })
520
+ end
521
+
522
+ return {
523
+ enabled = enabled,
524
+ scripts = scripts,
525
+ raw = unrecognised,
526
+ -- What this session switched on for itself as it loaded, which is the
527
+ -- only thing that can instrument code running at startup.
528
+ carriedOver = if #carriedOver > 0 then carriedOver else nil,
529
+ carriedFailed = if #carriedFailed > 0 then carriedFailed else nil,
530
+ remembered = rememberedCoverage(),
531
+ }
532
+ end
533
+
534
+ --[[
535
+ Every reference in the place that points at nothing.
536
+
537
+ A dead asset id is the quietest failure Roblox has. A Sound whose id was
538
+ deleted, made private, or mistyped plays silence; a Decal shows nothing; an
539
+ Animation does nothing. None of them errors, none of them warns, and the
540
+ instance looks perfectly healthy in the Explorer -- the id is a string, and
541
+ it is still a string. The only way to find them today is to play the game and
542
+ notice something missing, which nobody does reliably for the twentieth sound
543
+ effect.
544
+
545
+ `ContentProvider:PreloadAsync` settles it. Measured: a real id came back
546
+ `IsLoaded=true, TimeLength=0.632`, a dead one `IsLoaded=false, TimeLength=0`.
547
+ So this collects every asset-bearing property in the place, asks the engine
548
+ to fetch them, and reports the ones that did not arrive.
549
+
550
+ One honest cost: preloading a dead id writes a line to Studio's Output
551
+ window. That is the engine talking, not this tool, and it corroborates the
552
+ finding rather than contradicting it -- but it is the user's Output window,
553
+ so the tool says up front that it will happen.
554
+ ]]
555
+
556
+ --[[
557
+ Assets fetched in one audit.
558
+
559
+ Preloading is network work, and a place with two thousand sounds would spend
560
+ minutes on it while Studio sat still. The cap is generous enough to cover
561
+ most places whole and small enough that the worst case is seconds, and the
562
+ reply says when it stopped short rather than implying it checked everything.
563
+ ]]
564
+ local MAX_ASSETS = 200
565
+
566
+ --[[
567
+ How long the whole fetch may take before this gives up on it.
568
+
569
+ Long enough for two hundred ordinary assets over a slow connection, short
570
+ enough that a stuck fetch is a slow answer rather than a hung Studio.
571
+ ]]
572
+ local PRELOAD_SECONDS = 20
573
+
574
+ --[[
575
+ Which property on which class holds a content id.
576
+
577
+ Written out rather than discovered from the API dump, because "a string
578
+ property whose name sounds like an asset" catches `Name` and misses
579
+ `MeshContent`. The list is short, and being wrong here means reporting a
580
+ healthy place as broken.
581
+ ]]
582
+ local ASSET_FIELDS: { { className: string, property: string } } = {
583
+ { className = "Sound", property = "SoundId" },
584
+ { className = "Decal", property = "Texture" },
585
+ { className = "Texture", property = "Texture" },
586
+ { className = "ImageLabel", property = "Image" },
587
+ { className = "ImageButton", property = "Image" },
588
+ { className = "Animation", property = "AnimationId" },
589
+ { className = "Sky", property = "SkyboxUp" },
590
+ { className = "ParticleEmitter", property = "Texture" },
591
+ { className = "Beam", property = "Texture" },
592
+ { className = "Trail", property = "Texture" },
593
+ }
594
+
595
+ function Perf.audit(params: { [string]: any }): { [string]: any }
596
+ local ContentProvider = game:GetService("ContentProvider")
597
+
598
+ local unset: { { [string]: any } } = {}
599
+ local candidates: { { instance: Instance, id: string, property: string } } = {}
600
+ local disabled: { string } = {}
601
+ local duplicates: { string } = {}
602
+
603
+ local seenNames: { [string]: boolean } = {}
604
+
605
+ for _, descendant in game:GetDescendants() do
606
+ if Scope.isNoisy(descendant) then
607
+ continue
608
+ end
609
+
610
+ for _, field in ASSET_FIELDS do
611
+ if not descendant:IsA(field.className) then
612
+ continue
613
+ end
614
+ local ok, value = pcall(function()
615
+ return (descendant :: any)[field.property]
616
+ end)
617
+ if not ok or typeof(value) ~= "string" then
618
+ continue
619
+ end
620
+ if value == "" then
621
+ table.insert(unset, { path = Paths.of(descendant), property = field.property })
622
+ elseif #candidates < MAX_ASSETS then
623
+ table.insert(candidates, { instance = descendant, id = value, property = field.property })
624
+ end
625
+ break
626
+ end
627
+
628
+ --[[
629
+ `Disabled` exists on BaseScript, not on ModuleScript -- reading it off
630
+ the wrong one throws, which is how this check failed the first time it
631
+ was written. Narrowed to the class that has it.
632
+ ]]
633
+ if descendant:IsA("BaseScript") and (descendant :: any).Disabled == true then
634
+ if #disabled < 25 then
635
+ table.insert(disabled, Paths.of(descendant))
636
+ end
637
+ end
638
+
639
+ --[[
640
+ Same-named siblings, which is what breaks `WaitForChild`: it returns
641
+ whichever one the engine reaches first, and that is not stable.
642
+
643
+ Geometry is excluded, and that exclusion is the whole value of the
644
+ check. Measured on a real place: the first version reported 25
645
+ duplicates and every one of them was a wall bar, a bench or a table
646
+ leg in a blockout -- decorative parts nobody ever looks up by name.
647
+ The real findings were buried under them.
648
+
649
+ A duplicated script, RemoteEvent, value or GUI element is a different
650
+ matter: those exist to be found by name, and two of them means code
651
+ somewhere is reaching for whichever the engine happened to index
652
+ first.
653
+ ]]
654
+ local parent = descendant.Parent
655
+ if parent ~= nil and not descendant:IsA("BasePart") and not descendant:IsA("Attachment") then
656
+ local key = tostring(parent:GetDebugId(8)) .. "/" .. descendant.Name
657
+ if seenNames[key] and #duplicates < 25 then
658
+ table.insert(duplicates, Paths.of(descendant))
659
+ end
660
+ seenNames[key] = true
661
+ end
662
+ end
663
+
664
+ --[[
665
+ Preloaded in one call rather than one per asset.
666
+
667
+ `PreloadAsync` takes the whole list and fetches in parallel; asking for
668
+ them one at a time would serialise two hundred network round trips into
669
+ something that looks like a hang.
670
+ ]]
671
+ local toLoad: { Instance } = {}
672
+ for _, entry in candidates do
673
+ table.insert(toLoad, entry.instance)
674
+ end
675
+ --[[
676
+ Given a deadline, because `PreloadAsync` has none of its own.
677
+
678
+ It blocks until every id in the list resolves, and an id that resolves
679
+ slowly -- or a network that has stopped answering -- blocks it for as
680
+ long as that takes. Measured: an audit against a running playtest sat
681
+ still long enough for the request to time out and the playtest to end
682
+ with it. A tool that can stop Studio responding is worse than one that
683
+ reports less.
684
+
685
+ Run on its own thread and waited on here, so the wait can end without
686
+ the work being cancelled -- there is no way to cancel it, and pretending
687
+ otherwise would leave a thread writing into a finished request.
688
+ ]]
689
+ local preloaded = true
690
+ if #toLoad > 0 then
691
+ local done = false
692
+ task.spawn(function()
693
+ pcall(function()
694
+ ContentProvider:PreloadAsync(toLoad)
695
+ end)
696
+ done = true
697
+ end)
698
+
699
+ local waited = 0
700
+ while not done and waited < PRELOAD_SECONDS do
701
+ waited += task.wait(0.05)
702
+ end
703
+ preloaded = done
704
+ end
705
+
706
+ local dead: { { [string]: any } } = {}
707
+ for _, entry in candidates do
708
+ local instance = entry.instance
709
+ local loaded = true
710
+ if instance:IsA("Sound") then
711
+ -- A Sound that fetched has a length; one that did not is still zero.
712
+ loaded = (instance :: Sound).TimeLength > 0
713
+ else
714
+ local ok, value = pcall(function()
715
+ return (instance :: any).IsLoaded
716
+ end)
717
+ if ok and typeof(value) == "boolean" then
718
+ loaded = value
719
+ end
720
+ end
721
+ --[[
722
+ Nothing is called dead once the fetch has been abandoned.
723
+
724
+ An id that simply had not arrived yet is indistinguishable here from
725
+ one that points at nothing, and "this sound is broken" about a sound
726
+ that is fine is the one answer this check must never give.
727
+ ]]
728
+ if not loaded and not preloaded then
729
+ continue
730
+ end
731
+ if not loaded and #dead < 50 then
732
+ table.insert(dead, {
733
+ path = Paths.of(instance),
734
+ property = entry.property,
735
+ id = entry.id,
736
+ })
737
+ end
738
+ end
739
+
740
+ return {
741
+ checked = #candidates,
742
+ -- Reported beside the fetched count, because "3 checked" on a place with
743
+ -- fourteen blank ids reads as if the audit barely ran. A blank id needs
744
+ -- no fetch to be judged; it is still a reference that points nowhere.
745
+ scanned = #candidates + #unset,
746
+ truncated = #candidates >= MAX_ASSETS,
747
+ -- Said out loud, because an empty `dead` list from an abandoned fetch
748
+ -- reads exactly like a clean place.
749
+ incomplete = if not preloaded then true else nil,
750
+ dead = dead,
751
+ deadCount = #dead,
752
+ unset = unset,
753
+ unsetCount = #unset,
754
+ disabledScripts = disabled,
755
+ duplicateNames = duplicates,
756
+ }
757
+ end
758
+
759
+ function Perf.register(host: Plugin?)
760
+ pluginRef = host
761
+
762
+ --[[
763
+ Applied at load, which is the only moment early enough to matter. A
764
+ playtest's scripts run as its DataModel starts, so a session that waited
765
+ for a tool call to tell it what to instrument would already have missed
766
+ the run it was meant to measure.
767
+
768
+ Paths that no longer resolve are skipped rather than reported: the set is
769
+ remembered across sessions, and a script deleted in between is a stale
770
+ entry, not a failure worth surfacing here.
771
+ ]]
772
+ for _, path in rememberedCoverage() do
773
+ local ok, err = pcall(function()
774
+ ScriptContext:EnableCoverage(Paths.resolve(path))
775
+ end)
776
+ if ok then
777
+ table.insert(carriedOver, path)
778
+ else
779
+ -- Reported rather than swallowed. When this silently did nothing the
780
+ -- symptom was identical to the bug it was written to fix -- an empty
781
+ -- coverage read -- and there was no way to tell which half had failed.
782
+ table.insert(carriedFailed, { path = path, error = tostring(err) })
783
+ end
784
+ end
785
+
786
+ --[[
787
+ What this place is made of, and where its weight sits.
788
+
789
+ `SceneAnalysisService` answers questions the counters cannot. A snapshot says
790
+ memory is 2.4GB; this says which assets hold it and which instances are
791
+ responsible, broken down by category rather than by process.
792
+
793
+ The return shapes are undocumented -- the reference page lists the six
794
+ methods and names their result types without describing a single field -- so
795
+ what follows was read off a live session. Every one of them returns the same
796
+ recursive node: `{ Name, Size, Children }`, with two exceptions.
797
+ `GetTriangleCompositionAsync` carries `Sizes`, a dictionary of Triangles and
798
+ Drawcalls, in place of the single `Size`; and animation nodes add `AssetId`
799
+ and `Owners`, each owner being `{ Name, ClassName }`.
800
+
801
+ Sizes are counts for instance composition and bytes for the memory readings,
802
+ which the engine does not label either -- so this file labels them.
803
+ ]]
804
+ local function flatten(node: any, depth: number, into: { { [string]: any } })
805
+ if typeof(node) ~= "table" then
806
+ return
807
+ end
808
+ local children = (node :: any).Children
809
+ if typeof(children) ~= "table" then
810
+ return
811
+ end
812
+ for _, child in children do
813
+ local entry: { [string]: any } = {
814
+ name = tostring((child :: any).Name),
815
+ depth = depth,
816
+ }
817
+ local size = (child :: any).Size
818
+ if typeof(size) == "number" then
819
+ entry.size = size
820
+ end
821
+ local sizes = (child :: any).Sizes
822
+ if typeof(sizes) == "table" then
823
+ for key, value in sizes :: { [string]: any } do
824
+ entry[string.lower(tostring(key))] = value
825
+ end
826
+ end
827
+ local assetId = (child :: any).AssetId
828
+ if assetId ~= nil then
829
+ entry.assetId = tostring(assetId)
830
+ end
831
+ local owners = (child :: any).Owners
832
+ if typeof(owners) == "table" then
833
+ local names: { string } = {}
834
+ for _, owner in owners :: { any } do
835
+ if typeof(owner) == "table" then
836
+ table.insert(names, string.format("%s (%s)", tostring(owner.Name), tostring(owner.ClassName)))
837
+ end
838
+ end
839
+ entry.owners = names
840
+ end
841
+ table.insert(into, entry)
842
+
843
+ -- Two levels is the useful depth: "3D Objects -> MeshPart" answers the
844
+ -- question, while a third level is per-instance detail that `find` gives
845
+ -- better and on demand.
846
+ if depth < 2 then
847
+ flatten(child, depth + 1, into)
848
+ end
849
+ end
850
+ end
851
+
852
+ local SCENE_SECTIONS = {
853
+ { key = "composition", method = "GetInstanceCompositionAsync", unit = "instances" },
854
+ { key = "triangles", method = "GetTriangleCompositionAsync", unit = "triangles" },
855
+ { key = "scriptMemory", method = "GetScriptMemoryAsync", unit = "bytes" },
856
+ { key = "animationMemory", method = "GetAnimationMemoryAsync", unit = "bytes" },
857
+ { key = "audioMemory", method = "GetAudioMemoryAsync", unit = "bytes" },
858
+ { key = "unparented", method = "GetUnparentedInstancesAsync", unit = "instances" },
859
+ }
860
+
861
+ function Perf.scene(params: { [string]: any }): { [string]: any }
862
+ local only = if typeof(params.section) == "string" and params.section ~= "" then params.section else nil
863
+ local sections: { [string]: any } = {}
864
+
865
+ for _, spec in SCENE_SECTIONS do
866
+ if only ~= nil and only ~= spec.key then
867
+ continue
868
+ end
869
+ local ok, root = pcall(function()
870
+ return (SceneAnalysisService :: any)[spec.method](SceneAnalysisService)
871
+ end)
872
+ if not ok then
873
+ sections[spec.key] = { error = tostring(root) }
874
+ continue
875
+ end
876
+
877
+ local rows: { { [string]: any } } = {}
878
+ flatten(root, 1, rows)
879
+
880
+ local total = (root :: any).Size
881
+ local totals = (root :: any).Sizes
882
+ sections[spec.key] = {
883
+ total = if typeof(total) == "number" then total else nil,
884
+ totals = if typeof(totals) == "table" then totals else nil,
885
+ unit = spec.unit,
886
+ entries = rows,
887
+ }
888
+ end
889
+
890
+ if only ~= nil and sections[only] == nil then
891
+ Dispatch.fail(
892
+ "BAD_PARAMS",
893
+ string.format("unknown scene section %q", only),
894
+ "Sections are: composition, triangles, scriptMemory, animationMemory, audioMemory, unparented."
895
+ )
896
+ end
897
+
898
+ return sections
899
+ end
900
+
901
+ Dispatch.registerAll("perf", {
902
+ audit = Perf.audit,
903
+ console = Perf.console,
904
+ snapshot = function()
905
+ return Perf.snapshot()
906
+ end,
907
+ profile = Perf.profile,
908
+ coverage = Perf.coverage,
909
+ scene = Perf.scene,
910
+ })
911
+ end
912
+
913
+ return Perf