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