@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.
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/tools/anim.js +159 -0
- package/dist/tools/anim.js.map +1 -0
- package/dist/tools/character.js +95 -5
- package/dist/tools/character.js.map +1 -1
- package/dist/tools/data.js +173 -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 +48 -1
- 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 +51 -3
- package/dist/tools/scripts.js.map +1 -1
- package/dist/tools/world.js +317 -44
- package/dist/tools/world.js.map +1 -1
- package/package.json +74 -74
- package/plugin/src/Commands.luau +622 -622
- 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 +164 -16
- package/plugin/src/Png.luau +8 -4
- package/plugin/src/Serialize.luau +499 -327
- package/plugin/src/Undo.luau +94 -6
- package/plugin/src/handlers/Anim.luau +897 -0
- package/plugin/src/handlers/Assets.luau +587 -352
- 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 +127 -0
- 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/Viewport.luau +268 -0
- package/plugin/src/handlers/World.luau +89 -15
- package/plugin/src/init.server.luau +879 -875
- package/scripts/build-plugin.mjs +20 -0
- 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
|
-
|
|
18
|
-
local
|
|
19
|
-
local
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
local
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
"
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
--
|
|
86
|
-
|
|
87
|
-
local
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
local
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
--
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
"
|
|
186
|
-
"
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
is
|
|
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
|
-
local
|
|
223
|
-
local
|
|
224
|
-
local
|
|
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
|
-
local
|
|
254
|
-
local
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
the
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
local
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
local
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
local
|
|
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
|
-
|
|
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
|