@chrrxs/robloxstudio-mcp 2.11.4 → 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.
@@ -0,0 +1,305 @@
1
+ /* eslint-disable */
2
+ // Shared execute_luau machinery for edit/server (MetadataHandlers.executeLuau)
3
+ // and the play-client peer (ClientBroker.handleExecuteLuau). Three things this
4
+ // module owns:
5
+ //
6
+ // 1. The IIFE wrapper that captures print/warn, runs user code in xpcall,
7
+ // and always returns { ok, value, output } so the ModuleScript itself
8
+ // always returns exactly one value (otherwise `print("hi")` with no
9
+ // return would fail with "Module code did not return exactly one value").
10
+ //
11
+ // 2. The loadstring-then-ModuleScript-require fallback, with the parse-error
12
+ // recovery hack that pulls the real diagnostic from LogService.
13
+ //
14
+ // 3. Return-value formatting: tables get HttpService:JSONEncode'd so the
15
+ // caller sees `{"x":1,"y":2}` instead of `table: 0xaddr`; primitives
16
+ // pass through tostring. The encode is pcall'd so cycles or
17
+ // non-serializable values gracefully fall back to tostring.
18
+ //
19
+ // Before this module existed, the client peer used a stripped-down
20
+ // require-only execution path that lacked both the wrapper and the JSON
21
+ // formatting, producing two well-known papercuts:
22
+ // - `print("hi")` (no return) failed with "Module code did not return..."
23
+ // - Returning a table yielded `table: 0xaddr` instead of structured data.
24
+
25
+ const HttpService = game.GetService("HttpService");
26
+ const LogService = game.GetService("LogService");
27
+
28
+ interface WrapperResult {
29
+ ok?: boolean;
30
+ value?: unknown;
31
+ output?: defined;
32
+ }
33
+
34
+ interface ExecuteResult {
35
+ success: boolean;
36
+ returnValue?: string;
37
+ output?: string[];
38
+ error?: string;
39
+ message?: string;
40
+ }
41
+
42
+ const PAYLOAD_INSTANCE_NAME = "__MCPExecLuauPayload";
43
+ const PAYLOAD_PATH_PREFIX = `Workspace.${PAYLOAD_INSTANCE_NAME}:`;
44
+
45
+ // Number of lines the wrapper emits BEFORE the first line of user code.
46
+ // Used both inside the wrapper (Luau __mcp_LINE_OFFSET) and on the TS side
47
+ // (remapPayloadLines, for compile errors recovered from LogService) so user
48
+ // code errors report user-relative line numbers instead of the inflated
49
+ // "line 23" the wrapper would otherwise expose. If you reorder buildWrapper's
50
+ // prefix lines, update this constant — there's a self-check below.
51
+ const WRAPPER_LINE_OFFSET = 23;
52
+
53
+ // Count source lines so the wrapper can filter traceback frames that fall
54
+ // outside the user code range (the wrapper's own preamble/postamble lines).
55
+ function countLines(s: string): number {
56
+ let n = 1;
57
+ const size = s.size();
58
+ for (let i = 1; i <= size; i++) {
59
+ if (string.sub(s, i, i) === "\n") n++;
60
+ }
61
+ return n;
62
+ }
63
+
64
+ function buildWrapper(code: string): string {
65
+ // If you reorder the prefix lines below, update WRAPPER_LINE_OFFSET to
66
+ // match the number of lines emitted BEFORE the ${code} substitution.
67
+ // The constant is mirrored inside the wrapper (__mcp_LINE_OFFSET) and
68
+ // used by remapPayloadLines on the TS side.
69
+ const userLines = countLines(code);
70
+ return `return ((function()
71
+ \tlocal __mcp_traceback
72
+ \tlocal __mcp_remap
73
+ \tlocal __mcp_LINE_OFFSET = ${WRAPPER_LINE_OFFSET}
74
+ \tlocal __mcp_USER_LINES = ${userLines}
75
+ \tlocal __mcp_output = {}
76
+ \tlocal __mcp_real_print = print
77
+ \tlocal __mcp_real_warn = warn
78
+ \tlocal print = function(...)
79
+ \t\t__mcp_real_print(...)
80
+ \t\tlocal args = {...}
81
+ \t\tlocal parts = table.create(#args)
82
+ \t\tfor i, a in ipairs(args) do parts[i] = tostring(a) end
83
+ \t\ttable.insert(__mcp_output, table.concat(parts, "\\t"))
84
+ \tend
85
+ \tlocal warn = function(...)
86
+ \t\t__mcp_real_warn(...)
87
+ \t\tlocal args = {...}
88
+ \t\tlocal parts = table.create(#args)
89
+ \t\tfor i, a in ipairs(args) do parts[i] = tostring(a) end
90
+ \t\ttable.insert(__mcp_output, "[warn] " .. table.concat(parts, "\\t"))
91
+ \tend
92
+ \tlocal function __mcp_run()
93
+ ${code}
94
+ \tend
95
+ \t__mcp_remap = function(s)
96
+ \t\t-- Two chunk-name formats can reference our payload:
97
+ \t\t-- * "Workspace.__MCPExecLuauPayload:N" — ModuleScript:require fallback path
98
+ \t\t-- * "[string \\"return ((function()...\\"]:N" — loadstring() (default in plugin)
99
+ \t\t-- Subtract LINE_OFFSET to get the user-relative number, then clamp.
100
+ \t\t-- Clamping matters for unclosed constructs ("local x = (") where the
101
+ \t\t-- parser keeps reading into wrapper postamble and reports a payload
102
+ \t\t-- line past user EOF. Without clamping the message says "user_code:49"
103
+ \t\t-- for one-line input, framing the wrapper as user code.
104
+ \t\tlocal function __mcp_user_line(payload_n)
105
+ \t\t\tlocal user_n = payload_n - __mcp_LINE_OFFSET
106
+ \t\t\tif user_n < 1 then return "1" end
107
+ \t\t\tif user_n > __mcp_USER_LINES then return tostring(__mcp_USER_LINES) .. " (at end of input)" end
108
+ \t\t\treturn tostring(user_n)
109
+ \t\tend
110
+ \t\ts = string.gsub(s, "__MCPExecLuauPayload:(%d+)", function(num)
111
+ \t\t\tlocal n = tonumber(num)
112
+ \t\t\tif n then return "user_code:" .. __mcp_user_line(n) end
113
+ \t\t\treturn "user_code:" .. num
114
+ \t\tend)
115
+ \t\ts = string.gsub(s, '%[string "[^"]+"%]:(%d+)', function(num)
116
+ \t\t\tlocal n = tonumber(num)
117
+ \t\t\tif n then return "user_code:" .. __mcp_user_line(n) end
118
+ \t\t\treturn "user_code:" .. num
119
+ \t\tend)
120
+ \t\treturn s
121
+ \tend
122
+ \t__mcp_traceback = function(err)
123
+ \t\tlocal raw = debug.traceback(tostring(err), 2)
124
+ \t\tlocal kept = {}
125
+ \t\tfor line in string.gmatch(raw, "[^\\n]+") do
126
+ \t\t\t-- Extract referenced line number (either chunk-name format).
127
+ \t\t\tlocal num_str = string.match(line, "__MCPExecLuauPayload:(%d+)")
128
+ \t\t\t\tor string.match(line, '%[string "[^"]+"%]:(%d+)')
129
+ \t\t\tlocal n = num_str and tonumber(num_str)
130
+ \t\t\t-- Strip the "in function '__mcp_run'" annotation before doing
131
+ \t\t\t-- any filtering, because user-code frames carry that suffix —
132
+ \t\t\t-- the entire user payload is hosted inside __mcp_run, so EVERY
133
+ \t\t\t-- user frame would otherwise match a naive "__mcp_" filter and
134
+ \t\t\t-- get dropped. Strip first, then apply filters.
135
+ \t\t\tline = (string.gsub(line, " in function '__mcp_run'", ""))
136
+ \t\t\tlocal skip = string.find(line, "MCPPlugin", 1, true)
137
+ \t\t\t\tor string.find(line, "__mcp_", 1, true)
138
+ \t\t\t\tor string.find(line, "in function 'xpcall'", 1, true)
139
+ \t\t\t-- Frame lines pointing at wrapper preamble/postamble (outside
140
+ \t\t\t-- user range) are wrapper internals — drop them. Lines without
141
+ \t\t\t-- a payload-chunk line number (the traceback header / engine
142
+ \t\t\t-- C frames) are kept; remap is a no-op for them.
143
+ \t\t\tif n and (n <= __mcp_LINE_OFFSET or n > __mcp_LINE_OFFSET + __mcp_USER_LINES) then
144
+ \t\t\t\tskip = true
145
+ \t\t\tend
146
+ \t\t\tif not skip then
147
+ \t\t\t\ttable.insert(kept, __mcp_remap(line))
148
+ \t\t\tend
149
+ \t\tend
150
+ \t\treturn table.concat(kept, "\\n")
151
+ \tend
152
+ \tlocal ok, errOrValue = xpcall(__mcp_run, __mcp_traceback)
153
+ \treturn { ok = ok, value = errOrValue, output = __mcp_output }
154
+ end)())`;
155
+ }
156
+
157
+ // TS-side mirror of the Lua __mcp_remap. Used by runViaModuleScript when
158
+ // pulling the real compile-error diagnostic out of LogService — that error
159
+ // references the payload module's line number directly, and never passes
160
+ // through the IIFE's runtime wrapper.
161
+ function remapPayloadLines(s: string, userLines: number): string {
162
+ // Mirror of the Lua __mcp_remap inside the wrapper, for paths that
163
+ // don't pass through the IIFE (compile errors recovered from
164
+ // LogService, the immediate loadstring compileError surface). Same
165
+ // two-format coverage plus the same clamp: unclosed user constructs
166
+ // let the parser consume wrapper postamble, so the raw payload line
167
+ // is sometimes well past user EOF — clamp to [1, userLines] and
168
+ // annotate so the error doesn't say "user_code:49" for one-line input.
169
+ const userLine = (payload: number): string => {
170
+ const u = payload - WRAPPER_LINE_OFFSET;
171
+ if (u < 1) return "1";
172
+ if (u > userLines) return `${tostring(userLines)} (at end of input)`;
173
+ return tostring(u);
174
+ };
175
+ let out = s;
176
+ const [a] = string.gsub(out, "__MCPExecLuauPayload:(%d+)", (num: string) => {
177
+ const n = tonumber(num);
178
+ if (n !== undefined) return `user_code:${userLine(n)}`;
179
+ return `user_code:${num}`;
180
+ });
181
+ out = a;
182
+ const [b] = string.gsub(out, '%[string "[^"]+"%]:(%d+)', (num: string) => {
183
+ const n = tonumber(num);
184
+ if (n !== undefined) return `user_code:${userLine(n)}`;
185
+ return `user_code:${num}`;
186
+ });
187
+ out = b;
188
+ return out;
189
+ }
190
+
191
+ function runViaModuleScript(wrapped: string, userLines: number): WrapperResult {
192
+ const m = new Instance("ModuleScript");
193
+ m.Name = PAYLOAD_INSTANCE_NAME;
194
+ const [okSet, setErr] = pcall(() => {
195
+ (m as unknown as { Source: string }).Source = wrapped;
196
+ });
197
+ if (!okSet) {
198
+ m.Destroy();
199
+ // error(..., 0) suppresses the "user_MCPPlugin.rbxmx.MCPPlugin.modules.LuauExec:N:"
200
+ // prefix that error() would otherwise prepend, keeping the visible
201
+ // message focused on the user-actionable error rather than our path.
202
+ error(`ModuleScript Source set failed: ${tostring(setErr)}`, 0);
203
+ }
204
+ m.Parent = game.GetService("Workspace");
205
+ const [okReq, reqResult] = pcall(() => require(m));
206
+ m.Destroy();
207
+ if (!okReq) {
208
+ let errMsg = tostring(reqResult);
209
+ // pcall(require, m) collapses parse/compile failures into the canned
210
+ // engine string. The real diagnostic was emitted to LogService on the
211
+ // next engine frame — give it ~50ms to land then scan backward.
212
+ if (errMsg === "Requested module experienced an error while loading") {
213
+ task.wait(0.05);
214
+ const hist = LogService.GetLogHistory();
215
+ for (let i = hist.size() - 1; i >= 0; i--) {
216
+ const e = hist[i];
217
+ if (
218
+ e.messageType === Enum.MessageType.MessageError &&
219
+ string.sub(e.message, 1, PAYLOAD_PATH_PREFIX.size()) === PAYLOAD_PATH_PREFIX
220
+ ) {
221
+ errMsg = e.message;
222
+ break;
223
+ }
224
+ }
225
+ }
226
+ // Compile errors reference the payload module's line number directly
227
+ // — remap + clamp to user-relative line numbers so `local x = 1 +`
228
+ // reports :1: instead of :23:, and reports the clamp annotation
229
+ // when the parser ran off the end of user code into wrapper code.
230
+ error(remapPayloadLines(errMsg, userLines), 0);
231
+ }
232
+ return reqResult as unknown as WrapperResult;
233
+ }
234
+
235
+ function isLoadstringUnavailable(err: unknown): boolean {
236
+ const errStr = tostring(err);
237
+ const [matchStart] = string.find(errStr, "not available", 1, true);
238
+ return matchStart !== undefined;
239
+ }
240
+
241
+ // Returns a string suitable for `returnValue`. Tables get JSON-encoded so
242
+ // the caller sees structured data instead of "table: 0xaddr". Anything that
243
+ // JSONEncode chokes on (cycles, Roblox userdata) falls back to tostring.
244
+ function formatReturnValue(value: unknown): string {
245
+ if (value === undefined) return "";
246
+ if (typeIs(value, "table")) {
247
+ const [ok, encoded] = pcall(() => HttpService.JSONEncode(value));
248
+ if (ok) return encoded as string;
249
+ }
250
+ return tostring(value);
251
+ }
252
+
253
+ function execute(code: string): ExecuteResult {
254
+ if (!code || code === "") {
255
+ return { success: false, error: "code is required" };
256
+ }
257
+ const wrapped = buildWrapper(code);
258
+ const userLines = countLines(code);
259
+
260
+ let [success, result] = pcall(() => {
261
+ const [fn, compileError] = loadstring(wrapped);
262
+ if (!fn) {
263
+ if (isLoadstringUnavailable(compileError)) {
264
+ return runViaModuleScript(wrapped, userLines);
265
+ }
266
+ error(`Compile error: ${remapPayloadLines(tostring(compileError), userLines)}`, 0);
267
+ }
268
+ return fn() as unknown as WrapperResult;
269
+ });
270
+
271
+ // loadstring can throw (not return nil) when ServerScriptService.
272
+ // LoadStringEnabled is false; treat that as a second-chance fallback.
273
+ if (!success && isLoadstringUnavailable(result)) {
274
+ [success, result] = pcall(() => runViaModuleScript(wrapped, userLines));
275
+ }
276
+
277
+ if (!success) {
278
+ return {
279
+ success: false,
280
+ error: tostring(result),
281
+ output: [],
282
+ message: "Code execution failed",
283
+ };
284
+ }
285
+
286
+ const r = result as unknown as WrapperResult;
287
+ const capturedOutput = r.output as unknown as string[] | undefined;
288
+ const output = capturedOutput !== undefined ? capturedOutput : ([] as string[]);
289
+ if (r.ok === true) {
290
+ return {
291
+ success: true,
292
+ returnValue: r.value !== undefined ? formatReturnValue(r.value) : undefined,
293
+ output,
294
+ message: "Code executed successfully",
295
+ };
296
+ }
297
+ return {
298
+ success: false,
299
+ error: r.value !== undefined ? tostring(r.value) : "(unknown error)",
300
+ output,
301
+ message: "Code execution failed",
302
+ };
303
+ }
304
+
305
+ export = { execute };
@@ -0,0 +1,60 @@
1
+ // Detects whether the Studio window is actually rendering, so virtual input
2
+ // and screenshot tools can surface a clear reason instead of silently failing.
3
+ //
4
+ // When a Studio window is MINIMIZED, the engine suspends the render loop AND
5
+ // input processing, but keeps running scripts (Heartbeat keeps firing). That's
6
+ // why simulate_*_input would return success while having zero effect, and
7
+ // CaptureService:CaptureScreenshot would time out. Validated live: during a 3s
8
+ // minimize, RenderStepped's max inter-frame gap was 5.08s while Heartbeat's was
9
+ // 0.10s. So RenderStepped freshness is the reliable "is this window rendering?"
10
+ // signal; Heartbeat is not.
11
+
12
+ import { RunService } from "@rbxts/services";
13
+
14
+ let lastFrame = 0;
15
+ let connected = false;
16
+
17
+ // Above this many seconds since the last rendered frame, we treat the window
18
+ // as not rendering. RenderStepped normally fires every ~16ms; a multi-second
19
+ // gap only happens when minimized/suspended, so 1s cleanly avoids false
20
+ // positives from ordinary frame hitches while still catching the real case.
21
+ const STALE_THRESHOLD = 1.0;
22
+
23
+ export function start(): void {
24
+ if (connected) return;
25
+ // RenderStepped can only be connected from a client/edit render loop; it
26
+ // throws in the play-server DM. pcall so a server-DM call is a safe no-op
27
+ // (connected stays false → notRenderingReason() returns undefined there).
28
+ const [ok] = pcall(() => {
29
+ RunService.RenderStepped.Connect(() => {
30
+ lastFrame = tick();
31
+ });
32
+ });
33
+ if (ok) {
34
+ connected = true;
35
+ lastFrame = tick();
36
+ }
37
+ }
38
+
39
+ export function secondsSinceFrame(): number {
40
+ if (!connected) return 0;
41
+ return tick() - lastFrame;
42
+ }
43
+
44
+ // Returns a human-readable reason if the window appears minimized / not
45
+ // rendering (so input + screenshots won't work), else undefined. Fail-open:
46
+ // when the monitor isn't active in this DM (server peer, or connect failed) it
47
+ // returns undefined so we never block on a false signal.
48
+ export function notRenderingReason(): string | undefined {
49
+ if (!connected) return undefined;
50
+ const gap = secondsSinceFrame();
51
+ if (gap > STALE_THRESHOLD) {
52
+ return string.format(
53
+ "Studio window appears minimized or not rendering (no frame in %.1fs). " +
54
+ "Virtual input and screenshots only work while the window is visible — " +
55
+ "restore/un-minimize the Studio window and retry.",
56
+ gap,
57
+ );
58
+ }
59
+ return undefined;
60
+ }
@@ -1,32 +1,40 @@
1
- // Cross-DM stop_playtest signaling via plugin:SetSetting.
1
+ // Cross-DM stop_playtest signaling via plugin:SetSetting, scoped by
2
+ // per-instance setting key so the same Studio process can host playtests
3
+ // for multiple places without one place's stop_playtest yanking another's.
2
4
  //
3
5
  // `plugin:SetSetting` / `plugin:GetSetting` is a per-plugin persistent store
4
- // that's shared across every DataModel the plugin runs in (edit, play-server,
5
- // play-clients). We use it as a one-bit flag for "please call EndTest in the
6
- // play-server DM":
6
+ // shared across every DataModel the plugin runs in (edit DMs, play-server
7
+ // DMs, play-client DMs). For each connected place we use a dedicated key
8
+ // "MCP_STOP_PLAY_<instanceId>" as a single-bit mailbox:
7
9
  //
8
- // * The edit DM's stopPlaytest handler writes the flag (requestStop).
9
- // * A monitor loop running inside the play-server DM polls the flag at 1Hz
10
- // and calls StudioTestService:EndTest when it flips true, then resets it.
11
- // * The edit DM then waits up to ~2.5s for the flag to be reset, which
12
- // tells us a play-server actually consumed the request (no false-positive
13
- // success when nothing was running).
10
+ // * The edit DM's stopPlaytest handler writes `true` into its own key
11
+ // (computed from its placeId / ServerStorage anon UUID).
12
+ // * Each play-server DM's monitor loop polls the key matching its own
13
+ // instanceId at 0.1Hz; on `true` it clears the key and calls
14
+ // StudioTestService:EndTest. Play-server DMs for other places never
15
+ // touch this key.
16
+ // * The edit DM waits up to ~8s for its key to be cleared, confirming a
17
+ // matching play-server actually consumed the request.
14
18
  //
15
- // Why this is simpler than the previous edit-proxy registration:
16
- // * Doesn't depend on the MCP server tracking peer roles at all.
17
- // * Survives MCP server restarts: monitor loop is local to the play-server
18
- // plugin lifetime, not to any HTTP/registration state.
19
- // * No need for cross-DM LogService.MessageOut reflection (which we verified
20
- // does not work edit -> play-server anyway).
21
- //
22
- // Pattern mirrors the official Roblox Studio MCP
23
- // (Roblox/studio-rust-mcp-server, plugin/src/Utils/GameStopUtil.luau).
19
+ // Earlier versions used a single shared boolean flag, which let any
20
+ // play-server DM in the same Studio process consume any place's stop
21
+ // request silently yanking teammates' playtests. The per-key scoping
22
+ // below is the fix.
23
+
24
+ import { HttpService, ServerStorage } from "@rbxts/services";
24
25
 
25
26
  const StudioTestService = game.GetService("StudioTestService");
26
27
 
27
- const SETTING_KEY = "MCP_STOP_PLAY_SIGNAL";
28
- const POLL_INTERVAL_SEC = 1;
29
- const WAIT_FOR_CONSUMPTION_TIMEOUT_SEC = 2.5;
28
+ const SETTING_KEY_PREFIX = "MCP_STOP_PLAY_";
29
+ // Monitor checks the key at this cadence. 0.1s keeps worst-case detection
30
+ // lag tight so the consumption-confirmation window doesn't have to absorb
31
+ // polling jitter on top of EndTest's teardown time.
32
+ const POLL_INTERVAL_SEC = 0.1;
33
+ // Total time we wait for the matching play-server DM to consume the
34
+ // signal. Must cover: monitor detection (<= POLL_INTERVAL_SEC) +
35
+ // StudioTestService:EndTest teardown (several seconds on heavier places).
36
+ // 8s is comfortable; the tighter poll above keeps real cases well under.
37
+ const WAIT_FOR_CONSUMPTION_TIMEOUT_SEC = 8.0;
30
38
  const WAIT_POLL_SEC = 0.1;
31
39
 
32
40
  let pluginRef: Plugin | undefined;
@@ -35,20 +43,45 @@ function init(p: Plugin): void {
35
43
  pluginRef = p;
36
44
  }
37
45
 
46
+ // Mirror of Communication.computeInstanceId(). Duplicated here because
47
+ // StopPlayMonitor runs in both edit and play-server DMs, and both must
48
+ // agree on the place identifier (published places: placeId; unpublished:
49
+ // UUID on ServerStorage's __MCPPlaceId attribute, travels with the .rbxl
50
+ // into the play DM).
51
+ function computeInstanceId(): string {
52
+ if (game.PlaceId !== 0) {
53
+ return `place:${tostring(game.PlaceId)}`;
54
+ }
55
+ const existing = ServerStorage.GetAttribute("__MCPPlaceId");
56
+ if (typeIs(existing, "string") && existing !== "") {
57
+ return `anon:${existing as string}`;
58
+ }
59
+ const fresh = HttpService.GenerateGUID(false);
60
+ pcall(() => ServerStorage.SetAttribute("__MCPPlaceId", fresh));
61
+ return `anon:${fresh}`;
62
+ }
63
+
64
+ function settingKey(instanceId: string): string {
65
+ return SETTING_KEY_PREFIX + instanceId;
66
+ }
67
+
38
68
  function startMonitor(): void {
39
69
  if (!pluginRef) {
40
70
  warn("[MCP] StopPlayMonitor.startMonitor called before init; skipping");
41
71
  return;
42
72
  }
43
- // Clear any stale value left from a prior session. If a real stop request
44
- // is in-flight when this runs, the requesting edit DM will set it again
45
- // within its 2.5s wait window.
46
- pcall(() => pluginRef!.SetSetting(SETTING_KEY, false));
73
+ const myKey = settingKey(computeInstanceId());
74
+ // Clear any stale value left from a prior session. If a real stop
75
+ // request is in-flight when this runs, the requesting edit DM will
76
+ // write again within its consumption-confirmation window.
77
+ pcall(() => pluginRef!.SetSetting(myKey, false));
47
78
  task.spawn(() => {
48
79
  while (true) {
49
- const [okGet, val] = pcall(() => pluginRef!.GetSetting(SETTING_KEY));
80
+ const [okGet, val] = pcall(() => pluginRef!.GetSetting(myKey));
50
81
  if (okGet && val === true) {
51
- pcall(() => pluginRef!.SetSetting(SETTING_KEY, false));
82
+ // Consume the flag first so requestStop's
83
+ // waitForConsumption returns success, then end the test.
84
+ pcall(() => pluginRef!.SetSetting(myKey, false));
52
85
  pcall(() => StudioTestService.EndTest("stopped_by_mcp"));
53
86
  }
54
87
  task.wait(POLL_INTERVAL_SEC);
@@ -58,15 +91,17 @@ function startMonitor(): void {
58
91
 
59
92
  function requestStop(): boolean {
60
93
  if (!pluginRef) return false;
61
- const [ok] = pcall(() => pluginRef!.SetSetting(SETTING_KEY, true));
94
+ const myKey = settingKey(computeInstanceId());
95
+ const [ok] = pcall(() => pluginRef!.SetSetting(myKey, true));
62
96
  return ok;
63
97
  }
64
98
 
65
99
  function waitForConsumption(): boolean {
66
100
  if (!pluginRef) return false;
101
+ const myKey = settingKey(computeInstanceId());
67
102
  const start = tick();
68
103
  while (tick() - start < WAIT_FOR_CONSUMPTION_TIMEOUT_SEC) {
69
- const [okGet, val] = pcall(() => pluginRef!.GetSetting(SETTING_KEY));
104
+ const [okGet, val] = pcall(() => pluginRef!.GetSetting(myKey));
70
105
  if (okGet && val !== true) return true;
71
106
  task.wait(WAIT_POLL_SEC);
72
107
  }
@@ -75,7 +110,8 @@ function waitForConsumption(): boolean {
75
110
 
76
111
  function clearPending(): void {
77
112
  if (!pluginRef) return;
78
- pcall(() => pluginRef!.SetSetting(SETTING_KEY, false));
113
+ const myKey = settingKey(computeInstanceId());
114
+ pcall(() => pluginRef!.SetSetting(myKey, false));
79
115
  }
80
116
 
81
117
  export = {
@@ -1,3 +1,5 @@
1
+ import * as RenderMonitor from "../RenderMonitor";
2
+
1
3
  const CaptureService = game.GetService("CaptureService");
2
4
  const AssetService = game.GetService("AssetService");
3
5
 
@@ -71,7 +73,17 @@ function readPixelsTiled(img: EditableImage, w: number, h: number): buffer {
71
73
  return fullBuf;
72
74
  }
73
75
 
74
- function captureScreenshotData(): unknown {
76
+ // Triggers CaptureService:CaptureScreenshot and waits for the temporary
77
+ // content id. Works in any DM, including the play CLIENT (where reading the
78
+ // pixels back is blocked, but capturing is not). The returned rbxtemp:// id is
79
+ // a process-scoped handle: it can be dereferenced from a DIFFERENT, more
80
+ // privileged DM (the edit DM) — see captureRead.
81
+ function doCaptureScreenshot(): { contentId: string } | { error: string } {
82
+ // Fast-fail with a clear reason if the window isn't rendering — otherwise
83
+ // CaptureScreenshot's callback never fires and we'd block for the full 10s.
84
+ const notRendering = RenderMonitor.notRenderingReason();
85
+ if (notRendering !== undefined) return { error: notRendering };
86
+
75
87
  let contentId: string | undefined;
76
88
 
77
89
  CaptureService.CaptureScreenshot((id: string) => {
@@ -82,14 +94,23 @@ function captureScreenshotData(): unknown {
82
94
  while (contentId === undefined) {
83
95
  if (tick() - startTime > 10) {
84
96
  return {
85
- error: "Screenshot capture timed out. Ensure the Studio viewport is visible and you are in Edit mode (not Play mode). Known Roblox bug: capture may fail if viewport renders a solid color.",
97
+ 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.)",
86
98
  };
87
99
  }
88
100
  task.wait(0.1);
89
101
  }
90
102
 
103
+ return { contentId };
104
+ }
105
+
106
+ // Promotes a CaptureScreenshot content id into an EditableImage and reads its
107
+ // RGBA pixels. MUST run in the edit/plugin context: the running game VM lacks
108
+ // the privilege to create an EditableImage from a temporary texture id (errors
109
+ // "cannot currently create editable image from temporary texture id"), while
110
+ // the edit DM can — even for an id captured in the play client DM.
111
+ function readContentToBase64(contentId: string): unknown {
91
112
  const [editableOk, editableResult] = pcall(() => {
92
- return AssetService.CreateEditableImageAsync(Content.fromUri(contentId!));
113
+ return AssetService.CreateEditableImageAsync(Content.fromUri(contentId));
93
114
  });
94
115
 
95
116
  if (!editableOk) {
@@ -118,11 +139,32 @@ function captureScreenshotData(): unknown {
118
139
  return { success: true, width: w, height: h, data: base64Data };
119
140
  }
120
141
 
142
+ // Edit-mode single shot: capture and read back in the same (edit) context.
143
+ function captureScreenshotData(): unknown {
144
+ const cap = doCaptureScreenshot();
145
+ if ("error" in cap) return cap;
146
+ return readContentToBase64(cap.contentId);
147
+ }
148
+
121
149
  function captureScreenshot(): unknown {
122
150
  return captureScreenshotData();
123
151
  }
124
152
 
153
+ // Play-mode step 1 (run on the CLIENT): capture only, return the temp id.
154
+ function captureBegin(): unknown {
155
+ return doCaptureScreenshot();
156
+ }
157
+
158
+ // Play-mode step 2 (run on EDIT): read pixels from a temp id captured elsewhere.
159
+ function captureRead(requestData: Record<string, unknown>): unknown {
160
+ const contentId = requestData.contentId as string | undefined;
161
+ if (!contentId) return { error: "contentId is required" };
162
+ return readContentToBase64(contentId);
163
+ }
164
+
125
165
  export = {
126
166
  captureScreenshotData,
127
167
  captureScreenshot,
168
+ captureBegin,
169
+ captureRead,
128
170
  };