@chrrxs/robloxstudio-mcp-inspector 3.1.4 → 3.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +920 -261
- package/package.json +1 -1
- package/studio-plugin/MCPInspectorPlugin.rbxmx +976 -132
|
@@ -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.5" 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.5`
|
|
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,377 @@ 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
|
+
gui.ScreenInsets = Enum.ScreenInsets.None
|
|
3704
|
+
gui.DisplayOrder = 2147483647
|
|
3705
|
+
gui.ResetOnSpawn = false
|
|
3706
|
+
gui.ZIndexBehavior = Enum.ZIndexBehavior.Global
|
|
3707
|
+
local corners = { { 0, 0 }, { 1, 0 }, { 0, 1 }, { 1, 1 } }
|
|
3708
|
+
for _, _binding in corners do
|
|
3709
|
+
local x = _binding[1]
|
|
3710
|
+
local y = _binding[2]
|
|
3711
|
+
local frame = Instance.new("Frame")
|
|
3712
|
+
frame.Name = `Corner{x}{y}`
|
|
3713
|
+
frame.AnchorPoint = Vector2.new(x, y)
|
|
3714
|
+
frame.Position = UDim2.fromScale(x, y)
|
|
3715
|
+
frame.Size = UDim2.fromOffset(MARKER_SIZE, MARKER_SIZE)
|
|
3716
|
+
frame.BackgroundColor3 = MARKER_COLOR
|
|
3717
|
+
frame.BackgroundTransparency = 0
|
|
3718
|
+
frame.BorderSizePixel = 0
|
|
3719
|
+
frame.ZIndex = 2147483647
|
|
3720
|
+
frame.Parent = gui
|
|
3721
|
+
end
|
|
3722
|
+
gui.Parent = CoreGui
|
|
3723
|
+
task.delay(MARKER_AUTO_HIDE_SECONDS, function()
|
|
3724
|
+
if gui.Parent ~= nil then
|
|
3725
|
+
gui:Destroy()
|
|
3726
|
+
end
|
|
3727
|
+
end)
|
|
3728
|
+
local framesRendered = waitForRenderedFrames()
|
|
3729
|
+
local accessError = markerAccessError("show", captureId)
|
|
3730
|
+
if accessError ~= nil then
|
|
3731
|
+
gui:Destroy()
|
|
3732
|
+
return {
|
|
3733
|
+
error = accessError,
|
|
3734
|
+
}
|
|
3735
|
+
end
|
|
3736
|
+
local renderedSize = viewportSize()
|
|
3737
|
+
if renderedSize == nil then
|
|
3738
|
+
gui:Destroy()
|
|
3739
|
+
return {
|
|
3740
|
+
error = "No CurrentCamera after rendering viewport markers.",
|
|
3741
|
+
}
|
|
3742
|
+
end
|
|
3743
|
+
local _object = {
|
|
3744
|
+
success = true,
|
|
3745
|
+
}
|
|
3746
|
+
for _k, _v in renderedSize do
|
|
3747
|
+
_object[_k] = _v
|
|
3748
|
+
end
|
|
3749
|
+
_object.markerSize = MARKER_SIZE
|
|
3750
|
+
_object.framesRendered = framesRendered
|
|
3751
|
+
return _object
|
|
3752
|
+
end
|
|
3753
|
+
-- prepare temporarily fits a simulated viewport; finish restores it after the
|
|
3754
|
+
-- clean host grab. hide only removes markers: restoring here would clip that grab.
|
|
3755
|
+
-- show/query report the current camera dimensions, not prepare's original size.
|
|
3756
|
+
local function captureMarkers(requestData)
|
|
3757
|
+
local action = requestData.action
|
|
3758
|
+
if action == "prepare" then
|
|
3759
|
+
return prepareMarkerCapture()
|
|
3760
|
+
end
|
|
3761
|
+
if action == "finish" then
|
|
3762
|
+
local captureId = requestData.captureId
|
|
3763
|
+
if not (type(captureId) == "string") or captureId == "" then
|
|
3764
|
+
return {
|
|
3765
|
+
error = 'capture-markers action "finish" requires a non-empty captureId.',
|
|
3766
|
+
}
|
|
3767
|
+
end
|
|
3768
|
+
return finishMarkerCapture(captureId)
|
|
3769
|
+
end
|
|
3770
|
+
if action == "show" or action == "hide" or action == "query" then
|
|
3771
|
+
local accessError = markerAccessError(action, requestData.captureId)
|
|
3772
|
+
if accessError ~= nil then
|
|
3773
|
+
return {
|
|
3774
|
+
error = accessError,
|
|
3775
|
+
}
|
|
3776
|
+
end
|
|
3777
|
+
end
|
|
3778
|
+
if action == "show" then
|
|
3779
|
+
return showMarkers(requestData.captureId)
|
|
3780
|
+
end
|
|
3781
|
+
if action == "hide" then
|
|
3782
|
+
hideMarkers()
|
|
3783
|
+
return {
|
|
3784
|
+
success = true,
|
|
3785
|
+
}
|
|
3786
|
+
end
|
|
3787
|
+
if action == "query" then
|
|
3788
|
+
local size = viewportSize()
|
|
3789
|
+
if size == nil then
|
|
3790
|
+
return {
|
|
3791
|
+
error = "No CurrentCamera; cannot read viewport size.",
|
|
3792
|
+
}
|
|
3793
|
+
end
|
|
3794
|
+
local _object = {
|
|
3795
|
+
success = true,
|
|
3796
|
+
}
|
|
3797
|
+
for _k, _v in size do
|
|
3798
|
+
_object[_k] = _v
|
|
3799
|
+
end
|
|
3800
|
+
_object.markerSize = MARKER_SIZE
|
|
3801
|
+
return _object
|
|
3802
|
+
end
|
|
3803
|
+
return {
|
|
3804
|
+
error = `capture-markers action must be "prepare", "finish", "show", "hide" or "query" (got {tostring(action)})`,
|
|
3805
|
+
}
|
|
3806
|
+
end
|
|
3293
3807
|
return {
|
|
3294
3808
|
captureScreenshotData = captureScreenshotData,
|
|
3295
3809
|
captureScreenshot = captureScreenshot,
|
|
3296
3810
|
captureStudio = captureStudio,
|
|
3297
3811
|
captureBegin = captureBegin,
|
|
3298
3812
|
captureRead = captureRead,
|
|
3813
|
+
captureMarkers = captureMarkers,
|
|
3299
3814
|
}
|
|
3300
3815
|
]]></string>
|
|
3301
3816
|
</Properties>
|
|
@@ -4879,6 +5394,16 @@ local function captureMicroProfiler(requestData)
|
|
|
4879
5394
|
backend = backend,
|
|
4880
5395
|
}
|
|
4881
5396
|
end
|
|
5397
|
+
local frameLimitOk, frameLimitResult = safeCall(function()
|
|
5398
|
+
return LibMP.Control:SetFrameLimit(math.min(frameWindow, 256))
|
|
5399
|
+
end)
|
|
5400
|
+
if not frameLimitOk then
|
|
5401
|
+
return {
|
|
5402
|
+
error = "micro_profiler_frame_limit_failed",
|
|
5403
|
+
message = tostring(frameLimitResult),
|
|
5404
|
+
backend = backend,
|
|
5405
|
+
}
|
|
5406
|
+
end
|
|
4882
5407
|
local profilerOk, profilerResult = safeCall(function()
|
|
4883
5408
|
return LibMP.Control:EnableProfiler(true)
|
|
4884
5409
|
end)
|
|
@@ -4900,6 +5425,11 @@ local function captureMicroProfiler(requestData)
|
|
|
4900
5425
|
}
|
|
4901
5426
|
end
|
|
4902
5427
|
task.wait(durationMs / 1000)
|
|
5428
|
+
-- Active reads synchronize the engine's data; paused reads can return an
|
|
5429
|
+
-- empty or stale cache. Export first, then pause, including on export failure.
|
|
5430
|
+
local bufferOk, snapshotOrErr = safeCall(function()
|
|
5431
|
+
return LibMP.Control:CaptureToBufferSync()
|
|
5432
|
+
end)
|
|
4903
5433
|
local captureStopOk, captureStopResult = safeCall(function()
|
|
4904
5434
|
return LibMP.Control:EnableCapture(false)
|
|
4905
5435
|
end)
|
|
@@ -4910,9 +5440,6 @@ local function captureMicroProfiler(requestData)
|
|
|
4910
5440
|
backend = backend,
|
|
4911
5441
|
}
|
|
4912
5442
|
end
|
|
4913
|
-
local bufferOk, snapshotOrErr = safeCall(function()
|
|
4914
|
-
return LibMP.Control:CaptureToBufferSync()
|
|
4915
|
-
end)
|
|
4916
5443
|
if not bufferOk then
|
|
4917
5444
|
return {
|
|
4918
5445
|
error = "micro_profiler_snapshot_failed",
|
|
@@ -8512,6 +9039,7 @@ local function stopPlaytest(_requestData)
|
|
|
8512
9039
|
-- Signal the play-server DM's StopPlayMonitor via plugin:SetSetting.
|
|
8513
9040
|
-- The monitor acknowledges with the matching request id only after its
|
|
8514
9041
|
-- StudioTestService:EndTest call returns from pcall.
|
|
9042
|
+
local hadActivePlaytest = testRunning or not StudioTestService.EditModeActive
|
|
8515
9043
|
local stopRequest = StopPlayMonitor.requestStop()
|
|
8516
9044
|
if not stopRequest.ok or stopRequest.requestId == nil then
|
|
8517
9045
|
return {
|
|
@@ -8534,6 +9062,15 @@ local function stopPlaytest(_requestData)
|
|
|
8534
9062
|
-- Either way clean up the pending request so a future playtest's monitor
|
|
8535
9063
|
-- doesn't fire EndTest on startup against a stale signal.
|
|
8536
9064
|
StopPlayMonitor.clearPending(stopRequest.requestId)
|
|
9065
|
+
-- Settings acknowledgement can be lost as the server VM exits. Reconcile
|
|
9066
|
+
-- only a playtest observed at entry, and only after both native teardown
|
|
9067
|
+
-- and the tracked execution have finished. Never mask an EndTest error.
|
|
9068
|
+
if not consumption.consumed and hadActivePlaytest and not testRunning and StudioTestService.EditModeActive then
|
|
9069
|
+
return {
|
|
9070
|
+
success = true,
|
|
9071
|
+
message = "Playtest stopped.",
|
|
9072
|
+
}
|
|
9073
|
+
end
|
|
8537
9074
|
if testRunning then
|
|
8538
9075
|
return {
|
|
8539
9076
|
error = "Playtest stop signal failed or was not acknowledged. " .. "The playtest may have ended anyway; check get_connected_instances.",
|
|
@@ -10305,9 +10842,10 @@ return {
|
|
|
10305
10842
|
<Properties>
|
|
10306
10843
|
<string name="Name">State</string>
|
|
10307
10844
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
10308
|
-
local CURRENT_VERSION = "3.1.
|
|
10845
|
+
local CURRENT_VERSION = "3.1.5"
|
|
10309
10846
|
local PLUGIN_VARIANT = "inspector"
|
|
10310
10847
|
local BASE_PORT = 58741
|
|
10848
|
+
local SENSITIVE_DIAGNOSTIC_TERMS = { "token", "authorization", "bearer", "password", "cookie", "secret" }
|
|
10311
10849
|
local function createConnection(port)
|
|
10312
10850
|
return {
|
|
10313
10851
|
port = port,
|
|
@@ -10326,11 +10864,118 @@ local connection = createConnection(BASE_PORT)
|
|
|
10326
10864
|
local function getActiveConnection()
|
|
10327
10865
|
return connection
|
|
10328
10866
|
end
|
|
10867
|
+
local function clearTransportDiagnostics()
|
|
10868
|
+
connection.transportState = nil
|
|
10869
|
+
connection.transportAttempt = nil
|
|
10870
|
+
connection.transportDetail = nil
|
|
10871
|
+
connection.lastTransportFailure = nil
|
|
10872
|
+
connection.nextRetryAt = nil
|
|
10873
|
+
end
|
|
10874
|
+
local function sanitizeTransportDetail(detail)
|
|
10875
|
+
-- Transport emits credential-free stage descriptions; also defend the UI
|
|
10876
|
+
-- against accidental raw errors, URLs, markup, and multiline responses.
|
|
10877
|
+
local lower = string.lower(detail)
|
|
10878
|
+
for _, sensitive in SENSITIVE_DIAGNOSTIC_TERMS do
|
|
10879
|
+
if (string.find(lower, sensitive, 1, true)) ~= nil then
|
|
10880
|
+
return "Connection failed (sensitive details hidden)."
|
|
10881
|
+
end
|
|
10882
|
+
end
|
|
10883
|
+
local plain = string.gsub(detail, "%w+://%S+", "[address hidden]")
|
|
10884
|
+
plain = string.gsub(plain, "%c", " ")
|
|
10885
|
+
plain = string.gsub(plain, "<[^>]*>", "")
|
|
10886
|
+
plain = string.gsub(plain, "%s+", " ")
|
|
10887
|
+
return if #plain > 180 then `{string.sub(plain, 1, 177)}...` else plain
|
|
10888
|
+
end
|
|
10889
|
+
local function applyTransportUpdate(update, now)
|
|
10890
|
+
if not connection.isActive then
|
|
10891
|
+
return nil
|
|
10892
|
+
end
|
|
10893
|
+
if update.state == "open" then
|
|
10894
|
+
clearTransportDiagnostics()
|
|
10895
|
+
connection.lastHttpOk = true
|
|
10896
|
+
connection.lastMcpOk = false
|
|
10897
|
+
connection.consecutiveFailures = 0
|
|
10898
|
+
connection.currentRetryDelay = 0.5
|
|
10899
|
+
connection.mcpWaitStartTime = now
|
|
10900
|
+
return nil
|
|
10901
|
+
end
|
|
10902
|
+
connection.lastHttpOk = false
|
|
10903
|
+
connection.lastMcpOk = false
|
|
10904
|
+
connection.consecutiveFailures = update.attempt
|
|
10905
|
+
connection.mcpWaitStartTime = nil
|
|
10906
|
+
local previousState = connection.transportState
|
|
10907
|
+
connection.transportState = update.state
|
|
10908
|
+
connection.transportAttempt = update.attempt
|
|
10909
|
+
if update.detail ~= nil then
|
|
10910
|
+
connection.transportDetail = sanitizeTransportDetail(update.detail)
|
|
10911
|
+
elseif update.state ~= "connecting" or previousState ~= "connecting" then
|
|
10912
|
+
connection.transportDetail = nil
|
|
10913
|
+
end
|
|
10914
|
+
if update.state == "retrying" or update.state == "waiting-duplicate" then
|
|
10915
|
+
local _condition = connection.transportDetail
|
|
10916
|
+
if _condition == nil then
|
|
10917
|
+
_condition = "Connection attempt failed."
|
|
10918
|
+
end
|
|
10919
|
+
connection.lastTransportFailure = _condition
|
|
10920
|
+
connection.currentRetryDelay = update.retryDelay
|
|
10921
|
+
connection.nextRetryAt = now + update.retryDelay
|
|
10922
|
+
else
|
|
10923
|
+
connection.nextRetryAt = nil
|
|
10924
|
+
end
|
|
10925
|
+
end
|
|
10926
|
+
local function getTransportDiagnostics(now)
|
|
10927
|
+
if not connection.isActive then
|
|
10928
|
+
return {
|
|
10929
|
+
status = "Disconnected",
|
|
10930
|
+
detail = "",
|
|
10931
|
+
}
|
|
10932
|
+
end
|
|
10933
|
+
if connection.lastHttpOk then
|
|
10934
|
+
return if connection.lastMcpOk then {
|
|
10935
|
+
status = "Connected",
|
|
10936
|
+
detail = "Listener: connected MCP client: connected",
|
|
10937
|
+
} else {
|
|
10938
|
+
status = "Waiting for MCP client",
|
|
10939
|
+
detail = "Listener: connected MCP client: not connected",
|
|
10940
|
+
}
|
|
10941
|
+
end
|
|
10942
|
+
local _condition = connection.transportAttempt
|
|
10943
|
+
if _condition == nil then
|
|
10944
|
+
_condition = 0
|
|
10945
|
+
end
|
|
10946
|
+
local attempt = _condition + 1
|
|
10947
|
+
local failure = connection.lastTransportFailure
|
|
10948
|
+
if connection.nextRetryAt ~= nil then
|
|
10949
|
+
local remaining = math.max(0, math.ceil((connection.nextRetryAt - now) * 10) / 10)
|
|
10950
|
+
local _object = {
|
|
10951
|
+
status = if connection.transportState == "waiting-duplicate" then "Waiting for previous instance" else "Listener disconnected",
|
|
10952
|
+
}
|
|
10953
|
+
local _left = "detail"
|
|
10954
|
+
local _condition_1 = failure
|
|
10955
|
+
if _condition_1 == nil then
|
|
10956
|
+
_condition_1 = "Connection attempt failed."
|
|
10957
|
+
end
|
|
10958
|
+
_object[_left] = `Retry attempt {attempt} in {remaining}s\n{_condition_1}`
|
|
10959
|
+
return _object
|
|
10960
|
+
end
|
|
10961
|
+
local _condition_1 = connection.transportDetail
|
|
10962
|
+
if _condition_1 == nil then
|
|
10963
|
+
_condition_1 = "Starting listener connection"
|
|
10964
|
+
end
|
|
10965
|
+
local stage = _condition_1
|
|
10966
|
+
return {
|
|
10967
|
+
status = `Connecting (attempt {attempt})`,
|
|
10968
|
+
detail = if failure ~= nil then `{stage}\nLast failure: {failure}` else stage,
|
|
10969
|
+
}
|
|
10970
|
+
end
|
|
10329
10971
|
return {
|
|
10330
10972
|
CURRENT_VERSION = CURRENT_VERSION,
|
|
10331
10973
|
PLUGIN_VARIANT = PLUGIN_VARIANT,
|
|
10332
10974
|
BASE_PORT = BASE_PORT,
|
|
10333
10975
|
getActiveConnection = getActiveConnection,
|
|
10976
|
+
clearTransportDiagnostics = clearTransportDiagnostics,
|
|
10977
|
+
applyTransportUpdate = applyTransportUpdate,
|
|
10978
|
+
getTransportDiagnostics = getTransportDiagnostics,
|
|
10334
10979
|
}
|
|
10335
10980
|
]]></string>
|
|
10336
10981
|
</Properties>
|
|
@@ -10349,6 +10994,10 @@ local RunService = _services.RunService
|
|
|
10349
10994
|
local PluginSession = TS.import(script, script.Parent, "PluginSession")
|
|
10350
10995
|
local StudioTestService = game:GetService("StudioTestService")
|
|
10351
10996
|
local SETTING_KEY_PREFIX = "MCP_STOP_PLAY_"
|
|
10997
|
+
local RESULT_KEY_SUFFIX = "_RESULT"
|
|
10998
|
+
-- Capture before registration yields. Requests from an earlier runtime must
|
|
10999
|
+
-- never stop a newly created play session, even if clearing settings was lost.
|
|
11000
|
+
local moduleLoadedAt = tick()
|
|
10352
11001
|
-- Keep this conservative. plugin:GetSetting is backed by Studio's plugin
|
|
10353
11002
|
-- settings store, and this monitor runs during every play session, including
|
|
10354
11003
|
-- manually-started Play. The official reference implementation polls at 1s.
|
|
@@ -10361,9 +11010,14 @@ local POLL_INTERVAL_SEC = 1
|
|
|
10361
11010
|
local WAIT_FOR_CONSUMPTION_TIMEOUT_SEC = 8.0
|
|
10362
11011
|
local WAIT_POLL_SEC = 0.1
|
|
10363
11012
|
local REQUEST_TTL_SEC = 12.0
|
|
11013
|
+
-- Plugin settings can silently lose writes when other Studio windows use the
|
|
11014
|
+
-- same plugin. Redeliver the same request, without extending its deadline/TTL.
|
|
11015
|
+
local RETRY_INTERVAL_SEC = 1
|
|
10364
11016
|
local pluginRef
|
|
10365
11017
|
local endTestIssued = false
|
|
10366
11018
|
local transportLifecycle
|
|
11019
|
+
local pendingRequest
|
|
11020
|
+
local lastResult
|
|
10367
11021
|
local function init(p)
|
|
10368
11022
|
pluginRef = p
|
|
10369
11023
|
end
|
|
@@ -10426,14 +11080,21 @@ local function writePayload(key, payload)
|
|
|
10426
11080
|
return writeSetting(key, encoded)
|
|
10427
11081
|
end
|
|
10428
11082
|
local function writeResult(key, request, ok, errText)
|
|
10429
|
-
|
|
11083
|
+
local payload = {
|
|
10430
11084
|
kind = "result",
|
|
10431
11085
|
id = request.id,
|
|
10432
11086
|
requestedAt = request.requestedAt,
|
|
10433
11087
|
consumedAt = tick(),
|
|
10434
11088
|
ok = ok,
|
|
10435
11089
|
error = errText,
|
|
10436
|
-
}
|
|
11090
|
+
}
|
|
11091
|
+
-- Cache before publishing: a lost acknowledgement must not issue EndTest a
|
|
11092
|
+
-- second time or turn a successful duplicate into an "already issued" error.
|
|
11093
|
+
lastResult = {
|
|
11094
|
+
key = key,
|
|
11095
|
+
payload = payload,
|
|
11096
|
+
}
|
|
11097
|
+
writePayload(key .. RESULT_KEY_SUFFIX, payload)
|
|
10437
11098
|
end
|
|
10438
11099
|
local function handleStopRequest(key, request)
|
|
10439
11100
|
local _condition = request.kind ~= "request"
|
|
@@ -10450,10 +11111,14 @@ local function handleStopRequest(key, request)
|
|
|
10450
11111
|
return nil
|
|
10451
11112
|
end
|
|
10452
11113
|
local age = tick() - request.requestedAt
|
|
10453
|
-
if age < -5 or age > REQUEST_TTL_SEC then
|
|
11114
|
+
if request.requestedAt < moduleLoadedAt or age < -5 or age > REQUEST_TTL_SEC then
|
|
10454
11115
|
writeSetting(key, false)
|
|
10455
11116
|
return nil
|
|
10456
11117
|
end
|
|
11118
|
+
if lastResult ~= nil and lastResult.key == key and lastResult.payload.id == request.id then
|
|
11119
|
+
writePayload(key .. RESULT_KEY_SUFFIX, lastResult.payload)
|
|
11120
|
+
return nil
|
|
11121
|
+
end
|
|
10457
11122
|
if endTestIssued then
|
|
10458
11123
|
writeResult(key, request, false, "StudioTestService:EndTest was already issued for this play session, but the runtime DataModel is still alive.")
|
|
10459
11124
|
return nil
|
|
@@ -10514,6 +11179,11 @@ local function requestStop()
|
|
|
10514
11179
|
requestedAt = tick(),
|
|
10515
11180
|
}
|
|
10516
11181
|
local ok = writePayload(settingKey(), payload)
|
|
11182
|
+
pendingRequest = if ok then {
|
|
11183
|
+
key = settingKey(),
|
|
11184
|
+
payload = payload,
|
|
11185
|
+
lastPublishedAt = tick(),
|
|
11186
|
+
} else nil
|
|
10517
11187
|
return {
|
|
10518
11188
|
ok = ok,
|
|
10519
11189
|
requestId = if ok then requestId else nil,
|
|
@@ -10528,8 +11198,12 @@ local function waitForConsumption(requestId)
|
|
|
10528
11198
|
}
|
|
10529
11199
|
end
|
|
10530
11200
|
local start = tick()
|
|
11201
|
+
local pending = pendingRequest
|
|
11202
|
+
local key = if pending ~= nil and pending.payload.id == requestId then pending.key else settingKey()
|
|
10531
11203
|
while tick() - start < WAIT_FOR_CONSUMPTION_TIMEOUT_SEC do
|
|
10532
|
-
|
|
11204
|
+
-- Separate slots prevent a retry from overwriting a durable acknowledgement
|
|
11205
|
+
-- during a transient nil read after the server VM has already exited.
|
|
11206
|
+
local payload = decodePayload(readSetting(key .. RESULT_KEY_SUFFIX))
|
|
10533
11207
|
if payload and payload.kind == "result" and payload.id == requestId then
|
|
10534
11208
|
return {
|
|
10535
11209
|
ok = payload.ok == true,
|
|
@@ -10537,6 +11211,19 @@ local function waitForConsumption(requestId)
|
|
|
10537
11211
|
error = payload.error,
|
|
10538
11212
|
}
|
|
10539
11213
|
end
|
|
11214
|
+
local _condition = pending ~= nil and pendingRequest == pending and pending.payload.id == requestId and tick() - pending.lastPublishedAt >= RETRY_INTERVAL_SEC
|
|
11215
|
+
if _condition then
|
|
11216
|
+
local _exp = tick()
|
|
11217
|
+
local _condition_1 = pending.payload.requestedAt
|
|
11218
|
+
if _condition_1 == nil then
|
|
11219
|
+
_condition_1 = 0
|
|
11220
|
+
end
|
|
11221
|
+
_condition = _exp - _condition_1 < REQUEST_TTL_SEC
|
|
11222
|
+
end
|
|
11223
|
+
if _condition then
|
|
11224
|
+
writePayload(key, pending.payload)
|
|
11225
|
+
pending.lastPublishedAt = tick()
|
|
11226
|
+
end
|
|
10540
11227
|
task.wait(WAIT_POLL_SEC)
|
|
10541
11228
|
end
|
|
10542
11229
|
return {
|
|
@@ -10549,6 +11236,17 @@ local function clearPending(requestId)
|
|
|
10549
11236
|
if not pluginRef then
|
|
10550
11237
|
return nil
|
|
10551
11238
|
end
|
|
11239
|
+
local _condition = requestId == nil
|
|
11240
|
+
if not _condition then
|
|
11241
|
+
local _result = pendingRequest
|
|
11242
|
+
if _result ~= nil then
|
|
11243
|
+
_result = _result.payload.id
|
|
11244
|
+
end
|
|
11245
|
+
_condition = _result == requestId
|
|
11246
|
+
end
|
|
11247
|
+
if _condition then
|
|
11248
|
+
pendingRequest = nil
|
|
11249
|
+
end
|
|
10552
11250
|
local myKey = settingKey()
|
|
10553
11251
|
if requestId ~= nil then
|
|
10554
11252
|
local payload = decodePayload(readSetting(myKey))
|
|
@@ -10580,6 +11278,7 @@ local PROTOCOL_VERSION = 1
|
|
|
10580
11278
|
local INITIAL_RETRY_DELAY_SECONDS = 0.5
|
|
10581
11279
|
local MAX_RETRY_DELAY_SECONDS = 5
|
|
10582
11280
|
local SOCKET_SILENCE_TIMEOUT_SECONDS = 20
|
|
11281
|
+
local CONNECTION_STAGE_TIMEOUT_SECONDS = 20
|
|
10583
11282
|
local RESPONSE_RETENTION_SECONDS = 5 * 60
|
|
10584
11283
|
local RESPONSE_ACK_TIMEOUT_SECONDS = 120
|
|
10585
11284
|
local MAX_TERMINAL_RESPONSES = 32768
|
|
@@ -10600,7 +11299,11 @@ local socketOpen = false
|
|
|
10600
11299
|
local socketConnections = {}
|
|
10601
11300
|
local lastValidEventAt = 0
|
|
10602
11301
|
local cachedReady
|
|
10603
|
-
local
|
|
11302
|
+
local connectionWork
|
|
11303
|
+
local refreshWork
|
|
11304
|
+
local refreshRequested = false
|
|
11305
|
+
local reconnectTimer
|
|
11306
|
+
local silenceTimer
|
|
10604
11307
|
local pendingResponseBytes = 0
|
|
10605
11308
|
local inFlightRequestBytes = 0
|
|
10606
11309
|
local pendingRejectionCount = 0
|
|
@@ -11147,7 +11850,7 @@ local function dispatchRequest(request, requestBytes)
|
|
|
11147
11850
|
local dispatchOk, response = pcall(function()
|
|
11148
11851
|
return dispatchOptions.dispatchRequest(request, context)
|
|
11149
11852
|
end)
|
|
11150
|
-
local executionOutcome = if dispatchOk then handlerOutcome(response) else "error"
|
|
11853
|
+
local executionOutcome = context.executionOutcome or (if dispatchOk then handlerOutcome(response) else "error")
|
|
11151
11854
|
-- Report handler return before JSON encoding or result retention can fail.
|
|
11152
11855
|
completeProgress(request.requestId, inFlight.progress, executionOutcome)
|
|
11153
11856
|
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 +11874,90 @@ local function reportTransport(update)
|
|
|
11171
11874
|
end)
|
|
11172
11875
|
end
|
|
11173
11876
|
end
|
|
11877
|
+
local function ownsWork(work)
|
|
11878
|
+
return active and generation == work.generation and options == work.options and (connectionWork == work or refreshWork == work)
|
|
11879
|
+
end
|
|
11880
|
+
local function cancelThread(worker)
|
|
11881
|
+
-- Cancellation is best-effort for native yielding calls. Identity checks
|
|
11882
|
+
-- remain necessary when an operation returns after its owner was retired.
|
|
11883
|
+
if worker ~= nil and worker ~= coroutine.running() then
|
|
11884
|
+
pcall(function()
|
|
11885
|
+
return task.cancel(worker)
|
|
11886
|
+
end)
|
|
11887
|
+
end
|
|
11888
|
+
end
|
|
11889
|
+
local function finishWork(work)
|
|
11890
|
+
if connectionWork == work then
|
|
11891
|
+
connectionWork = nil
|
|
11892
|
+
end
|
|
11893
|
+
if refreshWork == work then
|
|
11894
|
+
refreshWork = nil
|
|
11895
|
+
end
|
|
11896
|
+
cancelThread(work.deadline)
|
|
11897
|
+
cancelThread(work.worker)
|
|
11898
|
+
work.deadline = nil
|
|
11899
|
+
work.worker = nil
|
|
11900
|
+
end
|
|
11901
|
+
local function cancelTransportWork()
|
|
11902
|
+
if connectionWork ~= nil then
|
|
11903
|
+
finishWork(connectionWork)
|
|
11904
|
+
end
|
|
11905
|
+
if refreshWork ~= nil then
|
|
11906
|
+
refreshRequested = true
|
|
11907
|
+
finishWork(refreshWork)
|
|
11908
|
+
end
|
|
11909
|
+
cancelThread(reconnectTimer)
|
|
11910
|
+
cancelThread(silenceTimer)
|
|
11911
|
+
reconnectTimer = nil
|
|
11912
|
+
silenceTimer = nil
|
|
11913
|
+
end
|
|
11914
|
+
local function runWork(work, callback, failed)
|
|
11915
|
+
local finished = false
|
|
11916
|
+
local worker = task.spawn(function()
|
|
11917
|
+
if not ownsWork(work) then
|
|
11918
|
+
return nil
|
|
11919
|
+
end
|
|
11920
|
+
work.worker = coroutine.running()
|
|
11921
|
+
local ok, workError = pcall(callback)
|
|
11922
|
+
work.worker = nil
|
|
11923
|
+
finished = true
|
|
11924
|
+
if not ok and ownsWork(work) then
|
|
11925
|
+
-- Native upgrade errors can echo credential-bearing request data.
|
|
11926
|
+
local detail = if work.stage == "WebSocket upgrade" then "WebSocket upgrade failed" else `{work.stage} failed: {tostring(workError)}`
|
|
11927
|
+
failed(detail)
|
|
11928
|
+
end
|
|
11929
|
+
end)
|
|
11930
|
+
if not finished and ownsWork(work) then
|
|
11931
|
+
work.worker = worker
|
|
11932
|
+
end
|
|
11933
|
+
end
|
|
11934
|
+
local flushRefresh
|
|
11935
|
+
local function beginStage(work, stage)
|
|
11936
|
+
cancelThread(work.deadline)
|
|
11937
|
+
work.stage = stage
|
|
11938
|
+
work.deadline = task.delay(CONNECTION_STAGE_TIMEOUT_SECONDS, function()
|
|
11939
|
+
if not ownsWork(work) then
|
|
11940
|
+
return nil
|
|
11941
|
+
end
|
|
11942
|
+
work.deadline = nil
|
|
11943
|
+
local detail = `{stage} timed out after {CONNECTION_STAGE_TIMEOUT_SECONDS} seconds`
|
|
11944
|
+
if connectionWork == work then
|
|
11945
|
+
scheduleReconnect(work.generation, detail)
|
|
11946
|
+
else
|
|
11947
|
+
finishWork(work)
|
|
11948
|
+
warn(`[robloxstudio-mcp] {detail}`)
|
|
11949
|
+
flushRefresh()
|
|
11950
|
+
end
|
|
11951
|
+
end)
|
|
11952
|
+
if connectionWork == work then
|
|
11953
|
+
reportTransport({
|
|
11954
|
+
state = "connecting",
|
|
11955
|
+
attempt = reconnectAttempt,
|
|
11956
|
+
retryDelay = 0,
|
|
11957
|
+
detail = `{stage} (timeout {CONNECTION_STAGE_TIMEOUT_SECONDS} seconds)`,
|
|
11958
|
+
})
|
|
11959
|
+
end
|
|
11960
|
+
end
|
|
11174
11961
|
local connect
|
|
11175
11962
|
function scheduleReconnect(expectedGeneration, detail, duplicate)
|
|
11176
11963
|
if duplicate == nil then
|
|
@@ -11179,7 +11966,14 @@ function scheduleReconnect(expectedGeneration, detail, duplicate)
|
|
|
11179
11966
|
if not active or generation ~= expectedGeneration then
|
|
11180
11967
|
return nil
|
|
11181
11968
|
end
|
|
11969
|
+
-- A healthy registration gets one socket-only revalidation, preserving
|
|
11970
|
+
-- recovery under HTTP quota. Any failure before Opened exhausts that path.
|
|
11971
|
+
if not socketOpen then
|
|
11972
|
+
cachedReady = nil
|
|
11973
|
+
end
|
|
11182
11974
|
generation += 1
|
|
11975
|
+
local nextGeneration = generation
|
|
11976
|
+
cancelTransportWork()
|
|
11183
11977
|
closeCurrentSocket()
|
|
11184
11978
|
reconnectAttempt += 1
|
|
11185
11979
|
local delay = if duplicate then 1 else retryDelay(reconnectAttempt)
|
|
@@ -11189,8 +11983,11 @@ function scheduleReconnect(expectedGeneration, detail, duplicate)
|
|
|
11189
11983
|
retryDelay = delay,
|
|
11190
11984
|
detail = detail,
|
|
11191
11985
|
})
|
|
11192
|
-
|
|
11193
|
-
|
|
11986
|
+
if not active or generation ~= nextGeneration then
|
|
11987
|
+
return nil
|
|
11988
|
+
end
|
|
11989
|
+
reconnectTimer = task.delay(delay, function()
|
|
11990
|
+
reconnectTimer = nil
|
|
11194
11991
|
if active and generation == nextGeneration then
|
|
11195
11992
|
connect(nextGeneration)
|
|
11196
11993
|
end
|
|
@@ -11198,10 +11995,11 @@ function scheduleReconnect(expectedGeneration, detail, duplicate)
|
|
|
11198
11995
|
end
|
|
11199
11996
|
local function watchForSilence(expectedGeneration, expectedClient)
|
|
11200
11997
|
local elapsed = tick() - lastValidEventAt
|
|
11201
|
-
task.delay(math.max(SOCKET_SILENCE_TIMEOUT_SECONDS - elapsed, 0.1), function()
|
|
11998
|
+
silenceTimer = task.delay(math.max(SOCKET_SILENCE_TIMEOUT_SECONDS - elapsed, 0.1), function()
|
|
11202
11999
|
if not active or generation ~= expectedGeneration or socketClient ~= expectedClient then
|
|
11203
12000
|
return nil
|
|
11204
12001
|
end
|
|
12002
|
+
silenceTimer = nil
|
|
11205
12003
|
local silentFor = tick() - lastValidEventAt
|
|
11206
12004
|
if silentFor >= SOCKET_SILENCE_TIMEOUT_SECONDS then
|
|
11207
12005
|
scheduleReconnect(expectedGeneration, `WebSocket silent for {math.floor(silentFor)} seconds`)
|
|
@@ -11268,13 +12066,18 @@ local function parseReadyResponse(body)
|
|
|
11268
12066
|
transportToken = value.transportToken,
|
|
11269
12067
|
}
|
|
11270
12068
|
end
|
|
11271
|
-
local function registerReady(
|
|
12069
|
+
local function registerReady(work, reconnectOnFailure)
|
|
12070
|
+
if not ownsWork(work) then
|
|
12071
|
+
return nil
|
|
12072
|
+
end
|
|
12073
|
+
local currentOptions = work.options
|
|
12074
|
+
local expectedGeneration = work.generation
|
|
11272
12075
|
local instanceId = PluginSession.getInstanceId()
|
|
11273
12076
|
local multiplayerGroupId = PluginSession.getMultiplayerGroupId()
|
|
11274
12077
|
local transportRole = PluginSession.getRole()
|
|
11275
12078
|
local readyUrl = `{currentOptions.serverUrl}/ready`
|
|
11276
12079
|
local readyPayload = PluginSession.createReadyPayload(PluginSession.peerId, transportRole, instanceId, multiplayerGroupId)
|
|
11277
|
-
if not
|
|
12080
|
+
if not ownsWork(work) then
|
|
11278
12081
|
return nil
|
|
11279
12082
|
end
|
|
11280
12083
|
local readyOk, readyResult = pcall(function()
|
|
@@ -11287,7 +12090,7 @@ local function registerReady(currentOptions, expectedGeneration, reconnectOnFail
|
|
|
11287
12090
|
Body = HttpService:JSONEncode(readyPayload),
|
|
11288
12091
|
})
|
|
11289
12092
|
end)
|
|
11290
|
-
if not
|
|
12093
|
+
if not ownsWork(work) then
|
|
11291
12094
|
return nil
|
|
11292
12095
|
end
|
|
11293
12096
|
local readyLogKey = `{currentOptions.serverUrl}|{PluginSession.peerId}`
|
|
@@ -11317,7 +12120,7 @@ local function registerReady(currentOptions, expectedGeneration, reconnectOnFail
|
|
|
11317
12120
|
invokeCallback("WebSocket ready", function()
|
|
11318
12121
|
return currentOptions.onReady(readyData)
|
|
11319
12122
|
end)
|
|
11320
|
-
return readyData
|
|
12123
|
+
return if ownsWork(work) then readyData else nil
|
|
11321
12124
|
end
|
|
11322
12125
|
local function credentialsRejected(statusCode)
|
|
11323
12126
|
return statusCode == 401 or statusCode == 403 or statusCode == 404
|
|
@@ -11327,14 +12130,28 @@ function connect(expectedGeneration)
|
|
|
11327
12130
|
if not active or generation ~= expectedGeneration or currentOptions == nil then
|
|
11328
12131
|
return nil
|
|
11329
12132
|
end
|
|
11330
|
-
|
|
11331
|
-
|
|
11332
|
-
|
|
11333
|
-
|
|
11334
|
-
|
|
11335
|
-
|
|
11336
|
-
|
|
11337
|
-
|
|
12133
|
+
if refreshWork ~= nil then
|
|
12134
|
+
finishWork(refreshWork)
|
|
12135
|
+
end
|
|
12136
|
+
local work = {
|
|
12137
|
+
generation = expectedGeneration,
|
|
12138
|
+
options = currentOptions,
|
|
12139
|
+
stage = "",
|
|
12140
|
+
}
|
|
12141
|
+
connectionWork = work
|
|
12142
|
+
beginStage(work, if cachedReady == nil then "/ready registration" else "WebSocket upgrade")
|
|
12143
|
+
if not ownsWork(work) then
|
|
12144
|
+
return nil
|
|
12145
|
+
end
|
|
12146
|
+
runWork(work, function()
|
|
12147
|
+
local readyData = cachedReady or registerReady(work, true)
|
|
12148
|
+
if readyData == nil or not ownsWork(work) then
|
|
12149
|
+
return nil
|
|
12150
|
+
end
|
|
12151
|
+
if work.stage ~= "WebSocket upgrade" then
|
|
12152
|
+
beginStage(work, "WebSocket upgrade")
|
|
12153
|
+
end
|
|
12154
|
+
if not ownsWork(work) then
|
|
11338
12155
|
return nil
|
|
11339
12156
|
end
|
|
11340
12157
|
local socketBaseUrl = string.gsub(currentOptions.serverUrl, "^http", "ws")
|
|
@@ -11347,28 +12164,32 @@ function connect(expectedGeneration)
|
|
|
11347
12164
|
})
|
|
11348
12165
|
end)
|
|
11349
12166
|
if not createOk then
|
|
11350
|
-
scheduleReconnect(expectedGeneration,
|
|
12167
|
+
scheduleReconnect(expectedGeneration, "Failed to create WebSocket")
|
|
11351
12168
|
return nil
|
|
11352
12169
|
end
|
|
11353
|
-
if not
|
|
12170
|
+
if not ownsWork(work) then
|
|
11354
12171
|
pcall(function()
|
|
11355
12172
|
return createdClient:Close()
|
|
11356
12173
|
end)
|
|
11357
12174
|
return nil
|
|
11358
12175
|
end
|
|
11359
12176
|
socketClient = createdClient
|
|
11360
|
-
|
|
12177
|
+
-- Attach incrementally so a failed subscription cannot leak earlier ones.
|
|
12178
|
+
local _socketConnections = socketConnections
|
|
12179
|
+
local _arg0 = createdClient.Opened:Connect(function(statusCode, _headers)
|
|
11361
12180
|
if not active or generation ~= expectedGeneration or socketClient ~= createdClient then
|
|
11362
12181
|
return nil
|
|
11363
12182
|
end
|
|
12183
|
+
if socketOpen then
|
|
12184
|
+
return nil
|
|
12185
|
+
end
|
|
11364
12186
|
if statusCode ~= 101 and (statusCode < 200 or statusCode >= 300) then
|
|
11365
|
-
|
|
11366
|
-
cachedReady = nil
|
|
11367
|
-
end
|
|
12187
|
+
cachedReady = nil
|
|
11368
12188
|
scheduleReconnect(expectedGeneration, `WebSocket opened with HTTP {statusCode}`)
|
|
11369
12189
|
return nil
|
|
11370
12190
|
end
|
|
11371
12191
|
socketOpen = true
|
|
12192
|
+
finishWork(work)
|
|
11372
12193
|
lastValidEventAt = tick()
|
|
11373
12194
|
reconnectAttempt = 0
|
|
11374
12195
|
reportTransport({
|
|
@@ -11376,8 +12197,18 @@ function connect(expectedGeneration)
|
|
|
11376
12197
|
attempt = 0,
|
|
11377
12198
|
retryDelay = 0,
|
|
11378
12199
|
})
|
|
12200
|
+
if not active or generation ~= expectedGeneration or socketClient ~= createdClient then
|
|
12201
|
+
return nil
|
|
12202
|
+
end
|
|
12203
|
+
watchForSilence(expectedGeneration, createdClient)
|
|
11379
12204
|
resumePendingResponses()
|
|
11380
|
-
|
|
12205
|
+
if active and generation == expectedGeneration and socketClient == createdClient then
|
|
12206
|
+
flushRefresh()
|
|
12207
|
+
end
|
|
12208
|
+
end)
|
|
12209
|
+
table.insert(_socketConnections, _arg0)
|
|
12210
|
+
local _socketConnections_1 = socketConnections
|
|
12211
|
+
local _arg0_1 = createdClient.MessageReceived:Connect(function(message)
|
|
11381
12212
|
if not active or generation ~= expectedGeneration or socketClient ~= createdClient then
|
|
11382
12213
|
return nil
|
|
11383
12214
|
end
|
|
@@ -11415,27 +12246,41 @@ function connect(expectedGeneration)
|
|
|
11415
12246
|
invokeCallback("WebSocket status", function()
|
|
11416
12247
|
return currentOptions.onStatus(event)
|
|
11417
12248
|
end)
|
|
12249
|
+
if not active or generation ~= expectedGeneration or socketClient ~= createdClient then
|
|
12250
|
+
return nil
|
|
12251
|
+
end
|
|
11418
12252
|
if not event.knownPeer then
|
|
11419
12253
|
cachedReady = nil
|
|
11420
12254
|
scheduleReconnect(expectedGeneration, "WebSocket session is no longer registered")
|
|
11421
12255
|
end
|
|
11422
12256
|
end
|
|
11423
|
-
end)
|
|
12257
|
+
end)
|
|
12258
|
+
table.insert(_socketConnections_1, _arg0_1)
|
|
12259
|
+
local _socketConnections_2 = socketConnections
|
|
12260
|
+
local _arg0_2 = createdClient.Error:Connect(function(statusCode, _message)
|
|
11424
12261
|
if not active or generation ~= expectedGeneration or socketClient ~= createdClient then
|
|
11425
12262
|
return nil
|
|
11426
12263
|
end
|
|
11427
|
-
if credentialsRejected(statusCode) then
|
|
12264
|
+
if not socketOpen or credentialsRejected(statusCode) then
|
|
11428
12265
|
cachedReady = nil
|
|
11429
12266
|
end
|
|
11430
|
-
|
|
11431
|
-
|
|
12267
|
+
-- Native error messages may echo request credentials.
|
|
12268
|
+
scheduleReconnect(expectedGeneration, `WebSocket error {statusCode}`)
|
|
12269
|
+
end)
|
|
12270
|
+
table.insert(_socketConnections_2, _arg0_2)
|
|
12271
|
+
local _socketConnections_3 = socketConnections
|
|
12272
|
+
local _arg0_3 = createdClient.Closed:Connect(function()
|
|
11432
12273
|
if not active or generation ~= expectedGeneration or socketClient ~= createdClient then
|
|
11433
12274
|
return nil
|
|
11434
12275
|
end
|
|
12276
|
+
if not socketOpen then
|
|
12277
|
+
cachedReady = nil
|
|
12278
|
+
end
|
|
11435
12279
|
scheduleReconnect(expectedGeneration, "WebSocket closed")
|
|
11436
|
-
end)
|
|
11437
|
-
|
|
11438
|
-
|
|
12280
|
+
end)
|
|
12281
|
+
table.insert(_socketConnections_3, _arg0_3)
|
|
12282
|
+
end, function(detail)
|
|
12283
|
+
return scheduleReconnect(expectedGeneration, detail)
|
|
11439
12284
|
end)
|
|
11440
12285
|
end
|
|
11441
12286
|
local stop
|
|
@@ -11450,20 +12295,42 @@ local function start(newOptions)
|
|
|
11450
12295
|
generation += 1
|
|
11451
12296
|
connect(generation)
|
|
11452
12297
|
end
|
|
11453
|
-
|
|
12298
|
+
function flushRefresh()
|
|
11454
12299
|
local currentOptions = options
|
|
11455
|
-
if not active or currentOptions == nil or
|
|
12300
|
+
if not refreshRequested or not active or currentOptions == nil or refreshWork ~= nil or connectionWork ~= nil or not socketOpen then
|
|
11456
12301
|
return nil
|
|
11457
12302
|
end
|
|
11458
|
-
|
|
11459
|
-
--
|
|
11460
|
-
|
|
11461
|
-
local
|
|
11462
|
-
|
|
11463
|
-
|
|
11464
|
-
|
|
12303
|
+
refreshRequested = false
|
|
12304
|
+
-- Only explicit metadata changes are queued. Failure alone never schedules
|
|
12305
|
+
-- more HTTP requests or replaces a healthy transport.
|
|
12306
|
+
local work = {
|
|
12307
|
+
generation = generation,
|
|
12308
|
+
options = currentOptions,
|
|
12309
|
+
stage = "",
|
|
12310
|
+
}
|
|
12311
|
+
refreshWork = work
|
|
12312
|
+
beginStage(work, "/ready metadata refresh")
|
|
12313
|
+
runWork(work, function()
|
|
12314
|
+
-- Consume at worker entry so changes coalesce even before task.spawn runs.
|
|
12315
|
+
refreshRequested = false
|
|
12316
|
+
registerReady(work, false)
|
|
12317
|
+
if ownsWork(work) then
|
|
12318
|
+
finishWork(work)
|
|
12319
|
+
flushRefresh()
|
|
12320
|
+
end
|
|
12321
|
+
end, function(detail)
|
|
12322
|
+
finishWork(work)
|
|
12323
|
+
warn(`[robloxstudio-mcp] {detail}`)
|
|
12324
|
+
flushRefresh()
|
|
11465
12325
|
end)
|
|
11466
12326
|
end
|
|
12327
|
+
local function refresh()
|
|
12328
|
+
if not active or options == nil then
|
|
12329
|
+
return nil
|
|
12330
|
+
end
|
|
12331
|
+
refreshRequested = true
|
|
12332
|
+
flushRefresh()
|
|
12333
|
+
end
|
|
11467
12334
|
-- EndTest may tear down this VM without an Unloading callback. Release the
|
|
11468
12335
|
-- native socket before yielding to unregister; a failed EndTest can resume.
|
|
11469
12336
|
local function suspendForShutdown()
|
|
@@ -11474,6 +12341,8 @@ local function suspendForShutdown()
|
|
|
11474
12341
|
active = false
|
|
11475
12342
|
shutdownSuspended = true
|
|
11476
12343
|
generation += 1
|
|
12344
|
+
cancelTransportWork()
|
|
12345
|
+
refreshRequested = false
|
|
11477
12346
|
reconnectAttempt = 0
|
|
11478
12347
|
closeCurrentSocket()
|
|
11479
12348
|
cachedReady = nil
|
|
@@ -11497,6 +12366,8 @@ function stop()
|
|
|
11497
12366
|
active = false
|
|
11498
12367
|
shutdownSuspended = false
|
|
11499
12368
|
generation += 1
|
|
12369
|
+
cancelTransportWork()
|
|
12370
|
+
refreshRequested = false
|
|
11500
12371
|
for _, inFlight in inFlightRequests do
|
|
11501
12372
|
inFlight.cancelled = true
|
|
11502
12373
|
end
|
|
@@ -11827,12 +12698,13 @@ local function init(pluginRef)
|
|
|
11827
12698
|
end
|
|
11828
12699
|
end)
|
|
11829
12700
|
local statusRow = Instance.new("Frame")
|
|
11830
|
-
statusRow.Size = UDim2.new(1, 0, 0,
|
|
12701
|
+
statusRow.Size = UDim2.new(1, 0, 0, 0)
|
|
12702
|
+
statusRow.AutomaticSize = Enum.AutomaticSize.Y
|
|
11831
12703
|
statusRow.BackgroundTransparency = 1
|
|
11832
12704
|
statusRow.LayoutOrder = 2
|
|
11833
12705
|
statusRow.Parent = card
|
|
11834
12706
|
local statusLabel = Instance.new("TextLabel")
|
|
11835
|
-
statusLabel.Size = UDim2.new(1, 0,
|
|
12707
|
+
statusLabel.Size = UDim2.new(1, 0, 0, 18)
|
|
11836
12708
|
statusLabel.BackgroundTransparency = 1
|
|
11837
12709
|
statusLabel.Text = "Disconnected"
|
|
11838
12710
|
statusLabel.TextColor3 = C.red
|
|
@@ -11840,17 +12712,20 @@ local function init(pluginRef)
|
|
|
11840
12712
|
statusLabel.Font = Enum.Font.GothamBold
|
|
11841
12713
|
statusLabel.TextXAlignment = Enum.TextXAlignment.Left
|
|
11842
12714
|
statusLabel.TextWrapped = true
|
|
12715
|
+
statusLabel.RichText = false
|
|
11843
12716
|
statusLabel.Parent = statusRow
|
|
11844
12717
|
local detailStatusLabel = Instance.new("TextLabel")
|
|
11845
|
-
detailStatusLabel.Size = UDim2.new(
|
|
11846
|
-
detailStatusLabel.Position = UDim2.new(0
|
|
12718
|
+
detailStatusLabel.Size = UDim2.new(1, 0, 0, 0)
|
|
12719
|
+
detailStatusLabel.Position = UDim2.new(0, 0, 0, 18)
|
|
12720
|
+
detailStatusLabel.AutomaticSize = Enum.AutomaticSize.Y
|
|
11847
12721
|
detailStatusLabel.BackgroundTransparency = 1
|
|
11848
12722
|
detailStatusLabel.Text = "HTTP: X MCP: X"
|
|
11849
12723
|
detailStatusLabel.TextColor3 = C.muted
|
|
11850
12724
|
detailStatusLabel.TextSize = 9
|
|
11851
12725
|
detailStatusLabel.Font = Enum.Font.GothamMedium
|
|
11852
|
-
detailStatusLabel.TextXAlignment = Enum.TextXAlignment.
|
|
12726
|
+
detailStatusLabel.TextXAlignment = Enum.TextXAlignment.Left
|
|
11853
12727
|
detailStatusLabel.TextWrapped = true
|
|
12728
|
+
detailStatusLabel.RichText = false
|
|
11854
12729
|
detailStatusLabel.Parent = statusRow
|
|
11855
12730
|
local stepsFrame = Instance.new("Frame")
|
|
11856
12731
|
stepsFrame.Size = UDim2.new(1, 0, 0, 0)
|
|
@@ -11987,13 +12862,14 @@ local function updateUIState()
|
|
|
11987
12862
|
return nil
|
|
11988
12863
|
end
|
|
11989
12864
|
local el = elements
|
|
12865
|
+
local diagnostics = State.getTransportDiagnostics(tick())
|
|
11990
12866
|
if not conn.isActive then
|
|
11991
|
-
el.statusLabel.Text =
|
|
12867
|
+
el.statusLabel.Text = diagnostics.status
|
|
11992
12868
|
el.statusLabel.TextColor3 = C.muted
|
|
11993
12869
|
el.statusIndicator.BackgroundColor3 = C.red
|
|
11994
12870
|
el.statusPulse.BackgroundColor3 = C.red
|
|
11995
12871
|
el.statusText.Text = "OFFLINE"
|
|
11996
|
-
el.detailStatusLabel.Text =
|
|
12872
|
+
el.detailStatusLabel.Text = diagnostics.detail
|
|
11997
12873
|
el.detailStatusLabel.TextColor3 = C.muted
|
|
11998
12874
|
stopPulseAnimation()
|
|
11999
12875
|
el.step1Dot.BackgroundColor3 = C.gray
|
|
@@ -12016,12 +12892,12 @@ local function updateUIState()
|
|
|
12016
12892
|
el.urlInput.TextEditable = false
|
|
12017
12893
|
el.urlInput.BackgroundColor3 = C.card
|
|
12018
12894
|
if conn.lastHttpOk and conn.lastMcpOk then
|
|
12019
|
-
el.statusLabel.Text =
|
|
12895
|
+
el.statusLabel.Text = diagnostics.status
|
|
12020
12896
|
el.statusLabel.TextColor3 = Color3.fromRGB(34, 197, 94)
|
|
12021
12897
|
el.statusIndicator.BackgroundColor3 = Color3.fromRGB(34, 197, 94)
|
|
12022
12898
|
el.statusPulse.BackgroundColor3 = Color3.fromRGB(34, 197, 94)
|
|
12023
12899
|
el.statusText.Text = "ONLINE"
|
|
12024
|
-
el.detailStatusLabel.Text =
|
|
12900
|
+
el.detailStatusLabel.Text = diagnostics.detail
|
|
12025
12901
|
el.detailStatusLabel.TextColor3 = Color3.fromRGB(34, 197, 94)
|
|
12026
12902
|
el.step1Dot.BackgroundColor3 = Color3.fromRGB(34, 197, 94)
|
|
12027
12903
|
el.step1Label.Text = "HTTP server (OK)"
|
|
@@ -12032,12 +12908,12 @@ local function updateUIState()
|
|
|
12032
12908
|
el.troubleshootLabel.Visible = false
|
|
12033
12909
|
stopPulseAnimation()
|
|
12034
12910
|
elseif conn.lastHttpOk and not conn.lastMcpOk then
|
|
12035
|
-
el.statusLabel.Text =
|
|
12911
|
+
el.statusLabel.Text = diagnostics.status
|
|
12036
12912
|
el.statusLabel.TextColor3 = Color3.fromRGB(245, 158, 11)
|
|
12037
12913
|
el.statusIndicator.BackgroundColor3 = Color3.fromRGB(245, 158, 11)
|
|
12038
12914
|
el.statusPulse.BackgroundColor3 = Color3.fromRGB(245, 158, 11)
|
|
12039
12915
|
el.statusText.Text = "WAITING"
|
|
12040
|
-
el.detailStatusLabel.Text =
|
|
12916
|
+
el.detailStatusLabel.Text = diagnostics.detail
|
|
12041
12917
|
el.detailStatusLabel.TextColor3 = Color3.fromRGB(245, 158, 11)
|
|
12042
12918
|
el.step1Dot.BackgroundColor3 = Color3.fromRGB(34, 197, 94)
|
|
12043
12919
|
el.step1Label.Text = "HTTP server (OK)"
|
|
@@ -12048,53 +12924,21 @@ local function updateUIState()
|
|
|
12048
12924
|
local elapsed = if conn.mcpWaitStartTime ~= nil then tick() - conn.mcpWaitStartTime else 0
|
|
12049
12925
|
el.troubleshootLabel.Visible = elapsed > 8
|
|
12050
12926
|
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
12927
|
else
|
|
12085
|
-
|
|
12928
|
+
local retrying = conn.nextRetryAt ~= nil
|
|
12929
|
+
el.statusLabel.Text = diagnostics.status
|
|
12086
12930
|
el.statusLabel.TextColor3 = C.yellow
|
|
12087
12931
|
el.statusIndicator.BackgroundColor3 = C.yellow
|
|
12088
12932
|
el.statusPulse.BackgroundColor3 = C.yellow
|
|
12089
|
-
el.statusText.Text = "CONNECTING"
|
|
12090
|
-
el.detailStatusLabel.Text =
|
|
12091
|
-
el.detailStatusLabel.TextColor3 = C.
|
|
12933
|
+
el.statusText.Text = if retrying then "RETRY" else "CONNECTING"
|
|
12934
|
+
el.detailStatusLabel.Text = diagnostics.detail
|
|
12935
|
+
el.detailStatusLabel.TextColor3 = C.yellow
|
|
12092
12936
|
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 (
|
|
12937
|
+
el.step1Label.Text = if retrying then "Listener (disconnected)" else "Listener (connecting...)"
|
|
12938
|
+
el.step2Dot.BackgroundColor3 = C.gray
|
|
12939
|
+
el.step2Label.Text = "MCP client (waiting for listener)"
|
|
12940
|
+
el.step3Dot.BackgroundColor3 = C.gray
|
|
12941
|
+
el.step3Label.Text = "Commands (waiting for listener)"
|
|
12098
12942
|
el.troubleshootLabel.Visible = false
|
|
12099
12943
|
startPulseAnimation()
|
|
12100
12944
|
end
|