@el4cteo/rbx-studio-mcp 0.6.1 → 0.6.5

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 (47) hide show
  1. package/dist/index.js +4 -0
  2. package/dist/index.js.map +1 -1
  3. package/dist/tools/anim.js +159 -0
  4. package/dist/tools/anim.js.map +1 -0
  5. package/dist/tools/character.js +95 -5
  6. package/dist/tools/character.js.map +1 -1
  7. package/dist/tools/data.js +173 -0
  8. package/dist/tools/data.js.map +1 -0
  9. package/dist/tools/device.js +77 -7
  10. package/dist/tools/device.js.map +1 -1
  11. package/dist/tools/discover.js +80 -4
  12. package/dist/tools/discover.js.map +1 -1
  13. package/dist/tools/exec.js +48 -1
  14. package/dist/tools/exec.js.map +1 -1
  15. package/dist/tools/input.js +35 -9
  16. package/dist/tools/input.js.map +1 -1
  17. package/dist/tools/perf.js +74 -7
  18. package/dist/tools/perf.js.map +1 -1
  19. package/dist/tools/scripts.js +51 -3
  20. package/dist/tools/scripts.js.map +1 -1
  21. package/dist/tools/world.js +317 -44
  22. package/dist/tools/world.js.map +1 -1
  23. package/package.json +74 -74
  24. package/plugin/src/Commands.luau +622 -622
  25. package/plugin/src/Config.luau +65 -65
  26. package/plugin/src/Console.luau +1909 -1843
  27. package/plugin/src/Emulation.luau +172 -0
  28. package/plugin/src/Phrase.luau +164 -16
  29. package/plugin/src/Png.luau +8 -4
  30. package/plugin/src/Serialize.luau +499 -327
  31. package/plugin/src/Undo.luau +94 -6
  32. package/plugin/src/handlers/Anim.luau +897 -0
  33. package/plugin/src/handlers/Assets.luau +587 -352
  34. package/plugin/src/handlers/Capture.luau +155 -20
  35. package/plugin/src/handlers/Character.luau +823 -361
  36. package/plugin/src/handlers/Data.luau +539 -0
  37. package/plugin/src/handlers/Device.luau +394 -139
  38. package/plugin/src/handlers/Discover.luau +685 -363
  39. package/plugin/src/handlers/Geometry.luau +127 -0
  40. package/plugin/src/handlers/Perf.luau +227 -0
  41. package/plugin/src/handlers/Scripts.luau +673 -539
  42. package/plugin/src/handlers/Session.luau +3 -0
  43. package/plugin/src/handlers/Viewport.luau +268 -0
  44. package/plugin/src/handlers/World.luau +89 -15
  45. package/plugin/src/init.server.luau +879 -875
  46. package/scripts/build-plugin.mjs +20 -0
  47. package/scripts/check-plugin.mjs +171 -124
@@ -1,352 +1,587 @@
1
- --!strict
2
- --[[
3
- Inserting Creator Store assets into the place.
4
-
5
- The search half of this lives on the server, not here: Node already has
6
- internet access and Roblox's toolbox endpoints answer unauthenticated, while
7
- a plugin making outbound HTTP needs the user to grant permission per domain.
8
- Doing it server-side means search works the moment the server starts, and the
9
- plugin only ever talks to the one host it already talks to.
10
-
11
- So this file does the half only Studio can do: turn an asset id into real
12
- instances in the data model, under a recording so one insert is one Ctrl+Z.
13
- ]]
14
-
15
- local AssetService = game:GetService("AssetService")
16
-
17
- local Dispatch = require(script.Parent.Parent.Dispatch)
18
- local Paths = require(script.Parent.Parent.Paths)
19
- local Undo = require(script.Parent.Parent.Undo)
20
-
21
- local Assets = {}
22
-
23
- --[[
24
- Counts scripts anywhere inside what was inserted.
25
-
26
- Free models carrying scripts are the oldest hazard on the platform, and the
27
- agent inserting one has no other way to know. This is reported on every
28
- insert rather than only when asked, because the caller who most needs to see
29
- it is the one who did not think to look.
30
- ]]
31
- local function countScripts(root: Instance): (number, { string })
32
- local total = 0
33
- local names: { string } = {}
34
- for _, descendant in root:GetDescendants() do
35
- if descendant:IsA("LuaSourceContainer") then
36
- total += 1
37
- if #names < 10 then
38
- table.insert(names, string.format("%s (%s)", descendant.Name, descendant.ClassName))
39
- end
40
- end
41
- end
42
- return total, names
43
- end
44
-
45
- function Assets.insert(params: { [string]: any }): { [string]: any }
46
- local assetId = tonumber(params.assetId)
47
- if assetId == nil or assetId <= 0 then
48
- Dispatch.fail("BAD_PARAMS", "insert needs a numeric `assetId`.")
49
- end
50
-
51
- local parent = if typeof(params.parent) == "string" and params.parent ~= ""
52
- then Paths.resolve(params.parent)
53
- else workspace
54
-
55
- --[[
56
- `game:GetObjects`, not `InsertService:LoadAsset`.
57
-
58
- LoadAsset is the documented route and it refuses everything here: every
59
- public model tried, from several creators, came back "User is not
60
- authorized to access Asset". It enforces ownership, which makes it useful
61
- for a game loading its own assets and useless for inserting from the
62
- Creator Store -- the entire point of this tool.
63
-
64
- GetObjects is the plugin-security path Studio's own toolbox uses, and it
65
- loaded the same ids without complaint. It returns an array of top-level
66
- instances rather than a wrapper model, so there is nothing to unwrap.
67
- ]]
68
- local ok, loaded = pcall(function()
69
- return game:GetObjects("rbxassetid://" .. assetId)
70
- end)
71
- if not ok then
72
- Dispatch.fail(
73
- "INSERT_FAILED",
74
- string.format("Could not load asset %d: %s", assetId, tostring(loaded)),
75
- "The asset may be private, deleted, or not a model."
76
- )
77
- end
78
-
79
- local children = loaded :: { Instance }
80
- if #children == 0 then
81
- Dispatch.fail("EMPTY_ASSET", string.format("Asset %d contained nothing.", assetId))
82
- end
83
-
84
- -- Counted across every root, since an asset can arrive as several instances
85
- -- and a script hiding in the second one counts exactly as much.
86
- local scriptCount = 0
87
- local scriptNames: { string } = {}
88
- for _, child in children do
89
- local count, names = countScripts(child)
90
- scriptCount += count
91
- for _, name in names do
92
- if #scriptNames < 10 then
93
- table.insert(scriptNames, name)
94
- end
95
- end
96
- if child:IsA("LuaSourceContainer") then
97
- scriptCount += 1
98
- if #scriptNames < 10 then
99
- table.insert(scriptNames, string.format("%s (%s)", child.Name, child.ClassName))
100
- end
101
- end
102
- end
103
-
104
- local inserted: { string } = {}
105
- local _, undoable = Undo.record("MCPInsertAsset", "MCP insert asset", function()
106
- for _, child in children do
107
- if typeof(params.name) == "string" and params.name ~= "" and #children == 1 then
108
- child.Name = params.name
109
- end
110
- child.Parent = parent
111
- table.insert(inserted, Paths.of(child))
112
- end
113
-
114
- -- Position after parenting, so PrimaryPart and pivot are settled.
115
- if typeof(params.position) == "string" and params.position ~= "" then
116
- local x, y, z = string.match(params.position, "^%s*(-?[%d%.]+)%s*,%s*(-?[%d%.]+)%s*,%s*(-?[%d%.]+)%s*$")
117
- if x then
118
- local target = CFrame.new(tonumber(x) :: number, tonumber(y) :: number, tonumber(z) :: number)
119
- for _, child in children do
120
- pcall(function()
121
- if child:IsA("Model") then
122
- (child :: Model):PivotTo(target)
123
- elseif child:IsA("BasePart") then
124
- (child :: BasePart).CFrame = target
125
- end
126
- end)
127
- end
128
- end
129
- end
130
- end)
131
-
132
- return {
133
- inserted = inserted,
134
- assetId = assetId,
135
- scriptCount = scriptCount,
136
- scripts = if scriptCount > 0 then scriptNames else nil,
137
- undoable = undoable,
138
- }
139
- end
140
-
141
- --[[
142
- Turns editable mesh and image data into content that replicates.
143
-
144
- Anything built in memory -- a generated mesh, a procedurally written image --
145
- is backed by an editable object, and editable objects do not replicate. In
146
- edit mode nothing shows this: the part looks correct and reads correct. Press
147
- Play and the server has it while every client renders a cyan-and-magenta
148
- checkerboard, which is the engine's placeholder for content it could not
149
- send.
150
-
151
- `CreateDataModelContentAsync` is the fix. It bakes the data into static
152
- content that lives in this DataModel session and replicates normally. It does
153
- not upload anything and does not touch the user's account -- the result is
154
- local to the place, and still has to be saved with it.
155
-
156
- Reported per part rather than failing as a batch: one mesh the service
157
- refuses should not cost the twenty that converted, and a part that needed
158
- nothing doing should say so rather than look like a failure.
159
- ]]
160
- function Assets.bake(params: { [string]: any }): { [string]: any }
161
- local paths = params.paths
162
- if typeof(paths) ~= "table" or #(paths :: { any }) == 0 then
163
- Dispatch.fail("BAD_PARAMS", "bake needs a `paths` list.")
164
- end
165
-
166
- local targets: { MeshPart } = {}
167
- for _, path in paths :: { string } do
168
- local instance = Paths.resolve(path)
169
- if instance:IsA("MeshPart") then
170
- table.insert(targets, instance :: MeshPart)
171
- else
172
- -- A model is the natural thing to point at after a generation, so
173
- -- it is walked rather than refused.
174
- for _, descendant in instance:GetDescendants() do
175
- if descendant:IsA("MeshPart") then
176
- table.insert(targets, descendant :: MeshPart)
177
- end
178
- end
179
- end
180
- end
181
-
182
- if #targets == 0 then
183
- Dispatch.fail(
184
- "NOTHING_TO_BAKE",
185
- "None of those paths held a MeshPart.",
186
- "Point at a MeshPart, or at a model containing some."
187
- )
188
- end
189
-
190
- --[[
191
- What kind of content this is, which decides whether baking applies.
192
-
193
- `Object` is an EditableMesh or EditableImage and is the only thing the
194
- service takes. `Uri` is a published asset and already replicates. `None`
195
- is nothing at all.
196
-
197
- `Opaque` is the interesting one, and the reason this is a three-way
198
- answer rather than a boolean. It reads like "in memory, so bake it" and
199
- the service refuses it: "The input content type is not supported by
200
- AssetService::CreateDataModelContentAsync yet!". Generated meshes are
201
- Opaque, so pointing this at a generation used to produce that engine
202
- message once per property per part -- four identical lines for one
203
- chest, none of which said what to do instead. Reported as its own case
204
- now, once, with the actual answer.
205
- ]]
206
- local function kindOf(content: any): string
207
- local ok, source = pcall(function()
208
- return tostring((content :: any).SourceType)
209
- end)
210
- if not ok then
211
- return "none"
212
- end
213
- if string.find(source, "Object") then
214
- return "editable"
215
- elseif string.find(source, "Opaque") then
216
- return "opaque"
217
- end
218
- return "none"
219
- end
220
-
221
- local converted: { string } = {}
222
- local skipped: { string } = {}
223
- local failed: { string } = {}
224
- local opaque = 0
225
-
226
- --[[
227
- Bakes one content value, or says why it could not.
228
-
229
- `CreateDataModelContentAsync` returns a TUPLE -- a
230
- `Enum.CreateContentResult` and then the content -- not the content
231
- alone. Reading the first value as the result is the mistake this
232
- function exists to stop repeating: it produced
233
- `Enum.CreateContentResult.Success` where a Content was wanted, and
234
- assigning an EnumItem to a content property fails, quietly, inside the
235
- pcall that was meant to guard the property not existing.
236
- ]]
237
- local function bakeContent(current: any): (any, string?)
238
- local ok, status, content = pcall(function()
239
- return AssetService:CreateDataModelContentAsync(current)
240
- end)
241
- if not ok then
242
- return nil, tostring(status)
243
- end
244
- if status ~= Enum.CreateContentResult.Success then
245
- return nil, tostring(status)
246
- end
247
- return content, nil
248
- end
249
-
250
- local _, undoable = Undo.record("MCPBake", "MCP bake content", function()
251
- for _, part in targets do
252
- local did = false
253
- local sawOpaque = false
254
- local path = Paths.of(part)
255
-
256
- --[[
257
- The mesh goes on through `ApplyMesh`, not by assignment.
258
-
259
- `MeshContent` cannot be written from a plugin thread at all --
260
- "The current thread cannot write 'MeshContent' (lacking
261
- capability NotAccessible)". Measured. The supported route is to
262
- build a MeshPart around the baked content and apply it, which
263
- the engine allows.
264
-
265
- `ApplyMesh` takes the donor's size with it, so the part would
266
- silently resize on what is meant to be an invisible conversion.
267
- The size is put back.
268
- ]]
269
- local mesh = (part :: any).MeshContent
270
- local meshKind = if mesh == nil then "none" else kindOf(mesh)
271
- if meshKind == "editable" then
272
- local baked, why = bakeContent(mesh)
273
- if baked == nil then
274
- table.insert(failed, string.format("%s (mesh: %s)", path, tostring(why)))
275
- else
276
- local okNew, fresh = pcall(function()
277
- return AssetService:CreateMeshPartAsync(baked, {
278
- CollisionFidelity = part.CollisionFidelity,
279
- })
280
- end)
281
- if not okNew then
282
- table.insert(failed, string.format("%s (mesh: %s)", path, tostring(fresh)))
283
- else
284
- local size = part.Size
285
- local okApply, applyError = pcall(function()
286
- part:ApplyMesh(fresh :: MeshPart)
287
- end)
288
- if okApply then
289
- part.Size = size
290
- did = true
291
- else
292
- table.insert(failed, string.format("%s (mesh: %s)", path, tostring(applyError)))
293
- end
294
- end
295
- end
296
- elseif meshKind == "opaque" then
297
- sawOpaque = true
298
- end
299
-
300
- -- The texture is an ordinary property write, which this thread may do.
301
- local texture = (part :: any).TextureContent
302
- local textureKind = if texture == nil then "none" else kindOf(texture)
303
- if textureKind == "editable" then
304
- local baked, why = bakeContent(texture)
305
- if baked == nil then
306
- table.insert(failed, string.format("%s (texture: %s)", path, tostring(why)))
307
- else
308
- local okSet, setError = pcall(function()
309
- (part :: any).TextureContent = baked
310
- end)
311
- if okSet then
312
- did = true
313
- else
314
- -- Reported, not swallowed. A failed write used to leave
315
- -- `did` false and fall through to "skipped", so a part
316
- -- this tool had failed to convert was announced as one
317
- -- that needed nothing doing.
318
- table.insert(failed, string.format("%s (texture: %s)", path, tostring(setError)))
319
- end
320
- end
321
- elseif textureKind == "opaque" then
322
- sawOpaque = true
323
- end
324
-
325
- if did then
326
- table.insert(converted, path)
327
- elseif sawOpaque then
328
- opaque += 1
329
- else
330
- table.insert(skipped, path)
331
- end
332
- end
333
- end)
334
-
335
- return {
336
- converted = converted,
337
- skipped = skipped,
338
- failed = failed,
339
- opaque = opaque,
340
- examined = #targets,
341
- undoable = undoable,
342
- }
343
- end
344
-
345
- function Assets.register()
346
- Dispatch.registerAll("assets", {
347
- insert = Assets.insert,
348
- bake = Assets.bake,
349
- })
350
- end
351
-
352
- return Assets
1
+ --!strict
2
+ --[[
3
+ Inserting Creator Store assets into the place.
4
+
5
+ The search half of this lives on the server, not here: Node already has
6
+ internet access and Roblox's toolbox endpoints answer unauthenticated, while
7
+ a plugin making outbound HTTP needs the user to grant permission per domain.
8
+ Doing it server-side means search works the moment the server starts, and the
9
+ plugin only ever talks to the one host it already talks to.
10
+
11
+ So this file does the half only Studio can do: turn an asset id into real
12
+ instances in the data model, under a recording so one insert is one Ctrl+Z.
13
+ ]]
14
+
15
+ local AssetService = game:GetService("AssetService")
16
+ local InsertService = game:GetService("InsertService")
17
+
18
+ local Dispatch = require(script.Parent.Parent.Dispatch)
19
+ local Paths = require(script.Parent.Parent.Paths)
20
+ local Undo = require(script.Parent.Parent.Undo)
21
+
22
+ local Assets = {}
23
+
24
+ --[[
25
+ Counts scripts anywhere inside what was inserted.
26
+
27
+ Free models carrying scripts are the oldest hazard on the platform, and the
28
+ agent inserting one has no other way to know. This is reported on every
29
+ insert rather than only when asked, because the caller who most needs to see
30
+ it is the one who did not think to look.
31
+ ]]
32
+ local function countScripts(root: Instance): (number, { string })
33
+ local total = 0
34
+ local names: { string } = {}
35
+ for _, descendant in root:GetDescendants() do
36
+ if descendant:IsA("LuaSourceContainer") then
37
+ total += 1
38
+ if #names < 10 then
39
+ table.insert(names, string.format("%s (%s)", descendant.Name, descendant.ClassName))
40
+ end
41
+ end
42
+ end
43
+ return total, names
44
+ end
45
+
46
+ function Assets.insert(params: { [string]: any }): { [string]: any }
47
+ local assetId = tonumber(params.assetId)
48
+ if assetId == nil or assetId <= 0 then
49
+ Dispatch.fail("BAD_PARAMS", "insert needs a numeric `assetId`.")
50
+ end
51
+
52
+ local parent = if typeof(params.parent) == "string" and params.parent ~= ""
53
+ then Paths.resolve(params.parent)
54
+ else workspace
55
+
56
+ --[[
57
+ `game:GetObjects`, not `InsertService:LoadAsset`.
58
+
59
+ LoadAsset is the documented route and it refuses everything here: every
60
+ public model tried, from several creators, came back "User is not
61
+ authorized to access Asset". It enforces ownership, which makes it useful
62
+ for a game loading its own assets and useless for inserting from the
63
+ Creator Store -- the entire point of this tool.
64
+
65
+ GetObjects is the plugin-security path Studio's own toolbox uses, and it
66
+ loaded the same ids without complaint. It returns an array of top-level
67
+ instances rather than a wrapper model, so there is nothing to unwrap.
68
+ ]]
69
+ local ok, loaded = pcall(function()
70
+ return game:GetObjects("rbxassetid://" .. assetId)
71
+ end)
72
+ if not ok then
73
+ Dispatch.fail(
74
+ "INSERT_FAILED",
75
+ string.format("Could not load asset %d: %s", assetId, tostring(loaded)),
76
+ "The asset may be private, deleted, or not a model."
77
+ )
78
+ end
79
+
80
+ local children = loaded :: { Instance }
81
+ if #children == 0 then
82
+ Dispatch.fail("EMPTY_ASSET", string.format("Asset %d contained nothing.", assetId))
83
+ end
84
+
85
+ -- Counted across every root, since an asset can arrive as several instances
86
+ -- and a script hiding in the second one counts exactly as much.
87
+ local scriptCount = 0
88
+ local scriptNames: { string } = {}
89
+ for _, child in children do
90
+ local count, names = countScripts(child)
91
+ scriptCount += count
92
+ for _, name in names do
93
+ if #scriptNames < 10 then
94
+ table.insert(scriptNames, name)
95
+ end
96
+ end
97
+ if child:IsA("LuaSourceContainer") then
98
+ scriptCount += 1
99
+ if #scriptNames < 10 then
100
+ table.insert(scriptNames, string.format("%s (%s)", child.Name, child.ClassName))
101
+ end
102
+ end
103
+ end
104
+
105
+ local inserted: { string } = {}
106
+ local _, undoable = Undo.record("MCPInsertAsset", "MCP insert asset", function()
107
+ for _, child in children do
108
+ if typeof(params.name) == "string" and params.name ~= "" and #children == 1 then
109
+ child.Name = params.name
110
+ end
111
+ child.Parent = parent
112
+ table.insert(inserted, Paths.of(child))
113
+ end
114
+
115
+ -- Position after parenting, so PrimaryPart and pivot are settled.
116
+ if typeof(params.position) == "string" and params.position ~= "" then
117
+ local x, y, z = string.match(params.position, "^%s*(-?[%d%.]+)%s*,%s*(-?[%d%.]+)%s*,%s*(-?[%d%.]+)%s*$")
118
+ if x then
119
+ local target = CFrame.new(tonumber(x) :: number, tonumber(y) :: number, tonumber(z) :: number)
120
+ for _, child in children do
121
+ pcall(function()
122
+ if child:IsA("Model") then
123
+ (child :: Model):PivotTo(target)
124
+ elseif child:IsA("BasePart") then
125
+ (child :: BasePart).CFrame = target
126
+ end
127
+ end)
128
+ end
129
+ end
130
+ end
131
+ end)
132
+
133
+ return {
134
+ inserted = inserted,
135
+ assetId = assetId,
136
+ scriptCount = scriptCount,
137
+ scripts = if scriptCount > 0 then scriptNames else nil,
138
+ undoable = undoable,
139
+ }
140
+ end
141
+
142
+ --[[
143
+ Turns editable mesh and image data into content that replicates.
144
+
145
+ Anything built in memory -- a generated mesh, a procedurally written image --
146
+ is backed by an editable object, and editable objects do not replicate. In
147
+ edit mode nothing shows this: the part looks correct and reads correct. Press
148
+ Play and the server has it while every client renders a cyan-and-magenta
149
+ checkerboard, which is the engine's placeholder for content it could not
150
+ send.
151
+
152
+ `CreateDataModelContentAsync` is the fix. It bakes the data into static
153
+ content that lives in this DataModel session and replicates normally. It does
154
+ not upload anything and does not touch the user's account -- the result is
155
+ local to the place, and still has to be saved with it.
156
+
157
+ Reported per part rather than failing as a batch: one mesh the service
158
+ refuses should not cost the twenty that converted, and a part that needed
159
+ nothing doing should say so rather than look like a failure.
160
+ ]]
161
+ function Assets.bake(params: { [string]: any }): { [string]: any }
162
+ local paths = params.paths
163
+ if typeof(paths) ~= "table" or #(paths :: { any }) == 0 then
164
+ Dispatch.fail("BAD_PARAMS", "bake needs a `paths` list.")
165
+ end
166
+
167
+ local targets: { MeshPart } = {}
168
+ for _, path in paths :: { string } do
169
+ local instance = Paths.resolve(path)
170
+ if instance:IsA("MeshPart") then
171
+ table.insert(targets, instance :: MeshPart)
172
+ else
173
+ -- A model is the natural thing to point at after a generation, so
174
+ -- it is walked rather than refused.
175
+ for _, descendant in instance:GetDescendants() do
176
+ if descendant:IsA("MeshPart") then
177
+ table.insert(targets, descendant :: MeshPart)
178
+ end
179
+ end
180
+ end
181
+ end
182
+
183
+ if #targets == 0 then
184
+ Dispatch.fail(
185
+ "NOTHING_TO_BAKE",
186
+ "None of those paths held a MeshPart.",
187
+ "Point at a MeshPart, or at a model containing some."
188
+ )
189
+ end
190
+
191
+ --[[
192
+ What kind of content this is, which decides whether baking applies.
193
+
194
+ `Object` is an EditableMesh or EditableImage and is the only thing the
195
+ service takes. `Uri` is a published asset and already replicates. `None`
196
+ is nothing at all.
197
+
198
+ `Opaque` is the interesting one, and the reason this is a three-way
199
+ answer rather than a boolean. It reads like "in memory, so bake it" and
200
+ the service refuses it: "The input content type is not supported by
201
+ AssetService::CreateDataModelContentAsync yet!". Generated meshes are
202
+ Opaque, so pointing this at a generation used to produce that engine
203
+ message once per property per part -- four identical lines for one
204
+ chest, none of which said what to do instead. Reported as its own case
205
+ now, once, with the actual answer.
206
+ ]]
207
+ local function kindOf(content: any): string
208
+ local ok, source = pcall(function()
209
+ return tostring((content :: any).SourceType)
210
+ end)
211
+ if not ok then
212
+ return "none"
213
+ end
214
+ if string.find(source, "Object") then
215
+ return "editable"
216
+ elseif string.find(source, "Opaque") then
217
+ return "opaque"
218
+ end
219
+ return "none"
220
+ end
221
+
222
+ local converted: { string } = {}
223
+ local skipped: { string } = {}
224
+ local failed: { string } = {}
225
+ local opaque = 0
226
+
227
+ --[[
228
+ Bakes one content value, or says why it could not.
229
+
230
+ `CreateDataModelContentAsync` returns a TUPLE -- a
231
+ `Enum.CreateContentResult` and then the content -- not the content
232
+ alone. Reading the first value as the result is the mistake this
233
+ function exists to stop repeating: it produced
234
+ `Enum.CreateContentResult.Success` where a Content was wanted, and
235
+ assigning an EnumItem to a content property fails, quietly, inside the
236
+ pcall that was meant to guard the property not existing.
237
+ ]]
238
+ local function bakeContent(current: any): (any, string?)
239
+ local ok, status, content = pcall(function()
240
+ return AssetService:CreateDataModelContentAsync(current)
241
+ end)
242
+ if not ok then
243
+ return nil, tostring(status)
244
+ end
245
+ if status ~= Enum.CreateContentResult.Success then
246
+ return nil, tostring(status)
247
+ end
248
+ return content, nil
249
+ end
250
+
251
+ local _, undoable = Undo.record("MCPBake", "MCP bake content", function()
252
+ for _, part in targets do
253
+ local did = false
254
+ local sawOpaque = false
255
+ local path = Paths.of(part)
256
+
257
+ --[[
258
+ The mesh goes on through `ApplyMesh`, not by assignment.
259
+
260
+ `MeshContent` cannot be written from a plugin thread at all --
261
+ "The current thread cannot write 'MeshContent' (lacking
262
+ capability NotAccessible)". Measured. The supported route is to
263
+ build a MeshPart around the baked content and apply it, which
264
+ the engine allows.
265
+
266
+ `ApplyMesh` takes the donor's size with it, so the part would
267
+ silently resize on what is meant to be an invisible conversion.
268
+ The size is put back.
269
+ ]]
270
+ local mesh = (part :: any).MeshContent
271
+ local meshKind = if mesh == nil then "none" else kindOf(mesh)
272
+ if meshKind == "editable" then
273
+ local baked, why = bakeContent(mesh)
274
+ if baked == nil then
275
+ table.insert(failed, string.format("%s (mesh: %s)", path, tostring(why)))
276
+ else
277
+ local okNew, fresh = pcall(function()
278
+ return AssetService:CreateMeshPartAsync(baked, {
279
+ CollisionFidelity = part.CollisionFidelity,
280
+ })
281
+ end)
282
+ if not okNew then
283
+ table.insert(failed, string.format("%s (mesh: %s)", path, tostring(fresh)))
284
+ else
285
+ local size = part.Size
286
+ local okApply, applyError = pcall(function()
287
+ part:ApplyMesh(fresh :: MeshPart)
288
+ end)
289
+ if okApply then
290
+ part.Size = size
291
+ did = true
292
+ else
293
+ table.insert(failed, string.format("%s (mesh: %s)", path, tostring(applyError)))
294
+ end
295
+ end
296
+ end
297
+ elseif meshKind == "opaque" then
298
+ sawOpaque = true
299
+ end
300
+
301
+ -- The texture is an ordinary property write, which this thread may do.
302
+ local texture = (part :: any).TextureContent
303
+ local textureKind = if texture == nil then "none" else kindOf(texture)
304
+ if textureKind == "editable" then
305
+ local baked, why = bakeContent(texture)
306
+ if baked == nil then
307
+ table.insert(failed, string.format("%s (texture: %s)", path, tostring(why)))
308
+ else
309
+ local okSet, setError = pcall(function()
310
+ (part :: any).TextureContent = baked
311
+ end)
312
+ if okSet then
313
+ did = true
314
+ else
315
+ -- Reported, not swallowed. A failed write used to leave
316
+ -- `did` false and fall through to "skipped", so a part
317
+ -- this tool had failed to convert was announced as one
318
+ -- that needed nothing doing.
319
+ table.insert(failed, string.format("%s (texture: %s)", path, tostring(setError)))
320
+ end
321
+ end
322
+ elseif textureKind == "opaque" then
323
+ sawOpaque = true
324
+ end
325
+
326
+ if did then
327
+ table.insert(converted, path)
328
+ elseif sawOpaque then
329
+ opaque += 1
330
+ else
331
+ table.insert(skipped, path)
332
+ end
333
+ end
334
+ end)
335
+
336
+ return {
337
+ converted = converted,
338
+ skipped = skipped,
339
+ failed = failed,
340
+ opaque = opaque,
341
+ examined = #targets,
342
+ undoable = undoable,
343
+ }
344
+ end
345
+
346
+ --[[
347
+ The audio library, which the Creator Store index answers badly.
348
+
349
+ `assets op="search" category="audio"` used to go through the same public
350
+ index the model search uses, and it came back with a name, a creator and a
351
+ vote ratio -- none of which is how anyone chooses a sound. The engine has a
352
+ purpose-built search for this, and it returns the fields that actually
353
+ decide: how long the clip is, whether it is music or a sound effect, who
354
+ performed it, and whether Roblox endorses it.
355
+
356
+ Measured against a live session: "footstep" returns 30 results carrying
357
+ Title, Artist, Duration, AudioType, Tags and IsEndorsed. Duration alone is
358
+ the difference between a footstep and a three-minute track that happens to
359
+ mention footsteps in its description -- and the old path could not tell them
360
+ apart.
361
+ ]]
362
+ function Assets.audio(params: { [string]: any }): { [string]: any }
363
+ local keyword = params.keyword
364
+ if typeof(keyword) ~= "string" or keyword == "" then
365
+ Dispatch.fail("BAD_PARAMS", "audio search needs a `keyword`.")
366
+ end
367
+ local limit = math.clamp(tonumber(params.limit) or 10, 1, 50)
368
+
369
+ local okParams, search = pcall(function()
370
+ local p = Instance.new("AudioSearchParams")
371
+ p.SearchKeyword = keyword
372
+ --[[
373
+ Both bounds are optional and both are worth passing through. A search
374
+ for a footstep wants something under two seconds; a search for
375
+ background music wants the opposite, and without a length filter the
376
+ two queries return the same list.
377
+ ]]
378
+ local minimum = tonumber(params.minDuration)
379
+ local maximum = tonumber(params.maxDuration)
380
+ if minimum ~= nil then
381
+ (p :: any).MinDuration = math.max(0, minimum)
382
+ end
383
+ if maximum ~= nil then
384
+ (p :: any).MaxDuration = math.max(0, maximum)
385
+ end
386
+ --[[
387
+ Always set, never left to the engine.
388
+
389
+ `AudioSubType` defaults to Music, and that default is a trap rather
390
+ than a preference: measured, a search for "footstep" with the default
391
+ returns "Silent Footsteps II" at 153 seconds, "Footsteps in the Dark"
392
+ at 268, and thirty more like them -- ambient tracks whose titles
393
+ mention footsteps. The same search as SoundEffect returns
394
+ "JjBg_Grass_Footstep_6" at one second.
395
+
396
+ Nothing about the empty result said "you searched the music library".
397
+ So the default here is SoundEffect, which is what someone typing a
398
+ noise into a game engine means, and Music is something you ask for.
399
+ ]]
400
+ local subType = if typeof(params.audioType) == "string" and params.audioType ~= ""
401
+ then params.audioType
402
+ else "SoundEffect"
403
+ ;(p :: any).AudioSubType = (Enum :: any).AudioSubType[subType]
404
+ return p
405
+ end)
406
+ if not okParams then
407
+ Dispatch.fail(
408
+ "BAD_PARAMS",
409
+ string.format("Those search parameters were refused: %s", tostring(search)),
410
+ 'audioType must be one of the Enum.AudioSubType names, e.g. "Music" or "SoundEffect".'
411
+ )
412
+ end
413
+
414
+ local okSearch, pages = pcall(function()
415
+ return (AssetService :: any):SearchAudioAsync(search)
416
+ end)
417
+ if not okSearch then
418
+ Dispatch.fail(
419
+ "AUDIO_SEARCH_FAILED",
420
+ string.format("The audio search failed: %s", tostring(pages))
421
+ )
422
+ end
423
+
424
+ local items: { { [string]: any } } = {}
425
+ local page = pages:GetCurrentPage()
426
+ for _, entry in page do
427
+ if #items >= limit then
428
+ break
429
+ end
430
+ local row = entry :: { [string]: any }
431
+ table.insert(items, {
432
+ assetId = tostring(row.Id),
433
+ title = tostring(row.Title),
434
+ artist = if row.Artist ~= nil and tostring(row.Artist) ~= "" then tostring(row.Artist) else nil,
435
+ -- Seconds, rounded. The API returns a float and nobody picking a
436
+ -- sound effect cares about the third decimal place.
437
+ duration = math.round(tonumber(row.Duration) or 0),
438
+ audioType = (tostring(row.AudioType):gsub("Enum%.AudioSubType%.", "")),
439
+ endorsed = row.IsEndorsed == true,
440
+ })
441
+ end
442
+
443
+ return {
444
+ items = items,
445
+ count = #items,
446
+ keyword = keyword,
447
+ audioType = if typeof(params.audioType) == "string" and params.audioType ~= ""
448
+ then params.audioType
449
+ else "SoundEffect",
450
+ }
451
+ end
452
+
453
+ --[[
454
+ Looks inside a published asset without putting it in the place.
455
+
456
+ `insert` already reports what came in, and reporting it afterwards is one
457
+ Ctrl+Z too late to be reassuring: the scripts are in the data model by then,
458
+ and the model may have parented things outside itself on the way in.
459
+ `LoadAssetAsync` builds the asset in memory instead, which is where it can be
460
+ read and then dropped.
461
+
462
+ So this is the safe half of the existing script warning. The Creator Store
463
+ index says whether an asset has scripts at all; this says which ones, what
464
+ class they are, and what else is in there -- before anything is committed.
465
+
466
+ The loaded container is destroyed on every path. It is never parented, so it
467
+ is invisible to the data model, to undo history and to the user.
468
+ ]]
469
+ function Assets.peek(params: { [string]: any }): { [string]: any }
470
+ local assetId = tonumber(params.assetId)
471
+ if assetId == nil or assetId <= 0 then
472
+ Dispatch.fail("BAD_PARAMS", "peek needs a numeric `assetId`.")
473
+ end
474
+
475
+ --[[
476
+ Loaded the way `insert` loads, not the way it is tempting to.
477
+
478
+ `AssetService:LoadAssetAsync` enforces ownership: it opens assets the
479
+ signed-in user owns or has been given, and refuses everything else with
480
+ "User is not authorized to access Asset". `InsertService:LoadAsset` does
481
+ not -- it is what the Toolbox itself uses, and it reaches any public
482
+ model.
483
+
484
+ Using the first one made this tool useless at the one job it exists for.
485
+ Measured: a free door from the Creator Store, two scripts inside it,
486
+ refused by `peek` and inserted by `insert` moments later. The SAFE
487
+ preview was weaker than the unsafe insert, so the only way to see what
488
+ was in a model was to put it in the place first -- which is precisely
489
+ what `peek` was written to avoid.
490
+
491
+ Nothing is parented here, so reaching further costs nothing: the model is
492
+ read in memory and destroyed before this function returns.
493
+ ]]
494
+ --[[
495
+ `game:GetObjects`, the same loader `insert` uses -- see the note there.
496
+
497
+ Two wrong loaders were tried before this one, and both failed the same
498
+ way: `AssetService:LoadAssetAsync` and `InsertService:LoadAsset` each
499
+ enforce ownership and refuse any Creator Store model with "User is not
500
+ authorized to access Asset". That made the SAFE preview weaker than the
501
+ unsafe insert -- measured, a free door with two scripts in it was refused
502
+ by `peek` and inserted by `insert` moments later, so the only way to see
503
+ inside a model was to put it in the place first.
504
+
505
+ `GetObjects` is the plugin-security path Studio's own toolbox uses and it
506
+ reaches everything `insert` reaches, which is the only correct answer
507
+ here: a preview that cannot see what the insert would bring in is not a
508
+ preview of anything.
509
+
510
+ It returns top-level instances rather than a wrapper model, so they are
511
+ walked directly.
512
+ ]]
513
+ local ok, loaded = pcall(function()
514
+ return game:GetObjects("rbxassetid://" .. assetId)
515
+ end)
516
+ if not ok then
517
+ Dispatch.fail(
518
+ "ASSET_UNAVAILABLE",
519
+ string.format("Could not load asset %d: %s", assetId, tostring(loaded)),
520
+ "The asset may be private, deleted, or not a model."
521
+ )
522
+ end
523
+
524
+ -- An array of roots, not one wrapper: `GetObjects` hands back whatever the
525
+ -- asset holds at its top level.
526
+ local roots = loaded :: { Instance }
527
+ local classes: { [string]: number } = {}
528
+ local scripts: { string } = {}
529
+ local total = 0
530
+
531
+ for _, root in roots do
532
+ total += 1
533
+ classes[root.ClassName] = (classes[root.ClassName] or 0) + 1
534
+ if root:IsA("LuaSourceContainer") and #scripts < 25 then
535
+ table.insert(scripts, string.format("%s (%s)", root.Name, root.ClassName))
536
+ end
537
+ for _, descendant in root:GetDescendants() do
538
+ total += 1
539
+ classes[descendant.ClassName] = (classes[descendant.ClassName] or 0) + 1
540
+ if descendant:IsA("LuaSourceContainer") and #scripts < 25 then
541
+ table.insert(scripts, string.format("%s (%s)", descendant:GetFullName(), descendant.ClassName))
542
+ end
543
+ end
544
+ end
545
+
546
+ local breakdown: { { [string]: any } } = {}
547
+ for className, count in classes do
548
+ table.insert(breakdown, { className = className, count = count })
549
+ end
550
+ table.sort(breakdown, function(a, b)
551
+ if a.count == b.count then
552
+ return a.className < b.className
553
+ end
554
+ return a.count > b.count
555
+ end)
556
+
557
+ local names: { string } = {}
558
+ for _, root in roots do
559
+ table.insert(names, string.format("%s (%s)", root.Name, root.ClassName))
560
+ end
561
+
562
+ -- Destroyed before returning, not after: the reply is built from plain
563
+ -- strings and numbers, so nothing in it outlives the instances it came from.
564
+ for _, root in roots do
565
+ root:Destroy()
566
+ end
567
+
568
+ return {
569
+ assetId = assetId,
570
+ roots = names,
571
+ descendants = total,
572
+ classes = breakdown,
573
+ scripts = scripts,
574
+ scriptCount = #scripts,
575
+ }
576
+ end
577
+
578
+ function Assets.register()
579
+ Dispatch.registerAll("assets", {
580
+ audio = Assets.audio,
581
+ peek = Assets.peek,
582
+ insert = Assets.insert,
583
+ bake = Assets.bake,
584
+ })
585
+ end
586
+
587
+ return Assets