@el4cteo/rbx-studio-mcp 0.7.2 → 0.7.6
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/dist/bridge/console.js +28 -0
- package/dist/bridge/console.js.map +1 -1
- package/dist/bridge/remote.js +9 -3
- package/dist/bridge/remote.js.map +1 -1
- package/dist/bridge/rpc.js +2 -1
- package/dist/bridge/rpc.js.map +1 -1
- package/dist/lib/timeout.js +13 -0
- package/dist/lib/timeout.js.map +1 -0
- package/dist/tools/debug.js +15 -4
- package/dist/tools/debug.js.map +1 -1
- package/dist/tools/exec.js +13 -6
- package/dist/tools/exec.js.map +1 -1
- package/dist/tools/input.js +8 -1
- package/dist/tools/input.js.map +1 -1
- package/dist/tools/playtest.js +11 -1
- package/dist/tools/playtest.js.map +1 -1
- package/package.json +75 -75
- package/plugin/src/ClientRelay.luau +88 -0
- package/plugin/src/Commands.luau +26 -0
- package/plugin/src/Config.luau +65 -65
- package/plugin/src/ExecRuntime.luau +161 -0
- package/plugin/src/Phrase.luau +3 -0
- package/plugin/src/RemoteTrace.luau +143 -0
- package/plugin/src/handlers/Debug.luau +574 -504
- package/plugin/src/handlers/Exec.luau +36 -161
- package/plugin/src/handlers/Input.luau +54 -75
- package/plugin/src/handlers/Playtest.luau +245 -205
- package/plugin/src/init.server.luau +946 -939
- package/scripts/test-bridge.mjs +37 -0
- package/scripts/test-console.mjs +25 -0
- package/scripts/test-failover.mjs +69 -0
- package/scripts/test-live.mjs +283 -115
- package/scripts/test-plugin.mjs +103 -85
- package/scripts/test-tools.mjs +162 -39
|
@@ -1,504 +1,574 @@
|
|
|
1
|
-
--!strict
|
|
2
|
-
--[[
|
|
3
|
-
Breakpoints and runtime inspection, through `ScriptDebuggerService`.
|
|
4
|
-
|
|
5
|
-
Not `DebuggerManager`, which is the legacy service and refuses plugins
|
|
6
|
-
outright -- it wants the LocalUser capability. ScriptDebuggerService is
|
|
7
|
-
PluginSecurity throughout and is what Roblox shipped to replace it.
|
|
8
|
-
|
|
9
|
-
Two things shape this design, both learned rather than assumed.
|
|
10
|
-
|
|
11
|
-
The service reports "ScriptDebuggerService was never initialized" until a
|
|
12
|
-
callback is attached, in an editor session and a running playtest alike. So
|
|
13
|
-
`OnStopped` is installed before anything else is attempted.
|
|
14
|
-
|
|
15
|
-
And `OnStopped` must return its resume decision synchronously. It cannot yield
|
|
16
|
-
waiting for an agent to look at the stack and decide what to do, which rules
|
|
17
|
-
out interactive stepping over a request/response protocol entirely. What works
|
|
18
|
-
instead is a tracepoint: the callback captures the stack and variables into a
|
|
19
|
-
buffer, lets execution continue, and the agent reads the snapshots afterwards.
|
|
20
|
-
For finding out what a value was at a moment in time -- which is what a
|
|
21
|
-
debugger is usually reached for -- that is as good, and it does not leave the
|
|
22
|
-
user's Studio frozen mid-frame.
|
|
23
|
-
|
|
24
|
-
The documented return shape is contradictory -- the API dump says the callback
|
|
25
|
-
returns a Dictionary, Roblox's own announcement returns
|
|
26
|
-
`Enum.DebuggerResumeType.Resume` directly -- and this file used to hedge by
|
|
27
|
-
never stopping at all, which is worth recording because the hedge cost the
|
|
28
|
-
whole feature. `ContinueExecution = true` means the debugger does not stop;
|
|
29
|
-
not stopping means `OnStopped` never runs; and `OnStopped` is the only place
|
|
30
|
-
a stack or a variable is readable. Every capture breakpoint verified, fired,
|
|
31
|
-
and recorded nothing, while logpoints on the same lines printed normally --
|
|
32
|
-
so the half that needed no callback worked and hid that the other half was
|
|
33
|
-
dead.
|
|
34
|
-
|
|
35
|
-
The enum is the right return. It resumes cleanly: a script with a breakpoint
|
|
36
|
-
mid-loop ran to completion untouched, so stopping to capture costs the run
|
|
37
|
-
nothing and strands nothing.
|
|
38
|
-
|
|
39
|
-
There is no third option. `DebuggerResumeType` offers StepInto, StepOut,
|
|
40
|
-
StepOver and Resume, and none of them means "stay stopped", so a breakpoint
|
|
41
|
-
that holds a thread for someone to look at is not expressible here -- which
|
|
42
|
-
is why nothing in this file offers one.
|
|
43
|
-
]]
|
|
44
|
-
|
|
45
|
-
--[[
|
|
46
|
-
Fetched defensively, because this module is required at plugin start.
|
|
47
|
-
|
|
48
|
-
Measured with the beta off and Studio restarted: `GetService` does NOT throw
|
|
49
|
-
here, it hands back a working service object. So this guard is not fixing an
|
|
50
|
-
observed crash -- it is insurance on the one call in this file that runs
|
|
51
|
-
before any tool is invoked. init.server.luau requires this module alongside
|
|
52
|
-
every other handler, so a throw at this line would take down all 29 tools
|
|
53
|
-
rather than the three that need a debugger, and it would do it silently: a
|
|
54
|
-
plugin that never loads never connects, leaving nothing to read anywhere.
|
|
55
|
-
|
|
56
|
-
The beta is NOT detected here, because nothing detects it cheaply. See the
|
|
57
|
-
note on the wholesale-refusal branch in `Debug.set` for the two probes that
|
|
58
|
-
were tried and why neither works.
|
|
59
|
-
]]
|
|
60
|
-
local ScriptDebuggerService: any = nil
|
|
61
|
-
do
|
|
62
|
-
local ok, service = pcall(game.GetService, game, "ScriptDebuggerService")
|
|
63
|
-
if ok then
|
|
64
|
-
ScriptDebuggerService = service
|
|
65
|
-
end
|
|
66
|
-
end
|
|
67
|
-
|
|
68
|
-
local Dispatch = require(script.Parent.Parent.Dispatch)
|
|
69
|
-
local Paths = require(script.Parent.Parent.Paths)
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
--
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
local
|
|
77
|
-
|
|
78
|
-
local
|
|
79
|
-
|
|
80
|
-
local
|
|
81
|
-
|
|
82
|
-
local
|
|
83
|
-
|
|
84
|
-
local
|
|
85
|
-
local
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
local
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
end
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
local
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
end
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
--
|
|
202
|
-
--
|
|
203
|
-
--
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
end
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
`ContinueExecution
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
the
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
--
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
with the beta
|
|
366
|
-
|
|
367
|
-
the
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
"
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
.. "
|
|
381
|
-
.. "
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
local
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
end
|
|
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
|
-
|
|
1
|
+
--!strict
|
|
2
|
+
--[[
|
|
3
|
+
Breakpoints and runtime inspection, through `ScriptDebuggerService`.
|
|
4
|
+
|
|
5
|
+
Not `DebuggerManager`, which is the legacy service and refuses plugins
|
|
6
|
+
outright -- it wants the LocalUser capability. ScriptDebuggerService is
|
|
7
|
+
PluginSecurity throughout and is what Roblox shipped to replace it.
|
|
8
|
+
|
|
9
|
+
Two things shape this design, both learned rather than assumed.
|
|
10
|
+
|
|
11
|
+
The service reports "ScriptDebuggerService was never initialized" until a
|
|
12
|
+
callback is attached, in an editor session and a running playtest alike. So
|
|
13
|
+
`OnStopped` is installed before anything else is attempted.
|
|
14
|
+
|
|
15
|
+
And `OnStopped` must return its resume decision synchronously. It cannot yield
|
|
16
|
+
waiting for an agent to look at the stack and decide what to do, which rules
|
|
17
|
+
out interactive stepping over a request/response protocol entirely. What works
|
|
18
|
+
instead is a tracepoint: the callback captures the stack and variables into a
|
|
19
|
+
buffer, lets execution continue, and the agent reads the snapshots afterwards.
|
|
20
|
+
For finding out what a value was at a moment in time -- which is what a
|
|
21
|
+
debugger is usually reached for -- that is as good, and it does not leave the
|
|
22
|
+
user's Studio frozen mid-frame.
|
|
23
|
+
|
|
24
|
+
The documented return shape is contradictory -- the API dump says the callback
|
|
25
|
+
returns a Dictionary, Roblox's own announcement returns
|
|
26
|
+
`Enum.DebuggerResumeType.Resume` directly -- and this file used to hedge by
|
|
27
|
+
never stopping at all, which is worth recording because the hedge cost the
|
|
28
|
+
whole feature. `ContinueExecution = true` means the debugger does not stop;
|
|
29
|
+
not stopping means `OnStopped` never runs; and `OnStopped` is the only place
|
|
30
|
+
a stack or a variable is readable. Every capture breakpoint verified, fired,
|
|
31
|
+
and recorded nothing, while logpoints on the same lines printed normally --
|
|
32
|
+
so the half that needed no callback worked and hid that the other half was
|
|
33
|
+
dead.
|
|
34
|
+
|
|
35
|
+
The enum is the right return. It resumes cleanly: a script with a breakpoint
|
|
36
|
+
mid-loop ran to completion untouched, so stopping to capture costs the run
|
|
37
|
+
nothing and strands nothing.
|
|
38
|
+
|
|
39
|
+
There is no third option. `DebuggerResumeType` offers StepInto, StepOut,
|
|
40
|
+
StepOver and Resume, and none of them means "stay stopped", so a breakpoint
|
|
41
|
+
that holds a thread for someone to look at is not expressible here -- which
|
|
42
|
+
is why nothing in this file offers one.
|
|
43
|
+
]]
|
|
44
|
+
|
|
45
|
+
--[[
|
|
46
|
+
Fetched defensively, because this module is required at plugin start.
|
|
47
|
+
|
|
48
|
+
Measured with the beta off and Studio restarted: `GetService` does NOT throw
|
|
49
|
+
here, it hands back a working service object. So this guard is not fixing an
|
|
50
|
+
observed crash -- it is insurance on the one call in this file that runs
|
|
51
|
+
before any tool is invoked. init.server.luau requires this module alongside
|
|
52
|
+
every other handler, so a throw at this line would take down all 29 tools
|
|
53
|
+
rather than the three that need a debugger, and it would do it silently: a
|
|
54
|
+
plugin that never loads never connects, leaving nothing to read anywhere.
|
|
55
|
+
|
|
56
|
+
The beta is NOT detected here, because nothing detects it cheaply. See the
|
|
57
|
+
note on the wholesale-refusal branch in `Debug.set` for the two probes that
|
|
58
|
+
were tried and why neither works.
|
|
59
|
+
]]
|
|
60
|
+
local ScriptDebuggerService: any = nil
|
|
61
|
+
do
|
|
62
|
+
local ok, service = pcall(game.GetService, game, "ScriptDebuggerService")
|
|
63
|
+
if ok then
|
|
64
|
+
ScriptDebuggerService = service
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
local Dispatch = require(script.Parent.Parent.Dispatch)
|
|
69
|
+
local Paths = require(script.Parent.Parent.Paths)
|
|
70
|
+
local ClientRelay = require(script.Parent.Parent.ClientRelay)
|
|
71
|
+
local RemoteTrace = require(script.Parent.Parent.RemoteTrace)
|
|
72
|
+
|
|
73
|
+
-- Snapshots are far heavier than log lines: each carries a stack and its
|
|
74
|
+
-- variables. A tight cap keeps a breakpoint inside a loop from exhausting memory
|
|
75
|
+
-- before anyone reads it.
|
|
76
|
+
local MAX_SNAPSHOTS = 40
|
|
77
|
+
local MAX_FRAMES = 12
|
|
78
|
+
local MAX_VARIABLES = 40
|
|
79
|
+
|
|
80
|
+
local Debug = {}
|
|
81
|
+
|
|
82
|
+
local service = ScriptDebuggerService :: any
|
|
83
|
+
|
|
84
|
+
local snapshots: { { [string]: any } } = {}
|
|
85
|
+
local overflow = 0
|
|
86
|
+
local installed = false
|
|
87
|
+
local installError: string? = nil
|
|
88
|
+
|
|
89
|
+
--[[
|
|
90
|
+
Which lines currently carry a breakpoint, per script.
|
|
91
|
+
|
|
92
|
+
`ScriptDebuggerService` exposes no way to list what is set -- `AddBreakpoint`
|
|
93
|
+
and `RemoveBreakpoint` are the whole surface, one line at a time -- so
|
|
94
|
+
"clear every breakpoint in this script" needs its own record of what this
|
|
95
|
+
module put there, or it cannot be done at all without either the caller
|
|
96
|
+
naming every line back or this file falling back to `ClearBreakpoints()`,
|
|
97
|
+
which takes the whole session's breakpoints with it, including ones set by
|
|
98
|
+
someone else sharing this Studio.
|
|
99
|
+
]]
|
|
100
|
+
local trackedBreakpoints: { [Instance]: { [number]: boolean } } = {}
|
|
101
|
+
|
|
102
|
+
local function track(target: Instance, line: number)
|
|
103
|
+
local lines = trackedBreakpoints[target]
|
|
104
|
+
if lines == nil then
|
|
105
|
+
lines = {}
|
|
106
|
+
trackedBreakpoints[target] = lines
|
|
107
|
+
end
|
|
108
|
+
lines[line] = true
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
local function untrack(target: Instance, line: number)
|
|
112
|
+
local lines = trackedBreakpoints[target]
|
|
113
|
+
if lines then
|
|
114
|
+
lines[line] = nil
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
--[[
|
|
119
|
+
Flattens whatever the service hands back.
|
|
120
|
+
|
|
121
|
+
Every one of these shapes is undocumented, so nothing is indexed by a guessed
|
|
122
|
+
field name. Keys are taken as they come and values stringified, which means an
|
|
123
|
+
unfamiliar shape arrives readable instead of arriving empty.
|
|
124
|
+
]]
|
|
125
|
+
local function flatten(value: any, depth: number): any
|
|
126
|
+
if depth > 3 then
|
|
127
|
+
return "<nested>"
|
|
128
|
+
end
|
|
129
|
+
local kind = typeof(value)
|
|
130
|
+
if kind ~= "table" then
|
|
131
|
+
if kind == "Instance" then
|
|
132
|
+
return (value :: Instance):GetFullName()
|
|
133
|
+
end
|
|
134
|
+
if kind == "EnumItem" then
|
|
135
|
+
return tostring(value)
|
|
136
|
+
end
|
|
137
|
+
return if kind == "string" or kind == "number" or kind == "boolean"
|
|
138
|
+
then value
|
|
139
|
+
else tostring(value)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
local source = value :: { [any]: any }
|
|
143
|
+
local out: { [string]: any } = {}
|
|
144
|
+
local count = 0
|
|
145
|
+
for key, item in source do
|
|
146
|
+
count += 1
|
|
147
|
+
if count > MAX_VARIABLES then
|
|
148
|
+
out["..."] = "more"
|
|
149
|
+
break
|
|
150
|
+
end
|
|
151
|
+
out[tostring(key)] = flatten(item, depth + 1)
|
|
152
|
+
end
|
|
153
|
+
return out
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
--[[
|
|
157
|
+
Builds the record for one stop.
|
|
158
|
+
|
|
159
|
+
Each lookup is guarded on its own. A stop that yields a thread id but no
|
|
160
|
+
readable variables is still worth keeping, and losing the whole snapshot to
|
|
161
|
+
one failed call would waste the only moment the data existed.
|
|
162
|
+
]]
|
|
163
|
+
local function capture(stopped: any)
|
|
164
|
+
local record: { [string]: any } = {
|
|
165
|
+
at = os.time(),
|
|
166
|
+
stopped = flatten(stopped, 0),
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
--[[
|
|
170
|
+
`ThreadIds`, plural, holding an array -- measured from a real stop:
|
|
171
|
+
|
|
172
|
+
{ ThreadIds = {3002}, Reason = Enum.ScriptStoppedReason.Breakpoint }
|
|
173
|
+
|
|
174
|
+
Reading `ThreadId` instead cost nothing visible: snapshots still arrived,
|
|
175
|
+
carrying the reason and nothing else, so a breakpoint looked like it was
|
|
176
|
+
working while the stack and variables it exists to collect were never
|
|
177
|
+
fetched.
|
|
178
|
+
]]
|
|
179
|
+
local threadId: any = nil
|
|
180
|
+
if typeof(stopped) == "table" then
|
|
181
|
+
local ids = (stopped :: any).ThreadIds
|
|
182
|
+
threadId = if typeof(ids) == "table" then (ids :: { any })[1] else (stopped :: any).ThreadId
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
if threadId ~= nil then
|
|
186
|
+
local ok, trace = pcall(function()
|
|
187
|
+
return service:GetStackTrace(threadId)
|
|
188
|
+
end)
|
|
189
|
+
if ok then
|
|
190
|
+
record.stack = flatten(trace, 0)
|
|
191
|
+
|
|
192
|
+
-- Variables hang off a frame, so they are only reachable once the
|
|
193
|
+
-- trace has given up a frame id.
|
|
194
|
+
local frames = if typeof(trace) == "table" then (trace :: any).Frames else nil
|
|
195
|
+
if typeof(frames) == "table" then
|
|
196
|
+
local locals: { any } = {}
|
|
197
|
+
for index, frame in frames :: { any } do
|
|
198
|
+
if index > MAX_FRAMES then
|
|
199
|
+
break
|
|
200
|
+
end
|
|
201
|
+
-- `Id`, measured: a frame is {Id, Line, Name, ScriptPath}. The
|
|
202
|
+
-- StackFrame *instance* used by Studio's own debugger calls
|
|
203
|
+
-- it FrameId, which is what this read first, and the
|
|
204
|
+
-- mismatch cost only the variables -- the frame itself still
|
|
205
|
+
-- listed, so the stack looked complete.
|
|
206
|
+
local raw = frame :: any
|
|
207
|
+
local frameId = if typeof(frame) == "table" then (raw.Id or raw.FrameId) else nil
|
|
208
|
+
if frameId ~= nil then
|
|
209
|
+
local gotVars, vars = pcall(function()
|
|
210
|
+
return service:GetRootVariables(frameId)
|
|
211
|
+
end)
|
|
212
|
+
table.insert(locals, {
|
|
213
|
+
frame = flatten(frame, 1),
|
|
214
|
+
variables = if gotVars then flatten(vars, 1) else tostring(vars),
|
|
215
|
+
})
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
record.frames = locals
|
|
219
|
+
end
|
|
220
|
+
else
|
|
221
|
+
record.stackError = tostring(trace)
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
if #snapshots >= MAX_SNAPSHOTS then
|
|
226
|
+
table.remove(snapshots, 1)
|
|
227
|
+
overflow += 1
|
|
228
|
+
end
|
|
229
|
+
table.insert(snapshots, record)
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
--[[
|
|
233
|
+
Attaches the callback, which is also what initialises the service.
|
|
234
|
+
|
|
235
|
+
The resume decision follows Roblox's own example and returns the enum rather
|
|
236
|
+
than the Dictionary the dump describes. That is settled by observation now,
|
|
237
|
+
not by preference: breakpoints stop, this returns, and the stopped script
|
|
238
|
+
carries on to its last line.
|
|
239
|
+
]]
|
|
240
|
+
local function install(): boolean
|
|
241
|
+
if installed then
|
|
242
|
+
return true
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
if ScriptDebuggerService == nil then
|
|
246
|
+
installError = "the service is not registered in this Studio"
|
|
247
|
+
return false
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
local ok, err = pcall(function()
|
|
251
|
+
service.OnStopped = function(stopped: any): any
|
|
252
|
+
-- Capture must never be what keeps a thread paused.
|
|
253
|
+
pcall(capture, stopped)
|
|
254
|
+
return Enum.DebuggerResumeType.Resume
|
|
255
|
+
end
|
|
256
|
+
end)
|
|
257
|
+
|
|
258
|
+
if not ok then
|
|
259
|
+
installError = tostring(err)
|
|
260
|
+
return false
|
|
261
|
+
end
|
|
262
|
+
installed = true
|
|
263
|
+
installError = nil
|
|
264
|
+
return true
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
local function requireService()
|
|
268
|
+
if not install() then
|
|
269
|
+
Dispatch.fail(
|
|
270
|
+
"NO_DEBUGGER",
|
|
271
|
+
string.format(
|
|
272
|
+
"ScriptDebuggerService is not available in this session: %s",
|
|
273
|
+
tostring(installError)
|
|
274
|
+
),
|
|
275
|
+
"Turn on \"Debugger Luau API\" in File > Beta Features and restart Studio. "
|
|
276
|
+
.. "It is off by default, and the service is not registered until it is on."
|
|
277
|
+
)
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
function Debug.set(params: { [string]: any }): { [string]: any }
|
|
282
|
+
requireService()
|
|
283
|
+
|
|
284
|
+
local requested = params.breakpoints
|
|
285
|
+
if typeof(requested) ~= "table" then
|
|
286
|
+
Dispatch.fail("BAD_PARAMS", "debug set requires a `breakpoints` array.")
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
local added: { { [string]: any } } = {}
|
|
290
|
+
local failed: { { [string]: any } } = {}
|
|
291
|
+
|
|
292
|
+
for _, item in requested :: { { [string]: any } } do
|
|
293
|
+
local target = Paths.resolve(item.path)
|
|
294
|
+
if not target:IsA("LuaSourceContainer") then
|
|
295
|
+
Dispatch.fail(
|
|
296
|
+
"NOT_A_SCRIPT",
|
|
297
|
+
string.format("%s is a %s, not a script.", item.path, target.ClassName)
|
|
298
|
+
)
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
--[[
|
|
302
|
+
Capitalised keys, from Roblox's example -- `Line`, `Condition`,
|
|
303
|
+
`LogMessage`, `ContinueExecution`. Lowercase `line` was rejected.
|
|
304
|
+
|
|
305
|
+
`ContinueExecution` decides whether the debugger stops at all, and
|
|
306
|
+
stopping is the only thing that raises `OnStopped` -- which is the
|
|
307
|
+
only place a stack or a variable can be read. So a breakpoint that
|
|
308
|
+
continues past itself captures nothing, ever.
|
|
309
|
+
|
|
310
|
+
That was this file's default, on the reasoning that not stopping was
|
|
311
|
+
the safe choice: a wrong return from the callback could strand a
|
|
312
|
+
paused thread, and never pausing made that impossible. It also made
|
|
313
|
+
the feature impossible. Measured on a line proven to fire -- the same
|
|
314
|
+
breakpoint, same condition, differing only here -- it captured
|
|
315
|
+
nothing across two sessions, while the identical breakpoint carrying
|
|
316
|
+
a LogMessage printed on cue.
|
|
317
|
+
|
|
318
|
+
Stopping is not the hazard it was assumed to be. `OnStopped` returns
|
|
319
|
+
`Resume` and the thread continues on its own: the probe script ran to
|
|
320
|
+
completion, printing every line after the breakpoint, with no one
|
|
321
|
+
touching Studio. So capture stops, and a LogMessage -- which the
|
|
322
|
+
engine prints without help -- does not need to.
|
|
323
|
+
]]
|
|
324
|
+
local logMessage = if typeof(item.logMessage) == "string" and item.logMessage ~= ""
|
|
325
|
+
then item.logMessage
|
|
326
|
+
else nil
|
|
327
|
+
local descriptor: { [string]: any } = {
|
|
328
|
+
Line = tonumber(item.line),
|
|
329
|
+
ContinueExecution = logMessage ~= nil,
|
|
330
|
+
}
|
|
331
|
+
if typeof(item.condition) == "string" and item.condition ~= "" then
|
|
332
|
+
descriptor.Condition = item.condition
|
|
333
|
+
end
|
|
334
|
+
if logMessage ~= nil then
|
|
335
|
+
descriptor.LogMessage = logMessage
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
local ok, result = pcall(function()
|
|
339
|
+
return service:AddBreakpoint(target, descriptor)
|
|
340
|
+
end)
|
|
341
|
+
|
|
342
|
+
if ok then
|
|
343
|
+
track(target, descriptor.Line)
|
|
344
|
+
table.insert(added, {
|
|
345
|
+
path = Paths.of(target),
|
|
346
|
+
line = descriptor.Line,
|
|
347
|
+
-- What it will do when hit, since the two kinds behave nothing
|
|
348
|
+
-- alike: one writes a line to the output, the other stops long
|
|
349
|
+
-- enough to read the stack and then resumes itself.
|
|
350
|
+
mode = if logMessage ~= nil then "log" else "capture",
|
|
351
|
+
result = flatten(result, 1),
|
|
352
|
+
})
|
|
353
|
+
else
|
|
354
|
+
table.insert(failed, { path = item.path, line = descriptor.Line, error = tostring(result) })
|
|
355
|
+
end
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
--[[
|
|
359
|
+
A wholesale refusal names the likely cause without asserting it.
|
|
360
|
+
|
|
361
|
+
`AddBreakpoint` is where the "Debugger Luau API" beta actually bites, and
|
|
362
|
+
its own message -- "Failed to execute AddBreakpoint request" -- names
|
|
363
|
+
nothing anyone can act on. The beta cannot be detected from here to say
|
|
364
|
+
so with certainty, and two attempts to are worth recording so they are
|
|
365
|
+
not tried a third time: with the beta OFF, `GetService`, `FindService`,
|
|
366
|
+
assigning `OnStopped` and `Enum.DebuggerResumeType` all still succeed;
|
|
367
|
+
with the beta ON, `ReflectionService` still does not list the class. The
|
|
368
|
+
first is always available and the second never is, so neither varies with
|
|
369
|
+
the thing being measured.
|
|
370
|
+
|
|
371
|
+
The only reliable test is this call, which cannot be run speculatively on
|
|
372
|
+
a user's script just to answer a status question. So the hint says what
|
|
373
|
+
to check first and what else it could be, and does not claim to know.
|
|
374
|
+
]]
|
|
375
|
+
if #added == 0 and #failed > 0 then
|
|
376
|
+
Dispatch.fail(
|
|
377
|
+
"NO_BREAKPOINTS_SET",
|
|
378
|
+
string.format("No breakpoint could be set; all %d were refused.", #failed),
|
|
379
|
+
"Check that \"Debugger Luau API\" is on in File > Beta Features and that "
|
|
380
|
+
.. "Studio has been restarted since -- that is the usual cause, it is "
|
|
381
|
+
.. "off by default, and only the user can change it. Otherwise the "
|
|
382
|
+
.. "line may not be one that runs: a `return`, an `end` or a bare "
|
|
383
|
+
.. "declaration is often refused, so try the statement above it."
|
|
384
|
+
)
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
return { added = added, failed = failed, installed = installed }
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
function Debug.clear(params: { [string]: any }): { [string]: any }
|
|
391
|
+
requireService()
|
|
392
|
+
|
|
393
|
+
local path = params.path
|
|
394
|
+
if typeof(path) == "string" and path ~= "" then
|
|
395
|
+
local target = Paths.resolve(path)
|
|
396
|
+
local line = tonumber(params.line)
|
|
397
|
+
|
|
398
|
+
if line ~= nil then
|
|
399
|
+
local ok, removed = pcall(function()
|
|
400
|
+
return service:RemoveBreakpoint(target, line)
|
|
401
|
+
end)
|
|
402
|
+
untrack(target, line)
|
|
403
|
+
return { removed = ok and removed == true, path = Paths.of(target), line = line }
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
--[[
|
|
407
|
+
`path` alone, no `line`: every breakpoint this module put in that
|
|
408
|
+
script. `ScriptDebuggerService` has no "list breakpoints in this
|
|
409
|
+
script" of its own -- only `AddBreakpoint`/`RemoveBreakpoint`, one
|
|
410
|
+
line at a time -- so `trackedBreakpoints` is what makes this
|
|
411
|
+
possible at all, and it is also the reason it can only remove what
|
|
412
|
+
this session set: a breakpoint another client or the user placed
|
|
413
|
+
by hand was never tracked here, and this cannot see it to touch it.
|
|
414
|
+
]]
|
|
415
|
+
local lines = trackedBreakpoints[target]
|
|
416
|
+
local removedLines: { number } = {}
|
|
417
|
+
if lines then
|
|
418
|
+
for lineNumber in lines do
|
|
419
|
+
local ok = pcall(function()
|
|
420
|
+
service:RemoveBreakpoint(target, lineNumber)
|
|
421
|
+
end)
|
|
422
|
+
if ok then
|
|
423
|
+
table.insert(removedLines, lineNumber)
|
|
424
|
+
end
|
|
425
|
+
end
|
|
426
|
+
trackedBreakpoints[target] = nil
|
|
427
|
+
end
|
|
428
|
+
table.sort(removedLines)
|
|
429
|
+
return { removed = #removedLines > 0, path = Paths.of(target), lines = removedLines }
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
local ok, err = pcall(function()
|
|
433
|
+
service:ClearBreakpoints()
|
|
434
|
+
end)
|
|
435
|
+
if not ok then
|
|
436
|
+
Dispatch.fail("REFUSED", string.format("ClearBreakpoints refused: %s", tostring(err)))
|
|
437
|
+
end
|
|
438
|
+
table.clear(trackedBreakpoints)
|
|
439
|
+
return { cleared = true }
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
function Debug.snapshots(params: { [string]: any }): { [string]: any }
|
|
443
|
+
local limit = math.clamp(tonumber(params.limit) or 10, 1, MAX_SNAPSHOTS)
|
|
444
|
+
|
|
445
|
+
local out: { { [string]: any } } = {}
|
|
446
|
+
local first = math.max(1, #snapshots - limit + 1)
|
|
447
|
+
for index = first, #snapshots do
|
|
448
|
+
table.insert(out, snapshots[index])
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
if params.clear == true then
|
|
452
|
+
snapshots = {}
|
|
453
|
+
overflow = 0
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
return {
|
|
457
|
+
items = out,
|
|
458
|
+
total = #snapshots,
|
|
459
|
+
overflow = if overflow > 0 then overflow else nil,
|
|
460
|
+
installed = installed,
|
|
461
|
+
}
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
function Debug.exceptions(params: { [string]: any }): { [string]: any }
|
|
465
|
+
requireService()
|
|
466
|
+
|
|
467
|
+
local mode = tostring(params.mode or "Unhandled")
|
|
468
|
+
local item = (Enum.DebugBreakModeType :: any)[mode]
|
|
469
|
+
if item == nil then
|
|
470
|
+
Dispatch.fail("BAD_PARAMS", string.format("unknown break mode %q", mode))
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
local ok, err = pcall(function()
|
|
474
|
+
service:SetExceptionBreakMode(item)
|
|
475
|
+
end)
|
|
476
|
+
if not ok then
|
|
477
|
+
Dispatch.fail("REFUSED", string.format("SetExceptionBreakMode refused: %s", tostring(err)))
|
|
478
|
+
end
|
|
479
|
+
return { mode = mode }
|
|
480
|
+
end
|
|
481
|
+
|
|
482
|
+
-- Additive listeners only: no RemoteFunction callbacks or game remote sends.
|
|
483
|
+
function Debug.remotes(params: { [string]: any }): { [string]: any }
|
|
484
|
+
local player = ClientRelay.playerFor(params.player)
|
|
485
|
+
local seconds = math.clamp(tonumber(params.seconds) or 5, 1, 15)
|
|
486
|
+
local path = if typeof(params.path) == "string" then params.path else "game"
|
|
487
|
+
local root = if path == "game" then game else Paths.resolve(path)
|
|
488
|
+
local stopServer: (() -> { [string]: any })? = nil
|
|
489
|
+
local ok, client = pcall(function()
|
|
490
|
+
return ClientRelay.run(player, "MCPRemoteTraceRelay", [==[
|
|
491
|
+
local report = script:WaitForChild("Report", 10)
|
|
492
|
+
if not report then return end
|
|
493
|
+
local stop, handshake, destruction
|
|
494
|
+
local ok, result = pcall(function()
|
|
495
|
+
assert(game:GetService("RunService"):IsClient(), "Client VM required")
|
|
496
|
+
local Trace = require(script:WaitForChild("RemoteTrace"))
|
|
497
|
+
local Paths = require(script:WaitForChild("Paths"))
|
|
498
|
+
local path = script:GetAttribute("Path")
|
|
499
|
+
local root = if path == "game" then game else Paths.resolve(path)
|
|
500
|
+
local ready = false
|
|
501
|
+
handshake = report.OnClientEvent:Connect(function() ready = true end)
|
|
502
|
+
report:FireServer({ready = true})
|
|
503
|
+
local deadline = os.clock() + 5
|
|
504
|
+
while not ready and os.clock() < deadline do task.wait() end
|
|
505
|
+
handshake:Disconnect()
|
|
506
|
+
assert(ready, "Trace startup acknowledgement timed out")
|
|
507
|
+
stop = Trace.start(root, game:GetService("Players").LocalPlayer, true, script:GetAttribute("Seconds"), script)
|
|
508
|
+
destruction = script.Destroying:Connect(function() stop() end)
|
|
509
|
+
script:SetAttribute("Capturing", true)
|
|
510
|
+
task.wait(script:GetAttribute("Seconds"))
|
|
511
|
+
return stop()
|
|
512
|
+
end)
|
|
513
|
+
if handshake then handshake:Disconnect() end
|
|
514
|
+
if destruction then destruction:Disconnect() end
|
|
515
|
+
if stop then stop() end
|
|
516
|
+
report:FireServer(if ok then {ok = true, capture = result} else {ok = false, reason = tostring(result)})
|
|
517
|
+
]==], { Seconds = seconds, Path = path }, seconds + 10, function(relay)
|
|
518
|
+
for _, name in { "RemoteTrace", "Paths", "Dispatch" } do
|
|
519
|
+
local copy = script.Parent.Parent[name]:Clone()
|
|
520
|
+
copy.Parent = relay
|
|
521
|
+
end
|
|
522
|
+
end, function()
|
|
523
|
+
stopServer = RemoteTrace.start(root, player, false, seconds, nil)
|
|
524
|
+
end)
|
|
525
|
+
end)
|
|
526
|
+
local server = if stopServer then stopServer() else nil
|
|
527
|
+
if not ok then error(client, 0) end
|
|
528
|
+
if client.ok ~= true or server == nil then
|
|
529
|
+
Dispatch.fail("TRACE_FAILED", tostring(client.reason or "The client did not start the capture."))
|
|
530
|
+
end
|
|
531
|
+
local received = client.capture
|
|
532
|
+
local items = server.items
|
|
533
|
+
for _, item in received.items do table.insert(items, item) end
|
|
534
|
+
local result = { items = items, player = player.Name, requestedSeconds = seconds,
|
|
535
|
+
serverSeconds = server.seconds, clientSeconds = received.seconds,
|
|
536
|
+
events = server.events + received.events,
|
|
537
|
+
truncated = server.eventLimitReached or received.eventLimitReached or server.scanLimitReached
|
|
538
|
+
or received.scanLimitReached or server.rowsOmitted or received.rowsOmitted or server.skipped > 0 or received.skipped > 0,
|
|
539
|
+
note = "Received traffic for this player only; at most 1000 events per direction, 128 remotes per VM and 40 rows. Only visible, accessible remotes are observed. Samples show the first two shapes, not every payload. Rates use each direction's observed window." }
|
|
540
|
+
-- A byte ceiling includes JSON escaping; never silently expand the MCP response.
|
|
541
|
+
local HttpService = game:GetService("HttpService")
|
|
542
|
+
while #items > 0 and #HttpService:JSONEncode(result) > 12000 do
|
|
543
|
+
table.remove(items)
|
|
544
|
+
result.truncated = true
|
|
545
|
+
end
|
|
546
|
+
return result
|
|
547
|
+
end
|
|
548
|
+
|
|
549
|
+
function Debug.register()
|
|
550
|
+
--[[
|
|
551
|
+
Installed at load, not on the first debug request.
|
|
552
|
+
|
|
553
|
+
A breakpoint is registered in one session and hit in another: the editor
|
|
554
|
+
holds it, the playtest's DataModel runs the code and raises the stop. That
|
|
555
|
+
second session is created by pressing play, long after any tool call
|
|
556
|
+
reached the first, so waiting for a request to install the callback leaves
|
|
557
|
+
exactly the session that does the stopping without one -- and a breakpoint
|
|
558
|
+
that verifies, fires, and records nothing.
|
|
559
|
+
|
|
560
|
+
Attaching it here costs a callback assignment per session and means every
|
|
561
|
+
session is ready before anything needs it.
|
|
562
|
+
]]
|
|
563
|
+
install()
|
|
564
|
+
|
|
565
|
+
Dispatch.registerAll("debug", {
|
|
566
|
+
set = Debug.set,
|
|
567
|
+
clear = Debug.clear,
|
|
568
|
+
snapshots = Debug.snapshots,
|
|
569
|
+
exceptions = Debug.exceptions,
|
|
570
|
+
remotes = Debug.remotes,
|
|
571
|
+
})
|
|
572
|
+
end
|
|
573
|
+
|
|
574
|
+
return Debug
|