@chrrxs/robloxstudio-mcp 2.22.4 → 2.23.0
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 +5107 -3632
- package/package.json +1 -1
- package/studio-plugin/.Carbon.rbxm.lock +0 -0
- package/studio-plugin/Carbon.rbxm +0 -0
- package/studio-plugin/INSTALLATION.md +4 -0
- package/studio-plugin/MCPInspectorPlugin.rbxmx +345 -79
- package/studio-plugin/MCPPlugin.rbxmx +345 -79
- package/studio-plugin/src/modules/AssetSanitizationPolicy.ts +127 -0
- package/studio-plugin/src/modules/handlers/AssetHandlers.ts +179 -29
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
export interface ImportedAssetInstance {
|
|
2
|
+
IsA(className: string): boolean;
|
|
3
|
+
GetDescendants(): ImportedAssetInstance[];
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export interface ForbiddenImportScan {
|
|
7
|
+
scripts: ImportedAssetInstance[];
|
|
8
|
+
packageLinks: ImportedAssetInstance[];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface AssetSanitizationOperations {
|
|
12
|
+
forceUnparent: (root: ImportedAssetInstance) => boolean;
|
|
13
|
+
destroy: (instance: ImportedAssetInstance) => boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface AssetSanitizationResult {
|
|
17
|
+
success: boolean;
|
|
18
|
+
removedScriptCount: number;
|
|
19
|
+
removedPackageLinkCount: number;
|
|
20
|
+
remainingScriptCount: number;
|
|
21
|
+
remainingPackageLinkCount: number;
|
|
22
|
+
error?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function countInstances(instances: ImportedAssetInstance[]): number {
|
|
26
|
+
let count = 0;
|
|
27
|
+
for (const _instance of instances) count++;
|
|
28
|
+
return count;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function scanForbiddenImportedInstances(root: ImportedAssetInstance): ForbiddenImportScan {
|
|
32
|
+
const scripts: ImportedAssetInstance[] = [];
|
|
33
|
+
const packageLinks: ImportedAssetInstance[] = [];
|
|
34
|
+
|
|
35
|
+
function inspect(instance: ImportedAssetInstance) {
|
|
36
|
+
// IsA("LuaSourceContainer") covers Script, LocalScript, ModuleScript,
|
|
37
|
+
// and any future script-bearing subclass. Names, source, creator,
|
|
38
|
+
// reputation, and hierarchy depth are deliberately irrelevant.
|
|
39
|
+
if (instance.IsA("LuaSourceContainer")) {
|
|
40
|
+
scripts.push(instance);
|
|
41
|
+
}
|
|
42
|
+
if (instance.IsA("PackageLink")) {
|
|
43
|
+
packageLinks.push(instance);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
inspect(root);
|
|
48
|
+
// GetDescendants performs an engine-level, unlimited-depth traversal.
|
|
49
|
+
// Never replace this security scan with a depth-limited tree walk.
|
|
50
|
+
for (const descendant of root.GetDescendants()) {
|
|
51
|
+
inspect(descendant);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return { scripts, packageLinks };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function sanitizeLoadedAsset(
|
|
58
|
+
root: ImportedAssetInstance,
|
|
59
|
+
operations: AssetSanitizationOperations,
|
|
60
|
+
): AssetSanitizationResult {
|
|
61
|
+
if (!operations.forceUnparent(root)) {
|
|
62
|
+
operations.destroy(root);
|
|
63
|
+
return {
|
|
64
|
+
success: false,
|
|
65
|
+
removedScriptCount: 0,
|
|
66
|
+
removedPackageLinkCount: 0,
|
|
67
|
+
remainingScriptCount: 0,
|
|
68
|
+
remainingPackageLinkCount: 0,
|
|
69
|
+
error: "Loaded asset could not be kept unparented for sanitization.",
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const firstScan = scanForbiddenImportedInstances(root);
|
|
74
|
+
const firstScriptCount = countInstances(firstScan.scripts);
|
|
75
|
+
const firstPackageLinkCount = countInstances(firstScan.packageLinks);
|
|
76
|
+
|
|
77
|
+
// A forbidden root cannot be removed while preserving an import wrapper.
|
|
78
|
+
// Destroy the whole load and fail closed.
|
|
79
|
+
if (root.IsA("LuaSourceContainer") || root.IsA("PackageLink")) {
|
|
80
|
+
const rootWasScript = root.IsA("LuaSourceContainer");
|
|
81
|
+
const rootWasPackageLink = root.IsA("PackageLink");
|
|
82
|
+
operations.destroy(root);
|
|
83
|
+
return {
|
|
84
|
+
success: false,
|
|
85
|
+
removedScriptCount: firstScriptCount,
|
|
86
|
+
removedPackageLinkCount: firstPackageLinkCount,
|
|
87
|
+
remainingScriptCount: rootWasScript ? 1 : 0,
|
|
88
|
+
remainingPackageLinkCount: rootWasPackageLink ? 1 : 0,
|
|
89
|
+
error: "Loaded asset root is a forbidden executable or package-link instance.",
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
let removedScriptCount = 0;
|
|
94
|
+
for (const scriptInstance of firstScan.scripts) {
|
|
95
|
+
if (operations.destroy(scriptInstance)) removedScriptCount++;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
let removedPackageLinkCount = 0;
|
|
99
|
+
for (const packageLink of firstScan.packageLinks) {
|
|
100
|
+
if (operations.destroy(packageLink)) removedPackageLinkCount++;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Mandatory second unlimited-depth scan. If any forbidden instance
|
|
104
|
+
// survived, destroy the entire imported root and insert nothing.
|
|
105
|
+
const verificationScan = scanForbiddenImportedInstances(root);
|
|
106
|
+
const remainingScriptCount = countInstances(verificationScan.scripts);
|
|
107
|
+
const remainingPackageLinkCount = countInstances(verificationScan.packageLinks);
|
|
108
|
+
if (remainingScriptCount > 0 || remainingPackageLinkCount > 0) {
|
|
109
|
+
operations.destroy(root);
|
|
110
|
+
return {
|
|
111
|
+
success: false,
|
|
112
|
+
removedScriptCount,
|
|
113
|
+
removedPackageLinkCount,
|
|
114
|
+
remainingScriptCount,
|
|
115
|
+
remainingPackageLinkCount,
|
|
116
|
+
error: "Asset sanitization verification failed; the entire imported asset was destroyed.",
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
success: true,
|
|
122
|
+
removedScriptCount,
|
|
123
|
+
removedPackageLinkCount,
|
|
124
|
+
remainingScriptCount: 0,
|
|
125
|
+
remainingPackageLinkCount: 0,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import Utils from "../Utils";
|
|
2
2
|
import Recording from "../Recording";
|
|
3
|
+
import {
|
|
4
|
+
sanitizeLoadedAsset,
|
|
5
|
+
scanForbiddenImportedInstances,
|
|
6
|
+
type AssetSanitizationOperations,
|
|
7
|
+
type ImportedAssetInstance,
|
|
8
|
+
} from "../AssetSanitizationPolicy";
|
|
3
9
|
|
|
4
10
|
const AssetService = game.GetService("AssetService");
|
|
5
11
|
const ChangeHistoryService = game.GetService("ChangeHistoryService");
|
|
@@ -8,6 +14,41 @@ const Selection = game.GetService("Selection");
|
|
|
8
14
|
const { getInstancePath, getInstanceByPath } = Utils;
|
|
9
15
|
const { beginRecording, finishRecording } = Recording;
|
|
10
16
|
|
|
17
|
+
const THIRD_PARTY_ASSET_SETTING_HINT =
|
|
18
|
+
'\nTo load public Creator Store assets that you do not own, enable "Allow Loading Third Party Assets" in Game Settings > Security.';
|
|
19
|
+
|
|
20
|
+
function destroyImportedRoot(root: Instance) {
|
|
21
|
+
pcall(() => {
|
|
22
|
+
root.Destroy();
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function formatAssetLoadFailure(assetId: number, loadError: unknown): string {
|
|
27
|
+
const [settingReadable, allowInsertFreeAssets] = pcall(() => {
|
|
28
|
+
return (AssetService as unknown as { AllowInsertFreeAssets: boolean }).AllowInsertFreeAssets;
|
|
29
|
+
});
|
|
30
|
+
const settingHint = settingReadable && allowInsertFreeAssets === true
|
|
31
|
+
? ""
|
|
32
|
+
: THIRD_PARTY_ASSET_SETTING_HINT;
|
|
33
|
+
return `Failed to load asset ${assetId}: ${tostring(loadError)}${settingHint}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const sanitizationOperations: AssetSanitizationOperations = {
|
|
37
|
+
forceUnparent: (root: ImportedAssetInstance) => {
|
|
38
|
+
const instance = root as Instance;
|
|
39
|
+
const [unparentedOk] = pcall(() => {
|
|
40
|
+
instance.Parent = undefined;
|
|
41
|
+
});
|
|
42
|
+
return unparentedOk && instance.Parent === undefined;
|
|
43
|
+
},
|
|
44
|
+
destroy: (instance: ImportedAssetInstance) => {
|
|
45
|
+
const [destroyed] = pcall(() => {
|
|
46
|
+
(instance as Instance).Destroy();
|
|
47
|
+
});
|
|
48
|
+
return destroyed;
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
|
|
11
52
|
function insertAsset(requestData: Record<string, unknown>) {
|
|
12
53
|
const assetId = requestData.assetId as number;
|
|
13
54
|
const parentPath = (requestData.parentPath as string) ?? "game.Workspace";
|
|
@@ -25,21 +66,29 @@ function insertAsset(requestData: Record<string, unknown>) {
|
|
|
25
66
|
const recordingId = beginRecording(`Insert asset ${assetId}`);
|
|
26
67
|
|
|
27
68
|
let wrapperModel: Instance | undefined;
|
|
69
|
+
const insertedInstances: Instance[] = [];
|
|
28
70
|
const [insertSuccess, insertResult] = pcall(() => {
|
|
29
|
-
const
|
|
71
|
+
const [loadSuccess, loadResult] = pcall(() => {
|
|
72
|
+
return (AssetService as unknown as { LoadAssetAsync(id: number): Instance }).LoadAssetAsync(assetId);
|
|
73
|
+
});
|
|
74
|
+
if (!loadSuccess || !loadResult) {
|
|
75
|
+
error(formatAssetLoadFailure(assetId, loadResult), 0);
|
|
76
|
+
}
|
|
77
|
+
const loadedWrapper = loadResult as Instance;
|
|
30
78
|
wrapperModel = loadedWrapper;
|
|
31
79
|
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
for (const child of children) {
|
|
36
|
-
child.Parent = parentInstance;
|
|
37
|
-
insertedInstances.push(child);
|
|
80
|
+
const sanitization = sanitizeLoadedAsset(loadedWrapper, sanitizationOperations);
|
|
81
|
+
if (!sanitization.success) {
|
|
82
|
+
error(sanitization.error ?? "Asset sanitization failed.", 0);
|
|
38
83
|
}
|
|
39
84
|
|
|
85
|
+
const children = loadedWrapper.GetChildren();
|
|
86
|
+
|
|
87
|
+
// Position while every sanitized child is still contained by the
|
|
88
|
+
// unparented wrapper. Nothing reaches the DataModel before verification.
|
|
40
89
|
if (position) {
|
|
41
90
|
const pos = new Vector3(position.x ?? 0, position.y ?? 0, position.z ?? 0);
|
|
42
|
-
for (const inst of
|
|
91
|
+
for (const inst of children) {
|
|
43
92
|
if (inst.IsA("BasePart")) {
|
|
44
93
|
inst.Position = pos;
|
|
45
94
|
} else if (inst.IsA("Model")) {
|
|
@@ -55,6 +104,13 @@ function insertAsset(requestData: Record<string, unknown>) {
|
|
|
55
104
|
}
|
|
56
105
|
}
|
|
57
106
|
|
|
107
|
+
// This is the first point at which imported content is parented into
|
|
108
|
+
// Studio. Both unlimited-depth scans have already passed.
|
|
109
|
+
for (const child of children) {
|
|
110
|
+
child.Parent = parentInstance;
|
|
111
|
+
insertedInstances.push(child);
|
|
112
|
+
}
|
|
113
|
+
|
|
58
114
|
pcall(() => {
|
|
59
115
|
Selection.Set(insertedInstances);
|
|
60
116
|
});
|
|
@@ -71,9 +127,23 @@ function insertAsset(requestData: Record<string, unknown>) {
|
|
|
71
127
|
parentPath,
|
|
72
128
|
insertedCount: insertedInstances.size(),
|
|
73
129
|
instances: resultInstances,
|
|
130
|
+
sanitization: {
|
|
131
|
+
removedScriptCount: sanitization.removedScriptCount,
|
|
132
|
+
removedPackageLinkCount: sanitization.removedPackageLinkCount,
|
|
133
|
+
verifiedClean: true,
|
|
134
|
+
},
|
|
74
135
|
};
|
|
75
136
|
});
|
|
76
137
|
|
|
138
|
+
if (!insertSuccess) {
|
|
139
|
+
// Roll back any partially-parented children if a later operation failed.
|
|
140
|
+
for (const inserted of insertedInstances) {
|
|
141
|
+
pcall(() => {
|
|
142
|
+
inserted.Destroy();
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
77
147
|
if (wrapperModel) {
|
|
78
148
|
pcall(() => {
|
|
79
149
|
wrapperModel!.Destroy();
|
|
@@ -91,8 +161,8 @@ function insertAsset(requestData: Record<string, unknown>) {
|
|
|
91
161
|
|
|
92
162
|
function previewAsset(requestData: Record<string, unknown>) {
|
|
93
163
|
const assetId = requestData.assetId as number;
|
|
94
|
-
const includeProperties = (requestData.includeProperties as boolean) ??
|
|
95
|
-
const maxDepth = (requestData.maxDepth as number) ??
|
|
164
|
+
const includeProperties = (requestData.includeProperties as boolean) ?? false;
|
|
165
|
+
const maxDepth = (requestData.maxDepth as number) ?? 4;
|
|
96
166
|
|
|
97
167
|
if (!assetId) {
|
|
98
168
|
return { error: "assetId is required" };
|
|
@@ -103,27 +173,100 @@ function previewAsset(requestData: Record<string, unknown>) {
|
|
|
103
173
|
});
|
|
104
174
|
|
|
105
175
|
if (!loadSuccess || !wrapperModel) {
|
|
106
|
-
return { error:
|
|
176
|
+
return { error: formatAssetLoadFailure(assetId, wrapperModel) };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Previewed assets never enter the DataModel.
|
|
180
|
+
const [unparentedOk] = pcall(() => {
|
|
181
|
+
(wrapperModel as Instance).Parent = undefined;
|
|
182
|
+
});
|
|
183
|
+
if (!unparentedOk || (wrapperModel as Instance).Parent !== undefined) {
|
|
184
|
+
destroyImportedRoot(wrapperModel as Instance);
|
|
185
|
+
return { error: `Failed to keep asset ${assetId} unparented for preview` };
|
|
107
186
|
}
|
|
108
187
|
|
|
109
|
-
//
|
|
188
|
+
// Security and visual-capability tracking always scan the full descendant
|
|
189
|
+
// hierarchy. maxDepth only affects the display tree below.
|
|
110
190
|
let totalInstances = 0;
|
|
111
191
|
const classCounts: Record<string, number> = {};
|
|
112
|
-
let hasScripts = false;
|
|
113
192
|
let hasAnimations = false;
|
|
114
193
|
let hasSounds = false;
|
|
115
194
|
let hasParticles = false;
|
|
116
|
-
|
|
117
|
-
|
|
195
|
+
let hasVfx = false;
|
|
196
|
+
let hasDecalsOrTextures = false;
|
|
197
|
+
let hasMeshes = false;
|
|
198
|
+
let hasLights = false;
|
|
199
|
+
let hasAttachments = false;
|
|
200
|
+
const soundReferences: Record<string, unknown>[] = [];
|
|
201
|
+
const uniqueSoundContentIds = new Set<string>();
|
|
202
|
+
|
|
203
|
+
function recordSummary(instance: Instance) {
|
|
118
204
|
totalInstances++;
|
|
119
|
-
|
|
120
205
|
const className = instance.ClassName;
|
|
121
206
|
classCounts[className] = (classCounts[className] ?? 0) + 1;
|
|
122
207
|
|
|
123
|
-
if (instance.IsA("LuaSourceContainer")) hasScripts = true;
|
|
124
208
|
if (className === "Animation" || className === "AnimationController" || className === "Animator") hasAnimations = true;
|
|
125
|
-
if (instance.IsA("Sound"))
|
|
126
|
-
|
|
209
|
+
if (instance.IsA("Sound")) {
|
|
210
|
+
hasSounds = true;
|
|
211
|
+
const sound = instance as Sound;
|
|
212
|
+
const soundId = sound.SoundId;
|
|
213
|
+
if (soundId !== "") uniqueSoundContentIds.add(soundId);
|
|
214
|
+
soundReferences.push({
|
|
215
|
+
path: sound.GetFullName(),
|
|
216
|
+
name: sound.Name,
|
|
217
|
+
className,
|
|
218
|
+
soundId,
|
|
219
|
+
volume: sound.Volume,
|
|
220
|
+
playbackSpeed: sound.PlaybackSpeed,
|
|
221
|
+
looped: sound.Looped,
|
|
222
|
+
isLoaded: sound.IsLoaded,
|
|
223
|
+
timeLength: sound.TimeLength,
|
|
224
|
+
rollOffMode: tostring(sound.RollOffMode),
|
|
225
|
+
rollOffMinDistance: sound.RollOffMinDistance,
|
|
226
|
+
rollOffMaxDistance: sound.RollOffMaxDistance,
|
|
227
|
+
});
|
|
228
|
+
} else if (instance.IsA("AudioPlayer")) {
|
|
229
|
+
hasSounds = true;
|
|
230
|
+
const audioPlayer = instance as AudioPlayer;
|
|
231
|
+
const soundId = tostring(audioPlayer.Asset);
|
|
232
|
+
if (soundId !== "") uniqueSoundContentIds.add(soundId);
|
|
233
|
+
soundReferences.push({
|
|
234
|
+
path: audioPlayer.GetFullName(),
|
|
235
|
+
name: audioPlayer.Name,
|
|
236
|
+
className,
|
|
237
|
+
soundId,
|
|
238
|
+
volume: audioPlayer.Volume,
|
|
239
|
+
playbackSpeed: audioPlayer.PlaybackSpeed,
|
|
240
|
+
looped: audioPlayer.Looping,
|
|
241
|
+
autoPlay: audioPlayer.AutoPlay,
|
|
242
|
+
isLoaded: audioPlayer.IsReady,
|
|
243
|
+
timeLength: audioPlayer.TimeLength,
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
if (
|
|
247
|
+
instance.IsA("ParticleEmitter") ||
|
|
248
|
+
className === "Fire" ||
|
|
249
|
+
className === "Smoke" ||
|
|
250
|
+
className === "Sparkles"
|
|
251
|
+
) {
|
|
252
|
+
hasParticles = true;
|
|
253
|
+
hasVfx = true;
|
|
254
|
+
}
|
|
255
|
+
if (instance.IsA("Beam") || instance.IsA("Trail")) hasVfx = true;
|
|
256
|
+
if (instance.IsA("Decal") || instance.IsA("Texture")) hasDecalsOrTextures = true;
|
|
257
|
+
if (instance.IsA("MeshPart") || instance.IsA("SpecialMesh")) hasMeshes = true;
|
|
258
|
+
if (instance.IsA("Light")) hasLights = true;
|
|
259
|
+
if (instance.IsA("Attachment")) hasAttachments = true;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
recordSummary(wrapperModel as Instance);
|
|
263
|
+
for (const descendant of (wrapperModel as Instance).GetDescendants()) {
|
|
264
|
+
recordSummary(descendant);
|
|
265
|
+
}
|
|
266
|
+
const forbiddenScan = scanForbiddenImportedInstances(wrapperModel as Instance);
|
|
267
|
+
|
|
268
|
+
function buildHierarchy(instance: Instance, depth: number): Record<string, unknown> {
|
|
269
|
+
const className = instance.ClassName;
|
|
127
270
|
|
|
128
271
|
const node: Record<string, unknown> = {
|
|
129
272
|
name: instance.Name,
|
|
@@ -155,15 +298,6 @@ function previewAsset(requestData: Record<string, unknown>) {
|
|
|
155
298
|
}
|
|
156
299
|
}
|
|
157
300
|
|
|
158
|
-
if (instance.IsA("LuaSourceContainer")) {
|
|
159
|
-
const [ok, src] = pcall(() => (instance as unknown as { Source: string }).Source);
|
|
160
|
-
if (ok && src) {
|
|
161
|
-
const preview = string.sub(src, 1, 200);
|
|
162
|
-
props.sourcePreview = preview;
|
|
163
|
-
props.sourceLength = src.size();
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
|
|
167
301
|
if (className === "Decal" || className === "Texture") {
|
|
168
302
|
const [ok, texId] = pcall(() => (instance as unknown as { Texture: string }).Texture);
|
|
169
303
|
if (ok) props.texture = texId;
|
|
@@ -171,6 +305,8 @@ function previewAsset(requestData: Record<string, unknown>) {
|
|
|
171
305
|
|
|
172
306
|
if (instance.IsA("Sound")) {
|
|
173
307
|
props.soundId = (instance as Sound).SoundId;
|
|
308
|
+
} else if (instance.IsA("AudioPlayer")) {
|
|
309
|
+
props.soundId = tostring((instance as AudioPlayer).Asset);
|
|
174
310
|
}
|
|
175
311
|
|
|
176
312
|
// Only include props if there are any
|
|
@@ -213,13 +349,27 @@ function previewAsset(requestData: Record<string, unknown>) {
|
|
|
213
349
|
success: true,
|
|
214
350
|
assetId,
|
|
215
351
|
hierarchy: hierarchyRoots,
|
|
352
|
+
sounds: soundReferences,
|
|
216
353
|
summary: {
|
|
217
354
|
totalInstances,
|
|
218
355
|
classCounts,
|
|
219
|
-
hasScripts,
|
|
356
|
+
hasScripts: forbiddenScan.scripts.size() > 0,
|
|
357
|
+
scriptCount: forbiddenScan.scripts.size(),
|
|
358
|
+
hasPackageLinks: forbiddenScan.packageLinks.size() > 0,
|
|
359
|
+
packageLinkCount: forbiddenScan.packageLinks.size(),
|
|
220
360
|
hasAnimations,
|
|
221
361
|
hasSounds,
|
|
362
|
+
soundCount: soundReferences.size(),
|
|
363
|
+
uniqueSoundContentIdCount: uniqueSoundContentIds.size(),
|
|
222
364
|
hasParticles,
|
|
365
|
+
hasVfx,
|
|
366
|
+
hasDecalsOrTextures,
|
|
367
|
+
hasMeshes,
|
|
368
|
+
hasLights,
|
|
369
|
+
hasAttachments,
|
|
370
|
+
securityScanDepth: "unlimited",
|
|
371
|
+
scriptSourceExposed: false,
|
|
372
|
+
insertPolicy: "All LuaSourceContainer and PackageLink instances are stripped and verified before insertion.",
|
|
223
373
|
},
|
|
224
374
|
};
|
|
225
375
|
});
|