@chrrxs/robloxstudio-mcp-inspector 3.1.4 → 3.1.6
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 +1865 -618
- package/package.json +1 -1
- package/studio-plugin/MCPInspectorPlugin.rbxmx +1003 -139
|
@@ -607,6 +607,7 @@ local HttpService = _services.HttpService
|
|
|
607
607
|
local Players = _services.Players
|
|
608
608
|
local ReplicatedStorage = _services.ReplicatedStorage
|
|
609
609
|
local RunService = _services.RunService
|
|
610
|
+
local Workspace = _services.Workspace
|
|
610
611
|
local RuntimeLogBuffer = TS.import(script, script.Parent, "RuntimeLogBuffer")
|
|
611
612
|
local MemoryHandlers = TS.import(script, script.Parent, "handlers", "MemoryHandlers")
|
|
612
613
|
local SceneAnalysisHandlers = TS.import(script, script.Parent, "handlers", "SceneAnalysisHandlers")
|
|
@@ -633,6 +634,11 @@ local mcpUrl = DEFAULT_MCP_URL
|
|
|
633
634
|
local BROKER_NAME = "__MCPClientBroker"
|
|
634
635
|
local BROKER_OWNER_ATTRIBUTE = "__MCPBrokerOwner"
|
|
635
636
|
local CLIENT_IDENTITY_KIND = "identity"
|
|
637
|
+
-- A normal capture needs at most 15s sampling + 5s waiting for profiler data.
|
|
638
|
+
-- Leave room for processing, then return a diagnostic before the outer 30s wait.
|
|
639
|
+
local CLIENT_PROFILER_MAX_WAIT_SECONDS = 25
|
|
640
|
+
local CLIENT_PROFILER_RESPONSE_RESERVE_SECONDS = 5
|
|
641
|
+
local CLIENT_PROFILER_POLL_SECONDS = 0.05
|
|
636
642
|
-- Endpoints the server-peer broker is allowed to forward to the client peer.
|
|
637
643
|
-- Each requires the client peer's plugin VM (because the buffer / require
|
|
638
644
|
-- cache / etc. lives there) so the server peer alone can't satisfy them.
|
|
@@ -649,6 +655,7 @@ local CLIENT_BROKER_ALLOWED_ENDPOINTS = {
|
|
|
649
655
|
["/api/multiplayer-test-leave-client"] = true,
|
|
650
656
|
["/api/capture-begin"] = true,
|
|
651
657
|
["/api/capture-studio"] = true,
|
|
658
|
+
["/api/capture-markers"] = true,
|
|
652
659
|
["/api/simulate-mouse-input"] = true,
|
|
653
660
|
["/api/simulate-keyboard-input"] = true,
|
|
654
661
|
["/api/focus-viewport"] = true,
|
|
@@ -836,6 +843,32 @@ local function setupClientBroker(attempt)
|
|
|
836
843
|
return BreakpointHandlers.breakpoints(payload.data or {})
|
|
837
844
|
end
|
|
838
845
|
if payload and payload.endpoint == "/api/capture-script-profiler" then
|
|
846
|
+
-- os.clock is VM-local. Only the engine-synchronized server clock can
|
|
847
|
+
-- reject an envelope that expired while travelling to this client.
|
|
848
|
+
local _remainingMs = payload.remainingMs
|
|
849
|
+
local _condition_1 = type(_remainingMs) == "number"
|
|
850
|
+
if _condition_1 then
|
|
851
|
+
_condition_1 = payload.remainingMs <= 0
|
|
852
|
+
end
|
|
853
|
+
local _condition_2 = _condition_1
|
|
854
|
+
if not _condition_2 then
|
|
855
|
+
local _expiresAtServerTime = payload.expiresAtServerTime
|
|
856
|
+
local _condition_3 = type(_expiresAtServerTime) == "number"
|
|
857
|
+
if _condition_3 then
|
|
858
|
+
_condition_3 = Workspace:GetServerTimeNow() >= payload.expiresAtServerTime
|
|
859
|
+
end
|
|
860
|
+
_condition_2 = _condition_3
|
|
861
|
+
end
|
|
862
|
+
if _condition_2 then
|
|
863
|
+
return {
|
|
864
|
+
success = false,
|
|
865
|
+
error = "client_broker_expired",
|
|
866
|
+
stage = "client_broker_pre_start",
|
|
867
|
+
requestId = payload.requestId,
|
|
868
|
+
executionOutcome = "not_executed",
|
|
869
|
+
message = "Client profiler request expired before capture started.",
|
|
870
|
+
}
|
|
871
|
+
end
|
|
839
872
|
return ScriptProfilerHandlers.captureScriptProfiler(payload.data or {})
|
|
840
873
|
end
|
|
841
874
|
if payload and payload.endpoint == "/api/capture-micro-profiler" then
|
|
@@ -855,6 +888,9 @@ local function setupClientBroker(attempt)
|
|
|
855
888
|
return CaptureHandlers.captureStudio(payload.data or {})
|
|
856
889
|
end)
|
|
857
890
|
end
|
|
891
|
+
if payload and payload.endpoint == "/api/capture-markers" then
|
|
892
|
+
return CaptureHandlers.captureMarkers(payload.data or {})
|
|
893
|
+
end
|
|
858
894
|
if payload and payload.endpoint == CaptureTransfer.READ_ENDPOINT then
|
|
859
895
|
return CaptureTransfer.read(payload.data or {})
|
|
860
896
|
end
|
|
@@ -1133,7 +1169,112 @@ local function refreshAllProxyRegistrations()
|
|
|
1133
1169
|
task.spawn(registerProxyEntry, entry)
|
|
1134
1170
|
end
|
|
1135
1171
|
end
|
|
1136
|
-
local function
|
|
1172
|
+
local function invokeClientProfiler(entry, target, data, context)
|
|
1173
|
+
local startedAt = os.clock()
|
|
1174
|
+
local deadlineAt = math.min(context.deadlineAt - CLIENT_PROFILER_RESPONSE_RESERVE_SECONDS, startedAt + CLIENT_PROFILER_MAX_WAIT_SECONDS)
|
|
1175
|
+
local failure = function(code, stage, outcome, detail)
|
|
1176
|
+
context.executionOutcome = outcome
|
|
1177
|
+
return {
|
|
1178
|
+
success = false,
|
|
1179
|
+
error = code,
|
|
1180
|
+
stage = stage,
|
|
1181
|
+
requestId = context.requestId,
|
|
1182
|
+
peerId = entry.peerId,
|
|
1183
|
+
target = target,
|
|
1184
|
+
elapsedMs = math.floor(math.max(0, os.clock() - startedAt) * 1000),
|
|
1185
|
+
executionOutcome = outcome,
|
|
1186
|
+
message = if outcome == "unknown" then `{detail} Only the broker's local wait ended; remote execution may still be running. No retry was started.` else `{detail} Client profiler execution was not started.`,
|
|
1187
|
+
}
|
|
1188
|
+
end
|
|
1189
|
+
if context.isCancelled() then
|
|
1190
|
+
return failure("client_broker_cancelled", "client_broker_admission", "not_executed", "Request cancelled before client invocation.")
|
|
1191
|
+
end
|
|
1192
|
+
if startedAt >= deadlineAt then
|
|
1193
|
+
return failure("client_broker_timeout", "client_broker_admission", "not_executed", "Insufficient request budget for client invocation and response delivery.")
|
|
1194
|
+
end
|
|
1195
|
+
local interrupted = function()
|
|
1196
|
+
if context.isCancelled() then
|
|
1197
|
+
return failure("client_broker_cancelled", "client_broker_wait", "unknown", "Request cancelled while awaiting the client profiler response.")
|
|
1198
|
+
end
|
|
1199
|
+
local _peerId = entry.peerId
|
|
1200
|
+
local _condition = proxyByPeerId[_peerId] ~= entry
|
|
1201
|
+
if not _condition then
|
|
1202
|
+
local _player = entry.player
|
|
1203
|
+
_condition = proxyByPlayer[_player] ~= entry
|
|
1204
|
+
if not _condition then
|
|
1205
|
+
_condition = entry.player.Parent == nil or entry.remote.Parent == nil or not RunService:IsRunning()
|
|
1206
|
+
end
|
|
1207
|
+
end
|
|
1208
|
+
if _condition then
|
|
1209
|
+
return failure("client_broker_disconnected", "client_broker_wait", "unknown", "Client proxy disconnected while awaiting the profiler response.")
|
|
1210
|
+
end
|
|
1211
|
+
if os.clock() >= deadlineAt then
|
|
1212
|
+
return failure("client_broker_timeout", "client_broker_wait", "unknown", "Client profiler round-trip exceeded its response deadline.")
|
|
1213
|
+
end
|
|
1214
|
+
return nil
|
|
1215
|
+
end
|
|
1216
|
+
local remainingMs = math.max(0, math.floor((deadlineAt - os.clock()) * 1000))
|
|
1217
|
+
local envelope = {
|
|
1218
|
+
endpoint = "/api/capture-script-profiler",
|
|
1219
|
+
data = data,
|
|
1220
|
+
requestId = context.requestId,
|
|
1221
|
+
remainingMs = remainingMs,
|
|
1222
|
+
expiresAtServerTime = Workspace:GetServerTimeNow() + remainingMs / 1000,
|
|
1223
|
+
}
|
|
1224
|
+
local settled = false
|
|
1225
|
+
local response
|
|
1226
|
+
context.executionOutcome = "unknown"
|
|
1227
|
+
local invocation = task.spawn(function()
|
|
1228
|
+
local ok, result = pcall(function()
|
|
1229
|
+
return entry.remote:InvokeClient(entry.player, envelope)
|
|
1230
|
+
end)
|
|
1231
|
+
-- Cancelling a suspended engine wait is best effort. A late return must
|
|
1232
|
+
-- never replace the chosen outcome, even if task.cancel was unavailable.
|
|
1233
|
+
if settled then
|
|
1234
|
+
return nil
|
|
1235
|
+
end
|
|
1236
|
+
local interruption = interrupted()
|
|
1237
|
+
if interruption ~= nil then
|
|
1238
|
+
response = interruption
|
|
1239
|
+
elseif not ok then
|
|
1240
|
+
response = failure("client_broker_invoke_failed", "client_broker_wait", "unknown", `InvokeClient failed: {tostring(result)}`)
|
|
1241
|
+
elseif result == nil then
|
|
1242
|
+
response = failure("client_broker_nil_response", "client_broker_wait", "unknown", "Client profiler returned no response.")
|
|
1243
|
+
else
|
|
1244
|
+
response = result
|
|
1245
|
+
-- Only this endpoint's broker pre-start refusal establishes that no
|
|
1246
|
+
-- remote capture ran. Other responses retain normal handler outcome
|
|
1247
|
+
-- classification; arbitrary payload fields cannot override transport.
|
|
1248
|
+
context.executionOutcome = nil
|
|
1249
|
+
if type(result) == "table" then
|
|
1250
|
+
local reply = result
|
|
1251
|
+
if reply.error == "client_broker_expired" and reply.stage == "client_broker_pre_start" then
|
|
1252
|
+
context.executionOutcome = "not_executed"
|
|
1253
|
+
end
|
|
1254
|
+
end
|
|
1255
|
+
end
|
|
1256
|
+
settled = true
|
|
1257
|
+
end)
|
|
1258
|
+
while not settled do
|
|
1259
|
+
local interruption = interrupted()
|
|
1260
|
+
if interruption ~= nil then
|
|
1261
|
+
response = interruption
|
|
1262
|
+
settled = true
|
|
1263
|
+
-- This only releases the local coroutine; it cannot stop the remote
|
|
1264
|
+
-- OnClientInvoke callback or roll back an already-started capture.
|
|
1265
|
+
pcall(function()
|
|
1266
|
+
return task.cancel(invocation)
|
|
1267
|
+
end)
|
|
1268
|
+
break
|
|
1269
|
+
end
|
|
1270
|
+
task.wait(math.min(CLIENT_PROFILER_POLL_SECONDS, deadlineAt - os.clock()))
|
|
1271
|
+
end
|
|
1272
|
+
return response
|
|
1273
|
+
end
|
|
1274
|
+
local function dispatchClientRequest(peerId, target, endpoint, data, context)
|
|
1275
|
+
if endpoint == "/api/capture-script-profiler" then
|
|
1276
|
+
context.executionOutcome = "not_executed"
|
|
1277
|
+
end
|
|
1137
1278
|
local _peerId = peerId
|
|
1138
1279
|
local entry = proxyByPeerId[_peerId]
|
|
1139
1280
|
local _condition = not entry
|
|
@@ -1173,6 +1314,9 @@ local function dispatchClientRequest(peerId, target, endpoint, data)
|
|
|
1173
1314
|
error = `Client-proxy does not forward {endpoint}. Allowed: {table.concat(allowed, ", ")}.`,
|
|
1174
1315
|
}
|
|
1175
1316
|
end
|
|
1317
|
+
if endpoint == "/api/capture-script-profiler" then
|
|
1318
|
+
return invokeClientProfiler(entry, target, data, context)
|
|
1319
|
+
end
|
|
1176
1320
|
if endpoint == "/api/capture-studio" then
|
|
1177
1321
|
return CaptureTransfer.receive(function(captureEndpoint, captureData)
|
|
1178
1322
|
return entry.remote:InvokeClient(entry.player, {
|
|
@@ -1332,6 +1476,7 @@ local routeMap = {
|
|
|
1332
1476
|
["/api/capture-studio"] = CaptureHandlers.captureStudio,
|
|
1333
1477
|
["/api/capture-begin"] = CaptureHandlers.captureBegin,
|
|
1334
1478
|
["/api/capture-read"] = CaptureHandlers.captureRead,
|
|
1479
|
+
["/api/capture-markers"] = CaptureHandlers.captureMarkers,
|
|
1335
1480
|
["/api/simulate-mouse-input"] = InputHandlers.simulateMouseInput,
|
|
1336
1481
|
["/api/simulate-keyboard-input"] = InputHandlers.simulateKeyboardInput,
|
|
1337
1482
|
["/api/find-and-replace-in-scripts"] = ScriptHandlers.findAndReplaceInScripts,
|
|
@@ -1372,7 +1517,7 @@ local function getConnectionStatus()
|
|
|
1372
1517
|
end
|
|
1373
1518
|
local function dispatchSocketRequest(request, context)
|
|
1374
1519
|
if request.peerId ~= PluginSession.peerId then
|
|
1375
|
-
return ClientBroker.dispatchClientRequest(request.peerId, request.target, request.endpoint, request.data)
|
|
1520
|
+
return ClientBroker.dispatchClientRequest(request.peerId, request.target, request.endpoint, request.data, context)
|
|
1376
1521
|
end
|
|
1377
1522
|
local _condition = assignedRole
|
|
1378
1523
|
if _condition == nil then
|
|
@@ -1427,34 +1572,9 @@ local function handleTransportUpdate(update)
|
|
|
1427
1572
|
if not conn.isActive then
|
|
1428
1573
|
return nil
|
|
1429
1574
|
end
|
|
1430
|
-
|
|
1431
|
-
conn.lastHttpOk = true
|
|
1432
|
-
conn.lastMcpOk = false
|
|
1433
|
-
conn.consecutiveFailures = 0
|
|
1434
|
-
conn.currentRetryDelay = 0.5
|
|
1435
|
-
conn.mcpWaitStartTime = tick()
|
|
1436
|
-
else
|
|
1437
|
-
conn.lastHttpOk = false
|
|
1438
|
-
conn.lastMcpOk = false
|
|
1439
|
-
conn.consecutiveFailures = update.attempt
|
|
1440
|
-
if update.retryDelay > 0 then
|
|
1441
|
-
conn.currentRetryDelay = update.retryDelay
|
|
1442
|
-
end
|
|
1443
|
-
conn.mcpWaitStartTime = nil
|
|
1444
|
-
end
|
|
1575
|
+
State.applyTransportUpdate(update, tick())
|
|
1445
1576
|
UI.updateUIState()
|
|
1446
1577
|
UI.updateToolbarIcon()
|
|
1447
|
-
if update.state == "waiting-duplicate" then
|
|
1448
|
-
local ui = UI.getElements()
|
|
1449
|
-
ui.statusLabel.Text = "Waiting for previous instance"
|
|
1450
|
-
ui.statusLabel.TextColor3 = Color3.fromRGB(245, 158, 11)
|
|
1451
|
-
local _condition = update.detail
|
|
1452
|
-
if _condition == nil then
|
|
1453
|
-
_condition = "The previous plugin instance is still active."
|
|
1454
|
-
end
|
|
1455
|
-
ui.detailStatusLabel.Text = _condition
|
|
1456
|
-
ui.detailStatusLabel.TextColor3 = Color3.fromRGB(245, 158, 11)
|
|
1457
|
-
end
|
|
1458
1578
|
end
|
|
1459
1579
|
local nameChangeConn
|
|
1460
1580
|
local placeIdChangeConn
|
|
@@ -1505,6 +1625,7 @@ local function activatePlugin()
|
|
|
1505
1625
|
conn.lastHttpOk = false
|
|
1506
1626
|
conn.lastMcpOk = false
|
|
1507
1627
|
conn.mcpWaitStartTime = nil
|
|
1628
|
+
State.clearTransportDiagnostics()
|
|
1508
1629
|
local normalizedUrl = ServerUrlSettings.normalizeServerUrl(ui.urlInput.Text)
|
|
1509
1630
|
conn.serverUrl = if normalizedUrl ~= "" then normalizedUrl else conn.serverUrl
|
|
1510
1631
|
if conn.serverUrl == "" then
|
|
@@ -1527,6 +1648,7 @@ local function activatePlugin()
|
|
|
1527
1648
|
onTransportUpdate = handleTransportUpdate,
|
|
1528
1649
|
})
|
|
1529
1650
|
if not conn.heartbeatConnection then
|
|
1651
|
+
local lastDiagnosticUpdate = tick()
|
|
1530
1652
|
conn.heartbeatConnection = RunService.Heartbeat:Connect(function()
|
|
1531
1653
|
if initialRole == "server" and not RunService:IsRunning() then
|
|
1532
1654
|
ClientBroker.disconnectAllProxies()
|
|
@@ -1539,6 +1661,11 @@ local function activatePlugin()
|
|
|
1539
1661
|
PluginSession.invalidatePlaceName()
|
|
1540
1662
|
StudioWebSocket.refresh()
|
|
1541
1663
|
end
|
|
1664
|
+
local now = tick()
|
|
1665
|
+
if conn.nextRetryAt ~= nil and now - lastDiagnosticUpdate >= 0.25 then
|
|
1666
|
+
lastDiagnosticUpdate = now
|
|
1667
|
+
UI.updateUIState()
|
|
1668
|
+
end
|
|
1542
1669
|
end)
|
|
1543
1670
|
end
|
|
1544
1671
|
if initialRole == "edit" then
|
|
@@ -1555,6 +1682,7 @@ function deactivatePlugin()
|
|
|
1555
1682
|
conn.lastHttpOk = false
|
|
1556
1683
|
conn.lastMcpOk = false
|
|
1557
1684
|
conn.mcpWaitStartTime = nil
|
|
1685
|
+
State.clearTransportDiagnostics()
|
|
1558
1686
|
StudioWebSocket.stop()
|
|
1559
1687
|
disconnectIdentityWatchers()
|
|
1560
1688
|
if initialRole == "server" then
|
|
@@ -1814,9 +1942,9 @@ local function computeBridgeStamp()
|
|
|
1814
1942
|
for i = 1, #combined do
|
|
1815
1943
|
h = (h * 33 + (string.byte(combined, i))) % 2147483647
|
|
1816
1944
|
end
|
|
1817
|
-
-- "3.1.
|
|
1945
|
+
-- "3.1.6" is replaced with the package version at package time
|
|
1818
1946
|
-- (scripts/build-plugin.mjs injectVersion), so a release bump also restamps.
|
|
1819
|
-
return `{tostring(h)}-3.1.
|
|
1947
|
+
return `{tostring(h)}-3.1.6`
|
|
1820
1948
|
end
|
|
1821
1949
|
local BRIDGE_STAMP = computeBridgeStamp()
|
|
1822
1950
|
local function setSource(scriptInst, source)
|
|
@@ -2930,6 +3058,8 @@ local RenderMonitor = TS.import(script, script.Parent.Parent, "RenderMonitor")
|
|
|
2930
3058
|
local CaptureService = game:GetService("CaptureService")
|
|
2931
3059
|
local AssetService = game:GetService("AssetService")
|
|
2932
3060
|
local Workspace = game:GetService("Workspace")
|
|
3061
|
+
local CoreGui = game:GetService("CoreGui")
|
|
3062
|
+
local RunService = game:GetService("RunService")
|
|
2933
3063
|
local MAX_TILE_SIZE = 1024
|
|
2934
3064
|
local MAX_RAW_PIXEL_BYTES = 36 * 1024 * 1024
|
|
2935
3065
|
local MAX_CREATED_IMAGE_DIM = 2048
|
|
@@ -2941,10 +3071,16 @@ local PAD_BYTE = (string.byte("="))
|
|
|
2941
3071
|
-- ViewportSize, which is exactly the coordinate space simulate_mouse_input
|
|
2942
3072
|
-- expects. It is gated behind Studio FFlags and is missing from @rbxts/types,
|
|
2943
3073
|
-- hence the local structural declarations below.
|
|
3074
|
+
-- Studio exposes this API at runtime, but the installed @rbxts/types predates it.
|
|
2944
3075
|
-- Unchecked cast, single reason: these enums exist in Studio but are missing
|
|
2945
3076
|
-- from @rbxts/types, so the global Enum table has to be read dynamically.
|
|
2946
3077
|
local ENUM_TABLE = Enum
|
|
2947
3078
|
local STUDIO_CAPTURE_FORMATS = ENUM_TABLE.StudioCaptureScreenshotFormat
|
|
3079
|
+
local _FIT_TO_WINDOW = ENUM_TABLE.DeviceSimulatorScalingMode
|
|
3080
|
+
if _FIT_TO_WINDOW ~= nil then
|
|
3081
|
+
_FIT_TO_WINDOW = _FIT_TO_WINDOW.FitToWindow
|
|
3082
|
+
end
|
|
3083
|
+
local FIT_TO_WINDOW = _FIT_TO_WINDOW
|
|
2948
3084
|
-- Measured on a 1980x1032 viewport: RGBA8 completes in ~0.16s and PNG in ~1s.
|
|
2949
3085
|
-- A capture that is still Pending well past that never completes (it happens
|
|
2950
3086
|
-- while a playtest owns the renderer), so we stop waiting and let the caller
|
|
@@ -3059,7 +3195,11 @@ end
|
|
|
3059
3195
|
-- pixels back is blocked, but capturing is not). The returned rbxtemp:// id is
|
|
3060
3196
|
-- a process-scoped handle: it can be dereferenced from a DIFFERENT, more
|
|
3061
3197
|
-- privileged DM (the edit DM) — see captureRead.
|
|
3198
|
+
local withViewportRead, captureScreenshotFrame
|
|
3062
3199
|
local function doCaptureScreenshot()
|
|
3200
|
+
return withViewportRead(captureScreenshotFrame)
|
|
3201
|
+
end
|
|
3202
|
+
function captureScreenshotFrame()
|
|
3063
3203
|
-- Fast-fail with a clear reason if the window isn't rendering — otherwise
|
|
3064
3204
|
-- CaptureScreenshot's callback never fires and we'd block for the full 10s.
|
|
3065
3205
|
local notRendering = RenderMonitor.notRenderingReason()
|
|
@@ -3095,8 +3235,12 @@ local function readContentToBase64(contentId)
|
|
|
3095
3235
|
return AssetService:CreateEditableImageAsync(Content.fromUri(contentId))
|
|
3096
3236
|
end)
|
|
3097
3237
|
if not editableOk then
|
|
3238
|
+
-- Lead with Roblox's own message: the Game Settings toggle is only one of
|
|
3239
|
+
-- the reasons this can fail (temporary-texture privilege and multiplayer
|
|
3240
|
+
-- client handles are others), and telling someone who already enabled
|
|
3241
|
+
-- it to enable it again is a dead end.
|
|
3098
3242
|
return {
|
|
3099
|
-
error = `Failed to create EditableImage from screenshot.
|
|
3243
|
+
error = `Failed to create EditableImage from screenshot: {tostring(editableResult)}. ` .. "If that mentions permissions, check Game Settings > Security > 'Allow Mesh / Image APIs'.",
|
|
3100
3244
|
}
|
|
3101
3245
|
end
|
|
3102
3246
|
local sourceImage = editableResult
|
|
@@ -3168,7 +3312,13 @@ end
|
|
|
3168
3312
|
-- cannot capture right now (missing FFlag, permission not granted, or this
|
|
3169
3313
|
-- DataModel is not the active one) so the caller can fall back to the
|
|
3170
3314
|
-- CaptureService + EditableImage path.
|
|
3315
|
+
local captureStudioFrame
|
|
3171
3316
|
local function doStudioCapture(wantPng)
|
|
3317
|
+
return withViewportRead(function()
|
|
3318
|
+
return captureStudioFrame(wantPng)
|
|
3319
|
+
end)
|
|
3320
|
+
end
|
|
3321
|
+
function captureStudioFrame(wantPng)
|
|
3172
3322
|
if STUDIO_CAPTURE_FORMATS == nil then
|
|
3173
3323
|
return nil
|
|
3174
3324
|
end
|
|
@@ -3290,12 +3440,379 @@ local function captureRead(requestData)
|
|
|
3290
3440
|
end
|
|
3291
3441
|
return readContentToBase64(contentId)
|
|
3292
3442
|
end
|
|
3443
|
+
-- Viewport corner markers for the host-side window capture fallback.
|
|
3444
|
+
--
|
|
3445
|
+
-- Roblox's own capture APIs can be unavailable for the play viewport:
|
|
3446
|
+
-- StudioCaptureService reports CanCaptureScreenshot() == false in the play
|
|
3447
|
+
-- client (RequestScreenshotPermissionAsync raises "Feature not supported
|
|
3448
|
+
-- yet"), and CaptureService:CaptureScreenshot hands back a fully black frame
|
|
3449
|
+
-- there on some Studio builds (observed with the Vulkan renderer). When that
|
|
3450
|
+
-- happens the MCP server grabs the whole Studio window through the host OS
|
|
3451
|
+
-- instead and needs to know where the 3D viewport sits inside that window.
|
|
3452
|
+
-- Four small magenta squares pinned to the viewport corners give it an exact,
|
|
3453
|
+
-- DPI-independent answer; the server hides them again before the real capture.
|
|
3454
|
+
local MARKER_GUI_NAME = "__MCPCaptureMarkers"
|
|
3455
|
+
local MARKER_SIZE = 12
|
|
3456
|
+
local MARKER_COLOR = Color3.fromRGB(255, 0, 255)
|
|
3457
|
+
-- Safety net: the server always hides the markers itself, but if it dies
|
|
3458
|
+
-- mid-capture the viewport must not stay decorated.
|
|
3459
|
+
local MARKER_AUTO_HIDE_SECONDS = 15
|
|
3460
|
+
local MARKER_RENDER_FRAMES = 3
|
|
3461
|
+
local MARKER_RENDER_TIMEOUT = 2
|
|
3462
|
+
local CAPTURE_AUTO_FINISH_SECONDS = 60
|
|
3463
|
+
local VIEWPORT_ACQUIRE_TIMEOUT = 25
|
|
3464
|
+
local activeMarkerCapture
|
|
3465
|
+
local activeViewportReads = 0
|
|
3466
|
+
function withViewportRead(readFrame)
|
|
3467
|
+
local start = os.clock()
|
|
3468
|
+
while activeMarkerCapture ~= nil do
|
|
3469
|
+
if os.clock() - start >= VIEWPORT_ACQUIRE_TIMEOUT then
|
|
3470
|
+
return {
|
|
3471
|
+
error = "Timed out waiting for the active host capture to restore the viewport; retry capture.",
|
|
3472
|
+
}
|
|
3473
|
+
end
|
|
3474
|
+
task.wait(0.03)
|
|
3475
|
+
end
|
|
3476
|
+
-- No yield between observing the writer and acquiring this reader.
|
|
3477
|
+
activeViewportReads += 1
|
|
3478
|
+
local ok, result = pcall(readFrame)
|
|
3479
|
+
activeViewportReads -= 1
|
|
3480
|
+
if not ok then
|
|
3481
|
+
return {
|
|
3482
|
+
error = `Viewport capture failed: {tostring(result)}`,
|
|
3483
|
+
}
|
|
3484
|
+
end
|
|
3485
|
+
return result
|
|
3486
|
+
end
|
|
3487
|
+
local function markerAccessError(action, captureId)
|
|
3488
|
+
local capture = activeMarkerCapture
|
|
3489
|
+
if captureId ~= nil then
|
|
3490
|
+
local _captureId = captureId
|
|
3491
|
+
local _condition = not (type(_captureId) == "string")
|
|
3492
|
+
if not _condition then
|
|
3493
|
+
_condition = captureId == ""
|
|
3494
|
+
end
|
|
3495
|
+
if _condition then
|
|
3496
|
+
return "A non-empty captureId is required."
|
|
3497
|
+
end
|
|
3498
|
+
if capture == nil or capture.captureId ~= captureId or capture.expired then
|
|
3499
|
+
return "The host capture token is stale or expired; discard this capture and prepare a new transaction."
|
|
3500
|
+
end
|
|
3501
|
+
if capture.busy then
|
|
3502
|
+
return "The host capture is still preparing or restoring; retry after that operation completes."
|
|
3503
|
+
end
|
|
3504
|
+
elseif capture ~= nil and (action == "show" or action == "hide") then
|
|
3505
|
+
return "An active host capture owns the viewport markers; supply its captureId."
|
|
3506
|
+
end
|
|
3507
|
+
return nil
|
|
3508
|
+
end
|
|
3509
|
+
local function viewportSize()
|
|
3510
|
+
local camera = Workspace.CurrentCamera
|
|
3511
|
+
if camera == nil then
|
|
3512
|
+
return nil
|
|
3513
|
+
end
|
|
3514
|
+
local viewport = camera.ViewportSize
|
|
3515
|
+
return {
|
|
3516
|
+
viewportWidth = math.max(1, math.floor(viewport.X)),
|
|
3517
|
+
viewportHeight = math.max(1, math.floor(viewport.Y)),
|
|
3518
|
+
}
|
|
3519
|
+
end
|
|
3520
|
+
local function hideMarkers()
|
|
3521
|
+
local existing = CoreGui:FindFirstChild(MARKER_GUI_NAME)
|
|
3522
|
+
if existing ~= nil then
|
|
3523
|
+
existing:Destroy()
|
|
3524
|
+
end
|
|
3525
|
+
end
|
|
3526
|
+
-- Blocks until the markers have been composited into a few rendered frames
|
|
3527
|
+
-- (bounded, so a non-rendering window cannot hang the request).
|
|
3528
|
+
local function waitForRenderedFrames()
|
|
3529
|
+
local frames = 0
|
|
3530
|
+
local ok, connection = pcall(function()
|
|
3531
|
+
return RunService.RenderStepped:Connect(function()
|
|
3532
|
+
frames += 1
|
|
3533
|
+
end)
|
|
3534
|
+
end)
|
|
3535
|
+
if not ok then
|
|
3536
|
+
return 0
|
|
3537
|
+
end
|
|
3538
|
+
local start = os.clock()
|
|
3539
|
+
while frames < MARKER_RENDER_FRAMES and os.clock() - start < MARKER_RENDER_TIMEOUT do
|
|
3540
|
+
task.wait(0.03)
|
|
3541
|
+
end
|
|
3542
|
+
connection:Disconnect()
|
|
3543
|
+
return frames
|
|
3544
|
+
end
|
|
3545
|
+
local function finishMarkerCapture(captureId)
|
|
3546
|
+
local capture = activeMarkerCapture
|
|
3547
|
+
-- A delayed retry or safety callback must never touch another capture's GUI.
|
|
3548
|
+
if capture == nil or capture.captureId ~= captureId then
|
|
3549
|
+
return {
|
|
3550
|
+
success = true,
|
|
3551
|
+
captureId = captureId,
|
|
3552
|
+
stale = true,
|
|
3553
|
+
}
|
|
3554
|
+
end
|
|
3555
|
+
if capture.busy then
|
|
3556
|
+
return {
|
|
3557
|
+
error = "Host capture is still preparing or restoring; retry finish with the same captureId.",
|
|
3558
|
+
captureId = captureId,
|
|
3559
|
+
}
|
|
3560
|
+
end
|
|
3561
|
+
capture.busy = true
|
|
3562
|
+
local ok, failure = pcall(function()
|
|
3563
|
+
hideMarkers()
|
|
3564
|
+
local simulator = capture.simulator
|
|
3565
|
+
local originalMode = capture.scalingMode
|
|
3566
|
+
if capture.restorePending and simulator ~= nil and originalMode ~= nil then
|
|
3567
|
+
-- Never switch devices or overwrite a mode the user changed mid-capture.
|
|
3568
|
+
if simulator:GetDeviceAsync() == capture.deviceId and simulator:GetScalingModeAsync() == FIT_TO_WINDOW and simulator:GetDeviceAsync() == capture.deviceId then
|
|
3569
|
+
simulator:SetScalingModeAsync(originalMode)
|
|
3570
|
+
-- A minimized window may not render; the restored mode still releases
|
|
3571
|
+
-- the transaction so the next capture can proceed when it is visible.
|
|
3572
|
+
waitForRenderedFrames()
|
|
3573
|
+
end
|
|
3574
|
+
capture.restorePending = false
|
|
3575
|
+
end
|
|
3576
|
+
end)
|
|
3577
|
+
capture.busy = false
|
|
3578
|
+
if not ok then
|
|
3579
|
+
return {
|
|
3580
|
+
error = `Could not finish host capture or restore device scaling: {tostring(failure)} Retry finish with captureId {captureId}, or restore the simulator scaling mode manually.`,
|
|
3581
|
+
captureId = captureId,
|
|
3582
|
+
}
|
|
3583
|
+
end
|
|
3584
|
+
activeMarkerCapture = nil
|
|
3585
|
+
return {
|
|
3586
|
+
success = true,
|
|
3587
|
+
captureId = captureId,
|
|
3588
|
+
}
|
|
3589
|
+
end
|
|
3590
|
+
local function prepareMarkerCapture()
|
|
3591
|
+
local start = os.clock()
|
|
3592
|
+
while activeMarkerCapture ~= nil or activeViewportReads > 0 do
|
|
3593
|
+
if os.clock() - start >= VIEWPORT_ACQUIRE_TIMEOUT then
|
|
3594
|
+
return {
|
|
3595
|
+
error = "Timed out waiting for another viewport capture to finish; retry host capture.",
|
|
3596
|
+
}
|
|
3597
|
+
end
|
|
3598
|
+
task.wait(0.03)
|
|
3599
|
+
end
|
|
3600
|
+
local notRendering = RenderMonitor.notRenderingReason()
|
|
3601
|
+
if notRendering ~= nil then
|
|
3602
|
+
return {
|
|
3603
|
+
error = notRendering,
|
|
3604
|
+
}
|
|
3605
|
+
end
|
|
3606
|
+
local size = viewportSize()
|
|
3607
|
+
if size == nil then
|
|
3608
|
+
return {
|
|
3609
|
+
error = "No CurrentCamera; cannot prepare host capture.",
|
|
3610
|
+
}
|
|
3611
|
+
end
|
|
3612
|
+
local capture = {
|
|
3613
|
+
captureId = game:GetService("HttpService"):GenerateGUID(false),
|
|
3614
|
+
restorePending = false,
|
|
3615
|
+
busy = true,
|
|
3616
|
+
expired = false,
|
|
3617
|
+
}
|
|
3618
|
+
activeMarkerCapture = capture
|
|
3619
|
+
task.delay(CAPTURE_AUTO_FINISH_SECONDS, function()
|
|
3620
|
+
if activeMarkerCapture ~= capture then
|
|
3621
|
+
return nil
|
|
3622
|
+
end
|
|
3623
|
+
capture.expired = true
|
|
3624
|
+
-- A yielding prepare owns the state until it resumes and rolls back.
|
|
3625
|
+
if capture.busy then
|
|
3626
|
+
return nil
|
|
3627
|
+
end
|
|
3628
|
+
local result = finishMarkerCapture(capture.captureId)
|
|
3629
|
+
if result.error ~= nil then
|
|
3630
|
+
warn(result.error)
|
|
3631
|
+
end
|
|
3632
|
+
end)
|
|
3633
|
+
local ok, failure = pcall(function()
|
|
3634
|
+
-- Unchecked cast: the live simulator service is missing from this SDK's
|
|
3635
|
+
-- GetService overloads; the structural return type describes only APIs used here.
|
|
3636
|
+
local dynamicGame = game
|
|
3637
|
+
local simulator = dynamicGame:GetService("StudioDeviceSimulatorService")
|
|
3638
|
+
capture.simulator = simulator
|
|
3639
|
+
capture.deviceId = simulator:GetDeviceAsync()
|
|
3640
|
+
if capture.deviceId == "default" then
|
|
3641
|
+
return nil
|
|
3642
|
+
end
|
|
3643
|
+
if FIT_TO_WINDOW == nil then
|
|
3644
|
+
error("Studio does not expose DeviceSimulatorScalingMode.FitToWindow; update Studio before capturing a simulated viewport.")
|
|
3645
|
+
end
|
|
3646
|
+
local scalingMode = simulator:GetScalingModeAsync()
|
|
3647
|
+
capture.scalingMode = scalingMode
|
|
3648
|
+
if scalingMode == FIT_TO_WINDOW then
|
|
3649
|
+
return nil
|
|
3650
|
+
end
|
|
3651
|
+
if simulator:GetScalingModeAsync() ~= scalingMode or simulator:GetDeviceAsync() ~= capture.deviceId then
|
|
3652
|
+
error("The simulated device or scaling mode changed while preparing host capture; retry after the device settles.")
|
|
3653
|
+
end
|
|
3654
|
+
if capture.expired then
|
|
3655
|
+
error("Host capture preparation expired.")
|
|
3656
|
+
end
|
|
3657
|
+
-- Set before the yielding setter: a setter failure can still have changed Studio.
|
|
3658
|
+
capture.restorePending = true
|
|
3659
|
+
simulator:SetScalingModeAsync(FIT_TO_WINDOW)
|
|
3660
|
+
if waitForRenderedFrames() < MARKER_RENDER_FRAMES then
|
|
3661
|
+
local _condition = RenderMonitor.notRenderingReason()
|
|
3662
|
+
if _condition == nil then
|
|
3663
|
+
_condition = "The fitted viewport did not render in time; restore the Studio window and retry."
|
|
3664
|
+
end
|
|
3665
|
+
error(_condition)
|
|
3666
|
+
end
|
|
3667
|
+
if simulator:GetDeviceAsync() ~= capture.deviceId or simulator:GetScalingModeAsync() ~= FIT_TO_WINDOW then
|
|
3668
|
+
error("The simulated device or scaling mode changed while fitting the viewport; retry after the device settles.")
|
|
3669
|
+
end
|
|
3670
|
+
end)
|
|
3671
|
+
capture.busy = false
|
|
3672
|
+
if not ok or capture.expired then
|
|
3673
|
+
local restored = finishMarkerCapture(capture.captureId)
|
|
3674
|
+
local detail = if ok then "Host capture preparation expired." else tostring(failure)
|
|
3675
|
+
return {
|
|
3676
|
+
error = `Could not prepare host capture: {detail}{if restored.error ~= nil then ` {restored.error}` else ""}`,
|
|
3677
|
+
captureId = capture.captureId,
|
|
3678
|
+
}
|
|
3679
|
+
end
|
|
3680
|
+
-- Fitting can round 1200 logical pixels to 1199: keep the pre-fit dimensions
|
|
3681
|
+
-- for the server's final image, while show/query report the actual fitted camera.
|
|
3682
|
+
local _object = {
|
|
3683
|
+
success = true,
|
|
3684
|
+
captureId = capture.captureId,
|
|
3685
|
+
viewportChanged = capture.restorePending,
|
|
3686
|
+
}
|
|
3687
|
+
for _k, _v in size do
|
|
3688
|
+
_object[_k] = _v
|
|
3689
|
+
end
|
|
3690
|
+
return _object
|
|
3691
|
+
end
|
|
3692
|
+
local function showMarkers(captureId)
|
|
3693
|
+
hideMarkers()
|
|
3694
|
+
local size = viewportSize()
|
|
3695
|
+
if size == nil then
|
|
3696
|
+
return {
|
|
3697
|
+
error = "No CurrentCamera; cannot place viewport markers.",
|
|
3698
|
+
}
|
|
3699
|
+
end
|
|
3700
|
+
local gui = Instance.new("ScreenGui")
|
|
3701
|
+
gui.Name = MARKER_GUI_NAME
|
|
3702
|
+
gui.IgnoreGuiInset = true
|
|
3703
|
+
-- Camera.ViewportSize reports the device safe area, so markers must bound it too.
|
|
3704
|
+
gui.ScreenInsets = Enum.ScreenInsets.DeviceSafeInsets
|
|
3705
|
+
gui.SafeAreaCompatibility = Enum.SafeAreaCompatibility.None
|
|
3706
|
+
gui.DisplayOrder = 2147483647
|
|
3707
|
+
gui.ResetOnSpawn = false
|
|
3708
|
+
gui.ZIndexBehavior = Enum.ZIndexBehavior.Global
|
|
3709
|
+
local corners = { { 0, 0 }, { 1, 0 }, { 0, 1 }, { 1, 1 } }
|
|
3710
|
+
for _, _binding in corners do
|
|
3711
|
+
local x = _binding[1]
|
|
3712
|
+
local y = _binding[2]
|
|
3713
|
+
local frame = Instance.new("Frame")
|
|
3714
|
+
frame.Name = `Corner{x}{y}`
|
|
3715
|
+
frame.AnchorPoint = Vector2.new(x, y)
|
|
3716
|
+
frame.Position = UDim2.fromScale(x, y)
|
|
3717
|
+
frame.Size = UDim2.fromOffset(MARKER_SIZE, MARKER_SIZE)
|
|
3718
|
+
frame.BackgroundColor3 = MARKER_COLOR
|
|
3719
|
+
frame.BackgroundTransparency = 0
|
|
3720
|
+
frame.BorderSizePixel = 0
|
|
3721
|
+
frame.ZIndex = 2147483647
|
|
3722
|
+
frame.Parent = gui
|
|
3723
|
+
end
|
|
3724
|
+
gui.Parent = CoreGui
|
|
3725
|
+
task.delay(MARKER_AUTO_HIDE_SECONDS, function()
|
|
3726
|
+
if gui.Parent ~= nil then
|
|
3727
|
+
gui:Destroy()
|
|
3728
|
+
end
|
|
3729
|
+
end)
|
|
3730
|
+
local framesRendered = waitForRenderedFrames()
|
|
3731
|
+
local accessError = markerAccessError("show", captureId)
|
|
3732
|
+
if accessError ~= nil then
|
|
3733
|
+
gui:Destroy()
|
|
3734
|
+
return {
|
|
3735
|
+
error = accessError,
|
|
3736
|
+
}
|
|
3737
|
+
end
|
|
3738
|
+
local renderedSize = viewportSize()
|
|
3739
|
+
if renderedSize == nil then
|
|
3740
|
+
gui:Destroy()
|
|
3741
|
+
return {
|
|
3742
|
+
error = "No CurrentCamera after rendering viewport markers.",
|
|
3743
|
+
}
|
|
3744
|
+
end
|
|
3745
|
+
local _object = {
|
|
3746
|
+
success = true,
|
|
3747
|
+
}
|
|
3748
|
+
for _k, _v in renderedSize do
|
|
3749
|
+
_object[_k] = _v
|
|
3750
|
+
end
|
|
3751
|
+
_object.markerSize = MARKER_SIZE
|
|
3752
|
+
_object.framesRendered = framesRendered
|
|
3753
|
+
return _object
|
|
3754
|
+
end
|
|
3755
|
+
-- prepare temporarily fits a simulated viewport; finish restores it after the
|
|
3756
|
+
-- clean host grab. hide only removes markers: restoring here would clip that grab.
|
|
3757
|
+
-- show/query report the current camera dimensions, not prepare's original size.
|
|
3758
|
+
local function captureMarkers(requestData)
|
|
3759
|
+
local action = requestData.action
|
|
3760
|
+
if action == "prepare" then
|
|
3761
|
+
return prepareMarkerCapture()
|
|
3762
|
+
end
|
|
3763
|
+
if action == "finish" then
|
|
3764
|
+
local captureId = requestData.captureId
|
|
3765
|
+
if not (type(captureId) == "string") or captureId == "" then
|
|
3766
|
+
return {
|
|
3767
|
+
error = 'capture-markers action "finish" requires a non-empty captureId.',
|
|
3768
|
+
}
|
|
3769
|
+
end
|
|
3770
|
+
return finishMarkerCapture(captureId)
|
|
3771
|
+
end
|
|
3772
|
+
if action == "show" or action == "hide" or action == "query" then
|
|
3773
|
+
local accessError = markerAccessError(action, requestData.captureId)
|
|
3774
|
+
if accessError ~= nil then
|
|
3775
|
+
return {
|
|
3776
|
+
error = accessError,
|
|
3777
|
+
}
|
|
3778
|
+
end
|
|
3779
|
+
end
|
|
3780
|
+
if action == "show" then
|
|
3781
|
+
return showMarkers(requestData.captureId)
|
|
3782
|
+
end
|
|
3783
|
+
if action == "hide" then
|
|
3784
|
+
hideMarkers()
|
|
3785
|
+
return {
|
|
3786
|
+
success = true,
|
|
3787
|
+
}
|
|
3788
|
+
end
|
|
3789
|
+
if action == "query" then
|
|
3790
|
+
local size = viewportSize()
|
|
3791
|
+
if size == nil then
|
|
3792
|
+
return {
|
|
3793
|
+
error = "No CurrentCamera; cannot read viewport size.",
|
|
3794
|
+
}
|
|
3795
|
+
end
|
|
3796
|
+
local _object = {
|
|
3797
|
+
success = true,
|
|
3798
|
+
}
|
|
3799
|
+
for _k, _v in size do
|
|
3800
|
+
_object[_k] = _v
|
|
3801
|
+
end
|
|
3802
|
+
_object.markerSize = MARKER_SIZE
|
|
3803
|
+
return _object
|
|
3804
|
+
end
|
|
3805
|
+
return {
|
|
3806
|
+
error = `capture-markers action must be "prepare", "finish", "show", "hide" or "query" (got {tostring(action)})`,
|
|
3807
|
+
}
|
|
3808
|
+
end
|
|
3293
3809
|
return {
|
|
3294
3810
|
captureScreenshotData = captureScreenshotData,
|
|
3295
3811
|
captureScreenshot = captureScreenshot,
|
|
3296
3812
|
captureStudio = captureStudio,
|
|
3297
3813
|
captureBegin = captureBegin,
|
|
3298
3814
|
captureRead = captureRead,
|
|
3815
|
+
captureMarkers = captureMarkers,
|
|
3299
3816
|
}
|
|
3300
3817
|
]]></string>
|
|
3301
3818
|
</Properties>
|
|
@@ -4879,6 +5396,16 @@ local function captureMicroProfiler(requestData)
|
|
|
4879
5396
|
backend = backend,
|
|
4880
5397
|
}
|
|
4881
5398
|
end
|
|
5399
|
+
local frameLimitOk, frameLimitResult = safeCall(function()
|
|
5400
|
+
return LibMP.Control:SetFrameLimit(math.min(frameWindow, 256))
|
|
5401
|
+
end)
|
|
5402
|
+
if not frameLimitOk then
|
|
5403
|
+
return {
|
|
5404
|
+
error = "micro_profiler_frame_limit_failed",
|
|
5405
|
+
message = tostring(frameLimitResult),
|
|
5406
|
+
backend = backend,
|
|
5407
|
+
}
|
|
5408
|
+
end
|
|
4882
5409
|
local profilerOk, profilerResult = safeCall(function()
|
|
4883
5410
|
return LibMP.Control:EnableProfiler(true)
|
|
4884
5411
|
end)
|
|
@@ -4900,6 +5427,11 @@ local function captureMicroProfiler(requestData)
|
|
|
4900
5427
|
}
|
|
4901
5428
|
end
|
|
4902
5429
|
task.wait(durationMs / 1000)
|
|
5430
|
+
-- Active reads synchronize the engine's data; paused reads can return an
|
|
5431
|
+
-- empty or stale cache. Export first, then pause, including on export failure.
|
|
5432
|
+
local bufferOk, snapshotOrErr = safeCall(function()
|
|
5433
|
+
return LibMP.Control:CaptureToBufferSync()
|
|
5434
|
+
end)
|
|
4903
5435
|
local captureStopOk, captureStopResult = safeCall(function()
|
|
4904
5436
|
return LibMP.Control:EnableCapture(false)
|
|
4905
5437
|
end)
|
|
@@ -4910,9 +5442,6 @@ local function captureMicroProfiler(requestData)
|
|
|
4910
5442
|
backend = backend,
|
|
4911
5443
|
}
|
|
4912
5444
|
end
|
|
4913
|
-
local bufferOk, snapshotOrErr = safeCall(function()
|
|
4914
|
-
return LibMP.Control:CaptureToBufferSync()
|
|
4915
|
-
end)
|
|
4916
5445
|
if not bufferOk then
|
|
4917
5446
|
return {
|
|
4918
5447
|
error = "micro_profiler_snapshot_failed",
|
|
@@ -6069,6 +6598,7 @@ local Recording = TS.import(script, script.Parent.Parent, "Recording")
|
|
|
6069
6598
|
local _binding = Utils
|
|
6070
6599
|
local getInstanceByPath = _binding.getInstanceByPath
|
|
6071
6600
|
local convertPropertyValue = _binding.convertPropertyValue
|
|
6601
|
+
local applyScriptSource = _binding.applyScriptSource
|
|
6072
6602
|
local _binding_1 = Recording
|
|
6073
6603
|
local beginRecording = _binding_1.beginRecording
|
|
6074
6604
|
local finishRecording = _binding_1.finishRecording
|
|
@@ -6096,7 +6626,12 @@ local function setProperties(requestData)
|
|
|
6096
6626
|
for propName, propValue in pairs(properties) do
|
|
6097
6627
|
local success, err = pcall(function()
|
|
6098
6628
|
if propName == "Parent" or propName == "PrimaryPart" then
|
|
6099
|
-
if type(propValue) == "string" then
|
|
6629
|
+
if not (type(propValue) == "string") then
|
|
6630
|
+
error(`{propName} must be an instance path string (or an empty string to clear)`)
|
|
6631
|
+
end
|
|
6632
|
+
if propValue == "" then
|
|
6633
|
+
inst[propName] = nil
|
|
6634
|
+
else
|
|
6100
6635
|
local refInstance = getInstanceByPath(propValue)
|
|
6101
6636
|
if not refInstance then
|
|
6102
6637
|
error(`{propName} reference not found: {propValue}`)
|
|
@@ -6106,7 +6641,17 @@ local function setProperties(requestData)
|
|
|
6106
6641
|
elseif propName == "Name" then
|
|
6107
6642
|
instance.Name = tostring(propValue)
|
|
6108
6643
|
elseif propName == "Source" and instance:IsA("LuaSourceContainer") then
|
|
6109
|
-
|
|
6644
|
+
if not (type(propValue) == "string") then
|
|
6645
|
+
error("Source must be a string")
|
|
6646
|
+
end
|
|
6647
|
+
local result = applyScriptSource(instance, propValue)
|
|
6648
|
+
if not result.success then
|
|
6649
|
+
local _condition = result.error
|
|
6650
|
+
if _condition == nil then
|
|
6651
|
+
_condition = "Script source update failed"
|
|
6652
|
+
end
|
|
6653
|
+
error(_condition)
|
|
6654
|
+
end
|
|
6110
6655
|
else
|
|
6111
6656
|
local converted = convertPropertyValue(instance, propName, propValue)
|
|
6112
6657
|
inst[propName] = if converted ~= nil then converted else propValue
|
|
@@ -8512,6 +9057,7 @@ local function stopPlaytest(_requestData)
|
|
|
8512
9057
|
-- Signal the play-server DM's StopPlayMonitor via plugin:SetSetting.
|
|
8513
9058
|
-- The monitor acknowledges with the matching request id only after its
|
|
8514
9059
|
-- StudioTestService:EndTest call returns from pcall.
|
|
9060
|
+
local hadActivePlaytest = testRunning or not StudioTestService.EditModeActive
|
|
8515
9061
|
local stopRequest = StopPlayMonitor.requestStop()
|
|
8516
9062
|
if not stopRequest.ok or stopRequest.requestId == nil then
|
|
8517
9063
|
return {
|
|
@@ -8534,6 +9080,15 @@ local function stopPlaytest(_requestData)
|
|
|
8534
9080
|
-- Either way clean up the pending request so a future playtest's monitor
|
|
8535
9081
|
-- doesn't fire EndTest on startup against a stale signal.
|
|
8536
9082
|
StopPlayMonitor.clearPending(stopRequest.requestId)
|
|
9083
|
+
-- Settings acknowledgement can be lost as the server VM exits. Reconcile
|
|
9084
|
+
-- only a playtest observed at entry, and only after both native teardown
|
|
9085
|
+
-- and the tracked execution have finished. Never mask an EndTest error.
|
|
9086
|
+
if not consumption.consumed and hadActivePlaytest and not testRunning and StudioTestService.EditModeActive then
|
|
9087
|
+
return {
|
|
9088
|
+
success = true,
|
|
9089
|
+
message = "Playtest stopped.",
|
|
9090
|
+
}
|
|
9091
|
+
end
|
|
8537
9092
|
if testRunning then
|
|
8538
9093
|
return {
|
|
8539
9094
|
error = "Playtest stop signal failed or was not acknowledged. " .. "The playtest may have ended anyway; check get_connected_instances.",
|
|
@@ -10305,9 +10860,10 @@ return {
|
|
|
10305
10860
|
<Properties>
|
|
10306
10861
|
<string name="Name">State</string>
|
|
10307
10862
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
10308
|
-
local CURRENT_VERSION = "3.1.
|
|
10863
|
+
local CURRENT_VERSION = "3.1.6"
|
|
10309
10864
|
local PLUGIN_VARIANT = "inspector"
|
|
10310
10865
|
local BASE_PORT = 58741
|
|
10866
|
+
local SENSITIVE_DIAGNOSTIC_TERMS = { "token", "authorization", "bearer", "password", "cookie", "secret" }
|
|
10311
10867
|
local function createConnection(port)
|
|
10312
10868
|
return {
|
|
10313
10869
|
port = port,
|
|
@@ -10326,11 +10882,118 @@ local connection = createConnection(BASE_PORT)
|
|
|
10326
10882
|
local function getActiveConnection()
|
|
10327
10883
|
return connection
|
|
10328
10884
|
end
|
|
10885
|
+
local function clearTransportDiagnostics()
|
|
10886
|
+
connection.transportState = nil
|
|
10887
|
+
connection.transportAttempt = nil
|
|
10888
|
+
connection.transportDetail = nil
|
|
10889
|
+
connection.lastTransportFailure = nil
|
|
10890
|
+
connection.nextRetryAt = nil
|
|
10891
|
+
end
|
|
10892
|
+
local function sanitizeTransportDetail(detail)
|
|
10893
|
+
-- Transport emits credential-free stage descriptions; also defend the UI
|
|
10894
|
+
-- against accidental raw errors, URLs, markup, and multiline responses.
|
|
10895
|
+
local lower = string.lower(detail)
|
|
10896
|
+
for _, sensitive in SENSITIVE_DIAGNOSTIC_TERMS do
|
|
10897
|
+
if (string.find(lower, sensitive, 1, true)) ~= nil then
|
|
10898
|
+
return "Connection failed (sensitive details hidden)."
|
|
10899
|
+
end
|
|
10900
|
+
end
|
|
10901
|
+
local plain = string.gsub(detail, "%w+://%S+", "[address hidden]")
|
|
10902
|
+
plain = string.gsub(plain, "%c", " ")
|
|
10903
|
+
plain = string.gsub(plain, "<[^>]*>", "")
|
|
10904
|
+
plain = string.gsub(plain, "%s+", " ")
|
|
10905
|
+
return if #plain > 180 then `{string.sub(plain, 1, 177)}...` else plain
|
|
10906
|
+
end
|
|
10907
|
+
local function applyTransportUpdate(update, now)
|
|
10908
|
+
if not connection.isActive then
|
|
10909
|
+
return nil
|
|
10910
|
+
end
|
|
10911
|
+
if update.state == "open" then
|
|
10912
|
+
clearTransportDiagnostics()
|
|
10913
|
+
connection.lastHttpOk = true
|
|
10914
|
+
connection.lastMcpOk = false
|
|
10915
|
+
connection.consecutiveFailures = 0
|
|
10916
|
+
connection.currentRetryDelay = 0.5
|
|
10917
|
+
connection.mcpWaitStartTime = now
|
|
10918
|
+
return nil
|
|
10919
|
+
end
|
|
10920
|
+
connection.lastHttpOk = false
|
|
10921
|
+
connection.lastMcpOk = false
|
|
10922
|
+
connection.consecutiveFailures = update.attempt
|
|
10923
|
+
connection.mcpWaitStartTime = nil
|
|
10924
|
+
local previousState = connection.transportState
|
|
10925
|
+
connection.transportState = update.state
|
|
10926
|
+
connection.transportAttempt = update.attempt
|
|
10927
|
+
if update.detail ~= nil then
|
|
10928
|
+
connection.transportDetail = sanitizeTransportDetail(update.detail)
|
|
10929
|
+
elseif update.state ~= "connecting" or previousState ~= "connecting" then
|
|
10930
|
+
connection.transportDetail = nil
|
|
10931
|
+
end
|
|
10932
|
+
if update.state == "retrying" or update.state == "waiting-duplicate" then
|
|
10933
|
+
local _condition = connection.transportDetail
|
|
10934
|
+
if _condition == nil then
|
|
10935
|
+
_condition = "Connection attempt failed."
|
|
10936
|
+
end
|
|
10937
|
+
connection.lastTransportFailure = _condition
|
|
10938
|
+
connection.currentRetryDelay = update.retryDelay
|
|
10939
|
+
connection.nextRetryAt = now + update.retryDelay
|
|
10940
|
+
else
|
|
10941
|
+
connection.nextRetryAt = nil
|
|
10942
|
+
end
|
|
10943
|
+
end
|
|
10944
|
+
local function getTransportDiagnostics(now)
|
|
10945
|
+
if not connection.isActive then
|
|
10946
|
+
return {
|
|
10947
|
+
status = "Disconnected",
|
|
10948
|
+
detail = "",
|
|
10949
|
+
}
|
|
10950
|
+
end
|
|
10951
|
+
if connection.lastHttpOk then
|
|
10952
|
+
return if connection.lastMcpOk then {
|
|
10953
|
+
status = "Connected",
|
|
10954
|
+
detail = "Listener: connected MCP client: connected",
|
|
10955
|
+
} else {
|
|
10956
|
+
status = "Waiting for MCP client",
|
|
10957
|
+
detail = "Listener: connected MCP client: not connected",
|
|
10958
|
+
}
|
|
10959
|
+
end
|
|
10960
|
+
local _condition = connection.transportAttempt
|
|
10961
|
+
if _condition == nil then
|
|
10962
|
+
_condition = 0
|
|
10963
|
+
end
|
|
10964
|
+
local attempt = _condition + 1
|
|
10965
|
+
local failure = connection.lastTransportFailure
|
|
10966
|
+
if connection.nextRetryAt ~= nil then
|
|
10967
|
+
local remaining = math.max(0, math.ceil((connection.nextRetryAt - now) * 10) / 10)
|
|
10968
|
+
local _object = {
|
|
10969
|
+
status = if connection.transportState == "waiting-duplicate" then "Waiting for previous instance" else "Listener disconnected",
|
|
10970
|
+
}
|
|
10971
|
+
local _left = "detail"
|
|
10972
|
+
local _condition_1 = failure
|
|
10973
|
+
if _condition_1 == nil then
|
|
10974
|
+
_condition_1 = "Connection attempt failed."
|
|
10975
|
+
end
|
|
10976
|
+
_object[_left] = `Retry attempt {attempt} in {remaining}s\n{_condition_1}`
|
|
10977
|
+
return _object
|
|
10978
|
+
end
|
|
10979
|
+
local _condition_1 = connection.transportDetail
|
|
10980
|
+
if _condition_1 == nil then
|
|
10981
|
+
_condition_1 = "Starting listener connection"
|
|
10982
|
+
end
|
|
10983
|
+
local stage = _condition_1
|
|
10984
|
+
return {
|
|
10985
|
+
status = `Connecting (attempt {attempt})`,
|
|
10986
|
+
detail = if failure ~= nil then `{stage}\nLast failure: {failure}` else stage,
|
|
10987
|
+
}
|
|
10988
|
+
end
|
|
10329
10989
|
return {
|
|
10330
10990
|
CURRENT_VERSION = CURRENT_VERSION,
|
|
10331
10991
|
PLUGIN_VARIANT = PLUGIN_VARIANT,
|
|
10332
10992
|
BASE_PORT = BASE_PORT,
|
|
10333
10993
|
getActiveConnection = getActiveConnection,
|
|
10994
|
+
clearTransportDiagnostics = clearTransportDiagnostics,
|
|
10995
|
+
applyTransportUpdate = applyTransportUpdate,
|
|
10996
|
+
getTransportDiagnostics = getTransportDiagnostics,
|
|
10334
10997
|
}
|
|
10335
10998
|
]]></string>
|
|
10336
10999
|
</Properties>
|
|
@@ -10349,6 +11012,10 @@ local RunService = _services.RunService
|
|
|
10349
11012
|
local PluginSession = TS.import(script, script.Parent, "PluginSession")
|
|
10350
11013
|
local StudioTestService = game:GetService("StudioTestService")
|
|
10351
11014
|
local SETTING_KEY_PREFIX = "MCP_STOP_PLAY_"
|
|
11015
|
+
local RESULT_KEY_SUFFIX = "_RESULT"
|
|
11016
|
+
-- Capture before registration yields. Requests from an earlier runtime must
|
|
11017
|
+
-- never stop a newly created play session, even if clearing settings was lost.
|
|
11018
|
+
local moduleLoadedAt = tick()
|
|
10352
11019
|
-- Keep this conservative. plugin:GetSetting is backed by Studio's plugin
|
|
10353
11020
|
-- settings store, and this monitor runs during every play session, including
|
|
10354
11021
|
-- manually-started Play. The official reference implementation polls at 1s.
|
|
@@ -10361,9 +11028,14 @@ local POLL_INTERVAL_SEC = 1
|
|
|
10361
11028
|
local WAIT_FOR_CONSUMPTION_TIMEOUT_SEC = 8.0
|
|
10362
11029
|
local WAIT_POLL_SEC = 0.1
|
|
10363
11030
|
local REQUEST_TTL_SEC = 12.0
|
|
11031
|
+
-- Plugin settings can silently lose writes when other Studio windows use the
|
|
11032
|
+
-- same plugin. Redeliver the same request, without extending its deadline/TTL.
|
|
11033
|
+
local RETRY_INTERVAL_SEC = 1
|
|
10364
11034
|
local pluginRef
|
|
10365
11035
|
local endTestIssued = false
|
|
10366
11036
|
local transportLifecycle
|
|
11037
|
+
local pendingRequest
|
|
11038
|
+
local lastResult
|
|
10367
11039
|
local function init(p)
|
|
10368
11040
|
pluginRef = p
|
|
10369
11041
|
end
|
|
@@ -10426,14 +11098,21 @@ local function writePayload(key, payload)
|
|
|
10426
11098
|
return writeSetting(key, encoded)
|
|
10427
11099
|
end
|
|
10428
11100
|
local function writeResult(key, request, ok, errText)
|
|
10429
|
-
|
|
11101
|
+
local payload = {
|
|
10430
11102
|
kind = "result",
|
|
10431
11103
|
id = request.id,
|
|
10432
11104
|
requestedAt = request.requestedAt,
|
|
10433
11105
|
consumedAt = tick(),
|
|
10434
11106
|
ok = ok,
|
|
10435
11107
|
error = errText,
|
|
10436
|
-
}
|
|
11108
|
+
}
|
|
11109
|
+
-- Cache before publishing: a lost acknowledgement must not issue EndTest a
|
|
11110
|
+
-- second time or turn a successful duplicate into an "already issued" error.
|
|
11111
|
+
lastResult = {
|
|
11112
|
+
key = key,
|
|
11113
|
+
payload = payload,
|
|
11114
|
+
}
|
|
11115
|
+
writePayload(key .. RESULT_KEY_SUFFIX, payload)
|
|
10437
11116
|
end
|
|
10438
11117
|
local function handleStopRequest(key, request)
|
|
10439
11118
|
local _condition = request.kind ~= "request"
|
|
@@ -10450,10 +11129,14 @@ local function handleStopRequest(key, request)
|
|
|
10450
11129
|
return nil
|
|
10451
11130
|
end
|
|
10452
11131
|
local age = tick() - request.requestedAt
|
|
10453
|
-
if age < -5 or age > REQUEST_TTL_SEC then
|
|
11132
|
+
if request.requestedAt < moduleLoadedAt or age < -5 or age > REQUEST_TTL_SEC then
|
|
10454
11133
|
writeSetting(key, false)
|
|
10455
11134
|
return nil
|
|
10456
11135
|
end
|
|
11136
|
+
if lastResult ~= nil and lastResult.key == key and lastResult.payload.id == request.id then
|
|
11137
|
+
writePayload(key .. RESULT_KEY_SUFFIX, lastResult.payload)
|
|
11138
|
+
return nil
|
|
11139
|
+
end
|
|
10457
11140
|
if endTestIssued then
|
|
10458
11141
|
writeResult(key, request, false, "StudioTestService:EndTest was already issued for this play session, but the runtime DataModel is still alive.")
|
|
10459
11142
|
return nil
|
|
@@ -10514,6 +11197,11 @@ local function requestStop()
|
|
|
10514
11197
|
requestedAt = tick(),
|
|
10515
11198
|
}
|
|
10516
11199
|
local ok = writePayload(settingKey(), payload)
|
|
11200
|
+
pendingRequest = if ok then {
|
|
11201
|
+
key = settingKey(),
|
|
11202
|
+
payload = payload,
|
|
11203
|
+
lastPublishedAt = tick(),
|
|
11204
|
+
} else nil
|
|
10517
11205
|
return {
|
|
10518
11206
|
ok = ok,
|
|
10519
11207
|
requestId = if ok then requestId else nil,
|
|
@@ -10528,8 +11216,12 @@ local function waitForConsumption(requestId)
|
|
|
10528
11216
|
}
|
|
10529
11217
|
end
|
|
10530
11218
|
local start = tick()
|
|
11219
|
+
local pending = pendingRequest
|
|
11220
|
+
local key = if pending ~= nil and pending.payload.id == requestId then pending.key else settingKey()
|
|
10531
11221
|
while tick() - start < WAIT_FOR_CONSUMPTION_TIMEOUT_SEC do
|
|
10532
|
-
|
|
11222
|
+
-- Separate slots prevent a retry from overwriting a durable acknowledgement
|
|
11223
|
+
-- during a transient nil read after the server VM has already exited.
|
|
11224
|
+
local payload = decodePayload(readSetting(key .. RESULT_KEY_SUFFIX))
|
|
10533
11225
|
if payload and payload.kind == "result" and payload.id == requestId then
|
|
10534
11226
|
return {
|
|
10535
11227
|
ok = payload.ok == true,
|
|
@@ -10537,6 +11229,19 @@ local function waitForConsumption(requestId)
|
|
|
10537
11229
|
error = payload.error,
|
|
10538
11230
|
}
|
|
10539
11231
|
end
|
|
11232
|
+
local _condition = pending ~= nil and pendingRequest == pending and pending.payload.id == requestId and tick() - pending.lastPublishedAt >= RETRY_INTERVAL_SEC
|
|
11233
|
+
if _condition then
|
|
11234
|
+
local _exp = tick()
|
|
11235
|
+
local _condition_1 = pending.payload.requestedAt
|
|
11236
|
+
if _condition_1 == nil then
|
|
11237
|
+
_condition_1 = 0
|
|
11238
|
+
end
|
|
11239
|
+
_condition = _exp - _condition_1 < REQUEST_TTL_SEC
|
|
11240
|
+
end
|
|
11241
|
+
if _condition then
|
|
11242
|
+
writePayload(key, pending.payload)
|
|
11243
|
+
pending.lastPublishedAt = tick()
|
|
11244
|
+
end
|
|
10540
11245
|
task.wait(WAIT_POLL_SEC)
|
|
10541
11246
|
end
|
|
10542
11247
|
return {
|
|
@@ -10549,6 +11254,17 @@ local function clearPending(requestId)
|
|
|
10549
11254
|
if not pluginRef then
|
|
10550
11255
|
return nil
|
|
10551
11256
|
end
|
|
11257
|
+
local _condition = requestId == nil
|
|
11258
|
+
if not _condition then
|
|
11259
|
+
local _result = pendingRequest
|
|
11260
|
+
if _result ~= nil then
|
|
11261
|
+
_result = _result.payload.id
|
|
11262
|
+
end
|
|
11263
|
+
_condition = _result == requestId
|
|
11264
|
+
end
|
|
11265
|
+
if _condition then
|
|
11266
|
+
pendingRequest = nil
|
|
11267
|
+
end
|
|
10552
11268
|
local myKey = settingKey()
|
|
10553
11269
|
if requestId ~= nil then
|
|
10554
11270
|
local payload = decodePayload(readSetting(myKey))
|
|
@@ -10580,6 +11296,7 @@ local PROTOCOL_VERSION = 1
|
|
|
10580
11296
|
local INITIAL_RETRY_DELAY_SECONDS = 0.5
|
|
10581
11297
|
local MAX_RETRY_DELAY_SECONDS = 5
|
|
10582
11298
|
local SOCKET_SILENCE_TIMEOUT_SECONDS = 20
|
|
11299
|
+
local CONNECTION_STAGE_TIMEOUT_SECONDS = 20
|
|
10583
11300
|
local RESPONSE_RETENTION_SECONDS = 5 * 60
|
|
10584
11301
|
local RESPONSE_ACK_TIMEOUT_SECONDS = 120
|
|
10585
11302
|
local MAX_TERMINAL_RESPONSES = 32768
|
|
@@ -10600,7 +11317,11 @@ local socketOpen = false
|
|
|
10600
11317
|
local socketConnections = {}
|
|
10601
11318
|
local lastValidEventAt = 0
|
|
10602
11319
|
local cachedReady
|
|
10603
|
-
local
|
|
11320
|
+
local connectionWork
|
|
11321
|
+
local refreshWork
|
|
11322
|
+
local refreshRequested = false
|
|
11323
|
+
local reconnectTimer
|
|
11324
|
+
local silenceTimer
|
|
10604
11325
|
local pendingResponseBytes = 0
|
|
10605
11326
|
local inFlightRequestBytes = 0
|
|
10606
11327
|
local pendingRejectionCount = 0
|
|
@@ -11147,7 +11868,7 @@ local function dispatchRequest(request, requestBytes)
|
|
|
11147
11868
|
local dispatchOk, response = pcall(function()
|
|
11148
11869
|
return dispatchOptions.dispatchRequest(request, context)
|
|
11149
11870
|
end)
|
|
11150
|
-
local executionOutcome = if dispatchOk then handlerOutcome(response) else "error"
|
|
11871
|
+
local executionOutcome = context.executionOutcome or (if dispatchOk then handlerOutcome(response) else "error")
|
|
11151
11872
|
-- Report handler return before JSON encoding or result retention can fail.
|
|
11152
11873
|
completeProgress(request.requestId, inFlight.progress, executionOutcome)
|
|
11153
11874
|
local body = if dispatchOk then encodeResponse(request.requestId, response, executionOutcome) else encodeError(request.requestId, `Plugin request execution failed: {string.sub(tostring(response), 1, 512)}`, executionOutcome)
|
|
@@ -11171,6 +11892,90 @@ local function reportTransport(update)
|
|
|
11171
11892
|
end)
|
|
11172
11893
|
end
|
|
11173
11894
|
end
|
|
11895
|
+
local function ownsWork(work)
|
|
11896
|
+
return active and generation == work.generation and options == work.options and (connectionWork == work or refreshWork == work)
|
|
11897
|
+
end
|
|
11898
|
+
local function cancelThread(worker)
|
|
11899
|
+
-- Cancellation is best-effort for native yielding calls. Identity checks
|
|
11900
|
+
-- remain necessary when an operation returns after its owner was retired.
|
|
11901
|
+
if worker ~= nil and worker ~= coroutine.running() then
|
|
11902
|
+
pcall(function()
|
|
11903
|
+
return task.cancel(worker)
|
|
11904
|
+
end)
|
|
11905
|
+
end
|
|
11906
|
+
end
|
|
11907
|
+
local function finishWork(work)
|
|
11908
|
+
if connectionWork == work then
|
|
11909
|
+
connectionWork = nil
|
|
11910
|
+
end
|
|
11911
|
+
if refreshWork == work then
|
|
11912
|
+
refreshWork = nil
|
|
11913
|
+
end
|
|
11914
|
+
cancelThread(work.deadline)
|
|
11915
|
+
cancelThread(work.worker)
|
|
11916
|
+
work.deadline = nil
|
|
11917
|
+
work.worker = nil
|
|
11918
|
+
end
|
|
11919
|
+
local function cancelTransportWork()
|
|
11920
|
+
if connectionWork ~= nil then
|
|
11921
|
+
finishWork(connectionWork)
|
|
11922
|
+
end
|
|
11923
|
+
if refreshWork ~= nil then
|
|
11924
|
+
refreshRequested = true
|
|
11925
|
+
finishWork(refreshWork)
|
|
11926
|
+
end
|
|
11927
|
+
cancelThread(reconnectTimer)
|
|
11928
|
+
cancelThread(silenceTimer)
|
|
11929
|
+
reconnectTimer = nil
|
|
11930
|
+
silenceTimer = nil
|
|
11931
|
+
end
|
|
11932
|
+
local function runWork(work, callback, failed)
|
|
11933
|
+
local finished = false
|
|
11934
|
+
local worker = task.spawn(function()
|
|
11935
|
+
if not ownsWork(work) then
|
|
11936
|
+
return nil
|
|
11937
|
+
end
|
|
11938
|
+
work.worker = coroutine.running()
|
|
11939
|
+
local ok, workError = pcall(callback)
|
|
11940
|
+
work.worker = nil
|
|
11941
|
+
finished = true
|
|
11942
|
+
if not ok and ownsWork(work) then
|
|
11943
|
+
-- Native upgrade errors can echo credential-bearing request data.
|
|
11944
|
+
local detail = if work.stage == "WebSocket upgrade" then "WebSocket upgrade failed" else `{work.stage} failed: {tostring(workError)}`
|
|
11945
|
+
failed(detail)
|
|
11946
|
+
end
|
|
11947
|
+
end)
|
|
11948
|
+
if not finished and ownsWork(work) then
|
|
11949
|
+
work.worker = worker
|
|
11950
|
+
end
|
|
11951
|
+
end
|
|
11952
|
+
local flushRefresh
|
|
11953
|
+
local function beginStage(work, stage)
|
|
11954
|
+
cancelThread(work.deadline)
|
|
11955
|
+
work.stage = stage
|
|
11956
|
+
work.deadline = task.delay(CONNECTION_STAGE_TIMEOUT_SECONDS, function()
|
|
11957
|
+
if not ownsWork(work) then
|
|
11958
|
+
return nil
|
|
11959
|
+
end
|
|
11960
|
+
work.deadline = nil
|
|
11961
|
+
local detail = `{stage} timed out after {CONNECTION_STAGE_TIMEOUT_SECONDS} seconds`
|
|
11962
|
+
if connectionWork == work then
|
|
11963
|
+
scheduleReconnect(work.generation, detail)
|
|
11964
|
+
else
|
|
11965
|
+
finishWork(work)
|
|
11966
|
+
warn(`[robloxstudio-mcp] {detail}`)
|
|
11967
|
+
flushRefresh()
|
|
11968
|
+
end
|
|
11969
|
+
end)
|
|
11970
|
+
if connectionWork == work then
|
|
11971
|
+
reportTransport({
|
|
11972
|
+
state = "connecting",
|
|
11973
|
+
attempt = reconnectAttempt,
|
|
11974
|
+
retryDelay = 0,
|
|
11975
|
+
detail = `{stage} (timeout {CONNECTION_STAGE_TIMEOUT_SECONDS} seconds)`,
|
|
11976
|
+
})
|
|
11977
|
+
end
|
|
11978
|
+
end
|
|
11174
11979
|
local connect
|
|
11175
11980
|
function scheduleReconnect(expectedGeneration, detail, duplicate)
|
|
11176
11981
|
if duplicate == nil then
|
|
@@ -11179,7 +11984,14 @@ function scheduleReconnect(expectedGeneration, detail, duplicate)
|
|
|
11179
11984
|
if not active or generation ~= expectedGeneration then
|
|
11180
11985
|
return nil
|
|
11181
11986
|
end
|
|
11987
|
+
-- A healthy registration gets one socket-only revalidation, preserving
|
|
11988
|
+
-- recovery under HTTP quota. Any failure before Opened exhausts that path.
|
|
11989
|
+
if not socketOpen then
|
|
11990
|
+
cachedReady = nil
|
|
11991
|
+
end
|
|
11182
11992
|
generation += 1
|
|
11993
|
+
local nextGeneration = generation
|
|
11994
|
+
cancelTransportWork()
|
|
11183
11995
|
closeCurrentSocket()
|
|
11184
11996
|
reconnectAttempt += 1
|
|
11185
11997
|
local delay = if duplicate then 1 else retryDelay(reconnectAttempt)
|
|
@@ -11189,8 +12001,11 @@ function scheduleReconnect(expectedGeneration, detail, duplicate)
|
|
|
11189
12001
|
retryDelay = delay,
|
|
11190
12002
|
detail = detail,
|
|
11191
12003
|
})
|
|
11192
|
-
|
|
11193
|
-
|
|
12004
|
+
if not active or generation ~= nextGeneration then
|
|
12005
|
+
return nil
|
|
12006
|
+
end
|
|
12007
|
+
reconnectTimer = task.delay(delay, function()
|
|
12008
|
+
reconnectTimer = nil
|
|
11194
12009
|
if active and generation == nextGeneration then
|
|
11195
12010
|
connect(nextGeneration)
|
|
11196
12011
|
end
|
|
@@ -11198,10 +12013,11 @@ function scheduleReconnect(expectedGeneration, detail, duplicate)
|
|
|
11198
12013
|
end
|
|
11199
12014
|
local function watchForSilence(expectedGeneration, expectedClient)
|
|
11200
12015
|
local elapsed = tick() - lastValidEventAt
|
|
11201
|
-
task.delay(math.max(SOCKET_SILENCE_TIMEOUT_SECONDS - elapsed, 0.1), function()
|
|
12016
|
+
silenceTimer = task.delay(math.max(SOCKET_SILENCE_TIMEOUT_SECONDS - elapsed, 0.1), function()
|
|
11202
12017
|
if not active or generation ~= expectedGeneration or socketClient ~= expectedClient then
|
|
11203
12018
|
return nil
|
|
11204
12019
|
end
|
|
12020
|
+
silenceTimer = nil
|
|
11205
12021
|
local silentFor = tick() - lastValidEventAt
|
|
11206
12022
|
if silentFor >= SOCKET_SILENCE_TIMEOUT_SECONDS then
|
|
11207
12023
|
scheduleReconnect(expectedGeneration, `WebSocket silent for {math.floor(silentFor)} seconds`)
|
|
@@ -11268,13 +12084,18 @@ local function parseReadyResponse(body)
|
|
|
11268
12084
|
transportToken = value.transportToken,
|
|
11269
12085
|
}
|
|
11270
12086
|
end
|
|
11271
|
-
local function registerReady(
|
|
12087
|
+
local function registerReady(work, reconnectOnFailure)
|
|
12088
|
+
if not ownsWork(work) then
|
|
12089
|
+
return nil
|
|
12090
|
+
end
|
|
12091
|
+
local currentOptions = work.options
|
|
12092
|
+
local expectedGeneration = work.generation
|
|
11272
12093
|
local instanceId = PluginSession.getInstanceId()
|
|
11273
12094
|
local multiplayerGroupId = PluginSession.getMultiplayerGroupId()
|
|
11274
12095
|
local transportRole = PluginSession.getRole()
|
|
11275
12096
|
local readyUrl = `{currentOptions.serverUrl}/ready`
|
|
11276
12097
|
local readyPayload = PluginSession.createReadyPayload(PluginSession.peerId, transportRole, instanceId, multiplayerGroupId)
|
|
11277
|
-
if not
|
|
12098
|
+
if not ownsWork(work) then
|
|
11278
12099
|
return nil
|
|
11279
12100
|
end
|
|
11280
12101
|
local readyOk, readyResult = pcall(function()
|
|
@@ -11287,7 +12108,7 @@ local function registerReady(currentOptions, expectedGeneration, reconnectOnFail
|
|
|
11287
12108
|
Body = HttpService:JSONEncode(readyPayload),
|
|
11288
12109
|
})
|
|
11289
12110
|
end)
|
|
11290
|
-
if not
|
|
12111
|
+
if not ownsWork(work) then
|
|
11291
12112
|
return nil
|
|
11292
12113
|
end
|
|
11293
12114
|
local readyLogKey = `{currentOptions.serverUrl}|{PluginSession.peerId}`
|
|
@@ -11317,7 +12138,7 @@ local function registerReady(currentOptions, expectedGeneration, reconnectOnFail
|
|
|
11317
12138
|
invokeCallback("WebSocket ready", function()
|
|
11318
12139
|
return currentOptions.onReady(readyData)
|
|
11319
12140
|
end)
|
|
11320
|
-
return readyData
|
|
12141
|
+
return if ownsWork(work) then readyData else nil
|
|
11321
12142
|
end
|
|
11322
12143
|
local function credentialsRejected(statusCode)
|
|
11323
12144
|
return statusCode == 401 or statusCode == 403 or statusCode == 404
|
|
@@ -11327,14 +12148,28 @@ function connect(expectedGeneration)
|
|
|
11327
12148
|
if not active or generation ~= expectedGeneration or currentOptions == nil then
|
|
11328
12149
|
return nil
|
|
11329
12150
|
end
|
|
11330
|
-
|
|
11331
|
-
|
|
11332
|
-
|
|
11333
|
-
|
|
11334
|
-
|
|
11335
|
-
|
|
11336
|
-
|
|
11337
|
-
|
|
12151
|
+
if refreshWork ~= nil then
|
|
12152
|
+
finishWork(refreshWork)
|
|
12153
|
+
end
|
|
12154
|
+
local work = {
|
|
12155
|
+
generation = expectedGeneration,
|
|
12156
|
+
options = currentOptions,
|
|
12157
|
+
stage = "",
|
|
12158
|
+
}
|
|
12159
|
+
connectionWork = work
|
|
12160
|
+
beginStage(work, if cachedReady == nil then "/ready registration" else "WebSocket upgrade")
|
|
12161
|
+
if not ownsWork(work) then
|
|
12162
|
+
return nil
|
|
12163
|
+
end
|
|
12164
|
+
runWork(work, function()
|
|
12165
|
+
local readyData = cachedReady or registerReady(work, true)
|
|
12166
|
+
if readyData == nil or not ownsWork(work) then
|
|
12167
|
+
return nil
|
|
12168
|
+
end
|
|
12169
|
+
if work.stage ~= "WebSocket upgrade" then
|
|
12170
|
+
beginStage(work, "WebSocket upgrade")
|
|
12171
|
+
end
|
|
12172
|
+
if not ownsWork(work) then
|
|
11338
12173
|
return nil
|
|
11339
12174
|
end
|
|
11340
12175
|
local socketBaseUrl = string.gsub(currentOptions.serverUrl, "^http", "ws")
|
|
@@ -11347,28 +12182,32 @@ function connect(expectedGeneration)
|
|
|
11347
12182
|
})
|
|
11348
12183
|
end)
|
|
11349
12184
|
if not createOk then
|
|
11350
|
-
scheduleReconnect(expectedGeneration,
|
|
12185
|
+
scheduleReconnect(expectedGeneration, "Failed to create WebSocket")
|
|
11351
12186
|
return nil
|
|
11352
12187
|
end
|
|
11353
|
-
if not
|
|
12188
|
+
if not ownsWork(work) then
|
|
11354
12189
|
pcall(function()
|
|
11355
12190
|
return createdClient:Close()
|
|
11356
12191
|
end)
|
|
11357
12192
|
return nil
|
|
11358
12193
|
end
|
|
11359
12194
|
socketClient = createdClient
|
|
11360
|
-
|
|
12195
|
+
-- Attach incrementally so a failed subscription cannot leak earlier ones.
|
|
12196
|
+
local _socketConnections = socketConnections
|
|
12197
|
+
local _arg0 = createdClient.Opened:Connect(function(statusCode, _headers)
|
|
11361
12198
|
if not active or generation ~= expectedGeneration or socketClient ~= createdClient then
|
|
11362
12199
|
return nil
|
|
11363
12200
|
end
|
|
12201
|
+
if socketOpen then
|
|
12202
|
+
return nil
|
|
12203
|
+
end
|
|
11364
12204
|
if statusCode ~= 101 and (statusCode < 200 or statusCode >= 300) then
|
|
11365
|
-
|
|
11366
|
-
cachedReady = nil
|
|
11367
|
-
end
|
|
12205
|
+
cachedReady = nil
|
|
11368
12206
|
scheduleReconnect(expectedGeneration, `WebSocket opened with HTTP {statusCode}`)
|
|
11369
12207
|
return nil
|
|
11370
12208
|
end
|
|
11371
12209
|
socketOpen = true
|
|
12210
|
+
finishWork(work)
|
|
11372
12211
|
lastValidEventAt = tick()
|
|
11373
12212
|
reconnectAttempt = 0
|
|
11374
12213
|
reportTransport({
|
|
@@ -11376,8 +12215,18 @@ function connect(expectedGeneration)
|
|
|
11376
12215
|
attempt = 0,
|
|
11377
12216
|
retryDelay = 0,
|
|
11378
12217
|
})
|
|
12218
|
+
if not active or generation ~= expectedGeneration or socketClient ~= createdClient then
|
|
12219
|
+
return nil
|
|
12220
|
+
end
|
|
12221
|
+
watchForSilence(expectedGeneration, createdClient)
|
|
11379
12222
|
resumePendingResponses()
|
|
11380
|
-
|
|
12223
|
+
if active and generation == expectedGeneration and socketClient == createdClient then
|
|
12224
|
+
flushRefresh()
|
|
12225
|
+
end
|
|
12226
|
+
end)
|
|
12227
|
+
table.insert(_socketConnections, _arg0)
|
|
12228
|
+
local _socketConnections_1 = socketConnections
|
|
12229
|
+
local _arg0_1 = createdClient.MessageReceived:Connect(function(message)
|
|
11381
12230
|
if not active or generation ~= expectedGeneration or socketClient ~= createdClient then
|
|
11382
12231
|
return nil
|
|
11383
12232
|
end
|
|
@@ -11415,27 +12264,41 @@ function connect(expectedGeneration)
|
|
|
11415
12264
|
invokeCallback("WebSocket status", function()
|
|
11416
12265
|
return currentOptions.onStatus(event)
|
|
11417
12266
|
end)
|
|
12267
|
+
if not active or generation ~= expectedGeneration or socketClient ~= createdClient then
|
|
12268
|
+
return nil
|
|
12269
|
+
end
|
|
11418
12270
|
if not event.knownPeer then
|
|
11419
12271
|
cachedReady = nil
|
|
11420
12272
|
scheduleReconnect(expectedGeneration, "WebSocket session is no longer registered")
|
|
11421
12273
|
end
|
|
11422
12274
|
end
|
|
11423
|
-
end)
|
|
12275
|
+
end)
|
|
12276
|
+
table.insert(_socketConnections_1, _arg0_1)
|
|
12277
|
+
local _socketConnections_2 = socketConnections
|
|
12278
|
+
local _arg0_2 = createdClient.Error:Connect(function(statusCode, _message)
|
|
11424
12279
|
if not active or generation ~= expectedGeneration or socketClient ~= createdClient then
|
|
11425
12280
|
return nil
|
|
11426
12281
|
end
|
|
11427
|
-
if credentialsRejected(statusCode) then
|
|
12282
|
+
if not socketOpen or credentialsRejected(statusCode) then
|
|
11428
12283
|
cachedReady = nil
|
|
11429
12284
|
end
|
|
11430
|
-
|
|
11431
|
-
|
|
12285
|
+
-- Native error messages may echo request credentials.
|
|
12286
|
+
scheduleReconnect(expectedGeneration, `WebSocket error {statusCode}`)
|
|
12287
|
+
end)
|
|
12288
|
+
table.insert(_socketConnections_2, _arg0_2)
|
|
12289
|
+
local _socketConnections_3 = socketConnections
|
|
12290
|
+
local _arg0_3 = createdClient.Closed:Connect(function()
|
|
11432
12291
|
if not active or generation ~= expectedGeneration or socketClient ~= createdClient then
|
|
11433
12292
|
return nil
|
|
11434
12293
|
end
|
|
12294
|
+
if not socketOpen then
|
|
12295
|
+
cachedReady = nil
|
|
12296
|
+
end
|
|
11435
12297
|
scheduleReconnect(expectedGeneration, "WebSocket closed")
|
|
11436
|
-
end)
|
|
11437
|
-
|
|
11438
|
-
|
|
12298
|
+
end)
|
|
12299
|
+
table.insert(_socketConnections_3, _arg0_3)
|
|
12300
|
+
end, function(detail)
|
|
12301
|
+
return scheduleReconnect(expectedGeneration, detail)
|
|
11439
12302
|
end)
|
|
11440
12303
|
end
|
|
11441
12304
|
local stop
|
|
@@ -11450,20 +12313,42 @@ local function start(newOptions)
|
|
|
11450
12313
|
generation += 1
|
|
11451
12314
|
connect(generation)
|
|
11452
12315
|
end
|
|
11453
|
-
|
|
12316
|
+
function flushRefresh()
|
|
11454
12317
|
local currentOptions = options
|
|
11455
|
-
if not active or currentOptions == nil or
|
|
12318
|
+
if not refreshRequested or not active or currentOptions == nil or refreshWork ~= nil or connectionWork ~= nil or not socketOpen then
|
|
11456
12319
|
return nil
|
|
11457
12320
|
end
|
|
11458
|
-
|
|
11459
|
-
--
|
|
11460
|
-
|
|
11461
|
-
local
|
|
11462
|
-
|
|
11463
|
-
|
|
11464
|
-
|
|
12321
|
+
refreshRequested = false
|
|
12322
|
+
-- Only explicit metadata changes are queued. Failure alone never schedules
|
|
12323
|
+
-- more HTTP requests or replaces a healthy transport.
|
|
12324
|
+
local work = {
|
|
12325
|
+
generation = generation,
|
|
12326
|
+
options = currentOptions,
|
|
12327
|
+
stage = "",
|
|
12328
|
+
}
|
|
12329
|
+
refreshWork = work
|
|
12330
|
+
beginStage(work, "/ready metadata refresh")
|
|
12331
|
+
runWork(work, function()
|
|
12332
|
+
-- Consume at worker entry so changes coalesce even before task.spawn runs.
|
|
12333
|
+
refreshRequested = false
|
|
12334
|
+
registerReady(work, false)
|
|
12335
|
+
if ownsWork(work) then
|
|
12336
|
+
finishWork(work)
|
|
12337
|
+
flushRefresh()
|
|
12338
|
+
end
|
|
12339
|
+
end, function(detail)
|
|
12340
|
+
finishWork(work)
|
|
12341
|
+
warn(`[robloxstudio-mcp] {detail}`)
|
|
12342
|
+
flushRefresh()
|
|
11465
12343
|
end)
|
|
11466
12344
|
end
|
|
12345
|
+
local function refresh()
|
|
12346
|
+
if not active or options == nil then
|
|
12347
|
+
return nil
|
|
12348
|
+
end
|
|
12349
|
+
refreshRequested = true
|
|
12350
|
+
flushRefresh()
|
|
12351
|
+
end
|
|
11467
12352
|
-- EndTest may tear down this VM without an Unloading callback. Release the
|
|
11468
12353
|
-- native socket before yielding to unregister; a failed EndTest can resume.
|
|
11469
12354
|
local function suspendForShutdown()
|
|
@@ -11474,6 +12359,8 @@ local function suspendForShutdown()
|
|
|
11474
12359
|
active = false
|
|
11475
12360
|
shutdownSuspended = true
|
|
11476
12361
|
generation += 1
|
|
12362
|
+
cancelTransportWork()
|
|
12363
|
+
refreshRequested = false
|
|
11477
12364
|
reconnectAttempt = 0
|
|
11478
12365
|
closeCurrentSocket()
|
|
11479
12366
|
cachedReady = nil
|
|
@@ -11497,6 +12384,8 @@ function stop()
|
|
|
11497
12384
|
active = false
|
|
11498
12385
|
shutdownSuspended = false
|
|
11499
12386
|
generation += 1
|
|
12387
|
+
cancelTransportWork()
|
|
12388
|
+
refreshRequested = false
|
|
11500
12389
|
for _, inFlight in inFlightRequests do
|
|
11501
12390
|
inFlight.cancelled = true
|
|
11502
12391
|
end
|
|
@@ -11827,12 +12716,13 @@ local function init(pluginRef)
|
|
|
11827
12716
|
end
|
|
11828
12717
|
end)
|
|
11829
12718
|
local statusRow = Instance.new("Frame")
|
|
11830
|
-
statusRow.Size = UDim2.new(1, 0, 0,
|
|
12719
|
+
statusRow.Size = UDim2.new(1, 0, 0, 0)
|
|
12720
|
+
statusRow.AutomaticSize = Enum.AutomaticSize.Y
|
|
11831
12721
|
statusRow.BackgroundTransparency = 1
|
|
11832
12722
|
statusRow.LayoutOrder = 2
|
|
11833
12723
|
statusRow.Parent = card
|
|
11834
12724
|
local statusLabel = Instance.new("TextLabel")
|
|
11835
|
-
statusLabel.Size = UDim2.new(1, 0,
|
|
12725
|
+
statusLabel.Size = UDim2.new(1, 0, 0, 18)
|
|
11836
12726
|
statusLabel.BackgroundTransparency = 1
|
|
11837
12727
|
statusLabel.Text = "Disconnected"
|
|
11838
12728
|
statusLabel.TextColor3 = C.red
|
|
@@ -11840,17 +12730,20 @@ local function init(pluginRef)
|
|
|
11840
12730
|
statusLabel.Font = Enum.Font.GothamBold
|
|
11841
12731
|
statusLabel.TextXAlignment = Enum.TextXAlignment.Left
|
|
11842
12732
|
statusLabel.TextWrapped = true
|
|
12733
|
+
statusLabel.RichText = false
|
|
11843
12734
|
statusLabel.Parent = statusRow
|
|
11844
12735
|
local detailStatusLabel = Instance.new("TextLabel")
|
|
11845
|
-
detailStatusLabel.Size = UDim2.new(
|
|
11846
|
-
detailStatusLabel.Position = UDim2.new(0
|
|
12736
|
+
detailStatusLabel.Size = UDim2.new(1, 0, 0, 0)
|
|
12737
|
+
detailStatusLabel.Position = UDim2.new(0, 0, 0, 18)
|
|
12738
|
+
detailStatusLabel.AutomaticSize = Enum.AutomaticSize.Y
|
|
11847
12739
|
detailStatusLabel.BackgroundTransparency = 1
|
|
11848
12740
|
detailStatusLabel.Text = "HTTP: X MCP: X"
|
|
11849
12741
|
detailStatusLabel.TextColor3 = C.muted
|
|
11850
12742
|
detailStatusLabel.TextSize = 9
|
|
11851
12743
|
detailStatusLabel.Font = Enum.Font.GothamMedium
|
|
11852
|
-
detailStatusLabel.TextXAlignment = Enum.TextXAlignment.
|
|
12744
|
+
detailStatusLabel.TextXAlignment = Enum.TextXAlignment.Left
|
|
11853
12745
|
detailStatusLabel.TextWrapped = true
|
|
12746
|
+
detailStatusLabel.RichText = false
|
|
11854
12747
|
detailStatusLabel.Parent = statusRow
|
|
11855
12748
|
local stepsFrame = Instance.new("Frame")
|
|
11856
12749
|
stepsFrame.Size = UDim2.new(1, 0, 0, 0)
|
|
@@ -11987,13 +12880,14 @@ local function updateUIState()
|
|
|
11987
12880
|
return nil
|
|
11988
12881
|
end
|
|
11989
12882
|
local el = elements
|
|
12883
|
+
local diagnostics = State.getTransportDiagnostics(tick())
|
|
11990
12884
|
if not conn.isActive then
|
|
11991
|
-
el.statusLabel.Text =
|
|
12885
|
+
el.statusLabel.Text = diagnostics.status
|
|
11992
12886
|
el.statusLabel.TextColor3 = C.muted
|
|
11993
12887
|
el.statusIndicator.BackgroundColor3 = C.red
|
|
11994
12888
|
el.statusPulse.BackgroundColor3 = C.red
|
|
11995
12889
|
el.statusText.Text = "OFFLINE"
|
|
11996
|
-
el.detailStatusLabel.Text =
|
|
12890
|
+
el.detailStatusLabel.Text = diagnostics.detail
|
|
11997
12891
|
el.detailStatusLabel.TextColor3 = C.muted
|
|
11998
12892
|
stopPulseAnimation()
|
|
11999
12893
|
el.step1Dot.BackgroundColor3 = C.gray
|
|
@@ -12016,12 +12910,12 @@ local function updateUIState()
|
|
|
12016
12910
|
el.urlInput.TextEditable = false
|
|
12017
12911
|
el.urlInput.BackgroundColor3 = C.card
|
|
12018
12912
|
if conn.lastHttpOk and conn.lastMcpOk then
|
|
12019
|
-
el.statusLabel.Text =
|
|
12913
|
+
el.statusLabel.Text = diagnostics.status
|
|
12020
12914
|
el.statusLabel.TextColor3 = Color3.fromRGB(34, 197, 94)
|
|
12021
12915
|
el.statusIndicator.BackgroundColor3 = Color3.fromRGB(34, 197, 94)
|
|
12022
12916
|
el.statusPulse.BackgroundColor3 = Color3.fromRGB(34, 197, 94)
|
|
12023
12917
|
el.statusText.Text = "ONLINE"
|
|
12024
|
-
el.detailStatusLabel.Text =
|
|
12918
|
+
el.detailStatusLabel.Text = diagnostics.detail
|
|
12025
12919
|
el.detailStatusLabel.TextColor3 = Color3.fromRGB(34, 197, 94)
|
|
12026
12920
|
el.step1Dot.BackgroundColor3 = Color3.fromRGB(34, 197, 94)
|
|
12027
12921
|
el.step1Label.Text = "HTTP server (OK)"
|
|
@@ -12032,12 +12926,12 @@ local function updateUIState()
|
|
|
12032
12926
|
el.troubleshootLabel.Visible = false
|
|
12033
12927
|
stopPulseAnimation()
|
|
12034
12928
|
elseif conn.lastHttpOk and not conn.lastMcpOk then
|
|
12035
|
-
el.statusLabel.Text =
|
|
12929
|
+
el.statusLabel.Text = diagnostics.status
|
|
12036
12930
|
el.statusLabel.TextColor3 = Color3.fromRGB(245, 158, 11)
|
|
12037
12931
|
el.statusIndicator.BackgroundColor3 = Color3.fromRGB(245, 158, 11)
|
|
12038
12932
|
el.statusPulse.BackgroundColor3 = Color3.fromRGB(245, 158, 11)
|
|
12039
12933
|
el.statusText.Text = "WAITING"
|
|
12040
|
-
el.detailStatusLabel.Text =
|
|
12934
|
+
el.detailStatusLabel.Text = diagnostics.detail
|
|
12041
12935
|
el.detailStatusLabel.TextColor3 = Color3.fromRGB(245, 158, 11)
|
|
12042
12936
|
el.step1Dot.BackgroundColor3 = Color3.fromRGB(34, 197, 94)
|
|
12043
12937
|
el.step1Label.Text = "HTTP server (OK)"
|
|
@@ -12048,53 +12942,21 @@ local function updateUIState()
|
|
|
12048
12942
|
local elapsed = if conn.mcpWaitStartTime ~= nil then tick() - conn.mcpWaitStartTime else 0
|
|
12049
12943
|
el.troubleshootLabel.Visible = elapsed > 8
|
|
12050
12944
|
startPulseAnimation()
|
|
12051
|
-
elseif conn.consecutiveFailures >= conn.maxFailuresBeforeError then
|
|
12052
|
-
el.statusLabel.Text = "Server unavailable"
|
|
12053
|
-
el.statusLabel.TextColor3 = Color3.fromRGB(239, 68, 68)
|
|
12054
|
-
el.statusIndicator.BackgroundColor3 = Color3.fromRGB(239, 68, 68)
|
|
12055
|
-
el.statusPulse.BackgroundColor3 = Color3.fromRGB(239, 68, 68)
|
|
12056
|
-
el.statusText.Text = "ERROR"
|
|
12057
|
-
el.detailStatusLabel.Text = "HTTP: X MCP: X"
|
|
12058
|
-
el.detailStatusLabel.TextColor3 = Color3.fromRGB(239, 68, 68)
|
|
12059
|
-
el.step1Dot.BackgroundColor3 = Color3.fromRGB(239, 68, 68)
|
|
12060
|
-
el.step1Label.Text = "HTTP server (error)"
|
|
12061
|
-
el.step2Dot.BackgroundColor3 = Color3.fromRGB(239, 68, 68)
|
|
12062
|
-
el.step2Label.Text = "MCP bridge (error)"
|
|
12063
|
-
el.step3Dot.BackgroundColor3 = Color3.fromRGB(239, 68, 68)
|
|
12064
|
-
el.step3Label.Text = "Commands (error)"
|
|
12065
|
-
el.troubleshootLabel.Visible = false
|
|
12066
|
-
stopPulseAnimation()
|
|
12067
|
-
elseif conn.consecutiveFailures > 5 then
|
|
12068
|
-
local waitTime = math.ceil(conn.currentRetryDelay)
|
|
12069
|
-
el.statusLabel.Text = `Retrying ({waitTime}s)`
|
|
12070
|
-
el.statusLabel.TextColor3 = Color3.fromRGB(245, 158, 11)
|
|
12071
|
-
el.statusIndicator.BackgroundColor3 = Color3.fromRGB(245, 158, 11)
|
|
12072
|
-
el.statusPulse.BackgroundColor3 = Color3.fromRGB(245, 158, 11)
|
|
12073
|
-
el.statusText.Text = "RETRY"
|
|
12074
|
-
el.detailStatusLabel.Text = "HTTP: ... MCP: ..."
|
|
12075
|
-
el.detailStatusLabel.TextColor3 = Color3.fromRGB(245, 158, 11)
|
|
12076
|
-
el.step1Dot.BackgroundColor3 = Color3.fromRGB(245, 158, 11)
|
|
12077
|
-
el.step1Label.Text = "HTTP server (retrying...)"
|
|
12078
|
-
el.step2Dot.BackgroundColor3 = Color3.fromRGB(245, 158, 11)
|
|
12079
|
-
el.step2Label.Text = "MCP bridge (retrying...)"
|
|
12080
|
-
el.step3Dot.BackgroundColor3 = Color3.fromRGB(245, 158, 11)
|
|
12081
|
-
el.step3Label.Text = "Commands (retrying...)"
|
|
12082
|
-
el.troubleshootLabel.Visible = false
|
|
12083
|
-
startPulseAnimation()
|
|
12084
12945
|
else
|
|
12085
|
-
|
|
12946
|
+
local retrying = conn.nextRetryAt ~= nil
|
|
12947
|
+
el.statusLabel.Text = diagnostics.status
|
|
12086
12948
|
el.statusLabel.TextColor3 = C.yellow
|
|
12087
12949
|
el.statusIndicator.BackgroundColor3 = C.yellow
|
|
12088
12950
|
el.statusPulse.BackgroundColor3 = C.yellow
|
|
12089
|
-
el.statusText.Text = "CONNECTING"
|
|
12090
|
-
el.detailStatusLabel.Text =
|
|
12091
|
-
el.detailStatusLabel.TextColor3 = C.
|
|
12951
|
+
el.statusText.Text = if retrying then "RETRY" else "CONNECTING"
|
|
12952
|
+
el.detailStatusLabel.Text = diagnostics.detail
|
|
12953
|
+
el.detailStatusLabel.TextColor3 = C.yellow
|
|
12092
12954
|
el.step1Dot.BackgroundColor3 = C.yellow
|
|
12093
|
-
el.step1Label.Text = "
|
|
12094
|
-
el.step2Dot.BackgroundColor3 = C.
|
|
12095
|
-
el.step2Label.Text = "MCP
|
|
12096
|
-
el.step3Dot.BackgroundColor3 = C.
|
|
12097
|
-
el.step3Label.Text = "Commands (
|
|
12955
|
+
el.step1Label.Text = if retrying then "Listener (disconnected)" else "Listener (connecting...)"
|
|
12956
|
+
el.step2Dot.BackgroundColor3 = C.gray
|
|
12957
|
+
el.step2Label.Text = "MCP client (waiting for listener)"
|
|
12958
|
+
el.step3Dot.BackgroundColor3 = C.gray
|
|
12959
|
+
el.step3Label.Text = "Commands (waiting for listener)"
|
|
12098
12960
|
el.troubleshootLabel.Visible = false
|
|
12099
12961
|
startPulseAnimation()
|
|
12100
12962
|
end
|
|
@@ -12714,11 +13576,13 @@ local function convertPropertyValue(instance, propertyName, propertyValue)
|
|
|
12714
13576
|
if propertyName == "BrickColor" then
|
|
12715
13577
|
return BrickColor.new(propertyValue)
|
|
12716
13578
|
end
|
|
12717
|
-
if
|
|
12718
|
-
|
|
12719
|
-
|
|
12720
|
-
|
|
12721
|
-
|
|
13579
|
+
if success and typeof(currentVal) == "boolean" then
|
|
13580
|
+
if propertyValue == "true" then
|
|
13581
|
+
return true
|
|
13582
|
+
end
|
|
13583
|
+
if propertyValue == "false" then
|
|
13584
|
+
return false
|
|
13585
|
+
end
|
|
12722
13586
|
end
|
|
12723
13587
|
end
|
|
12724
13588
|
return propertyValue
|