@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.
@@ -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
- -- Snapshots are far heavier than log lines: each carries a stack and its
72
- -- variables. A tight cap keeps a breakpoint inside a loop from exhausting memory
73
- -- before anyone reads it.
74
- local MAX_SNAPSHOTS = 40
75
- local MAX_FRAMES = 12
76
- local MAX_VARIABLES = 40
77
-
78
- local Debug = {}
79
-
80
- local service = ScriptDebuggerService :: any
81
-
82
- local snapshots: { { [string]: any } } = {}
83
- local overflow = 0
84
- local installed = false
85
- local installError: string? = nil
86
-
87
- --[[
88
- Which lines currently carry a breakpoint, per script.
89
-
90
- `ScriptDebuggerService` exposes no way to list what is set -- `AddBreakpoint`
91
- and `RemoveBreakpoint` are the whole surface, one line at a time -- so
92
- "clear every breakpoint in this script" needs its own record of what this
93
- module put there, or it cannot be done at all without either the caller
94
- naming every line back or this file falling back to `ClearBreakpoints()`,
95
- which takes the whole session's breakpoints with it, including ones set by
96
- someone else sharing this Studio.
97
- ]]
98
- local trackedBreakpoints: { [Instance]: { [number]: boolean } } = {}
99
-
100
- local function track(target: Instance, line: number)
101
- local lines = trackedBreakpoints[target]
102
- if lines == nil then
103
- lines = {}
104
- trackedBreakpoints[target] = lines
105
- end
106
- lines[line] = true
107
- end
108
-
109
- local function untrack(target: Instance, line: number)
110
- local lines = trackedBreakpoints[target]
111
- if lines then
112
- lines[line] = nil
113
- end
114
- end
115
-
116
- --[[
117
- Flattens whatever the service hands back.
118
-
119
- Every one of these shapes is undocumented, so nothing is indexed by a guessed
120
- field name. Keys are taken as they come and values stringified, which means an
121
- unfamiliar shape arrives readable instead of arriving empty.
122
- ]]
123
- local function flatten(value: any, depth: number): any
124
- if depth > 3 then
125
- return "<nested>"
126
- end
127
- local kind = typeof(value)
128
- if kind ~= "table" then
129
- if kind == "Instance" then
130
- return (value :: Instance):GetFullName()
131
- end
132
- if kind == "EnumItem" then
133
- return tostring(value)
134
- end
135
- return if kind == "string" or kind == "number" or kind == "boolean"
136
- then value
137
- else tostring(value)
138
- end
139
-
140
- local source = value :: { [any]: any }
141
- local out: { [string]: any } = {}
142
- local count = 0
143
- for key, item in source do
144
- count += 1
145
- if count > MAX_VARIABLES then
146
- out["..."] = "more"
147
- break
148
- end
149
- out[tostring(key)] = flatten(item, depth + 1)
150
- end
151
- return out
152
- end
153
-
154
- --[[
155
- Builds the record for one stop.
156
-
157
- Each lookup is guarded on its own. A stop that yields a thread id but no
158
- readable variables is still worth keeping, and losing the whole snapshot to
159
- one failed call would waste the only moment the data existed.
160
- ]]
161
- local function capture(stopped: any)
162
- local record: { [string]: any } = {
163
- at = os.time(),
164
- stopped = flatten(stopped, 0),
165
- }
166
-
167
- --[[
168
- `ThreadIds`, plural, holding an array -- measured from a real stop:
169
-
170
- { ThreadIds = {3002}, Reason = Enum.ScriptStoppedReason.Breakpoint }
171
-
172
- Reading `ThreadId` instead cost nothing visible: snapshots still arrived,
173
- carrying the reason and nothing else, so a breakpoint looked like it was
174
- working while the stack and variables it exists to collect were never
175
- fetched.
176
- ]]
177
- local threadId: any = nil
178
- if typeof(stopped) == "table" then
179
- local ids = (stopped :: any).ThreadIds
180
- threadId = if typeof(ids) == "table" then (ids :: { any })[1] else (stopped :: any).ThreadId
181
- end
182
-
183
- if threadId ~= nil then
184
- local ok, trace = pcall(function()
185
- return service:GetStackTrace(threadId)
186
- end)
187
- if ok then
188
- record.stack = flatten(trace, 0)
189
-
190
- -- Variables hang off a frame, so they are only reachable once the
191
- -- trace has given up a frame id.
192
- local frames = if typeof(trace) == "table" then (trace :: any).Frames else nil
193
- if typeof(frames) == "table" then
194
- local locals: { any } = {}
195
- for index, frame in frames :: { any } do
196
- if index > MAX_FRAMES then
197
- break
198
- end
199
- -- `Id`, measured: a frame is {Id, Line, Name, ScriptPath}. The
200
- -- StackFrame *instance* used by Studio's own debugger calls
201
- -- it FrameId, which is what this read first, and the
202
- -- mismatch cost only the variables -- the frame itself still
203
- -- listed, so the stack looked complete.
204
- local raw = frame :: any
205
- local frameId = if typeof(frame) == "table" then (raw.Id or raw.FrameId) else nil
206
- if frameId ~= nil then
207
- local gotVars, vars = pcall(function()
208
- return service:GetRootVariables(frameId)
209
- end)
210
- table.insert(locals, {
211
- frame = flatten(frame, 1),
212
- variables = if gotVars then flatten(vars, 1) else tostring(vars),
213
- })
214
- end
215
- end
216
- record.frames = locals
217
- end
218
- else
219
- record.stackError = tostring(trace)
220
- end
221
- end
222
-
223
- if #snapshots >= MAX_SNAPSHOTS then
224
- table.remove(snapshots, 1)
225
- overflow += 1
226
- end
227
- table.insert(snapshots, record)
228
- end
229
-
230
- --[[
231
- Attaches the callback, which is also what initialises the service.
232
-
233
- The resume decision follows Roblox's own example and returns the enum rather
234
- than the Dictionary the dump describes. That is settled by observation now,
235
- not by preference: breakpoints stop, this returns, and the stopped script
236
- carries on to its last line.
237
- ]]
238
- local function install(): boolean
239
- if installed then
240
- return true
241
- end
242
-
243
- if ScriptDebuggerService == nil then
244
- installError = "the service is not registered in this Studio"
245
- return false
246
- end
247
-
248
- local ok, err = pcall(function()
249
- service.OnStopped = function(stopped: any): any
250
- -- Capture must never be what keeps a thread paused.
251
- pcall(capture, stopped)
252
- return Enum.DebuggerResumeType.Resume
253
- end
254
- end)
255
-
256
- if not ok then
257
- installError = tostring(err)
258
- return false
259
- end
260
- installed = true
261
- installError = nil
262
- return true
263
- end
264
-
265
- local function requireService()
266
- if not install() then
267
- Dispatch.fail(
268
- "NO_DEBUGGER",
269
- string.format(
270
- "ScriptDebuggerService is not available in this session: %s",
271
- tostring(installError)
272
- ),
273
- "Turn on \"Debugger Luau API\" in File > Beta Features and restart Studio. "
274
- .. "It is off by default, and the service is not registered until it is on."
275
- )
276
- end
277
- end
278
-
279
- function Debug.set(params: { [string]: any }): { [string]: any }
280
- requireService()
281
-
282
- local requested = params.breakpoints
283
- if typeof(requested) ~= "table" then
284
- Dispatch.fail("BAD_PARAMS", "debug set requires a `breakpoints` array.")
285
- end
286
-
287
- local added: { { [string]: any } } = {}
288
- local failed: { { [string]: any } } = {}
289
-
290
- for _, item in requested :: { { [string]: any } } do
291
- local target = Paths.resolve(item.path)
292
- if not target:IsA("LuaSourceContainer") then
293
- Dispatch.fail(
294
- "NOT_A_SCRIPT",
295
- string.format("%s is a %s, not a script.", item.path, target.ClassName)
296
- )
297
- end
298
-
299
- --[[
300
- Capitalised keys, from Roblox's example -- `Line`, `Condition`,
301
- `LogMessage`, `ContinueExecution`. Lowercase `line` was rejected.
302
-
303
- `ContinueExecution` decides whether the debugger stops at all, and
304
- stopping is the only thing that raises `OnStopped` -- which is the
305
- only place a stack or a variable can be read. So a breakpoint that
306
- continues past itself captures nothing, ever.
307
-
308
- That was this file's default, on the reasoning that not stopping was
309
- the safe choice: a wrong return from the callback could strand a
310
- paused thread, and never pausing made that impossible. It also made
311
- the feature impossible. Measured on a line proven to fire -- the same
312
- breakpoint, same condition, differing only here -- it captured
313
- nothing across two sessions, while the identical breakpoint carrying
314
- a LogMessage printed on cue.
315
-
316
- Stopping is not the hazard it was assumed to be. `OnStopped` returns
317
- `Resume` and the thread continues on its own: the probe script ran to
318
- completion, printing every line after the breakpoint, with no one
319
- touching Studio. So capture stops, and a LogMessage -- which the
320
- engine prints without help -- does not need to.
321
- ]]
322
- local logMessage = if typeof(item.logMessage) == "string" and item.logMessage ~= ""
323
- then item.logMessage
324
- else nil
325
- local descriptor: { [string]: any } = {
326
- Line = tonumber(item.line),
327
- ContinueExecution = logMessage ~= nil,
328
- }
329
- if typeof(item.condition) == "string" and item.condition ~= "" then
330
- descriptor.Condition = item.condition
331
- end
332
- if logMessage ~= nil then
333
- descriptor.LogMessage = logMessage
334
- end
335
-
336
- local ok, result = pcall(function()
337
- return service:AddBreakpoint(target, descriptor)
338
- end)
339
-
340
- if ok then
341
- track(target, descriptor.Line)
342
- table.insert(added, {
343
- path = Paths.of(target),
344
- line = descriptor.Line,
345
- -- What it will do when hit, since the two kinds behave nothing
346
- -- alike: one writes a line to the output, the other stops long
347
- -- enough to read the stack and then resumes itself.
348
- mode = if logMessage ~= nil then "log" else "capture",
349
- result = flatten(result, 1),
350
- })
351
- else
352
- table.insert(failed, { path = item.path, line = descriptor.Line, error = tostring(result) })
353
- end
354
- end
355
-
356
- --[[
357
- A wholesale refusal names the likely cause without asserting it.
358
-
359
- `AddBreakpoint` is where the "Debugger Luau API" beta actually bites, and
360
- its own message -- "Failed to execute AddBreakpoint request" -- names
361
- nothing anyone can act on. The beta cannot be detected from here to say
362
- so with certainty, and two attempts to are worth recording so they are
363
- not tried a third time: with the beta OFF, `GetService`, `FindService`,
364
- assigning `OnStopped` and `Enum.DebuggerResumeType` all still succeed;
365
- with the beta ON, `ReflectionService` still does not list the class. The
366
- first is always available and the second never is, so neither varies with
367
- the thing being measured.
368
-
369
- The only reliable test is this call, which cannot be run speculatively on
370
- a user's script just to answer a status question. So the hint says what
371
- to check first and what else it could be, and does not claim to know.
372
- ]]
373
- if #added == 0 and #failed > 0 then
374
- Dispatch.fail(
375
- "NO_BREAKPOINTS_SET",
376
- string.format("No breakpoint could be set; all %d were refused.", #failed),
377
- "Check that \"Debugger Luau API\" is on in File > Beta Features and that "
378
- .. "Studio has been restarted since -- that is the usual cause, it is "
379
- .. "off by default, and only the user can change it. Otherwise the "
380
- .. "line may not be one that runs: a `return`, an `end` or a bare "
381
- .. "declaration is often refused, so try the statement above it."
382
- )
383
- end
384
-
385
- return { added = added, failed = failed, installed = installed }
386
- end
387
-
388
- function Debug.clear(params: { [string]: any }): { [string]: any }
389
- requireService()
390
-
391
- local path = params.path
392
- if typeof(path) == "string" and path ~= "" then
393
- local target = Paths.resolve(path)
394
- local line = tonumber(params.line)
395
-
396
- if line ~= nil then
397
- local ok, removed = pcall(function()
398
- return service:RemoveBreakpoint(target, line)
399
- end)
400
- untrack(target, line)
401
- return { removed = ok and removed == true, path = Paths.of(target), line = line }
402
- end
403
-
404
- --[[
405
- `path` alone, no `line`: every breakpoint this module put in that
406
- script. `ScriptDebuggerService` has no "list breakpoints in this
407
- script" of its own -- only `AddBreakpoint`/`RemoveBreakpoint`, one
408
- line at a time -- so `trackedBreakpoints` is what makes this
409
- possible at all, and it is also the reason it can only remove what
410
- this session set: a breakpoint another client or the user placed
411
- by hand was never tracked here, and this cannot see it to touch it.
412
- ]]
413
- local lines = trackedBreakpoints[target]
414
- local removedLines: { number } = {}
415
- if lines then
416
- for lineNumber in lines do
417
- local ok = pcall(function()
418
- service:RemoveBreakpoint(target, lineNumber)
419
- end)
420
- if ok then
421
- table.insert(removedLines, lineNumber)
422
- end
423
- end
424
- trackedBreakpoints[target] = nil
425
- end
426
- table.sort(removedLines)
427
- return { removed = #removedLines > 0, path = Paths.of(target), lines = removedLines }
428
- end
429
-
430
- local ok, err = pcall(function()
431
- service:ClearBreakpoints()
432
- end)
433
- if not ok then
434
- Dispatch.fail("REFUSED", string.format("ClearBreakpoints refused: %s", tostring(err)))
435
- end
436
- table.clear(trackedBreakpoints)
437
- return { cleared = true }
438
- end
439
-
440
- function Debug.snapshots(params: { [string]: any }): { [string]: any }
441
- local limit = math.clamp(tonumber(params.limit) or 10, 1, MAX_SNAPSHOTS)
442
-
443
- local out: { { [string]: any } } = {}
444
- local first = math.max(1, #snapshots - limit + 1)
445
- for index = first, #snapshots do
446
- table.insert(out, snapshots[index])
447
- end
448
-
449
- if params.clear == true then
450
- snapshots = {}
451
- overflow = 0
452
- end
453
-
454
- return {
455
- items = out,
456
- total = #snapshots,
457
- overflow = if overflow > 0 then overflow else nil,
458
- installed = installed,
459
- }
460
- end
461
-
462
- function Debug.exceptions(params: { [string]: any }): { [string]: any }
463
- requireService()
464
-
465
- local mode = tostring(params.mode or "Unhandled")
466
- local item = (Enum.DebugBreakModeType :: any)[mode]
467
- if item == nil then
468
- Dispatch.fail("BAD_PARAMS", string.format("unknown break mode %q", mode))
469
- end
470
-
471
- local ok, err = pcall(function()
472
- service:SetExceptionBreakMode(item)
473
- end)
474
- if not ok then
475
- Dispatch.fail("REFUSED", string.format("SetExceptionBreakMode refused: %s", tostring(err)))
476
- end
477
- return { mode = mode }
478
- end
479
-
480
- function Debug.register()
481
- --[[
482
- Installed at load, not on the first debug request.
483
-
484
- A breakpoint is registered in one session and hit in another: the editor
485
- holds it, the playtest's DataModel runs the code and raises the stop. That
486
- second session is created by pressing play, long after any tool call
487
- reached the first, so waiting for a request to install the callback leaves
488
- exactly the session that does the stopping without one -- and a breakpoint
489
- that verifies, fires, and records nothing.
490
-
491
- Attaching it here costs a callback assignment per session and means every
492
- session is ready before anything needs it.
493
- ]]
494
- install()
495
-
496
- Dispatch.registerAll("debug", {
497
- set = Debug.set,
498
- clear = Debug.clear,
499
- snapshots = Debug.snapshots,
500
- exceptions = Debug.exceptions,
501
- })
502
- end
503
-
504
- return Debug
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