@princeofscale/bloxforge 2.20.2

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.
Files changed (49) hide show
  1. package/assets/Baseplate.rbxl +0 -0
  2. package/dist/index.js +19391 -0
  3. package/package.json +62 -0
  4. package/studio-plugin/INSTALLATION.md +161 -0
  5. package/studio-plugin/MCPInspectorPlugin.rbxmx +171699 -0
  6. package/studio-plugin/MCPPlugin.rbxmx +171699 -0
  7. package/studio-plugin/default.project.json +19 -0
  8. package/studio-plugin/dev.project.json +23 -0
  9. package/studio-plugin/include/LibMP.lua +156378 -0
  10. package/studio-plugin/inspector-icon.png +0 -0
  11. package/studio-plugin/package-lock.json +692 -0
  12. package/studio-plugin/package.json +19 -0
  13. package/studio-plugin/plugin.json +10 -0
  14. package/studio-plugin/src/modules/ClientBroker.ts +447 -0
  15. package/studio-plugin/src/modules/Communication.ts +697 -0
  16. package/studio-plugin/src/modules/EvalBridges.ts +255 -0
  17. package/studio-plugin/src/modules/HttpDiagnostics.ts +50 -0
  18. package/studio-plugin/src/modules/JobRegistry.ts +77 -0
  19. package/studio-plugin/src/modules/LuauExec.ts +465 -0
  20. package/studio-plugin/src/modules/Recording.ts +28 -0
  21. package/studio-plugin/src/modules/RenderMonitor.ts +60 -0
  22. package/studio-plugin/src/modules/RuntimeLogBuffer.ts +152 -0
  23. package/studio-plugin/src/modules/ServerUrlSettings.ts +114 -0
  24. package/studio-plugin/src/modules/State.ts +101 -0
  25. package/studio-plugin/src/modules/StopPlayMonitor.ts +276 -0
  26. package/studio-plugin/src/modules/UI.ts +790 -0
  27. package/studio-plugin/src/modules/Utils.ts +318 -0
  28. package/studio-plugin/src/modules/handlers/AssetHandlers.ts +241 -0
  29. package/studio-plugin/src/modules/handlers/BreakpointHandlers.ts +460 -0
  30. package/studio-plugin/src/modules/handlers/BuildHandlers.ts +481 -0
  31. package/studio-plugin/src/modules/handlers/CaptureHandlers.ts +203 -0
  32. package/studio-plugin/src/modules/handlers/EvalRuntimeHandlers.ts +149 -0
  33. package/studio-plugin/src/modules/handlers/InputHandlers.ts +160 -0
  34. package/studio-plugin/src/modules/handlers/InstanceHandlers.ts +380 -0
  35. package/studio-plugin/src/modules/handlers/JobHandlers.ts +111 -0
  36. package/studio-plugin/src/modules/handlers/LogHandlers.ts +14 -0
  37. package/studio-plugin/src/modules/handlers/MemoryHandlers.ts +44 -0
  38. package/studio-plugin/src/modules/handlers/MetadataHandlers.ts +354 -0
  39. package/studio-plugin/src/modules/handlers/MicroProfilerHandlers.ts +1263 -0
  40. package/studio-plugin/src/modules/handlers/PropertyHandlers.ts +191 -0
  41. package/studio-plugin/src/modules/handlers/QueryHandlers.ts +809 -0
  42. package/studio-plugin/src/modules/handlers/SceneAnalysisHandlers.ts +216 -0
  43. package/studio-plugin/src/modules/handlers/ScriptHandlers.ts +530 -0
  44. package/studio-plugin/src/modules/handlers/ScriptProfilerHandlers.ts +386 -0
  45. package/studio-plugin/src/modules/handlers/SerializationHandlers.ts +172 -0
  46. package/studio-plugin/src/modules/handlers/TestHandlers.ts +535 -0
  47. package/studio-plugin/src/server/index.server.ts +134 -0
  48. package/studio-plugin/src/types/index.d.ts +83 -0
  49. package/studio-plugin/tsconfig.json +21 -0
@@ -0,0 +1,465 @@
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, wraps require() so nested
7
+ // ModuleScript load failures can recover the real LogService diagnostic,
8
+ // also exposes fresh_require() (clone->require->destroy) to bypass the
9
+ // engine's per-instance require() cache after a Source edit, runs user
10
+ // code in xpcall, and always returns { ok, value, output } so the
11
+ // ModuleScript itself always returns exactly one value (otherwise
12
+ // `print("hi")` with no return would fail with "Module code did not
13
+ // return exactly one value").
14
+ //
15
+ // 2. The loadstring-then-ModuleScript-require fallback, with the parse-error
16
+ // recovery hack that pulls the real diagnostic from LogService.
17
+ //
18
+ // 3. Return-value formatting: tables get HttpService:JSONEncode'd so the
19
+ // caller sees `{"x":1,"y":2}` instead of `table: 0xaddr`; primitives
20
+ // pass through tostring. The encode is pcall'd so cycles or
21
+ // non-serializable values gracefully fall back to tostring.
22
+ //
23
+ // Before this module existed, the client peer used a stripped-down
24
+ // require-only execution path that lacked both the wrapper and the JSON
25
+ // formatting, producing two well-known papercuts:
26
+ // - `print("hi")` (no return) failed with "Module code did not return..."
27
+ // - Returning a table yielded `table: 0xaddr` instead of structured data.
28
+
29
+ const HttpService = game.GetService("HttpService");
30
+ const LogService = game.GetService("LogService");
31
+
32
+ interface WrapperResult {
33
+ ok?: boolean;
34
+ value?: unknown;
35
+ output?: defined;
36
+ }
37
+
38
+ interface ExecuteResult {
39
+ success: boolean;
40
+ returnValue?: string;
41
+ output?: string[];
42
+ error?: string;
43
+ message?: string;
44
+ }
45
+
46
+ const PAYLOAD_INSTANCE_NAME = "__MCPExecLuauPayload";
47
+ const REQUIRE_GENERIC_ERROR = "Requested module experienced an error while loading";
48
+
49
+ // Number of lines the wrapper emits BEFORE the first line of user code.
50
+ // Used both inside the wrapper (Luau __mcp_LINE_OFFSET) and on the TS side
51
+ // (remapPayloadLines, for compile errors recovered from LogService) so user
52
+ // code errors report user-relative line numbers instead of the inflated
53
+ // "line 49" the wrapper would otherwise expose.
54
+ //
55
+ // Computed dynamically from the actual wrapper prefix (see computeWrapperLine
56
+ // Offset) so reordering buildWrapper's preamble can never silently desync this
57
+ // constant from the real line count. The probe renders the wrapper with a
58
+ // sentinel on the code line and counts newlines before it; the offset value
59
+ // itself always occupies exactly one line, so a dummy 0 yields the right count.
60
+ const CODE_SENTINEL = "__MCP_USER_CODE_LINE__";
61
+ function computeWrapperLineOffset(): number {
62
+ const probe = renderWrapper(0, 0, CODE_SENTINEL, luaPatternEscape(PAYLOAD_INSTANCE_NAME));
63
+ const [start] = string.find(probe, CODE_SENTINEL, 1, true);
64
+ if (start === undefined) {
65
+ // Should be unreachable — the sentinel is emitted by renderWrapper.
66
+ error("MCP internal error: wrapper code sentinel not found while computing line offset", 0);
67
+ }
68
+ const before = string.sub(probe, 1, (start as number) - 1);
69
+ return countLines(before) - 1;
70
+ }
71
+ const WRAPPER_LINE_OFFSET = computeWrapperLineOffset();
72
+
73
+ // Count source lines so the wrapper can filter traceback frames that fall
74
+ // outside the user code range (the wrapper's own preamble/postamble lines).
75
+ function countLines(s: string): number {
76
+ let n = 1;
77
+ const size = s.size();
78
+ for (let i = 1; i <= size; i++) {
79
+ if (string.sub(s, i, i) === "\n") n++;
80
+ }
81
+ return n;
82
+ }
83
+
84
+ function luaPatternEscape(s: string): string {
85
+ const [escaped] = string.gsub(s, "([^%w])", "%%%1");
86
+ return escaped;
87
+ }
88
+
89
+ // Pure string renderer shared by buildWrapper (real offset/userLines) and
90
+ // computeWrapperLineOffset (probes with a sentinel to measure the prefix).
91
+ // Keeping the template in one place means the offset can be DERIVED from it
92
+ // instead of hand-maintained, so reordering preamble lines can never desync
93
+ // __mcp_LINE_OFFSET / remapPayloadLines from the real line count.
94
+ function renderWrapper(
95
+ lineOffset: number,
96
+ userLines: number,
97
+ code: string,
98
+ payloadPattern: string,
99
+ ): string {
100
+ return `return ((function()
101
+ \tlocal __mcp_traceback
102
+ \tlocal __mcp_remap
103
+ \tlocal __mcp_LINE_OFFSET = ${lineOffset}
104
+ \tlocal __mcp_USER_LINES = ${userLines}
105
+ \tlocal __mcp_LogService = game:GetService("LogService")
106
+ \tlocal __mcp_REQUIRE_GENERIC = "${REQUIRE_GENERIC_ERROR}"
107
+ \tlocal __mcp_output = {}
108
+ \tlocal __mcp_real_print = print
109
+ \tlocal __mcp_real_warn = warn
110
+ \tlocal __mcp_real_require = require
111
+ \tlocal function __mcp_string(value)
112
+ \t\tlocal s = tostring(value)
113
+ \t\tlocal valid = utf8.len(s)
114
+ \t\tif valid == false then return "<invalid UTF-8 omitted; avoid byte-slicing Unicode strings>" end
115
+ \t\treturn s
116
+ \tend
117
+ \tlocal print = function(...)
118
+ \t\t__mcp_real_print(...)
119
+ \t\tlocal args = {...}
120
+ \t\tlocal parts = table.create(#args)
121
+ \t\tfor i, a in ipairs(args) do parts[i] = __mcp_string(a) end
122
+ \t\ttable.insert(__mcp_output, table.concat(parts, "\\t"))
123
+ \tend
124
+ \tlocal warn = function(...)
125
+ \t\t__mcp_real_warn(...)
126
+ \t\tlocal args = {...}
127
+ \t\tlocal parts = table.create(#args)
128
+ \t\tfor i, a in ipairs(args) do parts[i] = __mcp_string(a) end
129
+ \t\ttable.insert(__mcp_output, "[warn] " .. table.concat(parts, "\\t"))
130
+ \tend
131
+ \tlocal function __mcp_is_stack_noise(msg)
132
+ \t\treturn msg == "Stack Begin" or msg == "Stack End" or string.sub(msg, 1, 8) == "Script '"
133
+ \tend
134
+ \tlocal function __mcp_is_actionable_require_log(entry)
135
+ \t\tif not entry or entry.messageType ~= Enum.MessageType.MessageError then return false end
136
+ \t\tlocal msg = tostring(entry.message)
137
+ \t\treturn msg ~= __mcp_REQUIRE_GENERIC and not __mcp_is_stack_noise(msg)
138
+ \tend
139
+ \tlocal function __mcp_entry_mentions_module(entry, module_path)
140
+ \t\tif not entry or not module_path or module_path == "" then return false end
141
+ \t\treturn string.find(tostring(entry.message), module_path, 1, true) ~= nil
142
+ \tend
143
+ \tlocal function __mcp_prior_module_error(hist, module_path)
144
+ \t\tif not module_path or module_path == "" then return nil end
145
+ \t\tfor i = #hist, 1, -1 do
146
+ \t\t\tlocal entry = hist[i]
147
+ \t\t\tif __mcp_entry_mentions_module(entry, module_path) then
148
+ \t\t\t\tif __mcp_is_actionable_require_log(entry) then
149
+ \t\t\t\t\treturn tostring(entry.message)
150
+ \t\t\t\tend
151
+ \t\t\t\tfor j = i - 1, math.max(1, i - 6), -1 do
152
+ \t\t\t\t\tlocal previous = hist[j]
153
+ \t\t\t\t\tif __mcp_is_actionable_require_log(previous) then
154
+ \t\t\t\t\t\treturn tostring(previous.message)
155
+ \t\t\t\t\tend
156
+ \t\t\t\tend
157
+ \t\t\tend
158
+ \t\tend
159
+ \t\treturn nil
160
+ \tend
161
+ \tlocal function __mcp_recover_require_error(err, history_start, module)
162
+ \t\tlocal err_msg = tostring(err)
163
+ \t\tif err_msg ~= __mcp_REQUIRE_GENERIC then return err_msg end
164
+ \t\tlocal module_path
165
+ \t\tif typeof(module) == "Instance" then
166
+ \t\t\tlocal ok_path, path = pcall(function()
167
+ \t\t\t\treturn module:GetFullName()
168
+ \t\t\tend)
169
+ \t\t\tif ok_path then module_path = path end
170
+ \t\tend
171
+ \t\ttask.wait(0.05)
172
+ \t\tlocal hist = __mcp_LogService:GetLogHistory()
173
+ \t\tfor i = #hist, history_start + 1, -1 do
174
+ \t\t\tlocal entry = hist[i]
175
+ \t\t\tif __mcp_is_actionable_require_log(entry) then
176
+ \t\t\t\treturn tostring(entry.message)
177
+ \t\t\tend
178
+ \t\tend
179
+ \t\tlocal prior = __mcp_prior_module_error(hist, module_path)
180
+ \t\tif prior then return prior end
181
+ \t\treturn err_msg
182
+ \tend
183
+ \tlocal function require(module)
184
+ \t\tlocal history_start = #__mcp_LogService:GetLogHistory()
185
+ \t\tlocal ok, value = pcall(__mcp_real_require, module)
186
+ \t\tif ok then return value end
187
+ \t\terror(__mcp_recover_require_error(value, history_start, module), 0)
188
+ \tend
189
+ \t-- fresh_require(module): require a FRESH copy, bypassing the engine's
190
+ \t-- per-instance require() cache. After editing a ModuleScript's Source
191
+ \t-- (edit_script_lines / set_script_source), a plain require() of that same
192
+ \t-- instance returns the STALE cached copy, so the edit looks like it "didn't
193
+ \t-- apply". fresh_require clones the module under Workspace (a new instance
194
+ \t-- identity = no cache hit), requires the clone, then Destroy()s it. Exposed
195
+ \t-- via _G so it is reachable from any nesting depth in user code.
196
+ \t-- CAVEATS: the returned table is a DIFFERENT instance than the cached one
197
+ \t-- (identity checks like require(M) == require(M) will not hold); nested
198
+ \t-- require() inside the module still hits the live cache. Use it to VERIFY
199
+ \t-- freshly edited code, not as a drop-in replacement for require().
200
+ \tlocal function fresh_require(module)
201
+ \t\tlocal clone = module:Clone()
202
+ \t\tclone.Name = "__MCP_fresh_require_clone"
203
+ \t\tclone.Parent = game:GetService("Workspace")
204
+ \t\tlocal history_start = #__mcp_LogService:GetLogHistory()
205
+ \t\tlocal ok, value = pcall(__mcp_real_require, clone)
206
+ \t\tclone:Destroy()
207
+ \t\tif ok then return value end
208
+ \t\terror(__mcp_recover_require_error(value, history_start, clone), 0)
209
+ \tend
210
+ \t_G.fresh_require = fresh_require
211
+ \tlocal function __mcp_run()
212
+ ${code}
213
+ \tend
214
+ \t__mcp_remap = function(s)
215
+ \t\t-- Two chunk-name formats can reference our payload:
216
+ \t\t-- * "Workspace.__MCPExecLuauPayload:N" — ModuleScript:require fallback path
217
+ \t\t-- * "[string \\"return ((function()...\\"]:N" — loadstring() (default in plugin)
218
+ \t\t-- Subtract LINE_OFFSET to get the user-relative number, then clamp.
219
+ \t\t-- Clamping matters for unclosed constructs ("local x = (") where the
220
+ \t\t-- parser keeps reading into wrapper postamble and reports a payload
221
+ \t\t-- line past user EOF. Without clamping, that frames wrapper postamble
222
+ \t\t-- as user code.
223
+ \t\tlocal function __mcp_user_line(payload_n)
224
+ \t\t\tlocal user_n = payload_n - __mcp_LINE_OFFSET
225
+ \t\t\tif user_n < 1 then return "1" end
226
+ \t\t\tif user_n > __mcp_USER_LINES then return tostring(__mcp_USER_LINES) .. " (at end of input)" end
227
+ \t\t\treturn tostring(user_n)
228
+ \t\tend
229
+ \t\ts = string.gsub(s, "Workspace%.${payloadPattern}:(%d+)", function(num)
230
+ \t\t\tlocal n = tonumber(num)
231
+ \t\t\tif n then return "user_code:" .. __mcp_user_line(n) end
232
+ \t\t\treturn "user_code:" .. num
233
+ \t\tend)
234
+ \t\ts = string.gsub(s, "${payloadPattern}:(%d+)", function(num)
235
+ \t\t\tlocal n = tonumber(num)
236
+ \t\t\tif n then return "user_code:" .. __mcp_user_line(n) end
237
+ \t\t\treturn "user_code:" .. num
238
+ \t\tend)
239
+ \t\ts = string.gsub(s, '%[string "[^"]+"%]:(%d+)', function(num)
240
+ \t\t\tlocal n = tonumber(num)
241
+ \t\t\tif n then return "user_code:" .. __mcp_user_line(n) end
242
+ \t\t\treturn "user_code:" .. num
243
+ \t\tend)
244
+ \t\treturn s
245
+ \tend
246
+ \t__mcp_traceback = function(err)
247
+ \t\tlocal raw = debug.traceback(tostring(err), 2)
248
+ \t\tlocal kept = {}
249
+ \t\tfor line in string.gmatch(raw, "[^\\n]+") do
250
+ \t\t\t-- Extract referenced line number (either chunk-name format).
251
+ \t\t\tlocal num_str = string.match(line, "__MCPExecLuauPayload:(%d+)")
252
+ \t\t\t\tor string.match(line, '%[string "[^"]+"%]:(%d+)')
253
+ \t\t\tlocal n = num_str and tonumber(num_str)
254
+ \t\t\t-- Strip the "in function '__mcp_run'" annotation before doing
255
+ \t\t\t-- any filtering, because user-code frames carry that suffix —
256
+ \t\t\t-- the entire user payload is hosted inside __mcp_run, so EVERY
257
+ \t\t\t-- user frame would otherwise match a naive "__mcp_" filter and
258
+ \t\t\t-- get dropped. Strip first, then apply filters.
259
+ \t\t\tline = (string.gsub(line, " in function '__mcp_run'", ""))
260
+ \t\t\tlocal skip = string.find(line, "MCPPlugin", 1, true)
261
+ \t\t\t\tor string.find(line, "__mcp_", 1, true)
262
+ \t\t\t\tor string.find(line, "in function 'xpcall'", 1, true)
263
+ \t\t\t-- Frame lines pointing at wrapper preamble/postamble (outside
264
+ \t\t\t-- user range) are wrapper internals — drop them. Lines without
265
+ \t\t\t-- a payload-chunk line number (the traceback header / engine
266
+ \t\t\t-- C frames) are kept; remap is a no-op for them.
267
+ \t\t\tif n and (n <= __mcp_LINE_OFFSET or n > __mcp_LINE_OFFSET + __mcp_USER_LINES) then
268
+ \t\t\t\tskip = true
269
+ \t\t\tend
270
+ \t\t\tif not skip then
271
+ \t\t\t\ttable.insert(kept, __mcp_remap(line))
272
+ \t\t\tend
273
+ \t\tend
274
+ \t\treturn table.concat(kept, "\\n")
275
+ \tend
276
+ \tlocal ok, errOrValue = xpcall(__mcp_run, __mcp_traceback)
277
+ \treturn { ok = ok, value = errOrValue, output = __mcp_output }
278
+ end)()`;
279
+ }
280
+
281
+ function buildWrapper(code: string, payloadInstanceName = PAYLOAD_INSTANCE_NAME): string {
282
+ // The line offset is DERIVED from the rendered template (see
283
+ // computeWrapperLineOffset) instead of hand-maintained, so reordering the
284
+ // preamble can never silently desync __mcp_LINE_OFFSET / remapPayloadLines
285
+ // from the real prefix line count.
286
+ const userLines = countLines(code);
287
+ const payloadPattern = luaPatternEscape(payloadInstanceName);
288
+ return renderWrapper(WRAPPER_LINE_OFFSET, userLines, code, payloadPattern);
289
+ }
290
+
291
+ // TS-side mirror of the Lua __mcp_remap. Used by runViaModuleScript when
292
+ // pulling the real compile-error diagnostic out of LogService — that error
293
+ // references the payload module's line number directly, and never passes
294
+ // through the IIFE's runtime wrapper.
295
+ function remapPayloadLines(s: string, userLines: number, payloadInstanceName = PAYLOAD_INSTANCE_NAME): string {
296
+ // Mirror of the Lua __mcp_remap inside the wrapper, for paths that
297
+ // don't pass through the IIFE (compile errors recovered from
298
+ // LogService, the immediate loadstring compileError surface). Same
299
+ // two-format coverage plus the same clamp: unclosed user constructs
300
+ // let the parser consume wrapper postamble, so the raw payload line
301
+ // is sometimes well past user EOF — clamp to [1, userLines] and
302
+ // annotate so the error doesn't say "user_code:49" for one-line input.
303
+ const userLine = (payload: number): string => {
304
+ const u = payload - WRAPPER_LINE_OFFSET;
305
+ if (u < 1) return "1";
306
+ if (u > userLines) return `${tostring(userLines)} (at end of input)`;
307
+ return tostring(u);
308
+ };
309
+ const payloadPattern = luaPatternEscape(payloadInstanceName);
310
+ let out = s;
311
+ const [a] = string.gsub(out, `Workspace%.${payloadPattern}:(%d+)`, (num: string) => {
312
+ const n = tonumber(num);
313
+ if (n !== undefined) return `user_code:${userLine(n)}`;
314
+ return `user_code:${num}`;
315
+ });
316
+ out = a;
317
+ const [b] = string.gsub(out, `${payloadPattern}:(%d+)`, (num: string) => {
318
+ const n = tonumber(num);
319
+ if (n !== undefined) return `user_code:${userLine(n)}`;
320
+ return `user_code:${num}`;
321
+ });
322
+ out = b;
323
+ const [c] = string.gsub(out, '%[string "[^"]+"%]:(%d+)', (num: string) => {
324
+ const n = tonumber(num);
325
+ if (n !== undefined) return `user_code:${userLine(n)}`;
326
+ return `user_code:${num}`;
327
+ });
328
+ return c;
329
+ }
330
+
331
+ function runViaModuleScript(wrapped: string, userLines: number): WrapperResult {
332
+ const m = new Instance("ModuleScript");
333
+ m.Name = PAYLOAD_INSTANCE_NAME;
334
+ const [okSet, setErr] = pcall(() => {
335
+ (m as unknown as { Source: string }).Source = wrapped;
336
+ });
337
+ if (!okSet) {
338
+ m.Destroy();
339
+ // error(..., 0) suppresses the "user_MCPPlugin.rbxmx.MCPPlugin.modules.LuauExec:N:"
340
+ // prefix that error() would otherwise prepend, keeping the visible
341
+ // message focused on the user-actionable error rather than our path.
342
+ error(`ModuleScript Source set failed: ${tostring(setErr)}`, 0);
343
+ }
344
+ m.Parent = game.GetService("Workspace");
345
+ const [okReq, reqResult] = pcall(() => require(m));
346
+ m.Destroy();
347
+ if (!okReq) {
348
+ // Compile errors reference the payload module's line number directly
349
+ // — remap + clamp to user-relative line numbers so `local x = 1 +`
350
+ // reports :1: instead of :23:, and reports the clamp annotation
351
+ // when the parser ran off the end of user code into wrapper code.
352
+ error(recoverPayloadRequireError(reqResult, userLines, PAYLOAD_INSTANCE_NAME), 0);
353
+ }
354
+ return reqResult as unknown as WrapperResult;
355
+ }
356
+
357
+ function isLoadstringUnavailable(err: unknown): boolean {
358
+ const errStr = tostring(err);
359
+ const [matchStart] = string.find(errStr, "not available", 1, true);
360
+ return matchStart !== undefined;
361
+ }
362
+
363
+ // Returns a string suitable for `returnValue`. Tables get JSON-encoded so
364
+ // the caller sees structured data instead of "table: 0xaddr". Anything that
365
+ // JSONEncode chokes on (cycles, Roblox userdata) falls back to tostring.
366
+ function formatReturnValue(value: unknown): string {
367
+ if (value === undefined) return "";
368
+ if (typeIs(value, "table")) {
369
+ const [ok, encoded] = pcall(() => HttpService.JSONEncode(value));
370
+ if (ok) return encoded as string;
371
+ }
372
+ const text = tostring(value);
373
+ const [valid] = utf8.len(text);
374
+ return valid === false ? "<invalid UTF-8 omitted; avoid byte-slicing Unicode strings>" : text;
375
+ }
376
+
377
+ function recoverPayloadRequireError(
378
+ err: unknown,
379
+ userLines: number,
380
+ payloadInstanceName = PAYLOAD_INSTANCE_NAME,
381
+ historyStart = 0,
382
+ ): string {
383
+ let errMsg = tostring(err);
384
+ // pcall(require, m) collapses parse/compile failures into the canned
385
+ // engine string. The real diagnostic is emitted to LogService on the
386
+ // next engine frame — give it ~50ms to land then scan backward.
387
+ if (errMsg === REQUIRE_GENERIC_ERROR) {
388
+ task.wait(0.05);
389
+ const payloadPathPrefix = `Workspace.${payloadInstanceName}:`;
390
+ const hist = LogService.GetLogHistory();
391
+ const start = math.max(0, historyStart);
392
+ for (let i = hist.size() - 1; i >= start; i--) {
393
+ const e = hist[i];
394
+ if (
395
+ e.messageType === Enum.MessageType.MessageError &&
396
+ string.sub(e.message, 1, payloadPathPrefix.size()) === payloadPathPrefix
397
+ ) {
398
+ errMsg = e.message;
399
+ break;
400
+ }
401
+ }
402
+ }
403
+ return remapPayloadLines(errMsg, userLines, payloadInstanceName);
404
+ }
405
+
406
+ function execute(code: string): ExecuteResult {
407
+ if (!code || code === "") {
408
+ return { success: false, error: "code is required" };
409
+ }
410
+ const wrapped = buildWrapper(code);
411
+ const userLines = countLines(code);
412
+
413
+ let [success, result] = pcall(() => {
414
+ const [fn, compileError] = loadstring(wrapped);
415
+ if (!fn) {
416
+ if (isLoadstringUnavailable(compileError)) {
417
+ return runViaModuleScript(wrapped, userLines);
418
+ }
419
+ error(`Compile error: ${remapPayloadLines(tostring(compileError), userLines)}`, 0);
420
+ }
421
+ return fn() as unknown as WrapperResult;
422
+ });
423
+
424
+ // loadstring can throw (not return nil) when ServerScriptService.
425
+ // LoadStringEnabled is false; treat that as a second-chance fallback.
426
+ if (!success && isLoadstringUnavailable(result)) {
427
+ [success, result] = pcall(() => runViaModuleScript(wrapped, userLines));
428
+ }
429
+
430
+ if (!success) {
431
+ return {
432
+ success: false,
433
+ error: tostring(result),
434
+ output: [],
435
+ message: "Code execution failed",
436
+ };
437
+ }
438
+
439
+ const r = result as unknown as WrapperResult;
440
+ const capturedOutput = r.output as unknown as string[] | undefined;
441
+ const output = capturedOutput !== undefined ? capturedOutput : ([] as string[]);
442
+ if (r.ok === true) {
443
+ return {
444
+ success: true,
445
+ returnValue: r.value !== undefined ? formatReturnValue(r.value) : undefined,
446
+ output,
447
+ message: "Code executed successfully",
448
+ };
449
+ }
450
+ return {
451
+ success: false,
452
+ error: r.value !== undefined ? tostring(r.value) : "(unknown error)",
453
+ output,
454
+ message: "Code execution failed",
455
+ };
456
+ }
457
+
458
+ export = {
459
+ buildWrapper,
460
+ countLines,
461
+ execute,
462
+ formatReturnValue,
463
+ recoverPayloadRequireError,
464
+ remapPayloadLines,
465
+ };
@@ -0,0 +1,28 @@
1
+ const ChangeHistoryService = game.GetService("ChangeHistoryService");
2
+
3
+ type RecordingId = string | undefined;
4
+
5
+ function beginRecording(actionName: string): RecordingId {
6
+ const [success, result] = pcall(() => ChangeHistoryService.TryBeginRecording(`MCP: ${actionName}`));
7
+ if (success) {
8
+ return result as RecordingId;
9
+ }
10
+ return undefined;
11
+ }
12
+
13
+ function finishRecording(recordingId: RecordingId, shouldCommit: boolean) {
14
+ if (recordingId === undefined) return;
15
+
16
+ const operation = shouldCommit
17
+ ? Enum.FinishRecordingOperation.Commit
18
+ : Enum.FinishRecordingOperation.Cancel;
19
+
20
+ pcall(() => {
21
+ ChangeHistoryService.FinishRecording(recordingId, operation);
22
+ });
23
+ }
24
+
25
+ export = {
26
+ beginRecording,
27
+ finishRecording,
28
+ };
@@ -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
+ }