@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.
Files changed (75) hide show
  1. package/README.md +28 -2
  2. package/dist/bridge/console.js +182 -0
  3. package/dist/bridge/console.js.map +1 -1
  4. package/dist/index.js +8 -0
  5. package/dist/index.js.map +1 -1
  6. package/dist/lib/cloudassets.js +233 -0
  7. package/dist/lib/cloudassets.js.map +1 -0
  8. package/dist/lib/credentials.js +180 -0
  9. package/dist/lib/credentials.js.map +1 -0
  10. package/dist/lib/livedata.js +325 -0
  11. package/dist/lib/livedata.js.map +1 -0
  12. package/dist/lib/liveluau.js +83 -0
  13. package/dist/lib/liveluau.js.map +1 -0
  14. package/dist/lib/liveops.js +358 -0
  15. package/dist/lib/liveops.js.map +1 -0
  16. package/dist/lib/opencloud.js +235 -0
  17. package/dist/lib/opencloud.js.map +1 -0
  18. package/dist/tools/anim.js +159 -0
  19. package/dist/tools/anim.js.map +1 -0
  20. package/dist/tools/audio.js +96 -0
  21. package/dist/tools/audio.js.map +1 -0
  22. package/dist/tools/character.js +95 -5
  23. package/dist/tools/character.js.map +1 -1
  24. package/dist/tools/data.js +292 -0
  25. package/dist/tools/data.js.map +1 -0
  26. package/dist/tools/device.js +77 -7
  27. package/dist/tools/device.js.map +1 -1
  28. package/dist/tools/discover.js +80 -4
  29. package/dist/tools/discover.js.map +1 -1
  30. package/dist/tools/exec.js +96 -2
  31. package/dist/tools/exec.js.map +1 -1
  32. package/dist/tools/input.js +35 -9
  33. package/dist/tools/input.js.map +1 -1
  34. package/dist/tools/perf.js +74 -7
  35. package/dist/tools/perf.js.map +1 -1
  36. package/dist/tools/scripts.js +162 -6
  37. package/dist/tools/scripts.js.map +1 -1
  38. package/dist/tools/spatial.js +135 -0
  39. package/dist/tools/spatial.js.map +1 -0
  40. package/dist/tools/universe.js +177 -0
  41. package/dist/tools/universe.js.map +1 -0
  42. package/dist/tools/upload.js +294 -0
  43. package/dist/tools/upload.js.map +1 -0
  44. package/dist/tools/world.js +675 -51
  45. package/dist/tools/world.js.map +1 -1
  46. package/package.json +2 -2
  47. package/plugin/src/Commands.luau +31 -7
  48. package/plugin/src/Config.luau +65 -65
  49. package/plugin/src/Console.luau +1909 -1843
  50. package/plugin/src/Emulation.luau +172 -0
  51. package/plugin/src/Phrase.luau +816 -618
  52. package/plugin/src/Png.luau +8 -4
  53. package/plugin/src/Prompt.luau +965 -961
  54. package/plugin/src/Secret.luau +86 -0
  55. package/plugin/src/Serialize.luau +440 -8
  56. package/plugin/src/Undo.luau +94 -6
  57. package/plugin/src/handlers/Anim.luau +897 -0
  58. package/plugin/src/handlers/Assets.luau +286 -2
  59. package/plugin/src/handlers/Audio.luau +411 -0
  60. package/plugin/src/handlers/Capture.luau +155 -20
  61. package/plugin/src/handlers/Character.luau +823 -361
  62. package/plugin/src/handlers/Data.luau +539 -0
  63. package/plugin/src/handlers/Device.luau +394 -139
  64. package/plugin/src/handlers/Discover.luau +685 -363
  65. package/plugin/src/handlers/Geometry.luau +722 -450
  66. package/plugin/src/handlers/Instances.luau +84 -4
  67. package/plugin/src/handlers/Perf.luau +227 -0
  68. package/plugin/src/handlers/Scripts.luau +673 -539
  69. package/plugin/src/handlers/Session.luau +3 -0
  70. package/plugin/src/handlers/Spatial.luau +334 -0
  71. package/plugin/src/handlers/Viewport.luau +268 -0
  72. package/plugin/src/handlers/World.luau +89 -15
  73. package/plugin/src/init.server.luau +9 -1
  74. package/scripts/build-plugin.mjs +20 -0
  75. 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
- function Discover.inspect(params: { [string]: any }): { [string]: any }
187
- local paths = params.paths
188
- if typeof(paths) ~= "table" or #paths == 0 then
189
- Dispatch.fail(
190
- "BAD_PARAMS",
191
- "inspect requires a non-empty `paths` array.",
192
- 'Pass paths like ["Workspace.Model.Part"]. Use `find` or `tree` to discover them.'
193
- )
194
- end
195
-
196
- local requested: { string }? = params.properties
197
- local includeChildren = params.includeChildren ~= false
198
- local childLimit = tonumber(params.childLimit) or 25
199
-
200
- local results: { { [string]: any } } = {}
201
- local failures: { string } = {}
202
- local memo: Paths.NameIndex = {}
203
-
204
- for _, path in paths do
205
- local ok, instance = pcall(Paths.resolve, path)
206
- if not ok then
207
- local err = instance :: any
208
- -- The hint carries the sibling listing ("It does have: ..."), which is
209
- -- what lets the agent correct the path without another round trip.
210
- local reason = if typeof(err) == "table"
211
- then (if err.hint then err.message .. " " .. err.hint else err.message)
212
- else tostring(err)
213
- table.insert(failures, string.format("%s: %s", path, reason))
214
- continue
215
- end
216
-
217
- local target = instance :: Instance
218
- local properties: { [string]: any } = {}
219
- if requested then
220
- for _, name in requested do
221
- local readOk, value = Serialize.readProperty(target, name)
222
- if readOk then
223
- properties[name] = value
224
- end
225
- end
226
- end
227
-
228
- local entry: { [string]: any } = {
229
- path = Paths.of(target, memo),
230
- -- Echoed back so a caller can correlate the answer with what it asked
231
- -- for. `path` is the canonical form and often differs: ask about
232
- -- "Workspace.Wall" and the answer comes back as "Workspace.Wall[1]".
233
- requested = path,
234
- className = target.ClassName,
235
- properties = properties,
236
- childCount = #target:GetChildren(),
237
- }
238
-
239
- -- Empty attribute and tag sets are omitted rather than sent as empty
240
- -- containers: most instances have neither, and Luau encodes an empty
241
- -- table as [] which reads as a list and confuses the shape.
242
- local attributes: { [string]: any } = {}
243
- local hasAttributes = false
244
- for name, value in target:GetAttributes() do
245
- attributes[name] = Serialize.value(value)
246
- hasAttributes = true
247
- end
248
- if hasAttributes then
249
- entry.attributes = attributes
250
- end
251
-
252
- local tags = CollectionService:GetTags(target)
253
- if #tags > 0 then
254
- entry.tags = tags
255
- end
256
-
257
- if includeChildren then
258
- local children: { { [string]: any } } = {}
259
- for index, child in target:GetChildren() do
260
- if index > childLimit then
261
- break
262
- end
263
- table.insert(children, { name = child.Name, className = child.ClassName })
264
- end
265
- entry.children = children
266
- end
267
-
268
- table.insert(results, entry)
269
- end
270
-
271
- return { items = results, failures = failures }
272
- end
273
-
274
- --[[
275
- One search over name, class, property value and tag.
276
-
277
- Every filter supplied must match (AND), which is what lets a single tool
278
- answer "every anchored Part under Workspace whose name contains 'door'"
279
- without the agent chaining three calls and intersecting the results itself.
280
- ]]
281
- function Discover.find(params: { [string]: any }): { [string]: any }
282
- local root = if params.path then Paths.resolve(params.path) else game
283
- local limit = tonumber(params.limit) or 100
284
- local offset = tonumber(params.offset) or 0
285
-
286
- local nameFilter = if params.nameContains then string.lower(params.nameContains) else nil
287
- local classFilter = params.className
288
- local propertyName = params.propertyName
289
- local propertyValue = params.propertyValue
290
- local tagFilter = params.tag
291
-
292
- -- A tag query is answered from CollectionService's index rather than by
293
- -- walking the tree, which is orders of magnitude cheaper on a big place.
294
- local candidates: { Instance }
295
- if tagFilter then
296
- candidates = CollectionService:GetTagged(tagFilter)
297
- else
298
- candidates = root:GetDescendants()
299
- end
300
-
301
- if #candidates > MAX_VISITS then
302
- Dispatch.fail(
303
- "TOO_BROAD",
304
- string.format(
305
- "That search would visit %d instances, over the %d limit.",
306
- #candidates,
307
- MAX_VISITS
308
- ),
309
- "Narrow it with `path` to search one service or model instead of the whole place."
310
- )
311
- end
312
-
313
- local matched: { Instance } = {}
314
- for _, instance in candidates do
315
- if tagFilter and root ~= game and not instance:IsDescendantOf(root) then
316
- continue
317
- end
318
- if isNoisy(instance) then
319
- continue
320
- end
321
- if classFilter and not instance:IsA(classFilter) then
322
- continue
323
- end
324
- if nameFilter and not string.find(string.lower(instance.Name), nameFilter, 1, true) then
325
- continue
326
- end
327
- if propertyName then
328
- local readOk, value = Serialize.readProperty(instance, propertyName)
329
- if not readOk then
330
- continue
331
- end
332
- if propertyValue ~= nil and not valueMatches(value, tostring(propertyValue)) then
333
- continue
334
- end
335
- end
336
- table.insert(matched, instance)
337
- end
338
-
339
- stableOrder(matched)
340
-
341
- local items: { { [string]: any } } = {}
342
- local memo: Paths.NameIndex = {}
343
- for index = offset + 1, math.min(offset + limit, #matched) do
344
- table.insert(items, summarise(matched[index] :: Instance, memo))
345
- end
346
-
347
- return {
348
- items = items,
349
- total = #matched,
350
- offset = offset,
351
- searched = #candidates,
352
- }
353
- end
354
-
355
- function Discover.register()
356
- Dispatch.registerAll("discover", {
357
- tree = Discover.tree,
358
- inspect = Discover.inspect,
359
- find = Discover.find,
360
- })
361
- end
362
-
363
- return Discover
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