@el4cteo/rbx-studio-mcp 0.1.0
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/LICENSE +21 -0
- package/README.md +203 -0
- package/dist/bridge/rpc.js +243 -0
- package/dist/bridge/rpc.js.map +1 -0
- package/dist/bridge/server.js +281 -0
- package/dist/bridge/server.js.map +1 -0
- package/dist/index.js +104 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/apidump.js +269 -0
- package/dist/lib/apidump.js.map +1 -0
- package/dist/lib/errors.js +38 -0
- package/dist/lib/errors.js.map +1 -0
- package/dist/lib/format.js +191 -0
- package/dist/lib/format.js.map +1 -0
- package/dist/lib/pluginbuild.js +83 -0
- package/dist/lib/pluginbuild.js.map +1 -0
- package/dist/lib/png.js +84 -0
- package/dist/lib/png.js.map +1 -0
- package/dist/lib/protocol.js +22 -0
- package/dist/lib/protocol.js.map +1 -0
- package/dist/lib/tool.js +27 -0
- package/dist/lib/tool.js.map +1 -0
- package/dist/resources.js +70 -0
- package/dist/resources.js.map +1 -0
- package/dist/tools/api.js +78 -0
- package/dist/tools/api.js.map +1 -0
- package/dist/tools/character.js +94 -0
- package/dist/tools/character.js.map +1 -0
- package/dist/tools/debug.js +211 -0
- package/dist/tools/debug.js.map +1 -0
- package/dist/tools/device.js +74 -0
- package/dist/tools/device.js.map +1 -0
- package/dist/tools/discover.js +217 -0
- package/dist/tools/discover.js.map +1 -0
- package/dist/tools/exec.js +191 -0
- package/dist/tools/exec.js.map +1 -0
- package/dist/tools/input.js +96 -0
- package/dist/tools/input.js.map +1 -0
- package/dist/tools/instances.js +261 -0
- package/dist/tools/instances.js.map +1 -0
- package/dist/tools/perf.js +367 -0
- package/dist/tools/perf.js.map +1 -0
- package/dist/tools/playtest.js +153 -0
- package/dist/tools/playtest.js.map +1 -0
- package/dist/tools/screenshot.js +75 -0
- package/dist/tools/screenshot.js.map +1 -0
- package/dist/tools/scripts.js +316 -0
- package/dist/tools/scripts.js.map +1 -0
- package/dist/tools/session.js +152 -0
- package/dist/tools/session.js.map +1 -0
- package/dist/tools/world.js +281 -0
- package/dist/tools/world.js.map +1 -0
- package/package.json +62 -0
- package/plugin/default.project.json +6 -0
- package/plugin/src/Config.luau +59 -0
- package/plugin/src/Console.luau +657 -0
- package/plugin/src/Context.luau +35 -0
- package/plugin/src/Dispatch.luau +90 -0
- package/plugin/src/Editor.luau +142 -0
- package/plugin/src/Emulation.luau +151 -0
- package/plugin/src/LogBuffer.luau +277 -0
- package/plugin/src/Net.luau +102 -0
- package/plugin/src/Paths.luau +255 -0
- package/plugin/src/Phrase.luau +465 -0
- package/plugin/src/Png.luau +238 -0
- package/plugin/src/Scope.luau +78 -0
- package/plugin/src/ScriptEdit.luau +100 -0
- package/plugin/src/Serialize.luau +287 -0
- package/plugin/src/TextEdit.luau +296 -0
- package/plugin/src/Transport.luau +328 -0
- package/plugin/src/Undo.luau +72 -0
- package/plugin/src/Visuals.luau +710 -0
- package/plugin/src/handlers/Api.luau +242 -0
- package/plugin/src/handlers/Assets.luau +145 -0
- package/plugin/src/handlers/Capture.luau +187 -0
- package/plugin/src/handlers/Character.luau +361 -0
- package/plugin/src/handlers/Debug.luau +391 -0
- package/plugin/src/handlers/Device.luau +119 -0
- package/plugin/src/handlers/Discover.luau +289 -0
- package/plugin/src/handlers/Exec.luau +270 -0
- package/plugin/src/handlers/Geometry.luau +261 -0
- package/plugin/src/handlers/Input.luau +287 -0
- package/plugin/src/handlers/Instances.luau +389 -0
- package/plugin/src/handlers/Perf.luau +645 -0
- package/plugin/src/handlers/Playtest.luau +205 -0
- package/plugin/src/handlers/Scripts.luau +387 -0
- package/plugin/src/handlers/Session.luau +168 -0
- package/plugin/src/handlers/Viewport.luau +302 -0
- package/plugin/src/handlers/World.luau +176 -0
- package/plugin/src/init.server.luau +317 -0
- package/scripts/build-plugin.mjs +157 -0
- package/scripts/check-plugin.mjs +97 -0
- package/scripts/install-plugin.mjs +39 -0
- package/scripts/latency.mjs +201 -0
- package/scripts/locate-luau.mjs +51 -0
- package/scripts/sourcemap.mjs +58 -0
- package/scripts/test-plugin.mjs +82 -0
|
@@ -0,0 +1,391 @@
|
|
|
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
|
+
local ScriptDebuggerService = game:GetService("ScriptDebuggerService")
|
|
46
|
+
|
|
47
|
+
local Dispatch = require(script.Parent.Parent.Dispatch)
|
|
48
|
+
local Paths = require(script.Parent.Parent.Paths)
|
|
49
|
+
|
|
50
|
+
-- Snapshots are far heavier than log lines: each carries a stack and its
|
|
51
|
+
-- variables. A tight cap keeps a breakpoint inside a loop from exhausting memory
|
|
52
|
+
-- before anyone reads it.
|
|
53
|
+
local MAX_SNAPSHOTS = 40
|
|
54
|
+
local MAX_FRAMES = 12
|
|
55
|
+
local MAX_VARIABLES = 40
|
|
56
|
+
|
|
57
|
+
local Debug = {}
|
|
58
|
+
|
|
59
|
+
local service = ScriptDebuggerService :: any
|
|
60
|
+
|
|
61
|
+
local snapshots: { { [string]: any } } = {}
|
|
62
|
+
local overflow = 0
|
|
63
|
+
local installed = false
|
|
64
|
+
local installError: string? = nil
|
|
65
|
+
|
|
66
|
+
--[[
|
|
67
|
+
Flattens whatever the service hands back.
|
|
68
|
+
|
|
69
|
+
Every one of these shapes is undocumented, so nothing is indexed by a guessed
|
|
70
|
+
field name. Keys are taken as they come and values stringified, which means an
|
|
71
|
+
unfamiliar shape arrives readable instead of arriving empty.
|
|
72
|
+
]]
|
|
73
|
+
local function flatten(value: any, depth: number): any
|
|
74
|
+
if depth > 3 then
|
|
75
|
+
return "<nested>"
|
|
76
|
+
end
|
|
77
|
+
local kind = typeof(value)
|
|
78
|
+
if kind ~= "table" then
|
|
79
|
+
if kind == "Instance" then
|
|
80
|
+
return (value :: Instance):GetFullName()
|
|
81
|
+
end
|
|
82
|
+
if kind == "EnumItem" then
|
|
83
|
+
return tostring(value)
|
|
84
|
+
end
|
|
85
|
+
return if kind == "string" or kind == "number" or kind == "boolean"
|
|
86
|
+
then value
|
|
87
|
+
else tostring(value)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
local source = value :: { [any]: any }
|
|
91
|
+
local out: { [string]: any } = {}
|
|
92
|
+
local count = 0
|
|
93
|
+
for key, item in source do
|
|
94
|
+
count += 1
|
|
95
|
+
if count > MAX_VARIABLES then
|
|
96
|
+
out["..."] = "more"
|
|
97
|
+
break
|
|
98
|
+
end
|
|
99
|
+
out[tostring(key)] = flatten(item, depth + 1)
|
|
100
|
+
end
|
|
101
|
+
return out
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
--[[
|
|
105
|
+
Builds the record for one stop.
|
|
106
|
+
|
|
107
|
+
Each lookup is guarded on its own. A stop that yields a thread id but no
|
|
108
|
+
readable variables is still worth keeping, and losing the whole snapshot to
|
|
109
|
+
one failed call would waste the only moment the data existed.
|
|
110
|
+
]]
|
|
111
|
+
local function capture(stopped: any)
|
|
112
|
+
local record: { [string]: any } = {
|
|
113
|
+
at = os.time(),
|
|
114
|
+
stopped = flatten(stopped, 0),
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
--[[
|
|
118
|
+
`ThreadIds`, plural, holding an array -- measured from a real stop:
|
|
119
|
+
|
|
120
|
+
{ ThreadIds = {3002}, Reason = Enum.ScriptStoppedReason.Breakpoint }
|
|
121
|
+
|
|
122
|
+
Reading `ThreadId` instead cost nothing visible: snapshots still arrived,
|
|
123
|
+
carrying the reason and nothing else, so a breakpoint looked like it was
|
|
124
|
+
working while the stack and variables it exists to collect were never
|
|
125
|
+
fetched.
|
|
126
|
+
]]
|
|
127
|
+
local threadId: any = nil
|
|
128
|
+
if typeof(stopped) == "table" then
|
|
129
|
+
local ids = (stopped :: any).ThreadIds
|
|
130
|
+
threadId = if typeof(ids) == "table" then (ids :: { any })[1] else (stopped :: any).ThreadId
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
if threadId ~= nil then
|
|
134
|
+
local ok, trace = pcall(function()
|
|
135
|
+
return service:GetStackTrace(threadId)
|
|
136
|
+
end)
|
|
137
|
+
if ok then
|
|
138
|
+
record.stack = flatten(trace, 0)
|
|
139
|
+
|
|
140
|
+
-- Variables hang off a frame, so they are only reachable once the
|
|
141
|
+
-- trace has given up a frame id.
|
|
142
|
+
local frames = if typeof(trace) == "table" then (trace :: any).Frames else nil
|
|
143
|
+
if typeof(frames) == "table" then
|
|
144
|
+
local locals: { any } = {}
|
|
145
|
+
for index, frame in frames :: { any } do
|
|
146
|
+
if index > MAX_FRAMES then
|
|
147
|
+
break
|
|
148
|
+
end
|
|
149
|
+
-- `Id`, measured: a frame is {Id, Line, Name, ScriptPath}. The
|
|
150
|
+
-- StackFrame *instance* used by Studio's own debugger calls
|
|
151
|
+
-- it FrameId, which is what this read first, and the
|
|
152
|
+
-- mismatch cost only the variables -- the frame itself still
|
|
153
|
+
-- listed, so the stack looked complete.
|
|
154
|
+
local raw = frame :: any
|
|
155
|
+
local frameId = if typeof(frame) == "table" then (raw.Id or raw.FrameId) else nil
|
|
156
|
+
if frameId ~= nil then
|
|
157
|
+
local gotVars, vars = pcall(function()
|
|
158
|
+
return service:GetRootVariables(frameId)
|
|
159
|
+
end)
|
|
160
|
+
table.insert(locals, {
|
|
161
|
+
frame = flatten(frame, 1),
|
|
162
|
+
variables = if gotVars then flatten(vars, 1) else tostring(vars),
|
|
163
|
+
})
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
record.frames = locals
|
|
167
|
+
end
|
|
168
|
+
else
|
|
169
|
+
record.stackError = tostring(trace)
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
if #snapshots >= MAX_SNAPSHOTS then
|
|
174
|
+
table.remove(snapshots, 1)
|
|
175
|
+
overflow += 1
|
|
176
|
+
end
|
|
177
|
+
table.insert(snapshots, record)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
--[[
|
|
181
|
+
Attaches the callback, which is also what initialises the service.
|
|
182
|
+
|
|
183
|
+
The resume decision follows Roblox's own example and returns the enum rather
|
|
184
|
+
than the Dictionary the dump describes. That is settled by observation now,
|
|
185
|
+
not by preference: breakpoints stop, this returns, and the stopped script
|
|
186
|
+
carries on to its last line.
|
|
187
|
+
]]
|
|
188
|
+
local function install(): boolean
|
|
189
|
+
if installed then
|
|
190
|
+
return true
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
local ok, err = pcall(function()
|
|
194
|
+
service.OnStopped = function(stopped: any): any
|
|
195
|
+
-- Capture must never be what keeps a thread paused.
|
|
196
|
+
pcall(capture, stopped)
|
|
197
|
+
return Enum.DebuggerResumeType.Resume
|
|
198
|
+
end
|
|
199
|
+
end)
|
|
200
|
+
|
|
201
|
+
if not ok then
|
|
202
|
+
installError = tostring(err)
|
|
203
|
+
return false
|
|
204
|
+
end
|
|
205
|
+
installed = true
|
|
206
|
+
installError = nil
|
|
207
|
+
return true
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
local function requireService()
|
|
211
|
+
if not install() then
|
|
212
|
+
Dispatch.fail(
|
|
213
|
+
"NO_DEBUGGER",
|
|
214
|
+
string.format(
|
|
215
|
+
"ScriptDebuggerService is not available in this session: %s",
|
|
216
|
+
tostring(installError)
|
|
217
|
+
),
|
|
218
|
+
"It is a beta API; the Studio build may not expose it yet."
|
|
219
|
+
)
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
function Debug.set(params: { [string]: any }): { [string]: any }
|
|
224
|
+
requireService()
|
|
225
|
+
|
|
226
|
+
local requested = params.breakpoints
|
|
227
|
+
if typeof(requested) ~= "table" then
|
|
228
|
+
Dispatch.fail("BAD_PARAMS", "debug set requires a `breakpoints` array.")
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
local added: { { [string]: any } } = {}
|
|
232
|
+
local failed: { { [string]: any } } = {}
|
|
233
|
+
|
|
234
|
+
for _, item in requested :: { { [string]: any } } do
|
|
235
|
+
local target = Paths.resolve(item.path)
|
|
236
|
+
if not target:IsA("LuaSourceContainer") then
|
|
237
|
+
Dispatch.fail(
|
|
238
|
+
"NOT_A_SCRIPT",
|
|
239
|
+
string.format("%s is a %s, not a script.", item.path, target.ClassName)
|
|
240
|
+
)
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
--[[
|
|
244
|
+
Capitalised keys, from Roblox's example -- `Line`, `Condition`,
|
|
245
|
+
`LogMessage`, `ContinueExecution`. Lowercase `line` was rejected.
|
|
246
|
+
|
|
247
|
+
`ContinueExecution` decides whether the debugger stops at all, and
|
|
248
|
+
stopping is the only thing that raises `OnStopped` -- which is the
|
|
249
|
+
only place a stack or a variable can be read. So a breakpoint that
|
|
250
|
+
continues past itself captures nothing, ever.
|
|
251
|
+
|
|
252
|
+
That was this file's default, on the reasoning that not stopping was
|
|
253
|
+
the safe choice: a wrong return from the callback could strand a
|
|
254
|
+
paused thread, and never pausing made that impossible. It also made
|
|
255
|
+
the feature impossible. Measured on a line proven to fire -- the same
|
|
256
|
+
breakpoint, same condition, differing only here -- it captured
|
|
257
|
+
nothing across two sessions, while the identical breakpoint carrying
|
|
258
|
+
a LogMessage printed on cue.
|
|
259
|
+
|
|
260
|
+
Stopping is not the hazard it was assumed to be. `OnStopped` returns
|
|
261
|
+
`Resume` and the thread continues on its own: the probe script ran to
|
|
262
|
+
completion, printing every line after the breakpoint, with no one
|
|
263
|
+
touching Studio. So capture stops, and a LogMessage -- which the
|
|
264
|
+
engine prints without help -- does not need to.
|
|
265
|
+
]]
|
|
266
|
+
local logMessage = if typeof(item.logMessage) == "string" and item.logMessage ~= ""
|
|
267
|
+
then item.logMessage
|
|
268
|
+
else nil
|
|
269
|
+
local descriptor: { [string]: any } = {
|
|
270
|
+
Line = tonumber(item.line),
|
|
271
|
+
ContinueExecution = logMessage ~= nil,
|
|
272
|
+
}
|
|
273
|
+
if typeof(item.condition) == "string" and item.condition ~= "" then
|
|
274
|
+
descriptor.Condition = item.condition
|
|
275
|
+
end
|
|
276
|
+
if logMessage ~= nil then
|
|
277
|
+
descriptor.LogMessage = logMessage
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
local ok, result = pcall(function()
|
|
281
|
+
return service:AddBreakpoint(target, descriptor)
|
|
282
|
+
end)
|
|
283
|
+
|
|
284
|
+
if ok then
|
|
285
|
+
table.insert(added, {
|
|
286
|
+
path = Paths.of(target),
|
|
287
|
+
line = descriptor.Line,
|
|
288
|
+
-- What it will do when hit, since the two kinds behave nothing
|
|
289
|
+
-- alike: one writes a line to the output, the other stops long
|
|
290
|
+
-- enough to read the stack and then resumes itself.
|
|
291
|
+
mode = if logMessage ~= nil then "log" else "capture",
|
|
292
|
+
result = flatten(result, 1),
|
|
293
|
+
})
|
|
294
|
+
else
|
|
295
|
+
table.insert(failed, { path = item.path, line = descriptor.Line, error = tostring(result) })
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
return { added = added, failed = failed, installed = installed }
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
function Debug.clear(params: { [string]: any }): { [string]: any }
|
|
303
|
+
requireService()
|
|
304
|
+
|
|
305
|
+
local path = params.path
|
|
306
|
+
if typeof(path) == "string" and path ~= "" then
|
|
307
|
+
local target = Paths.resolve(path)
|
|
308
|
+
local line = tonumber(params.line)
|
|
309
|
+
if line == nil then
|
|
310
|
+
Dispatch.fail("BAD_PARAMS", "removing one breakpoint needs a `line`.")
|
|
311
|
+
end
|
|
312
|
+
local ok, removed = pcall(function()
|
|
313
|
+
return service:RemoveBreakpoint(target, line)
|
|
314
|
+
end)
|
|
315
|
+
return { removed = ok and removed == true, path = Paths.of(target), line = line }
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
local ok, err = pcall(function()
|
|
319
|
+
service:ClearBreakpoints()
|
|
320
|
+
end)
|
|
321
|
+
if not ok then
|
|
322
|
+
Dispatch.fail("REFUSED", string.format("ClearBreakpoints refused: %s", tostring(err)))
|
|
323
|
+
end
|
|
324
|
+
return { cleared = true }
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
function Debug.snapshots(params: { [string]: any }): { [string]: any }
|
|
328
|
+
local limit = math.clamp(tonumber(params.limit) or 10, 1, MAX_SNAPSHOTS)
|
|
329
|
+
|
|
330
|
+
local out: { { [string]: any } } = {}
|
|
331
|
+
local first = math.max(1, #snapshots - limit + 1)
|
|
332
|
+
for index = first, #snapshots do
|
|
333
|
+
table.insert(out, snapshots[index])
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
if params.clear == true then
|
|
337
|
+
snapshots = {}
|
|
338
|
+
overflow = 0
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
return {
|
|
342
|
+
items = out,
|
|
343
|
+
total = #snapshots,
|
|
344
|
+
overflow = if overflow > 0 then overflow else nil,
|
|
345
|
+
installed = installed,
|
|
346
|
+
}
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
function Debug.exceptions(params: { [string]: any }): { [string]: any }
|
|
350
|
+
requireService()
|
|
351
|
+
|
|
352
|
+
local mode = tostring(params.mode or "Unhandled")
|
|
353
|
+
local item = (Enum.DebugBreakModeType :: any)[mode]
|
|
354
|
+
if item == nil then
|
|
355
|
+
Dispatch.fail("BAD_PARAMS", string.format("unknown break mode %q", mode))
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
local ok, err = pcall(function()
|
|
359
|
+
service:SetExceptionBreakMode(item)
|
|
360
|
+
end)
|
|
361
|
+
if not ok then
|
|
362
|
+
Dispatch.fail("REFUSED", string.format("SetExceptionBreakMode refused: %s", tostring(err)))
|
|
363
|
+
end
|
|
364
|
+
return { mode = mode }
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
function Debug.register()
|
|
368
|
+
--[[
|
|
369
|
+
Installed at load, not on the first debug request.
|
|
370
|
+
|
|
371
|
+
A breakpoint is registered in one session and hit in another: the editor
|
|
372
|
+
holds it, the playtest's DataModel runs the code and raises the stop. That
|
|
373
|
+
second session is created by pressing play, long after any tool call
|
|
374
|
+
reached the first, so waiting for a request to install the callback leaves
|
|
375
|
+
exactly the session that does the stopping without one -- and a breakpoint
|
|
376
|
+
that verifies, fires, and records nothing.
|
|
377
|
+
|
|
378
|
+
Attaching it here costs a callback assignment per session and means every
|
|
379
|
+
session is ready before anything needs it.
|
|
380
|
+
]]
|
|
381
|
+
install()
|
|
382
|
+
|
|
383
|
+
Dispatch.registerAll("debug", {
|
|
384
|
+
set = Debug.set,
|
|
385
|
+
clear = Debug.clear,
|
|
386
|
+
snapshots = Debug.snapshots,
|
|
387
|
+
exceptions = Debug.exceptions,
|
|
388
|
+
})
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
return Debug
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
--!strict
|
|
2
|
+
--[[
|
|
3
|
+
Device emulation: run Studio's viewport as a phone, tablet or console.
|
|
4
|
+
|
|
5
|
+
Most Roblox players are on a phone, and most UI is authored on a desktop
|
|
6
|
+
monitor. The gap between those two is where interfaces break -- a button
|
|
7
|
+
inside the notch, a menu off the bottom of a 393-pixel-tall screen, text
|
|
8
|
+
scaled for a display three times larger. Reading the data model cannot find
|
|
9
|
+
any of it, because every one of those instances has correct properties.
|
|
10
|
+
|
|
11
|
+
`StudioDeviceSimulatorService` resizes the viewport for real. Paired with
|
|
12
|
+
`screenshot` it is the only way to answer "does this work on a phone"
|
|
13
|
+
without owning one: set the device, take the picture, look.
|
|
14
|
+
|
|
15
|
+
Reading the current state is `Emulation`'s job, shared with `screenshot` and
|
|
16
|
+
`studio_status` so the three cannot drift apart on what is being emulated.
|
|
17
|
+
]]
|
|
18
|
+
|
|
19
|
+
local StudioDeviceSimulatorService = game:GetService("StudioDeviceSimulatorService")
|
|
20
|
+
|
|
21
|
+
local Dispatch = require(script.Parent.Parent.Dispatch)
|
|
22
|
+
local Emulation = require(script.Parent.Parent.Emulation)
|
|
23
|
+
|
|
24
|
+
local Device = {}
|
|
25
|
+
|
|
26
|
+
function Device.list(_params: { [string]: any }): { [string]: any }
|
|
27
|
+
local ok, list = pcall(function()
|
|
28
|
+
return (StudioDeviceSimulatorService :: any):GetDeviceListAsync()
|
|
29
|
+
end)
|
|
30
|
+
if not ok then
|
|
31
|
+
Dispatch.fail("DEVICE_UNAVAILABLE", string.format("Could not list devices: %s", tostring(list)))
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
--[[
|
|
35
|
+
The list is ids, not descriptions, and an id alone does not say whether
|
|
36
|
+
"generic_handheld_720" is a phone or a handheld console. The details come
|
|
37
|
+
from a second call per device, which is worth the round trip once because
|
|
38
|
+
the caller otherwise has to guess which id to ask for.
|
|
39
|
+
]]
|
|
40
|
+
local devices: { { [string]: any } } = {}
|
|
41
|
+
for _, id in list :: { string } do
|
|
42
|
+
local okInfo, info = pcall(function()
|
|
43
|
+
return (StudioDeviceSimulatorService :: any):GetDeviceInfoAsync(id)
|
|
44
|
+
end)
|
|
45
|
+
if okInfo and typeof(info) == "table" then
|
|
46
|
+
local entry = info :: { [string]: any }
|
|
47
|
+
table.insert(devices, {
|
|
48
|
+
id = tostring(id),
|
|
49
|
+
name = tostring(entry.Name),
|
|
50
|
+
form = tostring(entry.DeviceForm):gsub("Enum%.DeviceForm%.", ""),
|
|
51
|
+
resolution = string.format("%dx%d", entry.Width or 0, entry.Height or 0),
|
|
52
|
+
})
|
|
53
|
+
else
|
|
54
|
+
table.insert(devices, { id = tostring(id) })
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
return { devices = devices, count = #devices, current = Emulation.state() }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
function Device.set(params: { [string]: any }): { [string]: any }
|
|
62
|
+
local id = params.device
|
|
63
|
+
if typeof(id) ~= "string" or id == "" then
|
|
64
|
+
Dispatch.fail("BAD_PARAMS", "set needs a `device` id. Call `list` to see them.")
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
local ok, err = pcall(function()
|
|
68
|
+
(StudioDeviceSimulatorService :: any):SetDeviceAsync(id)
|
|
69
|
+
end)
|
|
70
|
+
if not ok then
|
|
71
|
+
Dispatch.fail(
|
|
72
|
+
"NO_SUCH_DEVICE",
|
|
73
|
+
string.format("Could not switch to %q: %s", id, tostring(err)),
|
|
74
|
+
"Device ids look like \"iphone_16\" or \"ipad_a16\"; call `list` for the full set."
|
|
75
|
+
)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
if typeof(params.orientation) == "string" and params.orientation ~= "" then
|
|
79
|
+
local okOrient, orientErr = pcall(function()
|
|
80
|
+
(StudioDeviceSimulatorService :: any):SetOrientationAsync(
|
|
81
|
+
(Enum :: any).ScreenOrientation[params.orientation]
|
|
82
|
+
)
|
|
83
|
+
end)
|
|
84
|
+
if not okOrient then
|
|
85
|
+
Dispatch.fail(
|
|
86
|
+
"BAD_PARAMS",
|
|
87
|
+
string.format("Could not set orientation %q: %s", params.orientation, tostring(orientErr)),
|
|
88
|
+
'Use "LandscapeLeft", "LandscapeRight", "Portrait" or "Sensor".'
|
|
89
|
+
)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
return Emulation.state()
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
function Device.stop(_params: { [string]: any }): { [string]: any }
|
|
97
|
+
local ok, err = pcall(function()
|
|
98
|
+
(StudioDeviceSimulatorService :: any):StopSimulationAsync()
|
|
99
|
+
end)
|
|
100
|
+
if not ok then
|
|
101
|
+
Dispatch.fail("DEVICE_UNAVAILABLE", string.format("Could not stop emulation: %s", tostring(err)))
|
|
102
|
+
end
|
|
103
|
+
return Emulation.state()
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
function Device.state(_params: { [string]: any }): { [string]: any }
|
|
107
|
+
return Emulation.state()
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
function Device.register()
|
|
111
|
+
Dispatch.registerAll("device", {
|
|
112
|
+
list = Device.list,
|
|
113
|
+
set = Device.set,
|
|
114
|
+
stop = Device.stop,
|
|
115
|
+
state = Device.state,
|
|
116
|
+
})
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
return Device
|