@el4cteo/rbx-studio-mcp 0.4.1 → 0.4.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/README.md +211 -211
- package/dist/bridge/api.js +3 -0
- package/dist/bridge/api.js.map +1 -1
- package/dist/bridge/failover.js +13 -0
- package/dist/bridge/failover.js.map +1 -1
- package/dist/bridge/remote.js +15 -1
- package/dist/bridge/remote.js.map +1 -1
- package/dist/bridge/rpc.js +35 -6
- package/dist/bridge/rpc.js.map +1 -1
- package/dist/bridge/server.js +55 -7
- package/dist/bridge/server.js.map +1 -1
- package/dist/doctor.js +160 -0
- package/dist/doctor.js.map +1 -0
- package/dist/index.js +29 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/protocol.js.map +1 -1
- package/dist/tools/exec.js +53 -3
- package/dist/tools/exec.js.map +1 -1
- package/dist/tools/generate.js +120 -0
- package/dist/tools/generate.js.map +1 -0
- package/dist/tools/scripts.js +20 -1
- package/dist/tools/scripts.js.map +1 -1
- package/dist/tools/world.js +203 -20
- package/dist/tools/world.js.map +1 -1
- package/package.json +5 -4
- package/plugin/src/Config.luau +1 -1
- package/plugin/src/Console.luau +233 -9
- package/plugin/src/Transport.luau +6 -1
- package/plugin/src/Visuals.luau +52 -1
- package/plugin/src/handlers/Assets.luau +352 -145
- package/plugin/src/handlers/Generate.luau +386 -0
- package/plugin/src/handlers/Geometry.luau +170 -0
- package/plugin/src/handlers/Scripts.luau +79 -1
- package/plugin/src/handlers/Viewport.luau +416 -302
- package/plugin/src/handlers/World.luau +15 -5
- package/plugin/src/init.server.luau +725 -636
- package/scripts/install-plugin.mjs +25 -2
- package/scripts/test-bridge.mjs +56 -0
- package/scripts/test-failover.mjs +136 -95
|
@@ -1,145 +1,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
|
|
16
|
-
|
|
17
|
-
local
|
|
18
|
-
|
|
19
|
-
local
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
end
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
(child ::
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|