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