@chrrxs/robloxstudio-mcp 2.13.0 → 2.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +546 -29
- package/package.json +2 -2
- package/studio-plugin/INSTALLATION.md +1 -0
- package/studio-plugin/MCPInspectorPlugin.rbxmx +1573 -330
- package/studio-plugin/MCPPlugin.rbxmx +633 -27
- package/studio-plugin/src/modules/ClientBroker.ts +77 -2
- package/studio-plugin/src/modules/Communication.ts +7 -0
- package/studio-plugin/src/modules/handlers/SceneAnalysisHandlers.ts +216 -0
- package/studio-plugin/src/modules/handlers/TestHandlers.ts +246 -12
|
@@ -12,6 +12,11 @@ local Communication = TS.import(script, script, "modules", "Communication")
|
|
|
12
12
|
local ClientBroker = TS.import(script, script, "modules", "ClientBroker")
|
|
13
13
|
local RuntimeLogBuffer = TS.import(script, script, "modules", "RuntimeLogBuffer")
|
|
14
14
|
local StopPlayMonitor = TS.import(script, script, "modules", "StopPlayMonitor")
|
|
15
|
+
local RenderMonitor = TS.import(script, script, "modules", "RenderMonitor")
|
|
16
|
+
-- Track render-loop liveness so input/screenshot tools can report "window
|
|
17
|
+
-- minimized / not rendering" instead of silently no-op'ing. No-op in the
|
|
18
|
+
-- server DM (RenderStepped can't connect there).
|
|
19
|
+
RenderMonitor.start()
|
|
15
20
|
-- Attach the per-peer LogService.MessageOut listener as early as possible so
|
|
16
21
|
-- boot-time prints from the user's place scripts are captured. Powers the
|
|
17
22
|
-- get_runtime_logs MCP tool. Idempotent; safe to call before UI.init().
|
|
@@ -94,8 +99,54 @@ local HttpService = _services.HttpService
|
|
|
94
99
|
local Players = _services.Players
|
|
95
100
|
local ReplicatedStorage = _services.ReplicatedStorage
|
|
96
101
|
local RunService = _services.RunService
|
|
102
|
+
local ServerStorage = _services.ServerStorage
|
|
97
103
|
local RuntimeLogBuffer = TS.import(script, script.Parent, "RuntimeLogBuffer")
|
|
98
104
|
local MemoryHandlers = TS.import(script, script.Parent, "handlers", "MemoryHandlers")
|
|
105
|
+
local SceneAnalysisHandlers = TS.import(script, script.Parent, "handlers", "SceneAnalysisHandlers")
|
|
106
|
+
local CaptureHandlers = TS.import(script, script.Parent, "handlers", "CaptureHandlers")
|
|
107
|
+
local InputHandlers = TS.import(script, script.Parent, "handlers", "InputHandlers")
|
|
108
|
+
local LuauExec = TS.import(script, script.Parent, "LuauExec")
|
|
109
|
+
local StudioTestService = game:GetService("StudioTestService")
|
|
110
|
+
-- Mirror of Communication.computeInstanceId() — duplicated here because the
|
|
111
|
+
-- client broker runs in the play-server DM where it can't easily import from
|
|
112
|
+
-- the edit-side module, and the place identifier must match what the edit-DM
|
|
113
|
+
-- plugin reports. Both use the same algorithm against the shared DataModel.
|
|
114
|
+
local function computeInstanceId()
|
|
115
|
+
if game.PlaceId ~= 0 then
|
|
116
|
+
return `place:{tostring(game.PlaceId)}`
|
|
117
|
+
end
|
|
118
|
+
local existing = ServerStorage:GetAttribute("__MCPPlaceId")
|
|
119
|
+
if type(existing) == "string" and existing ~= "" then
|
|
120
|
+
return `anon:{existing}`
|
|
121
|
+
end
|
|
122
|
+
local fresh = HttpService:GenerateGUID(false)
|
|
123
|
+
pcall(function()
|
|
124
|
+
return ServerStorage:SetAttribute("__MCPPlaceId", fresh)
|
|
125
|
+
end)
|
|
126
|
+
return `anon:{fresh}`
|
|
127
|
+
end
|
|
128
|
+
local cachedPlaceName
|
|
129
|
+
local function resolvePlaceName()
|
|
130
|
+
if cachedPlaceName ~= nil then
|
|
131
|
+
return cachedPlaceName
|
|
132
|
+
end
|
|
133
|
+
if game.PlaceId == 0 then
|
|
134
|
+
cachedPlaceName = game.Name
|
|
135
|
+
return cachedPlaceName
|
|
136
|
+
end
|
|
137
|
+
local MarketplaceService = game:GetService("MarketplaceService")
|
|
138
|
+
local ok, info = pcall(function()
|
|
139
|
+
return MarketplaceService:GetProductInfo(game.PlaceId)
|
|
140
|
+
end)
|
|
141
|
+
if ok and info ~= nil then
|
|
142
|
+
local name = info.Name
|
|
143
|
+
if type(name) == "string" and name ~= "" then
|
|
144
|
+
cachedPlaceName = name
|
|
145
|
+
return cachedPlaceName
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
return game.Name
|
|
149
|
+
end
|
|
99
150
|
-- The client peer cannot reach the MCP HTTP server - Roblox forbids
|
|
100
151
|
-- HttpService:RequestAsync from the client DM even under PluginSecurity, and
|
|
101
152
|
-- HttpEnabled reads as false there regardless of identity. So the server peer
|
|
@@ -109,6 +160,7 @@ local MemoryHandlers = TS.import(script, script.Parent, "handlers", "MemoryHandl
|
|
|
109
160
|
-- signaling, which works regardless of MCP server state.)
|
|
110
161
|
local MCP_URL = "http://localhost:58741"
|
|
111
162
|
local BROKER_NAME = "__MCPClientBroker"
|
|
163
|
+
local BROKER_OWNER_ATTRIBUTE = "__MCPBrokerOwner"
|
|
112
164
|
-- Endpoints the server-peer broker is allowed to forward to the client peer.
|
|
113
165
|
-- Each requires the client peer's plugin VM (because the buffer / require
|
|
114
166
|
-- cache / etc. lives there) so the server peer alone can't satisfy them.
|
|
@@ -116,6 +168,12 @@ local CLIENT_BROKER_ALLOWED_ENDPOINTS = {
|
|
|
116
168
|
["/api/execute-luau"] = true,
|
|
117
169
|
["/api/get-runtime-logs"] = true,
|
|
118
170
|
["/api/get-memory-breakdown"] = true,
|
|
171
|
+
["/api/get-scene-analysis"] = true,
|
|
172
|
+
["/api/multiplayer-test-state"] = true,
|
|
173
|
+
["/api/multiplayer-test-leave-client"] = true,
|
|
174
|
+
["/api/capture-begin"] = true,
|
|
175
|
+
["/api/simulate-mouse-input"] = true,
|
|
176
|
+
["/api/simulate-keyboard-input"] = true,
|
|
119
177
|
}
|
|
120
178
|
-- Throttle re-ready calls per proxyId so a brief window of unknownInstance
|
|
121
179
|
-- polls doesn't cause a re-register stampede.
|
|
@@ -136,8 +194,13 @@ local function reRegisterProxy(proxyId, role)
|
|
|
136
194
|
lastReadyByProxy[_proxyId_1] = now
|
|
137
195
|
pcall(function()
|
|
138
196
|
return postJson("/ready", {
|
|
139
|
-
|
|
197
|
+
pluginSessionId = proxyId,
|
|
198
|
+
instanceId = computeInstanceId(),
|
|
140
199
|
role = role,
|
|
200
|
+
placeId = game.PlaceId,
|
|
201
|
+
placeName = resolvePlaceName(),
|
|
202
|
+
dataModelName = game.Name,
|
|
203
|
+
isRunning = RunService:IsRunning(),
|
|
141
204
|
})
|
|
142
205
|
end)
|
|
143
206
|
end
|
|
@@ -170,34 +233,11 @@ local function handleExecuteLuau(data)
|
|
|
170
233
|
error = "code is required",
|
|
171
234
|
}
|
|
172
235
|
end
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
if not okSet then
|
|
179
|
-
m:Destroy()
|
|
180
|
-
return {
|
|
181
|
-
success = false,
|
|
182
|
-
error = `Source set failed: {tostring(setErr)}`,
|
|
183
|
-
}
|
|
184
|
-
end
|
|
185
|
-
m.Parent = game.Workspace
|
|
186
|
-
local okReq, result = pcall(function()
|
|
187
|
-
return require(m)
|
|
188
|
-
end)
|
|
189
|
-
m:Destroy()
|
|
190
|
-
if okReq then
|
|
191
|
-
return {
|
|
192
|
-
success = true,
|
|
193
|
-
returnValue = if result ~= nil then tostring(result) else nil,
|
|
194
|
-
message = "Code executed successfully",
|
|
195
|
-
}
|
|
196
|
-
end
|
|
197
|
-
return {
|
|
198
|
-
success = false,
|
|
199
|
-
error = tostring(result),
|
|
200
|
-
}
|
|
236
|
+
-- Shared with edit/server (MetadataHandlers.executeLuau). Adds the IIFE
|
|
237
|
+
-- wrapper (so `print("hi")` with no return doesn't fail the
|
|
238
|
+
-- ModuleScript's "must return one value" rule) and JSON-encodes table
|
|
239
|
+
-- returns instead of yielding "table: 0xaddr".
|
|
240
|
+
return LuauExec.execute(code)
|
|
201
241
|
end
|
|
202
242
|
local function handleGetRuntimeLogs(data)
|
|
203
243
|
local d = data or {}
|
|
@@ -212,10 +252,81 @@ local function handleGetRuntimeLogs(data)
|
|
|
212
252
|
filter = filter,
|
|
213
253
|
}, "client")
|
|
214
254
|
end
|
|
255
|
+
local function handleMultiplayerTestState()
|
|
256
|
+
local argsOk, args = pcall(function()
|
|
257
|
+
return StudioTestService:GetTestArgs()
|
|
258
|
+
end)
|
|
259
|
+
local canLeaveOk, canLeave = pcall(function()
|
|
260
|
+
return StudioTestService:CanLeaveTest()
|
|
261
|
+
end)
|
|
262
|
+
local _exp = Players:GetPlayers()
|
|
263
|
+
-- ▼ ReadonlyArray.map ▼
|
|
264
|
+
local _newValue = table.create(#_exp)
|
|
265
|
+
local _callback = function(player)
|
|
266
|
+
return {
|
|
267
|
+
name = player.Name,
|
|
268
|
+
userId = player.UserId,
|
|
269
|
+
displayName = player.DisplayName,
|
|
270
|
+
}
|
|
271
|
+
end
|
|
272
|
+
for _k, _v in _exp do
|
|
273
|
+
_newValue[_k] = _callback(_v, _k - 1, _exp)
|
|
274
|
+
end
|
|
275
|
+
-- ▲ ReadonlyArray.map ▲
|
|
276
|
+
local players = _newValue
|
|
277
|
+
table.sort(players, function(a, b)
|
|
278
|
+
return a.name < b.name
|
|
279
|
+
end)
|
|
280
|
+
return {
|
|
281
|
+
success = true,
|
|
282
|
+
peer = "client",
|
|
283
|
+
isRunning = RunService:IsRunning(),
|
|
284
|
+
isRunMode = RunService:IsRunMode(),
|
|
285
|
+
editModeActive = StudioTestService.EditModeActive,
|
|
286
|
+
testArgsOk = argsOk,
|
|
287
|
+
testArgs = if argsOk then args else nil,
|
|
288
|
+
testArgsError = if argsOk then nil else tostring(args),
|
|
289
|
+
players = players,
|
|
290
|
+
playerCount = #players,
|
|
291
|
+
localPlayer = if Players.LocalPlayer then Players.LocalPlayer.Name else nil,
|
|
292
|
+
canLeaveOk = canLeaveOk,
|
|
293
|
+
canLeave = if canLeaveOk then canLeave else false,
|
|
294
|
+
canLeaveError = if canLeaveOk then nil else tostring(canLeave),
|
|
295
|
+
}
|
|
296
|
+
end
|
|
297
|
+
local function handleMultiplayerTestLeaveClient()
|
|
298
|
+
local canLeaveOk, canLeave = pcall(function()
|
|
299
|
+
return StudioTestService:CanLeaveTest()
|
|
300
|
+
end)
|
|
301
|
+
if not canLeaveOk then
|
|
302
|
+
return {
|
|
303
|
+
error = tostring(canLeave),
|
|
304
|
+
canLeaveOk = false,
|
|
305
|
+
}
|
|
306
|
+
end
|
|
307
|
+
if not canLeave then
|
|
308
|
+
return {
|
|
309
|
+
error = "This client cannot leave the current test session.",
|
|
310
|
+
canLeaveOk = true,
|
|
311
|
+
canLeave = false,
|
|
312
|
+
}
|
|
313
|
+
end
|
|
314
|
+
local localPlayer = if Players.LocalPlayer then Players.LocalPlayer.Name else nil
|
|
315
|
+
task.defer(function()
|
|
316
|
+
pcall(function()
|
|
317
|
+
return StudioTestService:LeaveTest()
|
|
318
|
+
end)
|
|
319
|
+
end)
|
|
320
|
+
return {
|
|
321
|
+
success = true,
|
|
322
|
+
message = "Client leave requested.",
|
|
323
|
+
localPlayer = localPlayer,
|
|
324
|
+
}
|
|
325
|
+
end
|
|
215
326
|
local function setupClientBroker()
|
|
216
327
|
local rf = ReplicatedStorage:WaitForChild(BROKER_NAME, 10)
|
|
217
328
|
if not rf or not rf:IsA("RemoteFunction") then
|
|
218
|
-
warn(`[
|
|
329
|
+
warn(`[robloxstudio-mcp] client: {BROKER_NAME} not found`)
|
|
219
330
|
return nil
|
|
220
331
|
end
|
|
221
332
|
rf.OnClientInvoke = function(payload)
|
|
@@ -231,6 +342,24 @@ local function setupClientBroker()
|
|
|
231
342
|
if payload and payload.endpoint == "/api/get-memory-breakdown" then
|
|
232
343
|
return MemoryHandlers.getMemoryBreakdown(payload.data or {})
|
|
233
344
|
end
|
|
345
|
+
if payload and payload.endpoint == "/api/get-scene-analysis" then
|
|
346
|
+
return SceneAnalysisHandlers.getSceneAnalysis(payload.data or {})
|
|
347
|
+
end
|
|
348
|
+
if payload and payload.endpoint == "/api/multiplayer-test-state" then
|
|
349
|
+
return handleMultiplayerTestState()
|
|
350
|
+
end
|
|
351
|
+
if payload and payload.endpoint == "/api/multiplayer-test-leave-client" then
|
|
352
|
+
return handleMultiplayerTestLeaveClient()
|
|
353
|
+
end
|
|
354
|
+
if payload and payload.endpoint == "/api/capture-begin" then
|
|
355
|
+
return CaptureHandlers.captureBegin()
|
|
356
|
+
end
|
|
357
|
+
if payload and payload.endpoint == "/api/simulate-mouse-input" then
|
|
358
|
+
return InputHandlers.simulateMouseInput(payload.data or {})
|
|
359
|
+
end
|
|
360
|
+
if payload and payload.endpoint == "/api/simulate-keyboard-input" then
|
|
361
|
+
return InputHandlers.simulateKeyboardInput(payload.data or {})
|
|
362
|
+
end
|
|
234
363
|
if payload and payload.endpoint == "/api/execute-luau" then
|
|
235
364
|
return handleExecuteLuau(payload.data)
|
|
236
365
|
end
|
|
@@ -239,6 +368,7 @@ local function setupClientBroker()
|
|
|
239
368
|
end
|
|
240
369
|
end
|
|
241
370
|
local proxyByPlayer = {}
|
|
371
|
+
local serverBrokerStarted = false
|
|
242
372
|
local function pollProxy(proxyId, player, rf)
|
|
243
373
|
while true do
|
|
244
374
|
local _condition = player.Parent ~= nil
|
|
@@ -251,7 +381,7 @@ local function pollProxy(proxyId, player, rf)
|
|
|
251
381
|
end
|
|
252
382
|
local ok, res = pcall(function()
|
|
253
383
|
return HttpService:RequestAsync({
|
|
254
|
-
Url = `{MCP_URL}/poll?
|
|
384
|
+
Url = `{MCP_URL}/poll?pluginSessionId={proxyId}`,
|
|
255
385
|
Method = "GET",
|
|
256
386
|
Headers = {
|
|
257
387
|
["Content-Type"] = "application/json",
|
|
@@ -294,8 +424,12 @@ local function pollProxy(proxyId, player, rf)
|
|
|
294
424
|
}
|
|
295
425
|
end
|
|
296
426
|
else
|
|
427
|
+
local allowed = {}
|
|
428
|
+
for ep in CLIENT_BROKER_ALLOWED_ENDPOINTS do
|
|
429
|
+
table.insert(allowed, ep)
|
|
430
|
+
end
|
|
297
431
|
response = {
|
|
298
|
-
error = `Client-proxy does not forward {tostring(request.endpoint)}. ` .. `Allowed:
|
|
432
|
+
error = `Client-proxy does not forward {tostring(request.endpoint)}. ` .. `Allowed: {table.concat(allowed, ", ")}.`,
|
|
299
433
|
}
|
|
300
434
|
end
|
|
301
435
|
postJson("/response", {
|
|
@@ -315,11 +449,16 @@ local function registerProxy(player, rf)
|
|
|
315
449
|
end
|
|
316
450
|
local proxyId = HttpService:GenerateGUID(false)
|
|
317
451
|
local ok, res = postJson("/ready", {
|
|
318
|
-
|
|
452
|
+
pluginSessionId = proxyId,
|
|
453
|
+
instanceId = computeInstanceId(),
|
|
319
454
|
role = "client",
|
|
455
|
+
placeId = game.PlaceId,
|
|
456
|
+
placeName = resolvePlaceName(),
|
|
457
|
+
dataModelName = game.Name,
|
|
458
|
+
isRunning = RunService:IsRunning(),
|
|
320
459
|
})
|
|
321
460
|
if not ok or not res or not res.Success then
|
|
322
|
-
warn(`[
|
|
461
|
+
warn(`[robloxstudio-mcp] proxy register failed for {player.Name}`)
|
|
323
462
|
return nil
|
|
324
463
|
end
|
|
325
464
|
local body = HttpService:JSONDecode(res.Body)
|
|
@@ -330,7 +469,7 @@ local function registerProxy(player, rf)
|
|
|
330
469
|
local assigned = _condition
|
|
331
470
|
local _player_1 = player
|
|
332
471
|
local _arg1 = {
|
|
333
|
-
|
|
472
|
+
pluginSessionId = proxyId,
|
|
334
473
|
role = assigned,
|
|
335
474
|
}
|
|
336
475
|
proxyByPlayer[_player_1] = _arg1
|
|
@@ -341,12 +480,20 @@ end
|
|
|
341
480
|
-- plugin:SetSetting flag consumed by StopPlayMonitor in the play-server DM,
|
|
342
481
|
-- which doesn't depend on MCP server state or peer registration at all.)
|
|
343
482
|
local function setupServerBroker()
|
|
483
|
+
if serverBrokerStarted then
|
|
484
|
+
return nil
|
|
485
|
+
end
|
|
344
486
|
local rf = ReplicatedStorage:FindFirstChild(BROKER_NAME)
|
|
345
487
|
if not rf then
|
|
346
488
|
rf = Instance.new("RemoteFunction")
|
|
347
489
|
rf.Name = BROKER_NAME
|
|
348
490
|
rf.Parent = ReplicatedStorage
|
|
349
491
|
end
|
|
492
|
+
if rf:GetAttribute(BROKER_OWNER_ATTRIBUTE) ~= nil then
|
|
493
|
+
return nil
|
|
494
|
+
end
|
|
495
|
+
rf:SetAttribute(BROKER_OWNER_ATTRIBUTE, HttpService:GenerateGUID(false))
|
|
496
|
+
serverBrokerStarted = true
|
|
350
497
|
local broker = rf
|
|
351
498
|
Players.PlayerAdded:Connect(function(p)
|
|
352
499
|
return registerProxy(p, broker)
|
|
@@ -361,14 +508,14 @@ local function setupServerBroker()
|
|
|
361
508
|
local _p_1 = p
|
|
362
509
|
proxyByPlayer[_p_1] = nil
|
|
363
510
|
postJson("/disconnect", {
|
|
364
|
-
|
|
511
|
+
pluginSessionId = entry.pluginSessionId,
|
|
365
512
|
})
|
|
366
513
|
end
|
|
367
514
|
end)
|
|
368
515
|
game:BindToClose(function()
|
|
369
516
|
for _, entry in proxyByPlayer do
|
|
370
517
|
postJson("/disconnect", {
|
|
371
|
-
|
|
518
|
+
pluginSessionId = entry.pluginSessionId,
|
|
372
519
|
})
|
|
373
520
|
end
|
|
374
521
|
table.clear(proxyByPlayer)
|
|
@@ -391,9 +538,11 @@ local TS = require(script.Parent.Parent.include.RuntimeLib)
|
|
|
391
538
|
local _services = TS.import(script, script.Parent.Parent, "node_modules", "@rbxts", "services")
|
|
392
539
|
local HttpService = _services.HttpService
|
|
393
540
|
local RunService = _services.RunService
|
|
541
|
+
local ServerStorage = _services.ServerStorage
|
|
394
542
|
local State = TS.import(script, script.Parent, "State")
|
|
395
543
|
local Utils = TS.import(script, script.Parent, "Utils")
|
|
396
544
|
local UI = TS.import(script, script.Parent, "UI")
|
|
545
|
+
local ensureBridgesInstalled = TS.import(script, script.Parent, "EvalBridges").ensureBridgesInstalled
|
|
397
546
|
local QueryHandlers = TS.import(script, script.Parent, "handlers", "QueryHandlers")
|
|
398
547
|
local PropertyHandlers = TS.import(script, script.Parent, "handlers", "PropertyHandlers")
|
|
399
548
|
local InstanceHandlers = TS.import(script, script.Parent, "handlers", "InstanceHandlers")
|
|
@@ -407,8 +556,62 @@ local InputHandlers = TS.import(script, script.Parent, "handlers", "InputHandler
|
|
|
407
556
|
local LogHandlers = TS.import(script, script.Parent, "handlers", "LogHandlers")
|
|
408
557
|
local SerializationHandlers = TS.import(script, script.Parent, "handlers", "SerializationHandlers")
|
|
409
558
|
local MemoryHandlers = TS.import(script, script.Parent, "handlers", "MemoryHandlers")
|
|
410
|
-
local
|
|
559
|
+
local SceneAnalysisHandlers = TS.import(script, script.Parent, "handlers", "SceneAnalysisHandlers")
|
|
560
|
+
-- Per-plugin-load random GUID. Used as the /poll URL param so the server
|
|
561
|
+
-- can tell our polls apart from any other plugin's polls. Not user-facing —
|
|
562
|
+
-- MCP tools and the LLM operate on instanceId (the place identifier).
|
|
563
|
+
local pluginSessionId = HttpService:GenerateGUID(false)
|
|
564
|
+
-- Place-level identifier shared by every plugin running in DataModels of
|
|
565
|
+
-- the same place file (edit DM + playtest server DM + playtest clients).
|
|
566
|
+
-- Format: "place:<PlaceId>" when published, "anon:<UUID>" for unpublished
|
|
567
|
+
-- places where the UUID lives on ServerStorage's __MCPPlaceId attribute
|
|
568
|
+
-- and travels with the .rbxl.
|
|
569
|
+
local MCP_PLACE_ID_ATTRIBUTE = "__MCPPlaceId"
|
|
570
|
+
local function computeInstanceId()
|
|
571
|
+
if game.PlaceId ~= 0 then
|
|
572
|
+
return `place:{tostring(game.PlaceId)}`
|
|
573
|
+
end
|
|
574
|
+
local existing = ServerStorage:GetAttribute(MCP_PLACE_ID_ATTRIBUTE)
|
|
575
|
+
if type(existing) == "string" and existing ~= "" then
|
|
576
|
+
return `anon:{existing}`
|
|
577
|
+
end
|
|
578
|
+
local fresh = HttpService:GenerateGUID(false)
|
|
579
|
+
pcall(function()
|
|
580
|
+
return ServerStorage:SetAttribute(MCP_PLACE_ID_ATTRIBUTE, fresh)
|
|
581
|
+
end)
|
|
582
|
+
return `anon:{fresh}`
|
|
583
|
+
end
|
|
584
|
+
local instanceId = computeInstanceId()
|
|
411
585
|
local assignedRole
|
|
586
|
+
local duplicateInstanceRole = false
|
|
587
|
+
-- Cache the published place name from MarketplaceService:GetProductInfo so
|
|
588
|
+
-- /ready can carry a friendly identifier (e.g. "Natural Disasters") distinct
|
|
589
|
+
-- from game.Name (the DataModel name, often "Place1" in edit). We only fetch
|
|
590
|
+
-- once per plugin load; the published name doesn't change mid-session.
|
|
591
|
+
local cachedPlaceName
|
|
592
|
+
local function resolvePlaceName()
|
|
593
|
+
if cachedPlaceName ~= nil then
|
|
594
|
+
return cachedPlaceName
|
|
595
|
+
end
|
|
596
|
+
if game.PlaceId == 0 then
|
|
597
|
+
cachedPlaceName = game.Name
|
|
598
|
+
return cachedPlaceName
|
|
599
|
+
end
|
|
600
|
+
local MarketplaceService = game:GetService("MarketplaceService")
|
|
601
|
+
local ok, info = pcall(function()
|
|
602
|
+
return MarketplaceService:GetProductInfo(game.PlaceId)
|
|
603
|
+
end)
|
|
604
|
+
if ok and info ~= nil then
|
|
605
|
+
local name = info.Name
|
|
606
|
+
if type(name) == "string" and name ~= "" then
|
|
607
|
+
cachedPlaceName = name
|
|
608
|
+
return cachedPlaceName
|
|
609
|
+
end
|
|
610
|
+
end
|
|
611
|
+
-- Don't cache failures — could be transient (offline, rate-limited).
|
|
612
|
+
-- Next /ready will retry. Return game.Name as fallback.
|
|
613
|
+
return game.Name
|
|
614
|
+
end
|
|
412
615
|
local function detectRole()
|
|
413
616
|
if not RunService:IsRunning() then
|
|
414
617
|
return "edit"
|
|
@@ -464,6 +667,11 @@ local routeMap = {
|
|
|
464
667
|
["/api/start-playtest"] = TestHandlers.startPlaytest,
|
|
465
668
|
["/api/stop-playtest"] = TestHandlers.stopPlaytest,
|
|
466
669
|
["/api/get-playtest-output"] = TestHandlers.getPlaytestOutput,
|
|
670
|
+
["/api/multiplayer-test-start"] = TestHandlers.multiplayerTestStart,
|
|
671
|
+
["/api/multiplayer-test-state"] = TestHandlers.multiplayerTestState,
|
|
672
|
+
["/api/multiplayer-test-add-players"] = TestHandlers.multiplayerTestAddPlayers,
|
|
673
|
+
["/api/multiplayer-test-leave-client"] = TestHandlers.multiplayerTestLeaveClient,
|
|
674
|
+
["/api/multiplayer-test-end"] = TestHandlers.multiplayerTestEnd,
|
|
467
675
|
["/api/character-navigation"] = TestHandlers.characterNavigation,
|
|
468
676
|
["/api/export-build"] = BuildHandlers.exportBuild,
|
|
469
677
|
["/api/import-build"] = BuildHandlers.importBuild,
|
|
@@ -472,6 +680,8 @@ local routeMap = {
|
|
|
472
680
|
["/api/insert-asset"] = AssetHandlers.insertAsset,
|
|
473
681
|
["/api/preview-asset"] = AssetHandlers.previewAsset,
|
|
474
682
|
["/api/capture-screenshot"] = CaptureHandlers.captureScreenshot,
|
|
683
|
+
["/api/capture-begin"] = CaptureHandlers.captureBegin,
|
|
684
|
+
["/api/capture-read"] = CaptureHandlers.captureRead,
|
|
475
685
|
["/api/simulate-mouse-input"] = InputHandlers.simulateMouseInput,
|
|
476
686
|
["/api/simulate-keyboard-input"] = InputHandlers.simulateKeyboardInput,
|
|
477
687
|
["/api/find-and-replace-in-scripts"] = ScriptHandlers.findAndReplaceInScripts,
|
|
@@ -479,6 +689,7 @@ local routeMap = {
|
|
|
479
689
|
["/api/export-rbxm"] = SerializationHandlers.exportRbxm,
|
|
480
690
|
["/api/import-rbxm"] = SerializationHandlers.importRbxm,
|
|
481
691
|
["/api/get-memory-breakdown"] = MemoryHandlers.getMemoryBreakdown,
|
|
692
|
+
["/api/get-scene-analysis"] = SceneAnalysisHandlers.getSceneAnalysis,
|
|
482
693
|
}
|
|
483
694
|
local function processRequest(request)
|
|
484
695
|
local endpoint = request.endpoint
|
|
@@ -524,7 +735,33 @@ end
|
|
|
524
735
|
-- Without this, every poll during the brief window where the server has just
|
|
525
736
|
-- restarted but hasn't seen our re-ready yet would fire a duplicate /ready.
|
|
526
737
|
local lastReadyPostAt = 0
|
|
527
|
-
|
|
738
|
+
-- game.Name is sometimes "Place1" at plugin-load time and only settles to
|
|
739
|
+
-- the real DataModel name (e.g. "Game" once playtest spawns the play DM)
|
|
740
|
+
-- after Studio finishes wiring things up. Re-fire /ready when it changes so
|
|
741
|
+
-- get_connected_instances doesn't show a stale dataModelName forever. Set
|
|
742
|
+
-- up once per plugin load — the connection passed in is whichever was
|
|
743
|
+
-- active when activatePlugin was first called.
|
|
744
|
+
local nameChangeConn
|
|
745
|
+
local sendReady
|
|
746
|
+
local function ensureNameChangeWatcher(conn)
|
|
747
|
+
if nameChangeConn then
|
|
748
|
+
return nil
|
|
749
|
+
end
|
|
750
|
+
local okSig, signal = pcall(function()
|
|
751
|
+
return game:GetPropertyChangedSignal("Name")
|
|
752
|
+
end)
|
|
753
|
+
if not okSig or not signal then
|
|
754
|
+
return nil
|
|
755
|
+
end
|
|
756
|
+
nameChangeConn = signal:Connect(function()
|
|
757
|
+
-- sendReady has its own 2s throttle, so rapid burst changes coalesce.
|
|
758
|
+
sendReady(conn)
|
|
759
|
+
end)
|
|
760
|
+
end
|
|
761
|
+
function sendReady(conn)
|
|
762
|
+
if duplicateInstanceRole then
|
|
763
|
+
return nil
|
|
764
|
+
end
|
|
528
765
|
local now = tick()
|
|
529
766
|
if now - lastReadyPostAt < 2 then
|
|
530
767
|
return nil
|
|
@@ -539,14 +776,36 @@ local function sendReady(conn)
|
|
|
539
776
|
["Content-Type"] = "application/json",
|
|
540
777
|
},
|
|
541
778
|
Body = HttpService:JSONEncode({
|
|
779
|
+
pluginSessionId = pluginSessionId,
|
|
542
780
|
instanceId = instanceId,
|
|
543
781
|
role = detectRole(),
|
|
782
|
+
placeId = game.PlaceId,
|
|
783
|
+
placeName = resolvePlaceName(),
|
|
784
|
+
dataModelName = game.Name,
|
|
785
|
+
isRunning = RunService:IsRunning(),
|
|
544
786
|
pluginReady = true,
|
|
545
787
|
timestamp = tick(),
|
|
546
788
|
}),
|
|
547
789
|
})
|
|
548
790
|
end)
|
|
549
|
-
if readyOk
|
|
791
|
+
if not readyOk then
|
|
792
|
+
return nil
|
|
793
|
+
end
|
|
794
|
+
-- 409 = duplicate_instance_role. Surface in UI and stop polling.
|
|
795
|
+
if readyResult.StatusCode == 409 then
|
|
796
|
+
duplicateInstanceRole = true
|
|
797
|
+
conn.isActive = false
|
|
798
|
+
local ui = UI.getElements()
|
|
799
|
+
if State.getActiveTabIndex() == 0 then
|
|
800
|
+
ui.statusLabel.Text = "Duplicate instance"
|
|
801
|
+
ui.statusLabel.TextColor3 = Color3.fromRGB(239, 68, 68)
|
|
802
|
+
ui.detailStatusLabel.Text = "Another Studio is already connected as this place + role"
|
|
803
|
+
ui.detailStatusLabel.TextColor3 = Color3.fromRGB(239, 68, 68)
|
|
804
|
+
end
|
|
805
|
+
warn(`[MCPPlugin] Another Studio is already connected as ({instanceId}, {detectRole()}). Close the other Studio window or this one.`)
|
|
806
|
+
return nil
|
|
807
|
+
end
|
|
808
|
+
if readyResult.Success then
|
|
550
809
|
local parseOk, readyData = pcall(function()
|
|
551
810
|
return HttpService:JSONDecode(readyResult.Body)
|
|
552
811
|
end)
|
|
@@ -568,7 +827,7 @@ local function pollForRequests(connIndex)
|
|
|
568
827
|
conn.isPolling = true
|
|
569
828
|
local success, result = pcall(function()
|
|
570
829
|
return HttpService:RequestAsync({
|
|
571
|
-
Url = `{conn.serverUrl}/poll?
|
|
830
|
+
Url = `{conn.serverUrl}/poll?pluginSessionId={pluginSessionId}`,
|
|
572
831
|
Method = "GET",
|
|
573
832
|
Headers = {
|
|
574
833
|
["Content-Type"] = "application/json",
|
|
@@ -764,6 +1023,22 @@ local function activatePlugin(connIndex)
|
|
|
764
1023
|
-- Initial /ready; pollForRequests will also re-fire ready if the server
|
|
765
1024
|
-- later reports knownInstance=false (process restart, etc).
|
|
766
1025
|
sendReady(conn)
|
|
1026
|
+
-- Keep the eval bridges present in the edit DM so that ANY playtest —
|
|
1027
|
+
-- including one the dev starts manually via the Studio Play button —
|
|
1028
|
+
-- clones them into the play DMs and eval_*_runtime works with no setup
|
|
1029
|
+
-- roundtrip. Only the edit DM installs; play DMs already have the cloned
|
|
1030
|
+
-- copies. Idempotent, so reconnects don't re-dirty the place.
|
|
1031
|
+
if not RunService:IsRunning() then
|
|
1032
|
+
task.spawn(function()
|
|
1033
|
+
local result = ensureBridgesInstalled()
|
|
1034
|
+
if not result.installed then
|
|
1035
|
+
warn(`[MCPPlugin] Eval bridge install failed: {result.error}`)
|
|
1036
|
+
end
|
|
1037
|
+
end)
|
|
1038
|
+
end
|
|
1039
|
+
-- Watch for game.Name updates so a stale "Place1" captured at first
|
|
1040
|
+
-- /ready gets refreshed once Studio settles on the real DM name.
|
|
1041
|
+
ensureNameChangeWatcher(conn)
|
|
767
1042
|
end
|
|
768
1043
|
local function deactivatePlugin(connIndex)
|
|
769
1044
|
local _condition = connIndex
|
|
@@ -789,7 +1064,7 @@ local function deactivatePlugin(connIndex)
|
|
|
789
1064
|
["Content-Type"] = "application/json",
|
|
790
1065
|
},
|
|
791
1066
|
Body = HttpService:JSONEncode({
|
|
792
|
-
|
|
1067
|
+
pluginSessionId = pluginSessionId,
|
|
793
1068
|
timestamp = tick(),
|
|
794
1069
|
}),
|
|
795
1070
|
})
|
|
@@ -882,11 +1157,16 @@ local TS = require(script.Parent.Parent.include.RuntimeLib)
|
|
|
882
1157
|
-- ServerScriptService.LoadStringEnabled, so eval_server_runtime works even
|
|
883
1158
|
-- when LoadStringEnabled=false (the default in fresh places).
|
|
884
1159
|
--
|
|
885
|
-
-- Lifecycle:
|
|
886
|
-
--
|
|
887
|
-
--
|
|
888
|
-
--
|
|
889
|
-
--
|
|
1160
|
+
-- Lifecycle: the bridges live PERMANENTLY in the edit DM. Communication
|
|
1161
|
+
-- installs them (ensureBridgesInstalled) when the plugin connects in edit,
|
|
1162
|
+
-- and TestHandlers.startPlaytest force-refreshes them right before
|
|
1163
|
+
-- ExecutePlayModeAsync. ExecutePlayModeAsync clones the DataModel into the
|
|
1164
|
+
-- play DMs, so the scripts come along and run there. We keep them in the edit
|
|
1165
|
+
-- DM after a playtest ends (rather than cleaning up) so that a playtest the
|
|
1166
|
+
-- dev starts MANUALLY via the Studio Play button — not the MCP start_playtest
|
|
1167
|
+
-- tool — also gets the bridges cloned in. This is intentionally a little
|
|
1168
|
+
-- intrusive (two helper scripts visible in Explorer) in exchange for a
|
|
1169
|
+
-- zero-roundtrip eval_*_runtime experience for devs working 1:1 with an agent.
|
|
890
1170
|
--
|
|
891
1171
|
-- Archivable handling: ExecutePlayModeAsync's deep-clone SKIPS instances
|
|
892
1172
|
-- with Archivable=false (verified empirically in v2.9.0 testing - bridges
|
|
@@ -918,9 +1198,9 @@ local BRIDGE_NAMES = {
|
|
|
918
1198
|
-- Embedded Luau. The double `${...}` references our exported names so a
|
|
919
1199
|
-- rename here propagates to both the script source and the tool wrappers.
|
|
920
1200
|
local SERVER_BRIDGE_SOURCE = `\
|
|
921
|
-
--
|
|
922
|
-
--
|
|
923
|
-
--
|
|
1201
|
+
-- Installed by @chrrxs/robloxstudio-mcp to power the eval_server_runtime MCP\
|
|
1202
|
+
-- tool (shared-require-cache eval on the server during playtests). Inert\
|
|
1203
|
+
-- outside Studio (no-ops in live games); safe to leave in place.\
|
|
924
1204
|
\
|
|
925
1205
|
local ServerScriptService = game:GetService("ServerScriptService")\
|
|
926
1206
|
local RunService = game:GetService("RunService")\
|
|
@@ -944,9 +1224,9 @@ bf.OnInvoke = function(payload)\
|
|
|
944
1224
|
end\
|
|
945
1225
|
`
|
|
946
1226
|
local CLIENT_BRIDGE_SOURCE = `\
|
|
947
|
-
--
|
|
948
|
-
--
|
|
949
|
-
--
|
|
1227
|
+
-- Installed by @chrrxs/robloxstudio-mcp to power the eval_client_runtime MCP\
|
|
1228
|
+
-- tool (shared-require-cache eval on the client during playtests). Inert\
|
|
1229
|
+
-- outside Studio (no-ops in live games); safe to leave in place.\
|
|
950
1230
|
\
|
|
951
1231
|
local ReplicatedStorage = game:GetService("ReplicatedStorage")\
|
|
952
1232
|
local RunService = game:GetService("RunService")\
|
|
@@ -969,6 +1249,25 @@ bf.OnInvoke = function(payload)\
|
|
|
969
1249
|
return pcall(require, payload)\
|
|
970
1250
|
end\
|
|
971
1251
|
`
|
|
1252
|
+
-- Stamp written onto each installed bridge Script so we can tell whether the
|
|
1253
|
+
-- bridge currently in the DM was produced by THIS plugin build. It's a djb2
|
|
1254
|
+
-- hash of the actual bridge source plus the plugin version, so ANY change to
|
|
1255
|
+
-- the source (or a version bump) yields a new stamp — which makes
|
|
1256
|
+
-- ensureBridgesInstalled() force a refresh on the next plugin load instead of
|
|
1257
|
+
-- keeping a stale bridge that happens to still be present (e.g. one saved into
|
|
1258
|
+
-- the .rbxl from an older build).
|
|
1259
|
+
local STAMP_ATTR = "__MCPBridgeStamp"
|
|
1260
|
+
local function computeBridgeStamp()
|
|
1261
|
+
local combined = `{SERVER_BRIDGE_SOURCE}|{CLIENT_BRIDGE_SOURCE}`
|
|
1262
|
+
local h = 5381
|
|
1263
|
+
for i = 1, #combined do
|
|
1264
|
+
h = (h * 33 + (string.byte(combined, i))) % 2147483647
|
|
1265
|
+
end
|
|
1266
|
+
-- "2.15.0" is replaced with the package version at package time
|
|
1267
|
+
-- (scripts/build-plugin.mjs injectVersion), so a release bump also restamps.
|
|
1268
|
+
return `{tostring(h)}-2.15.0`
|
|
1269
|
+
end
|
|
1270
|
+
local BRIDGE_STAMP = computeBridgeStamp()
|
|
972
1271
|
local function setSource(scriptInst, source)
|
|
973
1272
|
-- ScriptEditorService is the cleaner API and integrates with Studio's
|
|
974
1273
|
-- edit history; fall back to direct Source mutation (allowed in plugin
|
|
@@ -1004,7 +1303,31 @@ local function cleanupBridges()
|
|
|
1004
1303
|
end)
|
|
1005
1304
|
end
|
|
1006
1305
|
end
|
|
1007
|
-
|
|
1306
|
+
-- Idempotent variant: install only if the bridge scripts aren't already
|
|
1307
|
+
-- present in the edit DM. Used to keep the bridges always available (so a
|
|
1308
|
+
-- playtest the dev starts manually — not via the MCP start_playtest tool —
|
|
1309
|
+
-- still clones them into the play DMs). Cheap no-op when already installed,
|
|
1310
|
+
-- which avoids re-dirtying the place on every plugin reconnect.
|
|
1311
|
+
local installBridges
|
|
1312
|
+
local function ensureBridgesInstalled()
|
|
1313
|
+
local _binding = findBridges()
|
|
1314
|
+
local server = _binding.server
|
|
1315
|
+
local client = _binding.client
|
|
1316
|
+
if server and client then
|
|
1317
|
+
-- Both present — but only skip the reinstall if they were produced by
|
|
1318
|
+
-- THIS build. A mismatched/absent stamp means a stale bridge (older
|
|
1319
|
+
-- plugin, or one persisted in the saved place), so force a refresh.
|
|
1320
|
+
local sStamp = server:GetAttribute(STAMP_ATTR)
|
|
1321
|
+
local cStamp = client:GetAttribute(STAMP_ATTR)
|
|
1322
|
+
if sStamp == BRIDGE_STAMP and cStamp == BRIDGE_STAMP then
|
|
1323
|
+
return {
|
|
1324
|
+
installed = true,
|
|
1325
|
+
}
|
|
1326
|
+
end
|
|
1327
|
+
end
|
|
1328
|
+
return installBridges()
|
|
1329
|
+
end
|
|
1330
|
+
function installBridges()
|
|
1008
1331
|
-- Defensive: clear any stale bridges from a prior unclean exit before
|
|
1009
1332
|
-- inserting fresh. The injected script also self-cleans its
|
|
1010
1333
|
-- ReplicatedStorage/ServerScriptService children at startup, but the
|
|
@@ -1017,6 +1340,7 @@ local function installBridges()
|
|
|
1017
1340
|
-- script. cleanupBridges() removes it from the edit DM when the
|
|
1018
1341
|
-- playtest ends.
|
|
1019
1342
|
setSource(serverScript, SERVER_BRIDGE_SOURCE)
|
|
1343
|
+
serverScript:SetAttribute(STAMP_ATTR, BRIDGE_STAMP)
|
|
1020
1344
|
serverScript.Parent = ServerScriptService
|
|
1021
1345
|
local sps = getStarterPlayerScripts()
|
|
1022
1346
|
if not sps then
|
|
@@ -1025,6 +1349,7 @@ local function installBridges()
|
|
|
1025
1349
|
local clientScript = Instance.new("LocalScript")
|
|
1026
1350
|
clientScript.Name = CLIENT_SCRIPT_NAME
|
|
1027
1351
|
setSource(clientScript, CLIENT_BRIDGE_SOURCE)
|
|
1352
|
+
clientScript:SetAttribute(STAMP_ATTR, BRIDGE_STAMP)
|
|
1028
1353
|
clientScript.Parent = sps
|
|
1029
1354
|
end)
|
|
1030
1355
|
if not ok then
|
|
@@ -1039,6 +1364,7 @@ local function installBridges()
|
|
|
1039
1364
|
end
|
|
1040
1365
|
return {
|
|
1041
1366
|
cleanupBridges = cleanupBridges,
|
|
1367
|
+
ensureBridgesInstalled = ensureBridgesInstalled,
|
|
1042
1368
|
installBridges = installBridges,
|
|
1043
1369
|
BRIDGE_NAMES = BRIDGE_NAMES,
|
|
1044
1370
|
}
|
|
@@ -1904,6 +2230,8 @@ return {
|
|
|
1904
2230
|
<Properties>
|
|
1905
2231
|
<string name="Name">CaptureHandlers</string>
|
|
1906
2232
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
2233
|
+
local TS = require(script.Parent.Parent.Parent.include.RuntimeLib)
|
|
2234
|
+
local RenderMonitor = TS.import(script, script.Parent.Parent, "RenderMonitor")
|
|
1907
2235
|
local CaptureService = game:GetService("CaptureService")
|
|
1908
2236
|
local AssetService = game:GetService("AssetService")
|
|
1909
2237
|
local MAX_TILE_SIZE = 1024
|
|
@@ -2013,7 +2341,20 @@ local function readPixelsTiled(img, w, h)
|
|
|
2013
2341
|
end
|
|
2014
2342
|
return fullBuf
|
|
2015
2343
|
end
|
|
2016
|
-
|
|
2344
|
+
-- Triggers CaptureService:CaptureScreenshot and waits for the temporary
|
|
2345
|
+
-- content id. Works in any DM, including the play CLIENT (where reading the
|
|
2346
|
+
-- pixels back is blocked, but capturing is not). The returned rbxtemp:// id is
|
|
2347
|
+
-- a process-scoped handle: it can be dereferenced from a DIFFERENT, more
|
|
2348
|
+
-- privileged DM (the edit DM) — see captureRead.
|
|
2349
|
+
local function doCaptureScreenshot()
|
|
2350
|
+
-- Fast-fail with a clear reason if the window isn't rendering — otherwise
|
|
2351
|
+
-- CaptureScreenshot's callback never fires and we'd block for the full 10s.
|
|
2352
|
+
local notRendering = RenderMonitor.notRenderingReason()
|
|
2353
|
+
if notRendering ~= nil then
|
|
2354
|
+
return {
|
|
2355
|
+
error = notRendering,
|
|
2356
|
+
}
|
|
2357
|
+
end
|
|
2017
2358
|
local contentId
|
|
2018
2359
|
CaptureService:CaptureScreenshot(function(id)
|
|
2019
2360
|
contentId = id
|
|
@@ -2022,11 +2363,21 @@ local function captureScreenshotData()
|
|
|
2022
2363
|
while contentId == nil do
|
|
2023
2364
|
if tick() - startTime > 10 then
|
|
2024
2365
|
return {
|
|
2025
|
-
error = "Screenshot capture timed out.
|
|
2366
|
+
error = "Screenshot capture timed out (CaptureScreenshot callback never fired). The Studio window is likely minimized or occluded — restore it so the viewport renders. (Known Roblox bug: capture can also fail if the viewport renders a solid color.)",
|
|
2026
2367
|
}
|
|
2027
2368
|
end
|
|
2028
2369
|
task.wait(0.1)
|
|
2029
2370
|
end
|
|
2371
|
+
return {
|
|
2372
|
+
contentId = contentId,
|
|
2373
|
+
}
|
|
2374
|
+
end
|
|
2375
|
+
-- Promotes a CaptureScreenshot content id into an EditableImage and reads its
|
|
2376
|
+
-- RGBA pixels. MUST run in the edit/plugin context: the running game VM lacks
|
|
2377
|
+
-- the privilege to create an EditableImage from a temporary texture id (errors
|
|
2378
|
+
-- "cannot currently create editable image from temporary texture id"), while
|
|
2379
|
+
-- the edit DM can — even for an id captured in the play client DM.
|
|
2380
|
+
local function readContentToBase64(contentId)
|
|
2030
2381
|
local editableOk, editableResult = pcall(function()
|
|
2031
2382
|
return AssetService:CreateEditableImageAsync(Content.fromUri(contentId))
|
|
2032
2383
|
end)
|
|
@@ -2056,12 +2407,36 @@ local function captureScreenshotData()
|
|
|
2056
2407
|
data = base64Data,
|
|
2057
2408
|
}
|
|
2058
2409
|
end
|
|
2410
|
+
-- Edit-mode single shot: capture and read back in the same (edit) context.
|
|
2411
|
+
local function captureScreenshotData()
|
|
2412
|
+
local cap = doCaptureScreenshot()
|
|
2413
|
+
if cap.error ~= nil then
|
|
2414
|
+
return cap
|
|
2415
|
+
end
|
|
2416
|
+
return readContentToBase64(cap.contentId)
|
|
2417
|
+
end
|
|
2059
2418
|
local function captureScreenshot()
|
|
2060
2419
|
return captureScreenshotData()
|
|
2061
2420
|
end
|
|
2421
|
+
-- Play-mode step 1 (run on the CLIENT): capture only, return the temp id.
|
|
2422
|
+
local function captureBegin()
|
|
2423
|
+
return doCaptureScreenshot()
|
|
2424
|
+
end
|
|
2425
|
+
-- Play-mode step 2 (run on EDIT): read pixels from a temp id captured elsewhere.
|
|
2426
|
+
local function captureRead(requestData)
|
|
2427
|
+
local contentId = requestData.contentId
|
|
2428
|
+
if not (contentId ~= "" and contentId) then
|
|
2429
|
+
return {
|
|
2430
|
+
error = "contentId is required",
|
|
2431
|
+
}
|
|
2432
|
+
end
|
|
2433
|
+
return readContentToBase64(contentId)
|
|
2434
|
+
end
|
|
2062
2435
|
return {
|
|
2063
2436
|
captureScreenshotData = captureScreenshotData,
|
|
2064
2437
|
captureScreenshot = captureScreenshot,
|
|
2438
|
+
captureBegin = captureBegin,
|
|
2439
|
+
captureRead = captureRead,
|
|
2065
2440
|
}
|
|
2066
2441
|
]]></string>
|
|
2067
2442
|
</Properties>
|
|
@@ -2070,19 +2445,56 @@ return {
|
|
|
2070
2445
|
<Properties>
|
|
2071
2446
|
<string name="Name">InputHandlers</string>
|
|
2072
2447
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
2073
|
-
local
|
|
2074
|
-
|
|
2075
|
-
|
|
2448
|
+
local TS = require(script.Parent.Parent.Parent.include.RuntimeLib)
|
|
2449
|
+
-- Virtual input via UserInputService:CreateVirtualInput().
|
|
2450
|
+
--
|
|
2451
|
+
-- We deliberately do NOT use VirtualInputManager:Send*Event — those methods
|
|
2452
|
+
-- are gated behind RobloxScriptSecurity ("lacking capability RobloxScript")
|
|
2453
|
+
-- in every context a plugin can reach (edit DM, play server/client DMs), so
|
|
2454
|
+
-- they silently never worked. CreateVirtualInput() is callable without that
|
|
2455
|
+
-- capability and drives the REAL input pipeline: SendKey feeds
|
|
2456
|
+
-- UserInputService.InputBegan/Ended and the control modules (so WASD walks the
|
|
2457
|
+
-- character at full WalkSpeed with controls intact, no Humanoid hijack),
|
|
2458
|
+
-- SendMouseButton feeds UIS and activates GUI buttons (and hit-tests against
|
|
2459
|
+
-- CoreGui), and SendTextInput types into the focused TextBox.
|
|
2460
|
+
--
|
|
2461
|
+
-- Method set on the VirtualInput object (verified live):
|
|
2462
|
+
-- SendKey(isDown: boolean, keyCode: Enum.KeyCode)
|
|
2463
|
+
-- SendMouseButton(position: Vector2, inputType: Enum.UserInputType, isDown: boolean)
|
|
2464
|
+
-- SendTextInput(text: string)
|
|
2465
|
+
-- There is NO SendMouseMove / SendMouseWheel / SendKeyEvent — so "move" and
|
|
2466
|
+
-- "scroll" mouse actions are not supported.
|
|
2467
|
+
--
|
|
2468
|
+
-- Coordinate space: SendMouseButton coordinates are viewport pixels matching
|
|
2469
|
+
-- what capture_screenshot returns (window space, origin at the top-left of the
|
|
2470
|
+
-- rendered viewport). Pass screenshot pixel coordinates straight through. Note
|
|
2471
|
+
-- that UserInputService reports input positions in GUI space, which is offset
|
|
2472
|
+
-- from this by GuiService:GetGuiInset() (~58px on the Y axis) — irrelevant for
|
|
2473
|
+
-- callers who pick coordinates off a screenshot, which is why we do not
|
|
2474
|
+
-- translate here.
|
|
2475
|
+
local RenderMonitor = TS.import(script, script.Parent.Parent, "RenderMonitor")
|
|
2476
|
+
local UserInputService = game:GetService("UserInputService")
|
|
2477
|
+
-- One VirtualInput per plugin VM, reused across calls so that a key held down
|
|
2478
|
+
-- in one call (action="press") and released in a later call (action="release")
|
|
2479
|
+
-- share the same input source.
|
|
2480
|
+
local cachedVI
|
|
2481
|
+
local function getVI()
|
|
2482
|
+
if cachedVI then
|
|
2483
|
+
return cachedVI
|
|
2484
|
+
end
|
|
2485
|
+
local ok, vi = pcall(function()
|
|
2486
|
+
return UserInputService:CreateVirtualInput()
|
|
2076
2487
|
end)
|
|
2077
|
-
if ok and
|
|
2078
|
-
|
|
2488
|
+
if ok and vi ~= nil then
|
|
2489
|
+
cachedVI = vi
|
|
2490
|
+
return cachedVI
|
|
2079
2491
|
end
|
|
2080
2492
|
return nil
|
|
2081
2493
|
end
|
|
2082
|
-
local
|
|
2083
|
-
Left =
|
|
2084
|
-
Right =
|
|
2085
|
-
Middle =
|
|
2494
|
+
local MOUSE_TYPE_MAP = {
|
|
2495
|
+
Left = Enum.UserInputType.MouseButton1,
|
|
2496
|
+
Right = Enum.UserInputType.MouseButton2,
|
|
2497
|
+
Middle = Enum.UserInputType.MouseButton3,
|
|
2086
2498
|
}
|
|
2087
2499
|
local function simulateMouseInput(requestData)
|
|
2088
2500
|
local action = requestData.action
|
|
@@ -2093,56 +2505,43 @@ local function simulateMouseInput(requestData)
|
|
|
2093
2505
|
_condition = "Left"
|
|
2094
2506
|
end
|
|
2095
2507
|
local button = _condition
|
|
2096
|
-
local scrollDirection = requestData.scrollDirection
|
|
2097
2508
|
if not (action ~= "" and action) then
|
|
2098
2509
|
return {
|
|
2099
2510
|
error = "action is required",
|
|
2100
2511
|
}
|
|
2101
2512
|
end
|
|
2102
|
-
|
|
2103
|
-
if not vim then
|
|
2513
|
+
if x == nil or y == nil then
|
|
2104
2514
|
return {
|
|
2105
|
-
error = "
|
|
2515
|
+
error = "x and y are required",
|
|
2106
2516
|
}
|
|
2107
2517
|
end
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2518
|
+
-- Input is silently dropped by the engine when the window isn't rendering
|
|
2519
|
+
-- (e.g. minimized). Surface that instead of returning a false success.
|
|
2520
|
+
local notRendering = RenderMonitor.notRenderingReason()
|
|
2521
|
+
if notRendering ~= nil then
|
|
2522
|
+
return {
|
|
2523
|
+
error = notRendering,
|
|
2524
|
+
}
|
|
2525
|
+
end
|
|
2526
|
+
local vi = getVI()
|
|
2527
|
+
if not vi then
|
|
2528
|
+
return {
|
|
2529
|
+
error = "UserInputService:CreateVirtualInput() is not available in this context",
|
|
2530
|
+
}
|
|
2111
2531
|
end
|
|
2112
|
-
local
|
|
2532
|
+
local inputType = MOUSE_TYPE_MAP[button] or Enum.UserInputType.MouseButton1
|
|
2533
|
+
local pos = Vector2.new(x, y)
|
|
2113
2534
|
local success, err = pcall(function()
|
|
2114
2535
|
if action == "click" then
|
|
2115
|
-
|
|
2116
|
-
error("x and y are required for click")
|
|
2117
|
-
end
|
|
2118
|
-
vim:SendMouseButtonEvent(x, y, buttonNum, true)
|
|
2536
|
+
vi:SendMouseButton(pos, inputType, true)
|
|
2119
2537
|
task.wait(0.05)
|
|
2120
|
-
|
|
2538
|
+
vi:SendMouseButton(pos, inputType, false)
|
|
2121
2539
|
elseif action == "mouseDown" then
|
|
2122
|
-
|
|
2123
|
-
error("x and y are required for mouseDown")
|
|
2124
|
-
end
|
|
2125
|
-
vim:SendMouseButtonEvent(x, y, buttonNum, true)
|
|
2540
|
+
vi:SendMouseButton(pos, inputType, true)
|
|
2126
2541
|
elseif action == "mouseUp" then
|
|
2127
|
-
|
|
2128
|
-
error("x and y are required for mouseUp")
|
|
2129
|
-
end
|
|
2130
|
-
vim:SendMouseButtonEvent(x, y, buttonNum, false)
|
|
2131
|
-
elseif action == "move" then
|
|
2132
|
-
if x == nil or y == nil then
|
|
2133
|
-
error("x and y are required for move")
|
|
2134
|
-
end
|
|
2135
|
-
vim:SendMouseMoveEvent(x, y)
|
|
2136
|
-
elseif action == "scroll" then
|
|
2137
|
-
if x == nil or y == nil then
|
|
2138
|
-
error("x and y are required for scroll")
|
|
2139
|
-
end
|
|
2140
|
-
if not (scrollDirection ~= "" and scrollDirection) then
|
|
2141
|
-
error("scrollDirection is required for scroll")
|
|
2142
|
-
end
|
|
2143
|
-
vim:SendMouseWheelEvent(x, y, scrollDirection == "up")
|
|
2542
|
+
vi:SendMouseButton(pos, inputType, false)
|
|
2144
2543
|
else
|
|
2145
|
-
error(`
|
|
2544
|
+
error(`Unsupported action "{action}". CreateVirtualInput supports click, mouseDown, mouseUp ` .. `(no move/scroll — those methods don't exist on VirtualInput).`)
|
|
2146
2545
|
end
|
|
2147
2546
|
end)
|
|
2148
2547
|
if success then
|
|
@@ -2159,7 +2558,40 @@ local function simulateMouseInput(requestData)
|
|
|
2159
2558
|
}
|
|
2160
2559
|
end
|
|
2161
2560
|
local function simulateKeyboardInput(requestData)
|
|
2561
|
+
local notRendering = RenderMonitor.notRenderingReason()
|
|
2562
|
+
if notRendering ~= nil then
|
|
2563
|
+
return {
|
|
2564
|
+
error = notRendering,
|
|
2565
|
+
}
|
|
2566
|
+
end
|
|
2567
|
+
local vi = getVI()
|
|
2568
|
+
if not vi then
|
|
2569
|
+
return {
|
|
2570
|
+
error = "UserInputService:CreateVirtualInput() is not available in this context",
|
|
2571
|
+
}
|
|
2572
|
+
end
|
|
2573
|
+
-- Text mode: type a string into the focused TextBox.
|
|
2574
|
+
local text = requestData.text
|
|
2575
|
+
if text ~= nil then
|
|
2576
|
+
local ok, err = pcall(function()
|
|
2577
|
+
return vi:SendTextInput(text)
|
|
2578
|
+
end)
|
|
2579
|
+
if ok then
|
|
2580
|
+
return {
|
|
2581
|
+
success = true,
|
|
2582
|
+
text = text,
|
|
2583
|
+
}
|
|
2584
|
+
end
|
|
2585
|
+
return {
|
|
2586
|
+
error = `Failed to send text input: {err}`,
|
|
2587
|
+
}
|
|
2588
|
+
end
|
|
2162
2589
|
local keyCodeName = requestData.keyCode
|
|
2590
|
+
if not (keyCodeName ~= "" and keyCodeName) then
|
|
2591
|
+
return {
|
|
2592
|
+
error = "keyCode (or text) is required",
|
|
2593
|
+
}
|
|
2594
|
+
end
|
|
2163
2595
|
local _condition = (requestData.action)
|
|
2164
2596
|
if _condition == nil then
|
|
2165
2597
|
_condition = "tap"
|
|
@@ -2170,17 +2602,6 @@ local function simulateKeyboardInput(requestData)
|
|
|
2170
2602
|
_condition_1 = 0.1
|
|
2171
2603
|
end
|
|
2172
2604
|
local duration = _condition_1
|
|
2173
|
-
if not (keyCodeName ~= "" and keyCodeName) then
|
|
2174
|
-
return {
|
|
2175
|
-
error = "keyCode is required",
|
|
2176
|
-
}
|
|
2177
|
-
end
|
|
2178
|
-
local vim = getVIM()
|
|
2179
|
-
if not vim then
|
|
2180
|
-
return {
|
|
2181
|
-
error = "VirtualInputManager is not available in this context",
|
|
2182
|
-
}
|
|
2183
|
-
end
|
|
2184
2605
|
local enumOk, keyCode = pcall(function()
|
|
2185
2606
|
return (Enum.KeyCode)[keyCodeName]
|
|
2186
2607
|
end)
|
|
@@ -2191,13 +2612,13 @@ local function simulateKeyboardInput(requestData)
|
|
|
2191
2612
|
end
|
|
2192
2613
|
local success, err = pcall(function()
|
|
2193
2614
|
if action == "press" then
|
|
2194
|
-
|
|
2615
|
+
vi:SendKey(true, keyCode)
|
|
2195
2616
|
elseif action == "release" then
|
|
2196
|
-
|
|
2617
|
+
vi:SendKey(false, keyCode)
|
|
2197
2618
|
elseif action == "tap" then
|
|
2198
|
-
|
|
2619
|
+
vi:SendKey(true, keyCode)
|
|
2199
2620
|
task.wait(duration)
|
|
2200
|
-
|
|
2621
|
+
vi:SendKey(false, keyCode)
|
|
2201
2622
|
else
|
|
2202
2623
|
error(`Unknown action: {action}`)
|
|
2203
2624
|
end
|
|
@@ -2827,11 +3248,10 @@ return {
|
|
|
2827
3248
|
<string name="Name">MetadataHandlers</string>
|
|
2828
3249
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
2829
3250
|
local TS = require(script.Parent.Parent.Parent.include.RuntimeLib)
|
|
2830
|
-
local
|
|
2831
|
-
local CollectionService = _services.CollectionService
|
|
2832
|
-
local LogService = _services.LogService
|
|
3251
|
+
local CollectionService = TS.import(script, script.Parent.Parent.Parent, "node_modules", "@rbxts", "services").CollectionService
|
|
2833
3252
|
local Utils = TS.import(script, script.Parent.Parent, "Utils")
|
|
2834
3253
|
local Recording = TS.import(script, script.Parent.Parent, "Recording")
|
|
3254
|
+
local LuauExec = TS.import(script, script.Parent.Parent, "LuauExec")
|
|
2835
3255
|
local ChangeHistoryService = game:GetService("ChangeHistoryService")
|
|
2836
3256
|
local Selection = game:GetService("Selection")
|
|
2837
3257
|
local _binding = Utils
|
|
@@ -3257,137 +3677,11 @@ local function executeLuau(requestData)
|
|
|
3257
3677
|
error = "Code is required",
|
|
3258
3678
|
}
|
|
3259
3679
|
end
|
|
3260
|
-
--
|
|
3261
|
-
--
|
|
3262
|
-
--
|
|
3263
|
-
--
|
|
3264
|
-
|
|
3265
|
-
-- generic "Requested module experienced an error while loading"
|
|
3266
|
-
-- message. Wrapping user code in xpcall INSIDE the IIFE keeps the
|
|
3267
|
-
-- ModuleScript itself returning successfully — the real error +
|
|
3268
|
-
-- traceback live in the returned table.
|
|
3269
|
-
--
|
|
3270
|
-
-- 2. The ModuleScript path runs in its own environment, so a plugin-
|
|
3271
|
-
-- side getfenv print/warn override never reached user prints. A
|
|
3272
|
-
-- lexical local print/warn inside the IIFE captures user prints
|
|
3273
|
-
-- regardless of which path executes. We also call the real global
|
|
3274
|
-
-- print/warn so messages still flow to Studio's output and
|
|
3275
|
-
-- LogService.MessageOut (which powers get_runtime_logs).
|
|
3276
|
-
--
|
|
3277
|
-
-- Prints from required sub-modules don't reach this capture (they have
|
|
3278
|
-
-- their own env) — those go through the runtime log buffer.
|
|
3279
|
-
local wrapped = `return ((function()\
|
|
3280
|
-
\tlocal __mcp_output = \{\}\
|
|
3281
|
-
\tlocal __mcp_real_print = print\
|
|
3282
|
-
\tlocal __mcp_real_warn = warn\
|
|
3283
|
-
\tlocal print = function(...)\
|
|
3284
|
-
\t\t__mcp_real_print(...)\
|
|
3285
|
-
\t\tlocal args = \{...\}\
|
|
3286
|
-
\t\tlocal parts = table.create(#args)\
|
|
3287
|
-
\t\tfor i, a in ipairs(args) do parts[i] = tostring(a) end\
|
|
3288
|
-
\t\ttable.insert(__mcp_output, table.concat(parts, "\\t"))\
|
|
3289
|
-
\tend\
|
|
3290
|
-
\tlocal warn = function(...)\
|
|
3291
|
-
\t\t__mcp_real_warn(...)\
|
|
3292
|
-
\t\tlocal args = \{...\}\
|
|
3293
|
-
\t\tlocal parts = table.create(#args)\
|
|
3294
|
-
\t\tfor i, a in ipairs(args) do parts[i] = tostring(a) end\
|
|
3295
|
-
\t\ttable.insert(__mcp_output, "[warn] " .. table.concat(parts, "\\t"))\
|
|
3296
|
-
\tend\
|
|
3297
|
-
\tlocal function __mcp_run()\
|
|
3298
|
-
{code}\
|
|
3299
|
-
\tend\
|
|
3300
|
-
\tlocal ok, errOrValue = xpcall(__mcp_run, debug.traceback)\
|
|
3301
|
-
\treturn \{ ok = ok, value = errOrValue, output = __mcp_output \}\
|
|
3302
|
-
end)())`
|
|
3303
|
-
local runViaModuleScript = function()
|
|
3304
|
-
local m = Instance.new("ModuleScript")
|
|
3305
|
-
m.Name = "__MCPExecLuauPayload"
|
|
3306
|
-
local okSet, setErr = pcall(function()
|
|
3307
|
-
m.Source = wrapped
|
|
3308
|
-
end)
|
|
3309
|
-
if not okSet then
|
|
3310
|
-
m:Destroy()
|
|
3311
|
-
error(`ModuleScript Source set failed: {tostring(setErr)}`)
|
|
3312
|
-
end
|
|
3313
|
-
m.Parent = game:GetService("Workspace")
|
|
3314
|
-
local okReq, reqResult = pcall(function()
|
|
3315
|
-
return require(m)
|
|
3316
|
-
end)
|
|
3317
|
-
m:Destroy()
|
|
3318
|
-
if not okReq then
|
|
3319
|
-
local errMsg = tostring(reqResult)
|
|
3320
|
-
-- pcall(require, m) collapses parse/compile failures into the
|
|
3321
|
-
-- canned engine string below. Walk LogService backward for the
|
|
3322
|
-
-- real diagnostic, which was emitted to MessageOut just before.
|
|
3323
|
-
if errMsg == "Requested module experienced an error while loading" then
|
|
3324
|
-
-- The parser diagnostic is emitted to LogService on the next
|
|
3325
|
-
-- engine frame, not synchronously with pcall(require). task.wait(0)
|
|
3326
|
-
-- yields too early; 50ms is enough to let the frame complete and
|
|
3327
|
-
-- the message land in GetLogHistory.
|
|
3328
|
-
task.wait(0.05)
|
|
3329
|
-
local hist = LogService:GetLogHistory()
|
|
3330
|
-
for i = #hist - 1, 0, -1 do
|
|
3331
|
-
local e = hist[i + 1]
|
|
3332
|
-
if e.messageType == Enum.MessageType.MessageError and string.sub(e.message, 1, 31) == "Workspace.__MCPExecLuauPayload:" then
|
|
3333
|
-
errMsg = e.message
|
|
3334
|
-
break
|
|
3335
|
-
end
|
|
3336
|
-
end
|
|
3337
|
-
end
|
|
3338
|
-
error(errMsg)
|
|
3339
|
-
end
|
|
3340
|
-
return reqResult
|
|
3341
|
-
end
|
|
3342
|
-
local isLoadstringUnavailable = function(err)
|
|
3343
|
-
local errStr = tostring(err)
|
|
3344
|
-
local matchStart = string.find(errStr, "not available", 1, true)
|
|
3345
|
-
return matchStart ~= nil
|
|
3346
|
-
end
|
|
3347
|
-
local success, result = pcall(function()
|
|
3348
|
-
local fn, compileError = loadstring(wrapped)
|
|
3349
|
-
if not fn then
|
|
3350
|
-
if isLoadstringUnavailable(compileError) then
|
|
3351
|
-
return runViaModuleScript()
|
|
3352
|
-
end
|
|
3353
|
-
error(`Compile error: {compileError}`)
|
|
3354
|
-
end
|
|
3355
|
-
return fn()
|
|
3356
|
-
end)
|
|
3357
|
-
-- loadstring throws (not returns nil) in some plugin contexts when
|
|
3358
|
-
-- LoadStringEnabled=false. Catch that as a second-chance fallback.
|
|
3359
|
-
if not success and isLoadstringUnavailable(result) then
|
|
3360
|
-
success, result = pcall(runViaModuleScript)
|
|
3361
|
-
end
|
|
3362
|
-
if not success then
|
|
3363
|
-
-- Outer pcall failed - the wrapper itself didn't even run (e.g. compile
|
|
3364
|
-
-- error in the user code, or ModuleScript setup error). 'result' is the
|
|
3365
|
-
-- raw error string from pcall.
|
|
3366
|
-
return {
|
|
3367
|
-
success = false,
|
|
3368
|
-
error = tostring(result),
|
|
3369
|
-
output = {},
|
|
3370
|
-
message = "Code execution failed",
|
|
3371
|
-
}
|
|
3372
|
-
end
|
|
3373
|
-
-- Wrapper executed - unpack { ok, value, output }.
|
|
3374
|
-
local r = result
|
|
3375
|
-
local capturedOutput = r.output
|
|
3376
|
-
local output = if capturedOutput ~= nil then capturedOutput else ({})
|
|
3377
|
-
if r.ok == true then
|
|
3378
|
-
return {
|
|
3379
|
-
success = true,
|
|
3380
|
-
returnValue = if r.value ~= nil then tostring(r.value) else nil,
|
|
3381
|
-
output = output,
|
|
3382
|
-
message = "Code executed successfully",
|
|
3383
|
-
}
|
|
3384
|
-
end
|
|
3385
|
-
return {
|
|
3386
|
-
success = false,
|
|
3387
|
-
error = if r.value ~= nil then tostring(r.value) else "(unknown error)",
|
|
3388
|
-
output = output,
|
|
3389
|
-
message = "Code execution failed",
|
|
3390
|
-
}
|
|
3680
|
+
-- All wrapping, print/warn capture, loadstring fallback, JSON-encoding
|
|
3681
|
+
-- of table returns, and parse-error recovery live in LuauExec so the
|
|
3682
|
+
-- edit/server (this handler) and the play-client (ClientBroker) take
|
|
3683
|
+
-- the same code path and produce identical output shapes.
|
|
3684
|
+
return LuauExec.execute(code)
|
|
3391
3685
|
end
|
|
3392
3686
|
local function undo(_requestData)
|
|
3393
3687
|
local success, result = pcall(function()
|
|
@@ -4784,28 +5078,277 @@ return {
|
|
|
4784
5078
|
</Item>
|
|
4785
5079
|
<Item class="ModuleScript" referent="16">
|
|
4786
5080
|
<Properties>
|
|
4787
|
-
<string name="Name">
|
|
5081
|
+
<string name="Name">SceneAnalysisHandlers</string>
|
|
4788
5082
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
4789
|
-
local
|
|
4790
|
-
|
|
4791
|
-
|
|
4792
|
-
|
|
4793
|
-
|
|
4794
|
-
|
|
4795
|
-
|
|
4796
|
-
|
|
4797
|
-
|
|
4798
|
-
|
|
4799
|
-
|
|
4800
|
-
|
|
4801
|
-
|
|
4802
|
-
|
|
4803
|
-
|
|
4804
|
-
|
|
4805
|
-
|
|
4806
|
-
|
|
4807
|
-
|
|
4808
|
-
|
|
5083
|
+
local MODE_CONFIGS = {
|
|
5084
|
+
instance_composition = {
|
|
5085
|
+
method = "GetInstanceCompositionAsync",
|
|
5086
|
+
query = function(service)
|
|
5087
|
+
return service:GetInstanceCompositionAsync()
|
|
5088
|
+
end,
|
|
5089
|
+
},
|
|
5090
|
+
script_memory = {
|
|
5091
|
+
method = "GetScriptMemoryAsync",
|
|
5092
|
+
query = function(service)
|
|
5093
|
+
return service:GetScriptMemoryAsync()
|
|
5094
|
+
end,
|
|
5095
|
+
},
|
|
5096
|
+
unparented_instances = {
|
|
5097
|
+
method = "GetUnparentedInstancesAsync",
|
|
5098
|
+
query = function(service)
|
|
5099
|
+
return service:GetUnparentedInstancesAsync()
|
|
5100
|
+
end,
|
|
5101
|
+
},
|
|
5102
|
+
triangle_composition = {
|
|
5103
|
+
method = "GetTriangleCompositionAsync",
|
|
5104
|
+
query = function(service)
|
|
5105
|
+
return service:GetTriangleCompositionAsync()
|
|
5106
|
+
end,
|
|
5107
|
+
sortByTriangles = true,
|
|
5108
|
+
},
|
|
5109
|
+
animation_memory = {
|
|
5110
|
+
method = "GetAnimationMemoryAsync",
|
|
5111
|
+
query = function(service)
|
|
5112
|
+
return service:GetAnimationMemoryAsync()
|
|
5113
|
+
end,
|
|
5114
|
+
},
|
|
5115
|
+
audio_memory = {
|
|
5116
|
+
method = "GetAudioMemoryAsync",
|
|
5117
|
+
query = function(service)
|
|
5118
|
+
return service:GetAudioMemoryAsync()
|
|
5119
|
+
end,
|
|
5120
|
+
},
|
|
5121
|
+
}
|
|
5122
|
+
local ALL_MODES = { "instance_composition", "script_memory", "unparented_instances", "triangle_composition", "animation_memory", "audio_memory" }
|
|
5123
|
+
local function betaDisabledError()
|
|
5124
|
+
return {
|
|
5125
|
+
error = "scene_analysis_not_enabled",
|
|
5126
|
+
message = "SceneAnalysisService is not enabled. Enable Scene Analysis in Studio Beta Features and restart Studio.",
|
|
5127
|
+
betaFeatureRequired = true,
|
|
5128
|
+
}
|
|
5129
|
+
end
|
|
5130
|
+
local function isBetaDisabledError(value)
|
|
5131
|
+
local _value = value
|
|
5132
|
+
local _condition = type(_value) == "string"
|
|
5133
|
+
if _condition then
|
|
5134
|
+
_condition = (string.find(value, "SceneAnalysisService is not enabled", 1, true)) ~= nil
|
|
5135
|
+
end
|
|
5136
|
+
return _condition
|
|
5137
|
+
end
|
|
5138
|
+
local function getSceneAnalysisService()
|
|
5139
|
+
local provider = game
|
|
5140
|
+
local ok, service = pcall(function()
|
|
5141
|
+
return provider:GetService("SceneAnalysisService")
|
|
5142
|
+
end)
|
|
5143
|
+
if not ok or not service then
|
|
5144
|
+
return {
|
|
5145
|
+
error = "scene_analysis_unavailable",
|
|
5146
|
+
message = `SceneAnalysisService is unavailable: {tostring(service)}`,
|
|
5147
|
+
}
|
|
5148
|
+
end
|
|
5149
|
+
return service
|
|
5150
|
+
end
|
|
5151
|
+
local function normalizeMode(mode)
|
|
5152
|
+
if mode == nil or mode == "all" then
|
|
5153
|
+
return "all"
|
|
5154
|
+
end
|
|
5155
|
+
local _mode = mode
|
|
5156
|
+
local _condition = not (type(_mode) == "string")
|
|
5157
|
+
if not _condition then
|
|
5158
|
+
_condition = MODE_CONFIGS[mode] == nil
|
|
5159
|
+
end
|
|
5160
|
+
if _condition then
|
|
5161
|
+
return {
|
|
5162
|
+
error = "invalid_mode",
|
|
5163
|
+
message = `mode must be one of: all, {table.concat(ALL_MODES, ", ")}`,
|
|
5164
|
+
}
|
|
5165
|
+
end
|
|
5166
|
+
return mode
|
|
5167
|
+
end
|
|
5168
|
+
local function normalizeTopN(topN)
|
|
5169
|
+
local _topN = topN
|
|
5170
|
+
if not (type(_topN) == "number") then
|
|
5171
|
+
return 10
|
|
5172
|
+
end
|
|
5173
|
+
return math.clamp(math.floor(topN), 1, 100)
|
|
5174
|
+
end
|
|
5175
|
+
local function countLeaves(node)
|
|
5176
|
+
local children = node.Children
|
|
5177
|
+
if children and #children > 0 then
|
|
5178
|
+
local total = 0
|
|
5179
|
+
for _, child in children do
|
|
5180
|
+
total += countLeaves(child)
|
|
5181
|
+
end
|
|
5182
|
+
return total
|
|
5183
|
+
end
|
|
5184
|
+
return 1
|
|
5185
|
+
end
|
|
5186
|
+
local function flattenLeaves(node, out)
|
|
5187
|
+
local children = node.Children
|
|
5188
|
+
if children and #children > 0 then
|
|
5189
|
+
for _, child in children do
|
|
5190
|
+
flattenLeaves(child, out)
|
|
5191
|
+
end
|
|
5192
|
+
return nil
|
|
5193
|
+
end
|
|
5194
|
+
local _out = out
|
|
5195
|
+
local _node = node
|
|
5196
|
+
table.insert(_out, _node)
|
|
5197
|
+
end
|
|
5198
|
+
local function compactEntry(node)
|
|
5199
|
+
local entry = {
|
|
5200
|
+
name = node.Name,
|
|
5201
|
+
}
|
|
5202
|
+
if node.Size ~= nil then
|
|
5203
|
+
entry.size = node.Size
|
|
5204
|
+
end
|
|
5205
|
+
if node.Sizes ~= nil then
|
|
5206
|
+
entry.sizes = node.Sizes
|
|
5207
|
+
end
|
|
5208
|
+
if node.AssetId ~= nil then
|
|
5209
|
+
entry.asset_id = node.AssetId
|
|
5210
|
+
end
|
|
5211
|
+
return entry
|
|
5212
|
+
end
|
|
5213
|
+
local function compactRoot(node, leafCount)
|
|
5214
|
+
local children = node.Children
|
|
5215
|
+
local root = {
|
|
5216
|
+
name = node.Name,
|
|
5217
|
+
child_count = if children then #children else 0,
|
|
5218
|
+
leaf_count = leafCount,
|
|
5219
|
+
}
|
|
5220
|
+
if node.Size ~= nil then
|
|
5221
|
+
root.size = node.Size
|
|
5222
|
+
end
|
|
5223
|
+
if node.Sizes ~= nil then
|
|
5224
|
+
root.sizes = node.Sizes
|
|
5225
|
+
end
|
|
5226
|
+
return root
|
|
5227
|
+
end
|
|
5228
|
+
local function metric(node, sortByTriangles)
|
|
5229
|
+
if sortByTriangles then
|
|
5230
|
+
local sizes = node.Sizes
|
|
5231
|
+
local triangles = if sizes then sizes.Triangles else nil
|
|
5232
|
+
local _condition = triangles
|
|
5233
|
+
if _condition == nil then
|
|
5234
|
+
_condition = 0
|
|
5235
|
+
end
|
|
5236
|
+
return _condition
|
|
5237
|
+
end
|
|
5238
|
+
local _condition = node.Size
|
|
5239
|
+
if _condition == nil then
|
|
5240
|
+
_condition = 0
|
|
5241
|
+
end
|
|
5242
|
+
return _condition
|
|
5243
|
+
end
|
|
5244
|
+
local function summarizeMode(mode, config, service, topN, raw)
|
|
5245
|
+
local started = os.clock()
|
|
5246
|
+
local ok, result = pcall(function()
|
|
5247
|
+
return config.query(service)
|
|
5248
|
+
end)
|
|
5249
|
+
local elapsedMs = math.floor((os.clock() - started) * 1000)
|
|
5250
|
+
if not ok then
|
|
5251
|
+
if isBetaDisabledError(result) then
|
|
5252
|
+
return betaDisabledError()
|
|
5253
|
+
end
|
|
5254
|
+
return {
|
|
5255
|
+
error = "scene_analysis_query_failed",
|
|
5256
|
+
mode = mode,
|
|
5257
|
+
method = config.method,
|
|
5258
|
+
message = tostring(result),
|
|
5259
|
+
}
|
|
5260
|
+
end
|
|
5261
|
+
local tree = result
|
|
5262
|
+
local leaves = {}
|
|
5263
|
+
flattenLeaves(tree, leaves)
|
|
5264
|
+
table.sort(leaves, function(a, b)
|
|
5265
|
+
return metric(a, config.sortByTriangles == true) > metric(b, config.sortByTriangles == true)
|
|
5266
|
+
end)
|
|
5267
|
+
local top = {}
|
|
5268
|
+
do
|
|
5269
|
+
local i = 0
|
|
5270
|
+
local _shouldIncrement = false
|
|
5271
|
+
while true do
|
|
5272
|
+
if _shouldIncrement then
|
|
5273
|
+
i += 1
|
|
5274
|
+
else
|
|
5275
|
+
_shouldIncrement = true
|
|
5276
|
+
end
|
|
5277
|
+
if not (i < math.min(topN, #leaves)) then
|
|
5278
|
+
break
|
|
5279
|
+
end
|
|
5280
|
+
local _arg0 = compactEntry(leaves[i + 1])
|
|
5281
|
+
table.insert(top, _arg0)
|
|
5282
|
+
end
|
|
5283
|
+
end
|
|
5284
|
+
local body = {
|
|
5285
|
+
mode = mode,
|
|
5286
|
+
method = config.method,
|
|
5287
|
+
elapsed_ms = elapsedMs,
|
|
5288
|
+
root = compactRoot(tree, #leaves),
|
|
5289
|
+
top = top,
|
|
5290
|
+
}
|
|
5291
|
+
if raw then
|
|
5292
|
+
body.tree = tree
|
|
5293
|
+
end
|
|
5294
|
+
return body
|
|
5295
|
+
end
|
|
5296
|
+
local function getSceneAnalysis(requestData)
|
|
5297
|
+
local mode = normalizeMode(requestData.mode)
|
|
5298
|
+
if not (type(mode) == "string") then
|
|
5299
|
+
return mode
|
|
5300
|
+
end
|
|
5301
|
+
local serviceOrError = getSceneAnalysisService()
|
|
5302
|
+
local _value = serviceOrError.IsA
|
|
5303
|
+
if not (_value ~= 0 and _value == _value and _value ~= "" and _value) then
|
|
5304
|
+
return serviceOrError
|
|
5305
|
+
end
|
|
5306
|
+
local service = serviceOrError
|
|
5307
|
+
local topN = normalizeTopN(requestData.topN)
|
|
5308
|
+
local raw = requestData.raw == true
|
|
5309
|
+
if mode ~= "all" then
|
|
5310
|
+
return summarizeMode(mode, MODE_CONFIGS[mode], service, topN, raw)
|
|
5311
|
+
end
|
|
5312
|
+
local body = {}
|
|
5313
|
+
for _, m in ALL_MODES do
|
|
5314
|
+
local result = summarizeMode(m, MODE_CONFIGS[m], service, topN, raw)
|
|
5315
|
+
if result.error == "scene_analysis_not_enabled" then
|
|
5316
|
+
return result
|
|
5317
|
+
end
|
|
5318
|
+
body[m] = result
|
|
5319
|
+
end
|
|
5320
|
+
return body
|
|
5321
|
+
end
|
|
5322
|
+
return {
|
|
5323
|
+
getSceneAnalysis = getSceneAnalysis,
|
|
5324
|
+
}
|
|
5325
|
+
]]></string>
|
|
5326
|
+
</Properties>
|
|
5327
|
+
</Item>
|
|
5328
|
+
<Item class="ModuleScript" referent="17">
|
|
5329
|
+
<Properties>
|
|
5330
|
+
<string name="Name">ScriptHandlers</string>
|
|
5331
|
+
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
5332
|
+
local TS = require(script.Parent.Parent.Parent.include.RuntimeLib)
|
|
5333
|
+
local Utils = TS.import(script, script.Parent.Parent, "Utils")
|
|
5334
|
+
local Recording = TS.import(script, script.Parent.Parent, "Recording")
|
|
5335
|
+
local ScriptEditorService = game:GetService("ScriptEditorService")
|
|
5336
|
+
local _binding = Utils
|
|
5337
|
+
local getInstancePath = _binding.getInstancePath
|
|
5338
|
+
local getInstanceByPath = _binding.getInstanceByPath
|
|
5339
|
+
local readScriptSource = _binding.readScriptSource
|
|
5340
|
+
local splitLines = _binding.splitLines
|
|
5341
|
+
local joinLines = _binding.joinLines
|
|
5342
|
+
local _binding_1 = Recording
|
|
5343
|
+
local beginRecording = _binding_1.beginRecording
|
|
5344
|
+
local finishRecording = _binding_1.finishRecording
|
|
5345
|
+
local function normalizeEscapes(s)
|
|
5346
|
+
local result = s
|
|
5347
|
+
result = (string.gsub(result, "\\\\", "\x01"))
|
|
5348
|
+
result = (string.gsub(result, "\\n", "\n"))
|
|
5349
|
+
result = (string.gsub(result, "\\t", "\t"))
|
|
5350
|
+
result = (string.gsub(result, "\\r", "\r"))
|
|
5351
|
+
result = (string.gsub(result, '\\"', '"'))
|
|
4809
5352
|
result = (string.gsub(result, "\x01", "\\"))
|
|
4810
5353
|
return result
|
|
4811
5354
|
end
|
|
@@ -5478,7 +6021,7 @@ return {
|
|
|
5478
6021
|
]]></string>
|
|
5479
6022
|
</Properties>
|
|
5480
6023
|
</Item>
|
|
5481
|
-
<Item class="ModuleScript" referent="
|
|
6024
|
+
<Item class="ModuleScript" referent="18">
|
|
5482
6025
|
<Properties>
|
|
5483
6026
|
<string name="Name">SerializationHandlers</string>
|
|
5484
6027
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -5664,7 +6207,7 @@ return {
|
|
|
5664
6207
|
]]></string>
|
|
5665
6208
|
</Properties>
|
|
5666
6209
|
</Item>
|
|
5667
|
-
<Item class="ModuleScript" referent="
|
|
6210
|
+
<Item class="ModuleScript" referent="19">
|
|
5668
6211
|
<Properties>
|
|
5669
6212
|
<string name="Name">TestHandlers</string>
|
|
5670
6213
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -5672,10 +6215,11 @@ local TS = require(script.Parent.Parent.Parent.include.RuntimeLib)
|
|
|
5672
6215
|
local _services = TS.import(script, script.Parent.Parent.Parent, "node_modules", "@rbxts", "services")
|
|
5673
6216
|
local HttpService = _services.HttpService
|
|
5674
6217
|
local LogService = _services.LogService
|
|
6218
|
+
local Players = _services.Players
|
|
5675
6219
|
local RunService = _services.RunService
|
|
5676
6220
|
local _EvalBridges = TS.import(script, script.Parent.Parent, "EvalBridges")
|
|
5677
6221
|
local installBridges = _EvalBridges.installBridges
|
|
5678
|
-
local
|
|
6222
|
+
local ensureBridgesInstalled = _EvalBridges.ensureBridgesInstalled
|
|
5679
6223
|
local StopPlayMonitor = TS.import(script, script.Parent.Parent, "StopPlayMonitor")
|
|
5680
6224
|
local StudioTestService = game:GetService("StudioTestService")
|
|
5681
6225
|
local ServerScriptService = game:GetService("ServerScriptService")
|
|
@@ -5693,6 +6237,63 @@ local testResult
|
|
|
5693
6237
|
local testError
|
|
5694
6238
|
local stopListenerScript
|
|
5695
6239
|
local navResultCallback
|
|
6240
|
+
local multiplayerState = {
|
|
6241
|
+
phase = "idle",
|
|
6242
|
+
}
|
|
6243
|
+
local function detectPeerRole()
|
|
6244
|
+
if not RunService:IsRunning() then
|
|
6245
|
+
return "edit"
|
|
6246
|
+
end
|
|
6247
|
+
if RunService:IsServer() then
|
|
6248
|
+
return "server"
|
|
6249
|
+
end
|
|
6250
|
+
return "client"
|
|
6251
|
+
end
|
|
6252
|
+
local function getPlayersSnapshot()
|
|
6253
|
+
local _exp = Players:GetPlayers()
|
|
6254
|
+
-- ▼ ReadonlyArray.map ▼
|
|
6255
|
+
local _newValue = table.create(#_exp)
|
|
6256
|
+
local _callback = function(player)
|
|
6257
|
+
return {
|
|
6258
|
+
name = player.Name,
|
|
6259
|
+
userId = player.UserId,
|
|
6260
|
+
displayName = player.DisplayName,
|
|
6261
|
+
}
|
|
6262
|
+
end
|
|
6263
|
+
for _k, _v in _exp do
|
|
6264
|
+
_newValue[_k] = _callback(_v, _k - 1, _exp)
|
|
6265
|
+
end
|
|
6266
|
+
-- ▲ ReadonlyArray.map ▲
|
|
6267
|
+
local players = _newValue
|
|
6268
|
+
table.sort(players, function(a, b)
|
|
6269
|
+
return a.name < b.name
|
|
6270
|
+
end)
|
|
6271
|
+
return players
|
|
6272
|
+
end
|
|
6273
|
+
local function cloneMultiplayerState()
|
|
6274
|
+
return {
|
|
6275
|
+
phase = multiplayerState.phase,
|
|
6276
|
+
testId = multiplayerState.testId,
|
|
6277
|
+
numPlayers = multiplayerState.numPlayers,
|
|
6278
|
+
testArgs = multiplayerState.testArgs,
|
|
6279
|
+
startedAt = multiplayerState.startedAt,
|
|
6280
|
+
completedAt = multiplayerState.completedAt,
|
|
6281
|
+
ok = multiplayerState.ok,
|
|
6282
|
+
result = multiplayerState.result,
|
|
6283
|
+
error = multiplayerState.error,
|
|
6284
|
+
}
|
|
6285
|
+
end
|
|
6286
|
+
local function normalizeNumPlayers(value)
|
|
6287
|
+
local _value = value
|
|
6288
|
+
if not (type(_value) == "number") then
|
|
6289
|
+
return nil
|
|
6290
|
+
end
|
|
6291
|
+
local n = math.floor(value)
|
|
6292
|
+
if n ~= value or n < 1 or n > 8 then
|
|
6293
|
+
return nil
|
|
6294
|
+
end
|
|
6295
|
+
return n
|
|
6296
|
+
end
|
|
5696
6297
|
local function buildCommandListenerSource()
|
|
5697
6298
|
return `local LogService = game:GetService("LogService")\
|
|
5698
6299
|
local PathfindingService = game:GetService("PathfindingService")\
|
|
@@ -5789,6 +6390,11 @@ local function startPlaytest(requestData)
|
|
|
5789
6390
|
error = 'mode must be "play" or "run"',
|
|
5790
6391
|
}
|
|
5791
6392
|
end
|
|
6393
|
+
if numPlayers ~= nil then
|
|
6394
|
+
return {
|
|
6395
|
+
error = "start_playtest is single-player only. Use multiplayer_test_start for multi-client StudioTestService sessions.",
|
|
6396
|
+
}
|
|
6397
|
+
end
|
|
5792
6398
|
-- Self-heal: if testRunning is stuck true but Studio reports no active
|
|
5793
6399
|
-- playtest, the previous start_playtest's task.spawn was orphaned
|
|
5794
6400
|
-- (plugin reload mid-test, Studio entered some inconsistent state, etc).
|
|
@@ -5800,7 +6406,9 @@ local function startPlaytest(requestData)
|
|
|
5800
6406
|
logConnection = nil
|
|
5801
6407
|
end
|
|
5802
6408
|
cleanupStopListener()
|
|
5803
|
-
|
|
6409
|
+
-- Note: eval bridges are intentionally NOT cleaned up — they live
|
|
6410
|
+
-- permanently in the edit DM so manual playtests also get them. See
|
|
6411
|
+
-- EvalBridges.ts lifecycle comment.
|
|
5804
6412
|
end
|
|
5805
6413
|
if testRunning then
|
|
5806
6414
|
return {
|
|
@@ -5843,17 +6451,14 @@ local function startPlaytest(requestData)
|
|
|
5843
6451
|
if not injected then
|
|
5844
6452
|
warn(`[MCP] Failed to inject stop listener: {injErr}`)
|
|
5845
6453
|
end
|
|
5846
|
-
--
|
|
5847
|
-
-- so
|
|
5848
|
-
--
|
|
6454
|
+
-- Force-refresh the game-VM eval bridges (ServerEvalBridge + ClientEvalBridge)
|
|
6455
|
+
-- right before cloning so the play DMs get the current source. They also
|
|
6456
|
+
-- live permanently in the edit DM (installed on connect) so manually-started
|
|
6457
|
+
-- playtests get them too; here we just ensure they're fresh.
|
|
5849
6458
|
local bridgeInstall = installBridges()
|
|
5850
6459
|
if not bridgeInstall.installed then
|
|
5851
6460
|
warn(`[MCP] Eval bridge install failed: {bridgeInstall.error}`)
|
|
5852
6461
|
end
|
|
5853
|
-
if numPlayers ~= nil and mode == "run" then
|
|
5854
|
-
local TestService = game:GetService("TestService")
|
|
5855
|
-
TestService.NumberOfPlayers = math.clamp(numPlayers, 1, 8)
|
|
5856
|
-
end
|
|
5857
6462
|
task.spawn(function()
|
|
5858
6463
|
local ok, result = pcall(function()
|
|
5859
6464
|
if mode == "play" then
|
|
@@ -5872,12 +6477,13 @@ local function startPlaytest(requestData)
|
|
|
5872
6477
|
end
|
|
5873
6478
|
testRunning = false
|
|
5874
6479
|
cleanupStopListener()
|
|
5875
|
-
|
|
6480
|
+
-- Eval bridges persist in the edit DM (see EvalBridges.ts) — do not
|
|
6481
|
+
-- clean up here, so the next manual playtest still gets them.
|
|
6482
|
+
ensureBridgesInstalled()
|
|
5876
6483
|
end)
|
|
5877
|
-
local msg = if numPlayers ~= nil then `Playtest started in {mode} mode with {numPlayers} player(s).` else `Playtest started in {mode} mode.`
|
|
5878
6484
|
local response = {
|
|
5879
6485
|
success = true,
|
|
5880
|
-
message =
|
|
6486
|
+
message = `Playtest started in {mode} mode.`,
|
|
5881
6487
|
}
|
|
5882
6488
|
-- Only mention eval bridges when they failed — when they're fine, the
|
|
5883
6489
|
-- detail is noise. eval_server_runtime / eval_client_runtime will surface
|
|
@@ -5900,9 +6506,25 @@ local function stopPlaytest(_requestData)
|
|
|
5900
6506
|
}
|
|
5901
6507
|
end
|
|
5902
6508
|
if not StopPlayMonitor.waitForConsumption() then
|
|
5903
|
-
--
|
|
5904
|
-
--
|
|
6509
|
+
-- Two distinct failure modes collapse here, distinguished by whether
|
|
6510
|
+
-- THIS edit DM has a playtest tracked:
|
|
6511
|
+
--
|
|
6512
|
+
-- - testRunning=false: no playtest was running from this edit DM
|
|
6513
|
+
-- (true negative). Return "no active playtest" — fine to retry only
|
|
6514
|
+
-- after actually starting a playtest.
|
|
6515
|
+
-- - testRunning=true: a playtest IS running but the cross-DM signal
|
|
6516
|
+
-- didn't propagate within the consumption timeout (false negative
|
|
6517
|
+
-- from the caller's perspective — playtest may actually have ended).
|
|
6518
|
+
-- Tell the caller it's a timing issue and they can retry.
|
|
6519
|
+
--
|
|
6520
|
+
-- Either way clean up the pending flag so a future playtest's monitor
|
|
6521
|
+
-- doesn't fire EndTest on startup against a stale signal.
|
|
5905
6522
|
StopPlayMonitor.clearPending()
|
|
6523
|
+
if testRunning then
|
|
6524
|
+
return {
|
|
6525
|
+
error = "Playtest stop signal sent but consumption confirmation timed out. " .. "The playtest may have ended anyway; check get_connected_instances.",
|
|
6526
|
+
}
|
|
6527
|
+
end
|
|
5906
6528
|
return {
|
|
5907
6529
|
error = "No active playtest to stop.",
|
|
5908
6530
|
}
|
|
@@ -5944,6 +6566,198 @@ local function getPlaytestOutput(_requestData)
|
|
|
5944
6566
|
_object.testError = testError
|
|
5945
6567
|
return _object
|
|
5946
6568
|
end
|
|
6569
|
+
local function multiplayerTestStart(requestData)
|
|
6570
|
+
if RunService:IsRunning() then
|
|
6571
|
+
return {
|
|
6572
|
+
error = "multiplayer_test_start must be called on the edit DataModel. Route with target=edit.",
|
|
6573
|
+
}
|
|
6574
|
+
end
|
|
6575
|
+
local numPlayers = normalizeNumPlayers(requestData.numPlayers)
|
|
6576
|
+
if numPlayers == nil then
|
|
6577
|
+
return {
|
|
6578
|
+
error = "numPlayers must be an integer from 1 to 8",
|
|
6579
|
+
}
|
|
6580
|
+
end
|
|
6581
|
+
if multiplayerState.phase == "starting" or multiplayerState.phase == "running" then
|
|
6582
|
+
return {
|
|
6583
|
+
error = "A multiplayer Studio test is already running",
|
|
6584
|
+
state = cloneMultiplayerState(),
|
|
6585
|
+
}
|
|
6586
|
+
end
|
|
6587
|
+
local testArgs = if requestData.testArgs ~= nil then requestData.testArgs else {}
|
|
6588
|
+
local testId = HttpService:GenerateGUID(false)
|
|
6589
|
+
local bridgeInstall = installBridges()
|
|
6590
|
+
if not bridgeInstall.installed then
|
|
6591
|
+
warn(`[MCP] Eval bridge install failed: {bridgeInstall.error}`)
|
|
6592
|
+
end
|
|
6593
|
+
multiplayerState = {
|
|
6594
|
+
phase = "starting",
|
|
6595
|
+
testId = testId,
|
|
6596
|
+
numPlayers = numPlayers,
|
|
6597
|
+
testArgs = testArgs,
|
|
6598
|
+
startedAt = tick(),
|
|
6599
|
+
}
|
|
6600
|
+
task.spawn(function()
|
|
6601
|
+
multiplayerState.phase = "running"
|
|
6602
|
+
local ok, result = pcall(function()
|
|
6603
|
+
return StudioTestService:ExecuteMultiplayerTestAsync(numPlayers, testArgs)
|
|
6604
|
+
end)
|
|
6605
|
+
multiplayerState.completedAt = tick()
|
|
6606
|
+
multiplayerState.ok = ok
|
|
6607
|
+
if ok then
|
|
6608
|
+
multiplayerState.phase = "completed"
|
|
6609
|
+
multiplayerState.result = result
|
|
6610
|
+
multiplayerState.error = nil
|
|
6611
|
+
else
|
|
6612
|
+
multiplayerState.phase = "failed"
|
|
6613
|
+
multiplayerState.result = nil
|
|
6614
|
+
multiplayerState.error = tostring(result)
|
|
6615
|
+
end
|
|
6616
|
+
ensureBridgesInstalled()
|
|
6617
|
+
end)
|
|
6618
|
+
local response = {
|
|
6619
|
+
success = true,
|
|
6620
|
+
message = `Multiplayer Studio test starting with {numPlayers} player(s).`,
|
|
6621
|
+
testId = testId,
|
|
6622
|
+
phase = multiplayerState.phase,
|
|
6623
|
+
numPlayers = numPlayers,
|
|
6624
|
+
testArgs = testArgs,
|
|
6625
|
+
}
|
|
6626
|
+
if not bridgeInstall.installed then
|
|
6627
|
+
response.evalBridgesError = bridgeInstall.error
|
|
6628
|
+
end
|
|
6629
|
+
return response
|
|
6630
|
+
end
|
|
6631
|
+
local function multiplayerTestState(_requestData)
|
|
6632
|
+
local peer = detectPeerRole()
|
|
6633
|
+
local response = {
|
|
6634
|
+
success = true,
|
|
6635
|
+
peer = peer,
|
|
6636
|
+
isRunning = RunService:IsRunning(),
|
|
6637
|
+
isRunMode = RunService:IsRunMode(),
|
|
6638
|
+
editModeActive = StudioTestService.EditModeActive,
|
|
6639
|
+
}
|
|
6640
|
+
if peer == "edit" then
|
|
6641
|
+
response.session = cloneMultiplayerState()
|
|
6642
|
+
return response
|
|
6643
|
+
end
|
|
6644
|
+
local argsOk, args = pcall(function()
|
|
6645
|
+
return StudioTestService:GetTestArgs()
|
|
6646
|
+
end)
|
|
6647
|
+
response.testArgsOk = argsOk
|
|
6648
|
+
response.testArgs = if argsOk then args else nil
|
|
6649
|
+
if not argsOk then
|
|
6650
|
+
response.testArgsError = tostring(args)
|
|
6651
|
+
end
|
|
6652
|
+
local players = getPlayersSnapshot()
|
|
6653
|
+
response.players = players
|
|
6654
|
+
response.playerCount = #players
|
|
6655
|
+
if peer == "client" then
|
|
6656
|
+
response.localPlayer = if Players.LocalPlayer then Players.LocalPlayer.Name else nil
|
|
6657
|
+
local canLeaveOk, canLeave = pcall(function()
|
|
6658
|
+
return StudioTestService:CanLeaveTest()
|
|
6659
|
+
end)
|
|
6660
|
+
response.canLeaveOk = canLeaveOk
|
|
6661
|
+
response.canLeave = if canLeaveOk then canLeave else false
|
|
6662
|
+
if not canLeaveOk then
|
|
6663
|
+
response.canLeaveError = tostring(canLeave)
|
|
6664
|
+
end
|
|
6665
|
+
end
|
|
6666
|
+
return response
|
|
6667
|
+
end
|
|
6668
|
+
local function multiplayerTestAddPlayers(requestData)
|
|
6669
|
+
if not RunService:IsRunning() or not RunService:IsServer() then
|
|
6670
|
+
return {
|
|
6671
|
+
error = "multiplayer_test_add_players must be called on the running server peer. Route with target=server.",
|
|
6672
|
+
}
|
|
6673
|
+
end
|
|
6674
|
+
local numPlayers = normalizeNumPlayers(requestData.numPlayers)
|
|
6675
|
+
if numPlayers == nil then
|
|
6676
|
+
return {
|
|
6677
|
+
error = "numPlayers must be an integer from 1 to 8",
|
|
6678
|
+
}
|
|
6679
|
+
end
|
|
6680
|
+
local before = #Players:GetPlayers()
|
|
6681
|
+
local ok, result = pcall(function()
|
|
6682
|
+
return StudioTestService:AddPlayers(numPlayers)
|
|
6683
|
+
end)
|
|
6684
|
+
if not ok then
|
|
6685
|
+
return {
|
|
6686
|
+
error = tostring(result),
|
|
6687
|
+
}
|
|
6688
|
+
end
|
|
6689
|
+
local _exp = tick()
|
|
6690
|
+
local _condition = (requestData.timeout)
|
|
6691
|
+
if _condition == nil then
|
|
6692
|
+
_condition = 10
|
|
6693
|
+
end
|
|
6694
|
+
local deadline = _exp + _condition
|
|
6695
|
+
while #Players:GetPlayers() < before + numPlayers and tick() < deadline do
|
|
6696
|
+
task.wait(0.1)
|
|
6697
|
+
end
|
|
6698
|
+
local players = getPlayersSnapshot()
|
|
6699
|
+
return {
|
|
6700
|
+
success = true,
|
|
6701
|
+
message = `Requested {numPlayers} additional player(s).`,
|
|
6702
|
+
playerCount = #players,
|
|
6703
|
+
players = players,
|
|
6704
|
+
}
|
|
6705
|
+
end
|
|
6706
|
+
local function multiplayerTestLeaveClient(_requestData)
|
|
6707
|
+
if not RunService:IsRunning() or RunService:IsServer() then
|
|
6708
|
+
return {
|
|
6709
|
+
error = "multiplayer_test_leave_client must be called on a running client peer. Route with target=client-N.",
|
|
6710
|
+
}
|
|
6711
|
+
end
|
|
6712
|
+
local canLeaveOk, canLeave = pcall(function()
|
|
6713
|
+
return StudioTestService:CanLeaveTest()
|
|
6714
|
+
end)
|
|
6715
|
+
if not canLeaveOk then
|
|
6716
|
+
return {
|
|
6717
|
+
error = tostring(canLeave),
|
|
6718
|
+
canLeaveOk = false,
|
|
6719
|
+
}
|
|
6720
|
+
end
|
|
6721
|
+
if not canLeave then
|
|
6722
|
+
return {
|
|
6723
|
+
error = "This client cannot leave the current test session.",
|
|
6724
|
+
canLeaveOk = true,
|
|
6725
|
+
canLeave = false,
|
|
6726
|
+
}
|
|
6727
|
+
end
|
|
6728
|
+
local localPlayer = if Players.LocalPlayer then Players.LocalPlayer.Name else nil
|
|
6729
|
+
task.defer(function()
|
|
6730
|
+
pcall(function()
|
|
6731
|
+
return StudioTestService:LeaveTest()
|
|
6732
|
+
end)
|
|
6733
|
+
end)
|
|
6734
|
+
return {
|
|
6735
|
+
success = true,
|
|
6736
|
+
message = "Client leave requested.",
|
|
6737
|
+
localPlayer = localPlayer,
|
|
6738
|
+
}
|
|
6739
|
+
end
|
|
6740
|
+
local function multiplayerTestEnd(requestData)
|
|
6741
|
+
if not RunService:IsRunning() or not RunService:IsServer() then
|
|
6742
|
+
return {
|
|
6743
|
+
error = "multiplayer_test_end must be called on the running server peer. Route with target=server.",
|
|
6744
|
+
}
|
|
6745
|
+
end
|
|
6746
|
+
local value = if requestData.value ~= nil then requestData.value else "ended_by_mcp"
|
|
6747
|
+
local ok, result = pcall(function()
|
|
6748
|
+
return StudioTestService:EndTest(value)
|
|
6749
|
+
end)
|
|
6750
|
+
if not ok then
|
|
6751
|
+
return {
|
|
6752
|
+
error = tostring(result),
|
|
6753
|
+
}
|
|
6754
|
+
end
|
|
6755
|
+
return {
|
|
6756
|
+
success = true,
|
|
6757
|
+
message = "Multiplayer Studio test end requested.",
|
|
6758
|
+
value = value,
|
|
6759
|
+
}
|
|
6760
|
+
end
|
|
5947
6761
|
local function characterNavigation(requestData)
|
|
5948
6762
|
if not testRunning then
|
|
5949
6763
|
return {
|
|
@@ -6015,13 +6829,336 @@ return {
|
|
|
6015
6829
|
startPlaytest = startPlaytest,
|
|
6016
6830
|
stopPlaytest = stopPlaytest,
|
|
6017
6831
|
getPlaytestOutput = getPlaytestOutput,
|
|
6832
|
+
multiplayerTestStart = multiplayerTestStart,
|
|
6833
|
+
multiplayerTestState = multiplayerTestState,
|
|
6834
|
+
multiplayerTestAddPlayers = multiplayerTestAddPlayers,
|
|
6835
|
+
multiplayerTestLeaveClient = multiplayerTestLeaveClient,
|
|
6836
|
+
multiplayerTestEnd = multiplayerTestEnd,
|
|
6018
6837
|
characterNavigation = characterNavigation,
|
|
6019
6838
|
}
|
|
6020
6839
|
]]></string>
|
|
6021
6840
|
</Properties>
|
|
6022
6841
|
</Item>
|
|
6023
6842
|
</Item>
|
|
6024
|
-
<Item class="ModuleScript" referent="
|
|
6843
|
+
<Item class="ModuleScript" referent="20">
|
|
6844
|
+
<Properties>
|
|
6845
|
+
<string name="Name">LuauExec</string>
|
|
6846
|
+
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
6847
|
+
-- eslint-disable
|
|
6848
|
+
-- Shared execute_luau machinery for edit/server (MetadataHandlers.executeLuau)
|
|
6849
|
+
-- and the play-client peer (ClientBroker.handleExecuteLuau). Three things this
|
|
6850
|
+
-- module owns:
|
|
6851
|
+
--
|
|
6852
|
+
-- 1. The IIFE wrapper that captures print/warn, runs user code in xpcall,
|
|
6853
|
+
-- and always returns { ok, value, output } so the ModuleScript itself
|
|
6854
|
+
-- always returns exactly one value (otherwise `print("hi")` with no
|
|
6855
|
+
-- return would fail with "Module code did not return exactly one value").
|
|
6856
|
+
--
|
|
6857
|
+
-- 2. The loadstring-then-ModuleScript-require fallback, with the parse-error
|
|
6858
|
+
-- recovery hack that pulls the real diagnostic from LogService.
|
|
6859
|
+
--
|
|
6860
|
+
-- 3. Return-value formatting: tables get HttpService:JSONEncode'd so the
|
|
6861
|
+
-- caller sees `{"x":1,"y":2}` instead of `table: 0xaddr`; primitives
|
|
6862
|
+
-- pass through tostring. The encode is pcall'd so cycles or
|
|
6863
|
+
-- non-serializable values gracefully fall back to tostring.
|
|
6864
|
+
--
|
|
6865
|
+
-- Before this module existed, the client peer used a stripped-down
|
|
6866
|
+
-- require-only execution path that lacked both the wrapper and the JSON
|
|
6867
|
+
-- formatting, producing two well-known papercuts:
|
|
6868
|
+
-- - `print("hi")` (no return) failed with "Module code did not return..."
|
|
6869
|
+
-- - Returning a table yielded `table: 0xaddr` instead of structured data.
|
|
6870
|
+
local HttpService = game:GetService("HttpService")
|
|
6871
|
+
local LogService = game:GetService("LogService")
|
|
6872
|
+
local PAYLOAD_INSTANCE_NAME = "__MCPExecLuauPayload"
|
|
6873
|
+
local PAYLOAD_PATH_PREFIX = `Workspace.{PAYLOAD_INSTANCE_NAME}:`
|
|
6874
|
+
-- Number of lines the wrapper emits BEFORE the first line of user code.
|
|
6875
|
+
-- Used both inside the wrapper (Luau __mcp_LINE_OFFSET) and on the TS side
|
|
6876
|
+
-- (remapPayloadLines, for compile errors recovered from LogService) so user
|
|
6877
|
+
-- code errors report user-relative line numbers instead of the inflated
|
|
6878
|
+
-- "line 23" the wrapper would otherwise expose. If you reorder buildWrapper's
|
|
6879
|
+
-- prefix lines, update this constant — there's a self-check below.
|
|
6880
|
+
local WRAPPER_LINE_OFFSET = 23
|
|
6881
|
+
-- Count source lines so the wrapper can filter traceback frames that fall
|
|
6882
|
+
-- outside the user code range (the wrapper's own preamble/postamble lines).
|
|
6883
|
+
local function countLines(s)
|
|
6884
|
+
local n = 1
|
|
6885
|
+
local size = #s
|
|
6886
|
+
do
|
|
6887
|
+
local i = 1
|
|
6888
|
+
local _shouldIncrement = false
|
|
6889
|
+
while true do
|
|
6890
|
+
if _shouldIncrement then
|
|
6891
|
+
i += 1
|
|
6892
|
+
else
|
|
6893
|
+
_shouldIncrement = true
|
|
6894
|
+
end
|
|
6895
|
+
if not (i <= size) then
|
|
6896
|
+
break
|
|
6897
|
+
end
|
|
6898
|
+
if string.sub(s, i, i) == "\n" then
|
|
6899
|
+
n += 1
|
|
6900
|
+
end
|
|
6901
|
+
end
|
|
6902
|
+
end
|
|
6903
|
+
return n
|
|
6904
|
+
end
|
|
6905
|
+
local function buildWrapper(code)
|
|
6906
|
+
-- If you reorder the prefix lines below, update WRAPPER_LINE_OFFSET to
|
|
6907
|
+
-- match the number of lines emitted BEFORE the ${code} substitution.
|
|
6908
|
+
-- The constant is mirrored inside the wrapper (__mcp_LINE_OFFSET) and
|
|
6909
|
+
-- used by remapPayloadLines on the TS side.
|
|
6910
|
+
local userLines = countLines(code)
|
|
6911
|
+
return `return ((function()\
|
|
6912
|
+
\tlocal __mcp_traceback\
|
|
6913
|
+
\tlocal __mcp_remap\
|
|
6914
|
+
\tlocal __mcp_LINE_OFFSET = {WRAPPER_LINE_OFFSET}\
|
|
6915
|
+
\tlocal __mcp_USER_LINES = {userLines}\
|
|
6916
|
+
\tlocal __mcp_output = \{\}\
|
|
6917
|
+
\tlocal __mcp_real_print = print\
|
|
6918
|
+
\tlocal __mcp_real_warn = warn\
|
|
6919
|
+
\tlocal print = function(...)\
|
|
6920
|
+
\t\t__mcp_real_print(...)\
|
|
6921
|
+
\t\tlocal args = \{...\}\
|
|
6922
|
+
\t\tlocal parts = table.create(#args)\
|
|
6923
|
+
\t\tfor i, a in ipairs(args) do parts[i] = tostring(a) end\
|
|
6924
|
+
\t\ttable.insert(__mcp_output, table.concat(parts, "\\t"))\
|
|
6925
|
+
\tend\
|
|
6926
|
+
\tlocal warn = function(...)\
|
|
6927
|
+
\t\t__mcp_real_warn(...)\
|
|
6928
|
+
\t\tlocal args = \{...\}\
|
|
6929
|
+
\t\tlocal parts = table.create(#args)\
|
|
6930
|
+
\t\tfor i, a in ipairs(args) do parts[i] = tostring(a) end\
|
|
6931
|
+
\t\ttable.insert(__mcp_output, "[warn] " .. table.concat(parts, "\\t"))\
|
|
6932
|
+
\tend\
|
|
6933
|
+
\tlocal function __mcp_run()\
|
|
6934
|
+
{code}\
|
|
6935
|
+
\tend\
|
|
6936
|
+
\t__mcp_remap = function(s)\
|
|
6937
|
+
\t\t-- Two chunk-name formats can reference our payload:\
|
|
6938
|
+
\t\t-- * "Workspace.__MCPExecLuauPayload:N" — ModuleScript:require fallback path\
|
|
6939
|
+
\t\t-- * "[string \\"return ((function()...\\"]:N" — loadstring() (default in plugin)\
|
|
6940
|
+
\t\t-- Subtract LINE_OFFSET to get the user-relative number, then clamp.\
|
|
6941
|
+
\t\t-- Clamping matters for unclosed constructs ("local x = (") where the\
|
|
6942
|
+
\t\t-- parser keeps reading into wrapper postamble and reports a payload\
|
|
6943
|
+
\t\t-- line past user EOF. Without clamping the message says "user_code:49"\
|
|
6944
|
+
\t\t-- for one-line input, framing the wrapper as user code.\
|
|
6945
|
+
\t\tlocal function __mcp_user_line(payload_n)\
|
|
6946
|
+
\t\t\tlocal user_n = payload_n - __mcp_LINE_OFFSET\
|
|
6947
|
+
\t\t\tif user_n < 1 then return "1" end\
|
|
6948
|
+
\t\t\tif user_n > __mcp_USER_LINES then return tostring(__mcp_USER_LINES) .. " (at end of input)" end\
|
|
6949
|
+
\t\t\treturn tostring(user_n)\
|
|
6950
|
+
\t\tend\
|
|
6951
|
+
\t\ts = string.gsub(s, "__MCPExecLuauPayload:(%d+)", function(num)\
|
|
6952
|
+
\t\t\tlocal n = tonumber(num)\
|
|
6953
|
+
\t\t\tif n then return "user_code:" .. __mcp_user_line(n) end\
|
|
6954
|
+
\t\t\treturn "user_code:" .. num\
|
|
6955
|
+
\t\tend)\
|
|
6956
|
+
\t\ts = string.gsub(s, '%[string "[^"]+"%]:(%d+)', function(num)\
|
|
6957
|
+
\t\t\tlocal n = tonumber(num)\
|
|
6958
|
+
\t\t\tif n then return "user_code:" .. __mcp_user_line(n) end\
|
|
6959
|
+
\t\t\treturn "user_code:" .. num\
|
|
6960
|
+
\t\tend)\
|
|
6961
|
+
\t\treturn s\
|
|
6962
|
+
\tend\
|
|
6963
|
+
\t__mcp_traceback = function(err)\
|
|
6964
|
+
\t\tlocal raw = debug.traceback(tostring(err), 2)\
|
|
6965
|
+
\t\tlocal kept = \{\}\
|
|
6966
|
+
\t\tfor line in string.gmatch(raw, "[^\\n]+") do\
|
|
6967
|
+
\t\t\t-- Extract referenced line number (either chunk-name format).\
|
|
6968
|
+
\t\t\tlocal num_str = string.match(line, "__MCPExecLuauPayload:(%d+)")\
|
|
6969
|
+
\t\t\t\tor string.match(line, '%[string "[^"]+"%]:(%d+)')\
|
|
6970
|
+
\t\t\tlocal n = num_str and tonumber(num_str)\
|
|
6971
|
+
\t\t\t-- Strip the "in function '__mcp_run'" annotation before doing\
|
|
6972
|
+
\t\t\t-- any filtering, because user-code frames carry that suffix —\
|
|
6973
|
+
\t\t\t-- the entire user payload is hosted inside __mcp_run, so EVERY\
|
|
6974
|
+
\t\t\t-- user frame would otherwise match a naive "__mcp_" filter and\
|
|
6975
|
+
\t\t\t-- get dropped. Strip first, then apply filters.\
|
|
6976
|
+
\t\t\tline = (string.gsub(line, " in function '__mcp_run'", ""))\
|
|
6977
|
+
\t\t\tlocal skip = string.find(line, "MCPPlugin", 1, true)\
|
|
6978
|
+
\t\t\t\tor string.find(line, "__mcp_", 1, true)\
|
|
6979
|
+
\t\t\t\tor string.find(line, "in function 'xpcall'", 1, true)\
|
|
6980
|
+
\t\t\t-- Frame lines pointing at wrapper preamble/postamble (outside\
|
|
6981
|
+
\t\t\t-- user range) are wrapper internals — drop them. Lines without\
|
|
6982
|
+
\t\t\t-- a payload-chunk line number (the traceback header / engine\
|
|
6983
|
+
\t\t\t-- C frames) are kept; remap is a no-op for them.\
|
|
6984
|
+
\t\t\tif n and (n <= __mcp_LINE_OFFSET or n > __mcp_LINE_OFFSET + __mcp_USER_LINES) then\
|
|
6985
|
+
\t\t\t\tskip = true\
|
|
6986
|
+
\t\t\tend\
|
|
6987
|
+
\t\t\tif not skip then\
|
|
6988
|
+
\t\t\t\ttable.insert(kept, __mcp_remap(line))\
|
|
6989
|
+
\t\t\tend\
|
|
6990
|
+
\t\tend\
|
|
6991
|
+
\t\treturn table.concat(kept, "\\n")\
|
|
6992
|
+
\tend\
|
|
6993
|
+
\tlocal ok, errOrValue = xpcall(__mcp_run, __mcp_traceback)\
|
|
6994
|
+
\treturn \{ ok = ok, value = errOrValue, output = __mcp_output \}\
|
|
6995
|
+
end)())`
|
|
6996
|
+
end
|
|
6997
|
+
-- TS-side mirror of the Lua __mcp_remap. Used by runViaModuleScript when
|
|
6998
|
+
-- pulling the real compile-error diagnostic out of LogService — that error
|
|
6999
|
+
-- references the payload module's line number directly, and never passes
|
|
7000
|
+
-- through the IIFE's runtime wrapper.
|
|
7001
|
+
local function remapPayloadLines(s, userLines)
|
|
7002
|
+
-- Mirror of the Lua __mcp_remap inside the wrapper, for paths that
|
|
7003
|
+
-- don't pass through the IIFE (compile errors recovered from
|
|
7004
|
+
-- LogService, the immediate loadstring compileError surface). Same
|
|
7005
|
+
-- two-format coverage plus the same clamp: unclosed user constructs
|
|
7006
|
+
-- let the parser consume wrapper postamble, so the raw payload line
|
|
7007
|
+
-- is sometimes well past user EOF — clamp to [1, userLines] and
|
|
7008
|
+
-- annotate so the error doesn't say "user_code:49" for one-line input.
|
|
7009
|
+
local userLine = function(payload)
|
|
7010
|
+
local u = payload - WRAPPER_LINE_OFFSET
|
|
7011
|
+
if u < 1 then
|
|
7012
|
+
return "1"
|
|
7013
|
+
end
|
|
7014
|
+
if u > userLines then
|
|
7015
|
+
return `{tostring(userLines)} (at end of input)`
|
|
7016
|
+
end
|
|
7017
|
+
return tostring(u)
|
|
7018
|
+
end
|
|
7019
|
+
local out = s
|
|
7020
|
+
local a = string.gsub(out, "__MCPExecLuauPayload:(%d+)", function(num)
|
|
7021
|
+
local n = tonumber(num)
|
|
7022
|
+
if n ~= nil then
|
|
7023
|
+
return `user_code:{userLine(n)}`
|
|
7024
|
+
end
|
|
7025
|
+
return `user_code:{num}`
|
|
7026
|
+
end)
|
|
7027
|
+
out = a
|
|
7028
|
+
local b = string.gsub(out, '%[string "[^"]+"%]:(%d+)', function(num)
|
|
7029
|
+
local n = tonumber(num)
|
|
7030
|
+
if n ~= nil then
|
|
7031
|
+
return `user_code:{userLine(n)}`
|
|
7032
|
+
end
|
|
7033
|
+
return `user_code:{num}`
|
|
7034
|
+
end)
|
|
7035
|
+
out = b
|
|
7036
|
+
return out
|
|
7037
|
+
end
|
|
7038
|
+
local function runViaModuleScript(wrapped, userLines)
|
|
7039
|
+
local m = Instance.new("ModuleScript")
|
|
7040
|
+
m.Name = PAYLOAD_INSTANCE_NAME
|
|
7041
|
+
local okSet, setErr = pcall(function()
|
|
7042
|
+
m.Source = wrapped
|
|
7043
|
+
end)
|
|
7044
|
+
if not okSet then
|
|
7045
|
+
m:Destroy()
|
|
7046
|
+
-- error(..., 0) suppresses the "user_MCPPlugin.rbxmx.MCPPlugin.modules.LuauExec:N:"
|
|
7047
|
+
-- prefix that error() would otherwise prepend, keeping the visible
|
|
7048
|
+
-- message focused on the user-actionable error rather than our path.
|
|
7049
|
+
error(`ModuleScript Source set failed: {tostring(setErr)}`, 0)
|
|
7050
|
+
end
|
|
7051
|
+
m.Parent = game:GetService("Workspace")
|
|
7052
|
+
local okReq, reqResult = pcall(function()
|
|
7053
|
+
return require(m)
|
|
7054
|
+
end)
|
|
7055
|
+
m:Destroy()
|
|
7056
|
+
if not okReq then
|
|
7057
|
+
local errMsg = tostring(reqResult)
|
|
7058
|
+
-- pcall(require, m) collapses parse/compile failures into the canned
|
|
7059
|
+
-- engine string. The real diagnostic was emitted to LogService on the
|
|
7060
|
+
-- next engine frame — give it ~50ms to land then scan backward.
|
|
7061
|
+
if errMsg == "Requested module experienced an error while loading" then
|
|
7062
|
+
task.wait(0.05)
|
|
7063
|
+
local hist = LogService:GetLogHistory()
|
|
7064
|
+
for i = #hist - 1, 0, -1 do
|
|
7065
|
+
local e = hist[i + 1]
|
|
7066
|
+
if e.messageType == Enum.MessageType.MessageError and string.sub(e.message, 1, #PAYLOAD_PATH_PREFIX) == PAYLOAD_PATH_PREFIX then
|
|
7067
|
+
errMsg = e.message
|
|
7068
|
+
break
|
|
7069
|
+
end
|
|
7070
|
+
end
|
|
7071
|
+
end
|
|
7072
|
+
-- Compile errors reference the payload module's line number directly
|
|
7073
|
+
-- — remap + clamp to user-relative line numbers so `local x = 1 +`
|
|
7074
|
+
-- reports :1: instead of :23:, and reports the clamp annotation
|
|
7075
|
+
-- when the parser ran off the end of user code into wrapper code.
|
|
7076
|
+
error(remapPayloadLines(errMsg, userLines), 0)
|
|
7077
|
+
end
|
|
7078
|
+
return reqResult
|
|
7079
|
+
end
|
|
7080
|
+
local function isLoadstringUnavailable(err)
|
|
7081
|
+
local errStr = tostring(err)
|
|
7082
|
+
local matchStart = string.find(errStr, "not available", 1, true)
|
|
7083
|
+
return matchStart ~= nil
|
|
7084
|
+
end
|
|
7085
|
+
-- Returns a string suitable for `returnValue`. Tables get JSON-encoded so
|
|
7086
|
+
-- the caller sees structured data instead of "table: 0xaddr". Anything that
|
|
7087
|
+
-- JSONEncode chokes on (cycles, Roblox userdata) falls back to tostring.
|
|
7088
|
+
local function formatReturnValue(value)
|
|
7089
|
+
if value == nil then
|
|
7090
|
+
return ""
|
|
7091
|
+
end
|
|
7092
|
+
local _value = value
|
|
7093
|
+
if type(_value) == "table" then
|
|
7094
|
+
local ok, encoded = pcall(function()
|
|
7095
|
+
return HttpService:JSONEncode(value)
|
|
7096
|
+
end)
|
|
7097
|
+
if ok then
|
|
7098
|
+
return encoded
|
|
7099
|
+
end
|
|
7100
|
+
end
|
|
7101
|
+
return tostring(value)
|
|
7102
|
+
end
|
|
7103
|
+
local function execute(code)
|
|
7104
|
+
if not (code ~= "" and code) or code == "" then
|
|
7105
|
+
return {
|
|
7106
|
+
success = false,
|
|
7107
|
+
error = "code is required",
|
|
7108
|
+
}
|
|
7109
|
+
end
|
|
7110
|
+
local wrapped = buildWrapper(code)
|
|
7111
|
+
local userLines = countLines(code)
|
|
7112
|
+
local success, result = pcall(function()
|
|
7113
|
+
local fn, compileError = loadstring(wrapped)
|
|
7114
|
+
if not fn then
|
|
7115
|
+
if isLoadstringUnavailable(compileError) then
|
|
7116
|
+
return runViaModuleScript(wrapped, userLines)
|
|
7117
|
+
end
|
|
7118
|
+
error(`Compile error: {remapPayloadLines(tostring(compileError), userLines)}`, 0)
|
|
7119
|
+
end
|
|
7120
|
+
return fn()
|
|
7121
|
+
end)
|
|
7122
|
+
-- loadstring can throw (not return nil) when ServerScriptService.
|
|
7123
|
+
-- LoadStringEnabled is false; treat that as a second-chance fallback.
|
|
7124
|
+
if not success and isLoadstringUnavailable(result) then
|
|
7125
|
+
success, result = pcall(function()
|
|
7126
|
+
return runViaModuleScript(wrapped, userLines)
|
|
7127
|
+
end)
|
|
7128
|
+
end
|
|
7129
|
+
if not success then
|
|
7130
|
+
return {
|
|
7131
|
+
success = false,
|
|
7132
|
+
error = tostring(result),
|
|
7133
|
+
output = {},
|
|
7134
|
+
message = "Code execution failed",
|
|
7135
|
+
}
|
|
7136
|
+
end
|
|
7137
|
+
local r = result
|
|
7138
|
+
local capturedOutput = r.output
|
|
7139
|
+
local output = if capturedOutput ~= nil then capturedOutput else ({})
|
|
7140
|
+
if r.ok == true then
|
|
7141
|
+
return {
|
|
7142
|
+
success = true,
|
|
7143
|
+
returnValue = if r.value ~= nil then formatReturnValue(r.value) else nil,
|
|
7144
|
+
output = output,
|
|
7145
|
+
message = "Code executed successfully",
|
|
7146
|
+
}
|
|
7147
|
+
end
|
|
7148
|
+
return {
|
|
7149
|
+
success = false,
|
|
7150
|
+
error = if r.value ~= nil then tostring(r.value) else "(unknown error)",
|
|
7151
|
+
output = output,
|
|
7152
|
+
message = "Code execution failed",
|
|
7153
|
+
}
|
|
7154
|
+
end
|
|
7155
|
+
return {
|
|
7156
|
+
execute = execute,
|
|
7157
|
+
}
|
|
7158
|
+
]]></string>
|
|
7159
|
+
</Properties>
|
|
7160
|
+
</Item>
|
|
7161
|
+
<Item class="ModuleScript" referent="21">
|
|
6025
7162
|
<Properties>
|
|
6026
7163
|
<string name="Name">Recording</string>
|
|
6027
7164
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -6051,7 +7188,75 @@ return {
|
|
|
6051
7188
|
]]></string>
|
|
6052
7189
|
</Properties>
|
|
6053
7190
|
</Item>
|
|
6054
|
-
<Item class="ModuleScript" referent="
|
|
7191
|
+
<Item class="ModuleScript" referent="22">
|
|
7192
|
+
<Properties>
|
|
7193
|
+
<string name="Name">RenderMonitor</string>
|
|
7194
|
+
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
7195
|
+
local TS = require(script.Parent.Parent.include.RuntimeLib)
|
|
7196
|
+
-- Detects whether the Studio window is actually rendering, so virtual input
|
|
7197
|
+
-- and screenshot tools can surface a clear reason instead of silently failing.
|
|
7198
|
+
--
|
|
7199
|
+
-- When a Studio window is MINIMIZED, the engine suspends the render loop AND
|
|
7200
|
+
-- input processing, but keeps running scripts (Heartbeat keeps firing). That's
|
|
7201
|
+
-- why simulate_*_input would return success while having zero effect, and
|
|
7202
|
+
-- CaptureService:CaptureScreenshot would time out. Validated live: during a 3s
|
|
7203
|
+
-- minimize, RenderStepped's max inter-frame gap was 5.08s while Heartbeat's was
|
|
7204
|
+
-- 0.10s. So RenderStepped freshness is the reliable "is this window rendering?"
|
|
7205
|
+
-- signal; Heartbeat is not.
|
|
7206
|
+
local RunService = TS.import(script, script.Parent.Parent, "node_modules", "@rbxts", "services").RunService
|
|
7207
|
+
local lastFrame = 0
|
|
7208
|
+
local connected = false
|
|
7209
|
+
-- Above this many seconds since the last rendered frame, we treat the window
|
|
7210
|
+
-- as not rendering. RenderStepped normally fires every ~16ms; a multi-second
|
|
7211
|
+
-- gap only happens when minimized/suspended, so 1s cleanly avoids false
|
|
7212
|
+
-- positives from ordinary frame hitches while still catching the real case.
|
|
7213
|
+
local STALE_THRESHOLD = 1.0
|
|
7214
|
+
local function start()
|
|
7215
|
+
if connected then
|
|
7216
|
+
return nil
|
|
7217
|
+
end
|
|
7218
|
+
-- RenderStepped can only be connected from a client/edit render loop; it
|
|
7219
|
+
-- throws in the play-server DM. pcall so a server-DM call is a safe no-op
|
|
7220
|
+
-- (connected stays false → notRenderingReason() returns undefined there).
|
|
7221
|
+
local ok = pcall(function()
|
|
7222
|
+
RunService.RenderStepped:Connect(function()
|
|
7223
|
+
lastFrame = tick()
|
|
7224
|
+
end)
|
|
7225
|
+
end)
|
|
7226
|
+
if ok then
|
|
7227
|
+
connected = true
|
|
7228
|
+
lastFrame = tick()
|
|
7229
|
+
end
|
|
7230
|
+
end
|
|
7231
|
+
local function secondsSinceFrame()
|
|
7232
|
+
if not connected then
|
|
7233
|
+
return 0
|
|
7234
|
+
end
|
|
7235
|
+
return tick() - lastFrame
|
|
7236
|
+
end
|
|
7237
|
+
-- Returns a human-readable reason if the window appears minimized / not
|
|
7238
|
+
-- rendering (so input + screenshots won't work), else undefined. Fail-open:
|
|
7239
|
+
-- when the monitor isn't active in this DM (server peer, or connect failed) it
|
|
7240
|
+
-- returns undefined so we never block on a false signal.
|
|
7241
|
+
local function notRenderingReason()
|
|
7242
|
+
if not connected then
|
|
7243
|
+
return nil
|
|
7244
|
+
end
|
|
7245
|
+
local gap = secondsSinceFrame()
|
|
7246
|
+
if gap > STALE_THRESHOLD then
|
|
7247
|
+
return string.format("Studio window appears minimized or not rendering (no frame in %.1fs). " .. "Virtual input and screenshots only work while the window is visible — " .. "restore/un-minimize the Studio window and retry.", gap)
|
|
7248
|
+
end
|
|
7249
|
+
return nil
|
|
7250
|
+
end
|
|
7251
|
+
return {
|
|
7252
|
+
start = start,
|
|
7253
|
+
secondsSinceFrame = secondsSinceFrame,
|
|
7254
|
+
notRenderingReason = notRenderingReason,
|
|
7255
|
+
}
|
|
7256
|
+
]]></string>
|
|
7257
|
+
</Properties>
|
|
7258
|
+
</Item>
|
|
7259
|
+
<Item class="ModuleScript" referent="23">
|
|
6055
7260
|
<Properties>
|
|
6056
7261
|
<string name="Name">RuntimeLogBuffer</string>
|
|
6057
7262
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -6232,11 +7437,11 @@ return {
|
|
|
6232
7437
|
]]></string>
|
|
6233
7438
|
</Properties>
|
|
6234
7439
|
</Item>
|
|
6235
|
-
<Item class="ModuleScript" referent="
|
|
7440
|
+
<Item class="ModuleScript" referent="24">
|
|
6236
7441
|
<Properties>
|
|
6237
7442
|
<string name="Name">State</string>
|
|
6238
7443
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
6239
|
-
local CURRENT_VERSION = "2.
|
|
7444
|
+
local CURRENT_VERSION = "2.15.0"
|
|
6240
7445
|
local MAX_CONNECTIONS = 5
|
|
6241
7446
|
local BASE_PORT = 58741
|
|
6242
7447
|
local activeTabIndex = 0
|
|
@@ -6328,61 +7533,96 @@ return {
|
|
|
6328
7533
|
]]></string>
|
|
6329
7534
|
</Properties>
|
|
6330
7535
|
</Item>
|
|
6331
|
-
<Item class="ModuleScript" referent="
|
|
7536
|
+
<Item class="ModuleScript" referent="25">
|
|
6332
7537
|
<Properties>
|
|
6333
7538
|
<string name="Name">StopPlayMonitor</string>
|
|
6334
7539
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
6335
|
-
|
|
7540
|
+
local TS = require(script.Parent.Parent.include.RuntimeLib)
|
|
7541
|
+
-- Cross-DM stop_playtest signaling via plugin:SetSetting, scoped by
|
|
7542
|
+
-- per-instance setting key so the same Studio process can host playtests
|
|
7543
|
+
-- for multiple places without one place's stop_playtest yanking another's.
|
|
6336
7544
|
--
|
|
6337
7545
|
-- `plugin:SetSetting` / `plugin:GetSetting` is a per-plugin persistent store
|
|
6338
|
-
--
|
|
6339
|
-
-- play-
|
|
6340
|
-
--
|
|
6341
|
-
--
|
|
6342
|
-
-- * The edit DM's stopPlaytest handler writes the flag (requestStop).
|
|
6343
|
-
-- * A monitor loop running inside the play-server DM polls the flag at 1Hz
|
|
6344
|
-
-- and calls StudioTestService:EndTest when it flips true, then resets it.
|
|
6345
|
-
-- * The edit DM then waits up to ~2.5s for the flag to be reset, which
|
|
6346
|
-
-- tells us a play-server actually consumed the request (no false-positive
|
|
6347
|
-
-- success when nothing was running).
|
|
7546
|
+
-- shared across every DataModel the plugin runs in (edit DMs, play-server
|
|
7547
|
+
-- DMs, play-client DMs). For each connected place we use a dedicated key
|
|
7548
|
+
-- "MCP_STOP_PLAY_<instanceId>" as a single-bit mailbox:
|
|
6348
7549
|
--
|
|
6349
|
-
--
|
|
6350
|
-
--
|
|
6351
|
-
-- *
|
|
6352
|
-
--
|
|
6353
|
-
--
|
|
6354
|
-
--
|
|
7550
|
+
-- * The edit DM's stopPlaytest handler writes `true` into its own key
|
|
7551
|
+
-- (computed from its placeId / ServerStorage anon UUID).
|
|
7552
|
+
-- * Each play-server DM's monitor loop polls the key matching its own
|
|
7553
|
+
-- instanceId at 0.1Hz; on `true` it clears the key and calls
|
|
7554
|
+
-- StudioTestService:EndTest. Play-server DMs for other places never
|
|
7555
|
+
-- touch this key.
|
|
7556
|
+
-- * The edit DM waits up to ~8s for its key to be cleared, confirming a
|
|
7557
|
+
-- matching play-server actually consumed the request.
|
|
6355
7558
|
--
|
|
6356
|
-
--
|
|
6357
|
-
--
|
|
7559
|
+
-- Earlier versions used a single shared boolean flag, which let any
|
|
7560
|
+
-- play-server DM in the same Studio process consume any place's stop
|
|
7561
|
+
-- request — silently yanking teammates' playtests. The per-key scoping
|
|
7562
|
+
-- below is the fix.
|
|
7563
|
+
local _services = TS.import(script, script.Parent.Parent, "node_modules", "@rbxts", "services")
|
|
7564
|
+
local HttpService = _services.HttpService
|
|
7565
|
+
local ServerStorage = _services.ServerStorage
|
|
6358
7566
|
local StudioTestService = game:GetService("StudioTestService")
|
|
6359
|
-
local
|
|
6360
|
-
|
|
6361
|
-
|
|
7567
|
+
local SETTING_KEY_PREFIX = "MCP_STOP_PLAY_"
|
|
7568
|
+
-- Monitor checks the key at this cadence. 0.1s keeps worst-case detection
|
|
7569
|
+
-- lag tight so the consumption-confirmation window doesn't have to absorb
|
|
7570
|
+
-- polling jitter on top of EndTest's teardown time.
|
|
7571
|
+
local POLL_INTERVAL_SEC = 0.1
|
|
7572
|
+
-- Total time we wait for the matching play-server DM to consume the
|
|
7573
|
+
-- signal. Must cover: monitor detection (<= POLL_INTERVAL_SEC) +
|
|
7574
|
+
-- StudioTestService:EndTest teardown (several seconds on heavier places).
|
|
7575
|
+
-- 8s is comfortable; the tighter poll above keeps real cases well under.
|
|
7576
|
+
local WAIT_FOR_CONSUMPTION_TIMEOUT_SEC = 8.0
|
|
6362
7577
|
local WAIT_POLL_SEC = 0.1
|
|
6363
7578
|
local pluginRef
|
|
6364
7579
|
local function init(p)
|
|
6365
7580
|
pluginRef = p
|
|
6366
7581
|
end
|
|
7582
|
+
-- Mirror of Communication.computeInstanceId(). Duplicated here because
|
|
7583
|
+
-- StopPlayMonitor runs in both edit and play-server DMs, and both must
|
|
7584
|
+
-- agree on the place identifier (published places: placeId; unpublished:
|
|
7585
|
+
-- UUID on ServerStorage's __MCPPlaceId attribute, travels with the .rbxl
|
|
7586
|
+
-- into the play DM).
|
|
7587
|
+
local function computeInstanceId()
|
|
7588
|
+
if game.PlaceId ~= 0 then
|
|
7589
|
+
return `place:{tostring(game.PlaceId)}`
|
|
7590
|
+
end
|
|
7591
|
+
local existing = ServerStorage:GetAttribute("__MCPPlaceId")
|
|
7592
|
+
if type(existing) == "string" and existing ~= "" then
|
|
7593
|
+
return `anon:{existing}`
|
|
7594
|
+
end
|
|
7595
|
+
local fresh = HttpService:GenerateGUID(false)
|
|
7596
|
+
pcall(function()
|
|
7597
|
+
return ServerStorage:SetAttribute("__MCPPlaceId", fresh)
|
|
7598
|
+
end)
|
|
7599
|
+
return `anon:{fresh}`
|
|
7600
|
+
end
|
|
7601
|
+
local function settingKey(instanceId)
|
|
7602
|
+
return SETTING_KEY_PREFIX .. instanceId
|
|
7603
|
+
end
|
|
6367
7604
|
local function startMonitor()
|
|
6368
7605
|
if not pluginRef then
|
|
6369
7606
|
warn("[MCP] StopPlayMonitor.startMonitor called before init; skipping")
|
|
6370
7607
|
return nil
|
|
6371
7608
|
end
|
|
6372
|
-
|
|
6373
|
-
--
|
|
6374
|
-
--
|
|
7609
|
+
local myKey = settingKey(computeInstanceId())
|
|
7610
|
+
-- Clear any stale value left from a prior session. If a real stop
|
|
7611
|
+
-- request is in-flight when this runs, the requesting edit DM will
|
|
7612
|
+
-- write again within its consumption-confirmation window.
|
|
6375
7613
|
pcall(function()
|
|
6376
|
-
return pluginRef:SetSetting(
|
|
7614
|
+
return pluginRef:SetSetting(myKey, false)
|
|
6377
7615
|
end)
|
|
6378
7616
|
task.spawn(function()
|
|
6379
7617
|
while true do
|
|
6380
7618
|
local okGet, val = pcall(function()
|
|
6381
|
-
return pluginRef:GetSetting(
|
|
7619
|
+
return pluginRef:GetSetting(myKey)
|
|
6382
7620
|
end)
|
|
6383
7621
|
if okGet and val == true then
|
|
7622
|
+
-- Consume the flag first so requestStop's
|
|
7623
|
+
-- waitForConsumption returns success, then end the test.
|
|
6384
7624
|
pcall(function()
|
|
6385
|
-
return pluginRef:SetSetting(
|
|
7625
|
+
return pluginRef:SetSetting(myKey, false)
|
|
6386
7626
|
end)
|
|
6387
7627
|
pcall(function()
|
|
6388
7628
|
return StudioTestService:EndTest("stopped_by_mcp")
|
|
@@ -6396,8 +7636,9 @@ local function requestStop()
|
|
|
6396
7636
|
if not pluginRef then
|
|
6397
7637
|
return false
|
|
6398
7638
|
end
|
|
7639
|
+
local myKey = settingKey(computeInstanceId())
|
|
6399
7640
|
local ok = pcall(function()
|
|
6400
|
-
return pluginRef:SetSetting(
|
|
7641
|
+
return pluginRef:SetSetting(myKey, true)
|
|
6401
7642
|
end)
|
|
6402
7643
|
return ok
|
|
6403
7644
|
end
|
|
@@ -6405,10 +7646,11 @@ local function waitForConsumption()
|
|
|
6405
7646
|
if not pluginRef then
|
|
6406
7647
|
return false
|
|
6407
7648
|
end
|
|
7649
|
+
local myKey = settingKey(computeInstanceId())
|
|
6408
7650
|
local start = tick()
|
|
6409
7651
|
while tick() - start < WAIT_FOR_CONSUMPTION_TIMEOUT_SEC do
|
|
6410
7652
|
local okGet, val = pcall(function()
|
|
6411
|
-
return pluginRef:GetSetting(
|
|
7653
|
+
return pluginRef:GetSetting(myKey)
|
|
6412
7654
|
end)
|
|
6413
7655
|
if okGet and val ~= true then
|
|
6414
7656
|
return true
|
|
@@ -6421,8 +7663,9 @@ local function clearPending()
|
|
|
6421
7663
|
if not pluginRef then
|
|
6422
7664
|
return nil
|
|
6423
7665
|
end
|
|
7666
|
+
local myKey = settingKey(computeInstanceId())
|
|
6424
7667
|
pcall(function()
|
|
6425
|
-
return pluginRef:SetSetting(
|
|
7668
|
+
return pluginRef:SetSetting(myKey, false)
|
|
6426
7669
|
end)
|
|
6427
7670
|
end
|
|
6428
7671
|
return {
|
|
@@ -6435,7 +7678,7 @@ return {
|
|
|
6435
7678
|
]]></string>
|
|
6436
7679
|
</Properties>
|
|
6437
7680
|
</Item>
|
|
6438
|
-
<Item class="ModuleScript" referent="
|
|
7681
|
+
<Item class="ModuleScript" referent="26">
|
|
6439
7682
|
<Properties>
|
|
6440
7683
|
<string name="Name">UI</string>
|
|
6441
7684
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -7186,7 +8429,7 @@ return {
|
|
|
7186
8429
|
]]></string>
|
|
7187
8430
|
</Properties>
|
|
7188
8431
|
</Item>
|
|
7189
|
-
<Item class="ModuleScript" referent="
|
|
8432
|
+
<Item class="ModuleScript" referent="27">
|
|
7190
8433
|
<Properties>
|
|
7191
8434
|
<string name="Name">Utils</string>
|
|
7192
8435
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -7716,11 +8959,11 @@ return {
|
|
|
7716
8959
|
</Properties>
|
|
7717
8960
|
</Item>
|
|
7718
8961
|
</Item>
|
|
7719
|
-
<Item class="Folder" referent="
|
|
8962
|
+
<Item class="Folder" referent="31">
|
|
7720
8963
|
<Properties>
|
|
7721
8964
|
<string name="Name">include</string>
|
|
7722
8965
|
</Properties>
|
|
7723
|
-
<Item class="ModuleScript" referent="
|
|
8966
|
+
<Item class="ModuleScript" referent="28">
|
|
7724
8967
|
<Properties>
|
|
7725
8968
|
<string name="Name">Promise</string>
|
|
7726
8969
|
<string name="Source"><![CDATA[--[[
|
|
@@ -9794,7 +11037,7 @@ return Promise
|
|
|
9794
11037
|
]]></string>
|
|
9795
11038
|
</Properties>
|
|
9796
11039
|
</Item>
|
|
9797
|
-
<Item class="ModuleScript" referent="
|
|
11040
|
+
<Item class="ModuleScript" referent="29">
|
|
9798
11041
|
<Properties>
|
|
9799
11042
|
<string name="Name">RuntimeLib</string>
|
|
9800
11043
|
<string name="Source"><![CDATA[local Promise = require(script.Parent.Promise)
|
|
@@ -10061,15 +11304,15 @@ return TS
|
|
|
10061
11304
|
</Properties>
|
|
10062
11305
|
</Item>
|
|
10063
11306
|
</Item>
|
|
10064
|
-
<Item class="Folder" referent="
|
|
11307
|
+
<Item class="Folder" referent="32">
|
|
10065
11308
|
<Properties>
|
|
10066
11309
|
<string name="Name">node_modules</string>
|
|
10067
11310
|
</Properties>
|
|
10068
|
-
<Item class="Folder" referent="
|
|
11311
|
+
<Item class="Folder" referent="33">
|
|
10069
11312
|
<Properties>
|
|
10070
11313
|
<string name="Name">@rbxts</string>
|
|
10071
11314
|
</Properties>
|
|
10072
|
-
<Item class="ModuleScript" referent="
|
|
11315
|
+
<Item class="ModuleScript" referent="30">
|
|
10073
11316
|
<Properties>
|
|
10074
11317
|
<string name="Name">services</string>
|
|
10075
11318
|
<string name="Source"><![CDATA[return setmetatable({}, {
|