@el4cteo/rbx-studio-mcp 0.6.1 → 0.6.7
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/README.md +28 -2
- package/dist/bridge/console.js +182 -0
- package/dist/bridge/console.js.map +1 -1
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/cloudassets.js +233 -0
- package/dist/lib/cloudassets.js.map +1 -0
- package/dist/lib/credentials.js +180 -0
- package/dist/lib/credentials.js.map +1 -0
- package/dist/lib/livedata.js +325 -0
- package/dist/lib/livedata.js.map +1 -0
- package/dist/lib/liveluau.js +83 -0
- package/dist/lib/liveluau.js.map +1 -0
- package/dist/lib/liveops.js +358 -0
- package/dist/lib/liveops.js.map +1 -0
- package/dist/lib/opencloud.js +235 -0
- package/dist/lib/opencloud.js.map +1 -0
- package/dist/tools/anim.js +159 -0
- package/dist/tools/anim.js.map +1 -0
- package/dist/tools/audio.js +96 -0
- package/dist/tools/audio.js.map +1 -0
- package/dist/tools/character.js +95 -5
- package/dist/tools/character.js.map +1 -1
- package/dist/tools/data.js +292 -0
- package/dist/tools/data.js.map +1 -0
- package/dist/tools/device.js +77 -7
- package/dist/tools/device.js.map +1 -1
- package/dist/tools/discover.js +80 -4
- package/dist/tools/discover.js.map +1 -1
- package/dist/tools/exec.js +96 -2
- package/dist/tools/exec.js.map +1 -1
- package/dist/tools/input.js +35 -9
- package/dist/tools/input.js.map +1 -1
- package/dist/tools/perf.js +74 -7
- package/dist/tools/perf.js.map +1 -1
- package/dist/tools/scripts.js +162 -6
- package/dist/tools/scripts.js.map +1 -1
- package/dist/tools/spatial.js +135 -0
- package/dist/tools/spatial.js.map +1 -0
- package/dist/tools/universe.js +177 -0
- package/dist/tools/universe.js.map +1 -0
- package/dist/tools/upload.js +294 -0
- package/dist/tools/upload.js.map +1 -0
- package/dist/tools/world.js +675 -51
- package/dist/tools/world.js.map +1 -1
- package/package.json +2 -2
- package/plugin/src/Commands.luau +31 -7
- package/plugin/src/Config.luau +65 -65
- package/plugin/src/Console.luau +1909 -1843
- package/plugin/src/Emulation.luau +172 -0
- package/plugin/src/Phrase.luau +816 -618
- package/plugin/src/Png.luau +8 -4
- package/plugin/src/Prompt.luau +965 -961
- package/plugin/src/Secret.luau +86 -0
- package/plugin/src/Serialize.luau +440 -8
- package/plugin/src/Undo.luau +94 -6
- package/plugin/src/handlers/Anim.luau +897 -0
- package/plugin/src/handlers/Assets.luau +286 -2
- package/plugin/src/handlers/Audio.luau +411 -0
- package/plugin/src/handlers/Capture.luau +155 -20
- package/plugin/src/handlers/Character.luau +823 -361
- package/plugin/src/handlers/Data.luau +539 -0
- package/plugin/src/handlers/Device.luau +394 -139
- package/plugin/src/handlers/Discover.luau +685 -363
- package/plugin/src/handlers/Geometry.luau +722 -450
- package/plugin/src/handlers/Instances.luau +84 -4
- package/plugin/src/handlers/Perf.luau +227 -0
- package/plugin/src/handlers/Scripts.luau +673 -539
- package/plugin/src/handlers/Session.luau +3 -0
- package/plugin/src/handlers/Spatial.luau +334 -0
- package/plugin/src/handlers/Viewport.luau +268 -0
- package/plugin/src/handlers/World.luau +89 -15
- package/plugin/src/init.server.luau +9 -1
- package/scripts/build-plugin.mjs +20 -0
- package/scripts/check-plugin.mjs +171 -124
|
@@ -1,363 +1,685 @@
|
|
|
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
|
-
Puts a result list into an order that does not change between calls.
|
|
44
|
-
|
|
45
|
-
`GetChildren` and `GetDescendants` do NOT return a stable order. Measured on
|
|
46
|
-
twelve parts named P01..P12 with nothing mutating between the two calls, the
|
|
47
|
-
engine returned them in ascending order once and as P08, P06, P04, P03, P11,
|
|
48
|
-
P02, P01, P12, P10, P07, P05, P09 the next time.
|
|
49
|
-
|
|
50
|
-
Unsorted, that made paging incoherent rather than merely untidy: `offset` is
|
|
51
|
-
a position in this list, so page two came from a different ordering than page
|
|
52
|
-
one, which silently skips some instances and repeats others. Nothing about
|
|
53
|
-
the response would have said so -- the counts stay right and every row is a
|
|
54
|
-
real instance.
|
|
55
|
-
|
|
56
|
-
Sorted by name, with the engine's per-instance debug id breaking ties so
|
|
57
|
-
same-named siblings hold a fixed order too. Name first because it is also
|
|
58
|
-
the order a reader expects: P01 before P02.
|
|
59
|
-
]]
|
|
60
|
-
local function stableOrder(list: { Instance })
|
|
61
|
-
local ids: { [Instance]: string } = {}
|
|
62
|
-
for _, instance in list do
|
|
63
|
-
-- Cached: `GetDebugId` is a call per comparison otherwise, and a sort
|
|
64
|
-
-- makes O(n log n) of them.
|
|
65
|
-
local ok, id = pcall(function()
|
|
66
|
-
return instance:GetDebugId()
|
|
67
|
-
end)
|
|
68
|
-
ids[instance] = if ok then id else tostring(instance)
|
|
69
|
-
end
|
|
70
|
-
table.sort(list, function(a: Instance, b: Instance): boolean
|
|
71
|
-
if a.Name ~= b.Name then
|
|
72
|
-
return a.Name < b.Name
|
|
73
|
-
end
|
|
74
|
-
return (ids[a] or "") < (ids[b] or "")
|
|
75
|
-
end)
|
|
76
|
-
end
|
|
77
|
-
|
|
78
|
-
--[[
|
|
79
|
-
Compares a property against the text a caller asked for.
|
|
80
|
-
|
|
81
|
-
This was an exact `tostring` match, which made `find` disagree with `modify`
|
|
82
|
-
about its own notation: `modify` documents an enum as either "Neon" or
|
|
83
|
-
"Enum.Material.Neon", but only the qualified form matched here, and "False"
|
|
84
|
-
matched nothing at all. Both came back as a confident zero rather than an
|
|
85
|
-
error, which is the hardest kind of wrong answer to notice.
|
|
86
|
-
|
|
87
|
-
The bare-name shortcut is allowed only for enums, so a part named
|
|
88
|
-
"Map.Backup" is not matched by a search for "Backup".
|
|
89
|
-
]]
|
|
90
|
-
local function valueMatches(actual: any, wanted: string): boolean
|
|
91
|
-
local text = tostring(actual)
|
|
92
|
-
if text == wanted then
|
|
93
|
-
return true
|
|
94
|
-
end
|
|
95
|
-
|
|
96
|
-
local lowered = string.lower(text)
|
|
97
|
-
local target = string.lower(wanted)
|
|
98
|
-
if lowered == target then
|
|
99
|
-
return true
|
|
100
|
-
end
|
|
101
|
-
|
|
102
|
-
if string.sub(lowered, 1, 5) == "enum." then
|
|
103
|
-
return string.match(lowered, "([^%.]+)$") == target
|
|
104
|
-
end
|
|
105
|
-
return false
|
|
106
|
-
end
|
|
107
|
-
|
|
108
|
-
--[[
|
|
109
|
-
Breadth-first walk to `depth`, newest level last, so a truncated result is
|
|
110
|
-
still a coherent picture of the top of the tree rather than one deep spur.
|
|
111
|
-
]]
|
|
112
|
-
function Discover.tree(params: { [string]: any }): { [string]: any }
|
|
113
|
-
local root = if params.path then Paths.resolve(params.path) else game
|
|
114
|
-
local depth = tonumber(params.depth) or 2
|
|
115
|
-
local limit = tonumber(params.limit) or 100
|
|
116
|
-
local offset = tonumber(params.offset) or 0
|
|
117
|
-
local classFilter = params.className
|
|
118
|
-
local nameFilter = if params.nameContains then string.lower(params.nameContains) else nil
|
|
119
|
-
|
|
120
|
-
local matched: { Instance } = {}
|
|
121
|
-
local hidden = 0
|
|
122
|
-
local visits = 0
|
|
123
|
-
local frontier: { Instance } = { root }
|
|
124
|
-
|
|
125
|
-
for level = 1, depth do
|
|
126
|
-
local nextFrontier: { Instance } = {}
|
|
127
|
-
for _, parent in frontier do
|
|
128
|
-
for _, child in parent:GetChildren() do
|
|
129
|
-
visits += 1
|
|
130
|
-
if visits > MAX_VISITS then
|
|
131
|
-
break
|
|
132
|
-
end
|
|
133
|
-
if isNoisy(child) then
|
|
134
|
-
if parent == game then
|
|
135
|
-
hidden += 1
|
|
136
|
-
end
|
|
137
|
-
continue
|
|
138
|
-
end
|
|
139
|
-
|
|
140
|
-
local keep = true
|
|
141
|
-
if classFilter and not child:IsA(classFilter) then
|
|
142
|
-
keep = false
|
|
143
|
-
end
|
|
144
|
-
if keep and nameFilter and not string.find(string.lower(child.Name), nameFilter, 1, true) then
|
|
145
|
-
keep = false
|
|
146
|
-
end
|
|
147
|
-
if keep then
|
|
148
|
-
table.insert(matched, child)
|
|
149
|
-
end
|
|
150
|
-
if level < depth then
|
|
151
|
-
table.insert(nextFrontier, child)
|
|
152
|
-
end
|
|
153
|
-
end
|
|
154
|
-
end
|
|
155
|
-
-- Sorted per level rather than at the end, so the breadth-first shape is
|
|
156
|
-
-- kept -- shallow instances still come before deep ones -- while the
|
|
157
|
-
-- siblings inside each level stop reshuffling between calls.
|
|
158
|
-
stableOrder(nextFrontier)
|
|
159
|
-
frontier = nextFrontier
|
|
160
|
-
if #frontier == 0 or visits > MAX_VISITS then
|
|
161
|
-
break
|
|
162
|
-
end
|
|
163
|
-
end
|
|
164
|
-
|
|
165
|
-
stableOrder(matched)
|
|
166
|
-
|
|
167
|
-
local items: { { [string]: any } } = {}
|
|
168
|
-
local memo: Paths.NameIndex = {}
|
|
169
|
-
for index = offset + 1, math.min(offset + limit, #matched) do
|
|
170
|
-
table.insert(items, summarise(matched[index] :: Instance, memo))
|
|
171
|
-
end
|
|
172
|
-
|
|
173
|
-
return {
|
|
174
|
-
root = Paths.of(root, memo),
|
|
175
|
-
items = items,
|
|
176
|
-
total = #matched,
|
|
177
|
-
offset = offset,
|
|
178
|
-
hiddenServices = hidden,
|
|
179
|
-
}
|
|
180
|
-
end
|
|
181
|
-
|
|
182
|
-
--[[
|
|
183
|
-
Detailed read of specific instances. `properties` is chosen server-side from
|
|
184
|
-
the live API dump, so this handler never needs its own class table.
|
|
185
|
-
]]
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
]]
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
--
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
end
|
|
362
|
-
|
|
363
|
-
|
|
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
|
+
Puts a result list into an order that does not change between calls.
|
|
44
|
+
|
|
45
|
+
`GetChildren` and `GetDescendants` do NOT return a stable order. Measured on
|
|
46
|
+
twelve parts named P01..P12 with nothing mutating between the two calls, the
|
|
47
|
+
engine returned them in ascending order once and as P08, P06, P04, P03, P11,
|
|
48
|
+
P02, P01, P12, P10, P07, P05, P09 the next time.
|
|
49
|
+
|
|
50
|
+
Unsorted, that made paging incoherent rather than merely untidy: `offset` is
|
|
51
|
+
a position in this list, so page two came from a different ordering than page
|
|
52
|
+
one, which silently skips some instances and repeats others. Nothing about
|
|
53
|
+
the response would have said so -- the counts stay right and every row is a
|
|
54
|
+
real instance.
|
|
55
|
+
|
|
56
|
+
Sorted by name, with the engine's per-instance debug id breaking ties so
|
|
57
|
+
same-named siblings hold a fixed order too. Name first because it is also
|
|
58
|
+
the order a reader expects: P01 before P02.
|
|
59
|
+
]]
|
|
60
|
+
local function stableOrder(list: { Instance })
|
|
61
|
+
local ids: { [Instance]: string } = {}
|
|
62
|
+
for _, instance in list do
|
|
63
|
+
-- Cached: `GetDebugId` is a call per comparison otherwise, and a sort
|
|
64
|
+
-- makes O(n log n) of them.
|
|
65
|
+
local ok, id = pcall(function()
|
|
66
|
+
return instance:GetDebugId()
|
|
67
|
+
end)
|
|
68
|
+
ids[instance] = if ok then id else tostring(instance)
|
|
69
|
+
end
|
|
70
|
+
table.sort(list, function(a: Instance, b: Instance): boolean
|
|
71
|
+
if a.Name ~= b.Name then
|
|
72
|
+
return a.Name < b.Name
|
|
73
|
+
end
|
|
74
|
+
return (ids[a] or "") < (ids[b] or "")
|
|
75
|
+
end)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
--[[
|
|
79
|
+
Compares a property against the text a caller asked for.
|
|
80
|
+
|
|
81
|
+
This was an exact `tostring` match, which made `find` disagree with `modify`
|
|
82
|
+
about its own notation: `modify` documents an enum as either "Neon" or
|
|
83
|
+
"Enum.Material.Neon", but only the qualified form matched here, and "False"
|
|
84
|
+
matched nothing at all. Both came back as a confident zero rather than an
|
|
85
|
+
error, which is the hardest kind of wrong answer to notice.
|
|
86
|
+
|
|
87
|
+
The bare-name shortcut is allowed only for enums, so a part named
|
|
88
|
+
"Map.Backup" is not matched by a search for "Backup".
|
|
89
|
+
]]
|
|
90
|
+
local function valueMatches(actual: any, wanted: string): boolean
|
|
91
|
+
local text = tostring(actual)
|
|
92
|
+
if text == wanted then
|
|
93
|
+
return true
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
local lowered = string.lower(text)
|
|
97
|
+
local target = string.lower(wanted)
|
|
98
|
+
if lowered == target then
|
|
99
|
+
return true
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
if string.sub(lowered, 1, 5) == "enum." then
|
|
103
|
+
return string.match(lowered, "([^%.]+)$") == target
|
|
104
|
+
end
|
|
105
|
+
return false
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
--[[
|
|
109
|
+
Breadth-first walk to `depth`, newest level last, so a truncated result is
|
|
110
|
+
still a coherent picture of the top of the tree rather than one deep spur.
|
|
111
|
+
]]
|
|
112
|
+
function Discover.tree(params: { [string]: any }): { [string]: any }
|
|
113
|
+
local root = if params.path then Paths.resolve(params.path) else game
|
|
114
|
+
local depth = tonumber(params.depth) or 2
|
|
115
|
+
local limit = tonumber(params.limit) or 100
|
|
116
|
+
local offset = tonumber(params.offset) or 0
|
|
117
|
+
local classFilter = params.className
|
|
118
|
+
local nameFilter = if params.nameContains then string.lower(params.nameContains) else nil
|
|
119
|
+
|
|
120
|
+
local matched: { Instance } = {}
|
|
121
|
+
local hidden = 0
|
|
122
|
+
local visits = 0
|
|
123
|
+
local frontier: { Instance } = { root }
|
|
124
|
+
|
|
125
|
+
for level = 1, depth do
|
|
126
|
+
local nextFrontier: { Instance } = {}
|
|
127
|
+
for _, parent in frontier do
|
|
128
|
+
for _, child in parent:GetChildren() do
|
|
129
|
+
visits += 1
|
|
130
|
+
if visits > MAX_VISITS then
|
|
131
|
+
break
|
|
132
|
+
end
|
|
133
|
+
if isNoisy(child) then
|
|
134
|
+
if parent == game then
|
|
135
|
+
hidden += 1
|
|
136
|
+
end
|
|
137
|
+
continue
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
local keep = true
|
|
141
|
+
if classFilter and not child:IsA(classFilter) then
|
|
142
|
+
keep = false
|
|
143
|
+
end
|
|
144
|
+
if keep and nameFilter and not string.find(string.lower(child.Name), nameFilter, 1, true) then
|
|
145
|
+
keep = false
|
|
146
|
+
end
|
|
147
|
+
if keep then
|
|
148
|
+
table.insert(matched, child)
|
|
149
|
+
end
|
|
150
|
+
if level < depth then
|
|
151
|
+
table.insert(nextFrontier, child)
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
-- Sorted per level rather than at the end, so the breadth-first shape is
|
|
156
|
+
-- kept -- shallow instances still come before deep ones -- while the
|
|
157
|
+
-- siblings inside each level stop reshuffling between calls.
|
|
158
|
+
stableOrder(nextFrontier)
|
|
159
|
+
frontier = nextFrontier
|
|
160
|
+
if #frontier == 0 or visits > MAX_VISITS then
|
|
161
|
+
break
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
stableOrder(matched)
|
|
166
|
+
|
|
167
|
+
local items: { { [string]: any } } = {}
|
|
168
|
+
local memo: Paths.NameIndex = {}
|
|
169
|
+
for index = offset + 1, math.min(offset + limit, #matched) do
|
|
170
|
+
table.insert(items, summarise(matched[index] :: Instance, memo))
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
return {
|
|
174
|
+
root = Paths.of(root, memo),
|
|
175
|
+
items = items,
|
|
176
|
+
total = #matched,
|
|
177
|
+
offset = offset,
|
|
178
|
+
hiddenServices = hidden,
|
|
179
|
+
}
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
--[[
|
|
183
|
+
Detailed read of specific instances. `properties` is chosen server-side from
|
|
184
|
+
the live API dump, so this handler never needs its own class table.
|
|
185
|
+
]]
|
|
186
|
+
--[[
|
|
187
|
+
The physical facts about a part, which no property panel shows.
|
|
188
|
+
|
|
189
|
+
"Why does this fall over", "why does it sink", "why does the door fly off
|
|
190
|
+
when you touch it" are all mass questions, and mass is not a property you can
|
|
191
|
+
read in Studio -- it is computed from volume and material and appears
|
|
192
|
+
nowhere. A 4-stud cube of Metal weighs nine times the same cube of Neon, and
|
|
193
|
+
nothing on screen distinguishes them.
|
|
194
|
+
|
|
195
|
+
`AssemblyRootPart` is the other half. Parts welded together move as one body
|
|
196
|
+
with one combined mass, and which part leads that body decides how the whole
|
|
197
|
+
thing behaves. Two parts that look joined but report different assembly roots
|
|
198
|
+
are not joined at all, which is the usual answer to "why did half of it stay
|
|
199
|
+
behind".
|
|
200
|
+
]]
|
|
201
|
+
local function physicsOf(target: Instance): { [string]: any }?
|
|
202
|
+
if not target:IsA("BasePart") then
|
|
203
|
+
return nil
|
|
204
|
+
end
|
|
205
|
+
local part = target :: BasePart
|
|
206
|
+
|
|
207
|
+
local facts: { [string]: any } = {
|
|
208
|
+
anchored = part.Anchored,
|
|
209
|
+
canCollide = part.CanCollide,
|
|
210
|
+
massless = part.Massless,
|
|
211
|
+
material = tostring(part.Material):gsub("Enum%.Material%.", ""),
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
-- Every read is guarded separately: an anchored part has no assembly, and a
|
|
215
|
+
-- part mid-destruction can refuse any of these without the others failing.
|
|
216
|
+
pcall(function()
|
|
217
|
+
facts.mass = math.round(part:GetMass() * 1000) / 1000
|
|
218
|
+
end)
|
|
219
|
+
--[[
|
|
220
|
+
Infinity is a real answer here, and JSON cannot carry it.
|
|
221
|
+
|
|
222
|
+
An anchored part has infinite assembly mass -- that is the engine saying
|
|
223
|
+
"nothing will ever move this", which is exactly the fact someone asking
|
|
224
|
+
about mass wants. Encoded raw it came back as
|
|
225
|
+
`{"m":null,"t":"numeric","v":"inf"}`, which is the serialiser's honest
|
|
226
|
+
attempt at a number JSON has no room for, and is unreadable to everybody.
|
|
227
|
+
Said in words instead.
|
|
228
|
+
]]
|
|
229
|
+
pcall(function()
|
|
230
|
+
local assembly = part.AssemblyMass
|
|
231
|
+
if assembly == math.huge then
|
|
232
|
+
facts.assemblyMass = "infinite -- anchored, so nothing moves it"
|
|
233
|
+
else
|
|
234
|
+
facts.assemblyMass = math.round(assembly * 1000) / 1000
|
|
235
|
+
end
|
|
236
|
+
end)
|
|
237
|
+
pcall(function()
|
|
238
|
+
local root = part.AssemblyRootPart
|
|
239
|
+
if root == nil then
|
|
240
|
+
--[[
|
|
241
|
+
No assembly at all, which is not the same as being welded to
|
|
242
|
+
something.
|
|
243
|
+
|
|
244
|
+
Only parts inside Workspace are simulated; one sitting in
|
|
245
|
+
StarterPack, ReplicatedStorage or ServerStorage has no assembly
|
|
246
|
+
and `AssemblyRootPart` is nil. The first version read that nil as
|
|
247
|
+
"the root is not me" and reported `movesAlone = false`, which says
|
|
248
|
+
this part is attached to another -- about a part that is not in
|
|
249
|
+
the physics world at all. A pistol in StarterPack read as welded
|
|
250
|
+
to something.
|
|
251
|
+
]]
|
|
252
|
+
facts.simulated = false
|
|
253
|
+
facts.note = "Not in Workspace, so it has no physics assembly. Mass and "
|
|
254
|
+
.. "density are still real; anything about how it moves is not."
|
|
255
|
+
return
|
|
256
|
+
end
|
|
257
|
+
facts.simulated = true
|
|
258
|
+
facts.assemblyRoot = Paths.of(root)
|
|
259
|
+
--[[
|
|
260
|
+
Whether this part leads its own body. A part that is its own assembly
|
|
261
|
+
root is moving alone; one whose root is elsewhere is welded to
|
|
262
|
+
something, and the root names what.
|
|
263
|
+
]]
|
|
264
|
+
facts.movesAlone = root == part
|
|
265
|
+
end)
|
|
266
|
+
pcall(function()
|
|
267
|
+
--[[
|
|
268
|
+
Where the WHOLE body balances, in world coordinates.
|
|
269
|
+
|
|
270
|
+
`CenterOfMass` is the part's own, in its own space, and for any
|
|
271
|
+
ordinary part it is (0, 0, 0) -- it was reported for every part in
|
|
272
|
+
the place and said nothing about any of them. The figure that
|
|
273
|
+
answers a question is the assembly's: a plank at x = 0 on a fulcrum
|
|
274
|
+
at x = 0, with a weight bolted near one end, balances at x = 7, and
|
|
275
|
+
that number is the entire explanation of why it tips. Measured on
|
|
276
|
+
exactly that see-saw.
|
|
277
|
+
|
|
278
|
+
The local one is kept only when it is not the origin, which means
|
|
279
|
+
somebody set it deliberately and it is worth seeing.
|
|
280
|
+
]]
|
|
281
|
+
facts.centerOfMass = Serialize.value(part.AssemblyCenterOfMass)
|
|
282
|
+
local own = part.CenterOfMass
|
|
283
|
+
if own.Magnitude > 0.001 then
|
|
284
|
+
facts.centerOfMassOffset = Serialize.value(own)
|
|
285
|
+
end
|
|
286
|
+
end)
|
|
287
|
+
pcall(function()
|
|
288
|
+
local velocity = part.AssemblyLinearVelocity.Magnitude
|
|
289
|
+
if velocity > 0.01 then
|
|
290
|
+
facts.speed = math.round(velocity * 100) / 100
|
|
291
|
+
end
|
|
292
|
+
end)
|
|
293
|
+
--[[
|
|
294
|
+
Density from whichever source is actually in force.
|
|
295
|
+
|
|
296
|
+
An earlier version read `PhysicalProperties.new(part.Material)` whenever
|
|
297
|
+
custom properties existed, which reported the MATERIAL's density while the
|
|
298
|
+
part was using the override -- the one number in this block that would
|
|
299
|
+
have been confidently wrong. Measured: a Metal part reads 7.85 by
|
|
300
|
+
material and weighs 75.36, and the same part with a custom density of 0.3
|
|
301
|
+
weighs 2.88. Reporting 7.85 for the second one explains nothing about a
|
|
302
|
+
part that floats.
|
|
303
|
+
|
|
304
|
+
`CustomPhysicalProperties` is nil when unset, so its presence is the test
|
|
305
|
+
for which source applies.
|
|
306
|
+
]]
|
|
307
|
+
pcall(function()
|
|
308
|
+
local custom = part.CustomPhysicalProperties
|
|
309
|
+
if custom ~= nil then
|
|
310
|
+
facts.density = math.round(custom.Density * 1000) / 1000
|
|
311
|
+
facts.densityFrom = "CustomPhysicalProperties"
|
|
312
|
+
else
|
|
313
|
+
facts.density = math.round(PhysicalProperties.new(part.Material).Density * 1000) / 1000
|
|
314
|
+
facts.densityFrom = "material"
|
|
315
|
+
end
|
|
316
|
+
end)
|
|
317
|
+
|
|
318
|
+
return facts
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
function Discover.inspect(params: { [string]: any }): { [string]: any }
|
|
322
|
+
local paths = params.paths
|
|
323
|
+
if typeof(paths) ~= "table" or #paths == 0 then
|
|
324
|
+
Dispatch.fail(
|
|
325
|
+
"BAD_PARAMS",
|
|
326
|
+
"inspect requires a non-empty `paths` array.",
|
|
327
|
+
'Pass paths like ["Workspace.Model.Part"]. Use `find` or `tree` to discover them.'
|
|
328
|
+
)
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
local requested: { string }? = params.properties
|
|
332
|
+
local includeChildren = params.includeChildren ~= false
|
|
333
|
+
local childLimit = tonumber(params.childLimit) or 25
|
|
334
|
+
|
|
335
|
+
local results: { { [string]: any } } = {}
|
|
336
|
+
local failures: { string } = {}
|
|
337
|
+
local memo: Paths.NameIndex = {}
|
|
338
|
+
|
|
339
|
+
for _, path in paths do
|
|
340
|
+
local ok, instance = pcall(Paths.resolve, path)
|
|
341
|
+
if not ok then
|
|
342
|
+
local err = instance :: any
|
|
343
|
+
-- The hint carries the sibling listing ("It does have: ..."), which is
|
|
344
|
+
-- what lets the agent correct the path without another round trip.
|
|
345
|
+
local reason = if typeof(err) == "table"
|
|
346
|
+
then (if err.hint then err.message .. " " .. err.hint else err.message)
|
|
347
|
+
else tostring(err)
|
|
348
|
+
table.insert(failures, string.format("%s: %s", path, reason))
|
|
349
|
+
continue
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
local target = instance :: Instance
|
|
353
|
+
local properties: { [string]: any } = {}
|
|
354
|
+
if requested then
|
|
355
|
+
for _, name in requested do
|
|
356
|
+
local readOk, value = Serialize.readProperty(target, name)
|
|
357
|
+
if readOk then
|
|
358
|
+
properties[name] = value
|
|
359
|
+
end
|
|
360
|
+
end
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
local entry: { [string]: any } = {
|
|
364
|
+
path = Paths.of(target, memo),
|
|
365
|
+
-- Echoed back so a caller can correlate the answer with what it asked
|
|
366
|
+
-- for. `path` is the canonical form and often differs: ask about
|
|
367
|
+
-- "Workspace.Wall" and the answer comes back as "Workspace.Wall[1]".
|
|
368
|
+
requested = path,
|
|
369
|
+
className = target.ClassName,
|
|
370
|
+
properties = properties,
|
|
371
|
+
childCount = #target:GetChildren(),
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
-- Empty attribute and tag sets are omitted rather than sent as empty
|
|
375
|
+
-- containers: most instances have neither, and Luau encodes an empty
|
|
376
|
+
-- table as [] which reads as a list and confuses the shape.
|
|
377
|
+
local attributes: { [string]: any } = {}
|
|
378
|
+
local hasAttributes = false
|
|
379
|
+
for name, value in target:GetAttributes() do
|
|
380
|
+
attributes[name] = Serialize.value(value)
|
|
381
|
+
hasAttributes = true
|
|
382
|
+
end
|
|
383
|
+
if hasAttributes then
|
|
384
|
+
entry.attributes = attributes
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
local tags = CollectionService:GetTags(target)
|
|
388
|
+
if #tags > 0 then
|
|
389
|
+
entry.tags = tags
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
if params.physics == true then
|
|
393
|
+
entry.physics = physicsOf(target)
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
if includeChildren then
|
|
397
|
+
local children: { { [string]: any } } = {}
|
|
398
|
+
for index, child in target:GetChildren() do
|
|
399
|
+
if index > childLimit then
|
|
400
|
+
break
|
|
401
|
+
end
|
|
402
|
+
table.insert(children, { name = child.Name, className = child.ClassName })
|
|
403
|
+
end
|
|
404
|
+
entry.children = children
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
table.insert(results, entry)
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
return { items = results, failures = failures }
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
--[[
|
|
414
|
+
One search over name, class, property value and tag.
|
|
415
|
+
|
|
416
|
+
Every filter supplied must match (AND), which is what lets a single tool
|
|
417
|
+
answer "every anchored Part under Workspace whose name contains 'door'"
|
|
418
|
+
without the agent chaining three calls and intersecting the results itself.
|
|
419
|
+
]]
|
|
420
|
+
--[[
|
|
421
|
+
Runs a selector through the engine's own matcher.
|
|
422
|
+
|
|
423
|
+
`QueryDescendants` is a CSS-like query built into Instance, and it is doing
|
|
424
|
+
the same job the loop below does -- except in C++, over the whole subtree, in
|
|
425
|
+
one call. Measured on a 3700-instance place: "Part" answered 189 matches
|
|
426
|
+
without a single Luau iteration.
|
|
427
|
+
|
|
428
|
+
What it understands, confirmed against a live session rather than guessed:
|
|
429
|
+
|
|
430
|
+
Part class name, superclasses included
|
|
431
|
+
#Baseplate exact name
|
|
432
|
+
[Anchored=true] property equality, on its own or after a class
|
|
433
|
+
Part, Model either
|
|
434
|
+
Model > Part direct children of a Model
|
|
435
|
+
Model >> Part descendants of a Model
|
|
436
|
+
|
|
437
|
+
What it does not: substring names, and comparisons like `>` or `<` (it
|
|
438
|
+
answers "'=' expected after property name"). That is why this is one
|
|
439
|
+
candidate source among three rather than a replacement for the filters --
|
|
440
|
+
`nameContains` still needs the loop, and composes with a selector.
|
|
441
|
+
|
|
442
|
+
A malformed selector raises from inside the engine with a readable reason, so
|
|
443
|
+
it is caught and re-raised as BAD_PARAMS with the reason kept: "Pseudo-class
|
|
444
|
+
'first' filter is not supported" tells the caller exactly what to drop.
|
|
445
|
+
]]
|
|
446
|
+
local function query(root: Instance, selector: string): { Instance }
|
|
447
|
+
local ok, result = pcall(function()
|
|
448
|
+
return root:QueryDescendants(selector)
|
|
449
|
+
end)
|
|
450
|
+
if not ok then
|
|
451
|
+
Dispatch.fail(
|
|
452
|
+
"BAD_PARAMS",
|
|
453
|
+
string.format("selector %q was rejected: %s", selector, tostring(result)),
|
|
454
|
+
"Selectors support a class name, #Name, [Property=Value], `A, B`, "
|
|
455
|
+
.. "`A > B` for direct children and `A >> B` for descendants. They do "
|
|
456
|
+
.. "not support substring names or < > comparisons -- use "
|
|
457
|
+
.. "`nameContains` and `propertyValue` for those."
|
|
458
|
+
)
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
--[[
|
|
462
|
+
Deduplicated, because a union can match the same instance twice.
|
|
463
|
+
|
|
464
|
+
`Script, LocalScript` looks like two disjoint sets and is not: LocalScript
|
|
465
|
+
inherits Script, so every LocalScript satisfies both halves and the engine
|
|
466
|
+
returns it once per half. Measured -- a place with 7 scripts answered 14
|
|
467
|
+
matches, every one of them listed twice, and the count was as wrong as the
|
|
468
|
+
listing.
|
|
469
|
+
|
|
470
|
+
The engine is not doing anything unreasonable here; a union is a union.
|
|
471
|
+
But "how many are there" is the question a caller is usually asking, and
|
|
472
|
+
an answer that doubles depending on how the class hierarchy happens to be
|
|
473
|
+
shaped is not an answer.
|
|
474
|
+
]]
|
|
475
|
+
local matches = result :: { Instance }
|
|
476
|
+
local seen: { [Instance]: boolean } = {}
|
|
477
|
+
local unique: { Instance } = {}
|
|
478
|
+
for _, instance in matches do
|
|
479
|
+
if not seen[instance] then
|
|
480
|
+
seen[instance] = true
|
|
481
|
+
table.insert(unique, instance)
|
|
482
|
+
end
|
|
483
|
+
end
|
|
484
|
+
return unique
|
|
485
|
+
end
|
|
486
|
+
|
|
487
|
+
--[[
|
|
488
|
+
Which tags this place actually uses.
|
|
489
|
+
|
|
490
|
+
`find` can filter by tag and could never tell you which tags exist, so using
|
|
491
|
+
it meant guessing a name: a search for "Doors" that returns nothing is
|
|
492
|
+
indistinguishable from a place whose doors are tagged "Door". On a place
|
|
493
|
+
nobody has seen before that is the difference between reading how the game is
|
|
494
|
+
organised in one call and not being able to ask the question at all -- tags
|
|
495
|
+
like Enemy, Checkpoint or Interactable name a game's systems better than its
|
|
496
|
+
folder layout does.
|
|
497
|
+
|
|
498
|
+
Counted per tag, because an empty tag and a busy one mean different things: a
|
|
499
|
+
tag with nothing on it is usually left over from something deleted, and worth
|
|
500
|
+
saying so rather than listing beside the real ones.
|
|
501
|
+
]]
|
|
502
|
+
|
|
503
|
+
--[[
|
|
504
|
+
Studio's own tags, which are in every place and belong to none of them.
|
|
505
|
+
|
|
506
|
+
The tag registry is shared with Studio's interface, so an unfiltered listing
|
|
507
|
+
is mostly `data-testid=--studio-foundation--stylesheet-wrapper` and its
|
|
508
|
+
relatives -- measured, 7 tags in a place that uses 5. They are recognisable
|
|
509
|
+
by prefix rather than by a list, since the set changes with Studio's own UI
|
|
510
|
+
and hardcoding today's names would silently rot.
|
|
511
|
+
]]
|
|
512
|
+
local function studioOwned(tag: string): boolean
|
|
513
|
+
return string.sub(tag, 1, 12) == "data-testid="
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
function Discover.tags(params: { [string]: any }): { [string]: any }
|
|
517
|
+
local root = if typeof(params.path) == "string" and params.path ~= ""
|
|
518
|
+
then Paths.resolve(params.path)
|
|
519
|
+
else nil
|
|
520
|
+
|
|
521
|
+
local rows: { { [string]: any } } = {}
|
|
522
|
+
local hidden = 0
|
|
523
|
+
|
|
524
|
+
for _, tag in CollectionService:GetAllTags() do
|
|
525
|
+
if studioOwned(tag) then
|
|
526
|
+
hidden += 1
|
|
527
|
+
continue
|
|
528
|
+
end
|
|
529
|
+
|
|
530
|
+
local tagged = CollectionService:GetTagged(tag)
|
|
531
|
+
local count = 0
|
|
532
|
+
local sample: { string } = {}
|
|
533
|
+
|
|
534
|
+
for _, instance in tagged do
|
|
535
|
+
--[[
|
|
536
|
+
Scoped the same way `find` scopes, when a path is given. A tag is
|
|
537
|
+
global, so "which tags are used in Workspace.Map" is a different
|
|
538
|
+
question from "which tags exist", and both get asked.
|
|
539
|
+
]]
|
|
540
|
+
if root ~= nil and not instance:IsDescendantOf(root) then
|
|
541
|
+
continue
|
|
542
|
+
end
|
|
543
|
+
if Scope.isNoisy(instance) then
|
|
544
|
+
continue
|
|
545
|
+
end
|
|
546
|
+
count += 1
|
|
547
|
+
if #sample < 3 then
|
|
548
|
+
table.insert(sample, Paths.of(instance))
|
|
549
|
+
end
|
|
550
|
+
end
|
|
551
|
+
|
|
552
|
+
if root ~= nil and count == 0 then
|
|
553
|
+
continue
|
|
554
|
+
end
|
|
555
|
+
|
|
556
|
+
table.insert(rows, {
|
|
557
|
+
tag = tag,
|
|
558
|
+
count = count,
|
|
559
|
+
-- A few paths, not all of them: the point is to recognise what the tag
|
|
560
|
+
-- is for, and three examples do that where two hundred would not.
|
|
561
|
+
sample = sample,
|
|
562
|
+
})
|
|
563
|
+
end
|
|
564
|
+
|
|
565
|
+
table.sort(rows, function(a, b)
|
|
566
|
+
if a.count == b.count then
|
|
567
|
+
return tostring(a.tag) < tostring(b.tag)
|
|
568
|
+
end
|
|
569
|
+
return (a.count :: number) > (b.count :: number)
|
|
570
|
+
end)
|
|
571
|
+
|
|
572
|
+
return {
|
|
573
|
+
tags = rows,
|
|
574
|
+
count = #rows,
|
|
575
|
+
scope = if root ~= nil then Paths.of(root) else nil,
|
|
576
|
+
studioTagsHidden = hidden,
|
|
577
|
+
}
|
|
578
|
+
end
|
|
579
|
+
|
|
580
|
+
function Discover.find(params: { [string]: any }): { [string]: any }
|
|
581
|
+
local root = if params.path then Paths.resolve(params.path) else game
|
|
582
|
+
local limit = tonumber(params.limit) or 100
|
|
583
|
+
local offset = tonumber(params.offset) or 0
|
|
584
|
+
|
|
585
|
+
local nameFilter = if params.nameContains then string.lower(params.nameContains) else nil
|
|
586
|
+
local classFilter = params.className
|
|
587
|
+
local propertyName = params.propertyName
|
|
588
|
+
local propertyValue = params.propertyValue
|
|
589
|
+
local tagFilter = params.tag
|
|
590
|
+
local selector = if typeof(params.selector) == "string" and params.selector ~= ""
|
|
591
|
+
then params.selector
|
|
592
|
+
else nil
|
|
593
|
+
|
|
594
|
+
--[[
|
|
595
|
+
Three ways to produce candidates, cheapest first.
|
|
596
|
+
|
|
597
|
+
The filters below are the same in every case -- this only chooses where
|
|
598
|
+
the list to filter comes from, which is the part that decides whether a
|
|
599
|
+
search on a large place is fast or times out.
|
|
600
|
+
|
|
601
|
+
* A tag query reads CollectionService's index instead of walking the
|
|
602
|
+
tree, which is orders of magnitude cheaper.
|
|
603
|
+
* A selector hands the whole match to `QueryDescendants`, so the walk
|
|
604
|
+
happens in C++ and only survivors cross into Luau.
|
|
605
|
+
* Otherwise the tree is walked here.
|
|
606
|
+
]]
|
|
607
|
+
local candidates: { Instance }
|
|
608
|
+
if tagFilter then
|
|
609
|
+
candidates = CollectionService:GetTagged(tagFilter)
|
|
610
|
+
elseif selector then
|
|
611
|
+
candidates = query(root, selector :: string)
|
|
612
|
+
else
|
|
613
|
+
candidates = root:GetDescendants()
|
|
614
|
+
end
|
|
615
|
+
|
|
616
|
+
if #candidates > MAX_VISITS then
|
|
617
|
+
Dispatch.fail(
|
|
618
|
+
"TOO_BROAD",
|
|
619
|
+
string.format(
|
|
620
|
+
"That search would visit %d instances, over the %d limit.",
|
|
621
|
+
#candidates,
|
|
622
|
+
MAX_VISITS
|
|
623
|
+
),
|
|
624
|
+
"Narrow it with `path` to search one service or model instead of the whole "
|
|
625
|
+
.. "place, or pass a `selector` -- that filters inside the engine, so the "
|
|
626
|
+
.. "count here is what survived rather than what was visited."
|
|
627
|
+
)
|
|
628
|
+
end
|
|
629
|
+
|
|
630
|
+
local matched: { Instance } = {}
|
|
631
|
+
for _, instance in candidates do
|
|
632
|
+
if tagFilter and root ~= game and not instance:IsDescendantOf(root) then
|
|
633
|
+
continue
|
|
634
|
+
end
|
|
635
|
+
if isNoisy(instance) then
|
|
636
|
+
continue
|
|
637
|
+
end
|
|
638
|
+
if classFilter and not instance:IsA(classFilter) then
|
|
639
|
+
continue
|
|
640
|
+
end
|
|
641
|
+
if nameFilter and not string.find(string.lower(instance.Name), nameFilter, 1, true) then
|
|
642
|
+
continue
|
|
643
|
+
end
|
|
644
|
+
if propertyName then
|
|
645
|
+
local readOk, value = Serialize.readProperty(instance, propertyName)
|
|
646
|
+
if not readOk then
|
|
647
|
+
continue
|
|
648
|
+
end
|
|
649
|
+
if propertyValue ~= nil and not valueMatches(value, tostring(propertyValue)) then
|
|
650
|
+
continue
|
|
651
|
+
end
|
|
652
|
+
end
|
|
653
|
+
table.insert(matched, instance)
|
|
654
|
+
end
|
|
655
|
+
|
|
656
|
+
stableOrder(matched)
|
|
657
|
+
|
|
658
|
+
local items: { { [string]: any } } = {}
|
|
659
|
+
local memo: Paths.NameIndex = {}
|
|
660
|
+
for index = offset + 1, math.min(offset + limit, #matched) do
|
|
661
|
+
table.insert(items, summarise(matched[index] :: Instance, memo))
|
|
662
|
+
end
|
|
663
|
+
|
|
664
|
+
return {
|
|
665
|
+
items = items,
|
|
666
|
+
total = #matched,
|
|
667
|
+
offset = offset,
|
|
668
|
+
searched = #candidates,
|
|
669
|
+
-- Echoed so a caller can see the selector was understood. A typo in a
|
|
670
|
+
-- selector is not an error -- it matches nothing -- and "0 results" reads
|
|
671
|
+
-- the same either way without this.
|
|
672
|
+
selector = selector,
|
|
673
|
+
}
|
|
674
|
+
end
|
|
675
|
+
|
|
676
|
+
function Discover.register()
|
|
677
|
+
Dispatch.registerAll("discover", {
|
|
678
|
+
tags = Discover.tags,
|
|
679
|
+
tree = Discover.tree,
|
|
680
|
+
inspect = Discover.inspect,
|
|
681
|
+
find = Discover.find,
|
|
682
|
+
})
|
|
683
|
+
end
|
|
684
|
+
|
|
685
|
+
return Discover
|