@chrrxs/robloxstudio-mcp 3.1.3 → 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 +1156 -303
- package/package.json +1 -1
- package/studio-plugin/MCPPlugin.rbxmx +1497 -175
|
@@ -278,6 +278,326 @@ return {
|
|
|
278
278
|
</Properties>
|
|
279
279
|
</Item>
|
|
280
280
|
<Item class="ModuleScript" referent="3">
|
|
281
|
+
<Properties>
|
|
282
|
+
<string name="Name">CaptureTransfer</string>
|
|
283
|
+
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
284
|
+
-- Private ClientBroker transport. Only metadata and <=1 MiB data chunks cross
|
|
285
|
+
-- InvokeClient; callers still receive the original capture-studio response.
|
|
286
|
+
local HttpService = game:GetService("HttpService")
|
|
287
|
+
local READ_ENDPOINT = "/__mcp/capture-studio/read"
|
|
288
|
+
local RELEASE_ENDPOINT = "/__mcp/capture-studio/release"
|
|
289
|
+
local CHUNK_BYTES = 1024 * 1024
|
|
290
|
+
local MAX_TRANSFER_BYTES = 64 * 1024 * 1024
|
|
291
|
+
local MAX_TRANSFERS = 2
|
|
292
|
+
local TRANSFER_TTL_SECONDS = 60
|
|
293
|
+
local transfers = {}
|
|
294
|
+
local function integer(value, min, max)
|
|
295
|
+
local _value = value
|
|
296
|
+
local _condition = type(_value) == "number"
|
|
297
|
+
if _condition then
|
|
298
|
+
_condition = value >= min and value <= max and value == math.floor(value)
|
|
299
|
+
end
|
|
300
|
+
return _condition
|
|
301
|
+
end
|
|
302
|
+
local function validId(value)
|
|
303
|
+
local _value = value
|
|
304
|
+
local _condition = type(_value) == "string"
|
|
305
|
+
if _condition then
|
|
306
|
+
_condition = #value > 0 and #value <= 64
|
|
307
|
+
end
|
|
308
|
+
return _condition
|
|
309
|
+
end
|
|
310
|
+
local function metadata(value)
|
|
311
|
+
local _value = value
|
|
312
|
+
if not (type(_value) == "table") then
|
|
313
|
+
return nil
|
|
314
|
+
end
|
|
315
|
+
local fields = value
|
|
316
|
+
if fields.success ~= true or (fields.encoding ~= "png" and fields.encoding ~= "rgba8") or fields.source ~= "StudioCaptureService" or not integer(fields.width, 1, 65536) or not integer(fields.height, 1, 65536) or not integer(fields.nativeWidth, 1, 65536) or not integer(fields.nativeHeight, 1, 65536) then
|
|
317
|
+
return nil
|
|
318
|
+
end
|
|
319
|
+
return {
|
|
320
|
+
success = true,
|
|
321
|
+
encoding = fields.encoding,
|
|
322
|
+
source = fields.source,
|
|
323
|
+
width = fields.width,
|
|
324
|
+
height = fields.height,
|
|
325
|
+
nativeWidth = fields.nativeWidth,
|
|
326
|
+
nativeHeight = fields.nativeHeight,
|
|
327
|
+
}
|
|
328
|
+
end
|
|
329
|
+
local function fail(message)
|
|
330
|
+
return {
|
|
331
|
+
success = false,
|
|
332
|
+
error = `Capture transfer: {message}`,
|
|
333
|
+
}
|
|
334
|
+
end
|
|
335
|
+
local function discard(id)
|
|
336
|
+
local _id = id
|
|
337
|
+
local transfer = transfers[_id]
|
|
338
|
+
if transfer == nil then
|
|
339
|
+
return nil
|
|
340
|
+
end
|
|
341
|
+
local _id_1 = id
|
|
342
|
+
transfers[_id_1] = nil
|
|
343
|
+
if transfer.expiryThread ~= nil then
|
|
344
|
+
task.cancel(transfer.expiryThread)
|
|
345
|
+
end
|
|
346
|
+
end
|
|
347
|
+
local function prune()
|
|
348
|
+
local now = os.clock()
|
|
349
|
+
for id, transfer in transfers do
|
|
350
|
+
if now >= transfer.expiresAt then
|
|
351
|
+
discard(id)
|
|
352
|
+
end
|
|
353
|
+
end
|
|
354
|
+
end
|
|
355
|
+
local function release(data)
|
|
356
|
+
if not validId(data.transferId) then
|
|
357
|
+
return fail("invalid transfer ID")
|
|
358
|
+
end
|
|
359
|
+
discard(data.transferId)
|
|
360
|
+
-- Idempotent release also negotiates chunk support before capture starts.
|
|
361
|
+
return {
|
|
362
|
+
success = true,
|
|
363
|
+
chunkBytes = CHUNK_BYTES,
|
|
364
|
+
}
|
|
365
|
+
end
|
|
366
|
+
local validSize
|
|
367
|
+
local function begin(data, capture)
|
|
368
|
+
prune()
|
|
369
|
+
local id = data.transferId
|
|
370
|
+
-- An older server cannot consume chunk descriptors. Let it use legacy
|
|
371
|
+
-- capture rather than returning an unsafe inline image or a hard error.
|
|
372
|
+
if id == nil then
|
|
373
|
+
return {
|
|
374
|
+
unavailable = "Client capture requires bounded transfer support",
|
|
375
|
+
}
|
|
376
|
+
end
|
|
377
|
+
if not validId(id) then
|
|
378
|
+
return fail("invalid transfer ID")
|
|
379
|
+
end
|
|
380
|
+
if transfers[id] ~= nil then
|
|
381
|
+
return fail("duplicate transfer ID")
|
|
382
|
+
end
|
|
383
|
+
-- Reserve before capture yields: concurrent captures cannot over-admit or
|
|
384
|
+
-- evict each other's data. Retained strings are capped at 2 * 64 MiB.
|
|
385
|
+
-- ▼ ReadonlyMap.size ▼
|
|
386
|
+
local _size = 0
|
|
387
|
+
for _ in transfers do
|
|
388
|
+
_size += 1
|
|
389
|
+
end
|
|
390
|
+
-- ▲ ReadonlyMap.size ▲
|
|
391
|
+
if _size >= MAX_TRANSFERS then
|
|
392
|
+
return fail("two captures are already in flight; release or await them first")
|
|
393
|
+
end
|
|
394
|
+
local transfer = {
|
|
395
|
+
offset = 0,
|
|
396
|
+
expiresAt = os.clock() + TRANSFER_TTL_SECONDS,
|
|
397
|
+
}
|
|
398
|
+
transfers[id] = transfer
|
|
399
|
+
-- Capture only the ID, not the large payload, in the expiry closure.
|
|
400
|
+
transfer.expiryThread = task.delay(TRANSFER_TTL_SECONDS, function()
|
|
401
|
+
local current = transfers[id]
|
|
402
|
+
if current ~= nil and os.clock() >= current.expiresAt then
|
|
403
|
+
current.expiryThread = nil
|
|
404
|
+
discard(id)
|
|
405
|
+
end
|
|
406
|
+
end)
|
|
407
|
+
local ok, result = pcall(capture)
|
|
408
|
+
if not ok then
|
|
409
|
+
if transfers[id] == transfer then
|
|
410
|
+
discard(id)
|
|
411
|
+
end
|
|
412
|
+
return fail(`capture failed: {tostring(result)}`)
|
|
413
|
+
end
|
|
414
|
+
if transfers[id] ~= transfer or os.clock() >= transfer.expiresAt then
|
|
415
|
+
if transfers[id] == transfer then
|
|
416
|
+
discard(id)
|
|
417
|
+
end
|
|
418
|
+
return fail("capture expired before it completed")
|
|
419
|
+
end
|
|
420
|
+
local info = metadata(result)
|
|
421
|
+
if info == nil then
|
|
422
|
+
discard(id)
|
|
423
|
+
-- Unavailability must reach core unchanged to preserve legacy fallback.
|
|
424
|
+
if type(result) == "table" then
|
|
425
|
+
local fields = result
|
|
426
|
+
local _unavailable = fields.unavailable
|
|
427
|
+
if type(_unavailable) == "string" then
|
|
428
|
+
return {
|
|
429
|
+
unavailable = string.sub(fields.unavailable, 1, 4096),
|
|
430
|
+
}
|
|
431
|
+
end
|
|
432
|
+
local _error = fields.error
|
|
433
|
+
if type(_error) == "string" then
|
|
434
|
+
return fail(string.sub(fields.error, 1, 4096))
|
|
435
|
+
end
|
|
436
|
+
end
|
|
437
|
+
return fail("invalid capture response")
|
|
438
|
+
end
|
|
439
|
+
local fields = result
|
|
440
|
+
local pixels = fields.data
|
|
441
|
+
if not (type(pixels) == "string") or not validSize(#pixels, info) then
|
|
442
|
+
discard(id)
|
|
443
|
+
return fail("invalid or oversized capture data")
|
|
444
|
+
end
|
|
445
|
+
transfer.data = pixels
|
|
446
|
+
return {
|
|
447
|
+
transferId = id,
|
|
448
|
+
totalBytes = #pixels,
|
|
449
|
+
chunkBytes = CHUNK_BYTES,
|
|
450
|
+
metadata = info,
|
|
451
|
+
}
|
|
452
|
+
end
|
|
453
|
+
function validSize(size, info)
|
|
454
|
+
if not integer(size, 4, MAX_TRANSFER_BYTES) or size % 4 ~= 0 then
|
|
455
|
+
return false
|
|
456
|
+
end
|
|
457
|
+
return info.encoding ~= "rgba8" or size == math.ceil(info.width * info.height * 4 / 3) * 4
|
|
458
|
+
end
|
|
459
|
+
local function read(data)
|
|
460
|
+
prune()
|
|
461
|
+
local id = data.transferId
|
|
462
|
+
if not validId(id) then
|
|
463
|
+
return fail("invalid transfer ID")
|
|
464
|
+
end
|
|
465
|
+
local transfer = transfers[id]
|
|
466
|
+
if transfer == nil then
|
|
467
|
+
return fail("unknown or expired transfer")
|
|
468
|
+
end
|
|
469
|
+
local pixels = transfer.data
|
|
470
|
+
local offset = data.offset
|
|
471
|
+
local length = data.length
|
|
472
|
+
if pixels == nil or not integer(offset, 0, #pixels - 1) or not integer(length, 1, CHUNK_BYTES) or offset ~= transfer.offset or length ~= math.min(CHUNK_BYTES, #pixels - offset) then
|
|
473
|
+
discard(id)
|
|
474
|
+
return fail("invalid chunk offset or length")
|
|
475
|
+
end
|
|
476
|
+
local _arg0 = offset + 1
|
|
477
|
+
local _arg1 = offset + length
|
|
478
|
+
local chunk = string.sub(pixels, _arg0, _arg1)
|
|
479
|
+
transfer.offset += length
|
|
480
|
+
-- Last read releases immediately, even if the receiver disappears before
|
|
481
|
+
-- its explicit release. Explicit release is idempotent for error paths.
|
|
482
|
+
if transfer.offset == #pixels then
|
|
483
|
+
discard(id)
|
|
484
|
+
end
|
|
485
|
+
return {
|
|
486
|
+
transferId = id,
|
|
487
|
+
offset = offset,
|
|
488
|
+
data = chunk,
|
|
489
|
+
}
|
|
490
|
+
end
|
|
491
|
+
local function receive(invoke, data)
|
|
492
|
+
local id = HttpService:GenerateGUID(false)
|
|
493
|
+
local ok, result = pcall(function()
|
|
494
|
+
-- Older clients return inline data and may disconnect on a large capture.
|
|
495
|
+
-- Probe a harmless release of our fresh ID before requesting any pixels.
|
|
496
|
+
local probe = invoke(RELEASE_ENDPOINT, {
|
|
497
|
+
transferId = id,
|
|
498
|
+
})
|
|
499
|
+
if not (type(probe) == "table") then
|
|
500
|
+
return {
|
|
501
|
+
unavailable = "Client capture lacks bounded transfer support",
|
|
502
|
+
}
|
|
503
|
+
end
|
|
504
|
+
local capability = probe
|
|
505
|
+
if capability.success ~= true or capability.chunkBytes ~= CHUNK_BYTES then
|
|
506
|
+
return {
|
|
507
|
+
unavailable = "Client capture lacks bounded transfer support",
|
|
508
|
+
}
|
|
509
|
+
end
|
|
510
|
+
local response = invoke("/api/capture-studio", {
|
|
511
|
+
encoding = data.encoding,
|
|
512
|
+
transferId = id,
|
|
513
|
+
})
|
|
514
|
+
if not (type(response) == "table") then
|
|
515
|
+
error("invalid transfer response")
|
|
516
|
+
end
|
|
517
|
+
local fields = response
|
|
518
|
+
local _unavailable = fields.unavailable
|
|
519
|
+
if type(_unavailable) == "string" then
|
|
520
|
+
return {
|
|
521
|
+
unavailable = string.sub(fields.unavailable, 1, 4096),
|
|
522
|
+
}
|
|
523
|
+
end
|
|
524
|
+
local _error = fields.error
|
|
525
|
+
if type(_error) == "string" then
|
|
526
|
+
error(string.sub(fields.error, 1, 4096))
|
|
527
|
+
end
|
|
528
|
+
local info = metadata(fields.metadata)
|
|
529
|
+
if info == nil or fields.transferId ~= id or fields.chunkBytes ~= CHUNK_BYTES or not validSize(fields.totalBytes, info) then
|
|
530
|
+
error("invalid transfer metadata or size")
|
|
531
|
+
end
|
|
532
|
+
local size = fields.totalBytes
|
|
533
|
+
local chunks = {}
|
|
534
|
+
local offset = 0
|
|
535
|
+
while offset < size do
|
|
536
|
+
local length = math.min(CHUNK_BYTES, size - offset)
|
|
537
|
+
local response = invoke(READ_ENDPOINT, {
|
|
538
|
+
transferId = id,
|
|
539
|
+
offset = offset,
|
|
540
|
+
length = length,
|
|
541
|
+
})
|
|
542
|
+
if not (type(response) == "table") then
|
|
543
|
+
error("invalid chunk response")
|
|
544
|
+
end
|
|
545
|
+
local chunk = response
|
|
546
|
+
local _error_1 = chunk.error
|
|
547
|
+
if type(_error_1) == "string" then
|
|
548
|
+
error(string.sub(chunk.error, 1, 4096))
|
|
549
|
+
end
|
|
550
|
+
local _condition = chunk.transferId ~= id or chunk.offset ~= offset
|
|
551
|
+
if not _condition then
|
|
552
|
+
local _data = chunk.data
|
|
553
|
+
_condition = not (type(_data) == "string")
|
|
554
|
+
if not _condition then
|
|
555
|
+
_condition = #chunk.data ~= length
|
|
556
|
+
end
|
|
557
|
+
end
|
|
558
|
+
if _condition then
|
|
559
|
+
error("chunk ID, offset or length mismatch")
|
|
560
|
+
end
|
|
561
|
+
local _data = chunk.data
|
|
562
|
+
table.insert(chunks, _data)
|
|
563
|
+
offset += length
|
|
564
|
+
end
|
|
565
|
+
return {
|
|
566
|
+
success = true,
|
|
567
|
+
encoding = info.encoding,
|
|
568
|
+
source = info.source,
|
|
569
|
+
width = info.width,
|
|
570
|
+
height = info.height,
|
|
571
|
+
nativeWidth = info.nativeWidth,
|
|
572
|
+
nativeHeight = info.nativeHeight,
|
|
573
|
+
data = table.concat(chunks, ""),
|
|
574
|
+
}
|
|
575
|
+
end)
|
|
576
|
+
-- The final read already frees successful transfers. Cleanup is best-effort:
|
|
577
|
+
-- it must not mask an unavailable signal from an older client or a completed
|
|
578
|
+
-- image. Failed partial transfers also have the bounded expiry above.
|
|
579
|
+
pcall(function()
|
|
580
|
+
return invoke(RELEASE_ENDPOINT, {
|
|
581
|
+
transferId = id,
|
|
582
|
+
})
|
|
583
|
+
end)
|
|
584
|
+
if not ok then
|
|
585
|
+
return fail(tostring(result))
|
|
586
|
+
end
|
|
587
|
+
return result
|
|
588
|
+
end
|
|
589
|
+
return {
|
|
590
|
+
READ_ENDPOINT = READ_ENDPOINT,
|
|
591
|
+
RELEASE_ENDPOINT = RELEASE_ENDPOINT,
|
|
592
|
+
begin = begin,
|
|
593
|
+
read = read,
|
|
594
|
+
release = release,
|
|
595
|
+
receive = receive,
|
|
596
|
+
}
|
|
597
|
+
]]></string>
|
|
598
|
+
</Properties>
|
|
599
|
+
</Item>
|
|
600
|
+
<Item class="ModuleScript" referent="4">
|
|
281
601
|
<Properties>
|
|
282
602
|
<string name="Name">ClientBroker</string>
|
|
283
603
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -287,10 +607,12 @@ local HttpService = _services.HttpService
|
|
|
287
607
|
local Players = _services.Players
|
|
288
608
|
local ReplicatedStorage = _services.ReplicatedStorage
|
|
289
609
|
local RunService = _services.RunService
|
|
610
|
+
local Workspace = _services.Workspace
|
|
290
611
|
local RuntimeLogBuffer = TS.import(script, script.Parent, "RuntimeLogBuffer")
|
|
291
612
|
local MemoryHandlers = TS.import(script, script.Parent, "handlers", "MemoryHandlers")
|
|
292
613
|
local SceneAnalysisHandlers = TS.import(script, script.Parent, "handlers", "SceneAnalysisHandlers")
|
|
293
614
|
local CaptureHandlers = TS.import(script, script.Parent, "handlers", "CaptureHandlers")
|
|
615
|
+
local CaptureTransfer = TS.import(script, script.Parent, "CaptureTransfer")
|
|
294
616
|
local InputHandlers = TS.import(script, script.Parent, "handlers", "InputHandlers")
|
|
295
617
|
local MetadataHandlers = TS.import(script, script.Parent, "handlers", "MetadataHandlers")
|
|
296
618
|
local EvalRuntimeHandlers = TS.import(script, script.Parent, "handlers", "EvalRuntimeHandlers")
|
|
@@ -312,6 +634,11 @@ local mcpUrl = DEFAULT_MCP_URL
|
|
|
312
634
|
local BROKER_NAME = "__MCPClientBroker"
|
|
313
635
|
local BROKER_OWNER_ATTRIBUTE = "__MCPBrokerOwner"
|
|
314
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
|
|
315
642
|
-- Endpoints the server-peer broker is allowed to forward to the client peer.
|
|
316
643
|
-- Each requires the client peer's plugin VM (because the buffer / require
|
|
317
644
|
-- cache / etc. lives there) so the server peer alone can't satisfy them.
|
|
@@ -327,6 +654,8 @@ local CLIENT_BROKER_ALLOWED_ENDPOINTS = {
|
|
|
327
654
|
["/api/multiplayer-test-state"] = true,
|
|
328
655
|
["/api/multiplayer-test-leave-client"] = true,
|
|
329
656
|
["/api/capture-begin"] = true,
|
|
657
|
+
["/api/capture-studio"] = true,
|
|
658
|
+
["/api/capture-markers"] = true,
|
|
330
659
|
["/api/simulate-mouse-input"] = true,
|
|
331
660
|
["/api/simulate-keyboard-input"] = true,
|
|
332
661
|
["/api/focus-viewport"] = true,
|
|
@@ -514,6 +843,32 @@ local function setupClientBroker(attempt)
|
|
|
514
843
|
return BreakpointHandlers.breakpoints(payload.data or {})
|
|
515
844
|
end
|
|
516
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
|
|
517
872
|
return ScriptProfilerHandlers.captureScriptProfiler(payload.data or {})
|
|
518
873
|
end
|
|
519
874
|
if payload and payload.endpoint == "/api/capture-micro-profiler" then
|
|
@@ -528,6 +883,20 @@ local function setupClientBroker(attempt)
|
|
|
528
883
|
if payload and payload.endpoint == "/api/capture-begin" then
|
|
529
884
|
return CaptureHandlers.captureBegin()
|
|
530
885
|
end
|
|
886
|
+
if payload and payload.endpoint == "/api/capture-studio" then
|
|
887
|
+
return CaptureTransfer.begin(payload.data or {}, function()
|
|
888
|
+
return CaptureHandlers.captureStudio(payload.data or {})
|
|
889
|
+
end)
|
|
890
|
+
end
|
|
891
|
+
if payload and payload.endpoint == "/api/capture-markers" then
|
|
892
|
+
return CaptureHandlers.captureMarkers(payload.data or {})
|
|
893
|
+
end
|
|
894
|
+
if payload and payload.endpoint == CaptureTransfer.READ_ENDPOINT then
|
|
895
|
+
return CaptureTransfer.read(payload.data or {})
|
|
896
|
+
end
|
|
897
|
+
if payload and payload.endpoint == CaptureTransfer.RELEASE_ENDPOINT then
|
|
898
|
+
return CaptureTransfer.release(payload.data or {})
|
|
899
|
+
end
|
|
531
900
|
if payload and payload.endpoint == "/api/simulate-mouse-input" then
|
|
532
901
|
return InputHandlers.simulateMouseInput(payload.data or {})
|
|
533
902
|
end
|
|
@@ -800,7 +1169,112 @@ local function refreshAllProxyRegistrations()
|
|
|
800
1169
|
task.spawn(registerProxyEntry, entry)
|
|
801
1170
|
end
|
|
802
1171
|
end
|
|
803
|
-
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
|
|
804
1278
|
local _peerId = peerId
|
|
805
1279
|
local entry = proxyByPeerId[_peerId]
|
|
806
1280
|
local _condition = not entry
|
|
@@ -840,6 +1314,17 @@ local function dispatchClientRequest(peerId, target, endpoint, data)
|
|
|
840
1314
|
error = `Client-proxy does not forward {endpoint}. Allowed: {table.concat(allowed, ", ")}.`,
|
|
841
1315
|
}
|
|
842
1316
|
end
|
|
1317
|
+
if endpoint == "/api/capture-script-profiler" then
|
|
1318
|
+
return invokeClientProfiler(entry, target, data, context)
|
|
1319
|
+
end
|
|
1320
|
+
if endpoint == "/api/capture-studio" then
|
|
1321
|
+
return CaptureTransfer.receive(function(captureEndpoint, captureData)
|
|
1322
|
+
return entry.remote:InvokeClient(entry.player, {
|
|
1323
|
+
endpoint = captureEndpoint,
|
|
1324
|
+
data = captureData,
|
|
1325
|
+
})
|
|
1326
|
+
end, data or {})
|
|
1327
|
+
end
|
|
843
1328
|
local envelope = {
|
|
844
1329
|
endpoint = endpoint,
|
|
845
1330
|
data = data,
|
|
@@ -920,7 +1405,7 @@ return {
|
|
|
920
1405
|
]]></string>
|
|
921
1406
|
</Properties>
|
|
922
1407
|
</Item>
|
|
923
|
-
<Item class="ModuleScript" referent="
|
|
1408
|
+
<Item class="ModuleScript" referent="5">
|
|
924
1409
|
<Properties>
|
|
925
1410
|
<string name="Name">Communication</string>
|
|
926
1411
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -988,8 +1473,10 @@ local routeMap = {
|
|
|
988
1473
|
["/api/insert-asset"] = AssetHandlers.insertAsset,
|
|
989
1474
|
["/api/preview-asset"] = AssetHandlers.previewAsset,
|
|
990
1475
|
["/api/capture-screenshot"] = CaptureHandlers.captureScreenshot,
|
|
1476
|
+
["/api/capture-studio"] = CaptureHandlers.captureStudio,
|
|
991
1477
|
["/api/capture-begin"] = CaptureHandlers.captureBegin,
|
|
992
1478
|
["/api/capture-read"] = CaptureHandlers.captureRead,
|
|
1479
|
+
["/api/capture-markers"] = CaptureHandlers.captureMarkers,
|
|
993
1480
|
["/api/simulate-mouse-input"] = InputHandlers.simulateMouseInput,
|
|
994
1481
|
["/api/simulate-keyboard-input"] = InputHandlers.simulateKeyboardInput,
|
|
995
1482
|
["/api/find-and-replace-in-scripts"] = ScriptHandlers.findAndReplaceInScripts,
|
|
@@ -1030,7 +1517,7 @@ local function getConnectionStatus()
|
|
|
1030
1517
|
end
|
|
1031
1518
|
local function dispatchSocketRequest(request, context)
|
|
1032
1519
|
if request.peerId ~= PluginSession.peerId then
|
|
1033
|
-
return ClientBroker.dispatchClientRequest(request.peerId, request.target, request.endpoint, request.data)
|
|
1520
|
+
return ClientBroker.dispatchClientRequest(request.peerId, request.target, request.endpoint, request.data, context)
|
|
1034
1521
|
end
|
|
1035
1522
|
local _condition = assignedRole
|
|
1036
1523
|
if _condition == nil then
|
|
@@ -1085,34 +1572,9 @@ local function handleTransportUpdate(update)
|
|
|
1085
1572
|
if not conn.isActive then
|
|
1086
1573
|
return nil
|
|
1087
1574
|
end
|
|
1088
|
-
|
|
1089
|
-
conn.lastHttpOk = true
|
|
1090
|
-
conn.lastMcpOk = false
|
|
1091
|
-
conn.consecutiveFailures = 0
|
|
1092
|
-
conn.currentRetryDelay = 0.5
|
|
1093
|
-
conn.mcpWaitStartTime = tick()
|
|
1094
|
-
else
|
|
1095
|
-
conn.lastHttpOk = false
|
|
1096
|
-
conn.lastMcpOk = false
|
|
1097
|
-
conn.consecutiveFailures = update.attempt
|
|
1098
|
-
if update.retryDelay > 0 then
|
|
1099
|
-
conn.currentRetryDelay = update.retryDelay
|
|
1100
|
-
end
|
|
1101
|
-
conn.mcpWaitStartTime = nil
|
|
1102
|
-
end
|
|
1575
|
+
State.applyTransportUpdate(update, tick())
|
|
1103
1576
|
UI.updateUIState()
|
|
1104
1577
|
UI.updateToolbarIcon()
|
|
1105
|
-
if update.state == "waiting-duplicate" then
|
|
1106
|
-
local ui = UI.getElements()
|
|
1107
|
-
ui.statusLabel.Text = "Waiting for previous instance"
|
|
1108
|
-
ui.statusLabel.TextColor3 = Color3.fromRGB(245, 158, 11)
|
|
1109
|
-
local _condition = update.detail
|
|
1110
|
-
if _condition == nil then
|
|
1111
|
-
_condition = "The previous plugin instance is still active."
|
|
1112
|
-
end
|
|
1113
|
-
ui.detailStatusLabel.Text = _condition
|
|
1114
|
-
ui.detailStatusLabel.TextColor3 = Color3.fromRGB(245, 158, 11)
|
|
1115
|
-
end
|
|
1116
1578
|
end
|
|
1117
1579
|
local nameChangeConn
|
|
1118
1580
|
local placeIdChangeConn
|
|
@@ -1163,6 +1625,7 @@ local function activatePlugin()
|
|
|
1163
1625
|
conn.lastHttpOk = false
|
|
1164
1626
|
conn.lastMcpOk = false
|
|
1165
1627
|
conn.mcpWaitStartTime = nil
|
|
1628
|
+
State.clearTransportDiagnostics()
|
|
1166
1629
|
local normalizedUrl = ServerUrlSettings.normalizeServerUrl(ui.urlInput.Text)
|
|
1167
1630
|
conn.serverUrl = if normalizedUrl ~= "" then normalizedUrl else conn.serverUrl
|
|
1168
1631
|
if conn.serverUrl == "" then
|
|
@@ -1185,6 +1648,7 @@ local function activatePlugin()
|
|
|
1185
1648
|
onTransportUpdate = handleTransportUpdate,
|
|
1186
1649
|
})
|
|
1187
1650
|
if not conn.heartbeatConnection then
|
|
1651
|
+
local lastDiagnosticUpdate = tick()
|
|
1188
1652
|
conn.heartbeatConnection = RunService.Heartbeat:Connect(function()
|
|
1189
1653
|
if initialRole == "server" and not RunService:IsRunning() then
|
|
1190
1654
|
ClientBroker.disconnectAllProxies()
|
|
@@ -1197,6 +1661,11 @@ local function activatePlugin()
|
|
|
1197
1661
|
PluginSession.invalidatePlaceName()
|
|
1198
1662
|
StudioWebSocket.refresh()
|
|
1199
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
|
|
1200
1669
|
end)
|
|
1201
1670
|
end
|
|
1202
1671
|
if initialRole == "edit" then
|
|
@@ -1213,6 +1682,7 @@ function deactivatePlugin()
|
|
|
1213
1682
|
conn.lastHttpOk = false
|
|
1214
1683
|
conn.lastMcpOk = false
|
|
1215
1684
|
conn.mcpWaitStartTime = nil
|
|
1685
|
+
State.clearTransportDiagnostics()
|
|
1216
1686
|
StudioWebSocket.stop()
|
|
1217
1687
|
disconnectIdentityWatchers()
|
|
1218
1688
|
if initialRole == "server" then
|
|
@@ -1276,7 +1746,7 @@ return {
|
|
|
1276
1746
|
]]></string>
|
|
1277
1747
|
</Properties>
|
|
1278
1748
|
</Item>
|
|
1279
|
-
<Item class="ModuleScript" referent="
|
|
1749
|
+
<Item class="ModuleScript" referent="6">
|
|
1280
1750
|
<Properties>
|
|
1281
1751
|
<string name="Name">CooperativeJobRunner</string>
|
|
1282
1752
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -1351,7 +1821,7 @@ return {
|
|
|
1351
1821
|
]]></string>
|
|
1352
1822
|
</Properties>
|
|
1353
1823
|
</Item>
|
|
1354
|
-
<Item class="ModuleScript" referent="
|
|
1824
|
+
<Item class="ModuleScript" referent="7">
|
|
1355
1825
|
<Properties>
|
|
1356
1826
|
<string name="Name">EvalBridges</string>
|
|
1357
1827
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -1472,9 +1942,9 @@ local function computeBridgeStamp()
|
|
|
1472
1942
|
for i = 1, #combined do
|
|
1473
1943
|
h = (h * 33 + (string.byte(combined, i))) % 2147483647
|
|
1474
1944
|
end
|
|
1475
|
-
-- "3.1.
|
|
1945
|
+
-- "3.1.5" is replaced with the package version at package time
|
|
1476
1946
|
-- (scripts/build-plugin.mjs injectVersion), so a release bump also restamps.
|
|
1477
|
-
return `{tostring(h)}-3.1.
|
|
1947
|
+
return `{tostring(h)}-3.1.5`
|
|
1478
1948
|
end
|
|
1479
1949
|
local BRIDGE_STAMP = computeBridgeStamp()
|
|
1480
1950
|
local function setSource(scriptInst, source)
|
|
@@ -1627,11 +2097,11 @@ return {
|
|
|
1627
2097
|
]]></string>
|
|
1628
2098
|
</Properties>
|
|
1629
2099
|
</Item>
|
|
1630
|
-
<Item class="Folder" referent="
|
|
2100
|
+
<Item class="Folder" referent="8">
|
|
1631
2101
|
<Properties>
|
|
1632
2102
|
<string name="Name">handlers</string>
|
|
1633
2103
|
</Properties>
|
|
1634
|
-
<Item class="ModuleScript" referent="
|
|
2104
|
+
<Item class="ModuleScript" referent="9">
|
|
1635
2105
|
<Properties>
|
|
1636
2106
|
<string name="Name">AssetHandlers</string>
|
|
1637
2107
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -2072,7 +2542,7 @@ return {
|
|
|
2072
2542
|
]]></string>
|
|
2073
2543
|
</Properties>
|
|
2074
2544
|
</Item>
|
|
2075
|
-
<Item class="ModuleScript" referent="
|
|
2545
|
+
<Item class="ModuleScript" referent="10">
|
|
2076
2546
|
<Properties>
|
|
2077
2547
|
<string name="Name">BreakpointHandlers</string>
|
|
2078
2548
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -2579,7 +3049,7 @@ return {
|
|
|
2579
3049
|
]]></string>
|
|
2580
3050
|
</Properties>
|
|
2581
3051
|
</Item>
|
|
2582
|
-
<Item class="ModuleScript" referent="
|
|
3052
|
+
<Item class="ModuleScript" referent="11">
|
|
2583
3053
|
<Properties>
|
|
2584
3054
|
<string name="Name">CaptureHandlers</string>
|
|
2585
3055
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -2587,11 +3057,35 @@ local TS = require(script.Parent.Parent.Parent.include.RuntimeLib)
|
|
|
2587
3057
|
local RenderMonitor = TS.import(script, script.Parent.Parent, "RenderMonitor")
|
|
2588
3058
|
local CaptureService = game:GetService("CaptureService")
|
|
2589
3059
|
local AssetService = game:GetService("AssetService")
|
|
3060
|
+
local Workspace = game:GetService("Workspace")
|
|
3061
|
+
local CoreGui = game:GetService("CoreGui")
|
|
3062
|
+
local RunService = game:GetService("RunService")
|
|
2590
3063
|
local MAX_TILE_SIZE = 1024
|
|
2591
3064
|
local MAX_RAW_PIXEL_BYTES = 36 * 1024 * 1024
|
|
2592
3065
|
local MAX_CREATED_IMAGE_DIM = 2048
|
|
2593
3066
|
local BASE64_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
|
|
2594
3067
|
local PAD_BYTE = (string.byte("="))
|
|
3068
|
+
-- StudioCaptureService (Studio-only, PluginSecurity) is the fast path: it hands
|
|
3069
|
+
-- back the framebuffer directly, so it needs neither CaptureService's
|
|
3070
|
+
-- asynchronous callback nor an EditableImage round-trip — and it captures at
|
|
3071
|
+
-- ViewportSize, which is exactly the coordinate space simulate_mouse_input
|
|
3072
|
+
-- expects. It is gated behind Studio FFlags and is missing from @rbxts/types,
|
|
3073
|
+
-- hence the local structural declarations below.
|
|
3074
|
+
-- Studio exposes this API at runtime, but the installed @rbxts/types predates it.
|
|
3075
|
+
-- Unchecked cast, single reason: these enums exist in Studio but are missing
|
|
3076
|
+
-- from @rbxts/types, so the global Enum table has to be read dynamically.
|
|
3077
|
+
local ENUM_TABLE = Enum
|
|
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
|
|
3084
|
+
-- Measured on a 1980x1032 viewport: RGBA8 completes in ~0.16s and PNG in ~1s.
|
|
3085
|
+
-- A capture that is still Pending well past that never completes (it happens
|
|
3086
|
+
-- while a playtest owns the renderer), so we stop waiting and let the caller
|
|
3087
|
+
-- fall back instead of stalling the tool call.
|
|
3088
|
+
local STUDIO_CAPTURE_TIMEOUT = 3
|
|
2595
3089
|
local B64 = {}
|
|
2596
3090
|
for i = 0, 63 do
|
|
2597
3091
|
B64[i + 1] = (string.byte(BASE64_CHARS, i + 1))
|
|
@@ -2701,7 +3195,11 @@ end
|
|
|
2701
3195
|
-- pixels back is blocked, but capturing is not). The returned rbxtemp:// id is
|
|
2702
3196
|
-- a process-scoped handle: it can be dereferenced from a DIFFERENT, more
|
|
2703
3197
|
-- privileged DM (the edit DM) — see captureRead.
|
|
3198
|
+
local withViewportRead, captureScreenshotFrame
|
|
2704
3199
|
local function doCaptureScreenshot()
|
|
3200
|
+
return withViewportRead(captureScreenshotFrame)
|
|
3201
|
+
end
|
|
3202
|
+
function captureScreenshotFrame()
|
|
2705
3203
|
-- Fast-fail with a clear reason if the window isn't rendering — otherwise
|
|
2706
3204
|
-- CaptureScreenshot's callback never fires and we'd block for the full 10s.
|
|
2707
3205
|
local notRendering = RenderMonitor.notRenderingReason()
|
|
@@ -2737,8 +3235,12 @@ local function readContentToBase64(contentId)
|
|
|
2737
3235
|
return AssetService:CreateEditableImageAsync(Content.fromUri(contentId))
|
|
2738
3236
|
end)
|
|
2739
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.
|
|
2740
3242
|
return {
|
|
2741
|
-
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'.",
|
|
2742
3244
|
}
|
|
2743
3245
|
end
|
|
2744
3246
|
local sourceImage = editableResult
|
|
@@ -2789,6 +3291,130 @@ local function readContentToBase64(contentId)
|
|
|
2789
3291
|
nativeHeight = nativeH,
|
|
2790
3292
|
}
|
|
2791
3293
|
end
|
|
3294
|
+
local cachedStudioService
|
|
3295
|
+
local function getStudioCaptureService()
|
|
3296
|
+
if cachedStudioService ~= nil then
|
|
3297
|
+
return cachedStudioService
|
|
3298
|
+
end
|
|
3299
|
+
-- Unchecked cast, single reason: StudioCaptureService is absent from
|
|
3300
|
+
-- @rbxts/types, so GetService cannot be called through the typed overload.
|
|
3301
|
+
local dynamicGame = game
|
|
3302
|
+
local ok, service = pcall(function()
|
|
3303
|
+
return dynamicGame:GetService("StudioCaptureService")
|
|
3304
|
+
end)
|
|
3305
|
+
if not ok or service == nil then
|
|
3306
|
+
return nil
|
|
3307
|
+
end
|
|
3308
|
+
cachedStudioService = service
|
|
3309
|
+
return cachedStudioService
|
|
3310
|
+
end
|
|
3311
|
+
-- Captures through StudioCaptureService. Returns undefined when the service
|
|
3312
|
+
-- cannot capture right now (missing FFlag, permission not granted, or this
|
|
3313
|
+
-- DataModel is not the active one) so the caller can fall back to the
|
|
3314
|
+
-- CaptureService + EditableImage path.
|
|
3315
|
+
local captureStudioFrame
|
|
3316
|
+
local function doStudioCapture(wantPng)
|
|
3317
|
+
return withViewportRead(function()
|
|
3318
|
+
return captureStudioFrame(wantPng)
|
|
3319
|
+
end)
|
|
3320
|
+
end
|
|
3321
|
+
function captureStudioFrame(wantPng)
|
|
3322
|
+
if STUDIO_CAPTURE_FORMATS == nil then
|
|
3323
|
+
return nil
|
|
3324
|
+
end
|
|
3325
|
+
local service = getStudioCaptureService()
|
|
3326
|
+
if service == nil then
|
|
3327
|
+
return nil
|
|
3328
|
+
end
|
|
3329
|
+
local canOk, can = pcall(function()
|
|
3330
|
+
return service:CanCaptureScreenshot()
|
|
3331
|
+
end)
|
|
3332
|
+
if not canOk or can ~= true then
|
|
3333
|
+
return nil
|
|
3334
|
+
end
|
|
3335
|
+
local camera = Workspace.CurrentCamera
|
|
3336
|
+
if camera == nil then
|
|
3337
|
+
return nil
|
|
3338
|
+
end
|
|
3339
|
+
local viewport = camera.ViewportSize
|
|
3340
|
+
local nativeW = math.max(1, math.floor(viewport.X))
|
|
3341
|
+
local nativeH = math.max(1, math.floor(viewport.Y))
|
|
3342
|
+
local w = nativeW
|
|
3343
|
+
local h = nativeH
|
|
3344
|
+
-- Raw RGBA rides back base64-encoded, so an oversized viewport is
|
|
3345
|
+
-- downscaled by the engine during the capture itself — no second pass.
|
|
3346
|
+
if not wantPng and nativeW * nativeH * 4 > MAX_RAW_PIXEL_BYTES then
|
|
3347
|
+
local scale = math.sqrt(MAX_RAW_PIXEL_BYTES / (nativeW * nativeH * 4))
|
|
3348
|
+
w = math.max(1, math.floor(nativeW * scale))
|
|
3349
|
+
h = math.max(1, math.floor(nativeH * scale))
|
|
3350
|
+
end
|
|
3351
|
+
-- CaptureSize is a framebuffer crop, not the logical viewport size. At
|
|
3352
|
+
-- fractional display scaling it cuts off the right/bottom of the frame.
|
|
3353
|
+
local options = {
|
|
3354
|
+
OutputSize = Vector2.new(w, h),
|
|
3355
|
+
ResampleMode = Enum.ResamplerMode.Default,
|
|
3356
|
+
Format = if wantPng then STUDIO_CAPTURE_FORMATS.PNG else STUDIO_CAPTURE_FORMATS.RGBA8,
|
|
3357
|
+
}
|
|
3358
|
+
local captureOk, captureResult = pcall(function()
|
|
3359
|
+
return service:CaptureScreenshot(options)
|
|
3360
|
+
end)
|
|
3361
|
+
if not captureOk then
|
|
3362
|
+
return {
|
|
3363
|
+
error = `StudioCaptureService:CaptureScreenshot failed: {tostring(captureResult)}`,
|
|
3364
|
+
}
|
|
3365
|
+
end
|
|
3366
|
+
if captureResult == nil then
|
|
3367
|
+
return nil
|
|
3368
|
+
end
|
|
3369
|
+
local capture = captureResult
|
|
3370
|
+
local startTime = tick()
|
|
3371
|
+
while capture.BufferStatus.Name == "Pending" or capture.BufferStatus.Name == "NotStarted" do
|
|
3372
|
+
if tick() - startTime > STUDIO_CAPTURE_TIMEOUT then
|
|
3373
|
+
return nil
|
|
3374
|
+
end
|
|
3375
|
+
task.wait(0.02)
|
|
3376
|
+
end
|
|
3377
|
+
if capture.BufferStatus.Name ~= "Ready" then
|
|
3378
|
+
local errorsOk, errors = pcall(function()
|
|
3379
|
+
return capture:GetErrors()
|
|
3380
|
+
end)
|
|
3381
|
+
local detail = if errorsOk then game:GetService("HttpService"):JSONEncode(errors) else "unavailable"
|
|
3382
|
+
return {
|
|
3383
|
+
error = `StudioCaptureService capture failed (status {capture.BufferStatus.Name}): {detail}`,
|
|
3384
|
+
}
|
|
3385
|
+
end
|
|
3386
|
+
local bufferOk, captureBuffer = pcall(function()
|
|
3387
|
+
return capture:GetBuffer()
|
|
3388
|
+
end)
|
|
3389
|
+
if not bufferOk then
|
|
3390
|
+
return {
|
|
3391
|
+
error = `StudioCaptureService:GetBuffer failed: {tostring(captureBuffer)}`,
|
|
3392
|
+
}
|
|
3393
|
+
end
|
|
3394
|
+
local resolution = capture.Resolution
|
|
3395
|
+
return {
|
|
3396
|
+
success = true,
|
|
3397
|
+
encoding = if wantPng then "png" else "rgba8",
|
|
3398
|
+
source = "StudioCaptureService",
|
|
3399
|
+
width = math.max(1, math.floor(resolution.X)),
|
|
3400
|
+
height = math.max(1, math.floor(resolution.Y)),
|
|
3401
|
+
nativeWidth = nativeW,
|
|
3402
|
+
nativeHeight = nativeH,
|
|
3403
|
+
data = encodeBase64(captureBuffer),
|
|
3404
|
+
}
|
|
3405
|
+
end
|
|
3406
|
+
-- Studio-only capture endpoint. Reports `unavailable` (instead of an error) so
|
|
3407
|
+
-- the server can fall back to the legacy CaptureService path.
|
|
3408
|
+
local function captureStudio(requestData)
|
|
3409
|
+
local wantPng = requestData.encoding == "png"
|
|
3410
|
+
local result = doStudioCapture(wantPng)
|
|
3411
|
+
if result == nil then
|
|
3412
|
+
return {
|
|
3413
|
+
unavailable = "StudioCaptureService cannot capture this DataModel right now",
|
|
3414
|
+
}
|
|
3415
|
+
end
|
|
3416
|
+
return result
|
|
3417
|
+
end
|
|
2792
3418
|
-- Edit-mode single shot: capture and read back in the same (edit) context.
|
|
2793
3419
|
local function captureScreenshotData()
|
|
2794
3420
|
local cap = doCaptureScreenshot()
|
|
@@ -2814,16 +3440,382 @@ local function captureRead(requestData)
|
|
|
2814
3440
|
end
|
|
2815
3441
|
return readContentToBase64(contentId)
|
|
2816
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
|
|
2817
3807
|
return {
|
|
2818
3808
|
captureScreenshotData = captureScreenshotData,
|
|
2819
3809
|
captureScreenshot = captureScreenshot,
|
|
3810
|
+
captureStudio = captureStudio,
|
|
2820
3811
|
captureBegin = captureBegin,
|
|
2821
3812
|
captureRead = captureRead,
|
|
3813
|
+
captureMarkers = captureMarkers,
|
|
2822
3814
|
}
|
|
2823
3815
|
]]></string>
|
|
2824
3816
|
</Properties>
|
|
2825
3817
|
</Item>
|
|
2826
|
-
<Item class="ModuleScript" referent="
|
|
3818
|
+
<Item class="ModuleScript" referent="12">
|
|
2827
3819
|
<Properties>
|
|
2828
3820
|
<string name="Name">EvalRuntimeHandlers</string>
|
|
2829
3821
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -2977,7 +3969,7 @@ return {
|
|
|
2977
3969
|
]]></string>
|
|
2978
3970
|
</Properties>
|
|
2979
3971
|
</Item>
|
|
2980
|
-
<Item class="ModuleScript" referent="
|
|
3972
|
+
<Item class="ModuleScript" referent="13">
|
|
2981
3973
|
<Properties>
|
|
2982
3974
|
<string name="Name">GenerateModelHandlers</string>
|
|
2983
3975
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -3182,7 +4174,7 @@ return {
|
|
|
3182
4174
|
]]></string>
|
|
3183
4175
|
</Properties>
|
|
3184
4176
|
</Item>
|
|
3185
|
-
<Item class="ModuleScript" referent="
|
|
4177
|
+
<Item class="ModuleScript" referent="14">
|
|
3186
4178
|
<Properties>
|
|
3187
4179
|
<string name="Name">InputHandlers</string>
|
|
3188
4180
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -3382,7 +4374,7 @@ return {
|
|
|
3382
4374
|
]]></string>
|
|
3383
4375
|
</Properties>
|
|
3384
4376
|
</Item>
|
|
3385
|
-
<Item class="ModuleScript" referent="
|
|
4377
|
+
<Item class="ModuleScript" referent="15">
|
|
3386
4378
|
<Properties>
|
|
3387
4379
|
<string name="Name">LogHandlers</string>
|
|
3388
4380
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -3404,7 +4396,7 @@ return {
|
|
|
3404
4396
|
]]></string>
|
|
3405
4397
|
</Properties>
|
|
3406
4398
|
</Item>
|
|
3407
|
-
<Item class="ModuleScript" referent="
|
|
4399
|
+
<Item class="ModuleScript" referent="16">
|
|
3408
4400
|
<Properties>
|
|
3409
4401
|
<string name="Name">MemoryHandlers</string>
|
|
3410
4402
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -3472,7 +4464,7 @@ return {
|
|
|
3472
4464
|
]]></string>
|
|
3473
4465
|
</Properties>
|
|
3474
4466
|
</Item>
|
|
3475
|
-
<Item class="ModuleScript" referent="
|
|
4467
|
+
<Item class="ModuleScript" referent="17">
|
|
3476
4468
|
<Properties>
|
|
3477
4469
|
<string name="Name">MetadataHandlers</string>
|
|
3478
4470
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -3829,7 +4821,7 @@ return {
|
|
|
3829
4821
|
]]></string>
|
|
3830
4822
|
</Properties>
|
|
3831
4823
|
</Item>
|
|
3832
|
-
<Item class="ModuleScript" referent="
|
|
4824
|
+
<Item class="ModuleScript" referent="18">
|
|
3833
4825
|
<Properties>
|
|
3834
4826
|
<string name="Name">MicroProfilerHandlers</string>
|
|
3835
4827
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -4402,6 +5394,16 @@ local function captureMicroProfiler(requestData)
|
|
|
4402
5394
|
backend = backend,
|
|
4403
5395
|
}
|
|
4404
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
|
|
4405
5407
|
local profilerOk, profilerResult = safeCall(function()
|
|
4406
5408
|
return LibMP.Control:EnableProfiler(true)
|
|
4407
5409
|
end)
|
|
@@ -4423,6 +5425,11 @@ local function captureMicroProfiler(requestData)
|
|
|
4423
5425
|
}
|
|
4424
5426
|
end
|
|
4425
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)
|
|
4426
5433
|
local captureStopOk, captureStopResult = safeCall(function()
|
|
4427
5434
|
return LibMP.Control:EnableCapture(false)
|
|
4428
5435
|
end)
|
|
@@ -4433,9 +5440,6 @@ local function captureMicroProfiler(requestData)
|
|
|
4433
5440
|
backend = backend,
|
|
4434
5441
|
}
|
|
4435
5442
|
end
|
|
4436
|
-
local bufferOk, snapshotOrErr = safeCall(function()
|
|
4437
|
-
return LibMP.Control:CaptureToBufferSync()
|
|
4438
|
-
end)
|
|
4439
5443
|
if not bufferOk then
|
|
4440
5444
|
return {
|
|
4441
5445
|
error = "micro_profiler_snapshot_failed",
|
|
@@ -5582,7 +6586,7 @@ return {
|
|
|
5582
6586
|
]]></string>
|
|
5583
6587
|
</Properties>
|
|
5584
6588
|
</Item>
|
|
5585
|
-
<Item class="ModuleScript" referent="
|
|
6589
|
+
<Item class="ModuleScript" referent="19">
|
|
5586
6590
|
<Properties>
|
|
5587
6591
|
<string name="Name">PropertyHandlers</string>
|
|
5588
6592
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -5667,6 +6671,7 @@ local function setProperties(requestData)
|
|
|
5667
6671
|
-- execute_luau edits as well, even when every assignment in this call failed.
|
|
5668
6672
|
finishRecording(recordingId, true)
|
|
5669
6673
|
return {
|
|
6674
|
+
success = failureCount == 0,
|
|
5670
6675
|
instancePath = instancePath,
|
|
5671
6676
|
summary = {
|
|
5672
6677
|
total = successCount + failureCount,
|
|
@@ -5682,7 +6687,7 @@ return {
|
|
|
5682
6687
|
]]></string>
|
|
5683
6688
|
</Properties>
|
|
5684
6689
|
</Item>
|
|
5685
|
-
<Item class="ModuleScript" referent="
|
|
6690
|
+
<Item class="ModuleScript" referent="20">
|
|
5686
6691
|
<Properties>
|
|
5687
6692
|
<string name="Name">QueryHandlers</string>
|
|
5688
6693
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -6337,7 +7342,7 @@ return {
|
|
|
6337
7342
|
]]></string>
|
|
6338
7343
|
</Properties>
|
|
6339
7344
|
</Item>
|
|
6340
|
-
<Item class="ModuleScript" referent="
|
|
7345
|
+
<Item class="ModuleScript" referent="21">
|
|
6341
7346
|
<Properties>
|
|
6342
7347
|
<string name="Name">SceneAnalysisHandlers</string>
|
|
6343
7348
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -6586,7 +7591,7 @@ return {
|
|
|
6586
7591
|
]]></string>
|
|
6587
7592
|
</Properties>
|
|
6588
7593
|
</Item>
|
|
6589
|
-
<Item class="ModuleScript" referent="
|
|
7594
|
+
<Item class="ModuleScript" referent="22">
|
|
6590
7595
|
<Properties>
|
|
6591
7596
|
<string name="Name">ScriptHandlers</string>
|
|
6592
7597
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -7258,7 +8263,7 @@ return {
|
|
|
7258
8263
|
]]></string>
|
|
7259
8264
|
</Properties>
|
|
7260
8265
|
</Item>
|
|
7261
|
-
<Item class="ModuleScript" referent="
|
|
8266
|
+
<Item class="ModuleScript" referent="23">
|
|
7262
8267
|
<Properties>
|
|
7263
8268
|
<string name="Name">ScriptProfilerHandlers</string>
|
|
7264
8269
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -7721,7 +8726,7 @@ return {
|
|
|
7721
8726
|
]]></string>
|
|
7722
8727
|
</Properties>
|
|
7723
8728
|
</Item>
|
|
7724
|
-
<Item class="ModuleScript" referent="
|
|
8729
|
+
<Item class="ModuleScript" referent="24">
|
|
7725
8730
|
<Properties>
|
|
7726
8731
|
<string name="Name">SerializationHandlers</string>
|
|
7727
8732
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -7907,7 +8912,7 @@ return {
|
|
|
7907
8912
|
]]></string>
|
|
7908
8913
|
</Properties>
|
|
7909
8914
|
</Item>
|
|
7910
|
-
<Item class="ModuleScript" referent="
|
|
8915
|
+
<Item class="ModuleScript" referent="25">
|
|
7911
8916
|
<Properties>
|
|
7912
8917
|
<string name="Name">TestHandlers</string>
|
|
7913
8918
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -8034,6 +9039,7 @@ local function stopPlaytest(_requestData)
|
|
|
8034
9039
|
-- Signal the play-server DM's StopPlayMonitor via plugin:SetSetting.
|
|
8035
9040
|
-- The monitor acknowledges with the matching request id only after its
|
|
8036
9041
|
-- StudioTestService:EndTest call returns from pcall.
|
|
9042
|
+
local hadActivePlaytest = testRunning or not StudioTestService.EditModeActive
|
|
8037
9043
|
local stopRequest = StopPlayMonitor.requestStop()
|
|
8038
9044
|
if not stopRequest.ok or stopRequest.requestId == nil then
|
|
8039
9045
|
return {
|
|
@@ -8056,6 +9062,15 @@ local function stopPlaytest(_requestData)
|
|
|
8056
9062
|
-- Either way clean up the pending request so a future playtest's monitor
|
|
8057
9063
|
-- doesn't fire EndTest on startup against a stale signal.
|
|
8058
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
|
|
8059
9074
|
if testRunning then
|
|
8060
9075
|
return {
|
|
8061
9076
|
error = "Playtest stop signal failed or was not acknowledged. " .. "The playtest may have ended anyway; check get_connected_instances.",
|
|
@@ -8298,7 +9313,7 @@ return {
|
|
|
8298
9313
|
</Properties>
|
|
8299
9314
|
</Item>
|
|
8300
9315
|
</Item>
|
|
8301
|
-
<Item class="ModuleScript" referent="
|
|
9316
|
+
<Item class="ModuleScript" referent="26">
|
|
8302
9317
|
<Properties>
|
|
8303
9318
|
<string name="Name">HttpDiagnostics</string>
|
|
8304
9319
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -8379,7 +9394,7 @@ return {
|
|
|
8379
9394
|
]]></string>
|
|
8380
9395
|
</Properties>
|
|
8381
9396
|
</Item>
|
|
8382
|
-
<Item class="ModuleScript" referent="
|
|
9397
|
+
<Item class="ModuleScript" referent="27">
|
|
8383
9398
|
<Properties>
|
|
8384
9399
|
<string name="Name">LuauExec</string>
|
|
8385
9400
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -8813,7 +9828,7 @@ return {
|
|
|
8813
9828
|
]]></string>
|
|
8814
9829
|
</Properties>
|
|
8815
9830
|
</Item>
|
|
8816
|
-
<Item class="ModuleScript" referent="
|
|
9831
|
+
<Item class="ModuleScript" referent="28">
|
|
8817
9832
|
<Properties>
|
|
8818
9833
|
<string name="Name">PeerRole</string>
|
|
8819
9834
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -8834,7 +9849,7 @@ return {
|
|
|
8834
9849
|
]]></string>
|
|
8835
9850
|
</Properties>
|
|
8836
9851
|
</Item>
|
|
8837
|
-
<Item class="ModuleScript" referent="
|
|
9852
|
+
<Item class="ModuleScript" referent="29">
|
|
8838
9853
|
<Properties>
|
|
8839
9854
|
<string name="Name">PluginSession</string>
|
|
8840
9855
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -9023,7 +10038,7 @@ return {
|
|
|
9023
10038
|
]]></string>
|
|
9024
10039
|
</Properties>
|
|
9025
10040
|
</Item>
|
|
9026
|
-
<Item class="ModuleScript" referent="
|
|
10041
|
+
<Item class="ModuleScript" referent="30">
|
|
9027
10042
|
<Properties>
|
|
9028
10043
|
<string name="Name">Recording</string>
|
|
9029
10044
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -9053,7 +10068,7 @@ return {
|
|
|
9053
10068
|
]]></string>
|
|
9054
10069
|
</Properties>
|
|
9055
10070
|
</Item>
|
|
9056
|
-
<Item class="ModuleScript" referent="
|
|
10071
|
+
<Item class="ModuleScript" referent="31">
|
|
9057
10072
|
<Properties>
|
|
9058
10073
|
<string name="Name">RenderMonitor</string>
|
|
9059
10074
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -9121,7 +10136,7 @@ return {
|
|
|
9121
10136
|
]]></string>
|
|
9122
10137
|
</Properties>
|
|
9123
10138
|
</Item>
|
|
9124
|
-
<Item class="ModuleScript" referent="
|
|
10139
|
+
<Item class="ModuleScript" referent="32">
|
|
9125
10140
|
<Properties>
|
|
9126
10141
|
<string name="Name">RuntimeLogBuffer</string>
|
|
9127
10142
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -9357,7 +10372,7 @@ return {
|
|
|
9357
10372
|
]]></string>
|
|
9358
10373
|
</Properties>
|
|
9359
10374
|
</Item>
|
|
9360
|
-
<Item class="ModuleScript" referent="
|
|
10375
|
+
<Item class="ModuleScript" referent="33">
|
|
9361
10376
|
<Properties>
|
|
9362
10377
|
<string name="Name">ScriptSearch</string>
|
|
9363
10378
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -9684,7 +10699,7 @@ return {
|
|
|
9684
10699
|
]]></string>
|
|
9685
10700
|
</Properties>
|
|
9686
10701
|
</Item>
|
|
9687
|
-
<Item class="ModuleScript" referent="
|
|
10702
|
+
<Item class="ModuleScript" referent="34">
|
|
9688
10703
|
<Properties>
|
|
9689
10704
|
<string name="Name">ServerUrlSettings</string>
|
|
9690
10705
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -9823,13 +10838,14 @@ return {
|
|
|
9823
10838
|
]]></string>
|
|
9824
10839
|
</Properties>
|
|
9825
10840
|
</Item>
|
|
9826
|
-
<Item class="ModuleScript" referent="
|
|
10841
|
+
<Item class="ModuleScript" referent="35">
|
|
9827
10842
|
<Properties>
|
|
9828
10843
|
<string name="Name">State</string>
|
|
9829
10844
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
9830
|
-
local CURRENT_VERSION = "3.1.
|
|
10845
|
+
local CURRENT_VERSION = "3.1.5"
|
|
9831
10846
|
local PLUGIN_VARIANT = "main"
|
|
9832
10847
|
local BASE_PORT = 58741
|
|
10848
|
+
local SENSITIVE_DIAGNOSTIC_TERMS = { "token", "authorization", "bearer", "password", "cookie", "secret" }
|
|
9833
10849
|
local function createConnection(port)
|
|
9834
10850
|
return {
|
|
9835
10851
|
port = port,
|
|
@@ -9848,16 +10864,123 @@ local connection = createConnection(BASE_PORT)
|
|
|
9848
10864
|
local function getActiveConnection()
|
|
9849
10865
|
return connection
|
|
9850
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
|
|
9851
10971
|
return {
|
|
9852
10972
|
CURRENT_VERSION = CURRENT_VERSION,
|
|
9853
10973
|
PLUGIN_VARIANT = PLUGIN_VARIANT,
|
|
9854
10974
|
BASE_PORT = BASE_PORT,
|
|
9855
10975
|
getActiveConnection = getActiveConnection,
|
|
10976
|
+
clearTransportDiagnostics = clearTransportDiagnostics,
|
|
10977
|
+
applyTransportUpdate = applyTransportUpdate,
|
|
10978
|
+
getTransportDiagnostics = getTransportDiagnostics,
|
|
9856
10979
|
}
|
|
9857
10980
|
]]></string>
|
|
9858
10981
|
</Properties>
|
|
9859
10982
|
</Item>
|
|
9860
|
-
<Item class="ModuleScript" referent="
|
|
10983
|
+
<Item class="ModuleScript" referent="36">
|
|
9861
10984
|
<Properties>
|
|
9862
10985
|
<string name="Name">StopPlayMonitor</string>
|
|
9863
10986
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -9871,6 +10994,10 @@ local RunService = _services.RunService
|
|
|
9871
10994
|
local PluginSession = TS.import(script, script.Parent, "PluginSession")
|
|
9872
10995
|
local StudioTestService = game:GetService("StudioTestService")
|
|
9873
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()
|
|
9874
11001
|
-- Keep this conservative. plugin:GetSetting is backed by Studio's plugin
|
|
9875
11002
|
-- settings store, and this monitor runs during every play session, including
|
|
9876
11003
|
-- manually-started Play. The official reference implementation polls at 1s.
|
|
@@ -9883,9 +11010,14 @@ local POLL_INTERVAL_SEC = 1
|
|
|
9883
11010
|
local WAIT_FOR_CONSUMPTION_TIMEOUT_SEC = 8.0
|
|
9884
11011
|
local WAIT_POLL_SEC = 0.1
|
|
9885
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
|
|
9886
11016
|
local pluginRef
|
|
9887
11017
|
local endTestIssued = false
|
|
9888
11018
|
local transportLifecycle
|
|
11019
|
+
local pendingRequest
|
|
11020
|
+
local lastResult
|
|
9889
11021
|
local function init(p)
|
|
9890
11022
|
pluginRef = p
|
|
9891
11023
|
end
|
|
@@ -9948,14 +11080,21 @@ local function writePayload(key, payload)
|
|
|
9948
11080
|
return writeSetting(key, encoded)
|
|
9949
11081
|
end
|
|
9950
11082
|
local function writeResult(key, request, ok, errText)
|
|
9951
|
-
|
|
11083
|
+
local payload = {
|
|
9952
11084
|
kind = "result",
|
|
9953
11085
|
id = request.id,
|
|
9954
11086
|
requestedAt = request.requestedAt,
|
|
9955
11087
|
consumedAt = tick(),
|
|
9956
11088
|
ok = ok,
|
|
9957
11089
|
error = errText,
|
|
9958
|
-
}
|
|
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)
|
|
9959
11098
|
end
|
|
9960
11099
|
local function handleStopRequest(key, request)
|
|
9961
11100
|
local _condition = request.kind ~= "request"
|
|
@@ -9972,10 +11111,14 @@ local function handleStopRequest(key, request)
|
|
|
9972
11111
|
return nil
|
|
9973
11112
|
end
|
|
9974
11113
|
local age = tick() - request.requestedAt
|
|
9975
|
-
if age < -5 or age > REQUEST_TTL_SEC then
|
|
11114
|
+
if request.requestedAt < moduleLoadedAt or age < -5 or age > REQUEST_TTL_SEC then
|
|
9976
11115
|
writeSetting(key, false)
|
|
9977
11116
|
return nil
|
|
9978
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
|
|
9979
11122
|
if endTestIssued then
|
|
9980
11123
|
writeResult(key, request, false, "StudioTestService:EndTest was already issued for this play session, but the runtime DataModel is still alive.")
|
|
9981
11124
|
return nil
|
|
@@ -10036,6 +11179,11 @@ local function requestStop()
|
|
|
10036
11179
|
requestedAt = tick(),
|
|
10037
11180
|
}
|
|
10038
11181
|
local ok = writePayload(settingKey(), payload)
|
|
11182
|
+
pendingRequest = if ok then {
|
|
11183
|
+
key = settingKey(),
|
|
11184
|
+
payload = payload,
|
|
11185
|
+
lastPublishedAt = tick(),
|
|
11186
|
+
} else nil
|
|
10039
11187
|
return {
|
|
10040
11188
|
ok = ok,
|
|
10041
11189
|
requestId = if ok then requestId else nil,
|
|
@@ -10050,8 +11198,12 @@ local function waitForConsumption(requestId)
|
|
|
10050
11198
|
}
|
|
10051
11199
|
end
|
|
10052
11200
|
local start = tick()
|
|
11201
|
+
local pending = pendingRequest
|
|
11202
|
+
local key = if pending ~= nil and pending.payload.id == requestId then pending.key else settingKey()
|
|
10053
11203
|
while tick() - start < WAIT_FOR_CONSUMPTION_TIMEOUT_SEC do
|
|
10054
|
-
|
|
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))
|
|
10055
11207
|
if payload and payload.kind == "result" and payload.id == requestId then
|
|
10056
11208
|
return {
|
|
10057
11209
|
ok = payload.ok == true,
|
|
@@ -10059,6 +11211,19 @@ local function waitForConsumption(requestId)
|
|
|
10059
11211
|
error = payload.error,
|
|
10060
11212
|
}
|
|
10061
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
|
|
10062
11227
|
task.wait(WAIT_POLL_SEC)
|
|
10063
11228
|
end
|
|
10064
11229
|
return {
|
|
@@ -10071,6 +11236,17 @@ local function clearPending(requestId)
|
|
|
10071
11236
|
if not pluginRef then
|
|
10072
11237
|
return nil
|
|
10073
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
|
|
10074
11250
|
local myKey = settingKey()
|
|
10075
11251
|
if requestId ~= nil then
|
|
10076
11252
|
local payload = decodePayload(readSetting(myKey))
|
|
@@ -10090,7 +11266,7 @@ return {
|
|
|
10090
11266
|
]]></string>
|
|
10091
11267
|
</Properties>
|
|
10092
11268
|
</Item>
|
|
10093
|
-
<Item class="ModuleScript" referent="
|
|
11269
|
+
<Item class="ModuleScript" referent="37">
|
|
10094
11270
|
<Properties>
|
|
10095
11271
|
<string name="Name">StudioWebSocket</string>
|
|
10096
11272
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -10102,6 +11278,7 @@ local PROTOCOL_VERSION = 1
|
|
|
10102
11278
|
local INITIAL_RETRY_DELAY_SECONDS = 0.5
|
|
10103
11279
|
local MAX_RETRY_DELAY_SECONDS = 5
|
|
10104
11280
|
local SOCKET_SILENCE_TIMEOUT_SECONDS = 20
|
|
11281
|
+
local CONNECTION_STAGE_TIMEOUT_SECONDS = 20
|
|
10105
11282
|
local RESPONSE_RETENTION_SECONDS = 5 * 60
|
|
10106
11283
|
local RESPONSE_ACK_TIMEOUT_SECONDS = 120
|
|
10107
11284
|
local MAX_TERMINAL_RESPONSES = 32768
|
|
@@ -10122,7 +11299,11 @@ local socketOpen = false
|
|
|
10122
11299
|
local socketConnections = {}
|
|
10123
11300
|
local lastValidEventAt = 0
|
|
10124
11301
|
local cachedReady
|
|
10125
|
-
local
|
|
11302
|
+
local connectionWork
|
|
11303
|
+
local refreshWork
|
|
11304
|
+
local refreshRequested = false
|
|
11305
|
+
local reconnectTimer
|
|
11306
|
+
local silenceTimer
|
|
10126
11307
|
local pendingResponseBytes = 0
|
|
10127
11308
|
local inFlightRequestBytes = 0
|
|
10128
11309
|
local pendingRejectionCount = 0
|
|
@@ -10669,7 +11850,7 @@ local function dispatchRequest(request, requestBytes)
|
|
|
10669
11850
|
local dispatchOk, response = pcall(function()
|
|
10670
11851
|
return dispatchOptions.dispatchRequest(request, context)
|
|
10671
11852
|
end)
|
|
10672
|
-
local executionOutcome = if dispatchOk then handlerOutcome(response) else "error"
|
|
11853
|
+
local executionOutcome = context.executionOutcome or (if dispatchOk then handlerOutcome(response) else "error")
|
|
10673
11854
|
-- Report handler return before JSON encoding or result retention can fail.
|
|
10674
11855
|
completeProgress(request.requestId, inFlight.progress, executionOutcome)
|
|
10675
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)
|
|
@@ -10693,6 +11874,90 @@ local function reportTransport(update)
|
|
|
10693
11874
|
end)
|
|
10694
11875
|
end
|
|
10695
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
|
|
10696
11961
|
local connect
|
|
10697
11962
|
function scheduleReconnect(expectedGeneration, detail, duplicate)
|
|
10698
11963
|
if duplicate == nil then
|
|
@@ -10701,7 +11966,14 @@ function scheduleReconnect(expectedGeneration, detail, duplicate)
|
|
|
10701
11966
|
if not active or generation ~= expectedGeneration then
|
|
10702
11967
|
return nil
|
|
10703
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
|
|
10704
11974
|
generation += 1
|
|
11975
|
+
local nextGeneration = generation
|
|
11976
|
+
cancelTransportWork()
|
|
10705
11977
|
closeCurrentSocket()
|
|
10706
11978
|
reconnectAttempt += 1
|
|
10707
11979
|
local delay = if duplicate then 1 else retryDelay(reconnectAttempt)
|
|
@@ -10711,8 +11983,11 @@ function scheduleReconnect(expectedGeneration, detail, duplicate)
|
|
|
10711
11983
|
retryDelay = delay,
|
|
10712
11984
|
detail = detail,
|
|
10713
11985
|
})
|
|
10714
|
-
|
|
10715
|
-
|
|
11986
|
+
if not active or generation ~= nextGeneration then
|
|
11987
|
+
return nil
|
|
11988
|
+
end
|
|
11989
|
+
reconnectTimer = task.delay(delay, function()
|
|
11990
|
+
reconnectTimer = nil
|
|
10716
11991
|
if active and generation == nextGeneration then
|
|
10717
11992
|
connect(nextGeneration)
|
|
10718
11993
|
end
|
|
@@ -10720,10 +11995,11 @@ function scheduleReconnect(expectedGeneration, detail, duplicate)
|
|
|
10720
11995
|
end
|
|
10721
11996
|
local function watchForSilence(expectedGeneration, expectedClient)
|
|
10722
11997
|
local elapsed = tick() - lastValidEventAt
|
|
10723
|
-
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()
|
|
10724
11999
|
if not active or generation ~= expectedGeneration or socketClient ~= expectedClient then
|
|
10725
12000
|
return nil
|
|
10726
12001
|
end
|
|
12002
|
+
silenceTimer = nil
|
|
10727
12003
|
local silentFor = tick() - lastValidEventAt
|
|
10728
12004
|
if silentFor >= SOCKET_SILENCE_TIMEOUT_SECONDS then
|
|
10729
12005
|
scheduleReconnect(expectedGeneration, `WebSocket silent for {math.floor(silentFor)} seconds`)
|
|
@@ -10790,13 +12066,18 @@ local function parseReadyResponse(body)
|
|
|
10790
12066
|
transportToken = value.transportToken,
|
|
10791
12067
|
}
|
|
10792
12068
|
end
|
|
10793
|
-
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
|
|
10794
12075
|
local instanceId = PluginSession.getInstanceId()
|
|
10795
12076
|
local multiplayerGroupId = PluginSession.getMultiplayerGroupId()
|
|
10796
12077
|
local transportRole = PluginSession.getRole()
|
|
10797
12078
|
local readyUrl = `{currentOptions.serverUrl}/ready`
|
|
10798
12079
|
local readyPayload = PluginSession.createReadyPayload(PluginSession.peerId, transportRole, instanceId, multiplayerGroupId)
|
|
10799
|
-
if not
|
|
12080
|
+
if not ownsWork(work) then
|
|
10800
12081
|
return nil
|
|
10801
12082
|
end
|
|
10802
12083
|
local readyOk, readyResult = pcall(function()
|
|
@@ -10809,7 +12090,7 @@ local function registerReady(currentOptions, expectedGeneration, reconnectOnFail
|
|
|
10809
12090
|
Body = HttpService:JSONEncode(readyPayload),
|
|
10810
12091
|
})
|
|
10811
12092
|
end)
|
|
10812
|
-
if not
|
|
12093
|
+
if not ownsWork(work) then
|
|
10813
12094
|
return nil
|
|
10814
12095
|
end
|
|
10815
12096
|
local readyLogKey = `{currentOptions.serverUrl}|{PluginSession.peerId}`
|
|
@@ -10839,7 +12120,7 @@ local function registerReady(currentOptions, expectedGeneration, reconnectOnFail
|
|
|
10839
12120
|
invokeCallback("WebSocket ready", function()
|
|
10840
12121
|
return currentOptions.onReady(readyData)
|
|
10841
12122
|
end)
|
|
10842
|
-
return readyData
|
|
12123
|
+
return if ownsWork(work) then readyData else nil
|
|
10843
12124
|
end
|
|
10844
12125
|
local function credentialsRejected(statusCode)
|
|
10845
12126
|
return statusCode == 401 or statusCode == 403 or statusCode == 404
|
|
@@ -10849,14 +12130,28 @@ function connect(expectedGeneration)
|
|
|
10849
12130
|
if not active or generation ~= expectedGeneration or currentOptions == nil then
|
|
10850
12131
|
return nil
|
|
10851
12132
|
end
|
|
10852
|
-
|
|
10853
|
-
|
|
10854
|
-
|
|
10855
|
-
|
|
10856
|
-
|
|
10857
|
-
|
|
10858
|
-
|
|
10859
|
-
|
|
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
|
|
10860
12155
|
return nil
|
|
10861
12156
|
end
|
|
10862
12157
|
local socketBaseUrl = string.gsub(currentOptions.serverUrl, "^http", "ws")
|
|
@@ -10869,28 +12164,32 @@ function connect(expectedGeneration)
|
|
|
10869
12164
|
})
|
|
10870
12165
|
end)
|
|
10871
12166
|
if not createOk then
|
|
10872
|
-
scheduleReconnect(expectedGeneration,
|
|
12167
|
+
scheduleReconnect(expectedGeneration, "Failed to create WebSocket")
|
|
10873
12168
|
return nil
|
|
10874
12169
|
end
|
|
10875
|
-
if not
|
|
12170
|
+
if not ownsWork(work) then
|
|
10876
12171
|
pcall(function()
|
|
10877
12172
|
return createdClient:Close()
|
|
10878
12173
|
end)
|
|
10879
12174
|
return nil
|
|
10880
12175
|
end
|
|
10881
12176
|
socketClient = createdClient
|
|
10882
|
-
|
|
12177
|
+
-- Attach incrementally so a failed subscription cannot leak earlier ones.
|
|
12178
|
+
local _socketConnections = socketConnections
|
|
12179
|
+
local _arg0 = createdClient.Opened:Connect(function(statusCode, _headers)
|
|
10883
12180
|
if not active or generation ~= expectedGeneration or socketClient ~= createdClient then
|
|
10884
12181
|
return nil
|
|
10885
12182
|
end
|
|
12183
|
+
if socketOpen then
|
|
12184
|
+
return nil
|
|
12185
|
+
end
|
|
10886
12186
|
if statusCode ~= 101 and (statusCode < 200 or statusCode >= 300) then
|
|
10887
|
-
|
|
10888
|
-
cachedReady = nil
|
|
10889
|
-
end
|
|
12187
|
+
cachedReady = nil
|
|
10890
12188
|
scheduleReconnect(expectedGeneration, `WebSocket opened with HTTP {statusCode}`)
|
|
10891
12189
|
return nil
|
|
10892
12190
|
end
|
|
10893
12191
|
socketOpen = true
|
|
12192
|
+
finishWork(work)
|
|
10894
12193
|
lastValidEventAt = tick()
|
|
10895
12194
|
reconnectAttempt = 0
|
|
10896
12195
|
reportTransport({
|
|
@@ -10898,8 +12197,18 @@ function connect(expectedGeneration)
|
|
|
10898
12197
|
attempt = 0,
|
|
10899
12198
|
retryDelay = 0,
|
|
10900
12199
|
})
|
|
12200
|
+
if not active or generation ~= expectedGeneration or socketClient ~= createdClient then
|
|
12201
|
+
return nil
|
|
12202
|
+
end
|
|
12203
|
+
watchForSilence(expectedGeneration, createdClient)
|
|
10901
12204
|
resumePendingResponses()
|
|
10902
|
-
|
|
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)
|
|
10903
12212
|
if not active or generation ~= expectedGeneration or socketClient ~= createdClient then
|
|
10904
12213
|
return nil
|
|
10905
12214
|
end
|
|
@@ -10937,27 +12246,41 @@ function connect(expectedGeneration)
|
|
|
10937
12246
|
invokeCallback("WebSocket status", function()
|
|
10938
12247
|
return currentOptions.onStatus(event)
|
|
10939
12248
|
end)
|
|
12249
|
+
if not active or generation ~= expectedGeneration or socketClient ~= createdClient then
|
|
12250
|
+
return nil
|
|
12251
|
+
end
|
|
10940
12252
|
if not event.knownPeer then
|
|
10941
12253
|
cachedReady = nil
|
|
10942
12254
|
scheduleReconnect(expectedGeneration, "WebSocket session is no longer registered")
|
|
10943
12255
|
end
|
|
10944
12256
|
end
|
|
10945
|
-
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)
|
|
10946
12261
|
if not active or generation ~= expectedGeneration or socketClient ~= createdClient then
|
|
10947
12262
|
return nil
|
|
10948
12263
|
end
|
|
10949
|
-
if credentialsRejected(statusCode) then
|
|
12264
|
+
if not socketOpen or credentialsRejected(statusCode) then
|
|
10950
12265
|
cachedReady = nil
|
|
10951
12266
|
end
|
|
10952
|
-
|
|
10953
|
-
|
|
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()
|
|
10954
12273
|
if not active or generation ~= expectedGeneration or socketClient ~= createdClient then
|
|
10955
12274
|
return nil
|
|
10956
12275
|
end
|
|
12276
|
+
if not socketOpen then
|
|
12277
|
+
cachedReady = nil
|
|
12278
|
+
end
|
|
10957
12279
|
scheduleReconnect(expectedGeneration, "WebSocket closed")
|
|
10958
|
-
end)
|
|
10959
|
-
|
|
10960
|
-
|
|
12280
|
+
end)
|
|
12281
|
+
table.insert(_socketConnections_3, _arg0_3)
|
|
12282
|
+
end, function(detail)
|
|
12283
|
+
return scheduleReconnect(expectedGeneration, detail)
|
|
10961
12284
|
end)
|
|
10962
12285
|
end
|
|
10963
12286
|
local stop
|
|
@@ -10972,20 +12295,42 @@ local function start(newOptions)
|
|
|
10972
12295
|
generation += 1
|
|
10973
12296
|
connect(generation)
|
|
10974
12297
|
end
|
|
10975
|
-
|
|
12298
|
+
function flushRefresh()
|
|
10976
12299
|
local currentOptions = options
|
|
10977
|
-
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
|
|
10978
12301
|
return nil
|
|
10979
12302
|
end
|
|
10980
|
-
|
|
10981
|
-
--
|
|
10982
|
-
|
|
10983
|
-
local
|
|
10984
|
-
|
|
10985
|
-
|
|
10986
|
-
|
|
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()
|
|
10987
12325
|
end)
|
|
10988
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
|
|
10989
12334
|
-- EndTest may tear down this VM without an Unloading callback. Release the
|
|
10990
12335
|
-- native socket before yielding to unregister; a failed EndTest can resume.
|
|
10991
12336
|
local function suspendForShutdown()
|
|
@@ -10996,6 +12341,8 @@ local function suspendForShutdown()
|
|
|
10996
12341
|
active = false
|
|
10997
12342
|
shutdownSuspended = true
|
|
10998
12343
|
generation += 1
|
|
12344
|
+
cancelTransportWork()
|
|
12345
|
+
refreshRequested = false
|
|
10999
12346
|
reconnectAttempt = 0
|
|
11000
12347
|
closeCurrentSocket()
|
|
11001
12348
|
cachedReady = nil
|
|
@@ -11019,6 +12366,8 @@ function stop()
|
|
|
11019
12366
|
active = false
|
|
11020
12367
|
shutdownSuspended = false
|
|
11021
12368
|
generation += 1
|
|
12369
|
+
cancelTransportWork()
|
|
12370
|
+
refreshRequested = false
|
|
11022
12371
|
for _, inFlight in inFlightRequests do
|
|
11023
12372
|
inFlight.cancelled = true
|
|
11024
12373
|
end
|
|
@@ -11041,7 +12390,7 @@ return {
|
|
|
11041
12390
|
]]></string>
|
|
11042
12391
|
</Properties>
|
|
11043
12392
|
</Item>
|
|
11044
|
-
<Item class="ModuleScript" referent="
|
|
12393
|
+
<Item class="ModuleScript" referent="38">
|
|
11045
12394
|
<Properties>
|
|
11046
12395
|
<string name="Name">TopologyId</string>
|
|
11047
12396
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -11086,7 +12435,7 @@ return {
|
|
|
11086
12435
|
]]></string>
|
|
11087
12436
|
</Properties>
|
|
11088
12437
|
</Item>
|
|
11089
|
-
<Item class="ModuleScript" referent="
|
|
12438
|
+
<Item class="ModuleScript" referent="39">
|
|
11090
12439
|
<Properties>
|
|
11091
12440
|
<string name="Name">UI</string>
|
|
11092
12441
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -11349,12 +12698,13 @@ local function init(pluginRef)
|
|
|
11349
12698
|
end
|
|
11350
12699
|
end)
|
|
11351
12700
|
local statusRow = Instance.new("Frame")
|
|
11352
|
-
statusRow.Size = UDim2.new(1, 0, 0,
|
|
12701
|
+
statusRow.Size = UDim2.new(1, 0, 0, 0)
|
|
12702
|
+
statusRow.AutomaticSize = Enum.AutomaticSize.Y
|
|
11353
12703
|
statusRow.BackgroundTransparency = 1
|
|
11354
12704
|
statusRow.LayoutOrder = 2
|
|
11355
12705
|
statusRow.Parent = card
|
|
11356
12706
|
local statusLabel = Instance.new("TextLabel")
|
|
11357
|
-
statusLabel.Size = UDim2.new(1, 0,
|
|
12707
|
+
statusLabel.Size = UDim2.new(1, 0, 0, 18)
|
|
11358
12708
|
statusLabel.BackgroundTransparency = 1
|
|
11359
12709
|
statusLabel.Text = "Disconnected"
|
|
11360
12710
|
statusLabel.TextColor3 = C.red
|
|
@@ -11362,17 +12712,20 @@ local function init(pluginRef)
|
|
|
11362
12712
|
statusLabel.Font = Enum.Font.GothamBold
|
|
11363
12713
|
statusLabel.TextXAlignment = Enum.TextXAlignment.Left
|
|
11364
12714
|
statusLabel.TextWrapped = true
|
|
12715
|
+
statusLabel.RichText = false
|
|
11365
12716
|
statusLabel.Parent = statusRow
|
|
11366
12717
|
local detailStatusLabel = Instance.new("TextLabel")
|
|
11367
|
-
detailStatusLabel.Size = UDim2.new(
|
|
11368
|
-
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
|
|
11369
12721
|
detailStatusLabel.BackgroundTransparency = 1
|
|
11370
12722
|
detailStatusLabel.Text = "HTTP: X MCP: X"
|
|
11371
12723
|
detailStatusLabel.TextColor3 = C.muted
|
|
11372
12724
|
detailStatusLabel.TextSize = 9
|
|
11373
12725
|
detailStatusLabel.Font = Enum.Font.GothamMedium
|
|
11374
|
-
detailStatusLabel.TextXAlignment = Enum.TextXAlignment.
|
|
12726
|
+
detailStatusLabel.TextXAlignment = Enum.TextXAlignment.Left
|
|
11375
12727
|
detailStatusLabel.TextWrapped = true
|
|
12728
|
+
detailStatusLabel.RichText = false
|
|
11376
12729
|
detailStatusLabel.Parent = statusRow
|
|
11377
12730
|
local stepsFrame = Instance.new("Frame")
|
|
11378
12731
|
stepsFrame.Size = UDim2.new(1, 0, 0, 0)
|
|
@@ -11509,13 +12862,14 @@ local function updateUIState()
|
|
|
11509
12862
|
return nil
|
|
11510
12863
|
end
|
|
11511
12864
|
local el = elements
|
|
12865
|
+
local diagnostics = State.getTransportDiagnostics(tick())
|
|
11512
12866
|
if not conn.isActive then
|
|
11513
|
-
el.statusLabel.Text =
|
|
12867
|
+
el.statusLabel.Text = diagnostics.status
|
|
11514
12868
|
el.statusLabel.TextColor3 = C.muted
|
|
11515
12869
|
el.statusIndicator.BackgroundColor3 = C.red
|
|
11516
12870
|
el.statusPulse.BackgroundColor3 = C.red
|
|
11517
12871
|
el.statusText.Text = "OFFLINE"
|
|
11518
|
-
el.detailStatusLabel.Text =
|
|
12872
|
+
el.detailStatusLabel.Text = diagnostics.detail
|
|
11519
12873
|
el.detailStatusLabel.TextColor3 = C.muted
|
|
11520
12874
|
stopPulseAnimation()
|
|
11521
12875
|
el.step1Dot.BackgroundColor3 = C.gray
|
|
@@ -11538,12 +12892,12 @@ local function updateUIState()
|
|
|
11538
12892
|
el.urlInput.TextEditable = false
|
|
11539
12893
|
el.urlInput.BackgroundColor3 = C.card
|
|
11540
12894
|
if conn.lastHttpOk and conn.lastMcpOk then
|
|
11541
|
-
el.statusLabel.Text =
|
|
12895
|
+
el.statusLabel.Text = diagnostics.status
|
|
11542
12896
|
el.statusLabel.TextColor3 = Color3.fromRGB(34, 197, 94)
|
|
11543
12897
|
el.statusIndicator.BackgroundColor3 = Color3.fromRGB(34, 197, 94)
|
|
11544
12898
|
el.statusPulse.BackgroundColor3 = Color3.fromRGB(34, 197, 94)
|
|
11545
12899
|
el.statusText.Text = "ONLINE"
|
|
11546
|
-
el.detailStatusLabel.Text =
|
|
12900
|
+
el.detailStatusLabel.Text = diagnostics.detail
|
|
11547
12901
|
el.detailStatusLabel.TextColor3 = Color3.fromRGB(34, 197, 94)
|
|
11548
12902
|
el.step1Dot.BackgroundColor3 = Color3.fromRGB(34, 197, 94)
|
|
11549
12903
|
el.step1Label.Text = "HTTP server (OK)"
|
|
@@ -11554,12 +12908,12 @@ local function updateUIState()
|
|
|
11554
12908
|
el.troubleshootLabel.Visible = false
|
|
11555
12909
|
stopPulseAnimation()
|
|
11556
12910
|
elseif conn.lastHttpOk and not conn.lastMcpOk then
|
|
11557
|
-
el.statusLabel.Text =
|
|
12911
|
+
el.statusLabel.Text = diagnostics.status
|
|
11558
12912
|
el.statusLabel.TextColor3 = Color3.fromRGB(245, 158, 11)
|
|
11559
12913
|
el.statusIndicator.BackgroundColor3 = Color3.fromRGB(245, 158, 11)
|
|
11560
12914
|
el.statusPulse.BackgroundColor3 = Color3.fromRGB(245, 158, 11)
|
|
11561
12915
|
el.statusText.Text = "WAITING"
|
|
11562
|
-
el.detailStatusLabel.Text =
|
|
12916
|
+
el.detailStatusLabel.Text = diagnostics.detail
|
|
11563
12917
|
el.detailStatusLabel.TextColor3 = Color3.fromRGB(245, 158, 11)
|
|
11564
12918
|
el.step1Dot.BackgroundColor3 = Color3.fromRGB(34, 197, 94)
|
|
11565
12919
|
el.step1Label.Text = "HTTP server (OK)"
|
|
@@ -11570,53 +12924,21 @@ local function updateUIState()
|
|
|
11570
12924
|
local elapsed = if conn.mcpWaitStartTime ~= nil then tick() - conn.mcpWaitStartTime else 0
|
|
11571
12925
|
el.troubleshootLabel.Visible = elapsed > 8
|
|
11572
12926
|
startPulseAnimation()
|
|
11573
|
-
elseif conn.consecutiveFailures >= conn.maxFailuresBeforeError then
|
|
11574
|
-
el.statusLabel.Text = "Server unavailable"
|
|
11575
|
-
el.statusLabel.TextColor3 = Color3.fromRGB(239, 68, 68)
|
|
11576
|
-
el.statusIndicator.BackgroundColor3 = Color3.fromRGB(239, 68, 68)
|
|
11577
|
-
el.statusPulse.BackgroundColor3 = Color3.fromRGB(239, 68, 68)
|
|
11578
|
-
el.statusText.Text = "ERROR"
|
|
11579
|
-
el.detailStatusLabel.Text = "HTTP: X MCP: X"
|
|
11580
|
-
el.detailStatusLabel.TextColor3 = Color3.fromRGB(239, 68, 68)
|
|
11581
|
-
el.step1Dot.BackgroundColor3 = Color3.fromRGB(239, 68, 68)
|
|
11582
|
-
el.step1Label.Text = "HTTP server (error)"
|
|
11583
|
-
el.step2Dot.BackgroundColor3 = Color3.fromRGB(239, 68, 68)
|
|
11584
|
-
el.step2Label.Text = "MCP bridge (error)"
|
|
11585
|
-
el.step3Dot.BackgroundColor3 = Color3.fromRGB(239, 68, 68)
|
|
11586
|
-
el.step3Label.Text = "Commands (error)"
|
|
11587
|
-
el.troubleshootLabel.Visible = false
|
|
11588
|
-
stopPulseAnimation()
|
|
11589
|
-
elseif conn.consecutiveFailures > 5 then
|
|
11590
|
-
local waitTime = math.ceil(conn.currentRetryDelay)
|
|
11591
|
-
el.statusLabel.Text = `Retrying ({waitTime}s)`
|
|
11592
|
-
el.statusLabel.TextColor3 = Color3.fromRGB(245, 158, 11)
|
|
11593
|
-
el.statusIndicator.BackgroundColor3 = Color3.fromRGB(245, 158, 11)
|
|
11594
|
-
el.statusPulse.BackgroundColor3 = Color3.fromRGB(245, 158, 11)
|
|
11595
|
-
el.statusText.Text = "RETRY"
|
|
11596
|
-
el.detailStatusLabel.Text = "HTTP: ... MCP: ..."
|
|
11597
|
-
el.detailStatusLabel.TextColor3 = Color3.fromRGB(245, 158, 11)
|
|
11598
|
-
el.step1Dot.BackgroundColor3 = Color3.fromRGB(245, 158, 11)
|
|
11599
|
-
el.step1Label.Text = "HTTP server (retrying...)"
|
|
11600
|
-
el.step2Dot.BackgroundColor3 = Color3.fromRGB(245, 158, 11)
|
|
11601
|
-
el.step2Label.Text = "MCP bridge (retrying...)"
|
|
11602
|
-
el.step3Dot.BackgroundColor3 = Color3.fromRGB(245, 158, 11)
|
|
11603
|
-
el.step3Label.Text = "Commands (retrying...)"
|
|
11604
|
-
el.troubleshootLabel.Visible = false
|
|
11605
|
-
startPulseAnimation()
|
|
11606
12927
|
else
|
|
11607
|
-
|
|
12928
|
+
local retrying = conn.nextRetryAt ~= nil
|
|
12929
|
+
el.statusLabel.Text = diagnostics.status
|
|
11608
12930
|
el.statusLabel.TextColor3 = C.yellow
|
|
11609
12931
|
el.statusIndicator.BackgroundColor3 = C.yellow
|
|
11610
12932
|
el.statusPulse.BackgroundColor3 = C.yellow
|
|
11611
|
-
el.statusText.Text = "CONNECTING"
|
|
11612
|
-
el.detailStatusLabel.Text =
|
|
11613
|
-
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
|
|
11614
12936
|
el.step1Dot.BackgroundColor3 = C.yellow
|
|
11615
|
-
el.step1Label.Text = "
|
|
11616
|
-
el.step2Dot.BackgroundColor3 = C.
|
|
11617
|
-
el.step2Label.Text = "MCP
|
|
11618
|
-
el.step3Dot.BackgroundColor3 = C.
|
|
11619
|
-
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)"
|
|
11620
12942
|
el.troubleshootLabel.Visible = false
|
|
11621
12943
|
startPulseAnimation()
|
|
11622
12944
|
end
|
|
@@ -11638,7 +12960,7 @@ return {
|
|
|
11638
12960
|
]]></string>
|
|
11639
12961
|
</Properties>
|
|
11640
12962
|
</Item>
|
|
11641
|
-
<Item class="ModuleScript" referent="
|
|
12963
|
+
<Item class="ModuleScript" referent="40">
|
|
11642
12964
|
<Properties>
|
|
11643
12965
|
<string name="Name">Utils</string>
|
|
11644
12966
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -12479,11 +13801,11 @@ return {
|
|
|
12479
13801
|
</Properties>
|
|
12480
13802
|
</Item>
|
|
12481
13803
|
</Item>
|
|
12482
|
-
<Item class="Folder" referent="
|
|
13804
|
+
<Item class="Folder" referent="45">
|
|
12483
13805
|
<Properties>
|
|
12484
13806
|
<string name="Name">include</string>
|
|
12485
13807
|
</Properties>
|
|
12486
|
-
<Item class="ModuleScript" referent="
|
|
13808
|
+
<Item class="ModuleScript" referent="41">
|
|
12487
13809
|
<Properties>
|
|
12488
13810
|
<string name="Name">LibMP</string>
|
|
12489
13811
|
<string name="Source"><![CDATA[-- =============================================================================
|
|
@@ -168867,7 +170189,7 @@ return LibMP
|
|
|
168867
170189
|
]]></string>
|
|
168868
170190
|
</Properties>
|
|
168869
170191
|
</Item>
|
|
168870
|
-
<Item class="ModuleScript" referent="
|
|
170192
|
+
<Item class="ModuleScript" referent="42">
|
|
168871
170193
|
<Properties>
|
|
168872
170194
|
<string name="Name">Promise</string>
|
|
168873
170195
|
<string name="Source"><![CDATA[--[[
|
|
@@ -170941,7 +172263,7 @@ return Promise
|
|
|
170941
172263
|
]]></string>
|
|
170942
172264
|
</Properties>
|
|
170943
172265
|
</Item>
|
|
170944
|
-
<Item class="ModuleScript" referent="
|
|
172266
|
+
<Item class="ModuleScript" referent="43">
|
|
170945
172267
|
<Properties>
|
|
170946
172268
|
<string name="Name">RuntimeLib</string>
|
|
170947
172269
|
<string name="Source"><![CDATA[local Promise = require(script.Parent.Promise)
|
|
@@ -171208,15 +172530,15 @@ return TS
|
|
|
171208
172530
|
</Properties>
|
|
171209
172531
|
</Item>
|
|
171210
172532
|
</Item>
|
|
171211
|
-
<Item class="Folder" referent="
|
|
172533
|
+
<Item class="Folder" referent="46">
|
|
171212
172534
|
<Properties>
|
|
171213
172535
|
<string name="Name">node_modules</string>
|
|
171214
172536
|
</Properties>
|
|
171215
|
-
<Item class="Folder" referent="
|
|
172537
|
+
<Item class="Folder" referent="47">
|
|
171216
172538
|
<Properties>
|
|
171217
172539
|
<string name="Name">@rbxts</string>
|
|
171218
172540
|
</Properties>
|
|
171219
|
-
<Item class="ModuleScript" referent="
|
|
172541
|
+
<Item class="ModuleScript" referent="44">
|
|
171220
172542
|
<Properties>
|
|
171221
172543
|
<string name="Name">services</string>
|
|
171222
172544
|
<string name="Source"><![CDATA[return setmetatable({}, {
|