@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,465 @@
|
|
|
1
|
+
--!strict
|
|
2
|
+
--[[
|
|
3
|
+
Turns a command into something a person can read at a glance.
|
|
4
|
+
|
|
5
|
+
The console used to print the wire name: `-> script.edit`, `-> instances.create`.
|
|
6
|
+
That tells you the plugin is alive and nothing else. Watching an agent work,
|
|
7
|
+
the interesting question is always "what is it touching" -- and the answer is
|
|
8
|
+
sitting right there in the parameters, one field away from being shown.
|
|
9
|
+
|
|
10
|
+
So `script.edit` on ServerScriptService.KillBrick becomes "Edit KillBrick",
|
|
11
|
+
and `instances.create` with eight children becomes "Create 8 instances in
|
|
12
|
+
Workspace". The subject is what the user recognises; the op is what the
|
|
13
|
+
protocol needed.
|
|
14
|
+
|
|
15
|
+
Three rules hold this together:
|
|
16
|
+
|
|
17
|
+
Name the subject, not the path. "KillBrick" is what someone called their
|
|
18
|
+
script; "ServerScriptService.Systems.KillBrick" is where it lives, and it
|
|
19
|
+
buries the recognisable part behind the part that never changes.
|
|
20
|
+
|
|
21
|
+
Count when there are several. "Edit 4 scripts" is worth reading. Listing
|
|
22
|
+
four paths in a 76-pixel-wide log line is not.
|
|
23
|
+
|
|
24
|
+
Never invent. An unknown op falls back to a tidied version of its own name
|
|
25
|
+
rather than a guess, because a console that lies about what ran is worse
|
|
26
|
+
than one that reads awkwardly.
|
|
27
|
+
]]
|
|
28
|
+
|
|
29
|
+
local Phrase = {}
|
|
30
|
+
|
|
31
|
+
-- The tail of a dotted path is the bit a human named. Everything before it is
|
|
32
|
+
-- Roblox's filing system.
|
|
33
|
+
local function leaf(path: any): string?
|
|
34
|
+
if typeof(path) ~= "string" or path == "" then
|
|
35
|
+
return nil
|
|
36
|
+
end
|
|
37
|
+
local last = string.match(path, "([^%.]+)$")
|
|
38
|
+
if last == nil or last == "" then
|
|
39
|
+
return nil
|
|
40
|
+
end
|
|
41
|
+
-- Array indexing survives into the name -- "Part[3]" is meaningfully
|
|
42
|
+
-- different from "Part" when there are several siblings.
|
|
43
|
+
return last
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
local function count(value: any): number
|
|
47
|
+
if typeof(value) ~= "table" then
|
|
48
|
+
return 0
|
|
49
|
+
end
|
|
50
|
+
return #(value :: { any })
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
--[[
|
|
54
|
+
"1 script" / "4 scripts", without the caller assembling it each time.
|
|
55
|
+
]]
|
|
56
|
+
local function plural(n: number, singular: string, pluralForm: string?): string
|
|
57
|
+
local word = if n == 1 then singular else (pluralForm or (singular .. "s"))
|
|
58
|
+
return string.format("%d %s", n, word)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
--[[
|
|
62
|
+
Describes a list of paths: the name when there is one, a count when there are
|
|
63
|
+
several. This is the shape most batch tools take.
|
|
64
|
+
]]
|
|
65
|
+
local function subjectOf(paths: any, noun: string): string
|
|
66
|
+
local total = count(paths)
|
|
67
|
+
if total == 1 then
|
|
68
|
+
local name = leaf((paths :: { any })[1])
|
|
69
|
+
if name ~= nil then
|
|
70
|
+
return name
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
if total > 1 then
|
|
74
|
+
return plural(total, noun)
|
|
75
|
+
end
|
|
76
|
+
return noun
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
--[[
|
|
80
|
+
Capitalises an op fragment for the fallback. `snapshots` -> `Snapshots`.
|
|
81
|
+
]]
|
|
82
|
+
local function titleCase(value: string): string
|
|
83
|
+
return (string.upper(string.sub(value, 1, 1)) .. string.sub(value, 2))
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
type Describer = (params: { [string]: any }) -> string
|
|
87
|
+
|
|
88
|
+
--[[
|
|
89
|
+
One entry per op. Each reads its own parameters, because what identifies a
|
|
90
|
+
call differs completely between them: a script edit is about a path, a
|
|
91
|
+
playtest is about a mode, a find is about what was searched for.
|
|
92
|
+
]]
|
|
93
|
+
local DESCRIBERS: { [string]: Describer } = {
|
|
94
|
+
["script.read"] = function(params)
|
|
95
|
+
return "Read " .. subjectOf(params.paths, "script")
|
|
96
|
+
end,
|
|
97
|
+
["script.edit"] = function(params)
|
|
98
|
+
local edits = params.edits
|
|
99
|
+
local total = count(edits)
|
|
100
|
+
if total == 1 then
|
|
101
|
+
local name = leaf((edits :: { any })[1].path)
|
|
102
|
+
if name ~= nil then
|
|
103
|
+
return "Edit " .. name
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
-- Several edits often land on one script, and "Edit 3 scripts" would be
|
|
107
|
+
-- wrong when it is three changes to one file.
|
|
108
|
+
local seen: { [string]: boolean } = {}
|
|
109
|
+
local distinct = 0
|
|
110
|
+
for _, edit in (edits or {}) :: { { [string]: any } } do
|
|
111
|
+
local name = leaf(edit.path)
|
|
112
|
+
if name ~= nil and not seen[name] then
|
|
113
|
+
seen[name] = true
|
|
114
|
+
distinct += 1
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
if distinct == 1 then
|
|
118
|
+
for name in seen do
|
|
119
|
+
return string.format("Edit %s (%s)", name, plural(total, "change"))
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
return string.format("Edit %s", plural(distinct, "script"))
|
|
123
|
+
end,
|
|
124
|
+
["script.create"] = function(params)
|
|
125
|
+
local scripts = params.scripts
|
|
126
|
+
if count(scripts) == 1 then
|
|
127
|
+
local one = (scripts :: { any })[1]
|
|
128
|
+
local className = if typeof(one.className) == "string" then one.className else "Script"
|
|
129
|
+
return string.format("Create %s %s", one.name or "script", className)
|
|
130
|
+
end
|
|
131
|
+
return "Create " .. plural(count(scripts), "script")
|
|
132
|
+
end,
|
|
133
|
+
["script.grep"] = function(params)
|
|
134
|
+
local pattern = if typeof(params.pattern) == "string" then params.pattern else "?"
|
|
135
|
+
return string.format('Search scripts for "%s"', pattern)
|
|
136
|
+
end,
|
|
137
|
+
|
|
138
|
+
["instances.create"] = function(params)
|
|
139
|
+
local instances = params.instances
|
|
140
|
+
local total = count(instances)
|
|
141
|
+
if total == 1 then
|
|
142
|
+
local one = (instances :: { any })[1]
|
|
143
|
+
local name = one.name or one.className or "instance"
|
|
144
|
+
local where = leaf(one.parent)
|
|
145
|
+
return if where ~= nil
|
|
146
|
+
then string.format("Create %s in %s", name, where)
|
|
147
|
+
else "Create " .. name
|
|
148
|
+
end
|
|
149
|
+
local where = if total > 0 then leaf((instances :: { any })[1].parent) else nil
|
|
150
|
+
return if where ~= nil
|
|
151
|
+
then string.format("Create %s in %s", plural(total, "instance"), where)
|
|
152
|
+
else "Create " .. plural(total, "instance")
|
|
153
|
+
end,
|
|
154
|
+
["instances.modify"] = function(params)
|
|
155
|
+
local targets = params.targets
|
|
156
|
+
-- Each target carries its own path list, so the honest count is the
|
|
157
|
+
-- total across them rather than the number of entries.
|
|
158
|
+
local total = 0
|
|
159
|
+
local onlyName: string? = nil
|
|
160
|
+
for _, target in (targets or {}) :: { { [string]: any } } do
|
|
161
|
+
local paths = count(target.paths)
|
|
162
|
+
total += paths
|
|
163
|
+
if paths >= 1 and onlyName == nil then
|
|
164
|
+
onlyName = leaf((target.paths :: { any })[1])
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
if total == 1 and onlyName ~= nil then
|
|
168
|
+
return "Modify " .. onlyName
|
|
169
|
+
end
|
|
170
|
+
return "Modify " .. plural(total, "instance")
|
|
171
|
+
end,
|
|
172
|
+
["instances.delete"] = function(params)
|
|
173
|
+
return "Delete " .. subjectOf(params.paths, "instance")
|
|
174
|
+
end,
|
|
175
|
+
["instances.move"] = function(params)
|
|
176
|
+
local items = params.items
|
|
177
|
+
local total = count(items)
|
|
178
|
+
local cloning = total > 0 and (items :: { any })[1].mode == "clone"
|
|
179
|
+
local verb = if cloning then "Clone" else "Move"
|
|
180
|
+
if total == 1 then
|
|
181
|
+
local one = (items :: { any })[1]
|
|
182
|
+
local what = leaf(one.path)
|
|
183
|
+
local where = leaf(one.to)
|
|
184
|
+
if what ~= nil and where ~= nil then
|
|
185
|
+
return string.format("%s %s to %s", verb, what, where)
|
|
186
|
+
end
|
|
187
|
+
if what ~= nil then
|
|
188
|
+
return verb .. " " .. what
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
return string.format("%s %s", verb, plural(total, "instance"))
|
|
192
|
+
end,
|
|
193
|
+
|
|
194
|
+
["discover.tree"] = function(params)
|
|
195
|
+
local where = leaf(params.path)
|
|
196
|
+
return if where ~= nil then "Browse " .. where else "Browse the place"
|
|
197
|
+
end,
|
|
198
|
+
["discover.inspect"] = function(params)
|
|
199
|
+
return "Inspect " .. subjectOf(params.paths, "instance")
|
|
200
|
+
end,
|
|
201
|
+
["discover.find"] = function(params)
|
|
202
|
+
if typeof(params.tag) == "string" and params.tag ~= "" then
|
|
203
|
+
return string.format('Find things tagged "%s"', params.tag)
|
|
204
|
+
end
|
|
205
|
+
if typeof(params.nameContains) == "string" and params.nameContains ~= "" then
|
|
206
|
+
return string.format('Find "%s"', params.nameContains)
|
|
207
|
+
end
|
|
208
|
+
if typeof(params.className) == "string" and params.className ~= "" then
|
|
209
|
+
return string.format("Find every %s", params.className)
|
|
210
|
+
end
|
|
211
|
+
return "Search the place"
|
|
212
|
+
end,
|
|
213
|
+
|
|
214
|
+
["script.debugSet"] = function()
|
|
215
|
+
return "Set breakpoints"
|
|
216
|
+
end,
|
|
217
|
+
["debug.set"] = function(params)
|
|
218
|
+
local breakpoints = params.breakpoints
|
|
219
|
+
local total = count(breakpoints)
|
|
220
|
+
if total == 1 then
|
|
221
|
+
local one = (breakpoints :: { any })[1]
|
|
222
|
+
local name = leaf(one.path)
|
|
223
|
+
if name ~= nil then
|
|
224
|
+
return string.format("Break on %s:%s", name, tostring(one.line))
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
return "Set " .. plural(total, "breakpoint")
|
|
228
|
+
end,
|
|
229
|
+
["debug.clear"] = function(params)
|
|
230
|
+
local name = leaf(params.path)
|
|
231
|
+
return if name ~= nil then "Clear breakpoints in " .. name else "Clear all breakpoints"
|
|
232
|
+
end,
|
|
233
|
+
["debug.snapshots"] = function()
|
|
234
|
+
return "Read what the breakpoints caught"
|
|
235
|
+
end,
|
|
236
|
+
["debug.exceptions"] = function(params)
|
|
237
|
+
return string.format("Break on errors: %s", tostring(params.mode or "Unhandled"))
|
|
238
|
+
end,
|
|
239
|
+
|
|
240
|
+
["perf.console"] = function(params)
|
|
241
|
+
if typeof(params.level) == "string" and params.level ~= "" then
|
|
242
|
+
return string.format("Read the %s log", params.level)
|
|
243
|
+
end
|
|
244
|
+
if typeof(params.pattern) == "string" and params.pattern ~= "" then
|
|
245
|
+
return string.format('Search output for "%s"', params.pattern)
|
|
246
|
+
end
|
|
247
|
+
return "Read the output log"
|
|
248
|
+
end,
|
|
249
|
+
["perf.snapshot"] = function()
|
|
250
|
+
return "Measure performance"
|
|
251
|
+
end,
|
|
252
|
+
["perf.profile"] = function(params)
|
|
253
|
+
return string.format("Profile scripts for %ss", tostring(params.seconds or 5))
|
|
254
|
+
end,
|
|
255
|
+
["perf.coverage"] = function(params)
|
|
256
|
+
local total = count(params.enable)
|
|
257
|
+
if total > 0 then
|
|
258
|
+
return "Instrument " .. plural(total, "script")
|
|
259
|
+
end
|
|
260
|
+
return "Read line coverage"
|
|
261
|
+
end,
|
|
262
|
+
|
|
263
|
+
["playtest.control"] = function(params)
|
|
264
|
+
local op = tostring(params.op or "state")
|
|
265
|
+
if op == "play" then
|
|
266
|
+
return "Start a playtest"
|
|
267
|
+
elseif op == "run" then
|
|
268
|
+
return "Run the place"
|
|
269
|
+
elseif op == "multiplayer" then
|
|
270
|
+
return string.format("Start a %s test", plural(tonumber(params.players) or 2, "player"))
|
|
271
|
+
elseif op == "stop" then
|
|
272
|
+
return "Stop the playtest"
|
|
273
|
+
elseif op == "endTest" then
|
|
274
|
+
return "End the test"
|
|
275
|
+
end
|
|
276
|
+
return "Check the playtest"
|
|
277
|
+
end,
|
|
278
|
+
|
|
279
|
+
["exec.run"] = function(params)
|
|
280
|
+
local source = if typeof(params.source) == "string" then params.source else ""
|
|
281
|
+
|
|
282
|
+
--[[
|
|
283
|
+
Says what the code is about, not what its first line says.
|
|
284
|
+
|
|
285
|
+
This used to show the opening line verbatim, which produced entries
|
|
286
|
+
like "Run: local GenerationService = game:GetServi..." -- a fragment
|
|
287
|
+
of a variable declaration, cut mid-word, telling the reader nothing
|
|
288
|
+
except that some code ran. The declaration is never the point of a
|
|
289
|
+
snippet; it is the setup before it.
|
|
290
|
+
|
|
291
|
+
So the services it touches stand in for it. That is the one thing a
|
|
292
|
+
snippet reliably says about itself, it is short, and it is what
|
|
293
|
+
someone glancing at the console actually wants: which part of Studio
|
|
294
|
+
is being poked.
|
|
295
|
+
]]
|
|
296
|
+
local services: { string } = {}
|
|
297
|
+
local seen: { [string]: boolean } = {}
|
|
298
|
+
for name in string.gmatch(source, 'GetService%("([%w_]+)"%)') do
|
|
299
|
+
if not seen[name] then
|
|
300
|
+
seen[name] = true
|
|
301
|
+
table.insert(services, name)
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
local lines = 0
|
|
306
|
+
for _ in string.gmatch(source, "[^\r\n]+") do
|
|
307
|
+
lines += 1
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
if #services == 1 then
|
|
311
|
+
return "Run Luau on " .. services[1]
|
|
312
|
+
elseif #services == 2 then
|
|
313
|
+
return string.format("Run Luau on %s and %s", services[1], services[2])
|
|
314
|
+
elseif #services > 2 then
|
|
315
|
+
return string.format("Run Luau on %s and %d more", services[1], #services - 1)
|
|
316
|
+
end
|
|
317
|
+
return if lines <= 1 then "Run Luau" else string.format("Run Luau (%d lines)", lines)
|
|
318
|
+
end,
|
|
319
|
+
|
|
320
|
+
["viewport.select"] = function(params)
|
|
321
|
+
local total = count(params.paths)
|
|
322
|
+
if total == 0 then
|
|
323
|
+
return "Clear the selection"
|
|
324
|
+
end
|
|
325
|
+
return "Select " .. subjectOf(params.paths, "instance")
|
|
326
|
+
end,
|
|
327
|
+
["viewport.raycast"] = function()
|
|
328
|
+
return "Cast a ray"
|
|
329
|
+
end,
|
|
330
|
+
|
|
331
|
+
["capture.screenshot"] = function()
|
|
332
|
+
return "Take a screenshot"
|
|
333
|
+
end,
|
|
334
|
+
|
|
335
|
+
["viewport.focus"] = function(params)
|
|
336
|
+
local name = leaf(params.path)
|
|
337
|
+
return if name ~= nil then "Look at " .. name else "Move the camera"
|
|
338
|
+
end,
|
|
339
|
+
["viewport.camera"] = function()
|
|
340
|
+
return "Move the camera"
|
|
341
|
+
end,
|
|
342
|
+
|
|
343
|
+
["geometry.combine"] = function(params)
|
|
344
|
+
local op = tostring(params.op or "union")
|
|
345
|
+
local name = leaf(params.path)
|
|
346
|
+
local verb = if op == "subtract" then "Cut" elseif op == "intersect" then "Intersect" else "Merge"
|
|
347
|
+
if op == "subtract" and name ~= nil then
|
|
348
|
+
local from = count(params.with)
|
|
349
|
+
return string.format("Cut %s out of %s", plural(from, "shape"), name)
|
|
350
|
+
end
|
|
351
|
+
return if name ~= nil then string.format("%s %s", verb, name) else verb .. " parts"
|
|
352
|
+
end,
|
|
353
|
+
["geometry.fragment"] = function(params)
|
|
354
|
+
local name = leaf(params.path)
|
|
355
|
+
return if name ~= nil
|
|
356
|
+
then string.format("Shatter %s into %s", name, plural(tonumber(params.pieces) or 8, "piece"))
|
|
357
|
+
else "Shatter a part"
|
|
358
|
+
end,
|
|
359
|
+
|
|
360
|
+
["assets.insert"] = function(params)
|
|
361
|
+
return string.format("Insert asset %s", tostring(params.assetId or "?"))
|
|
362
|
+
end,
|
|
363
|
+
|
|
364
|
+
["world.history"] = function(params)
|
|
365
|
+
local action = tostring(params.action or "status")
|
|
366
|
+
if action == "status" then
|
|
367
|
+
return "Check the undo history"
|
|
368
|
+
end
|
|
369
|
+
local steps = tonumber(params.steps) or 1
|
|
370
|
+
return string.format("%s %s", if action == "undo" then "Undo" else "Redo", plural(steps, "step"))
|
|
371
|
+
end,
|
|
372
|
+
["world.collision"] = function(params)
|
|
373
|
+
local action = tostring(params.action or "list")
|
|
374
|
+
local group = if typeof(params.group) == "string" then params.group else "?"
|
|
375
|
+
if action == "list" then
|
|
376
|
+
return "List collision groups"
|
|
377
|
+
elseif action == "create" then
|
|
378
|
+
return "Create collision group " .. group
|
|
379
|
+
elseif action == "assign" then
|
|
380
|
+
return string.format("Put %s in %s", plural(count(params.paths), "instance"), group)
|
|
381
|
+
end
|
|
382
|
+
return string.format("Set %s against %s", group, tostring(params.with or "?"))
|
|
383
|
+
end,
|
|
384
|
+
|
|
385
|
+
["character.moveTo"] = function(params)
|
|
386
|
+
local name = leaf(params.path)
|
|
387
|
+
return if name ~= nil then "Walk to " .. name else string.format("Walk to %s", tostring(params.to or "?"))
|
|
388
|
+
end,
|
|
389
|
+
["character.act"] = function(params)
|
|
390
|
+
local action = tostring(params.action or "jump")
|
|
391
|
+
return titleCase(action) .. " the character"
|
|
392
|
+
end,
|
|
393
|
+
["character.state"] = function()
|
|
394
|
+
return "Check the character"
|
|
395
|
+
end,
|
|
396
|
+
|
|
397
|
+
["studio.status"] = function()
|
|
398
|
+
return "Check Studio"
|
|
399
|
+
end,
|
|
400
|
+
["studio.ping"] = function()
|
|
401
|
+
return "Ping"
|
|
402
|
+
end,
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
--[[
|
|
406
|
+
What kind of work an op is, for anything that wants to colour it.
|
|
407
|
+
|
|
408
|
+
Grouped by what it does to the place rather than by which handler owns it,
|
|
409
|
+
because that is the distinction someone watching cares about: reading is
|
|
410
|
+
safe and constant, writing changes their game, and running executes code.
|
|
411
|
+
]]
|
|
412
|
+
local KINDS: { [string]: string } = {
|
|
413
|
+
discover = "read",
|
|
414
|
+
studio = "read",
|
|
415
|
+
viewport = "read",
|
|
416
|
+
capture = "read",
|
|
417
|
+
instances = "write",
|
|
418
|
+
geometry = "write",
|
|
419
|
+
assets = "write",
|
|
420
|
+
world = "write",
|
|
421
|
+
exec = "run",
|
|
422
|
+
playtest = "run",
|
|
423
|
+
character = "run",
|
|
424
|
+
debug = "debug",
|
|
425
|
+
perf = "debug",
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function Phrase.kindOf(op: string): string
|
|
429
|
+
local group = string.match(op, "^([^%.]+)%.") or op
|
|
430
|
+
-- Reads within the script group, writes outside it: a script edit changes
|
|
431
|
+
-- the game, a read or a grep does not, and they are far apart in weight.
|
|
432
|
+
if group == "script" then
|
|
433
|
+
local action = string.match(op, "%.(.+)$") or ""
|
|
434
|
+
return if action == "read" or action == "grep" then "read" else "write"
|
|
435
|
+
end
|
|
436
|
+
return KINDS[group] or "read"
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
--[[
|
|
440
|
+
The readable form of one command.
|
|
441
|
+
|
|
442
|
+
Never throws and never yields: this runs on the path that logs every call,
|
|
443
|
+
including the ones that are about to fail, and a console that errors while
|
|
444
|
+
describing an error would take the diagnosis with it.
|
|
445
|
+
]]
|
|
446
|
+
function Phrase.of(op: string, params: { [string]: any }?): string
|
|
447
|
+
local describer = DESCRIBERS[op]
|
|
448
|
+
if describer ~= nil then
|
|
449
|
+
local ok, described = pcall(describer, params or {})
|
|
450
|
+
if ok and typeof(described) == "string" and described ~= "" then
|
|
451
|
+
return described
|
|
452
|
+
end
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
-- Unknown op, or a describer that met a shape it did not expect. Tidy the
|
|
456
|
+
-- wire name rather than inventing a subject for it: "instances.create"
|
|
457
|
+
-- becomes "Instances create", which is honest about knowing no better.
|
|
458
|
+
local group, action = string.match(op, "^([^%.]+)%.(.+)$")
|
|
459
|
+
if group ~= nil and action ~= nil then
|
|
460
|
+
return titleCase(group) .. " " .. action
|
|
461
|
+
end
|
|
462
|
+
return titleCase(op)
|
|
463
|
+
end
|
|
464
|
+
|
|
465
|
+
return Phrase
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
--!strict
|
|
2
|
+
--[[
|
|
3
|
+
PNG and base64, written out by hand.
|
|
4
|
+
|
|
5
|
+
Studio gives a screenshot as an EditableImage, which hands over raw RGBA
|
|
6
|
+
bytes and nothing else. There is no encoder anywhere in the engine's API, and
|
|
7
|
+
no base64 either -- `HttpService` has JSONEncode and nothing adjacent -- so
|
|
8
|
+
getting a picture from Studio to an agent means producing both here.
|
|
9
|
+
|
|
10
|
+
The compression is deliberately none at all. Deflate's stored mode wraps the
|
|
11
|
+
bytes in block headers and changes nothing else, which is legal zlib and a
|
|
12
|
+
valid PNG; a real compressor would take far longer to write and run, and the
|
|
13
|
+
image is downscaled before it reaches this point, so the saving would be on
|
|
14
|
+
something already small. Correctness is worth more than bytes here.
|
|
15
|
+
]]
|
|
16
|
+
|
|
17
|
+
local Png = {}
|
|
18
|
+
|
|
19
|
+
-- Standard CRC-32, built once. The per-byte loop is 8 shifts; a 256-entry table
|
|
20
|
+
-- turns each byte into one lookup, which matters over a megabyte of pixels.
|
|
21
|
+
local crcTable: { number } = {}
|
|
22
|
+
do
|
|
23
|
+
for index = 0, 255 do
|
|
24
|
+
local value = index
|
|
25
|
+
for _ = 1, 8 do
|
|
26
|
+
if value % 2 == 1 then
|
|
27
|
+
value = bit32.bxor(0xEDB88320, bit32.rshift(value, 1))
|
|
28
|
+
else
|
|
29
|
+
value = bit32.rshift(value, 1)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
crcTable[index] = value
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
local function crc32(data: buffer, from: number, count: number): number
|
|
37
|
+
local crc = 0xFFFFFFFF
|
|
38
|
+
for offset = from, from + count - 1 do
|
|
39
|
+
local index = bit32.band(bit32.bxor(crc, buffer.readu8(data, offset)), 0xFF)
|
|
40
|
+
crc = bit32.bxor(crcTable[index], bit32.rshift(crc, 8))
|
|
41
|
+
end
|
|
42
|
+
return bit32.bxor(crc, 0xFFFFFFFF)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
--[[
|
|
46
|
+
Adler-32, which zlib carries and PNG readers check.
|
|
47
|
+
|
|
48
|
+
The modulo runs every 4096 bytes rather than every byte: both accumulators
|
|
49
|
+
stay well inside 2^53 over that span, so the arithmetic is exact and the cost
|
|
50
|
+
is paid a few thousand times instead of a few million.
|
|
51
|
+
]]
|
|
52
|
+
local function adler32(data: buffer, length: number): number
|
|
53
|
+
local a, b = 1, 0
|
|
54
|
+
local index = 0
|
|
55
|
+
while index < length do
|
|
56
|
+
local chunkEnd = math.min(index + 4096, length)
|
|
57
|
+
while index < chunkEnd do
|
|
58
|
+
a += buffer.readu8(data, index)
|
|
59
|
+
b += a
|
|
60
|
+
index += 1
|
|
61
|
+
end
|
|
62
|
+
a %= 65521
|
|
63
|
+
b %= 65521
|
|
64
|
+
end
|
|
65
|
+
return bit32.bor(bit32.lshift(b, 16), a)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
local function writeU32(out: buffer, offset: number, value: number)
|
|
69
|
+
buffer.writeu8(out, offset, bit32.band(bit32.rshift(value, 24), 0xFF))
|
|
70
|
+
buffer.writeu8(out, offset + 1, bit32.band(bit32.rshift(value, 16), 0xFF))
|
|
71
|
+
buffer.writeu8(out, offset + 2, bit32.band(bit32.rshift(value, 8), 0xFF))
|
|
72
|
+
buffer.writeu8(out, offset + 3, bit32.band(value, 0xFF))
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
--[[
|
|
76
|
+
One PNG chunk: length, type, payload, CRC over type and payload.
|
|
77
|
+
]]
|
|
78
|
+
local function writeChunk(out: buffer, offset: number, kind: string, payload: buffer, payloadLength: number): number
|
|
79
|
+
writeU32(out, offset, payloadLength)
|
|
80
|
+
buffer.writestring(out, offset + 4, kind)
|
|
81
|
+
if payloadLength > 0 then
|
|
82
|
+
buffer.copy(out, offset + 8, payload, 0, payloadLength)
|
|
83
|
+
end
|
|
84
|
+
writeU32(out, offset + 8 + payloadLength, crc32(out, offset + 4, payloadLength + 4))
|
|
85
|
+
return offset + 12 + payloadLength
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
--[[
|
|
89
|
+
Encodes 8-bit RGB pixels as a PNG.
|
|
90
|
+
|
|
91
|
+
`rgb` holds width * height * 3 bytes, row-major, no padding. Alpha is dropped
|
|
92
|
+
before this point: a screenshot has none worth keeping and carrying it would
|
|
93
|
+
add a quarter to every byte that follows.
|
|
94
|
+
]]
|
|
95
|
+
function Png.encode(rgb: buffer, width: number, height: number): buffer
|
|
96
|
+
-- PNG wants a filter byte at the start of every scanline. Filter 0 means
|
|
97
|
+
-- "stored as-is", which is what makes the rest of this a straight copy.
|
|
98
|
+
local rowBytes = width * 3
|
|
99
|
+
local rawLength = (rowBytes + 1) * height
|
|
100
|
+
local raw = buffer.create(rawLength)
|
|
101
|
+
for row = 0, height - 1 do
|
|
102
|
+
local target = row * (rowBytes + 1)
|
|
103
|
+
buffer.writeu8(raw, target, 0)
|
|
104
|
+
buffer.copy(raw, target + 1, rgb, row * rowBytes, rowBytes)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
-- zlib: 0x78 0x01 header, stored deflate blocks, Adler-32 trailer.
|
|
108
|
+
local MAX_BLOCK = 65535
|
|
109
|
+
local blocks = math.max(1, math.ceil(rawLength / MAX_BLOCK))
|
|
110
|
+
local zlibLength = 2 + blocks * 5 + rawLength + 4
|
|
111
|
+
local zlib = buffer.create(zlibLength)
|
|
112
|
+
buffer.writeu8(zlib, 0, 0x78)
|
|
113
|
+
buffer.writeu8(zlib, 1, 0x01)
|
|
114
|
+
|
|
115
|
+
local readOffset = 0
|
|
116
|
+
local writeOffset = 2
|
|
117
|
+
for index = 1, blocks do
|
|
118
|
+
local size = math.min(MAX_BLOCK, rawLength - readOffset)
|
|
119
|
+
buffer.writeu8(zlib, writeOffset, if index == blocks then 1 else 0)
|
|
120
|
+
-- LEN then its one's complement, both little-endian.
|
|
121
|
+
buffer.writeu8(zlib, writeOffset + 1, bit32.band(size, 0xFF))
|
|
122
|
+
buffer.writeu8(zlib, writeOffset + 2, bit32.band(bit32.rshift(size, 8), 0xFF))
|
|
123
|
+
buffer.writeu8(zlib, writeOffset + 3, bit32.band(bit32.bnot(size), 0xFF))
|
|
124
|
+
buffer.writeu8(zlib, writeOffset + 4, bit32.band(bit32.rshift(bit32.bnot(size), 8), 0xFF))
|
|
125
|
+
buffer.copy(zlib, writeOffset + 5, raw, readOffset, size)
|
|
126
|
+
readOffset += size
|
|
127
|
+
writeOffset += 5 + size
|
|
128
|
+
end
|
|
129
|
+
writeU32(zlib, writeOffset, adler32(raw, rawLength))
|
|
130
|
+
|
|
131
|
+
local header = buffer.create(13)
|
|
132
|
+
writeU32(header, 0, width)
|
|
133
|
+
writeU32(header, 4, height)
|
|
134
|
+
buffer.writeu8(header, 8, 8) -- bit depth
|
|
135
|
+
buffer.writeu8(header, 9, 2) -- colour type 2: truecolour RGB
|
|
136
|
+
buffer.writeu8(header, 10, 0) -- deflate
|
|
137
|
+
buffer.writeu8(header, 11, 0) -- adaptive filtering
|
|
138
|
+
buffer.writeu8(header, 12, 0) -- no interlace
|
|
139
|
+
|
|
140
|
+
local total = 8 + (12 + 13) + (12 + zlibLength) + 12
|
|
141
|
+
local out = buffer.create(total)
|
|
142
|
+
-- Signature: the high bit and the CRLF pair catch transports that would
|
|
143
|
+
-- mangle the file without anyone noticing.
|
|
144
|
+
buffer.writeu8(out, 0, 0x89)
|
|
145
|
+
buffer.writestring(out, 1, "PNG\r\n")
|
|
146
|
+
buffer.writeu8(out, 6, 0x1A)
|
|
147
|
+
buffer.writeu8(out, 7, 0x0A)
|
|
148
|
+
|
|
149
|
+
local offset = writeChunk(out, 8, "IHDR", header, 13)
|
|
150
|
+
offset = writeChunk(out, offset, "IDAT", zlib, zlibLength)
|
|
151
|
+
writeChunk(out, offset, "IEND", buffer.create(0), 0)
|
|
152
|
+
return out
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
local BASE64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
|
|
156
|
+
|
|
157
|
+
--[[
|
|
158
|
+
Base64, three bytes at a time.
|
|
159
|
+
|
|
160
|
+
Assembled into a table of chunks and concatenated once at the end. Appending
|
|
161
|
+
to a string in a loop would rebuild the whole thing on every pass, which over
|
|
162
|
+
a megabyte is the difference between prompt and unusable.
|
|
163
|
+
]]
|
|
164
|
+
function Png.base64(data: buffer): string
|
|
165
|
+
local length = buffer.len(data)
|
|
166
|
+
local pieces: { string } = {}
|
|
167
|
+
local index = 0
|
|
168
|
+
|
|
169
|
+
while index + 3 <= length do
|
|
170
|
+
local a, b, c = buffer.readu8(data, index), buffer.readu8(data, index + 1), buffer.readu8(data, index + 2)
|
|
171
|
+
local packed = bit32.bor(bit32.lshift(a, 16), bit32.lshift(b, 8), c)
|
|
172
|
+
table.insert(
|
|
173
|
+
pieces,
|
|
174
|
+
string.sub(BASE64, bit32.rshift(packed, 18) + 1, bit32.rshift(packed, 18) + 1)
|
|
175
|
+
.. string.sub(BASE64, bit32.band(bit32.rshift(packed, 12), 63) + 1, bit32.band(bit32.rshift(packed, 12), 63) + 1)
|
|
176
|
+
.. string.sub(BASE64, bit32.band(bit32.rshift(packed, 6), 63) + 1, bit32.band(bit32.rshift(packed, 6), 63) + 1)
|
|
177
|
+
.. string.sub(BASE64, bit32.band(packed, 63) + 1, bit32.band(packed, 63) + 1)
|
|
178
|
+
)
|
|
179
|
+
index += 3
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
local remaining = length - index
|
|
183
|
+
if remaining == 1 then
|
|
184
|
+
local a = buffer.readu8(data, index)
|
|
185
|
+
local packed = bit32.lshift(a, 16)
|
|
186
|
+
table.insert(
|
|
187
|
+
pieces,
|
|
188
|
+
string.sub(BASE64, bit32.rshift(packed, 18) + 1, bit32.rshift(packed, 18) + 1)
|
|
189
|
+
.. string.sub(BASE64, bit32.band(bit32.rshift(packed, 12), 63) + 1, bit32.band(bit32.rshift(packed, 12), 63) + 1)
|
|
190
|
+
.. "=="
|
|
191
|
+
)
|
|
192
|
+
elseif remaining == 2 then
|
|
193
|
+
local a, b = buffer.readu8(data, index), buffer.readu8(data, index + 1)
|
|
194
|
+
local packed = bit32.bor(bit32.lshift(a, 16), bit32.lshift(b, 8))
|
|
195
|
+
table.insert(
|
|
196
|
+
pieces,
|
|
197
|
+
string.sub(BASE64, bit32.rshift(packed, 18) + 1, bit32.rshift(packed, 18) + 1)
|
|
198
|
+
.. string.sub(BASE64, bit32.band(bit32.rshift(packed, 12), 63) + 1, bit32.band(bit32.rshift(packed, 12), 63) + 1)
|
|
199
|
+
.. string.sub(BASE64, bit32.band(bit32.rshift(packed, 6), 63) + 1, bit32.band(bit32.rshift(packed, 6), 63) + 1)
|
|
200
|
+
.. "="
|
|
201
|
+
)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
return table.concat(pieces)
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
--[[
|
|
208
|
+
Nearest-neighbour downscale, RGBA in and RGB out.
|
|
209
|
+
|
|
210
|
+
Nearest rather than averaged on purpose. A screenshot is read for what is in
|
|
211
|
+
it -- is the part there, is the GUI covering it, is the material wrong -- and
|
|
212
|
+
averaging softens exactly the thin edges and one-pixel text that carry that.
|
|
213
|
+
It is also a single read per output pixel instead of four.
|
|
214
|
+
]]
|
|
215
|
+
function Png.downscaleToRgb(rgba: buffer, width: number, height: number, targetWidth: number): (buffer, number, number)
|
|
216
|
+
local scale = math.min(1, targetWidth / width)
|
|
217
|
+
local outWidth = math.max(1, math.floor(width * scale))
|
|
218
|
+
local outHeight = math.max(1, math.floor(height * scale))
|
|
219
|
+
local out = buffer.create(outWidth * outHeight * 3)
|
|
220
|
+
|
|
221
|
+
for row = 0, outHeight - 1 do
|
|
222
|
+
local sourceRow = math.min(height - 1, math.floor(row / scale))
|
|
223
|
+
local sourceRowBase = sourceRow * width * 4
|
|
224
|
+
local targetRowBase = row * outWidth * 3
|
|
225
|
+
for column = 0, outWidth - 1 do
|
|
226
|
+
local sourceColumn = math.min(width - 1, math.floor(column / scale))
|
|
227
|
+
local source = sourceRowBase + sourceColumn * 4
|
|
228
|
+
local target = targetRowBase + column * 3
|
|
229
|
+
buffer.writeu8(out, target, buffer.readu8(rgba, source))
|
|
230
|
+
buffer.writeu8(out, target + 1, buffer.readu8(rgba, source + 1))
|
|
231
|
+
buffer.writeu8(out, target + 2, buffer.readu8(rgba, source + 2))
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
return out, outWidth, outHeight
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
return Png
|