@chrrxs/robloxstudio-mcp-inspector 3.1.6 → 3.2.1

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.
@@ -17,6 +17,7 @@ local ensureRuntimeBridgeInstalled = _EvalBridges.ensureRuntimeBridgeInstalled
17
17
  local RuntimeLogBuffer = TS.import(script, script, "modules", "RuntimeLogBuffer")
18
18
  local StopPlayMonitor = TS.import(script, script, "modules", "StopPlayMonitor")
19
19
  local StudioWebSocket = TS.import(script, script, "modules", "StudioWebSocket")
20
+ local PluginSession = TS.import(script, script, "modules", "PluginSession")
20
21
  local BreakpointHandlers = TS.import(script, script, "modules", "handlers", "BreakpointHandlers")
21
22
  local RenderMonitor = TS.import(script, script, "modules", "RenderMonitor")
22
23
  -- Track render-loop liveness so input/screenshot tools can report "window
@@ -33,6 +34,14 @@ RuntimeLogBuffer.install()
33
34
  StopPlayMonitor.init(plugin)
34
35
  BreakpointHandlers.init(plugin)
35
36
  ServerUrlSettings.init(plugin)
37
+ -- Before anything reads place keys: an edit session removes MCP attributes
38
+ -- that older plugin versions saved into the place, and hands its topology to
39
+ -- playtest DataModels through plugin settings instead.
40
+ PluginSession.init(plugin, {
41
+ getServerUrl = function()
42
+ return State.getActiveConnection().serverUrl
43
+ end,
44
+ })
36
45
  local startupRole = ClientBroker.forkRole()
37
46
  local function applyRememberedServerUrl()
38
47
  if startupRole == "client" then
@@ -55,8 +64,8 @@ applyRememberedServerUrl()
55
64
  -- down their VM. Close the persistent WebStreamClient while BindToClose still
56
65
  -- gives this server peer a live execution context; otherwise each play cycle
57
66
  -- can retain one native WebSocket until Studio itself is restarted. The
58
- -- transport releases that native socket before yielding to unregister the
59
- -- logical server peer.
67
+ -- transport unregisters the server peer on that socket and waits at most two
68
+ -- seconds for the server to close it, so no HTTP request can hold up shutdown.
60
69
  if startupRole == "server" then
61
70
  game:BindToClose(StudioWebSocket.suspendForShutdown)
62
71
  end
@@ -95,8 +104,20 @@ plugin.Unloading:Connect(function()
95
104
  Communication.deactivateAll()
96
105
  end)
97
106
  UI.updateUIState()
98
- Communication.checkForUpdates()
107
+ -- Every DataModel shares Studio's five plugin HTTP request slots, and a
108
+ -- request has no time limit. Check for updates once, from the edit DataModel,
109
+ -- instead of again in every play server and client.
110
+ if startupRole == "edit" then
111
+ Communication.checkForUpdates()
112
+ end
99
113
  task.delay(TOOLBAR_REGISTRATION_DELAY_SECONDS, registerToolbarButton)
114
+ --* Runs one startup step so that its failure cannot skip the steps after it.
115
+ local function runStartupStep(description, step)
116
+ local ok, err = pcall(step)
117
+ if not ok then
118
+ warn(`[robloxstudio-mcp] {description} failed: {err}`)
119
+ end
120
+ end
100
121
  -- Auto-activate per Peer. Runtime plugin VMs can load before their first
101
122
  -- Heartbeat; task.delay() would then wait behind the very multiplayer startup
102
123
  -- that needs this Peer to register. Start runtime initialization immediately,
@@ -111,14 +132,25 @@ local function autoActivatePeer()
111
132
  warn(`[robloxstudio-mcp] Runtime eval bridge install failed: {result.error}`)
112
133
  end
113
134
  end
135
+ if role == "server" then
136
+ -- Yields until this playtest server knows which edit session (or
137
+ -- multiplayer group) it belongs to, so it registers under that identity.
138
+ -- On failure the server still registers, under its own identity.
139
+ runStartupStep("Playtest topology resolution", function()
140
+ return PluginSession.resolveRuntimeTopology()
141
+ end)
142
+ end
114
143
  if role == "edit" or role == "server" then
115
- local activationOk, activationError = pcall(function()
144
+ runStartupStep(`Automatic {role} Peer activation`, function()
116
145
  local conn = State.getActiveConnection()
117
146
  if not conn.isActive then
118
147
  if role == "server" then
119
- local _condition = ServerUrlSettings.readServerUrl()
148
+ local _condition = PluginSession.getInheritedServerUrl()
120
149
  if _condition == nil then
121
- _condition = ClientBroker.DEFAULT_MCP_URL
150
+ _condition = ServerUrlSettings.readServerUrl()
151
+ if _condition == nil then
152
+ _condition = ClientBroker.DEFAULT_MCP_URL
153
+ end
122
154
  end
123
155
  local inheritedServerUrl = _condition
124
156
  conn.serverUrl = ServerUrlSettings.normalizeServerUrl(inheritedServerUrl)
@@ -138,19 +170,20 @@ local function autoActivatePeer()
138
170
  Communication.activatePlugin()
139
171
  end
140
172
  end)
141
- if not activationOk then
142
- warn(`[robloxstudio-mcp] Automatic {role} Peer activation failed: {activationError}`)
143
- end
144
173
  end
145
174
  if role == "server" then
146
- ClientBroker.setupServerBroker()
175
+ runStartupStep("Client broker setup", function()
176
+ return ClientBroker.setupServerBroker()
177
+ end)
147
178
  -- The play-server DM is the only one where StudioTestService:EndTest is
148
179
  -- legal, so the stop-play monitor lives here. It consumes tokenized
149
180
  -- stop requests from plugin settings and acknowledges EndTest results.
150
- StopPlayMonitor.startMonitor({
151
- beforeEndTest = StudioWebSocket.suspendForShutdown,
152
- afterEndTestFailure = StudioWebSocket.resumeAfterShutdownFailure,
153
- })
181
+ runStartupStep("Stop-play monitor startup", function()
182
+ return StopPlayMonitor.startMonitor({
183
+ beforeEndTest = StudioWebSocket.suspendForShutdown,
184
+ afterEndTestFailure = StudioWebSocket.resumeAfterShutdownFailure,
185
+ })
186
+ end)
154
187
  elseif role == "client" then
155
188
  ClientBroker.setupClientBroker()
156
189
  end
@@ -1942,9 +1975,9 @@ local function computeBridgeStamp()
1942
1975
  for i = 1, #combined do
1943
1976
  h = (h * 33 + (string.byte(combined, i))) % 2147483647
1944
1977
  end
1945
- -- "3.1.6" is replaced with the package version at package time
1978
+ -- "3.2.1" is replaced with the package version at package time
1946
1979
  -- (scripts/build-plugin.mjs injectVersion), so a release bump also restamps.
1947
- return `{tostring(h)}-3.1.6`
1980
+ return `{tostring(h)}-3.2.1`
1948
1981
  end
1949
1982
  local BRIDGE_STAMP = computeBridgeStamp()
1950
1983
  local function setSource(scriptInst, source)
@@ -2549,13 +2582,12 @@ return {
2549
2582
  local TS = require(script.Parent.Parent.Parent.include.RuntimeLib)
2550
2583
  local Utils = TS.import(script, script.Parent.Parent, "Utils")
2551
2584
  local PeerRole = TS.import(script, script.Parent.Parent, "PeerRole")
2585
+ local PluginSession = TS.import(script, script.Parent.Parent, "PluginSession")
2552
2586
  local _binding = Utils
2553
2587
  local getInstanceByPath = _binding.getInstanceByPath
2554
2588
  local HttpService = game:GetService("HttpService")
2555
- local ServerStorage = game:GetService("ServerStorage")
2556
2589
  local LOG_PREFIX = "Breakpoint"
2557
2590
  local REGISTRY_KEY_PREFIX = "MCP_BREAKPOINTS_V1_"
2558
- local MCP_PLACE_ID_ATTRIBUTE = "__MCPPlaceId"
2559
2591
  local pluginRef
2560
2592
  local loadedRegistryKey
2561
2593
  local loadedRegistryFromSettings = false
@@ -2566,20 +2598,6 @@ end
2566
2598
  local function breakpointKey(scriptPath, line)
2567
2599
  return `{scriptPath}:{line}`
2568
2600
  end
2569
- local function computePlaceKey()
2570
- if game.PlaceId ~= 0 then
2571
- return `place:{tostring(game.PlaceId)}`
2572
- end
2573
- local existing = ServerStorage:GetAttribute(MCP_PLACE_ID_ATTRIBUTE)
2574
- if type(existing) == "string" and existing ~= "" then
2575
- return `anon:{existing}`
2576
- end
2577
- local fresh = HttpService:GenerateGUID(false)
2578
- pcall(function()
2579
- return ServerStorage:SetAttribute(MCP_PLACE_ID_ATTRIBUTE, fresh)
2580
- end)
2581
- return `anon:{fresh}`
2582
- end
2583
2601
  local function detectRole()
2584
2602
  return PeerRole.detect()
2585
2603
  end
@@ -2592,10 +2610,11 @@ local function requestedRole(requestData)
2592
2610
  return if _condition then requestData.__mcp_target_role else detectRole()
2593
2611
  end
2594
2612
  local function registryScope(requestData)
2595
- local placeKey = computePlaceKey()
2596
2613
  local role = requestedRole(requestData)
2614
+ local legacyPlaceKey = PluginSession.getLegacyPlaceKey()
2597
2615
  return {
2598
- key = `{REGISTRY_KEY_PREFIX}{placeKey}:{role}`,
2616
+ key = `{REGISTRY_KEY_PREFIX}{PluginSession.getPlaceKey()}:{role}`,
2617
+ legacyKey = if legacyPlaceKey ~= nil then `{REGISTRY_KEY_PREFIX}{legacyPlaceKey}:{role}` else nil,
2599
2618
  }
2600
2619
  end
2601
2620
  local function readSetting(key)
@@ -2654,7 +2673,11 @@ local function loadRegistry(requestData)
2654
2673
  return scope
2655
2674
  end
2656
2675
  loadedRegistryFromSettings = true
2657
- local stored = readSetting(scope.key)
2676
+ local _condition = readSetting(scope.key)
2677
+ if _condition == nil then
2678
+ _condition = (if scope.legacyKey ~= nil then readSetting(scope.legacyKey) else nil)
2679
+ end
2680
+ local stored = _condition
2658
2681
  if stored == nil then
2659
2682
  return scope
2660
2683
  end
@@ -5474,6 +5497,18 @@ local function captureMicroProfiler(requestData)
5474
5497
  local threadIds = session:FetchThreadIds() or {}
5475
5498
  local frameMin = session:GetFrameIdMin()
5476
5499
  local frameMax = session:GetFrameIdMax()
5500
+ -- Frame IDs start at 1. Studio cuts a frame only when it renders, so a capture
5501
+ -- without frames means nothing was drawn, not that the workload was idle.
5502
+ if frameMax <= 0 then
5503
+ session:Dispose()
5504
+ return {
5505
+ error = "micro_profiler_no_frames",
5506
+ message = "Studio rendered no frames during the capture, so MicroProfiler recorded no data. Studio draws frames only while its window can be displayed, which stops when the display turns off (for example after the power-saving timeout) or Studio is minimized. Wake the display or restore Studio, then retry.",
5507
+ duration_ms = durationMs,
5508
+ buffer_bytes = buffer.len(snapshot),
5509
+ backend = backend,
5510
+ }
5511
+ end
5477
5512
  local startFrame = math.max(frameMin, frameMax - frameWindow + 1)
5478
5513
  local framesConsidered = if frameMax >= startFrame then frameMax - startFrame + 1 else 0
5479
5514
  local groupCache = {}
@@ -9033,15 +9068,15 @@ local function startPlaytest(requestData)
9033
9068
  }
9034
9069
  end
9035
9070
  testRunning = true
9036
- local topologyMarkerToken = PluginSession.prepareSharedTopology()
9071
+ local preparedTest = PluginSession.prepareSoloTest()
9037
9072
  task.spawn(function()
9038
9073
  local ok, result = pcall(function()
9039
9074
  if mode == "play" then
9040
- return StudioTestService:ExecutePlayModeAsync({})
9075
+ return StudioTestService:ExecutePlayModeAsync(preparedTest.testArgs)
9041
9076
  end
9042
- return StudioTestService:ExecuteRunModeAsync({})
9077
+ return StudioTestService:ExecuteRunModeAsync(preparedTest.testArgs)
9043
9078
  end)
9044
- PluginSession.clearTopologyMarker(topologyMarkerToken)
9079
+ PluginSession.finishTest(preparedTest.token)
9045
9080
  if not ok then
9046
9081
  warn(`[robloxstudio-mcp] Playtest ended with error: {result}`)
9047
9082
  end
@@ -9159,13 +9194,13 @@ local function multiplayerTestStart(requestData)
9159
9194
  testArgs = testArgs,
9160
9195
  startedAt = tick(),
9161
9196
  }
9162
- local topologyMarkerToken = PluginSession.prepareMultiplayerTopology(testId)
9197
+ local topologyToken = PluginSession.prepareMultiplayerTest(testId)
9163
9198
  task.spawn(function()
9164
9199
  multiplayerState.phase = "running"
9165
9200
  local ok, result = pcall(function()
9166
9201
  return StudioTestService:ExecuteMultiplayerTestAsync(numPlayers, testArgs)
9167
9202
  end)
9168
- PluginSession.clearTopologyMarker(topologyMarkerToken)
9203
+ PluginSession.finishTest(topologyToken)
9169
9204
  multiplayerState.completedAt = tick()
9170
9205
  multiplayerState.ok = ok
9171
9206
  if ok then
@@ -9868,6 +9903,187 @@ return {
9868
9903
  </Properties>
9869
9904
  </Item>
9870
9905
  <Item class="ModuleScript" referent="29">
9906
+ <Properties>
9907
+ <string name="Name">PlaytestTickets</string>
9908
+ <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
9909
+ local TS = require(script.Parent.Parent.include.RuntimeLib)
9910
+ local HttpService = TS.import(script, script.Parent.Parent, "node_modules", "@rbxts", "services").HttpService
9911
+ -- Hands topology from an edit DataModel to the runtime DataModels of its
9912
+ -- playtest through plugin settings, so nothing MCP-owned has to be saved into
9913
+ -- the place that Studio copies. Plugin settings are one store shared by every
9914
+ -- Studio window running this plugin, and a window can silently overwrite
9915
+ -- another window's write. Tickets are therefore re-published on a heartbeat and
9916
+ -- ignored once that heartbeat stops.
9917
+ local BOARD_KEY = "MCP_PLAYTEST_TICKETS_V1"
9918
+ local CLAIM_KEY_PREFIX = "MCP_PLAYTEST_CLAIM_V1_"
9919
+ local STALE_AFTER_MS = 5_000
9920
+ local function nowMs()
9921
+ return DateTime.now().UnixTimestampMillis
9922
+ end
9923
+ local function readSetting(store, key)
9924
+ local ok, value = pcall(function()
9925
+ return store:GetSetting(key)
9926
+ end)
9927
+ return if ok then value else nil
9928
+ end
9929
+ local function writeSetting(store, key, value)
9930
+ pcall(function()
9931
+ return store:SetSetting(key, value)
9932
+ end)
9933
+ end
9934
+ local function optionalString(value)
9935
+ local _value = value
9936
+ local _condition = type(_value) == "string"
9937
+ if _condition then
9938
+ _condition = value ~= ""
9939
+ end
9940
+ return if _condition then value else nil
9941
+ end
9942
+ local function decodeTicket(value)
9943
+ local _value = value
9944
+ if not (type(_value) == "table") then
9945
+ return nil
9946
+ end
9947
+ local data = value
9948
+ local id = data.id
9949
+ local placeId = data.placeId
9950
+ local placeKey = data.placeKey
9951
+ local dataModelName = data.dataModelName
9952
+ local postedAt = data.postedAt
9953
+ local heartbeatAt = data.heartbeatAt
9954
+ if not (type(id) == "string") or id == "" then
9955
+ return nil
9956
+ end
9957
+ if not (type(placeId) == "number") or not (type(placeKey) == "string") or not (type(dataModelName) == "string") then
9958
+ return nil
9959
+ end
9960
+ if not (type(postedAt) == "number") or not (type(heartbeatAt) == "number") then
9961
+ return nil
9962
+ end
9963
+ local instanceId = optionalString(data.instanceId)
9964
+ local groupId = optionalString(data.groupId)
9965
+ if instanceId == nil and groupId == nil then
9966
+ return nil
9967
+ end
9968
+ return {
9969
+ id = id,
9970
+ instanceId = instanceId,
9971
+ groupId = groupId,
9972
+ placeId = placeId,
9973
+ placeKey = placeKey,
9974
+ dataModelName = dataModelName,
9975
+ serverUrl = optionalString(data.serverUrl),
9976
+ postedAt = postedAt,
9977
+ heartbeatAt = heartbeatAt,
9978
+ }
9979
+ end
9980
+ local function isFresh(ticket, now)
9981
+ return math.abs(now - ticket.heartbeatAt) <= STALE_AFTER_MS
9982
+ end
9983
+ local function readBoard(store)
9984
+ local raw = readSetting(store, BOARD_KEY)
9985
+ if not (type(raw) == "string") then
9986
+ return {}
9987
+ end
9988
+ local ok, tickets = pcall(function()
9989
+ local decoded = HttpService:JSONDecode(raw)
9990
+ local valid = {}
9991
+ if not (type(decoded) == "table") then
9992
+ return valid
9993
+ end
9994
+ for _, entry in decoded do
9995
+ local ticket = decodeTicket(entry)
9996
+ if ticket ~= nil then
9997
+ table.insert(valid, ticket)
9998
+ end
9999
+ end
10000
+ return valid
10001
+ end)
10002
+ return if ok then tickets else {}
10003
+ end
10004
+ local function writeBoard(store, tickets)
10005
+ if tickets[1] == nil then
10006
+ writeSetting(store, BOARD_KEY, nil)
10007
+ return nil
10008
+ end
10009
+ local ok, encoded = pcall(function()
10010
+ return HttpService:JSONEncode(tickets)
10011
+ end)
10012
+ if ok and type(encoded) == "string" then
10013
+ writeSetting(store, BOARD_KEY, encoded)
10014
+ end
10015
+ end
10016
+ --* Publishes or refreshes a ticket and prunes tickets whose owners stopped refreshing them.
10017
+ local function publish(store, ticket)
10018
+ local now = nowMs()
10019
+ local board = {}
10020
+ for _, existing in readBoard(store) do
10021
+ if existing.id ~= ticket.id and isFresh(existing, now) then
10022
+ table.insert(board, existing)
10023
+ end
10024
+ end
10025
+ local _object = table.clone(ticket)
10026
+ setmetatable(_object, nil)
10027
+ _object.heartbeatAt = now
10028
+ table.insert(board, _object)
10029
+ writeBoard(store, board)
10030
+ end
10031
+ local function withdraw(store, ticketId)
10032
+ local now = nowMs()
10033
+ local board = {}
10034
+ local changed = false
10035
+ for _, existing in readBoard(store) do
10036
+ if existing.id == ticketId or not isFresh(existing, now) then
10037
+ changed = true
10038
+ else
10039
+ table.insert(board, existing)
10040
+ end
10041
+ end
10042
+ if changed then
10043
+ writeBoard(store, board)
10044
+ end
10045
+ end
10046
+ local function liveTickets(store)
10047
+ local now = nowMs()
10048
+ local _exp = readBoard(store)
10049
+ -- ▼ ReadonlyArray.filter ▼
10050
+ local _newValue = {}
10051
+ local _callback = function(ticket)
10052
+ return isFresh(ticket, now)
10053
+ end
10054
+ local _length = 0
10055
+ for _k, _v in _exp do
10056
+ if _callback(_v, _k - 1, _exp) == true then
10057
+ _length += 1
10058
+ _newValue[_length] = _v
10059
+ end
10060
+ end
10061
+ -- ▲ ReadonlyArray.filter ▲
10062
+ return _newValue
10063
+ end
10064
+ local function readClaim(store, ticketId)
10065
+ return optionalString(readSetting(store, CLAIM_KEY_PREFIX .. ticketId))
10066
+ end
10067
+ local function writeClaim(store, ticketId, claimant)
10068
+ writeSetting(store, CLAIM_KEY_PREFIX .. ticketId, claimant)
10069
+ end
10070
+ local function clearClaim(store, ticketId)
10071
+ writeSetting(store, CLAIM_KEY_PREFIX .. ticketId, nil)
10072
+ end
10073
+ return {
10074
+ nowMs = nowMs,
10075
+ decodeTicket = decodeTicket,
10076
+ publish = publish,
10077
+ withdraw = withdraw,
10078
+ liveTickets = liveTickets,
10079
+ readClaim = readClaim,
10080
+ writeClaim = writeClaim,
10081
+ clearClaim = clearClaim,
10082
+ }
10083
+ ]]></string>
10084
+ </Properties>
10085
+ </Item>
10086
+ <Item class="ModuleScript" referent="30">
9871
10087
  <Properties>
9872
10088
  <string name="Name">PluginSession</string>
9873
10089
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -9880,101 +10096,542 @@ local ServerStorage = _services.ServerStorage
9880
10096
  local State = TS.import(script, script.Parent, "State")
9881
10097
  local PeerRole = TS.import(script, script.Parent, "PeerRole")
9882
10098
  local TopologyId = TS.import(script, script.Parent, "TopologyId")
10099
+ local PlaytestTickets = TS.import(script, script.Parent, "PlaytestTickets")
9883
10100
  local CoreGui = game:GetService("CoreGui")
9884
- local MCP_PLACE_ID_ATTRIBUTE = "__MCPPlaceId"
10101
+ local StudioTestService = game:GetService("StudioTestService")
10102
+ local LEGACY_PLACE_ID_ATTRIBUTE = "__MCPPlaceId"
9885
10103
  local TOPOLOGY_MODE_ATTRIBUTE = "__MCPTopologyMode"
9886
10104
  local TOPOLOGY_INSTANCE_ID_ATTRIBUTE = "__MCPTopologyInstanceId"
9887
10105
  local TOPOLOGY_GROUP_ID_ATTRIBUTE = "__MCPTopologyGroupId"
9888
10106
  local TOPOLOGY_TOKEN_ATTRIBUTE = "__MCPTopologyToken"
10107
+ -- Older plugin versions saved these into places. Nothing MCP-owned belongs in a
10108
+ -- saved or published DataModel, so edit sessions remove them on load.
10109
+ local LEGACY_TOPOLOGY_ATTRIBUTES = { TOPOLOGY_MODE_ATTRIBUTE, TOPOLOGY_INSTANCE_ID_ATTRIBUTE, TOPOLOGY_GROUP_ID_ATTRIBUTE, TOPOLOGY_TOKEN_ATTRIBUTE }
9889
10110
  local SESSION_IDENTITY_NAME = "__MCPSessionIdentity"
10111
+ -- JSON session state lives in StringValue children of the CoreGui identity
10112
+ -- value: CoreGui survives plugin reloads but is never saved with the place or
10113
+ -- copied into playtest DataModels. Not attributes: with NextGenerationReplication,
10114
+ -- playtest DataModels reject attribute strings longer than 50 characters.
10115
+ local PLAY_RECORD_NAME = "PlayRecord"
10116
+ local RUNTIME_TOPOLOGY_NAME = "RuntimeTopology"
10117
+ -- MCP-started solo and Run playtests carry their topology in StudioTestService
10118
+ -- test args, which never touch the DataModel.
10119
+ local TEST_ARGS_TOPOLOGY_KEY = "__mcpTopology"
10120
+ local TICKET_HEARTBEAT_SEC = 1
10121
+ local UNCLAIMED_TICKET_LIFETIME_MS = 120_000
10122
+ local RESOLVE_POLL_SEC = 0.25
10123
+ -- A runtime claims a ticket only after it has been the single match for this
10124
+ -- long, so a second window's Play starting at the same moment is noticed.
10125
+ local TICKET_SETTLE_SEC = 1
10126
+ local NO_TICKET_TIMEOUT_SEC = 5
10127
+ local AMBIGUOUS_TICKET_TIMEOUT_SEC = 15
10128
+ -- A prepared MCP playtest that never started (its handler died) must not stop
10129
+ -- later Play-button runs from posting tickets.
10130
+ local ARMED_PLAY_TTL_SEC = 30
10131
+ --* The edit's current playtest, kept in CoreGui so a plugin reload can finish it.
9890
10132
  local peerId = TopologyId.createPeerId()
9891
10133
  local isEditSession = PeerRole.detect() == "edit"
9892
10134
  local existingSessionIdentity = CoreGui:FindFirstChild(SESSION_IDENTITY_NAME)
9893
- local sessionInstanceId
9894
- local createdSessionIdentity = false
10135
+ local sessionIdentity
9895
10136
  if existingSessionIdentity ~= nil and existingSessionIdentity:IsA("StringValue") and not existingSessionIdentity.Archivable and (string.match(existingSessionIdentity.Value, "^instance:[0-9a-z][0-9a-z][0-9a-z]%-[0-9a-z][0-9a-z][0-9a-z]$")) ~= nil then
9896
- sessionInstanceId = existingSessionIdentity.Value
10137
+ sessionIdentity = existingSessionIdentity
9897
10138
  else
9898
10139
  local _result = existingSessionIdentity
9899
10140
  if _result ~= nil then
9900
10141
  _result:Destroy()
9901
10142
  end
9902
- sessionInstanceId = TopologyId.createInstanceId()
9903
- local sessionIdentity = Instance.new("StringValue")
10143
+ sessionIdentity = Instance.new("StringValue")
9904
10144
  sessionIdentity.Name = SESSION_IDENTITY_NAME
9905
- sessionIdentity.Value = sessionInstanceId
10145
+ sessionIdentity.Value = TopologyId.createInstanceId()
9906
10146
  sessionIdentity.Archivable = false
9907
10147
  sessionIdentity.Parent = CoreGui
9908
- createdSessionIdentity = true
9909
10148
  end
9910
- local inheritedInstanceId
10149
+ local sessionInstanceId = sessionIdentity.Value
10150
+ local settingsStore
10151
+ local serverUrlProvider
10152
+ local legacyPlaceKey
10153
+ local runtimeTopology
10154
+ local runtimeResolutionStarted = false
10155
+ local heartbeatTicketId
9911
10156
  local cachedPlaceName
9912
10157
  local cachedPlaceNamePlaceId
9913
- local function getMarkerMode()
9914
- local mode = ReplicatedStorage:GetAttribute(TOPOLOGY_MODE_ATTRIBUTE)
9915
- return if mode == "shared" or mode == "multiplayer" then mode else nil
9916
- end
9917
10158
  local function getInstanceId()
9918
- if inheritedInstanceId ~= nil then
9919
- return inheritedInstanceId
10159
+ local _result = runtimeTopology
10160
+ if _result ~= nil then
10161
+ _result = _result.instanceId
9920
10162
  end
9921
- if not isEditSession and getMarkerMode() == "shared" then
9922
- local sharedInstanceId = ReplicatedStorage:GetAttribute(TOPOLOGY_INSTANCE_ID_ATTRIBUTE)
9923
- if type(sharedInstanceId) == "string" and sharedInstanceId ~= "" then
9924
- inheritedInstanceId = sharedInstanceId
9925
- return sharedInstanceId
9926
- end
10163
+ local _condition = _result
10164
+ if _condition == nil then
10165
+ _condition = sessionInstanceId
9927
10166
  end
9928
- return sessionInstanceId
10167
+ return _condition
9929
10168
  end
10169
+ local readPlayRecord
9930
10170
  local function getMultiplayerGroupId()
9931
- if getMarkerMode() ~= "multiplayer" then
9932
- return nil
10171
+ if not isEditSession then
10172
+ local _result = runtimeTopology
10173
+ if _result ~= nil then
10174
+ _result = _result.groupId
10175
+ end
10176
+ return _result
10177
+ end
10178
+ local _result = readPlayRecord()
10179
+ if _result ~= nil then
10180
+ _result = _result.ticket
10181
+ if _result ~= nil then
10182
+ _result = _result.groupId
10183
+ end
9933
10184
  end
9934
- local groupId = ReplicatedStorage:GetAttribute(TOPOLOGY_GROUP_ID_ATTRIBUTE)
9935
- return if type(groupId) == "string" and groupId ~= "" then groupId else nil
10185
+ return _result
9936
10186
  end
9937
10187
  local function getPlaceKey()
10188
+ local _inherited = runtimeTopology
10189
+ if _inherited ~= nil then
10190
+ _inherited = _inherited.placeKey
10191
+ end
10192
+ local inherited = _inherited
10193
+ if inherited ~= nil then
10194
+ return inherited
10195
+ end
9938
10196
  if game.PlaceId ~= 0 then
9939
10197
  return `place:{tostring(game.PlaceId)}`
9940
10198
  end
9941
- local existing = ServerStorage:GetAttribute(MCP_PLACE_ID_ATTRIBUTE)
9942
- if type(existing) == "string" and existing ~= "" then
9943
- return `anon:{existing}`
10199
+ return `name:{game.Name}`
10200
+ end
10201
+ local function getLegacyPlaceKey()
10202
+ return legacyPlaceKey
10203
+ end
10204
+ local function getInheritedServerUrl()
10205
+ local _result = runtimeTopology
10206
+ if _result ~= nil then
10207
+ _result = _result.serverUrl
10208
+ end
10209
+ return _result
10210
+ end
10211
+ local function removeAttribute(target, name)
10212
+ if target:GetAttribute(name) == nil then
10213
+ return nil
9944
10214
  end
9945
- local fresh = HttpService:GenerateGUID(false)
9946
10215
  pcall(function()
9947
- return ServerStorage:SetAttribute(MCP_PLACE_ID_ATTRIBUTE, fresh)
10216
+ return target:SetAttribute(name, nil)
10217
+ end)
10218
+ end
10219
+ local function removeSavedSessionState()
10220
+ local savedPlaceId = ServerStorage:GetAttribute(LEGACY_PLACE_ID_ATTRIBUTE)
10221
+ if type(savedPlaceId) == "string" and savedPlaceId ~= "" then
10222
+ legacyPlaceKey = `anon:{savedPlaceId}`
10223
+ end
10224
+ removeAttribute(ServerStorage, LEGACY_PLACE_ID_ATTRIBUTE)
10225
+ for _, name in LEGACY_TOPOLOGY_ATTRIBUTES do
10226
+ removeAttribute(ReplicatedStorage, name)
10227
+ end
10228
+ end
10229
+ local function optionalString(value)
10230
+ local _value = value
10231
+ local _condition = type(_value) == "string"
10232
+ if _condition then
10233
+ _condition = value ~= ""
10234
+ end
10235
+ return if _condition then value else nil
10236
+ end
10237
+ local function readSessionState(name)
10238
+ local holder = sessionIdentity:FindFirstChild(name)
10239
+ if holder == nil or not holder:IsA("StringValue") then
10240
+ return nil
10241
+ end
10242
+ local ok, decoded = pcall(function()
10243
+ return HttpService:JSONDecode(holder.Value)
9948
10244
  end)
9949
- return `anon:{fresh}`
10245
+ if not ok or not (type(decoded) == "table") then
10246
+ return nil
10247
+ end
10248
+ return decoded
9950
10249
  end
9951
- local function setTopologyMarker(mode, instanceId, groupId)
10250
+ --* Best effort: saved session state only lets a plugin reload resume where it left off.
10251
+ local function writeSessionState(name, state)
10252
+ local ok, err = pcall(function()
10253
+ local existing = sessionIdentity:FindFirstChild(name)
10254
+ if state == nil then
10255
+ local _result = existing
10256
+ if _result ~= nil then
10257
+ _result:Destroy()
10258
+ end
10259
+ return nil
10260
+ end
10261
+ local encoded = HttpService:JSONEncode(state)
10262
+ if existing ~= nil and existing:IsA("StringValue") then
10263
+ existing.Value = encoded
10264
+ return nil
10265
+ end
10266
+ local _result = existing
10267
+ if _result ~= nil then
10268
+ _result:Destroy()
10269
+ end
10270
+ local holder = Instance.new("StringValue")
10271
+ holder.Name = name
10272
+ holder.Value = encoded
10273
+ holder.Archivable = false
10274
+ holder.Parent = sessionIdentity
10275
+ end)
10276
+ if not ok then
10277
+ warn(`[robloxstudio-mcp] Could not save {name} session state: {err}`)
10278
+ end
10279
+ end
10280
+ function readPlayRecord()
10281
+ local data = readSessionState(PLAY_RECORD_NAME)
10282
+ if data == nil then
10283
+ return nil
10284
+ end
10285
+ local phase = if data.phase == "armed" or data.phase == "active" then data.phase else nil
10286
+ if phase == nil then
10287
+ return nil
10288
+ end
10289
+ local armedAt = data.armedAt
10290
+ local ticketState = data.ticketState
10291
+ return {
10292
+ phase = phase,
10293
+ token = optionalString(data.token),
10294
+ armedAt = if type(armedAt) == "number" then armedAt else nil,
10295
+ ticket = PlaytestTickets.decodeTicket(data.ticket),
10296
+ ticketState = if ticketState == "posted" or ticketState == "claimed" or ticketState == "expired" then ticketState else nil,
10297
+ }
10298
+ end
10299
+ local function writePlayRecord(record)
10300
+ writeSessionState(PLAY_RECORD_NAME, record)
10301
+ end
10302
+ local function createTicket(instanceId, groupId)
10303
+ local now = PlaytestTickets.nowMs()
10304
+ local _object = {
10305
+ id = HttpService:GenerateGUID(false),
10306
+ instanceId = instanceId,
10307
+ groupId = groupId,
10308
+ placeId = game.PlaceId,
10309
+ placeKey = getPlaceKey(),
10310
+ dataModelName = game.Name,
10311
+ }
10312
+ local _left = "serverUrl"
10313
+ local _result = serverUrlProvider
10314
+ if _result ~= nil then
10315
+ _result = _result()
10316
+ end
10317
+ _object[_left] = _result
10318
+ _object.postedAt = now
10319
+ _object.heartbeatAt = now
10320
+ return _object
10321
+ end
10322
+ local refreshTicket
10323
+ local function startTicketHeartbeat(ticketId)
10324
+ if heartbeatTicketId == ticketId then
10325
+ return nil
10326
+ end
10327
+ heartbeatTicketId = ticketId
10328
+ task.delay(TICKET_HEARTBEAT_SEC, function()
10329
+ return refreshTicket(ticketId)
10330
+ end)
10331
+ end
10332
+ function refreshTicket(ticketId)
10333
+ if heartbeatTicketId ~= ticketId then
10334
+ return nil
10335
+ end
10336
+ local store = settingsStore
10337
+ local record = readPlayRecord()
10338
+ local _ticket = record
10339
+ if _ticket ~= nil then
10340
+ _ticket = _ticket.ticket
10341
+ end
10342
+ local ticket = _ticket
10343
+ if store == nil or record == nil or ticket == nil or ticket.id ~= ticketId or record.ticketState ~= "posted" then
10344
+ heartbeatTicketId = nil
10345
+ return nil
10346
+ end
10347
+ if PlaytestTickets.readClaim(store, ticketId) ~= nil then
10348
+ -- Keep the claim itself until Play ends: the runtime verifies it.
10349
+ PlaytestTickets.withdraw(store, ticketId)
10350
+ record.ticketState = "claimed"
10351
+ writePlayRecord(record)
10352
+ heartbeatTicketId = nil
10353
+ return nil
10354
+ end
10355
+ if PlaytestTickets.nowMs() - ticket.postedAt > UNCLAIMED_TICKET_LIFETIME_MS then
10356
+ PlaytestTickets.withdraw(store, ticketId)
10357
+ record.ticketState = "expired"
10358
+ writePlayRecord(record)
10359
+ heartbeatTicketId = nil
10360
+ return nil
10361
+ end
10362
+ PlaytestTickets.publish(store, ticket)
10363
+ task.delay(TICKET_HEARTBEAT_SEC, function()
10364
+ return refreshTicket(ticketId)
10365
+ end)
10366
+ end
10367
+ local function retirePlayRecord(record)
10368
+ local store = settingsStore
10369
+ local ticket = record.ticket
10370
+ if store == nil or ticket == nil then
10371
+ return nil
10372
+ end
10373
+ PlaytestTickets.withdraw(store, ticket.id)
10374
+ PlaytestTickets.clearClaim(store, ticket.id)
10375
+ end
10376
+ local function beginPlay()
10377
+ local store = settingsStore
10378
+ if store == nil then
10379
+ return nil
10380
+ end
10381
+ local record = readPlayRecord()
10382
+ if record ~= nil then
10383
+ if record.phase == "active" then
10384
+ return nil
10385
+ end
10386
+ local _exp = os.clock()
10387
+ local _condition = record.armedAt
10388
+ if _condition == nil then
10389
+ _condition = 0
10390
+ end
10391
+ if _exp - _condition <= ARMED_PLAY_TTL_SEC then
10392
+ record.phase = "active"
10393
+ writePlayRecord(record)
10394
+ return nil
10395
+ end
10396
+ retirePlayRecord(record)
10397
+ end
10398
+ local ticket = createTicket(sessionInstanceId, nil)
10399
+ writePlayRecord({
10400
+ phase = "active",
10401
+ ticket = ticket,
10402
+ ticketState = "posted",
10403
+ })
10404
+ PlaytestTickets.publish(store, ticket)
10405
+ startTicketHeartbeat(ticket.id)
10406
+ end
10407
+ local function finishPlay()
10408
+ local record = readPlayRecord()
10409
+ -- An armed record belongs to an MCP playtest whose Play has not begun yet.
10410
+ if record == nil or record.phase ~= "active" then
10411
+ return nil
10412
+ end
10413
+ retirePlayRecord(record)
10414
+ writePlayRecord(nil)
10415
+ end
10416
+ local function armPlay(record)
10417
+ local existing = readPlayRecord()
10418
+ if existing ~= nil then
10419
+ retirePlayRecord(existing)
10420
+ end
10421
+ writePlayRecord(record)
10422
+ end
10423
+ local function prepareSoloTest()
10424
+ local token = HttpService:GenerateGUID(false)
10425
+ armPlay({
10426
+ phase = "armed",
10427
+ token = token,
10428
+ armedAt = os.clock(),
10429
+ })
10430
+ local _object = {
10431
+ token = token,
10432
+ }
10433
+ local _left = "testArgs"
10434
+ local _object_1 = {}
10435
+ local _left_1 = TEST_ARGS_TOPOLOGY_KEY
10436
+ local _object_2 = {
10437
+ instanceId = sessionInstanceId,
10438
+ placeKey = getPlaceKey(),
10439
+ }
10440
+ local _left_2 = "serverUrl"
10441
+ local _result = serverUrlProvider
10442
+ if _result ~= nil then
10443
+ _result = _result()
10444
+ end
10445
+ _object_2[_left_2] = _result
10446
+ _object_1[_left_1] = _object_2
10447
+ _object[_left] = _object_1
10448
+ return _object
10449
+ end
10450
+ --[[
10451
+ *
10452
+ * Multiplayer test args belong to the user's game code, so the group reaches
10453
+ * the test server through a ticket instead.
10454
+
10455
+ ]]
10456
+ local function prepareMultiplayerTest(groupId)
9952
10457
  local token = HttpService:GenerateGUID(false)
9953
- ReplicatedStorage:SetAttribute(TOPOLOGY_MODE_ATTRIBUTE, nil)
9954
- ReplicatedStorage:SetAttribute(TOPOLOGY_INSTANCE_ID_ATTRIBUTE, instanceId)
9955
- ReplicatedStorage:SetAttribute(TOPOLOGY_GROUP_ID_ATTRIBUTE, groupId)
9956
- ReplicatedStorage:SetAttribute(TOPOLOGY_TOKEN_ATTRIBUTE, token)
9957
- ReplicatedStorage:SetAttribute(TOPOLOGY_MODE_ATTRIBUTE, mode)
10458
+ local ticket = createTicket(nil, groupId)
10459
+ armPlay({
10460
+ phase = "armed",
10461
+ token = token,
10462
+ armedAt = os.clock(),
10463
+ ticket = ticket,
10464
+ ticketState = "posted",
10465
+ })
10466
+ local store = settingsStore
10467
+ if store ~= nil then
10468
+ PlaytestTickets.publish(store, ticket)
10469
+ startTicketHeartbeat(ticket.id)
10470
+ end
9958
10471
  return token
9959
10472
  end
9960
- local function prepareSharedTopology()
9961
- return setTopologyMarker("shared", sessionInstanceId, nil)
10473
+ local function finishTest(token)
10474
+ local record = readPlayRecord()
10475
+ if record == nil or record.token ~= token then
10476
+ return nil
10477
+ end
10478
+ retirePlayRecord(record)
10479
+ writePlayRecord(nil)
9962
10480
  end
9963
- local function prepareMultiplayerTopology(groupId)
9964
- return setTopologyMarker("multiplayer", nil, groupId)
10481
+ local function onEditModeChanged()
10482
+ if StudioTestService.EditModeActive then
10483
+ finishPlay()
10484
+ else
10485
+ beginPlay()
10486
+ end
9965
10487
  end
9966
- local function clearTopologyMarker(token)
9967
- if ReplicatedStorage:GetAttribute(TOPOLOGY_TOKEN_ATTRIBUTE) ~= token then
10488
+ local function matchingTickets(store)
10489
+ local samePlace = {}
10490
+ local nameMatched = false
10491
+ for _, ticket in PlaytestTickets.liveTickets(store) do
10492
+ if ticket.placeId ~= game.PlaceId then
10493
+ continue
10494
+ end
10495
+ local claimant = PlaytestTickets.readClaim(store, ticket.id)
10496
+ if claimant ~= nil and claimant ~= peerId then
10497
+ continue
10498
+ end
10499
+ table.insert(samePlace, ticket)
10500
+ if ticket.dataModelName == game.Name then
10501
+ nameMatched = true
10502
+ end
10503
+ end
10504
+ -- Runtime DataModel names are expected to match their edit, but only narrow
10505
+ -- on the name when it actually distinguishes a candidate.
10506
+ local _result
10507
+ if nameMatched then
10508
+ -- ▼ ReadonlyArray.filter ▼
10509
+ local _newValue = {}
10510
+ local _callback = function(ticket)
10511
+ return ticket.dataModelName == game.Name
10512
+ end
10513
+ local _length = 0
10514
+ for _k, _v in samePlace do
10515
+ if _callback(_v, _k - 1, samePlace) == true then
10516
+ _length += 1
10517
+ _newValue[_length] = _v
10518
+ end
10519
+ end
10520
+ -- ▲ ReadonlyArray.filter ▲
10521
+ _result = _newValue
10522
+ else
10523
+ _result = samePlace
10524
+ end
10525
+ return _result
10526
+ end
10527
+ --[[
10528
+ *
10529
+ * Waits for exactly one matching ticket and claims it. Returns nothing when no
10530
+ * edit posts a ticket, or when several windows of the same place started Play
10531
+ * together: the runtime then keeps its own identity rather than guessing.
10532
+
10533
+ ]]
10534
+ local function claimTicket(store)
10535
+ local startedAt = os.clock()
10536
+ local candidateSeen = false
10537
+ local settlingId
10538
+ local settlingSince = 0
10539
+ while true do
10540
+ local now = os.clock()
10541
+ local candidates = matchingTickets(store)
10542
+ local first = candidates[1]
10543
+ local only = if first ~= nil and candidates[2] == nil then first else nil
10544
+ if first ~= nil then
10545
+ candidateSeen = true
10546
+ end
10547
+ if only == nil then
10548
+ settlingId = nil
10549
+ elseif settlingId ~= only.id then
10550
+ settlingId = only.id
10551
+ settlingSince = now
10552
+ elseif now - settlingSince >= TICKET_SETTLE_SEC then
10553
+ PlaytestTickets.writeClaim(store, only.id, peerId)
10554
+ task.wait(RESOLVE_POLL_SEC)
10555
+ if PlaytestTickets.readClaim(store, only.id) == peerId then
10556
+ return only
10557
+ end
10558
+ settlingId = nil
10559
+ continue
10560
+ end
10561
+ local timeout = if candidateSeen then AMBIGUOUS_TICKET_TIMEOUT_SEC else NO_TICKET_TIMEOUT_SEC
10562
+ if now - startedAt >= timeout then
10563
+ return nil
10564
+ end
10565
+ task.wait(RESOLVE_POLL_SEC)
10566
+ end
10567
+ end
10568
+ local function topologyFromTestArgs()
10569
+ local ok, args = pcall(function()
10570
+ return StudioTestService:GetTestArgs()
10571
+ end)
10572
+ if not ok or not (type(args) == "table") then
9968
10573
  return nil
9969
10574
  end
9970
- if isEditSession then
9971
- prepareSharedTopology()
10575
+ local testArgs = args
10576
+ local envelope = testArgs[TEST_ARGS_TOPOLOGY_KEY]
10577
+ if not (type(envelope) == "table") then
9972
10578
  return nil
9973
10579
  end
9974
- ReplicatedStorage:SetAttribute(TOPOLOGY_MODE_ATTRIBUTE, nil)
9975
- ReplicatedStorage:SetAttribute(TOPOLOGY_INSTANCE_ID_ATTRIBUTE, nil)
9976
- ReplicatedStorage:SetAttribute(TOPOLOGY_GROUP_ID_ATTRIBUTE, nil)
9977
- ReplicatedStorage:SetAttribute(TOPOLOGY_TOKEN_ATTRIBUTE, nil)
10580
+ local topology = envelope
10581
+ local instanceId = optionalString(topology.instanceId)
10582
+ if instanceId == nil then
10583
+ return nil
10584
+ end
10585
+ return {
10586
+ instanceId = instanceId,
10587
+ placeKey = optionalString(topology.placeKey),
10588
+ serverUrl = optionalString(topology.serverUrl),
10589
+ }
10590
+ end
10591
+ local function readRememberedRuntimeTopology()
10592
+ local data = readSessionState(RUNTIME_TOPOLOGY_NAME)
10593
+ if data == nil then
10594
+ return nil
10595
+ end
10596
+ return {
10597
+ instanceId = optionalString(data.instanceId),
10598
+ groupId = optionalString(data.groupId),
10599
+ placeKey = optionalString(data.placeKey),
10600
+ serverUrl = optionalString(data.serverUrl),
10601
+ }
10602
+ end
10603
+ local function adoptRuntimeTopology(topology)
10604
+ runtimeTopology = topology
10605
+ writeSessionState(RUNTIME_TOPOLOGY_NAME, topology)
10606
+ end
10607
+ --[[
10608
+ *
10609
+ * Resolves which edit session this playtest server belongs to. Yields; call it
10610
+ * before the server peer registers with MCP.
10611
+
10612
+ ]]
10613
+ local function resolveRuntimeTopology()
10614
+ if isEditSession or runtimeTopology ~= nil or runtimeResolutionStarted then
10615
+ return nil
10616
+ end
10617
+ if PeerRole.detect() ~= "server" then
10618
+ return nil
10619
+ end
10620
+ runtimeResolutionStarted = true
10621
+ local fromArgs = topologyFromTestArgs()
10622
+ if fromArgs ~= nil then
10623
+ adoptRuntimeTopology(fromArgs)
10624
+ return nil
10625
+ end
10626
+ local store = settingsStore
10627
+ local ticket = if store ~= nil then claimTicket(store) else nil
10628
+ -- Record a fallback too, so a plugin reload keeps this server's own identity.
10629
+ adoptRuntimeTopology(if ticket == nil then {} else {
10630
+ instanceId = ticket.instanceId,
10631
+ groupId = ticket.groupId,
10632
+ placeKey = ticket.placeKey,
10633
+ serverUrl = ticket.serverUrl,
10634
+ })
9978
10635
  end
9979
10636
  local function getRole()
9980
10637
  return PeerRole.detect()
@@ -10031,32 +10688,69 @@ local function createReadyPayload(readyPeerId, role, instanceId, multiplayerGrou
10031
10688
  timestamp = tick(),
10032
10689
  }
10033
10690
  end
10034
- -- CoreGui survives plugin reloads without saving or replicating this identity.
10035
- -- Preserve active test markers on reload; fresh edits replace saved place markers.
10036
- if isEditSession then
10037
- if createdSessionIdentity or getMarkerMode() == nil then
10038
- prepareSharedTopology()
10691
+ local function resumePlayRecord()
10692
+ local record = readPlayRecord()
10693
+ if record == nil then
10694
+ return nil
10039
10695
  end
10040
- else
10041
- getInstanceId()
10696
+ if StudioTestService.EditModeActive then
10697
+ -- The Play ended while the plugin was reloading.
10698
+ if record.phase == "active" then
10699
+ finishPlay()
10700
+ end
10701
+ elseif record.phase == "armed" then
10702
+ beginPlay()
10703
+ end
10704
+ local current = readPlayRecord()
10705
+ local _ticket = current
10706
+ if _ticket ~= nil then
10707
+ _ticket = _ticket.ticket
10708
+ end
10709
+ local ticket = _ticket
10710
+ local _result = current
10711
+ if _result ~= nil then
10712
+ _result = _result.ticketState
10713
+ end
10714
+ local _condition = _result == "posted"
10715
+ if _condition then
10716
+ _condition = ticket ~= nil
10717
+ end
10718
+ if _condition then
10719
+ startTicketHeartbeat(ticket.id)
10720
+ end
10721
+ end
10722
+ local function init(store, options)
10723
+ settingsStore = store
10724
+ serverUrlProvider = options.getServerUrl
10725
+ if not isEditSession then
10726
+ runtimeTopology = readRememberedRuntimeTopology()
10727
+ return nil
10728
+ end
10729
+ removeSavedSessionState()
10730
+ StudioTestService:GetPropertyChangedSignal("EditModeActive"):Connect(onEditModeChanged)
10731
+ resumePlayRecord()
10042
10732
  end
10043
10733
  return {
10044
10734
  peerId = peerId,
10735
+ init = init,
10736
+ resolveRuntimeTopology = resolveRuntimeTopology,
10045
10737
  getInstanceId = getInstanceId,
10046
10738
  getMultiplayerGroupId = getMultiplayerGroupId,
10047
10739
  getPlaceKey = getPlaceKey,
10740
+ getLegacyPlaceKey = getLegacyPlaceKey,
10741
+ getInheritedServerUrl = getInheritedServerUrl,
10048
10742
  getRole = getRole,
10049
10743
  getPlaceName = getPlaceName,
10050
10744
  invalidatePlaceName = invalidatePlaceName,
10051
- prepareSharedTopology = prepareSharedTopology,
10052
- prepareMultiplayerTopology = prepareMultiplayerTopology,
10053
- clearTopologyMarker = clearTopologyMarker,
10745
+ prepareSoloTest = prepareSoloTest,
10746
+ finishTest = finishTest,
10747
+ prepareMultiplayerTest = prepareMultiplayerTest,
10054
10748
  createReadyPayload = createReadyPayload,
10055
10749
  }
10056
10750
  ]]></string>
10057
10751
  </Properties>
10058
10752
  </Item>
10059
- <Item class="ModuleScript" referent="30">
10753
+ <Item class="ModuleScript" referent="31">
10060
10754
  <Properties>
10061
10755
  <string name="Name">Recording</string>
10062
10756
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -10086,7 +10780,7 @@ return {
10086
10780
  ]]></string>
10087
10781
  </Properties>
10088
10782
  </Item>
10089
- <Item class="ModuleScript" referent="31">
10783
+ <Item class="ModuleScript" referent="32">
10090
10784
  <Properties>
10091
10785
  <string name="Name">RenderMonitor</string>
10092
10786
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -10154,7 +10848,7 @@ return {
10154
10848
  ]]></string>
10155
10849
  </Properties>
10156
10850
  </Item>
10157
- <Item class="ModuleScript" referent="32">
10851
+ <Item class="ModuleScript" referent="33">
10158
10852
  <Properties>
10159
10853
  <string name="Name">RuntimeLogBuffer</string>
10160
10854
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -10390,7 +11084,7 @@ return {
10390
11084
  ]]></string>
10391
11085
  </Properties>
10392
11086
  </Item>
10393
- <Item class="ModuleScript" referent="33">
11087
+ <Item class="ModuleScript" referent="34">
10394
11088
  <Properties>
10395
11089
  <string name="Name">ScriptSearch</string>
10396
11090
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -10717,14 +11411,12 @@ return {
10717
11411
  ]]></string>
10718
11412
  </Properties>
10719
11413
  </Item>
10720
- <Item class="ModuleScript" referent="34">
11414
+ <Item class="ModuleScript" referent="35">
10721
11415
  <Properties>
10722
11416
  <string name="Name">ServerUrlSettings</string>
10723
11417
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
10724
11418
  local TS = require(script.Parent.Parent.include.RuntimeLib)
10725
- local _services = TS.import(script, script.Parent.Parent, "node_modules", "@rbxts", "services")
10726
- local HttpService = _services.HttpService
10727
- local ServerStorage = _services.ServerStorage
11419
+ local PluginSession = TS.import(script, script.Parent, "PluginSession")
10728
11420
  local SETTING_KEY_PREFIX = "MCP_LAST_SUCCESSFUL_SERVER_URL_"
10729
11421
  local GLOBAL_SETTING_KEY = "MCP_LAST_SUCCESSFUL_SERVER_URL_GLOBAL_V1"
10730
11422
  local pluginRef
@@ -10764,30 +11456,12 @@ local function addUnique(values, value)
10764
11456
  table.insert(_values_1, _value_1)
10765
11457
  end
10766
11458
  end
10767
- local function computePlaceKeys(options)
10768
- local placeKeys = {}
10769
- if game.PlaceId ~= 0 then
10770
- addUnique(placeKeys, `place:{tostring(game.PlaceId)}`)
10771
- end
10772
- local existing = ServerStorage:GetAttribute("__MCPPlaceId")
10773
- if type(existing) == "string" and existing ~= "" then
10774
- addUnique(placeKeys, `anon:{existing}`)
10775
- else
10776
- local _condition = game.PlaceId == 0
10777
- if _condition then
10778
- local _result = options
10779
- if _result ~= nil then
10780
- _result = _result.createAnonymous
10781
- end
10782
- _condition = _result == true
10783
- end
10784
- if _condition then
10785
- local fresh = HttpService:GenerateGUID(false)
10786
- pcall(function()
10787
- return ServerStorage:SetAttribute("__MCPPlaceId", fresh)
10788
- end)
10789
- addUnique(placeKeys, `anon:{fresh}`)
10790
- end
11459
+ local function computePlaceKeys()
11460
+ local placeKeys = { PluginSession.getPlaceKey() }
11461
+ -- Unpublished places used to be keyed by a saved __MCPPlaceId attribute.
11462
+ local legacyPlaceKey = PluginSession.getLegacyPlaceKey()
11463
+ if legacyPlaceKey ~= nil then
11464
+ addUnique(placeKeys, legacyPlaceKey)
10791
11465
  end
10792
11466
  return placeKeys
10793
11467
  end
@@ -10821,19 +11495,14 @@ local function rememberServerUrl(serverUrl)
10821
11495
  return nil
10822
11496
  end
10823
11497
  writeSettingString(GLOBAL_SETTING_KEY, normalized)
10824
- for _, placeKey in computePlaceKeys({
10825
- createAnonymous = true,
10826
- }) do
10827
- writeSettingString(settingKey(placeKey), normalized)
10828
- end
11498
+ writeSettingString(settingKey(PluginSession.getPlaceKey()), normalized)
10829
11499
  end
10830
11500
  local function readServerUrl()
10831
11501
  if not pluginRef then
10832
11502
  return nil
10833
11503
  end
10834
- -- Reading settings should not mint a place key. Client play DataModels have
10835
- -- their own ServerStorage; creating a key there would not match the
10836
- -- edit/server place-scoped setting.
11504
+ -- The legacy key is read only, so a place remembered by an older plugin
11505
+ -- version keeps its server URL until the current key is written.
10837
11506
  for _, placeKey in computePlaceKeys() do
10838
11507
  local remembered = readSettingString(settingKey(placeKey))
10839
11508
  if remembered ~= nil then
@@ -10856,11 +11525,11 @@ return {
10856
11525
  ]]></string>
10857
11526
  </Properties>
10858
11527
  </Item>
10859
- <Item class="ModuleScript" referent="35">
11528
+ <Item class="ModuleScript" referent="36">
10860
11529
  <Properties>
10861
11530
  <string name="Name">State</string>
10862
11531
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
10863
- local CURRENT_VERSION = "3.1.6"
11532
+ local CURRENT_VERSION = "3.2.1"
10864
11533
  local PLUGIN_VARIANT = "inspector"
10865
11534
  local BASE_PORT = 58741
10866
11535
  local SENSITIVE_DIAGNOSTIC_TERMS = { "token", "authorization", "bearer", "password", "cookie", "secret" }
@@ -10998,14 +11667,14 @@ return {
10998
11667
  ]]></string>
10999
11668
  </Properties>
11000
11669
  </Item>
11001
- <Item class="ModuleScript" referent="36">
11670
+ <Item class="ModuleScript" referent="37">
11002
11671
  <Properties>
11003
11672
  <string name="Name">StopPlayMonitor</string>
11004
11673
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
11005
11674
  local TS = require(script.Parent.Parent.include.RuntimeLib)
11006
- -- Cross-DataModel stop_playtest signaling via plugin settings. Solo edit and
11007
- -- runtime peers share one process instanceId through PluginSession's topology
11008
- -- marker, so only the intended Studio process can consume the request.
11675
+ -- Cross-DataModel stop_playtest signaling via plugin settings. A solo play
11676
+ -- server inherits its edit's instanceId from PluginSession (test args or a
11677
+ -- plugin-settings ticket), so only the intended Studio process consumes it.
11009
11678
  local _services = TS.import(script, script.Parent.Parent, "node_modules", "@rbxts", "services")
11010
11679
  local HttpService = _services.HttpService
11011
11680
  local RunService = _services.RunService
@@ -11284,7 +11953,7 @@ return {
11284
11953
  ]]></string>
11285
11954
  </Properties>
11286
11955
  </Item>
11287
- <Item class="ModuleScript" referent="37">
11956
+ <Item class="ModuleScript" referent="38">
11288
11957
  <Properties>
11289
11958
  <string name="Name">StudioWebSocket</string>
11290
11959
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -11307,6 +11976,10 @@ local MAX_PENDING_RESPONSE_BYTES = 64 * 1024 * 1024
11307
11976
  local RESERVED_ERROR_BYTES = 4096
11308
11977
  -- The bridge admits 128 UTF-16 code units; each can require three UTF-8 bytes.
11309
11978
  local MAX_REQUEST_ID_BYTES = 128 * 3
11979
+ -- Upper bound on BindToClose waiting for the server to close the socket after
11980
+ -- the unregister frame; it normally closes within milliseconds.
11981
+ local SHUTDOWN_UNREGISTER_WAIT_SECONDS = 2
11982
+ local SHUTDOWN_UNREGISTER_POLL_SECONDS = 0.05
11310
11983
  local options
11311
11984
  local active = false
11312
11985
  local shutdownSuspended = false
@@ -12349,8 +13022,13 @@ local function refresh()
12349
13022
  refreshRequested = true
12350
13023
  flushRefresh()
12351
13024
  end
12352
- -- EndTest may tear down this VM without an Unloading callback. Release the
12353
- -- native socket before yielding to unregister; a failed EndTest can resume.
13025
+ -- EndTest may tear down this VM without an Unloading callback, and BindToClose
13026
+ -- waits for this function. It unregisters on the socket itself: Studio runs at
13027
+ -- most five plugin HTTP requests at a time across the whole process, with no
13028
+ -- time limit, so a /disconnect queued behind five that never finish held
13029
+ -- shutdown until Studio's 30 s deadline. Closing right after Send can drop the
13030
+ -- frame, so it waits briefly for the server to close the socket first. A
13031
+ -- failed EndTest can resume and registers again.
12354
13032
  local function suspendForShutdown()
12355
13033
  local currentOptions = options
12356
13034
  if not active or currentOptions == nil then
@@ -12362,9 +13040,34 @@ local function suspendForShutdown()
12362
13040
  cancelTransportWork()
12363
13041
  refreshRequested = false
12364
13042
  reconnectAttempt = 0
13043
+ local client = socketClient
13044
+ local unregistered = false
13045
+ if client ~= nil and socketOpen then
13046
+ local serverClosed = false
13047
+ local closedConnection = client.Closed:Connect(function()
13048
+ serverClosed = true
13049
+ end)
13050
+ local sent = pcall(function()
13051
+ return client:Send(HttpService:JSONEncode({
13052
+ kind = "disconnect",
13053
+ }))
13054
+ end)
13055
+ unregistered = sent
13056
+ local deadline = os.clock() + SHUTDOWN_UNREGISTER_WAIT_SECONDS
13057
+ while sent and not serverClosed and os.clock() < deadline do
13058
+ task.wait(SHUTDOWN_UNREGISTER_POLL_SECONDS)
13059
+ end
13060
+ closedConnection:Disconnect()
13061
+ end
12365
13062
  closeCurrentSocket()
12366
13063
  cachedReady = nil
12367
- disconnectSession(currentOptions)
13064
+ -- Without an open socket, unregister over HTTP on another thread rather
13065
+ -- than wait; the server also drops a peer whose socket stays closed.
13066
+ if not unregistered then
13067
+ task.spawn(function()
13068
+ return disconnectSession(currentOptions)
13069
+ end)
13070
+ end
12368
13071
  end
12369
13072
  local function resumeAfterShutdownFailure()
12370
13073
  if not shutdownSuspended or options == nil then
@@ -12408,7 +13111,7 @@ return {
12408
13111
  ]]></string>
12409
13112
  </Properties>
12410
13113
  </Item>
12411
- <Item class="ModuleScript" referent="38">
13114
+ <Item class="ModuleScript" referent="39">
12412
13115
  <Properties>
12413
13116
  <string name="Name">TopologyId</string>
12414
13117
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -12453,7 +13156,7 @@ return {
12453
13156
  ]]></string>
12454
13157
  </Properties>
12455
13158
  </Item>
12456
- <Item class="ModuleScript" referent="39">
13159
+ <Item class="ModuleScript" referent="40">
12457
13160
  <Properties>
12458
13161
  <string name="Name">UI</string>
12459
13162
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -12978,7 +13681,7 @@ return {
12978
13681
  ]]></string>
12979
13682
  </Properties>
12980
13683
  </Item>
12981
- <Item class="ModuleScript" referent="40">
13684
+ <Item class="ModuleScript" referent="41">
12982
13685
  <Properties>
12983
13686
  <string name="Name">Utils</string>
12984
13687
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -13821,11 +14524,11 @@ return {
13821
14524
  </Properties>
13822
14525
  </Item>
13823
14526
  </Item>
13824
- <Item class="Folder" referent="45">
14527
+ <Item class="Folder" referent="46">
13825
14528
  <Properties>
13826
14529
  <string name="Name">include</string>
13827
14530
  </Properties>
13828
- <Item class="ModuleScript" referent="41">
14531
+ <Item class="ModuleScript" referent="42">
13829
14532
  <Properties>
13830
14533
  <string name="Name">LibMP</string>
13831
14534
  <string name="Source"><![CDATA[-- =============================================================================
@@ -170209,7 +170912,7 @@ return LibMP
170209
170912
  ]]></string>
170210
170913
  </Properties>
170211
170914
  </Item>
170212
- <Item class="ModuleScript" referent="42">
170915
+ <Item class="ModuleScript" referent="43">
170213
170916
  <Properties>
170214
170917
  <string name="Name">Promise</string>
170215
170918
  <string name="Source"><![CDATA[--[[
@@ -172283,7 +172986,7 @@ return Promise
172283
172986
  ]]></string>
172284
172987
  </Properties>
172285
172988
  </Item>
172286
- <Item class="ModuleScript" referent="43">
172989
+ <Item class="ModuleScript" referent="44">
172287
172990
  <Properties>
172288
172991
  <string name="Name">RuntimeLib</string>
172289
172992
  <string name="Source"><![CDATA[local Promise = require(script.Parent.Promise)
@@ -172550,15 +173253,15 @@ return TS
172550
173253
  </Properties>
172551
173254
  </Item>
172552
173255
  </Item>
172553
- <Item class="Folder" referent="46">
173256
+ <Item class="Folder" referent="47">
172554
173257
  <Properties>
172555
173258
  <string name="Name">node_modules</string>
172556
173259
  </Properties>
172557
- <Item class="Folder" referent="47">
173260
+ <Item class="Folder" referent="48">
172558
173261
  <Properties>
172559
173262
  <string name="Name">@rbxts</string>
172560
173263
  </Properties>
172561
- <Item class="ModuleScript" referent="44">
173264
+ <Item class="ModuleScript" referent="45">
172562
173265
  <Properties>
172563
173266
  <string name="Name">services</string>
172564
173267
  <string name="Source"><![CDATA[return setmetatable({}, {