@chrrxs/robloxstudio-mcp 2.14.0 → 2.15.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 +108 -3
- package/package.json +1 -1
- package/studio-plugin/MCPInspectorPlugin.rbxmx +277 -21
- package/studio-plugin/MCPPlugin.rbxmx +277 -21
- package/studio-plugin/src/modules/ClientBroker.ts +7 -2
- package/studio-plugin/src/modules/Communication.ts +2 -0
- package/studio-plugin/src/modules/handlers/SceneAnalysisHandlers.ts +216 -0
package/dist/index.js
CHANGED
|
@@ -721,6 +721,7 @@ var init_http_server = __esm({
|
|
|
721
721
|
simulate_keyboard_input: (tools, body) => tools.simulateKeyboardInput(body.keyCode, body.action, body.duration, body.text, body.target, body.instance_id),
|
|
722
722
|
character_navigation: (tools, body) => tools.characterNavigation(body.position, body.instancePath, body.waitForCompletion, body.timeout, body.target, body.instance_id),
|
|
723
723
|
get_memory_breakdown: (tools, body) => tools.getMemoryBreakdown(body.target, body.tags, body.instance_id),
|
|
724
|
+
get_scene_analysis: (tools, body) => tools.getSceneAnalysis(body.mode, body.target, body.topN, body.raw, body.instance_id),
|
|
724
725
|
export_rbxm: (tools, body) => tools.exportRbxm(body.instance_paths, body.output_path, body.target, body.instance_id),
|
|
725
726
|
import_rbxm: (tools, body) => tools.importRbxm(body.source, body.parent_path, body.target, body.instance_id),
|
|
726
727
|
find_and_replace_in_scripts: (tools, body) => tools.findAndReplaceInScripts(body.pattern, body.replacement, {
|
|
@@ -2884,6 +2885,19 @@ var init_tools = __esm({
|
|
|
2884
2885
|
const clientCount = this._clientRolesForInstance(instanceId).length;
|
|
2885
2886
|
return { ok: false, roles, timedOut: true, extraClients: clientCount > expectedClientCount, clientCount };
|
|
2886
2887
|
}
|
|
2888
|
+
async _waitForRuntimeRolesFresh(instanceId, connectedAfter, requiredRoles, timeoutSec = 60) {
|
|
2889
|
+
const deadline = Date.now() + timeoutSec * 1e3;
|
|
2890
|
+
while (Date.now() < deadline) {
|
|
2891
|
+
const instances = this.bridge.getInstances().filter((i) => i.instanceId === instanceId);
|
|
2892
|
+
const roles = instances.map((i) => i.role);
|
|
2893
|
+
const freshRoles = new Set(instances.filter((i) => i.connectedAt >= connectedAfter).map((i) => i.role));
|
|
2894
|
+
if (requiredRoles.every((role) => freshRoles.has(role))) {
|
|
2895
|
+
return { ok: true, roles, timedOut: false };
|
|
2896
|
+
}
|
|
2897
|
+
await sleep(250);
|
|
2898
|
+
}
|
|
2899
|
+
return { ok: false, roles: this._rolesForInstance(instanceId), timedOut: true };
|
|
2900
|
+
}
|
|
2887
2901
|
async getFileTree(path2 = "", instance_id) {
|
|
2888
2902
|
const response = await this._callSingle("/api/file-tree", { path: path2 }, void 0, instance_id);
|
|
2889
2903
|
return {
|
|
@@ -3532,12 +3546,37 @@ ${code}`
|
|
|
3532
3546
|
throw new Error("start_playtest is single-player only. Use multiplayer_test_start for multi-client StudioTestService sessions.");
|
|
3533
3547
|
}
|
|
3534
3548
|
const data = { mode };
|
|
3535
|
-
const
|
|
3549
|
+
const startedAt = Date.now();
|
|
3550
|
+
const resolved = this.bridge.resolveTarget({ instance_id, target: void 0 });
|
|
3551
|
+
if (!resolved.ok)
|
|
3552
|
+
throw new RoutingFailure(resolved.error);
|
|
3553
|
+
if (resolved.mode !== "single") {
|
|
3554
|
+
throw new RoutingFailure({
|
|
3555
|
+
code: "target_role_not_present_on_instance",
|
|
3556
|
+
message: "This tool does not support target=all. Pick a specific role or omit target.",
|
|
3557
|
+
data: {
|
|
3558
|
+
instances: this.bridge.getPublicInstances(),
|
|
3559
|
+
count: this.bridge.getInstances().length
|
|
3560
|
+
}
|
|
3561
|
+
});
|
|
3562
|
+
}
|
|
3563
|
+
const response = await this.client.request("/api/start-playtest", data, resolved.targetInstanceId, resolved.targetRole);
|
|
3564
|
+
let wait;
|
|
3565
|
+
if (response?.success === true) {
|
|
3566
|
+
const requiredRoles = mode === "play" ? ["server", "client-1"] : ["server"];
|
|
3567
|
+
wait = await this._waitForRuntimeRolesFresh(resolved.targetInstanceId, startedAt, requiredRoles);
|
|
3568
|
+
}
|
|
3569
|
+
const body = wait ? {
|
|
3570
|
+
...response,
|
|
3571
|
+
runtimeReady: wait.ok,
|
|
3572
|
+
timedOut: wait.timedOut,
|
|
3573
|
+
roles: wait.roles
|
|
3574
|
+
} : response;
|
|
3536
3575
|
return {
|
|
3537
3576
|
content: [
|
|
3538
3577
|
{
|
|
3539
3578
|
type: "text",
|
|
3540
|
-
text: JSON.stringify(
|
|
3579
|
+
text: JSON.stringify(body)
|
|
3541
3580
|
}
|
|
3542
3581
|
]
|
|
3543
3582
|
};
|
|
@@ -4602,6 +4641,39 @@ ${code}`
|
|
|
4602
4641
|
}
|
|
4603
4642
|
return { content: [{ type: "text", text: JSON.stringify(body) }] };
|
|
4604
4643
|
}
|
|
4644
|
+
async getSceneAnalysis(mode, target, topN, raw, instance_id) {
|
|
4645
|
+
const tgt = target ?? "all";
|
|
4646
|
+
const data = {};
|
|
4647
|
+
if (mode !== void 0)
|
|
4648
|
+
data.mode = mode;
|
|
4649
|
+
if (topN !== void 0)
|
|
4650
|
+
data.topN = topN;
|
|
4651
|
+
if (raw !== void 0)
|
|
4652
|
+
data.raw = raw;
|
|
4653
|
+
const resolved = this.bridge.resolveTarget({ instance_id, target: tgt });
|
|
4654
|
+
if (!resolved.ok)
|
|
4655
|
+
throw new RoutingFailure(resolved.error);
|
|
4656
|
+
if (resolved.mode === "single") {
|
|
4657
|
+
const response = await this.client.request("/api/get-scene-analysis", data, resolved.targetInstanceId, resolved.targetRole);
|
|
4658
|
+
return { content: [{ type: "text", text: JSON.stringify(response) }] };
|
|
4659
|
+
}
|
|
4660
|
+
const targets = resolved.targets.filter((t) => t.targetRole !== "edit-proxy");
|
|
4661
|
+
const responses = await Promise.allSettled(targets.map(async (t) => ({
|
|
4662
|
+
peer: t.targetRole,
|
|
4663
|
+
result: await this.client.request("/api/get-scene-analysis", data, t.targetInstanceId, t.targetRole)
|
|
4664
|
+
})));
|
|
4665
|
+
const body = {};
|
|
4666
|
+
for (let i = 0; i < responses.length; i++) {
|
|
4667
|
+
const r = responses[i];
|
|
4668
|
+
const peer = targets[i].targetRole;
|
|
4669
|
+
if (r.status === "fulfilled") {
|
|
4670
|
+
body[peer] = r.value.result;
|
|
4671
|
+
} else {
|
|
4672
|
+
body[peer] = { error: "disconnected" };
|
|
4673
|
+
}
|
|
4674
|
+
}
|
|
4675
|
+
return { content: [{ type: "text", text: JSON.stringify(body) }] };
|
|
4676
|
+
}
|
|
4605
4677
|
async exportRbxm(instancePaths, outputPath, target, instance_id) {
|
|
4606
4678
|
if (!Array.isArray(instancePaths) || instancePaths.length === 0) {
|
|
4607
4679
|
throw new Error("instance_paths must be a non-empty array for export_rbxm");
|
|
@@ -6032,7 +6104,7 @@ var init_definitions = __esm({
|
|
|
6032
6104
|
{
|
|
6033
6105
|
name: "start_playtest",
|
|
6034
6106
|
category: "write",
|
|
6035
|
-
description: "Start a simple single-player Studio playtest in play or run mode. Captures print/warn/error via LogService. Poll with get_playtest_output, end with stop_playtest. For multi-client testing use multiplayer_test_start instead.",
|
|
6107
|
+
description: "Start a simple single-player Studio playtest in play or run mode, waiting until a runtime peer registers with MCP. Captures print/warn/error via LogService. Poll with get_playtest_output, end with stop_playtest. For multi-client testing use multiplayer_test_start instead.",
|
|
6036
6108
|
inputSchema: {
|
|
6037
6109
|
type: "object",
|
|
6038
6110
|
properties: {
|
|
@@ -7010,6 +7082,39 @@ part(0,2,0,2,1,1,"b")`,
|
|
|
7010
7082
|
}
|
|
7011
7083
|
}
|
|
7012
7084
|
},
|
|
7085
|
+
{
|
|
7086
|
+
name: "get_scene_analysis",
|
|
7087
|
+
category: "read",
|
|
7088
|
+
description: 'Read Roblox SceneAnalysisService data for attribution-focused performance analysis. Complements get_memory_breakdown: returns compact top-N entries for instance composition, script memory, unparented instances, triangle composition, animation memory, and audio memory. Requires the Studio Scene Analysis beta feature; if disabled, returns scene_analysis_not_enabled with betaFeatureRequired=true. target="all" (default) returns per-peer data; single-peer targets return that peer directly. raw=true includes the full nested Scene Analysis tree.',
|
|
7089
|
+
inputSchema: {
|
|
7090
|
+
type: "object",
|
|
7091
|
+
properties: {
|
|
7092
|
+
mode: {
|
|
7093
|
+
type: "string",
|
|
7094
|
+
enum: ["all", "instance_composition", "script_memory", "unparented_instances", "triangle_composition", "animation_memory", "audio_memory"],
|
|
7095
|
+
description: 'Scene analysis mode to read. Defaults to "all".'
|
|
7096
|
+
},
|
|
7097
|
+
target: {
|
|
7098
|
+
type: "string",
|
|
7099
|
+
description: 'Peer to read from: "edit", "server", "client-N", or "all" (default).'
|
|
7100
|
+
},
|
|
7101
|
+
topN: {
|
|
7102
|
+
type: "number",
|
|
7103
|
+
minimum: 1,
|
|
7104
|
+
maximum: 100,
|
|
7105
|
+
description: "Number of flattened top entries to include per mode. Defaults to 10; plugin clamps to 1-100."
|
|
7106
|
+
},
|
|
7107
|
+
raw: {
|
|
7108
|
+
type: "boolean",
|
|
7109
|
+
description: "Include the full nested SceneAnalysisService tree in each mode result. Defaults to false."
|
|
7110
|
+
},
|
|
7111
|
+
instance_id: {
|
|
7112
|
+
type: "string",
|
|
7113
|
+
description: "Which connected Studio place to target. Required when multiple places are connected; omit when one. Use get_connected_instances to list available IDs."
|
|
7114
|
+
}
|
|
7115
|
+
}
|
|
7116
|
+
}
|
|
7117
|
+
},
|
|
7013
7118
|
// === SerializationService round-trip ===
|
|
7014
7119
|
{
|
|
7015
7120
|
name: "export_rbxm",
|
package/package.json
CHANGED
|
@@ -102,6 +102,7 @@ local RunService = _services.RunService
|
|
|
102
102
|
local ServerStorage = _services.ServerStorage
|
|
103
103
|
local RuntimeLogBuffer = TS.import(script, script.Parent, "RuntimeLogBuffer")
|
|
104
104
|
local MemoryHandlers = TS.import(script, script.Parent, "handlers", "MemoryHandlers")
|
|
105
|
+
local SceneAnalysisHandlers = TS.import(script, script.Parent, "handlers", "SceneAnalysisHandlers")
|
|
105
106
|
local CaptureHandlers = TS.import(script, script.Parent, "handlers", "CaptureHandlers")
|
|
106
107
|
local InputHandlers = TS.import(script, script.Parent, "handlers", "InputHandlers")
|
|
107
108
|
local LuauExec = TS.import(script, script.Parent, "LuauExec")
|
|
@@ -167,6 +168,7 @@ local CLIENT_BROKER_ALLOWED_ENDPOINTS = {
|
|
|
167
168
|
["/api/execute-luau"] = true,
|
|
168
169
|
["/api/get-runtime-logs"] = true,
|
|
169
170
|
["/api/get-memory-breakdown"] = true,
|
|
171
|
+
["/api/get-scene-analysis"] = true,
|
|
170
172
|
["/api/multiplayer-test-state"] = true,
|
|
171
173
|
["/api/multiplayer-test-leave-client"] = true,
|
|
172
174
|
["/api/capture-begin"] = true,
|
|
@@ -324,7 +326,7 @@ end
|
|
|
324
326
|
local function setupClientBroker()
|
|
325
327
|
local rf = ReplicatedStorage:WaitForChild(BROKER_NAME, 10)
|
|
326
328
|
if not rf or not rf:IsA("RemoteFunction") then
|
|
327
|
-
warn(`[
|
|
329
|
+
warn(`[robloxstudio-mcp] client: {BROKER_NAME} not found`)
|
|
328
330
|
return nil
|
|
329
331
|
end
|
|
330
332
|
rf.OnClientInvoke = function(payload)
|
|
@@ -340,6 +342,9 @@ local function setupClientBroker()
|
|
|
340
342
|
if payload and payload.endpoint == "/api/get-memory-breakdown" then
|
|
341
343
|
return MemoryHandlers.getMemoryBreakdown(payload.data or {})
|
|
342
344
|
end
|
|
345
|
+
if payload and payload.endpoint == "/api/get-scene-analysis" then
|
|
346
|
+
return SceneAnalysisHandlers.getSceneAnalysis(payload.data or {})
|
|
347
|
+
end
|
|
343
348
|
if payload and payload.endpoint == "/api/multiplayer-test-state" then
|
|
344
349
|
return handleMultiplayerTestState()
|
|
345
350
|
end
|
|
@@ -453,7 +458,7 @@ local function registerProxy(player, rf)
|
|
|
453
458
|
isRunning = RunService:IsRunning(),
|
|
454
459
|
})
|
|
455
460
|
if not ok or not res or not res.Success then
|
|
456
|
-
warn(`[
|
|
461
|
+
warn(`[robloxstudio-mcp] proxy register failed for {player.Name}`)
|
|
457
462
|
return nil
|
|
458
463
|
end
|
|
459
464
|
local body = HttpService:JSONDecode(res.Body)
|
|
@@ -551,6 +556,7 @@ local InputHandlers = TS.import(script, script.Parent, "handlers", "InputHandler
|
|
|
551
556
|
local LogHandlers = TS.import(script, script.Parent, "handlers", "LogHandlers")
|
|
552
557
|
local SerializationHandlers = TS.import(script, script.Parent, "handlers", "SerializationHandlers")
|
|
553
558
|
local MemoryHandlers = TS.import(script, script.Parent, "handlers", "MemoryHandlers")
|
|
559
|
+
local SceneAnalysisHandlers = TS.import(script, script.Parent, "handlers", "SceneAnalysisHandlers")
|
|
554
560
|
-- Per-plugin-load random GUID. Used as the /poll URL param so the server
|
|
555
561
|
-- can tell our polls apart from any other plugin's polls. Not user-facing —
|
|
556
562
|
-- MCP tools and the LLM operate on instanceId (the place identifier).
|
|
@@ -683,6 +689,7 @@ local routeMap = {
|
|
|
683
689
|
["/api/export-rbxm"] = SerializationHandlers.exportRbxm,
|
|
684
690
|
["/api/import-rbxm"] = SerializationHandlers.importRbxm,
|
|
685
691
|
["/api/get-memory-breakdown"] = MemoryHandlers.getMemoryBreakdown,
|
|
692
|
+
["/api/get-scene-analysis"] = SceneAnalysisHandlers.getSceneAnalysis,
|
|
686
693
|
}
|
|
687
694
|
local function processRequest(request)
|
|
688
695
|
local endpoint = request.endpoint
|
|
@@ -1256,9 +1263,9 @@ local function computeBridgeStamp()
|
|
|
1256
1263
|
for i = 1, #combined do
|
|
1257
1264
|
h = (h * 33 + (string.byte(combined, i))) % 2147483647
|
|
1258
1265
|
end
|
|
1259
|
-
-- "2.
|
|
1266
|
+
-- "2.15.0" is replaced with the package version at package time
|
|
1260
1267
|
-- (scripts/build-plugin.mjs injectVersion), so a release bump also restamps.
|
|
1261
|
-
return `{tostring(h)}-2.
|
|
1268
|
+
return `{tostring(h)}-2.15.0`
|
|
1262
1269
|
end
|
|
1263
1270
|
local BRIDGE_STAMP = computeBridgeStamp()
|
|
1264
1271
|
local function setSource(scriptInst, source)
|
|
@@ -5070,6 +5077,255 @@ return {
|
|
|
5070
5077
|
</Properties>
|
|
5071
5078
|
</Item>
|
|
5072
5079
|
<Item class="ModuleScript" referent="16">
|
|
5080
|
+
<Properties>
|
|
5081
|
+
<string name="Name">SceneAnalysisHandlers</string>
|
|
5082
|
+
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
5083
|
+
local MODE_CONFIGS = {
|
|
5084
|
+
instance_composition = {
|
|
5085
|
+
method = "GetInstanceCompositionAsync",
|
|
5086
|
+
query = function(service)
|
|
5087
|
+
return service:GetInstanceCompositionAsync()
|
|
5088
|
+
end,
|
|
5089
|
+
},
|
|
5090
|
+
script_memory = {
|
|
5091
|
+
method = "GetScriptMemoryAsync",
|
|
5092
|
+
query = function(service)
|
|
5093
|
+
return service:GetScriptMemoryAsync()
|
|
5094
|
+
end,
|
|
5095
|
+
},
|
|
5096
|
+
unparented_instances = {
|
|
5097
|
+
method = "GetUnparentedInstancesAsync",
|
|
5098
|
+
query = function(service)
|
|
5099
|
+
return service:GetUnparentedInstancesAsync()
|
|
5100
|
+
end,
|
|
5101
|
+
},
|
|
5102
|
+
triangle_composition = {
|
|
5103
|
+
method = "GetTriangleCompositionAsync",
|
|
5104
|
+
query = function(service)
|
|
5105
|
+
return service:GetTriangleCompositionAsync()
|
|
5106
|
+
end,
|
|
5107
|
+
sortByTriangles = true,
|
|
5108
|
+
},
|
|
5109
|
+
animation_memory = {
|
|
5110
|
+
method = "GetAnimationMemoryAsync",
|
|
5111
|
+
query = function(service)
|
|
5112
|
+
return service:GetAnimationMemoryAsync()
|
|
5113
|
+
end,
|
|
5114
|
+
},
|
|
5115
|
+
audio_memory = {
|
|
5116
|
+
method = "GetAudioMemoryAsync",
|
|
5117
|
+
query = function(service)
|
|
5118
|
+
return service:GetAudioMemoryAsync()
|
|
5119
|
+
end,
|
|
5120
|
+
},
|
|
5121
|
+
}
|
|
5122
|
+
local ALL_MODES = { "instance_composition", "script_memory", "unparented_instances", "triangle_composition", "animation_memory", "audio_memory" }
|
|
5123
|
+
local function betaDisabledError()
|
|
5124
|
+
return {
|
|
5125
|
+
error = "scene_analysis_not_enabled",
|
|
5126
|
+
message = "SceneAnalysisService is not enabled. Enable Scene Analysis in Studio Beta Features and restart Studio.",
|
|
5127
|
+
betaFeatureRequired = true,
|
|
5128
|
+
}
|
|
5129
|
+
end
|
|
5130
|
+
local function isBetaDisabledError(value)
|
|
5131
|
+
local _value = value
|
|
5132
|
+
local _condition = type(_value) == "string"
|
|
5133
|
+
if _condition then
|
|
5134
|
+
_condition = (string.find(value, "SceneAnalysisService is not enabled", 1, true)) ~= nil
|
|
5135
|
+
end
|
|
5136
|
+
return _condition
|
|
5137
|
+
end
|
|
5138
|
+
local function getSceneAnalysisService()
|
|
5139
|
+
local provider = game
|
|
5140
|
+
local ok, service = pcall(function()
|
|
5141
|
+
return provider:GetService("SceneAnalysisService")
|
|
5142
|
+
end)
|
|
5143
|
+
if not ok or not service then
|
|
5144
|
+
return {
|
|
5145
|
+
error = "scene_analysis_unavailable",
|
|
5146
|
+
message = `SceneAnalysisService is unavailable: {tostring(service)}`,
|
|
5147
|
+
}
|
|
5148
|
+
end
|
|
5149
|
+
return service
|
|
5150
|
+
end
|
|
5151
|
+
local function normalizeMode(mode)
|
|
5152
|
+
if mode == nil or mode == "all" then
|
|
5153
|
+
return "all"
|
|
5154
|
+
end
|
|
5155
|
+
local _mode = mode
|
|
5156
|
+
local _condition = not (type(_mode) == "string")
|
|
5157
|
+
if not _condition then
|
|
5158
|
+
_condition = MODE_CONFIGS[mode] == nil
|
|
5159
|
+
end
|
|
5160
|
+
if _condition then
|
|
5161
|
+
return {
|
|
5162
|
+
error = "invalid_mode",
|
|
5163
|
+
message = `mode must be one of: all, {table.concat(ALL_MODES, ", ")}`,
|
|
5164
|
+
}
|
|
5165
|
+
end
|
|
5166
|
+
return mode
|
|
5167
|
+
end
|
|
5168
|
+
local function normalizeTopN(topN)
|
|
5169
|
+
local _topN = topN
|
|
5170
|
+
if not (type(_topN) == "number") then
|
|
5171
|
+
return 10
|
|
5172
|
+
end
|
|
5173
|
+
return math.clamp(math.floor(topN), 1, 100)
|
|
5174
|
+
end
|
|
5175
|
+
local function countLeaves(node)
|
|
5176
|
+
local children = node.Children
|
|
5177
|
+
if children and #children > 0 then
|
|
5178
|
+
local total = 0
|
|
5179
|
+
for _, child in children do
|
|
5180
|
+
total += countLeaves(child)
|
|
5181
|
+
end
|
|
5182
|
+
return total
|
|
5183
|
+
end
|
|
5184
|
+
return 1
|
|
5185
|
+
end
|
|
5186
|
+
local function flattenLeaves(node, out)
|
|
5187
|
+
local children = node.Children
|
|
5188
|
+
if children and #children > 0 then
|
|
5189
|
+
for _, child in children do
|
|
5190
|
+
flattenLeaves(child, out)
|
|
5191
|
+
end
|
|
5192
|
+
return nil
|
|
5193
|
+
end
|
|
5194
|
+
local _out = out
|
|
5195
|
+
local _node = node
|
|
5196
|
+
table.insert(_out, _node)
|
|
5197
|
+
end
|
|
5198
|
+
local function compactEntry(node)
|
|
5199
|
+
local entry = {
|
|
5200
|
+
name = node.Name,
|
|
5201
|
+
}
|
|
5202
|
+
if node.Size ~= nil then
|
|
5203
|
+
entry.size = node.Size
|
|
5204
|
+
end
|
|
5205
|
+
if node.Sizes ~= nil then
|
|
5206
|
+
entry.sizes = node.Sizes
|
|
5207
|
+
end
|
|
5208
|
+
if node.AssetId ~= nil then
|
|
5209
|
+
entry.asset_id = node.AssetId
|
|
5210
|
+
end
|
|
5211
|
+
return entry
|
|
5212
|
+
end
|
|
5213
|
+
local function compactRoot(node, leafCount)
|
|
5214
|
+
local children = node.Children
|
|
5215
|
+
local root = {
|
|
5216
|
+
name = node.Name,
|
|
5217
|
+
child_count = if children then #children else 0,
|
|
5218
|
+
leaf_count = leafCount,
|
|
5219
|
+
}
|
|
5220
|
+
if node.Size ~= nil then
|
|
5221
|
+
root.size = node.Size
|
|
5222
|
+
end
|
|
5223
|
+
if node.Sizes ~= nil then
|
|
5224
|
+
root.sizes = node.Sizes
|
|
5225
|
+
end
|
|
5226
|
+
return root
|
|
5227
|
+
end
|
|
5228
|
+
local function metric(node, sortByTriangles)
|
|
5229
|
+
if sortByTriangles then
|
|
5230
|
+
local sizes = node.Sizes
|
|
5231
|
+
local triangles = if sizes then sizes.Triangles else nil
|
|
5232
|
+
local _condition = triangles
|
|
5233
|
+
if _condition == nil then
|
|
5234
|
+
_condition = 0
|
|
5235
|
+
end
|
|
5236
|
+
return _condition
|
|
5237
|
+
end
|
|
5238
|
+
local _condition = node.Size
|
|
5239
|
+
if _condition == nil then
|
|
5240
|
+
_condition = 0
|
|
5241
|
+
end
|
|
5242
|
+
return _condition
|
|
5243
|
+
end
|
|
5244
|
+
local function summarizeMode(mode, config, service, topN, raw)
|
|
5245
|
+
local started = os.clock()
|
|
5246
|
+
local ok, result = pcall(function()
|
|
5247
|
+
return config.query(service)
|
|
5248
|
+
end)
|
|
5249
|
+
local elapsedMs = math.floor((os.clock() - started) * 1000)
|
|
5250
|
+
if not ok then
|
|
5251
|
+
if isBetaDisabledError(result) then
|
|
5252
|
+
return betaDisabledError()
|
|
5253
|
+
end
|
|
5254
|
+
return {
|
|
5255
|
+
error = "scene_analysis_query_failed",
|
|
5256
|
+
mode = mode,
|
|
5257
|
+
method = config.method,
|
|
5258
|
+
message = tostring(result),
|
|
5259
|
+
}
|
|
5260
|
+
end
|
|
5261
|
+
local tree = result
|
|
5262
|
+
local leaves = {}
|
|
5263
|
+
flattenLeaves(tree, leaves)
|
|
5264
|
+
table.sort(leaves, function(a, b)
|
|
5265
|
+
return metric(a, config.sortByTriangles == true) > metric(b, config.sortByTriangles == true)
|
|
5266
|
+
end)
|
|
5267
|
+
local top = {}
|
|
5268
|
+
do
|
|
5269
|
+
local i = 0
|
|
5270
|
+
local _shouldIncrement = false
|
|
5271
|
+
while true do
|
|
5272
|
+
if _shouldIncrement then
|
|
5273
|
+
i += 1
|
|
5274
|
+
else
|
|
5275
|
+
_shouldIncrement = true
|
|
5276
|
+
end
|
|
5277
|
+
if not (i < math.min(topN, #leaves)) then
|
|
5278
|
+
break
|
|
5279
|
+
end
|
|
5280
|
+
local _arg0 = compactEntry(leaves[i + 1])
|
|
5281
|
+
table.insert(top, _arg0)
|
|
5282
|
+
end
|
|
5283
|
+
end
|
|
5284
|
+
local body = {
|
|
5285
|
+
mode = mode,
|
|
5286
|
+
method = config.method,
|
|
5287
|
+
elapsed_ms = elapsedMs,
|
|
5288
|
+
root = compactRoot(tree, #leaves),
|
|
5289
|
+
top = top,
|
|
5290
|
+
}
|
|
5291
|
+
if raw then
|
|
5292
|
+
body.tree = tree
|
|
5293
|
+
end
|
|
5294
|
+
return body
|
|
5295
|
+
end
|
|
5296
|
+
local function getSceneAnalysis(requestData)
|
|
5297
|
+
local mode = normalizeMode(requestData.mode)
|
|
5298
|
+
if not (type(mode) == "string") then
|
|
5299
|
+
return mode
|
|
5300
|
+
end
|
|
5301
|
+
local serviceOrError = getSceneAnalysisService()
|
|
5302
|
+
local _value = serviceOrError.IsA
|
|
5303
|
+
if not (_value ~= 0 and _value == _value and _value ~= "" and _value) then
|
|
5304
|
+
return serviceOrError
|
|
5305
|
+
end
|
|
5306
|
+
local service = serviceOrError
|
|
5307
|
+
local topN = normalizeTopN(requestData.topN)
|
|
5308
|
+
local raw = requestData.raw == true
|
|
5309
|
+
if mode ~= "all" then
|
|
5310
|
+
return summarizeMode(mode, MODE_CONFIGS[mode], service, topN, raw)
|
|
5311
|
+
end
|
|
5312
|
+
local body = {}
|
|
5313
|
+
for _, m in ALL_MODES do
|
|
5314
|
+
local result = summarizeMode(m, MODE_CONFIGS[m], service, topN, raw)
|
|
5315
|
+
if result.error == "scene_analysis_not_enabled" then
|
|
5316
|
+
return result
|
|
5317
|
+
end
|
|
5318
|
+
body[m] = result
|
|
5319
|
+
end
|
|
5320
|
+
return body
|
|
5321
|
+
end
|
|
5322
|
+
return {
|
|
5323
|
+
getSceneAnalysis = getSceneAnalysis,
|
|
5324
|
+
}
|
|
5325
|
+
]]></string>
|
|
5326
|
+
</Properties>
|
|
5327
|
+
</Item>
|
|
5328
|
+
<Item class="ModuleScript" referent="17">
|
|
5073
5329
|
<Properties>
|
|
5074
5330
|
<string name="Name">ScriptHandlers</string>
|
|
5075
5331
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -5765,7 +6021,7 @@ return {
|
|
|
5765
6021
|
]]></string>
|
|
5766
6022
|
</Properties>
|
|
5767
6023
|
</Item>
|
|
5768
|
-
<Item class="ModuleScript" referent="
|
|
6024
|
+
<Item class="ModuleScript" referent="18">
|
|
5769
6025
|
<Properties>
|
|
5770
6026
|
<string name="Name">SerializationHandlers</string>
|
|
5771
6027
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -5951,7 +6207,7 @@ return {
|
|
|
5951
6207
|
]]></string>
|
|
5952
6208
|
</Properties>
|
|
5953
6209
|
</Item>
|
|
5954
|
-
<Item class="ModuleScript" referent="
|
|
6210
|
+
<Item class="ModuleScript" referent="19">
|
|
5955
6211
|
<Properties>
|
|
5956
6212
|
<string name="Name">TestHandlers</string>
|
|
5957
6213
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -6584,7 +6840,7 @@ return {
|
|
|
6584
6840
|
</Properties>
|
|
6585
6841
|
</Item>
|
|
6586
6842
|
</Item>
|
|
6587
|
-
<Item class="ModuleScript" referent="
|
|
6843
|
+
<Item class="ModuleScript" referent="20">
|
|
6588
6844
|
<Properties>
|
|
6589
6845
|
<string name="Name">LuauExec</string>
|
|
6590
6846
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -6902,7 +7158,7 @@ return {
|
|
|
6902
7158
|
]]></string>
|
|
6903
7159
|
</Properties>
|
|
6904
7160
|
</Item>
|
|
6905
|
-
<Item class="ModuleScript" referent="
|
|
7161
|
+
<Item class="ModuleScript" referent="21">
|
|
6906
7162
|
<Properties>
|
|
6907
7163
|
<string name="Name">Recording</string>
|
|
6908
7164
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -6932,7 +7188,7 @@ return {
|
|
|
6932
7188
|
]]></string>
|
|
6933
7189
|
</Properties>
|
|
6934
7190
|
</Item>
|
|
6935
|
-
<Item class="ModuleScript" referent="
|
|
7191
|
+
<Item class="ModuleScript" referent="22">
|
|
6936
7192
|
<Properties>
|
|
6937
7193
|
<string name="Name">RenderMonitor</string>
|
|
6938
7194
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -7000,7 +7256,7 @@ return {
|
|
|
7000
7256
|
]]></string>
|
|
7001
7257
|
</Properties>
|
|
7002
7258
|
</Item>
|
|
7003
|
-
<Item class="ModuleScript" referent="
|
|
7259
|
+
<Item class="ModuleScript" referent="23">
|
|
7004
7260
|
<Properties>
|
|
7005
7261
|
<string name="Name">RuntimeLogBuffer</string>
|
|
7006
7262
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -7181,11 +7437,11 @@ return {
|
|
|
7181
7437
|
]]></string>
|
|
7182
7438
|
</Properties>
|
|
7183
7439
|
</Item>
|
|
7184
|
-
<Item class="ModuleScript" referent="
|
|
7440
|
+
<Item class="ModuleScript" referent="24">
|
|
7185
7441
|
<Properties>
|
|
7186
7442
|
<string name="Name">State</string>
|
|
7187
7443
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
7188
|
-
local CURRENT_VERSION = "2.
|
|
7444
|
+
local CURRENT_VERSION = "2.15.0"
|
|
7189
7445
|
local MAX_CONNECTIONS = 5
|
|
7190
7446
|
local BASE_PORT = 58741
|
|
7191
7447
|
local activeTabIndex = 0
|
|
@@ -7277,7 +7533,7 @@ return {
|
|
|
7277
7533
|
]]></string>
|
|
7278
7534
|
</Properties>
|
|
7279
7535
|
</Item>
|
|
7280
|
-
<Item class="ModuleScript" referent="
|
|
7536
|
+
<Item class="ModuleScript" referent="25">
|
|
7281
7537
|
<Properties>
|
|
7282
7538
|
<string name="Name">StopPlayMonitor</string>
|
|
7283
7539
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -7422,7 +7678,7 @@ return {
|
|
|
7422
7678
|
]]></string>
|
|
7423
7679
|
</Properties>
|
|
7424
7680
|
</Item>
|
|
7425
|
-
<Item class="ModuleScript" referent="
|
|
7681
|
+
<Item class="ModuleScript" referent="26">
|
|
7426
7682
|
<Properties>
|
|
7427
7683
|
<string name="Name">UI</string>
|
|
7428
7684
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -8173,7 +8429,7 @@ return {
|
|
|
8173
8429
|
]]></string>
|
|
8174
8430
|
</Properties>
|
|
8175
8431
|
</Item>
|
|
8176
|
-
<Item class="ModuleScript" referent="
|
|
8432
|
+
<Item class="ModuleScript" referent="27">
|
|
8177
8433
|
<Properties>
|
|
8178
8434
|
<string name="Name">Utils</string>
|
|
8179
8435
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -8703,11 +8959,11 @@ return {
|
|
|
8703
8959
|
</Properties>
|
|
8704
8960
|
</Item>
|
|
8705
8961
|
</Item>
|
|
8706
|
-
<Item class="Folder" referent="
|
|
8962
|
+
<Item class="Folder" referent="31">
|
|
8707
8963
|
<Properties>
|
|
8708
8964
|
<string name="Name">include</string>
|
|
8709
8965
|
</Properties>
|
|
8710
|
-
<Item class="ModuleScript" referent="
|
|
8966
|
+
<Item class="ModuleScript" referent="28">
|
|
8711
8967
|
<Properties>
|
|
8712
8968
|
<string name="Name">Promise</string>
|
|
8713
8969
|
<string name="Source"><![CDATA[--[[
|
|
@@ -10781,7 +11037,7 @@ return Promise
|
|
|
10781
11037
|
]]></string>
|
|
10782
11038
|
</Properties>
|
|
10783
11039
|
</Item>
|
|
10784
|
-
<Item class="ModuleScript" referent="
|
|
11040
|
+
<Item class="ModuleScript" referent="29">
|
|
10785
11041
|
<Properties>
|
|
10786
11042
|
<string name="Name">RuntimeLib</string>
|
|
10787
11043
|
<string name="Source"><![CDATA[local Promise = require(script.Parent.Promise)
|
|
@@ -11048,15 +11304,15 @@ return TS
|
|
|
11048
11304
|
</Properties>
|
|
11049
11305
|
</Item>
|
|
11050
11306
|
</Item>
|
|
11051
|
-
<Item class="Folder" referent="
|
|
11307
|
+
<Item class="Folder" referent="32">
|
|
11052
11308
|
<Properties>
|
|
11053
11309
|
<string name="Name">node_modules</string>
|
|
11054
11310
|
</Properties>
|
|
11055
|
-
<Item class="Folder" referent="
|
|
11311
|
+
<Item class="Folder" referent="33">
|
|
11056
11312
|
<Properties>
|
|
11057
11313
|
<string name="Name">@rbxts</string>
|
|
11058
11314
|
</Properties>
|
|
11059
|
-
<Item class="ModuleScript" referent="
|
|
11315
|
+
<Item class="ModuleScript" referent="30">
|
|
11060
11316
|
<Properties>
|
|
11061
11317
|
<string name="Name">services</string>
|
|
11062
11318
|
<string name="Source"><![CDATA[return setmetatable({}, {
|
|
@@ -102,6 +102,7 @@ local RunService = _services.RunService
|
|
|
102
102
|
local ServerStorage = _services.ServerStorage
|
|
103
103
|
local RuntimeLogBuffer = TS.import(script, script.Parent, "RuntimeLogBuffer")
|
|
104
104
|
local MemoryHandlers = TS.import(script, script.Parent, "handlers", "MemoryHandlers")
|
|
105
|
+
local SceneAnalysisHandlers = TS.import(script, script.Parent, "handlers", "SceneAnalysisHandlers")
|
|
105
106
|
local CaptureHandlers = TS.import(script, script.Parent, "handlers", "CaptureHandlers")
|
|
106
107
|
local InputHandlers = TS.import(script, script.Parent, "handlers", "InputHandlers")
|
|
107
108
|
local LuauExec = TS.import(script, script.Parent, "LuauExec")
|
|
@@ -167,6 +168,7 @@ local CLIENT_BROKER_ALLOWED_ENDPOINTS = {
|
|
|
167
168
|
["/api/execute-luau"] = true,
|
|
168
169
|
["/api/get-runtime-logs"] = true,
|
|
169
170
|
["/api/get-memory-breakdown"] = true,
|
|
171
|
+
["/api/get-scene-analysis"] = true,
|
|
170
172
|
["/api/multiplayer-test-state"] = true,
|
|
171
173
|
["/api/multiplayer-test-leave-client"] = true,
|
|
172
174
|
["/api/capture-begin"] = true,
|
|
@@ -324,7 +326,7 @@ end
|
|
|
324
326
|
local function setupClientBroker()
|
|
325
327
|
local rf = ReplicatedStorage:WaitForChild(BROKER_NAME, 10)
|
|
326
328
|
if not rf or not rf:IsA("RemoteFunction") then
|
|
327
|
-
warn(`[
|
|
329
|
+
warn(`[robloxstudio-mcp] client: {BROKER_NAME} not found`)
|
|
328
330
|
return nil
|
|
329
331
|
end
|
|
330
332
|
rf.OnClientInvoke = function(payload)
|
|
@@ -340,6 +342,9 @@ local function setupClientBroker()
|
|
|
340
342
|
if payload and payload.endpoint == "/api/get-memory-breakdown" then
|
|
341
343
|
return MemoryHandlers.getMemoryBreakdown(payload.data or {})
|
|
342
344
|
end
|
|
345
|
+
if payload and payload.endpoint == "/api/get-scene-analysis" then
|
|
346
|
+
return SceneAnalysisHandlers.getSceneAnalysis(payload.data or {})
|
|
347
|
+
end
|
|
343
348
|
if payload and payload.endpoint == "/api/multiplayer-test-state" then
|
|
344
349
|
return handleMultiplayerTestState()
|
|
345
350
|
end
|
|
@@ -453,7 +458,7 @@ local function registerProxy(player, rf)
|
|
|
453
458
|
isRunning = RunService:IsRunning(),
|
|
454
459
|
})
|
|
455
460
|
if not ok or not res or not res.Success then
|
|
456
|
-
warn(`[
|
|
461
|
+
warn(`[robloxstudio-mcp] proxy register failed for {player.Name}`)
|
|
457
462
|
return nil
|
|
458
463
|
end
|
|
459
464
|
local body = HttpService:JSONDecode(res.Body)
|
|
@@ -551,6 +556,7 @@ local InputHandlers = TS.import(script, script.Parent, "handlers", "InputHandler
|
|
|
551
556
|
local LogHandlers = TS.import(script, script.Parent, "handlers", "LogHandlers")
|
|
552
557
|
local SerializationHandlers = TS.import(script, script.Parent, "handlers", "SerializationHandlers")
|
|
553
558
|
local MemoryHandlers = TS.import(script, script.Parent, "handlers", "MemoryHandlers")
|
|
559
|
+
local SceneAnalysisHandlers = TS.import(script, script.Parent, "handlers", "SceneAnalysisHandlers")
|
|
554
560
|
-- Per-plugin-load random GUID. Used as the /poll URL param so the server
|
|
555
561
|
-- can tell our polls apart from any other plugin's polls. Not user-facing —
|
|
556
562
|
-- MCP tools and the LLM operate on instanceId (the place identifier).
|
|
@@ -683,6 +689,7 @@ local routeMap = {
|
|
|
683
689
|
["/api/export-rbxm"] = SerializationHandlers.exportRbxm,
|
|
684
690
|
["/api/import-rbxm"] = SerializationHandlers.importRbxm,
|
|
685
691
|
["/api/get-memory-breakdown"] = MemoryHandlers.getMemoryBreakdown,
|
|
692
|
+
["/api/get-scene-analysis"] = SceneAnalysisHandlers.getSceneAnalysis,
|
|
686
693
|
}
|
|
687
694
|
local function processRequest(request)
|
|
688
695
|
local endpoint = request.endpoint
|
|
@@ -1256,9 +1263,9 @@ local function computeBridgeStamp()
|
|
|
1256
1263
|
for i = 1, #combined do
|
|
1257
1264
|
h = (h * 33 + (string.byte(combined, i))) % 2147483647
|
|
1258
1265
|
end
|
|
1259
|
-
-- "2.
|
|
1266
|
+
-- "2.15.0" is replaced with the package version at package time
|
|
1260
1267
|
-- (scripts/build-plugin.mjs injectVersion), so a release bump also restamps.
|
|
1261
|
-
return `{tostring(h)}-2.
|
|
1268
|
+
return `{tostring(h)}-2.15.0`
|
|
1262
1269
|
end
|
|
1263
1270
|
local BRIDGE_STAMP = computeBridgeStamp()
|
|
1264
1271
|
local function setSource(scriptInst, source)
|
|
@@ -5070,6 +5077,255 @@ return {
|
|
|
5070
5077
|
</Properties>
|
|
5071
5078
|
</Item>
|
|
5072
5079
|
<Item class="ModuleScript" referent="16">
|
|
5080
|
+
<Properties>
|
|
5081
|
+
<string name="Name">SceneAnalysisHandlers</string>
|
|
5082
|
+
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
5083
|
+
local MODE_CONFIGS = {
|
|
5084
|
+
instance_composition = {
|
|
5085
|
+
method = "GetInstanceCompositionAsync",
|
|
5086
|
+
query = function(service)
|
|
5087
|
+
return service:GetInstanceCompositionAsync()
|
|
5088
|
+
end,
|
|
5089
|
+
},
|
|
5090
|
+
script_memory = {
|
|
5091
|
+
method = "GetScriptMemoryAsync",
|
|
5092
|
+
query = function(service)
|
|
5093
|
+
return service:GetScriptMemoryAsync()
|
|
5094
|
+
end,
|
|
5095
|
+
},
|
|
5096
|
+
unparented_instances = {
|
|
5097
|
+
method = "GetUnparentedInstancesAsync",
|
|
5098
|
+
query = function(service)
|
|
5099
|
+
return service:GetUnparentedInstancesAsync()
|
|
5100
|
+
end,
|
|
5101
|
+
},
|
|
5102
|
+
triangle_composition = {
|
|
5103
|
+
method = "GetTriangleCompositionAsync",
|
|
5104
|
+
query = function(service)
|
|
5105
|
+
return service:GetTriangleCompositionAsync()
|
|
5106
|
+
end,
|
|
5107
|
+
sortByTriangles = true,
|
|
5108
|
+
},
|
|
5109
|
+
animation_memory = {
|
|
5110
|
+
method = "GetAnimationMemoryAsync",
|
|
5111
|
+
query = function(service)
|
|
5112
|
+
return service:GetAnimationMemoryAsync()
|
|
5113
|
+
end,
|
|
5114
|
+
},
|
|
5115
|
+
audio_memory = {
|
|
5116
|
+
method = "GetAudioMemoryAsync",
|
|
5117
|
+
query = function(service)
|
|
5118
|
+
return service:GetAudioMemoryAsync()
|
|
5119
|
+
end,
|
|
5120
|
+
},
|
|
5121
|
+
}
|
|
5122
|
+
local ALL_MODES = { "instance_composition", "script_memory", "unparented_instances", "triangle_composition", "animation_memory", "audio_memory" }
|
|
5123
|
+
local function betaDisabledError()
|
|
5124
|
+
return {
|
|
5125
|
+
error = "scene_analysis_not_enabled",
|
|
5126
|
+
message = "SceneAnalysisService is not enabled. Enable Scene Analysis in Studio Beta Features and restart Studio.",
|
|
5127
|
+
betaFeatureRequired = true,
|
|
5128
|
+
}
|
|
5129
|
+
end
|
|
5130
|
+
local function isBetaDisabledError(value)
|
|
5131
|
+
local _value = value
|
|
5132
|
+
local _condition = type(_value) == "string"
|
|
5133
|
+
if _condition then
|
|
5134
|
+
_condition = (string.find(value, "SceneAnalysisService is not enabled", 1, true)) ~= nil
|
|
5135
|
+
end
|
|
5136
|
+
return _condition
|
|
5137
|
+
end
|
|
5138
|
+
local function getSceneAnalysisService()
|
|
5139
|
+
local provider = game
|
|
5140
|
+
local ok, service = pcall(function()
|
|
5141
|
+
return provider:GetService("SceneAnalysisService")
|
|
5142
|
+
end)
|
|
5143
|
+
if not ok or not service then
|
|
5144
|
+
return {
|
|
5145
|
+
error = "scene_analysis_unavailable",
|
|
5146
|
+
message = `SceneAnalysisService is unavailable: {tostring(service)}`,
|
|
5147
|
+
}
|
|
5148
|
+
end
|
|
5149
|
+
return service
|
|
5150
|
+
end
|
|
5151
|
+
local function normalizeMode(mode)
|
|
5152
|
+
if mode == nil or mode == "all" then
|
|
5153
|
+
return "all"
|
|
5154
|
+
end
|
|
5155
|
+
local _mode = mode
|
|
5156
|
+
local _condition = not (type(_mode) == "string")
|
|
5157
|
+
if not _condition then
|
|
5158
|
+
_condition = MODE_CONFIGS[mode] == nil
|
|
5159
|
+
end
|
|
5160
|
+
if _condition then
|
|
5161
|
+
return {
|
|
5162
|
+
error = "invalid_mode",
|
|
5163
|
+
message = `mode must be one of: all, {table.concat(ALL_MODES, ", ")}`,
|
|
5164
|
+
}
|
|
5165
|
+
end
|
|
5166
|
+
return mode
|
|
5167
|
+
end
|
|
5168
|
+
local function normalizeTopN(topN)
|
|
5169
|
+
local _topN = topN
|
|
5170
|
+
if not (type(_topN) == "number") then
|
|
5171
|
+
return 10
|
|
5172
|
+
end
|
|
5173
|
+
return math.clamp(math.floor(topN), 1, 100)
|
|
5174
|
+
end
|
|
5175
|
+
local function countLeaves(node)
|
|
5176
|
+
local children = node.Children
|
|
5177
|
+
if children and #children > 0 then
|
|
5178
|
+
local total = 0
|
|
5179
|
+
for _, child in children do
|
|
5180
|
+
total += countLeaves(child)
|
|
5181
|
+
end
|
|
5182
|
+
return total
|
|
5183
|
+
end
|
|
5184
|
+
return 1
|
|
5185
|
+
end
|
|
5186
|
+
local function flattenLeaves(node, out)
|
|
5187
|
+
local children = node.Children
|
|
5188
|
+
if children and #children > 0 then
|
|
5189
|
+
for _, child in children do
|
|
5190
|
+
flattenLeaves(child, out)
|
|
5191
|
+
end
|
|
5192
|
+
return nil
|
|
5193
|
+
end
|
|
5194
|
+
local _out = out
|
|
5195
|
+
local _node = node
|
|
5196
|
+
table.insert(_out, _node)
|
|
5197
|
+
end
|
|
5198
|
+
local function compactEntry(node)
|
|
5199
|
+
local entry = {
|
|
5200
|
+
name = node.Name,
|
|
5201
|
+
}
|
|
5202
|
+
if node.Size ~= nil then
|
|
5203
|
+
entry.size = node.Size
|
|
5204
|
+
end
|
|
5205
|
+
if node.Sizes ~= nil then
|
|
5206
|
+
entry.sizes = node.Sizes
|
|
5207
|
+
end
|
|
5208
|
+
if node.AssetId ~= nil then
|
|
5209
|
+
entry.asset_id = node.AssetId
|
|
5210
|
+
end
|
|
5211
|
+
return entry
|
|
5212
|
+
end
|
|
5213
|
+
local function compactRoot(node, leafCount)
|
|
5214
|
+
local children = node.Children
|
|
5215
|
+
local root = {
|
|
5216
|
+
name = node.Name,
|
|
5217
|
+
child_count = if children then #children else 0,
|
|
5218
|
+
leaf_count = leafCount,
|
|
5219
|
+
}
|
|
5220
|
+
if node.Size ~= nil then
|
|
5221
|
+
root.size = node.Size
|
|
5222
|
+
end
|
|
5223
|
+
if node.Sizes ~= nil then
|
|
5224
|
+
root.sizes = node.Sizes
|
|
5225
|
+
end
|
|
5226
|
+
return root
|
|
5227
|
+
end
|
|
5228
|
+
local function metric(node, sortByTriangles)
|
|
5229
|
+
if sortByTriangles then
|
|
5230
|
+
local sizes = node.Sizes
|
|
5231
|
+
local triangles = if sizes then sizes.Triangles else nil
|
|
5232
|
+
local _condition = triangles
|
|
5233
|
+
if _condition == nil then
|
|
5234
|
+
_condition = 0
|
|
5235
|
+
end
|
|
5236
|
+
return _condition
|
|
5237
|
+
end
|
|
5238
|
+
local _condition = node.Size
|
|
5239
|
+
if _condition == nil then
|
|
5240
|
+
_condition = 0
|
|
5241
|
+
end
|
|
5242
|
+
return _condition
|
|
5243
|
+
end
|
|
5244
|
+
local function summarizeMode(mode, config, service, topN, raw)
|
|
5245
|
+
local started = os.clock()
|
|
5246
|
+
local ok, result = pcall(function()
|
|
5247
|
+
return config.query(service)
|
|
5248
|
+
end)
|
|
5249
|
+
local elapsedMs = math.floor((os.clock() - started) * 1000)
|
|
5250
|
+
if not ok then
|
|
5251
|
+
if isBetaDisabledError(result) then
|
|
5252
|
+
return betaDisabledError()
|
|
5253
|
+
end
|
|
5254
|
+
return {
|
|
5255
|
+
error = "scene_analysis_query_failed",
|
|
5256
|
+
mode = mode,
|
|
5257
|
+
method = config.method,
|
|
5258
|
+
message = tostring(result),
|
|
5259
|
+
}
|
|
5260
|
+
end
|
|
5261
|
+
local tree = result
|
|
5262
|
+
local leaves = {}
|
|
5263
|
+
flattenLeaves(tree, leaves)
|
|
5264
|
+
table.sort(leaves, function(a, b)
|
|
5265
|
+
return metric(a, config.sortByTriangles == true) > metric(b, config.sortByTriangles == true)
|
|
5266
|
+
end)
|
|
5267
|
+
local top = {}
|
|
5268
|
+
do
|
|
5269
|
+
local i = 0
|
|
5270
|
+
local _shouldIncrement = false
|
|
5271
|
+
while true do
|
|
5272
|
+
if _shouldIncrement then
|
|
5273
|
+
i += 1
|
|
5274
|
+
else
|
|
5275
|
+
_shouldIncrement = true
|
|
5276
|
+
end
|
|
5277
|
+
if not (i < math.min(topN, #leaves)) then
|
|
5278
|
+
break
|
|
5279
|
+
end
|
|
5280
|
+
local _arg0 = compactEntry(leaves[i + 1])
|
|
5281
|
+
table.insert(top, _arg0)
|
|
5282
|
+
end
|
|
5283
|
+
end
|
|
5284
|
+
local body = {
|
|
5285
|
+
mode = mode,
|
|
5286
|
+
method = config.method,
|
|
5287
|
+
elapsed_ms = elapsedMs,
|
|
5288
|
+
root = compactRoot(tree, #leaves),
|
|
5289
|
+
top = top,
|
|
5290
|
+
}
|
|
5291
|
+
if raw then
|
|
5292
|
+
body.tree = tree
|
|
5293
|
+
end
|
|
5294
|
+
return body
|
|
5295
|
+
end
|
|
5296
|
+
local function getSceneAnalysis(requestData)
|
|
5297
|
+
local mode = normalizeMode(requestData.mode)
|
|
5298
|
+
if not (type(mode) == "string") then
|
|
5299
|
+
return mode
|
|
5300
|
+
end
|
|
5301
|
+
local serviceOrError = getSceneAnalysisService()
|
|
5302
|
+
local _value = serviceOrError.IsA
|
|
5303
|
+
if not (_value ~= 0 and _value == _value and _value ~= "" and _value) then
|
|
5304
|
+
return serviceOrError
|
|
5305
|
+
end
|
|
5306
|
+
local service = serviceOrError
|
|
5307
|
+
local topN = normalizeTopN(requestData.topN)
|
|
5308
|
+
local raw = requestData.raw == true
|
|
5309
|
+
if mode ~= "all" then
|
|
5310
|
+
return summarizeMode(mode, MODE_CONFIGS[mode], service, topN, raw)
|
|
5311
|
+
end
|
|
5312
|
+
local body = {}
|
|
5313
|
+
for _, m in ALL_MODES do
|
|
5314
|
+
local result = summarizeMode(m, MODE_CONFIGS[m], service, topN, raw)
|
|
5315
|
+
if result.error == "scene_analysis_not_enabled" then
|
|
5316
|
+
return result
|
|
5317
|
+
end
|
|
5318
|
+
body[m] = result
|
|
5319
|
+
end
|
|
5320
|
+
return body
|
|
5321
|
+
end
|
|
5322
|
+
return {
|
|
5323
|
+
getSceneAnalysis = getSceneAnalysis,
|
|
5324
|
+
}
|
|
5325
|
+
]]></string>
|
|
5326
|
+
</Properties>
|
|
5327
|
+
</Item>
|
|
5328
|
+
<Item class="ModuleScript" referent="17">
|
|
5073
5329
|
<Properties>
|
|
5074
5330
|
<string name="Name">ScriptHandlers</string>
|
|
5075
5331
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -5765,7 +6021,7 @@ return {
|
|
|
5765
6021
|
]]></string>
|
|
5766
6022
|
</Properties>
|
|
5767
6023
|
</Item>
|
|
5768
|
-
<Item class="ModuleScript" referent="
|
|
6024
|
+
<Item class="ModuleScript" referent="18">
|
|
5769
6025
|
<Properties>
|
|
5770
6026
|
<string name="Name">SerializationHandlers</string>
|
|
5771
6027
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -5951,7 +6207,7 @@ return {
|
|
|
5951
6207
|
]]></string>
|
|
5952
6208
|
</Properties>
|
|
5953
6209
|
</Item>
|
|
5954
|
-
<Item class="ModuleScript" referent="
|
|
6210
|
+
<Item class="ModuleScript" referent="19">
|
|
5955
6211
|
<Properties>
|
|
5956
6212
|
<string name="Name">TestHandlers</string>
|
|
5957
6213
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -6584,7 +6840,7 @@ return {
|
|
|
6584
6840
|
</Properties>
|
|
6585
6841
|
</Item>
|
|
6586
6842
|
</Item>
|
|
6587
|
-
<Item class="ModuleScript" referent="
|
|
6843
|
+
<Item class="ModuleScript" referent="20">
|
|
6588
6844
|
<Properties>
|
|
6589
6845
|
<string name="Name">LuauExec</string>
|
|
6590
6846
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -6902,7 +7158,7 @@ return {
|
|
|
6902
7158
|
]]></string>
|
|
6903
7159
|
</Properties>
|
|
6904
7160
|
</Item>
|
|
6905
|
-
<Item class="ModuleScript" referent="
|
|
7161
|
+
<Item class="ModuleScript" referent="21">
|
|
6906
7162
|
<Properties>
|
|
6907
7163
|
<string name="Name">Recording</string>
|
|
6908
7164
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -6932,7 +7188,7 @@ return {
|
|
|
6932
7188
|
]]></string>
|
|
6933
7189
|
</Properties>
|
|
6934
7190
|
</Item>
|
|
6935
|
-
<Item class="ModuleScript" referent="
|
|
7191
|
+
<Item class="ModuleScript" referent="22">
|
|
6936
7192
|
<Properties>
|
|
6937
7193
|
<string name="Name">RenderMonitor</string>
|
|
6938
7194
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -7000,7 +7256,7 @@ return {
|
|
|
7000
7256
|
]]></string>
|
|
7001
7257
|
</Properties>
|
|
7002
7258
|
</Item>
|
|
7003
|
-
<Item class="ModuleScript" referent="
|
|
7259
|
+
<Item class="ModuleScript" referent="23">
|
|
7004
7260
|
<Properties>
|
|
7005
7261
|
<string name="Name">RuntimeLogBuffer</string>
|
|
7006
7262
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -7181,11 +7437,11 @@ return {
|
|
|
7181
7437
|
]]></string>
|
|
7182
7438
|
</Properties>
|
|
7183
7439
|
</Item>
|
|
7184
|
-
<Item class="ModuleScript" referent="
|
|
7440
|
+
<Item class="ModuleScript" referent="24">
|
|
7185
7441
|
<Properties>
|
|
7186
7442
|
<string name="Name">State</string>
|
|
7187
7443
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
7188
|
-
local CURRENT_VERSION = "2.
|
|
7444
|
+
local CURRENT_VERSION = "2.15.0"
|
|
7189
7445
|
local MAX_CONNECTIONS = 5
|
|
7190
7446
|
local BASE_PORT = 58741
|
|
7191
7447
|
local activeTabIndex = 0
|
|
@@ -7277,7 +7533,7 @@ return {
|
|
|
7277
7533
|
]]></string>
|
|
7278
7534
|
</Properties>
|
|
7279
7535
|
</Item>
|
|
7280
|
-
<Item class="ModuleScript" referent="
|
|
7536
|
+
<Item class="ModuleScript" referent="25">
|
|
7281
7537
|
<Properties>
|
|
7282
7538
|
<string name="Name">StopPlayMonitor</string>
|
|
7283
7539
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -7422,7 +7678,7 @@ return {
|
|
|
7422
7678
|
]]></string>
|
|
7423
7679
|
</Properties>
|
|
7424
7680
|
</Item>
|
|
7425
|
-
<Item class="ModuleScript" referent="
|
|
7681
|
+
<Item class="ModuleScript" referent="26">
|
|
7426
7682
|
<Properties>
|
|
7427
7683
|
<string name="Name">UI</string>
|
|
7428
7684
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -8173,7 +8429,7 @@ return {
|
|
|
8173
8429
|
]]></string>
|
|
8174
8430
|
</Properties>
|
|
8175
8431
|
</Item>
|
|
8176
|
-
<Item class="ModuleScript" referent="
|
|
8432
|
+
<Item class="ModuleScript" referent="27">
|
|
8177
8433
|
<Properties>
|
|
8178
8434
|
<string name="Name">Utils</string>
|
|
8179
8435
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -8703,11 +8959,11 @@ return {
|
|
|
8703
8959
|
</Properties>
|
|
8704
8960
|
</Item>
|
|
8705
8961
|
</Item>
|
|
8706
|
-
<Item class="Folder" referent="
|
|
8962
|
+
<Item class="Folder" referent="31">
|
|
8707
8963
|
<Properties>
|
|
8708
8964
|
<string name="Name">include</string>
|
|
8709
8965
|
</Properties>
|
|
8710
|
-
<Item class="ModuleScript" referent="
|
|
8966
|
+
<Item class="ModuleScript" referent="28">
|
|
8711
8967
|
<Properties>
|
|
8712
8968
|
<string name="Name">Promise</string>
|
|
8713
8969
|
<string name="Source"><![CDATA[--[[
|
|
@@ -10781,7 +11037,7 @@ return Promise
|
|
|
10781
11037
|
]]></string>
|
|
10782
11038
|
</Properties>
|
|
10783
11039
|
</Item>
|
|
10784
|
-
<Item class="ModuleScript" referent="
|
|
11040
|
+
<Item class="ModuleScript" referent="29">
|
|
10785
11041
|
<Properties>
|
|
10786
11042
|
<string name="Name">RuntimeLib</string>
|
|
10787
11043
|
<string name="Source"><![CDATA[local Promise = require(script.Parent.Promise)
|
|
@@ -11048,15 +11304,15 @@ return TS
|
|
|
11048
11304
|
</Properties>
|
|
11049
11305
|
</Item>
|
|
11050
11306
|
</Item>
|
|
11051
|
-
<Item class="Folder" referent="
|
|
11307
|
+
<Item class="Folder" referent="32">
|
|
11052
11308
|
<Properties>
|
|
11053
11309
|
<string name="Name">node_modules</string>
|
|
11054
11310
|
</Properties>
|
|
11055
|
-
<Item class="Folder" referent="
|
|
11311
|
+
<Item class="Folder" referent="33">
|
|
11056
11312
|
<Properties>
|
|
11057
11313
|
<string name="Name">@rbxts</string>
|
|
11058
11314
|
</Properties>
|
|
11059
|
-
<Item class="ModuleScript" referent="
|
|
11315
|
+
<Item class="ModuleScript" referent="30">
|
|
11060
11316
|
<Properties>
|
|
11061
11317
|
<string name="Name">services</string>
|
|
11062
11318
|
<string name="Source"><![CDATA[return setmetatable({}, {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { HttpService, Players, ReplicatedStorage, RunService, ServerStorage } from "@rbxts/services";
|
|
2
2
|
import RuntimeLogBuffer from "./RuntimeLogBuffer";
|
|
3
3
|
import MemoryHandlers from "./handlers/MemoryHandlers";
|
|
4
|
+
import SceneAnalysisHandlers from "./handlers/SceneAnalysisHandlers";
|
|
4
5
|
import CaptureHandlers from "./handlers/CaptureHandlers";
|
|
5
6
|
import InputHandlers from "./handlers/InputHandlers";
|
|
6
7
|
import LuauExec from "./LuauExec";
|
|
@@ -87,6 +88,7 @@ const CLIENT_BROKER_ALLOWED_ENDPOINTS = new Set<string>([
|
|
|
87
88
|
"/api/execute-luau",
|
|
88
89
|
"/api/get-runtime-logs",
|
|
89
90
|
"/api/get-memory-breakdown",
|
|
91
|
+
"/api/get-scene-analysis",
|
|
90
92
|
"/api/multiplayer-test-state",
|
|
91
93
|
"/api/multiplayer-test-leave-client",
|
|
92
94
|
// Screenshot capture must run in the client peer (CaptureService captures
|
|
@@ -224,7 +226,7 @@ function handleMultiplayerTestLeaveClient(): unknown {
|
|
|
224
226
|
function setupClientBroker() {
|
|
225
227
|
const rf = ReplicatedStorage.WaitForChild(BROKER_NAME, 10);
|
|
226
228
|
if (!rf || !rf.IsA("RemoteFunction")) {
|
|
227
|
-
warn(`[
|
|
229
|
+
warn(`[robloxstudio-mcp] client: ${BROKER_NAME} not found`);
|
|
228
230
|
return;
|
|
229
231
|
}
|
|
230
232
|
rf.OnClientInvoke = (payload: BrokerEnvelope | undefined) => {
|
|
@@ -240,6 +242,9 @@ function setupClientBroker() {
|
|
|
240
242
|
if (payload && payload.endpoint === "/api/get-memory-breakdown") {
|
|
241
243
|
return MemoryHandlers.getMemoryBreakdown(payload.data ?? {});
|
|
242
244
|
}
|
|
245
|
+
if (payload && payload.endpoint === "/api/get-scene-analysis") {
|
|
246
|
+
return SceneAnalysisHandlers.getSceneAnalysis(payload.data ?? {});
|
|
247
|
+
}
|
|
243
248
|
if (payload && payload.endpoint === "/api/multiplayer-test-state") {
|
|
244
249
|
return handleMultiplayerTestState();
|
|
245
250
|
}
|
|
@@ -326,7 +331,7 @@ function registerProxy(player: Player, rf: RemoteFunction) {
|
|
|
326
331
|
isRunning: RunService.IsRunning(),
|
|
327
332
|
});
|
|
328
333
|
if (!ok || !res || !res.Success) {
|
|
329
|
-
warn(`[
|
|
334
|
+
warn(`[robloxstudio-mcp] proxy register failed for ${player.Name}`);
|
|
330
335
|
return;
|
|
331
336
|
}
|
|
332
337
|
const body = HttpService.JSONDecode(res.Body) as ReadyResponseBody;
|
|
@@ -16,6 +16,7 @@ import InputHandlers from "./handlers/InputHandlers";
|
|
|
16
16
|
import LogHandlers from "./handlers/LogHandlers";
|
|
17
17
|
import SerializationHandlers from "./handlers/SerializationHandlers";
|
|
18
18
|
import MemoryHandlers from "./handlers/MemoryHandlers";
|
|
19
|
+
import SceneAnalysisHandlers from "./handlers/SceneAnalysisHandlers";
|
|
19
20
|
import { Connection, RequestPayload, PollResponse, ReadyResponse } from "../types";
|
|
20
21
|
|
|
21
22
|
// Per-plugin-load random GUID. Used as the /poll URL param so the server
|
|
@@ -162,6 +163,7 @@ const routeMap: Record<string, Handler> = {
|
|
|
162
163
|
"/api/import-rbxm": SerializationHandlers.importRbxm,
|
|
163
164
|
|
|
164
165
|
"/api/get-memory-breakdown": MemoryHandlers.getMemoryBreakdown,
|
|
166
|
+
"/api/get-scene-analysis": SceneAnalysisHandlers.getSceneAnalysis,
|
|
165
167
|
};
|
|
166
168
|
|
|
167
169
|
function processRequest(request: RequestPayload): unknown {
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
interface SceneAnalysisServiceLike extends Instance {
|
|
2
|
+
GetInstanceCompositionAsync(this: SceneAnalysisServiceLike): unknown;
|
|
3
|
+
GetScriptMemoryAsync(this: SceneAnalysisServiceLike): unknown;
|
|
4
|
+
GetUnparentedInstancesAsync(this: SceneAnalysisServiceLike): unknown;
|
|
5
|
+
GetTriangleCompositionAsync(this: SceneAnalysisServiceLike): unknown;
|
|
6
|
+
GetAnimationMemoryAsync(this: SceneAnalysisServiceLike): unknown;
|
|
7
|
+
GetAudioMemoryAsync(this: SceneAnalysisServiceLike): unknown;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface SceneAnalysisNode {
|
|
11
|
+
Name?: string;
|
|
12
|
+
Size?: number;
|
|
13
|
+
Sizes?: Record<string, number>;
|
|
14
|
+
Children?: SceneAnalysisNode[];
|
|
15
|
+
AssetId?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface ModeConfig {
|
|
19
|
+
method: string;
|
|
20
|
+
query: (service: SceneAnalysisServiceLike) => unknown;
|
|
21
|
+
sortByTriangles?: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const MODE_CONFIGS: Record<string, ModeConfig> = {
|
|
25
|
+
instance_composition: {
|
|
26
|
+
method: "GetInstanceCompositionAsync",
|
|
27
|
+
query: (service) => service.GetInstanceCompositionAsync(),
|
|
28
|
+
},
|
|
29
|
+
script_memory: {
|
|
30
|
+
method: "GetScriptMemoryAsync",
|
|
31
|
+
query: (service) => service.GetScriptMemoryAsync(),
|
|
32
|
+
},
|
|
33
|
+
unparented_instances: {
|
|
34
|
+
method: "GetUnparentedInstancesAsync",
|
|
35
|
+
query: (service) => service.GetUnparentedInstancesAsync(),
|
|
36
|
+
},
|
|
37
|
+
triangle_composition: {
|
|
38
|
+
method: "GetTriangleCompositionAsync",
|
|
39
|
+
query: (service) => service.GetTriangleCompositionAsync(),
|
|
40
|
+
sortByTriangles: true,
|
|
41
|
+
},
|
|
42
|
+
animation_memory: {
|
|
43
|
+
method: "GetAnimationMemoryAsync",
|
|
44
|
+
query: (service) => service.GetAnimationMemoryAsync(),
|
|
45
|
+
},
|
|
46
|
+
audio_memory: {
|
|
47
|
+
method: "GetAudioMemoryAsync",
|
|
48
|
+
query: (service) => service.GetAudioMemoryAsync(),
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const ALL_MODES = [
|
|
53
|
+
"instance_composition",
|
|
54
|
+
"script_memory",
|
|
55
|
+
"unparented_instances",
|
|
56
|
+
"triangle_composition",
|
|
57
|
+
"animation_memory",
|
|
58
|
+
"audio_memory",
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
function betaDisabledError(): Record<string, unknown> {
|
|
62
|
+
return {
|
|
63
|
+
error: "scene_analysis_not_enabled",
|
|
64
|
+
message: "SceneAnalysisService is not enabled. Enable Scene Analysis in Studio Beta Features and restart Studio.",
|
|
65
|
+
betaFeatureRequired: true,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function isBetaDisabledError(value: unknown): boolean {
|
|
70
|
+
return typeIs(value, "string") && string.find(value, "SceneAnalysisService is not enabled", 1, true)[0] !== undefined;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function getSceneAnalysisService(): SceneAnalysisServiceLike | Record<string, unknown> {
|
|
74
|
+
const provider = game as unknown as { GetService(serviceName: string): Instance };
|
|
75
|
+
const [ok, service] = pcall(() => provider.GetService("SceneAnalysisService") as SceneAnalysisServiceLike);
|
|
76
|
+
if (!ok || !service) {
|
|
77
|
+
return {
|
|
78
|
+
error: "scene_analysis_unavailable",
|
|
79
|
+
message: `SceneAnalysisService is unavailable: ${tostring(service)}`,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
return service;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function normalizeMode(mode: unknown): string | Record<string, unknown> {
|
|
86
|
+
if (mode === undefined || mode === "all") return "all";
|
|
87
|
+
if (!typeIs(mode, "string") || MODE_CONFIGS[mode] === undefined) {
|
|
88
|
+
return {
|
|
89
|
+
error: "invalid_mode",
|
|
90
|
+
message: `mode must be one of: all, ${ALL_MODES.join(", ")}`,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
return mode;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function normalizeTopN(topN: unknown): number {
|
|
97
|
+
if (!typeIs(topN, "number")) return 10;
|
|
98
|
+
return math.clamp(math.floor(topN), 1, 100);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function countLeaves(node: SceneAnalysisNode): number {
|
|
102
|
+
const children = node.Children;
|
|
103
|
+
if (children && children.size() > 0) {
|
|
104
|
+
let total = 0;
|
|
105
|
+
for (const child of children) total += countLeaves(child);
|
|
106
|
+
return total;
|
|
107
|
+
}
|
|
108
|
+
return 1;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function flattenLeaves(node: SceneAnalysisNode, out: SceneAnalysisNode[]): void {
|
|
112
|
+
const children = node.Children;
|
|
113
|
+
if (children && children.size() > 0) {
|
|
114
|
+
for (const child of children) flattenLeaves(child, out);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
out.push(node);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function compactEntry(node: SceneAnalysisNode): Record<string, unknown> {
|
|
121
|
+
const entry: Record<string, unknown> = {
|
|
122
|
+
name: node.Name,
|
|
123
|
+
};
|
|
124
|
+
if (node.Size !== undefined) entry.size = node.Size;
|
|
125
|
+
if (node.Sizes !== undefined) entry.sizes = node.Sizes;
|
|
126
|
+
if (node.AssetId !== undefined) entry.asset_id = node.AssetId;
|
|
127
|
+
return entry;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function compactRoot(node: SceneAnalysisNode, leafCount: number): Record<string, unknown> {
|
|
131
|
+
const children = node.Children;
|
|
132
|
+
const root: Record<string, unknown> = {
|
|
133
|
+
name: node.Name,
|
|
134
|
+
child_count: children ? children.size() : 0,
|
|
135
|
+
leaf_count: leafCount,
|
|
136
|
+
};
|
|
137
|
+
if (node.Size !== undefined) root.size = node.Size;
|
|
138
|
+
if (node.Sizes !== undefined) root.sizes = node.Sizes;
|
|
139
|
+
return root;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function metric(node: SceneAnalysisNode, sortByTriangles: boolean): number {
|
|
143
|
+
if (sortByTriangles) {
|
|
144
|
+
const sizes = node.Sizes;
|
|
145
|
+
const triangles = sizes ? sizes.Triangles : undefined;
|
|
146
|
+
return triangles ?? 0;
|
|
147
|
+
}
|
|
148
|
+
return node.Size ?? 0;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function summarizeMode(
|
|
152
|
+
mode: string,
|
|
153
|
+
config: ModeConfig,
|
|
154
|
+
service: SceneAnalysisServiceLike,
|
|
155
|
+
topN: number,
|
|
156
|
+
raw: boolean,
|
|
157
|
+
): Record<string, unknown> {
|
|
158
|
+
const started = os.clock();
|
|
159
|
+
const [ok, result] = pcall(() => config.query(service) as SceneAnalysisNode);
|
|
160
|
+
const elapsedMs = math.floor((os.clock() - started) * 1000);
|
|
161
|
+
|
|
162
|
+
if (!ok) {
|
|
163
|
+
if (isBetaDisabledError(result)) return betaDisabledError();
|
|
164
|
+
return {
|
|
165
|
+
error: "scene_analysis_query_failed",
|
|
166
|
+
mode,
|
|
167
|
+
method: config.method,
|
|
168
|
+
message: tostring(result),
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const tree = result as SceneAnalysisNode;
|
|
173
|
+
const leaves: SceneAnalysisNode[] = [];
|
|
174
|
+
flattenLeaves(tree, leaves);
|
|
175
|
+
leaves.sort((a, b) => metric(a, config.sortByTriangles === true) > metric(b, config.sortByTriangles === true));
|
|
176
|
+
|
|
177
|
+
const top: Record<string, unknown>[] = [];
|
|
178
|
+
for (let i = 0; i < math.min(topN, leaves.size()); i++) {
|
|
179
|
+
top.push(compactEntry(leaves[i]));
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const body: Record<string, unknown> = {
|
|
183
|
+
mode,
|
|
184
|
+
method: config.method,
|
|
185
|
+
elapsed_ms: elapsedMs,
|
|
186
|
+
root: compactRoot(tree, leaves.size()),
|
|
187
|
+
top,
|
|
188
|
+
};
|
|
189
|
+
if (raw) body.tree = tree;
|
|
190
|
+
return body;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function getSceneAnalysis(requestData: Record<string, unknown>): unknown {
|
|
194
|
+
const mode = normalizeMode(requestData.mode);
|
|
195
|
+
if (!typeIs(mode, "string")) return mode;
|
|
196
|
+
|
|
197
|
+
const serviceOrError = getSceneAnalysisService();
|
|
198
|
+
if (!serviceOrError.IsA) return serviceOrError;
|
|
199
|
+
const service = serviceOrError as SceneAnalysisServiceLike;
|
|
200
|
+
const topN = normalizeTopN(requestData.topN);
|
|
201
|
+
const raw = requestData.raw === true;
|
|
202
|
+
|
|
203
|
+
if (mode !== "all") {
|
|
204
|
+
return summarizeMode(mode, MODE_CONFIGS[mode], service, topN, raw);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const body: Record<string, unknown> = {};
|
|
208
|
+
for (const m of ALL_MODES) {
|
|
209
|
+
const result = summarizeMode(m, MODE_CONFIGS[m], service, topN, raw);
|
|
210
|
+
if (result.error === "scene_analysis_not_enabled") return result;
|
|
211
|
+
body[m] = result;
|
|
212
|
+
}
|
|
213
|
+
return body;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export = { getSceneAnalysis };
|