@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,289 @@
|
|
|
1
|
+
--!strict
|
|
2
|
+
--[[
|
|
3
|
+
Hierarchy exploration: tree, inspect and find.
|
|
4
|
+
|
|
5
|
+
These three replace roughly a dozen tools in competing servers
|
|
6
|
+
(get_file_tree, get_project_structure, get_instance_children, search_objects,
|
|
7
|
+
search_by_property, get_attributes, get_tags, get_tagged, get_class_info...).
|
|
8
|
+
Fewer, wider tools mean less schema in the agent's context and fewer chances
|
|
9
|
+
to pick the wrong one.
|
|
10
|
+
|
|
11
|
+
Traversal is bounded everywhere. A production place can hold hundreds of
|
|
12
|
+
thousands of instances, and an unbounded walk would either time out or return
|
|
13
|
+
something no context window can hold.
|
|
14
|
+
]]
|
|
15
|
+
|
|
16
|
+
local CollectionService = game:GetService("CollectionService")
|
|
17
|
+
|
|
18
|
+
local Dispatch = require(script.Parent.Parent.Dispatch)
|
|
19
|
+
local Paths = require(script.Parent.Parent.Paths)
|
|
20
|
+
local Scope = require(script.Parent.Parent.Scope)
|
|
21
|
+
local Serialize = require(script.Parent.Parent.Serialize)
|
|
22
|
+
|
|
23
|
+
-- Ceiling on nodes visited in one call, independent of how many are returned.
|
|
24
|
+
-- Protects against a `find` over a whole place blocking Studio's main thread.
|
|
25
|
+
local MAX_VISITS = 200_000
|
|
26
|
+
|
|
27
|
+
local Discover = {}
|
|
28
|
+
|
|
29
|
+
-- Root-level noise filtering lives in Scope, which the script tools share.
|
|
30
|
+
local isNoisy = Scope.isNoisy
|
|
31
|
+
|
|
32
|
+
-- One memo per request keeps path formatting linear when many siblings share a
|
|
33
|
+
-- name; see Paths.of.
|
|
34
|
+
local function summarise(instance: Instance, memo: Paths.NameIndex): { [string]: any }
|
|
35
|
+
return {
|
|
36
|
+
path = Paths.of(instance, memo),
|
|
37
|
+
className = instance.ClassName,
|
|
38
|
+
childCount = #instance:GetChildren(),
|
|
39
|
+
}
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
--[[
|
|
43
|
+
Breadth-first walk to `depth`, newest level last, so a truncated result is
|
|
44
|
+
still a coherent picture of the top of the tree rather than one deep spur.
|
|
45
|
+
]]
|
|
46
|
+
function Discover.tree(params: { [string]: any }): { [string]: any }
|
|
47
|
+
local root = if params.path then Paths.resolve(params.path) else game
|
|
48
|
+
local depth = tonumber(params.depth) or 2
|
|
49
|
+
local limit = tonumber(params.limit) or 100
|
|
50
|
+
local offset = tonumber(params.offset) or 0
|
|
51
|
+
local classFilter = params.className
|
|
52
|
+
local nameFilter = if params.nameContains then string.lower(params.nameContains) else nil
|
|
53
|
+
|
|
54
|
+
local matched: { Instance } = {}
|
|
55
|
+
local hidden = 0
|
|
56
|
+
local visits = 0
|
|
57
|
+
local frontier: { Instance } = { root }
|
|
58
|
+
|
|
59
|
+
for level = 1, depth do
|
|
60
|
+
local nextFrontier: { Instance } = {}
|
|
61
|
+
for _, parent in frontier do
|
|
62
|
+
for _, child in parent:GetChildren() do
|
|
63
|
+
visits += 1
|
|
64
|
+
if visits > MAX_VISITS then
|
|
65
|
+
break
|
|
66
|
+
end
|
|
67
|
+
if isNoisy(child) then
|
|
68
|
+
if parent == game then
|
|
69
|
+
hidden += 1
|
|
70
|
+
end
|
|
71
|
+
continue
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
local keep = true
|
|
75
|
+
if classFilter and not child:IsA(classFilter) then
|
|
76
|
+
keep = false
|
|
77
|
+
end
|
|
78
|
+
if keep and nameFilter and not string.find(string.lower(child.Name), nameFilter, 1, true) then
|
|
79
|
+
keep = false
|
|
80
|
+
end
|
|
81
|
+
if keep then
|
|
82
|
+
table.insert(matched, child)
|
|
83
|
+
end
|
|
84
|
+
if level < depth then
|
|
85
|
+
table.insert(nextFrontier, child)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
frontier = nextFrontier
|
|
90
|
+
if #frontier == 0 or visits > MAX_VISITS then
|
|
91
|
+
break
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
local items: { { [string]: any } } = {}
|
|
96
|
+
local memo: Paths.NameIndex = {}
|
|
97
|
+
for index = offset + 1, math.min(offset + limit, #matched) do
|
|
98
|
+
table.insert(items, summarise(matched[index] :: Instance, memo))
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
root = Paths.of(root, memo),
|
|
103
|
+
items = items,
|
|
104
|
+
total = #matched,
|
|
105
|
+
offset = offset,
|
|
106
|
+
hiddenServices = hidden,
|
|
107
|
+
}
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
--[[
|
|
111
|
+
Detailed read of specific instances. `properties` is chosen server-side from
|
|
112
|
+
the live API dump, so this handler never needs its own class table.
|
|
113
|
+
]]
|
|
114
|
+
function Discover.inspect(params: { [string]: any }): { [string]: any }
|
|
115
|
+
local paths = params.paths
|
|
116
|
+
if typeof(paths) ~= "table" or #paths == 0 then
|
|
117
|
+
Dispatch.fail(
|
|
118
|
+
"BAD_PARAMS",
|
|
119
|
+
"inspect requires a non-empty `paths` array.",
|
|
120
|
+
'Pass paths like ["Workspace.Model.Part"]. Use `find` or `tree` to discover them.'
|
|
121
|
+
)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
local requested: { string }? = params.properties
|
|
125
|
+
local includeChildren = params.includeChildren ~= false
|
|
126
|
+
local childLimit = tonumber(params.childLimit) or 25
|
|
127
|
+
|
|
128
|
+
local results: { { [string]: any } } = {}
|
|
129
|
+
local failures: { string } = {}
|
|
130
|
+
local memo: Paths.NameIndex = {}
|
|
131
|
+
|
|
132
|
+
for _, path in paths do
|
|
133
|
+
local ok, instance = pcall(Paths.resolve, path)
|
|
134
|
+
if not ok then
|
|
135
|
+
local err = instance :: any
|
|
136
|
+
-- The hint carries the sibling listing ("It does have: ..."), which is
|
|
137
|
+
-- what lets the agent correct the path without another round trip.
|
|
138
|
+
local reason = if typeof(err) == "table"
|
|
139
|
+
then (if err.hint then err.message .. " " .. err.hint else err.message)
|
|
140
|
+
else tostring(err)
|
|
141
|
+
table.insert(failures, string.format("%s: %s", path, reason))
|
|
142
|
+
continue
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
local target = instance :: Instance
|
|
146
|
+
local properties: { [string]: any } = {}
|
|
147
|
+
if requested then
|
|
148
|
+
for _, name in requested do
|
|
149
|
+
local readOk, value = Serialize.readProperty(target, name)
|
|
150
|
+
if readOk then
|
|
151
|
+
properties[name] = value
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
local entry: { [string]: any } = {
|
|
157
|
+
path = Paths.of(target, memo),
|
|
158
|
+
-- Echoed back so a caller can correlate the answer with what it asked
|
|
159
|
+
-- for. `path` is the canonical form and often differs: ask about
|
|
160
|
+
-- "Workspace.Wall" and the answer comes back as "Workspace.Wall[1]".
|
|
161
|
+
requested = path,
|
|
162
|
+
className = target.ClassName,
|
|
163
|
+
properties = properties,
|
|
164
|
+
childCount = #target:GetChildren(),
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
-- Empty attribute and tag sets are omitted rather than sent as empty
|
|
168
|
+
-- containers: most instances have neither, and Luau encodes an empty
|
|
169
|
+
-- table as [] which reads as a list and confuses the shape.
|
|
170
|
+
local attributes: { [string]: any } = {}
|
|
171
|
+
local hasAttributes = false
|
|
172
|
+
for name, value in target:GetAttributes() do
|
|
173
|
+
attributes[name] = Serialize.value(value)
|
|
174
|
+
hasAttributes = true
|
|
175
|
+
end
|
|
176
|
+
if hasAttributes then
|
|
177
|
+
entry.attributes = attributes
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
local tags = CollectionService:GetTags(target)
|
|
181
|
+
if #tags > 0 then
|
|
182
|
+
entry.tags = tags
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
if includeChildren then
|
|
186
|
+
local children: { { [string]: any } } = {}
|
|
187
|
+
for index, child in target:GetChildren() do
|
|
188
|
+
if index > childLimit then
|
|
189
|
+
break
|
|
190
|
+
end
|
|
191
|
+
table.insert(children, { name = child.Name, className = child.ClassName })
|
|
192
|
+
end
|
|
193
|
+
entry.children = children
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
table.insert(results, entry)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
return { items = results, failures = failures }
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
--[[
|
|
203
|
+
One search over name, class, property value and tag.
|
|
204
|
+
|
|
205
|
+
Every filter supplied must match (AND), which is what lets a single tool
|
|
206
|
+
answer "every anchored Part under Workspace whose name contains 'door'"
|
|
207
|
+
without the agent chaining three calls and intersecting the results itself.
|
|
208
|
+
]]
|
|
209
|
+
function Discover.find(params: { [string]: any }): { [string]: any }
|
|
210
|
+
local root = if params.path then Paths.resolve(params.path) else game
|
|
211
|
+
local limit = tonumber(params.limit) or 100
|
|
212
|
+
local offset = tonumber(params.offset) or 0
|
|
213
|
+
|
|
214
|
+
local nameFilter = if params.nameContains then string.lower(params.nameContains) else nil
|
|
215
|
+
local classFilter = params.className
|
|
216
|
+
local propertyName = params.propertyName
|
|
217
|
+
local propertyValue = params.propertyValue
|
|
218
|
+
local tagFilter = params.tag
|
|
219
|
+
|
|
220
|
+
-- A tag query is answered from CollectionService's index rather than by
|
|
221
|
+
-- walking the tree, which is orders of magnitude cheaper on a big place.
|
|
222
|
+
local candidates: { Instance }
|
|
223
|
+
if tagFilter then
|
|
224
|
+
candidates = CollectionService:GetTagged(tagFilter)
|
|
225
|
+
else
|
|
226
|
+
candidates = root:GetDescendants()
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
if #candidates > MAX_VISITS then
|
|
230
|
+
Dispatch.fail(
|
|
231
|
+
"TOO_BROAD",
|
|
232
|
+
string.format(
|
|
233
|
+
"That search would visit %d instances, over the %d limit.",
|
|
234
|
+
#candidates,
|
|
235
|
+
MAX_VISITS
|
|
236
|
+
),
|
|
237
|
+
"Narrow it with `path` to search one service or model instead of the whole place."
|
|
238
|
+
)
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
local matched: { Instance } = {}
|
|
242
|
+
for _, instance in candidates do
|
|
243
|
+
if tagFilter and root ~= game and not instance:IsDescendantOf(root) then
|
|
244
|
+
continue
|
|
245
|
+
end
|
|
246
|
+
if isNoisy(instance) then
|
|
247
|
+
continue
|
|
248
|
+
end
|
|
249
|
+
if classFilter and not instance:IsA(classFilter) then
|
|
250
|
+
continue
|
|
251
|
+
end
|
|
252
|
+
if nameFilter and not string.find(string.lower(instance.Name), nameFilter, 1, true) then
|
|
253
|
+
continue
|
|
254
|
+
end
|
|
255
|
+
if propertyName then
|
|
256
|
+
local readOk, value = Serialize.readProperty(instance, propertyName)
|
|
257
|
+
if not readOk then
|
|
258
|
+
continue
|
|
259
|
+
end
|
|
260
|
+
if propertyValue ~= nil and tostring(value) ~= tostring(propertyValue) then
|
|
261
|
+
continue
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
table.insert(matched, instance)
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
local items: { { [string]: any } } = {}
|
|
268
|
+
local memo: Paths.NameIndex = {}
|
|
269
|
+
for index = offset + 1, math.min(offset + limit, #matched) do
|
|
270
|
+
table.insert(items, summarise(matched[index] :: Instance, memo))
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
return {
|
|
274
|
+
items = items,
|
|
275
|
+
total = #matched,
|
|
276
|
+
offset = offset,
|
|
277
|
+
searched = #candidates,
|
|
278
|
+
}
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
function Discover.register()
|
|
282
|
+
Dispatch.registerAll("discover", {
|
|
283
|
+
tree = Discover.tree,
|
|
284
|
+
inspect = Discover.inspect,
|
|
285
|
+
find = Discover.find,
|
|
286
|
+
})
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
return Discover
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
--!strict
|
|
2
|
+
--[[
|
|
3
|
+
Arbitrary Luau, run in the plugin's context.
|
|
4
|
+
|
|
5
|
+
This is the escape hatch, not the default. Every dedicated tool validates its
|
|
6
|
+
input, types values from the API dump, and wraps writes in an undo recording;
|
|
7
|
+
code run here does none of that. It exists for the cases nothing else covers.
|
|
8
|
+
|
|
9
|
+
`LogService:ExecuteScript` -- the API the command bar uses -- is
|
|
10
|
+
RobloxScriptSecurity, so this compiles with `loadstring` instead. Output is
|
|
11
|
+
captured while the chunk runs, because "what did it print" is almost always
|
|
12
|
+
the reason for running it at all.
|
|
13
|
+
]]
|
|
14
|
+
|
|
15
|
+
local LogService = game:GetService("LogService")
|
|
16
|
+
|
|
17
|
+
local Dispatch = require(script.Parent.Parent.Dispatch)
|
|
18
|
+
local Serialize = require(script.Parent.Parent.Serialize)
|
|
19
|
+
|
|
20
|
+
-- A chunk that prints in a loop would otherwise return a response no context
|
|
21
|
+
-- window can hold.
|
|
22
|
+
local MAX_OUTPUT_LINES = 200
|
|
23
|
+
local MAX_RETURN_VALUES = 10
|
|
24
|
+
|
|
25
|
+
-- Returned tables are walked rather than counted, but not without limit: a
|
|
26
|
+
-- chunk that returns a whole config tree, or one with a cycle in it, must not be
|
|
27
|
+
-- able to produce a response nothing can read.
|
|
28
|
+
local MAX_TABLE_ENTRIES = 50
|
|
29
|
+
local MAX_TABLE_DEPTH = 4
|
|
30
|
+
|
|
31
|
+
local CHUNK_NAME = "StudioMCP.execute_luau"
|
|
32
|
+
|
|
33
|
+
local Exec = {}
|
|
34
|
+
|
|
35
|
+
--[[
|
|
36
|
+
Describes a returned value.
|
|
37
|
+
|
|
38
|
+
Tables used to come back as "<table with 5 entries>", which threw away the
|
|
39
|
+
answer whenever the answer was a table -- and returning a table is the natural
|
|
40
|
+
way to report several things at once, so this hit constantly. They are walked
|
|
41
|
+
now, bounded by breadth and depth. Everything else goes through the same
|
|
42
|
+
serializer the rest of the server uses, so a Vector3 reads the way it does
|
|
43
|
+
everywhere else.
|
|
44
|
+
]]
|
|
45
|
+
local function describe(value: any, depth: number?): any
|
|
46
|
+
local level = depth or 0
|
|
47
|
+
local kind = typeof(value)
|
|
48
|
+
|
|
49
|
+
if kind == "function" or kind == "thread" then
|
|
50
|
+
return string.format("<%s>", kind)
|
|
51
|
+
end
|
|
52
|
+
if kind ~= "table" then
|
|
53
|
+
return Serialize.value(value)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
local source = value :: { [any]: any }
|
|
57
|
+
-- Also the cycle guard: a self-referencing table stops here rather than
|
|
58
|
+
-- recursing until the stack gives out.
|
|
59
|
+
if level >= MAX_TABLE_DEPTH then
|
|
60
|
+
return "<nested table>"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
local count = 0
|
|
64
|
+
for _ in source do
|
|
65
|
+
count += 1
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
-- Arrays keep their shape so they read as lists on the other side; anything
|
|
69
|
+
-- with non-sequential keys becomes a map with stringified keys.
|
|
70
|
+
if count == #source then
|
|
71
|
+
local list: { any } = {}
|
|
72
|
+
for index, item in ipairs(source) do
|
|
73
|
+
if index > MAX_TABLE_ENTRIES then
|
|
74
|
+
table.insert(list, string.format("<%d more>", count - MAX_TABLE_ENTRIES))
|
|
75
|
+
break
|
|
76
|
+
end
|
|
77
|
+
table.insert(list, describe(item, level + 1))
|
|
78
|
+
end
|
|
79
|
+
return list
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
local map: { [string]: any } = {}
|
|
83
|
+
local shown = 0
|
|
84
|
+
for key, item in source do
|
|
85
|
+
shown += 1
|
|
86
|
+
if shown > MAX_TABLE_ENTRIES then
|
|
87
|
+
map["..."] = string.format("<%d more>", count - MAX_TABLE_ENTRIES)
|
|
88
|
+
break
|
|
89
|
+
end
|
|
90
|
+
map[tostring(key)] = describe(item, level + 1)
|
|
91
|
+
end
|
|
92
|
+
return map
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
--[[
|
|
96
|
+
Compiles the chunk by whichever route this session actually allows.
|
|
97
|
+
|
|
98
|
+
`loadstring` is the direct one and the only one that keeps plugin identity,
|
|
99
|
+
but the global exists and *throws* unless `ServerScriptService.LoadStringEnabled`
|
|
100
|
+
is set -- which it is not by default. So every call against a running playtest
|
|
101
|
+
server failed with "loadstring() is not available" while the identical code ran
|
|
102
|
+
fine in the editor session, which is the worst shape a limitation can take.
|
|
103
|
+
|
|
104
|
+
The fallback compiles through a ModuleScript: a plugin may set `.Source`, and
|
|
105
|
+
requiring a fresh, unparented instance runs it without touching the place. The
|
|
106
|
+
cost is real and gets reported back rather than hidden -- a required module
|
|
107
|
+
runs at script identity, so plugin-only APIs are out of reach on that path.
|
|
108
|
+
|
|
109
|
+
Returns the callable, a compile error, which route was taken, and any module
|
|
110
|
+
to clean up once the chunk has finished with it.
|
|
111
|
+
]]
|
|
112
|
+
local function compile(
|
|
113
|
+
source: string
|
|
114
|
+
): (((...any) -> ...any)?, string?, string, ModuleScript?)
|
|
115
|
+
local ok, chunk, syntaxError = pcall(loadstring :: any, source, CHUNK_NAME)
|
|
116
|
+
if ok then
|
|
117
|
+
if typeof(chunk) == "function" then
|
|
118
|
+
return chunk, nil, "loadstring", nil
|
|
119
|
+
end
|
|
120
|
+
return nil, tostring(syntaxError), "loadstring", nil
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
-- `chunk` carries the pcall failure when `ok` is false.
|
|
124
|
+
local refusal = tostring(chunk)
|
|
125
|
+
|
|
126
|
+
local module = Instance.new("ModuleScript")
|
|
127
|
+
-- The wrapper opens on the same line the source starts on, so a syntax error
|
|
128
|
+
-- still reports the line the caller wrote.
|
|
129
|
+
local settable = pcall(function()
|
|
130
|
+
module.Source = "return function(...) " .. source .. "\nend"
|
|
131
|
+
end)
|
|
132
|
+
if not settable then
|
|
133
|
+
module:Destroy()
|
|
134
|
+
return nil,
|
|
135
|
+
string.format(
|
|
136
|
+
"no route to compile is open in this session: loadstring refused (%s) "
|
|
137
|
+
.. "and setting a script's source is not permitted either",
|
|
138
|
+
refusal
|
|
139
|
+
),
|
|
140
|
+
"none",
|
|
141
|
+
nil
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
local required, result = pcall(require, module)
|
|
145
|
+
if not required then
|
|
146
|
+
module:Destroy()
|
|
147
|
+
return nil, tostring(result), "module", nil
|
|
148
|
+
end
|
|
149
|
+
if typeof(result) ~= "function" then
|
|
150
|
+
module:Destroy()
|
|
151
|
+
return nil, "the chunk compiled but produced nothing callable", "module", nil
|
|
152
|
+
end
|
|
153
|
+
return result :: any, nil, "module", module
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
function Exec.run(params: { [string]: any }): { [string]: any }
|
|
157
|
+
local source = params.source
|
|
158
|
+
if typeof(source) ~= "string" or source == "" then
|
|
159
|
+
Dispatch.fail(
|
|
160
|
+
"BAD_PARAMS",
|
|
161
|
+
"execute_luau requires `source`.",
|
|
162
|
+
"Pass the Luau to run as a string."
|
|
163
|
+
)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
local chunk, compileError, route, module = compile(source)
|
|
167
|
+
if chunk == nil then
|
|
168
|
+
if route == "none" then
|
|
169
|
+
Dispatch.fail(
|
|
170
|
+
"NO_COMPILER",
|
|
171
|
+
string.format("Cannot run code in this session: %s", tostring(compileError)),
|
|
172
|
+
"Use the dedicated tools instead: create, modify, script_edit and find "
|
|
173
|
+
.. "cover most of what execute_luau is reached for."
|
|
174
|
+
)
|
|
175
|
+
end
|
|
176
|
+
Dispatch.fail(
|
|
177
|
+
"COMPILE_ERROR",
|
|
178
|
+
string.format("The code did not compile: %s", tostring(compileError)),
|
|
179
|
+
"Fix the syntax and try again. The chunk is compiled as a whole, so a "
|
|
180
|
+
.. "stray end or missing then reports here rather than at a line."
|
|
181
|
+
)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
-- Captured rather than inferred: a chunk's prints are usually the point, and
|
|
185
|
+
-- reading them back from the log afterwards would also pick up whatever else
|
|
186
|
+
-- the place logged in the meantime.
|
|
187
|
+
local output: { { [string]: any } } = {}
|
|
188
|
+
local connection = LogService.MessageOut:Connect(function(message, messageType)
|
|
189
|
+
if #output < MAX_OUTPUT_LINES then
|
|
190
|
+
table.insert(output, {
|
|
191
|
+
message = message,
|
|
192
|
+
level = if messageType == Enum.MessageType.MessageError
|
|
193
|
+
then "error"
|
|
194
|
+
elseif messageType == Enum.MessageType.MessageWarning then "warning"
|
|
195
|
+
else "print",
|
|
196
|
+
})
|
|
197
|
+
end
|
|
198
|
+
end)
|
|
199
|
+
|
|
200
|
+
local started = os.clock()
|
|
201
|
+
local results = table.pack(pcall(chunk :: () -> ...any))
|
|
202
|
+
local elapsed = os.clock() - started
|
|
203
|
+
|
|
204
|
+
--[[
|
|
205
|
+
MessageOut is deferred: `print` returns before the event fires, so
|
|
206
|
+
disconnecting straight after the chunk finished captured nothing at all.
|
|
207
|
+
A short settle picks the lines up.
|
|
208
|
+
|
|
209
|
+
It is bounded and stops as soon as the flow stops, rather than waiting a
|
|
210
|
+
fixed period, so a chunk that logged nothing costs almost no time and one
|
|
211
|
+
that logged plenty is not truncated.
|
|
212
|
+
]]
|
|
213
|
+
local settleDeadline = os.clock() + 0.5
|
|
214
|
+
local seen = #output
|
|
215
|
+
local quietFrames = 0
|
|
216
|
+
while os.clock() < settleDeadline and quietFrames < 3 do
|
|
217
|
+
task.wait()
|
|
218
|
+
if #output == seen then
|
|
219
|
+
quietFrames += 1
|
|
220
|
+
else
|
|
221
|
+
seen = #output
|
|
222
|
+
quietFrames = 0
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
connection:Disconnect()
|
|
227
|
+
|
|
228
|
+
if module then
|
|
229
|
+
module:Destroy()
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
-- Only mentioned when it constrains what the code could do. On the direct
|
|
233
|
+
-- route there is nothing to say.
|
|
234
|
+
local identity = if route == "module"
|
|
235
|
+
then "Ran through a ModuleScript because loadstring is disabled in this "
|
|
236
|
+
.. "session, so the code held script identity rather than plugin "
|
|
237
|
+
.. "identity -- plugin-only APIs would have been unavailable to it."
|
|
238
|
+
else nil
|
|
239
|
+
|
|
240
|
+
if not results[1] then
|
|
241
|
+
return {
|
|
242
|
+
ok = false,
|
|
243
|
+
error = tostring(results[2]),
|
|
244
|
+
output = output,
|
|
245
|
+
note = identity,
|
|
246
|
+
milliseconds = math.floor(elapsed * 1000 + 0.5),
|
|
247
|
+
}
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
local returned: { any } = {}
|
|
251
|
+
for index = 2, math.min(results.n, MAX_RETURN_VALUES + 1) do
|
|
252
|
+
table.insert(returned, describe(results[index]))
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
return {
|
|
256
|
+
ok = true,
|
|
257
|
+
returned = returned,
|
|
258
|
+
output = output,
|
|
259
|
+
note = identity,
|
|
260
|
+
milliseconds = math.floor(elapsed * 1000 + 0.5),
|
|
261
|
+
}
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
function Exec.register()
|
|
265
|
+
Dispatch.registerAll("exec", {
|
|
266
|
+
run = Exec.run,
|
|
267
|
+
})
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
return Exec
|