--!strict --[[ Breakpoints and runtime inspection, through `ScriptDebuggerService`. Not `DebuggerManager`, which is the legacy service and refuses plugins outright -- it wants the LocalUser capability. ScriptDebuggerService is PluginSecurity throughout and is what Roblox shipped to replace it. Two things shape this design, both learned rather than assumed. The service reports "ScriptDebuggerService was never initialized" until a callback is attached, in an editor session and a running playtest alike. So `OnStopped` is installed before anything else is attempted. And `OnStopped` must return its resume decision synchronously. It cannot yield waiting for an agent to look at the stack and decide what to do, which rules out interactive stepping over a request/response protocol entirely. What works instead is a tracepoint: the callback captures the stack and variables into a buffer, lets execution continue, and the agent reads the snapshots afterwards. For finding out what a value was at a moment in time -- which is what a debugger is usually reached for -- that is as good, and it does not leave the user's Studio frozen mid-frame. The documented return shape is contradictory -- the API dump says the callback returns a Dictionary, Roblox's own announcement returns `Enum.DebuggerResumeType.Resume` directly -- and this file used to hedge by never stopping at all, which is worth recording because the hedge cost the whole feature. `ContinueExecution = true` means the debugger does not stop; not stopping means `OnStopped` never runs; and `OnStopped` is the only place a stack or a variable is readable. Every capture breakpoint verified, fired, and recorded nothing, while logpoints on the same lines printed normally -- so the half that needed no callback worked and hid that the other half was dead. The enum is the right return. It resumes cleanly: a script with a breakpoint mid-loop ran to completion untouched, so stopping to capture costs the run nothing and strands nothing. There is no third option. `DebuggerResumeType` offers StepInto, StepOut, StepOver and Resume, and none of them means "stay stopped", so a breakpoint that holds a thread for someone to look at is not expressible here -- which is why nothing in this file offers one. ]] --[[ Fetched defensively, because this module is required at plugin start. Measured with the beta off and Studio restarted: `GetService` does NOT throw here, it hands back a working service object. So this guard is not fixing an observed crash -- it is insurance on the one call in this file that runs before any tool is invoked. init.server.luau requires this module alongside every other handler, so a throw at this line would take down all 29 tools rather than the three that need a debugger, and it would do it silently: a plugin that never loads never connects, leaving nothing to read anywhere. The beta is NOT detected here, because nothing detects it cheaply. See the note on the wholesale-refusal branch in `Debug.set` for the two probes that were tried and why neither works. ]] local ScriptDebuggerService: any = nil do local ok, service = pcall(game.GetService, game, "ScriptDebuggerService") if ok then ScriptDebuggerService = service end end local Dispatch = require(script.Parent.Parent.Dispatch) local Paths = require(script.Parent.Parent.Paths) local ClientRelay = require(script.Parent.Parent.ClientRelay) local RemoteTrace = require(script.Parent.Parent.RemoteTrace) local ScriptEdit = require(script.Parent.Parent.ScriptEdit) local TextEdit = require(script.Parent.Parent.TextEdit) -- Snapshots are far heavier than log lines: each carries a stack and its -- variables. A tight cap keeps a breakpoint inside a loop from exhausting memory -- before anyone reads it. local MAX_SNAPSHOTS = 40 local MAX_FRAMES = 12 local MAX_VARIABLES = 40 local Debug = {} local service = ScriptDebuggerService :: any local snapshots: { { [string]: any } } = {} local overflow = 0 local installed = false local installError: string? = nil --[[ Which lines currently carry a breakpoint, per script. `ScriptDebuggerService` exposes no way to list what is set -- `AddBreakpoint` and `RemoveBreakpoint` are the whole surface, one line at a time -- so "clear every breakpoint in this script" needs its own record of what this module put there, or it cannot be done at all without either the caller naming every line back or this file falling back to `ClearBreakpoints()`, which takes the whole session's breakpoints with it, including ones set by someone else sharing this Studio. ]] local trackedBreakpoints: { [Instance]: { [number]: boolean } } = {} local function track(target: Instance, line: number) local lines = trackedBreakpoints[target] if lines == nil then lines = {} trackedBreakpoints[target] = lines end lines[line] = true end local function untrack(target: Instance, line: number) local lines = trackedBreakpoints[target] if lines then lines[line] = nil end end --[[ Flattens whatever the service hands back. Every one of these shapes is undocumented, so nothing is indexed by a guessed field name. Keys are taken as they come and values stringified, which means an unfamiliar shape arrives readable instead of arriving empty. ]] local function flatten(value: any, depth: number): any if depth > 3 then return "" end local kind = typeof(value) if kind ~= "table" then if kind == "Instance" then return (value :: Instance):GetFullName() end if kind == "EnumItem" then return tostring(value) end return if kind == "string" or kind == "number" or kind == "boolean" then value else tostring(value) end local source = value :: { [any]: any } local out: { [string]: any } = {} local count = 0 for key, item in source do count += 1 if count > MAX_VARIABLES then out["..."] = "more" break end out[tostring(key)] = flatten(item, depth + 1) end return out end --[[ Builds the record for one stop. Each lookup is guarded on its own. A stop that yields a thread id but no readable variables is still worth keeping, and losing the whole snapshot to one failed call would waste the only moment the data existed. ]] local function capture(stopped: any) local record: { [string]: any } = { at = os.time(), stopped = flatten(stopped, 0), } --[[ `ThreadIds`, plural, holding an array -- measured from a real stop: { ThreadIds = {3002}, Reason = Enum.ScriptStoppedReason.Breakpoint } Reading `ThreadId` instead cost nothing visible: snapshots still arrived, carrying the reason and nothing else, so a breakpoint looked like it was working while the stack and variables it exists to collect were never fetched. ]] local threadId: any = nil if typeof(stopped) == "table" then local ids = (stopped :: any).ThreadIds threadId = if typeof(ids) == "table" then (ids :: { any })[1] else (stopped :: any).ThreadId end if threadId ~= nil then local ok, trace = pcall(function() return service:GetStackTrace(threadId) end) if ok then record.stack = flatten(trace, 0) -- Variables hang off a frame, so they are only reachable once the -- trace has given up a frame id. local frames = if typeof(trace) == "table" then (trace :: any).Frames else nil if typeof(frames) == "table" then local locals: { any } = {} for index, frame in frames :: { any } do if index > MAX_FRAMES then break end -- `Id`, measured: a frame is {Id, Line, Name, ScriptPath}. The -- StackFrame *instance* used by Studio's own debugger calls -- it FrameId, which is what this read first, and the -- mismatch cost only the variables -- the frame itself still -- listed, so the stack looked complete. local raw = frame :: any local frameId = if typeof(frame) == "table" then (raw.Id or raw.FrameId) else nil if frameId ~= nil then local gotVars, vars = pcall(function() return service:GetRootVariables(frameId) end) table.insert(locals, { frame = flatten(frame, 1), variables = if gotVars then flatten(vars, 1) else tostring(vars), }) end end record.frames = locals end else record.stackError = tostring(trace) end end if #snapshots >= MAX_SNAPSHOTS then table.remove(snapshots, 1) overflow += 1 end table.insert(snapshots, record) end --[[ Attaches the callback, which is also what initialises the service. The resume decision follows Roblox's own example and returns the enum rather than the Dictionary the dump describes. That is settled by observation now, not by preference: breakpoints stop, this returns, and the stopped script carries on to its last line. ]] local function install(): boolean if installed then return true end if ScriptDebuggerService == nil then installError = "the service is not registered in this Studio" return false end local ok, err = pcall(function() service.OnStopped = function(stopped: any): any -- Capture must never be what keeps a thread paused. pcall(capture, stopped) return Enum.DebuggerResumeType.Resume end end) if not ok then installError = tostring(err) return false end installed = true installError = nil return true end local function requireService() if not install() then Dispatch.fail( "NO_DEBUGGER", string.format( "ScriptDebuggerService is not available in this session: %s", tostring(installError) ), "Turn on \"Debugger Luau API\" in File > Beta Features and restart Studio. " .. "It is off by default, and the service is not registered until it is on." ) end end function Debug.set(params: { [string]: any }): { [string]: any } requireService() local requested = params.breakpoints if typeof(requested) ~= "table" then Dispatch.fail("BAD_PARAMS", "debug set requires a `breakpoints` array.") end local added: { { [string]: any } } = {} local failed: { { [string]: any } } = {} -- How many of `failed` were lines the script does not have, as opposed to -- refusals from the debugger itself. See below. local outside = 0 for _, item in requested :: { { [string]: any } } do local target = Paths.resolve(item.path) if not target:IsA("LuaSourceContainer") then Dispatch.fail( "NOT_A_SCRIPT", string.format("%s is a %s, not a script.", item.path, target.ClassName) ) end --[[ A line the script does not have is knowable here, so it is refused here. It used to go to `AddBreakpoint` like any other, which refuses it with nothing to say -- and the refusal below then blamed the "Debugger Luau API" beta, so a mistyped line number sent people off to change a Studio setting that was already on. ]] local wanted = tonumber(item.line) local lineCount = #TextEdit.toLines(ScriptEdit.read(target :: LuaSourceContainer)) if wanted == nil or wanted < 1 or wanted > lineCount then outside += 1 table.insert(failed, { path = item.path, line = wanted, error = string.format( "the script has %d line%s, so line %s does not exist", lineCount, if lineCount == 1 then "" else "s", tostring(item.line) ), }) continue end --[[ Capitalised keys, from Roblox's example -- `Line`, `Condition`, `LogMessage`, `ContinueExecution`. Lowercase `line` was rejected. `ContinueExecution` decides whether the debugger stops at all, and stopping is the only thing that raises `OnStopped` -- which is the only place a stack or a variable can be read. So a breakpoint that continues past itself captures nothing, ever. That was this file's default, on the reasoning that not stopping was the safe choice: a wrong return from the callback could strand a paused thread, and never pausing made that impossible. It also made the feature impossible. Measured on a line proven to fire -- the same breakpoint, same condition, differing only here -- it captured nothing across two sessions, while the identical breakpoint carrying a LogMessage printed on cue. Stopping is not the hazard it was assumed to be. `OnStopped` returns `Resume` and the thread continues on its own: the probe script ran to completion, printing every line after the breakpoint, with no one touching Studio. So capture stops, and a LogMessage -- which the engine prints without help -- does not need to. ]] local logMessage = if typeof(item.logMessage) == "string" and item.logMessage ~= "" then item.logMessage else nil local descriptor: { [string]: any } = { Line = tonumber(item.line), ContinueExecution = logMessage ~= nil, } if typeof(item.condition) == "string" and item.condition ~= "" then descriptor.Condition = item.condition end if logMessage ~= nil then descriptor.LogMessage = logMessage end local ok, result = pcall(function() return service:AddBreakpoint(target, descriptor) end) if ok then track(target, descriptor.Line) table.insert(added, { path = Paths.of(target), line = descriptor.Line, -- What it will do when hit, since the two kinds behave nothing -- alike: one writes a line to the output, the other stops long -- enough to read the stack and then resumes itself. mode = if logMessage ~= nil then "log" else "capture", result = flatten(result, 1), }) else table.insert(failed, { path = item.path, line = descriptor.Line, error = tostring(result) }) end end --[[ A wholesale refusal names the likely cause without asserting it. `AddBreakpoint` is where the "Debugger Luau API" beta actually bites, and its own message -- "Failed to execute AddBreakpoint request" -- names nothing anyone can act on. The beta cannot be detected from here to say so with certainty, and two attempts to are worth recording so they are not tried a third time: with the beta OFF, `GetService`, `FindService`, assigning `OnStopped` and `Enum.DebuggerResumeType` all still succeed; with the beta ON, `ReflectionService` still does not list the class. The first is always available and the second never is, so neither varies with the thing being measured. The only reliable test is this call, which cannot be run speculatively on a user's script just to answer a status question. So the hint says what to check first and what else it could be, and does not claim to know. ]] if #added == 0 and outside > 0 and outside == #failed then local reasons: { string } = {} for _, entry in failed do table.insert(reasons, string.format("%s: %s", tostring(entry.path), tostring(entry.error))) end Dispatch.fail( "BAD_LINE", string.format("No breakpoint was set: %s.", table.concat(reasons, "; ")), "Read the script with `script_read` for its line numbers -- they shift after every edit." ) end if #added == 0 and #failed > 0 then Dispatch.fail( "NO_BREAKPOINTS_SET", string.format("No breakpoint could be set; all %d were refused.", #failed), "Check that \"Debugger Luau API\" is on in File > Beta Features and that " .. "Studio has been restarted since -- that is the usual cause, it is " .. "off by default, and only the user can change it. Otherwise the " .. "line may not be one that runs: a `return`, an `end` or a bare " .. "declaration is often refused, so try the statement above it." ) end return { added = added, failed = failed, installed = installed } end function Debug.clear(params: { [string]: any }): { [string]: any } requireService() local path = params.path if typeof(path) == "string" and path ~= "" then local target = Paths.resolve(path) local line = tonumber(params.line) if line ~= nil then local ok, removed = pcall(function() return service:RemoveBreakpoint(target, line) end) if ok and removed == true then untrack(target, line) end return { removed = ok and removed == true, path = Paths.of(target), line = line } end --[[ `path` alone, no `line`: every breakpoint this module put in that script. `ScriptDebuggerService` has no "list breakpoints in this script" of its own -- only `AddBreakpoint`/`RemoveBreakpoint`, one line at a time -- so `trackedBreakpoints` is what makes this possible at all, and it is also the reason it can only remove what this session set: a breakpoint another client or the user placed by hand was never tracked here, and this cannot see it to touch it. ]] local lines = trackedBreakpoints[target] local removedLines: { number } = {} if lines then for lineNumber in lines do local ok, removed = pcall(function() return service:RemoveBreakpoint(target, lineNumber) end) if ok and removed == true then table.insert(removedLines, lineNumber) lines[lineNumber] = nil end end if next(lines) == nil then trackedBreakpoints[target] = nil end end table.sort(removedLines) return { removed = #removedLines > 0, path = Paths.of(target), lines = removedLines } end -- Only remove MCP-owned breakpoints, preserving those set in Studio by users. for target, lines in trackedBreakpoints do for lineNumber in lines do local ok, removed = pcall(function() return service:RemoveBreakpoint(target, lineNumber) end) if ok and removed then lines[lineNumber] = nil end end if next(lines) == nil then trackedBreakpoints[target] = nil end end return { cleared = next(trackedBreakpoints) == nil } end function Debug.snapshots(params: { [string]: any }): { [string]: any } local limit = math.clamp(tonumber(params.limit) or 10, 1, MAX_SNAPSHOTS) local out: { { [string]: any } } = {} local first = math.max(1, #snapshots - limit + 1) for index = first, #snapshots do table.insert(out, snapshots[index]) end local total, lost = #snapshots, overflow if params.clear == true then for index = #snapshots, first, -1 do table.remove(snapshots, index) end if #snapshots == 0 then overflow = 0 end end return { items = out, total = total, overflow = if lost > 0 then lost else nil, installed = installed, } end function Debug.exceptions(params: { [string]: any }): { [string]: any } requireService() local mode = tostring(params.mode or "Unhandled") local item = (Enum.DebugBreakModeType :: any)[mode] if item == nil then Dispatch.fail("BAD_PARAMS", string.format("unknown break mode %q", mode)) end local ok, err = pcall(function() service:SetExceptionBreakMode(item) end) if not ok then Dispatch.fail("REFUSED", string.format("SetExceptionBreakMode refused: %s", tostring(err))) end return { mode = mode } end -- Additive listeners only: no RemoteFunction callbacks or game remote sends. function Debug.remotes(params: { [string]: any }): { [string]: any } local player = ClientRelay.playerFor(params.player) local seconds = math.clamp(tonumber(params.seconds) or 5, 1, 15) local path = if typeof(params.path) == "string" then params.path else "game" local root = if path == "game" then game else Paths.resolve(path) local stopServer: (() -> { [string]: any })? = nil local ok, client = pcall(function() return ClientRelay.run(player, "MCPRemoteTraceRelay", [==[ local report = script:WaitForChild("Report", 10) if not report then return end local stop, handshake, destruction local ok, result = pcall(function() assert(game:GetService("RunService"):IsClient(), "Client VM required") local Trace = require(script:WaitForChild("RemoteTrace")) local Paths = require(script:WaitForChild("Paths")) local path = script:GetAttribute("Path") local root = if path == "game" then game else Paths.resolve(path) local ready = false handshake = report.OnClientEvent:Connect(function() ready = true end) report:FireServer({ready = true}) local deadline = os.clock() + 5 while not ready and os.clock() < deadline do task.wait() end handshake:Disconnect() assert(ready, "Trace startup acknowledgement timed out") stop = Trace.start(root, game:GetService("Players").LocalPlayer, true, script:GetAttribute("Seconds"), script) destruction = script.Destroying:Connect(function() stop() end) script:SetAttribute("Capturing", true) task.wait(script:GetAttribute("Seconds")) return stop() end) if handshake then handshake:Disconnect() end if destruction then destruction:Disconnect() end if stop then stop() end report:FireServer(if ok then {ok = true, capture = result} else {ok = false, reason = tostring(result)}) ]==], { Seconds = seconds, Path = path }, seconds + 10, function(relay) for _, name in { "RemoteTrace", "Paths", "Handles", "Dispatch" } do local copy = script.Parent.Parent[name]:Clone() copy.Parent = relay end end, function() stopServer = RemoteTrace.start(root, player, false, seconds, nil) end) end) local server = if stopServer then stopServer() else nil if not ok then error(client, 0) end if client.ok ~= true or server == nil then Dispatch.fail("TRACE_FAILED", tostring(client.reason or "The client did not start the capture.")) end local received = client.capture local items = server.items for _, item in received.items do table.insert(items, item) end local result = { items = items, player = player.Name, requestedSeconds = seconds, serverSeconds = server.seconds, clientSeconds = received.seconds, events = server.events + received.events, truncated = server.eventLimitReached or received.eventLimitReached or server.scanLimitReached or received.scanLimitReached or server.rowsOmitted or received.rowsOmitted or server.skipped > 0 or received.skipped > 0, 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." } -- A byte ceiling includes JSON escaping; never silently expand the MCP response. local HttpService = game:GetService("HttpService") while #items > 0 and #HttpService:JSONEncode(result) > 12000 do table.remove(items) result.truncated = true end return result end function Debug.register() --[[ Installed at load, not on the first debug request. A breakpoint is registered in one session and hit in another: the editor holds it, the playtest's DataModel runs the code and raises the stop. That second session is created by pressing play, long after any tool call reached the first, so waiting for a request to install the callback leaves exactly the session that does the stopping without one -- and a breakpoint that verifies, fires, and records nothing. Attaching it here costs a callback assignment per session and means every session is ready before anything needs it. ]] install() Dispatch.registerAll("debug", { watch = require(script.Parent.Parent.Watch).capture, set = Debug.set, clear = Debug.clear, snapshots = Debug.snapshots, exceptions = Debug.exceptions, remotes = Debug.remotes, collisions = require(script.Parent.Parent.Collisions).capture, }) end return Debug