@chrrxs/robloxstudio-mcp 2.12.0 → 2.13.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 +1073 -41
- package/package.json +1 -1
- package/studio-plugin/MCPPlugin.rbxmx +349 -91
- package/studio-plugin/src/modules/ClientBroker.ts +21 -1
- package/studio-plugin/src/modules/Communication.ts +17 -0
- package/studio-plugin/src/modules/EvalBridges.ts +60 -11
- package/studio-plugin/src/modules/RenderMonitor.ts +60 -0
- package/studio-plugin/src/modules/handlers/CaptureHandlers.ts +45 -3
- package/studio-plugin/src/modules/handlers/InputHandlers.ts +100 -39
- package/studio-plugin/src/modules/handlers/TestHandlers.ts +11 -6
- package/studio-plugin/src/server/index.server.ts +6 -0
|
@@ -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().
|
|
@@ -97,6 +102,8 @@ local RunService = _services.RunService
|
|
|
97
102
|
local ServerStorage = _services.ServerStorage
|
|
98
103
|
local RuntimeLogBuffer = TS.import(script, script.Parent, "RuntimeLogBuffer")
|
|
99
104
|
local MemoryHandlers = TS.import(script, script.Parent, "handlers", "MemoryHandlers")
|
|
105
|
+
local CaptureHandlers = TS.import(script, script.Parent, "handlers", "CaptureHandlers")
|
|
106
|
+
local InputHandlers = TS.import(script, script.Parent, "handlers", "InputHandlers")
|
|
100
107
|
local LuauExec = TS.import(script, script.Parent, "LuauExec")
|
|
101
108
|
-- Mirror of Communication.computeInstanceId() — duplicated here because the
|
|
102
109
|
-- client broker runs in the play-server DM where it can't easily import from
|
|
@@ -158,6 +165,9 @@ local CLIENT_BROKER_ALLOWED_ENDPOINTS = {
|
|
|
158
165
|
["/api/execute-luau"] = true,
|
|
159
166
|
["/api/get-runtime-logs"] = true,
|
|
160
167
|
["/api/get-memory-breakdown"] = true,
|
|
168
|
+
["/api/capture-begin"] = true,
|
|
169
|
+
["/api/simulate-mouse-input"] = true,
|
|
170
|
+
["/api/simulate-keyboard-input"] = true,
|
|
161
171
|
}
|
|
162
172
|
-- Throttle re-ready calls per proxyId so a brief window of unknownInstance
|
|
163
173
|
-- polls doesn't cause a re-register stampede.
|
|
@@ -255,6 +265,15 @@ local function setupClientBroker()
|
|
|
255
265
|
if payload and payload.endpoint == "/api/get-memory-breakdown" then
|
|
256
266
|
return MemoryHandlers.getMemoryBreakdown(payload.data or {})
|
|
257
267
|
end
|
|
268
|
+
if payload and payload.endpoint == "/api/capture-begin" then
|
|
269
|
+
return CaptureHandlers.captureBegin()
|
|
270
|
+
end
|
|
271
|
+
if payload and payload.endpoint == "/api/simulate-mouse-input" then
|
|
272
|
+
return InputHandlers.simulateMouseInput(payload.data or {})
|
|
273
|
+
end
|
|
274
|
+
if payload and payload.endpoint == "/api/simulate-keyboard-input" then
|
|
275
|
+
return InputHandlers.simulateKeyboardInput(payload.data or {})
|
|
276
|
+
end
|
|
258
277
|
if payload and payload.endpoint == "/api/execute-luau" then
|
|
259
278
|
return handleExecuteLuau(payload.data)
|
|
260
279
|
end
|
|
@@ -318,8 +337,12 @@ local function pollProxy(proxyId, player, rf)
|
|
|
318
337
|
}
|
|
319
338
|
end
|
|
320
339
|
else
|
|
340
|
+
local allowed = {}
|
|
341
|
+
for ep in CLIENT_BROKER_ALLOWED_ENDPOINTS do
|
|
342
|
+
table.insert(allowed, ep)
|
|
343
|
+
end
|
|
321
344
|
response = {
|
|
322
|
-
error = `Client-proxy does not forward {tostring(request.endpoint)}. ` .. `Allowed:
|
|
345
|
+
error = `Client-proxy does not forward {tostring(request.endpoint)}. ` .. `Allowed: {table.concat(allowed, ", ")}.`,
|
|
323
346
|
}
|
|
324
347
|
end
|
|
325
348
|
postJson("/response", {
|
|
@@ -424,6 +447,7 @@ local ServerStorage = _services.ServerStorage
|
|
|
424
447
|
local State = TS.import(script, script.Parent, "State")
|
|
425
448
|
local Utils = TS.import(script, script.Parent, "Utils")
|
|
426
449
|
local UI = TS.import(script, script.Parent, "UI")
|
|
450
|
+
local ensureBridgesInstalled = TS.import(script, script.Parent, "EvalBridges").ensureBridgesInstalled
|
|
427
451
|
local QueryHandlers = TS.import(script, script.Parent, "handlers", "QueryHandlers")
|
|
428
452
|
local PropertyHandlers = TS.import(script, script.Parent, "handlers", "PropertyHandlers")
|
|
429
453
|
local InstanceHandlers = TS.import(script, script.Parent, "handlers", "InstanceHandlers")
|
|
@@ -555,6 +579,8 @@ local routeMap = {
|
|
|
555
579
|
["/api/insert-asset"] = AssetHandlers.insertAsset,
|
|
556
580
|
["/api/preview-asset"] = AssetHandlers.previewAsset,
|
|
557
581
|
["/api/capture-screenshot"] = CaptureHandlers.captureScreenshot,
|
|
582
|
+
["/api/capture-begin"] = CaptureHandlers.captureBegin,
|
|
583
|
+
["/api/capture-read"] = CaptureHandlers.captureRead,
|
|
558
584
|
["/api/simulate-mouse-input"] = InputHandlers.simulateMouseInput,
|
|
559
585
|
["/api/simulate-keyboard-input"] = InputHandlers.simulateKeyboardInput,
|
|
560
586
|
["/api/find-and-replace-in-scripts"] = ScriptHandlers.findAndReplaceInScripts,
|
|
@@ -895,6 +921,19 @@ local function activatePlugin(connIndex)
|
|
|
895
921
|
-- Initial /ready; pollForRequests will also re-fire ready if the server
|
|
896
922
|
-- later reports knownInstance=false (process restart, etc).
|
|
897
923
|
sendReady(conn)
|
|
924
|
+
-- Keep the eval bridges present in the edit DM so that ANY playtest —
|
|
925
|
+
-- including one the dev starts manually via the Studio Play button —
|
|
926
|
+
-- clones them into the play DMs and eval_*_runtime works with no setup
|
|
927
|
+
-- roundtrip. Only the edit DM installs; play DMs already have the cloned
|
|
928
|
+
-- copies. Idempotent, so reconnects don't re-dirty the place.
|
|
929
|
+
if not RunService:IsRunning() then
|
|
930
|
+
task.spawn(function()
|
|
931
|
+
local result = ensureBridgesInstalled()
|
|
932
|
+
if not result.installed then
|
|
933
|
+
warn(`[MCPPlugin] Eval bridge install failed: {result.error}`)
|
|
934
|
+
end
|
|
935
|
+
end)
|
|
936
|
+
end
|
|
898
937
|
-- Watch for game.Name updates so a stale "Place1" captured at first
|
|
899
938
|
-- /ready gets refreshed once Studio settles on the real DM name.
|
|
900
939
|
ensureNameChangeWatcher(conn)
|
|
@@ -1016,11 +1055,16 @@ local TS = require(script.Parent.Parent.include.RuntimeLib)
|
|
|
1016
1055
|
-- ServerScriptService.LoadStringEnabled, so eval_server_runtime works even
|
|
1017
1056
|
-- when LoadStringEnabled=false (the default in fresh places).
|
|
1018
1057
|
--
|
|
1019
|
-
-- Lifecycle:
|
|
1020
|
-
--
|
|
1021
|
-
--
|
|
1022
|
-
--
|
|
1023
|
-
--
|
|
1058
|
+
-- Lifecycle: the bridges live PERMANENTLY in the edit DM. Communication
|
|
1059
|
+
-- installs them (ensureBridgesInstalled) when the plugin connects in edit,
|
|
1060
|
+
-- and TestHandlers.startPlaytest force-refreshes them right before
|
|
1061
|
+
-- ExecutePlayModeAsync. ExecutePlayModeAsync clones the DataModel into the
|
|
1062
|
+
-- play DMs, so the scripts come along and run there. We keep them in the edit
|
|
1063
|
+
-- DM after a playtest ends (rather than cleaning up) so that a playtest the
|
|
1064
|
+
-- dev starts MANUALLY via the Studio Play button — not the MCP start_playtest
|
|
1065
|
+
-- tool — also gets the bridges cloned in. This is intentionally a little
|
|
1066
|
+
-- intrusive (two helper scripts visible in Explorer) in exchange for a
|
|
1067
|
+
-- zero-roundtrip eval_*_runtime experience for devs working 1:1 with an agent.
|
|
1024
1068
|
--
|
|
1025
1069
|
-- Archivable handling: ExecutePlayModeAsync's deep-clone SKIPS instances
|
|
1026
1070
|
-- with Archivable=false (verified empirically in v2.9.0 testing - bridges
|
|
@@ -1052,9 +1096,9 @@ local BRIDGE_NAMES = {
|
|
|
1052
1096
|
-- Embedded Luau. The double `${...}` references our exported names so a
|
|
1053
1097
|
-- rename here propagates to both the script source and the tool wrappers.
|
|
1054
1098
|
local SERVER_BRIDGE_SOURCE = `\
|
|
1055
|
-
--
|
|
1056
|
-
--
|
|
1057
|
-
--
|
|
1099
|
+
-- Installed by @chrrxs/robloxstudio-mcp to power the eval_server_runtime MCP\
|
|
1100
|
+
-- tool (shared-require-cache eval on the server during playtests). Inert\
|
|
1101
|
+
-- outside Studio (no-ops in live games); safe to leave in place.\
|
|
1058
1102
|
\
|
|
1059
1103
|
local ServerScriptService = game:GetService("ServerScriptService")\
|
|
1060
1104
|
local RunService = game:GetService("RunService")\
|
|
@@ -1078,9 +1122,9 @@ bf.OnInvoke = function(payload)\
|
|
|
1078
1122
|
end\
|
|
1079
1123
|
`
|
|
1080
1124
|
local CLIENT_BRIDGE_SOURCE = `\
|
|
1081
|
-
--
|
|
1082
|
-
--
|
|
1083
|
-
--
|
|
1125
|
+
-- Installed by @chrrxs/robloxstudio-mcp to power the eval_client_runtime MCP\
|
|
1126
|
+
-- tool (shared-require-cache eval on the client during playtests). Inert\
|
|
1127
|
+
-- outside Studio (no-ops in live games); safe to leave in place.\
|
|
1084
1128
|
\
|
|
1085
1129
|
local ReplicatedStorage = game:GetService("ReplicatedStorage")\
|
|
1086
1130
|
local RunService = game:GetService("RunService")\
|
|
@@ -1103,6 +1147,25 @@ bf.OnInvoke = function(payload)\
|
|
|
1103
1147
|
return pcall(require, payload)\
|
|
1104
1148
|
end\
|
|
1105
1149
|
`
|
|
1150
|
+
-- Stamp written onto each installed bridge Script so we can tell whether the
|
|
1151
|
+
-- bridge currently in the DM was produced by THIS plugin build. It's a djb2
|
|
1152
|
+
-- hash of the actual bridge source plus the plugin version, so ANY change to
|
|
1153
|
+
-- the source (or a version bump) yields a new stamp — which makes
|
|
1154
|
+
-- ensureBridgesInstalled() force a refresh on the next plugin load instead of
|
|
1155
|
+
-- keeping a stale bridge that happens to still be present (e.g. one saved into
|
|
1156
|
+
-- the .rbxl from an older build).
|
|
1157
|
+
local STAMP_ATTR = "__MCPBridgeStamp"
|
|
1158
|
+
local function computeBridgeStamp()
|
|
1159
|
+
local combined = `{SERVER_BRIDGE_SOURCE}|{CLIENT_BRIDGE_SOURCE}`
|
|
1160
|
+
local h = 5381
|
|
1161
|
+
for i = 1, #combined do
|
|
1162
|
+
h = (h * 33 + (string.byte(combined, i))) % 2147483647
|
|
1163
|
+
end
|
|
1164
|
+
-- "2.13.0" is replaced with the package version at package time
|
|
1165
|
+
-- (scripts/build-plugin.mjs injectVersion), so a release bump also restamps.
|
|
1166
|
+
return `{tostring(h)}-2.13.0`
|
|
1167
|
+
end
|
|
1168
|
+
local BRIDGE_STAMP = computeBridgeStamp()
|
|
1106
1169
|
local function setSource(scriptInst, source)
|
|
1107
1170
|
-- ScriptEditorService is the cleaner API and integrates with Studio's
|
|
1108
1171
|
-- edit history; fall back to direct Source mutation (allowed in plugin
|
|
@@ -1138,7 +1201,31 @@ local function cleanupBridges()
|
|
|
1138
1201
|
end)
|
|
1139
1202
|
end
|
|
1140
1203
|
end
|
|
1141
|
-
|
|
1204
|
+
-- Idempotent variant: install only if the bridge scripts aren't already
|
|
1205
|
+
-- present in the edit DM. Used to keep the bridges always available (so a
|
|
1206
|
+
-- playtest the dev starts manually — not via the MCP start_playtest tool —
|
|
1207
|
+
-- still clones them into the play DMs). Cheap no-op when already installed,
|
|
1208
|
+
-- which avoids re-dirtying the place on every plugin reconnect.
|
|
1209
|
+
local installBridges
|
|
1210
|
+
local function ensureBridgesInstalled()
|
|
1211
|
+
local _binding = findBridges()
|
|
1212
|
+
local server = _binding.server
|
|
1213
|
+
local client = _binding.client
|
|
1214
|
+
if server and client then
|
|
1215
|
+
-- Both present — but only skip the reinstall if they were produced by
|
|
1216
|
+
-- THIS build. A mismatched/absent stamp means a stale bridge (older
|
|
1217
|
+
-- plugin, or one persisted in the saved place), so force a refresh.
|
|
1218
|
+
local sStamp = server:GetAttribute(STAMP_ATTR)
|
|
1219
|
+
local cStamp = client:GetAttribute(STAMP_ATTR)
|
|
1220
|
+
if sStamp == BRIDGE_STAMP and cStamp == BRIDGE_STAMP then
|
|
1221
|
+
return {
|
|
1222
|
+
installed = true,
|
|
1223
|
+
}
|
|
1224
|
+
end
|
|
1225
|
+
end
|
|
1226
|
+
return installBridges()
|
|
1227
|
+
end
|
|
1228
|
+
function installBridges()
|
|
1142
1229
|
-- Defensive: clear any stale bridges from a prior unclean exit before
|
|
1143
1230
|
-- inserting fresh. The injected script also self-cleans its
|
|
1144
1231
|
-- ReplicatedStorage/ServerScriptService children at startup, but the
|
|
@@ -1151,6 +1238,7 @@ local function installBridges()
|
|
|
1151
1238
|
-- script. cleanupBridges() removes it from the edit DM when the
|
|
1152
1239
|
-- playtest ends.
|
|
1153
1240
|
setSource(serverScript, SERVER_BRIDGE_SOURCE)
|
|
1241
|
+
serverScript:SetAttribute(STAMP_ATTR, BRIDGE_STAMP)
|
|
1154
1242
|
serverScript.Parent = ServerScriptService
|
|
1155
1243
|
local sps = getStarterPlayerScripts()
|
|
1156
1244
|
if not sps then
|
|
@@ -1159,6 +1247,7 @@ local function installBridges()
|
|
|
1159
1247
|
local clientScript = Instance.new("LocalScript")
|
|
1160
1248
|
clientScript.Name = CLIENT_SCRIPT_NAME
|
|
1161
1249
|
setSource(clientScript, CLIENT_BRIDGE_SOURCE)
|
|
1250
|
+
clientScript:SetAttribute(STAMP_ATTR, BRIDGE_STAMP)
|
|
1162
1251
|
clientScript.Parent = sps
|
|
1163
1252
|
end)
|
|
1164
1253
|
if not ok then
|
|
@@ -1173,6 +1262,7 @@ local function installBridges()
|
|
|
1173
1262
|
end
|
|
1174
1263
|
return {
|
|
1175
1264
|
cleanupBridges = cleanupBridges,
|
|
1265
|
+
ensureBridgesInstalled = ensureBridgesInstalled,
|
|
1176
1266
|
installBridges = installBridges,
|
|
1177
1267
|
BRIDGE_NAMES = BRIDGE_NAMES,
|
|
1178
1268
|
}
|
|
@@ -2038,6 +2128,8 @@ return {
|
|
|
2038
2128
|
<Properties>
|
|
2039
2129
|
<string name="Name">CaptureHandlers</string>
|
|
2040
2130
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
2131
|
+
local TS = require(script.Parent.Parent.Parent.include.RuntimeLib)
|
|
2132
|
+
local RenderMonitor = TS.import(script, script.Parent.Parent, "RenderMonitor")
|
|
2041
2133
|
local CaptureService = game:GetService("CaptureService")
|
|
2042
2134
|
local AssetService = game:GetService("AssetService")
|
|
2043
2135
|
local MAX_TILE_SIZE = 1024
|
|
@@ -2147,7 +2239,20 @@ local function readPixelsTiled(img, w, h)
|
|
|
2147
2239
|
end
|
|
2148
2240
|
return fullBuf
|
|
2149
2241
|
end
|
|
2150
|
-
|
|
2242
|
+
-- Triggers CaptureService:CaptureScreenshot and waits for the temporary
|
|
2243
|
+
-- content id. Works in any DM, including the play CLIENT (where reading the
|
|
2244
|
+
-- pixels back is blocked, but capturing is not). The returned rbxtemp:// id is
|
|
2245
|
+
-- a process-scoped handle: it can be dereferenced from a DIFFERENT, more
|
|
2246
|
+
-- privileged DM (the edit DM) — see captureRead.
|
|
2247
|
+
local function doCaptureScreenshot()
|
|
2248
|
+
-- Fast-fail with a clear reason if the window isn't rendering — otherwise
|
|
2249
|
+
-- CaptureScreenshot's callback never fires and we'd block for the full 10s.
|
|
2250
|
+
local notRendering = RenderMonitor.notRenderingReason()
|
|
2251
|
+
if notRendering ~= nil then
|
|
2252
|
+
return {
|
|
2253
|
+
error = notRendering,
|
|
2254
|
+
}
|
|
2255
|
+
end
|
|
2151
2256
|
local contentId
|
|
2152
2257
|
CaptureService:CaptureScreenshot(function(id)
|
|
2153
2258
|
contentId = id
|
|
@@ -2156,11 +2261,21 @@ local function captureScreenshotData()
|
|
|
2156
2261
|
while contentId == nil do
|
|
2157
2262
|
if tick() - startTime > 10 then
|
|
2158
2263
|
return {
|
|
2159
|
-
error = "Screenshot capture timed out.
|
|
2264
|
+
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.)",
|
|
2160
2265
|
}
|
|
2161
2266
|
end
|
|
2162
2267
|
task.wait(0.1)
|
|
2163
2268
|
end
|
|
2269
|
+
return {
|
|
2270
|
+
contentId = contentId,
|
|
2271
|
+
}
|
|
2272
|
+
end
|
|
2273
|
+
-- Promotes a CaptureScreenshot content id into an EditableImage and reads its
|
|
2274
|
+
-- RGBA pixels. MUST run in the edit/plugin context: the running game VM lacks
|
|
2275
|
+
-- the privilege to create an EditableImage from a temporary texture id (errors
|
|
2276
|
+
-- "cannot currently create editable image from temporary texture id"), while
|
|
2277
|
+
-- the edit DM can — even for an id captured in the play client DM.
|
|
2278
|
+
local function readContentToBase64(contentId)
|
|
2164
2279
|
local editableOk, editableResult = pcall(function()
|
|
2165
2280
|
return AssetService:CreateEditableImageAsync(Content.fromUri(contentId))
|
|
2166
2281
|
end)
|
|
@@ -2190,12 +2305,36 @@ local function captureScreenshotData()
|
|
|
2190
2305
|
data = base64Data,
|
|
2191
2306
|
}
|
|
2192
2307
|
end
|
|
2308
|
+
-- Edit-mode single shot: capture and read back in the same (edit) context.
|
|
2309
|
+
local function captureScreenshotData()
|
|
2310
|
+
local cap = doCaptureScreenshot()
|
|
2311
|
+
if cap.error ~= nil then
|
|
2312
|
+
return cap
|
|
2313
|
+
end
|
|
2314
|
+
return readContentToBase64(cap.contentId)
|
|
2315
|
+
end
|
|
2193
2316
|
local function captureScreenshot()
|
|
2194
2317
|
return captureScreenshotData()
|
|
2195
2318
|
end
|
|
2319
|
+
-- Play-mode step 1 (run on the CLIENT): capture only, return the temp id.
|
|
2320
|
+
local function captureBegin()
|
|
2321
|
+
return doCaptureScreenshot()
|
|
2322
|
+
end
|
|
2323
|
+
-- Play-mode step 2 (run on EDIT): read pixels from a temp id captured elsewhere.
|
|
2324
|
+
local function captureRead(requestData)
|
|
2325
|
+
local contentId = requestData.contentId
|
|
2326
|
+
if not (contentId ~= "" and contentId) then
|
|
2327
|
+
return {
|
|
2328
|
+
error = "contentId is required",
|
|
2329
|
+
}
|
|
2330
|
+
end
|
|
2331
|
+
return readContentToBase64(contentId)
|
|
2332
|
+
end
|
|
2196
2333
|
return {
|
|
2197
2334
|
captureScreenshotData = captureScreenshotData,
|
|
2198
2335
|
captureScreenshot = captureScreenshot,
|
|
2336
|
+
captureBegin = captureBegin,
|
|
2337
|
+
captureRead = captureRead,
|
|
2199
2338
|
}
|
|
2200
2339
|
]]></string>
|
|
2201
2340
|
</Properties>
|
|
@@ -2204,19 +2343,56 @@ return {
|
|
|
2204
2343
|
<Properties>
|
|
2205
2344
|
<string name="Name">InputHandlers</string>
|
|
2206
2345
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
2207
|
-
local
|
|
2208
|
-
|
|
2209
|
-
|
|
2346
|
+
local TS = require(script.Parent.Parent.Parent.include.RuntimeLib)
|
|
2347
|
+
-- Virtual input via UserInputService:CreateVirtualInput().
|
|
2348
|
+
--
|
|
2349
|
+
-- We deliberately do NOT use VirtualInputManager:Send*Event — those methods
|
|
2350
|
+
-- are gated behind RobloxScriptSecurity ("lacking capability RobloxScript")
|
|
2351
|
+
-- in every context a plugin can reach (edit DM, play server/client DMs), so
|
|
2352
|
+
-- they silently never worked. CreateVirtualInput() is callable without that
|
|
2353
|
+
-- capability and drives the REAL input pipeline: SendKey feeds
|
|
2354
|
+
-- UserInputService.InputBegan/Ended and the control modules (so WASD walks the
|
|
2355
|
+
-- character at full WalkSpeed with controls intact, no Humanoid hijack),
|
|
2356
|
+
-- SendMouseButton feeds UIS and activates GUI buttons (and hit-tests against
|
|
2357
|
+
-- CoreGui), and SendTextInput types into the focused TextBox.
|
|
2358
|
+
--
|
|
2359
|
+
-- Method set on the VirtualInput object (verified live):
|
|
2360
|
+
-- SendKey(isDown: boolean, keyCode: Enum.KeyCode)
|
|
2361
|
+
-- SendMouseButton(position: Vector2, inputType: Enum.UserInputType, isDown: boolean)
|
|
2362
|
+
-- SendTextInput(text: string)
|
|
2363
|
+
-- There is NO SendMouseMove / SendMouseWheel / SendKeyEvent — so "move" and
|
|
2364
|
+
-- "scroll" mouse actions are not supported.
|
|
2365
|
+
--
|
|
2366
|
+
-- Coordinate space: SendMouseButton coordinates are viewport pixels matching
|
|
2367
|
+
-- what capture_screenshot returns (window space, origin at the top-left of the
|
|
2368
|
+
-- rendered viewport). Pass screenshot pixel coordinates straight through. Note
|
|
2369
|
+
-- that UserInputService reports input positions in GUI space, which is offset
|
|
2370
|
+
-- from this by GuiService:GetGuiInset() (~58px on the Y axis) — irrelevant for
|
|
2371
|
+
-- callers who pick coordinates off a screenshot, which is why we do not
|
|
2372
|
+
-- translate here.
|
|
2373
|
+
local RenderMonitor = TS.import(script, script.Parent.Parent, "RenderMonitor")
|
|
2374
|
+
local UserInputService = game:GetService("UserInputService")
|
|
2375
|
+
-- One VirtualInput per plugin VM, reused across calls so that a key held down
|
|
2376
|
+
-- in one call (action="press") and released in a later call (action="release")
|
|
2377
|
+
-- share the same input source.
|
|
2378
|
+
local cachedVI
|
|
2379
|
+
local function getVI()
|
|
2380
|
+
if cachedVI then
|
|
2381
|
+
return cachedVI
|
|
2382
|
+
end
|
|
2383
|
+
local ok, vi = pcall(function()
|
|
2384
|
+
return UserInputService:CreateVirtualInput()
|
|
2210
2385
|
end)
|
|
2211
|
-
if ok and
|
|
2212
|
-
|
|
2386
|
+
if ok and vi ~= nil then
|
|
2387
|
+
cachedVI = vi
|
|
2388
|
+
return cachedVI
|
|
2213
2389
|
end
|
|
2214
2390
|
return nil
|
|
2215
2391
|
end
|
|
2216
|
-
local
|
|
2217
|
-
Left =
|
|
2218
|
-
Right =
|
|
2219
|
-
Middle =
|
|
2392
|
+
local MOUSE_TYPE_MAP = {
|
|
2393
|
+
Left = Enum.UserInputType.MouseButton1,
|
|
2394
|
+
Right = Enum.UserInputType.MouseButton2,
|
|
2395
|
+
Middle = Enum.UserInputType.MouseButton3,
|
|
2220
2396
|
}
|
|
2221
2397
|
local function simulateMouseInput(requestData)
|
|
2222
2398
|
local action = requestData.action
|
|
@@ -2227,56 +2403,43 @@ local function simulateMouseInput(requestData)
|
|
|
2227
2403
|
_condition = "Left"
|
|
2228
2404
|
end
|
|
2229
2405
|
local button = _condition
|
|
2230
|
-
local scrollDirection = requestData.scrollDirection
|
|
2231
2406
|
if not (action ~= "" and action) then
|
|
2232
2407
|
return {
|
|
2233
2408
|
error = "action is required",
|
|
2234
2409
|
}
|
|
2235
2410
|
end
|
|
2236
|
-
|
|
2237
|
-
if not vim then
|
|
2411
|
+
if x == nil or y == nil then
|
|
2238
2412
|
return {
|
|
2239
|
-
error = "
|
|
2413
|
+
error = "x and y are required",
|
|
2240
2414
|
}
|
|
2241
2415
|
end
|
|
2242
|
-
|
|
2243
|
-
|
|
2244
|
-
|
|
2416
|
+
-- Input is silently dropped by the engine when the window isn't rendering
|
|
2417
|
+
-- (e.g. minimized). Surface that instead of returning a false success.
|
|
2418
|
+
local notRendering = RenderMonitor.notRenderingReason()
|
|
2419
|
+
if notRendering ~= nil then
|
|
2420
|
+
return {
|
|
2421
|
+
error = notRendering,
|
|
2422
|
+
}
|
|
2423
|
+
end
|
|
2424
|
+
local vi = getVI()
|
|
2425
|
+
if not vi then
|
|
2426
|
+
return {
|
|
2427
|
+
error = "UserInputService:CreateVirtualInput() is not available in this context",
|
|
2428
|
+
}
|
|
2245
2429
|
end
|
|
2246
|
-
local
|
|
2430
|
+
local inputType = MOUSE_TYPE_MAP[button] or Enum.UserInputType.MouseButton1
|
|
2431
|
+
local pos = Vector2.new(x, y)
|
|
2247
2432
|
local success, err = pcall(function()
|
|
2248
2433
|
if action == "click" then
|
|
2249
|
-
|
|
2250
|
-
error("x and y are required for click")
|
|
2251
|
-
end
|
|
2252
|
-
vim:SendMouseButtonEvent(x, y, buttonNum, true)
|
|
2434
|
+
vi:SendMouseButton(pos, inputType, true)
|
|
2253
2435
|
task.wait(0.05)
|
|
2254
|
-
|
|
2436
|
+
vi:SendMouseButton(pos, inputType, false)
|
|
2255
2437
|
elseif action == "mouseDown" then
|
|
2256
|
-
|
|
2257
|
-
error("x and y are required for mouseDown")
|
|
2258
|
-
end
|
|
2259
|
-
vim:SendMouseButtonEvent(x, y, buttonNum, true)
|
|
2438
|
+
vi:SendMouseButton(pos, inputType, true)
|
|
2260
2439
|
elseif action == "mouseUp" then
|
|
2261
|
-
|
|
2262
|
-
error("x and y are required for mouseUp")
|
|
2263
|
-
end
|
|
2264
|
-
vim:SendMouseButtonEvent(x, y, buttonNum, false)
|
|
2265
|
-
elseif action == "move" then
|
|
2266
|
-
if x == nil or y == nil then
|
|
2267
|
-
error("x and y are required for move")
|
|
2268
|
-
end
|
|
2269
|
-
vim:SendMouseMoveEvent(x, y)
|
|
2270
|
-
elseif action == "scroll" then
|
|
2271
|
-
if x == nil or y == nil then
|
|
2272
|
-
error("x and y are required for scroll")
|
|
2273
|
-
end
|
|
2274
|
-
if not (scrollDirection ~= "" and scrollDirection) then
|
|
2275
|
-
error("scrollDirection is required for scroll")
|
|
2276
|
-
end
|
|
2277
|
-
vim:SendMouseWheelEvent(x, y, scrollDirection == "up")
|
|
2440
|
+
vi:SendMouseButton(pos, inputType, false)
|
|
2278
2441
|
else
|
|
2279
|
-
error(`
|
|
2442
|
+
error(`Unsupported action "{action}". CreateVirtualInput supports click, mouseDown, mouseUp ` .. `(no move/scroll — those methods don't exist on VirtualInput).`)
|
|
2280
2443
|
end
|
|
2281
2444
|
end)
|
|
2282
2445
|
if success then
|
|
@@ -2293,7 +2456,40 @@ local function simulateMouseInput(requestData)
|
|
|
2293
2456
|
}
|
|
2294
2457
|
end
|
|
2295
2458
|
local function simulateKeyboardInput(requestData)
|
|
2459
|
+
local notRendering = RenderMonitor.notRenderingReason()
|
|
2460
|
+
if notRendering ~= nil then
|
|
2461
|
+
return {
|
|
2462
|
+
error = notRendering,
|
|
2463
|
+
}
|
|
2464
|
+
end
|
|
2465
|
+
local vi = getVI()
|
|
2466
|
+
if not vi then
|
|
2467
|
+
return {
|
|
2468
|
+
error = "UserInputService:CreateVirtualInput() is not available in this context",
|
|
2469
|
+
}
|
|
2470
|
+
end
|
|
2471
|
+
-- Text mode: type a string into the focused TextBox.
|
|
2472
|
+
local text = requestData.text
|
|
2473
|
+
if text ~= nil then
|
|
2474
|
+
local ok, err = pcall(function()
|
|
2475
|
+
return vi:SendTextInput(text)
|
|
2476
|
+
end)
|
|
2477
|
+
if ok then
|
|
2478
|
+
return {
|
|
2479
|
+
success = true,
|
|
2480
|
+
text = text,
|
|
2481
|
+
}
|
|
2482
|
+
end
|
|
2483
|
+
return {
|
|
2484
|
+
error = `Failed to send text input: {err}`,
|
|
2485
|
+
}
|
|
2486
|
+
end
|
|
2296
2487
|
local keyCodeName = requestData.keyCode
|
|
2488
|
+
if not (keyCodeName ~= "" and keyCodeName) then
|
|
2489
|
+
return {
|
|
2490
|
+
error = "keyCode (or text) is required",
|
|
2491
|
+
}
|
|
2492
|
+
end
|
|
2297
2493
|
local _condition = (requestData.action)
|
|
2298
2494
|
if _condition == nil then
|
|
2299
2495
|
_condition = "tap"
|
|
@@ -2304,17 +2500,6 @@ local function simulateKeyboardInput(requestData)
|
|
|
2304
2500
|
_condition_1 = 0.1
|
|
2305
2501
|
end
|
|
2306
2502
|
local duration = _condition_1
|
|
2307
|
-
if not (keyCodeName ~= "" and keyCodeName) then
|
|
2308
|
-
return {
|
|
2309
|
-
error = "keyCode is required",
|
|
2310
|
-
}
|
|
2311
|
-
end
|
|
2312
|
-
local vim = getVIM()
|
|
2313
|
-
if not vim then
|
|
2314
|
-
return {
|
|
2315
|
-
error = "VirtualInputManager is not available in this context",
|
|
2316
|
-
}
|
|
2317
|
-
end
|
|
2318
2503
|
local enumOk, keyCode = pcall(function()
|
|
2319
2504
|
return (Enum.KeyCode)[keyCodeName]
|
|
2320
2505
|
end)
|
|
@@ -2325,13 +2510,13 @@ local function simulateKeyboardInput(requestData)
|
|
|
2325
2510
|
end
|
|
2326
2511
|
local success, err = pcall(function()
|
|
2327
2512
|
if action == "press" then
|
|
2328
|
-
|
|
2513
|
+
vi:SendKey(true, keyCode)
|
|
2329
2514
|
elseif action == "release" then
|
|
2330
|
-
|
|
2515
|
+
vi:SendKey(false, keyCode)
|
|
2331
2516
|
elseif action == "tap" then
|
|
2332
|
-
|
|
2517
|
+
vi:SendKey(true, keyCode)
|
|
2333
2518
|
task.wait(duration)
|
|
2334
|
-
|
|
2519
|
+
vi:SendKey(false, keyCode)
|
|
2335
2520
|
else
|
|
2336
2521
|
error(`Unknown action: {action}`)
|
|
2337
2522
|
end
|
|
@@ -5682,7 +5867,7 @@ local LogService = _services.LogService
|
|
|
5682
5867
|
local RunService = _services.RunService
|
|
5683
5868
|
local _EvalBridges = TS.import(script, script.Parent.Parent, "EvalBridges")
|
|
5684
5869
|
local installBridges = _EvalBridges.installBridges
|
|
5685
|
-
local
|
|
5870
|
+
local ensureBridgesInstalled = _EvalBridges.ensureBridgesInstalled
|
|
5686
5871
|
local StopPlayMonitor = TS.import(script, script.Parent.Parent, "StopPlayMonitor")
|
|
5687
5872
|
local StudioTestService = game:GetService("StudioTestService")
|
|
5688
5873
|
local ServerScriptService = game:GetService("ServerScriptService")
|
|
@@ -5807,7 +5992,9 @@ local function startPlaytest(requestData)
|
|
|
5807
5992
|
logConnection = nil
|
|
5808
5993
|
end
|
|
5809
5994
|
cleanupStopListener()
|
|
5810
|
-
|
|
5995
|
+
-- Note: eval bridges are intentionally NOT cleaned up — they live
|
|
5996
|
+
-- permanently in the edit DM so manual playtests also get them. See
|
|
5997
|
+
-- EvalBridges.ts lifecycle comment.
|
|
5811
5998
|
end
|
|
5812
5999
|
if testRunning then
|
|
5813
6000
|
return {
|
|
@@ -5850,9 +6037,10 @@ local function startPlaytest(requestData)
|
|
|
5850
6037
|
if not injected then
|
|
5851
6038
|
warn(`[MCP] Failed to inject stop listener: {injErr}`)
|
|
5852
6039
|
end
|
|
5853
|
-
--
|
|
5854
|
-
-- so
|
|
5855
|
-
--
|
|
6040
|
+
-- Force-refresh the game-VM eval bridges (ServerEvalBridge + ClientEvalBridge)
|
|
6041
|
+
-- right before cloning so the play DMs get the current source. They also
|
|
6042
|
+
-- live permanently in the edit DM (installed on connect) so manually-started
|
|
6043
|
+
-- playtests get them too; here we just ensure they're fresh.
|
|
5856
6044
|
local bridgeInstall = installBridges()
|
|
5857
6045
|
if not bridgeInstall.installed then
|
|
5858
6046
|
warn(`[MCP] Eval bridge install failed: {bridgeInstall.error}`)
|
|
@@ -5879,7 +6067,9 @@ local function startPlaytest(requestData)
|
|
|
5879
6067
|
end
|
|
5880
6068
|
testRunning = false
|
|
5881
6069
|
cleanupStopListener()
|
|
5882
|
-
|
|
6070
|
+
-- Eval bridges persist in the edit DM (see EvalBridges.ts) — do not
|
|
6071
|
+
-- clean up here, so the next manual playtest still gets them.
|
|
6072
|
+
ensureBridgesInstalled()
|
|
5883
6073
|
end)
|
|
5884
6074
|
local msg = if numPlayers ~= nil then `Playtest started in {mode} mode with {numPlayers} player(s).` else `Playtest started in {mode} mode.`
|
|
5885
6075
|
local response = {
|
|
@@ -6393,6 +6583,74 @@ return {
|
|
|
6393
6583
|
</Properties>
|
|
6394
6584
|
</Item>
|
|
6395
6585
|
<Item class="ModuleScript" referent="21">
|
|
6586
|
+
<Properties>
|
|
6587
|
+
<string name="Name">RenderMonitor</string>
|
|
6588
|
+
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
6589
|
+
local TS = require(script.Parent.Parent.include.RuntimeLib)
|
|
6590
|
+
-- Detects whether the Studio window is actually rendering, so virtual input
|
|
6591
|
+
-- and screenshot tools can surface a clear reason instead of silently failing.
|
|
6592
|
+
--
|
|
6593
|
+
-- When a Studio window is MINIMIZED, the engine suspends the render loop AND
|
|
6594
|
+
-- input processing, but keeps running scripts (Heartbeat keeps firing). That's
|
|
6595
|
+
-- why simulate_*_input would return success while having zero effect, and
|
|
6596
|
+
-- CaptureService:CaptureScreenshot would time out. Validated live: during a 3s
|
|
6597
|
+
-- minimize, RenderStepped's max inter-frame gap was 5.08s while Heartbeat's was
|
|
6598
|
+
-- 0.10s. So RenderStepped freshness is the reliable "is this window rendering?"
|
|
6599
|
+
-- signal; Heartbeat is not.
|
|
6600
|
+
local RunService = TS.import(script, script.Parent.Parent, "node_modules", "@rbxts", "services").RunService
|
|
6601
|
+
local lastFrame = 0
|
|
6602
|
+
local connected = false
|
|
6603
|
+
-- Above this many seconds since the last rendered frame, we treat the window
|
|
6604
|
+
-- as not rendering. RenderStepped normally fires every ~16ms; a multi-second
|
|
6605
|
+
-- gap only happens when minimized/suspended, so 1s cleanly avoids false
|
|
6606
|
+
-- positives from ordinary frame hitches while still catching the real case.
|
|
6607
|
+
local STALE_THRESHOLD = 1.0
|
|
6608
|
+
local function start()
|
|
6609
|
+
if connected then
|
|
6610
|
+
return nil
|
|
6611
|
+
end
|
|
6612
|
+
-- RenderStepped can only be connected from a client/edit render loop; it
|
|
6613
|
+
-- throws in the play-server DM. pcall so a server-DM call is a safe no-op
|
|
6614
|
+
-- (connected stays false → notRenderingReason() returns undefined there).
|
|
6615
|
+
local ok = pcall(function()
|
|
6616
|
+
RunService.RenderStepped:Connect(function()
|
|
6617
|
+
lastFrame = tick()
|
|
6618
|
+
end)
|
|
6619
|
+
end)
|
|
6620
|
+
if ok then
|
|
6621
|
+
connected = true
|
|
6622
|
+
lastFrame = tick()
|
|
6623
|
+
end
|
|
6624
|
+
end
|
|
6625
|
+
local function secondsSinceFrame()
|
|
6626
|
+
if not connected then
|
|
6627
|
+
return 0
|
|
6628
|
+
end
|
|
6629
|
+
return tick() - lastFrame
|
|
6630
|
+
end
|
|
6631
|
+
-- Returns a human-readable reason if the window appears minimized / not
|
|
6632
|
+
-- rendering (so input + screenshots won't work), else undefined. Fail-open:
|
|
6633
|
+
-- when the monitor isn't active in this DM (server peer, or connect failed) it
|
|
6634
|
+
-- returns undefined so we never block on a false signal.
|
|
6635
|
+
local function notRenderingReason()
|
|
6636
|
+
if not connected then
|
|
6637
|
+
return nil
|
|
6638
|
+
end
|
|
6639
|
+
local gap = secondsSinceFrame()
|
|
6640
|
+
if gap > STALE_THRESHOLD then
|
|
6641
|
+
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)
|
|
6642
|
+
end
|
|
6643
|
+
return nil
|
|
6644
|
+
end
|
|
6645
|
+
return {
|
|
6646
|
+
start = start,
|
|
6647
|
+
secondsSinceFrame = secondsSinceFrame,
|
|
6648
|
+
notRenderingReason = notRenderingReason,
|
|
6649
|
+
}
|
|
6650
|
+
]]></string>
|
|
6651
|
+
</Properties>
|
|
6652
|
+
</Item>
|
|
6653
|
+
<Item class="ModuleScript" referent="22">
|
|
6396
6654
|
<Properties>
|
|
6397
6655
|
<string name="Name">RuntimeLogBuffer</string>
|
|
6398
6656
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -6573,11 +6831,11 @@ return {
|
|
|
6573
6831
|
]]></string>
|
|
6574
6832
|
</Properties>
|
|
6575
6833
|
</Item>
|
|
6576
|
-
<Item class="ModuleScript" referent="
|
|
6834
|
+
<Item class="ModuleScript" referent="23">
|
|
6577
6835
|
<Properties>
|
|
6578
6836
|
<string name="Name">State</string>
|
|
6579
6837
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
6580
|
-
local CURRENT_VERSION = "2.
|
|
6838
|
+
local CURRENT_VERSION = "2.13.0"
|
|
6581
6839
|
local MAX_CONNECTIONS = 5
|
|
6582
6840
|
local BASE_PORT = 58741
|
|
6583
6841
|
local activeTabIndex = 0
|
|
@@ -6669,7 +6927,7 @@ return {
|
|
|
6669
6927
|
]]></string>
|
|
6670
6928
|
</Properties>
|
|
6671
6929
|
</Item>
|
|
6672
|
-
<Item class="ModuleScript" referent="
|
|
6930
|
+
<Item class="ModuleScript" referent="24">
|
|
6673
6931
|
<Properties>
|
|
6674
6932
|
<string name="Name">StopPlayMonitor</string>
|
|
6675
6933
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -6814,7 +7072,7 @@ return {
|
|
|
6814
7072
|
]]></string>
|
|
6815
7073
|
</Properties>
|
|
6816
7074
|
</Item>
|
|
6817
|
-
<Item class="ModuleScript" referent="
|
|
7075
|
+
<Item class="ModuleScript" referent="25">
|
|
6818
7076
|
<Properties>
|
|
6819
7077
|
<string name="Name">UI</string>
|
|
6820
7078
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -7565,7 +7823,7 @@ return {
|
|
|
7565
7823
|
]]></string>
|
|
7566
7824
|
</Properties>
|
|
7567
7825
|
</Item>
|
|
7568
|
-
<Item class="ModuleScript" referent="
|
|
7826
|
+
<Item class="ModuleScript" referent="26">
|
|
7569
7827
|
<Properties>
|
|
7570
7828
|
<string name="Name">Utils</string>
|
|
7571
7829
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -8095,11 +8353,11 @@ return {
|
|
|
8095
8353
|
</Properties>
|
|
8096
8354
|
</Item>
|
|
8097
8355
|
</Item>
|
|
8098
|
-
<Item class="Folder" referent="
|
|
8356
|
+
<Item class="Folder" referent="30">
|
|
8099
8357
|
<Properties>
|
|
8100
8358
|
<string name="Name">include</string>
|
|
8101
8359
|
</Properties>
|
|
8102
|
-
<Item class="ModuleScript" referent="
|
|
8360
|
+
<Item class="ModuleScript" referent="27">
|
|
8103
8361
|
<Properties>
|
|
8104
8362
|
<string name="Name">Promise</string>
|
|
8105
8363
|
<string name="Source"><![CDATA[--[[
|
|
@@ -10173,7 +10431,7 @@ return Promise
|
|
|
10173
10431
|
]]></string>
|
|
10174
10432
|
</Properties>
|
|
10175
10433
|
</Item>
|
|
10176
|
-
<Item class="ModuleScript" referent="
|
|
10434
|
+
<Item class="ModuleScript" referent="28">
|
|
10177
10435
|
<Properties>
|
|
10178
10436
|
<string name="Name">RuntimeLib</string>
|
|
10179
10437
|
<string name="Source"><![CDATA[local Promise = require(script.Parent.Promise)
|
|
@@ -10440,15 +10698,15 @@ return TS
|
|
|
10440
10698
|
</Properties>
|
|
10441
10699
|
</Item>
|
|
10442
10700
|
</Item>
|
|
10443
|
-
<Item class="Folder" referent="
|
|
10701
|
+
<Item class="Folder" referent="31">
|
|
10444
10702
|
<Properties>
|
|
10445
10703
|
<string name="Name">node_modules</string>
|
|
10446
10704
|
</Properties>
|
|
10447
|
-
<Item class="Folder" referent="
|
|
10705
|
+
<Item class="Folder" referent="32">
|
|
10448
10706
|
<Properties>
|
|
10449
10707
|
<string name="Name">@rbxts</string>
|
|
10450
10708
|
</Properties>
|
|
10451
|
-
<Item class="ModuleScript" referent="
|
|
10709
|
+
<Item class="ModuleScript" referent="29">
|
|
10452
10710
|
<Properties>
|
|
10453
10711
|
<string name="Name">services</string>
|
|
10454
10712
|
<string name="Source"><![CDATA[return setmetatable({}, {
|