@chrrxs/robloxstudio-mcp 2.16.4 → 2.17.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +234 -151
- package/package.json +1 -1
- package/studio-plugin/MCPInspectorPlugin.rbxmx +1456 -416
- package/studio-plugin/MCPPlugin.rbxmx +1456 -416
- package/studio-plugin/src/modules/ClientBroker.ts +10 -0
- package/studio-plugin/src/modules/Communication.ts +11 -7
- package/studio-plugin/src/modules/RuntimeLogBuffer.ts +36 -10
- package/studio-plugin/src/modules/ServerUrlSettings.ts +62 -9
- package/studio-plugin/src/modules/UI.ts +11 -4
- package/studio-plugin/src/modules/Utils.ts +147 -13
- package/studio-plugin/src/modules/handlers/BreakpointHandlers.ts +460 -0
- package/studio-plugin/src/modules/handlers/QueryHandlers.ts +0 -33
- package/studio-plugin/src/modules/handlers/ScriptHandlers.ts +3 -0
- package/studio-plugin/src/modules/handlers/ScriptProfilerHandlers.ts +386 -0
- package/studio-plugin/src/modules/handlers/TestHandlers.ts +3 -209
- package/studio-plugin/src/server/index.server.ts +20 -5
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
const HttpService = game.GetService("HttpService");
|
|
2
|
+
const Players = game.GetService("Players");
|
|
3
|
+
const RunService = game.GetService("RunService");
|
|
4
|
+
|
|
5
|
+
interface ScriptProfilerServiceLike extends Instance {
|
|
6
|
+
ServerStart(this: ScriptProfilerServiceLike, frequency?: number): void;
|
|
7
|
+
ServerStop(this: ScriptProfilerServiceLike): void;
|
|
8
|
+
ServerRequestData(this: ScriptProfilerServiceLike): void;
|
|
9
|
+
ClientStart(this: ScriptProfilerServiceLike, player: Player, frequency?: number): void;
|
|
10
|
+
ClientStop(this: ScriptProfilerServiceLike, player: Player): void;
|
|
11
|
+
ClientRequestData(this: ScriptProfilerServiceLike, player: Player): void;
|
|
12
|
+
OnNewData: RBXScriptSignal<(player: Player | undefined, jsonString: string) => void>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface CategoryInfo {
|
|
16
|
+
Name?: string;
|
|
17
|
+
NodeId?: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface FunctionInfo {
|
|
21
|
+
Source?: string;
|
|
22
|
+
Name?: string;
|
|
23
|
+
Line?: number;
|
|
24
|
+
TotalDuration?: number;
|
|
25
|
+
Flags?: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface ProfilingInfo {
|
|
29
|
+
Version?: number;
|
|
30
|
+
SessionStartTime?: number;
|
|
31
|
+
SessionEndTime?: number;
|
|
32
|
+
Categories?: CategoryInfo[];
|
|
33
|
+
Nodes?: unknown[];
|
|
34
|
+
Functions?: FunctionInfo[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface FunctionRow {
|
|
38
|
+
function_index: number;
|
|
39
|
+
name: string;
|
|
40
|
+
source?: string;
|
|
41
|
+
line?: number;
|
|
42
|
+
total_us: number;
|
|
43
|
+
is_native?: boolean;
|
|
44
|
+
is_plugin?: boolean;
|
|
45
|
+
is_debug_label?: boolean;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const DEFAULT_DURATION_MS = 1000;
|
|
49
|
+
const MIN_DURATION_MS = 100;
|
|
50
|
+
const MAX_DURATION_MS = 15000;
|
|
51
|
+
const DEFAULT_FREQUENCY = 1000;
|
|
52
|
+
const DEFAULT_MAX_FUNCTIONS = 20;
|
|
53
|
+
|
|
54
|
+
function getProfilerService(): ScriptProfilerServiceLike | Record<string, unknown> {
|
|
55
|
+
const provider = game as unknown as { GetService(serviceName: string): Instance };
|
|
56
|
+
const [ok, service] = pcall(() => provider.GetService("ScriptProfilerService") as ScriptProfilerServiceLike);
|
|
57
|
+
if (!ok || !service) {
|
|
58
|
+
return {
|
|
59
|
+
error: "script_profiler_unavailable",
|
|
60
|
+
message: `ScriptProfilerService is unavailable: ${tostring(service)}`,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
return service;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function normalizeDurationMs(value: unknown): number {
|
|
67
|
+
if (!typeIs(value, "number")) return DEFAULT_DURATION_MS;
|
|
68
|
+
return math.clamp(math.floor(value), MIN_DURATION_MS, MAX_DURATION_MS);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function normalizeFrequency(value: unknown): number {
|
|
72
|
+
if (!typeIs(value, "number")) return DEFAULT_FREQUENCY;
|
|
73
|
+
return math.clamp(math.floor(value), 1, 10000);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function normalizeMaxFunctions(value: unknown): number {
|
|
77
|
+
if (!typeIs(value, "number")) return DEFAULT_MAX_FUNCTIONS;
|
|
78
|
+
return math.clamp(math.floor(value), 1, 100);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function normalizeMinTotalUs(requestData: Record<string, unknown>): number {
|
|
82
|
+
const value = requestData.min_total_us;
|
|
83
|
+
if (typeIs(value, "number")) return math.max(0, value);
|
|
84
|
+
return 0;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function stringContains(haystack: string, needle: string): boolean {
|
|
88
|
+
return string.find(string.lower(haystack), string.lower(needle), 1, true)[0] !== undefined;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function localPlayer(): Player | undefined {
|
|
92
|
+
let player = Players.LocalPlayer;
|
|
93
|
+
const started = tick();
|
|
94
|
+
while (!player && tick() - started < 5) {
|
|
95
|
+
task.wait(0.05);
|
|
96
|
+
player = Players.LocalPlayer;
|
|
97
|
+
}
|
|
98
|
+
return player;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function functionDisplayName(func: FunctionInfo): string {
|
|
102
|
+
if (typeIs(func.Name, "string") && func.Name !== "") return func.Name;
|
|
103
|
+
if (typeIs(func.Source, "string") && func.Source !== "") {
|
|
104
|
+
if (typeIs(func.Line, "number") && func.Line > 0) return `${func.Source}:${func.Line}`;
|
|
105
|
+
return func.Source;
|
|
106
|
+
}
|
|
107
|
+
return "<anonymous>";
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function flagsOf(func: FunctionInfo): number {
|
|
111
|
+
return typeIs(func.Flags, "number") ? func.Flags : 0;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function isNativeFunction(func: FunctionInfo): boolean {
|
|
115
|
+
return bit32.band(flagsOf(func), 1) !== 0;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function isPluginFunction(func: FunctionInfo): boolean {
|
|
119
|
+
if (bit32.band(flagsOf(func), 2) !== 0) return true;
|
|
120
|
+
return typeIs(func.Source, "string") && string.find(func.Source, "MCPPlugin", 1, true)[0] !== undefined;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function isDebugLabel(func: FunctionInfo, filter: string | undefined): boolean {
|
|
124
|
+
if (filter === undefined) return false;
|
|
125
|
+
if (!typeIs(func.Name, "string") || func.Name === "") return false;
|
|
126
|
+
if (!typeIs(func.Source, "string") || func.Source === "" || func.Source === "[C]" || func.Source === "GC") return false;
|
|
127
|
+
if (func.Line !== undefined && func.Line !== 0) return false;
|
|
128
|
+
if (isNativeFunction(func) || isPluginFunction(func)) return false;
|
|
129
|
+
return stringContains(func.Name, filter) || stringContains(func.Source, filter);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function pctOfCapture(row: FunctionRow, durationMs: number): number | undefined {
|
|
133
|
+
const captureUs = durationMs * 1000;
|
|
134
|
+
if (captureUs <= 0) return undefined;
|
|
135
|
+
return math.floor((row.total_us / captureUs) * 10000 + 0.5) / 100;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function compactFunction(row: FunctionRow, rank: number, durationMs: number): Record<string, unknown> {
|
|
139
|
+
const out: Record<string, unknown> = {
|
|
140
|
+
rank,
|
|
141
|
+
function_index: row.function_index,
|
|
142
|
+
name: row.name,
|
|
143
|
+
total_us: math.floor(row.total_us + 0.5),
|
|
144
|
+
};
|
|
145
|
+
const pct = pctOfCapture(row, durationMs);
|
|
146
|
+
if (pct !== undefined) out.pct_of_capture = pct;
|
|
147
|
+
if (row.source !== undefined) out.source = row.source;
|
|
148
|
+
if (row.line !== undefined) out.line = row.line;
|
|
149
|
+
if (row.is_native === true) out.is_native = true;
|
|
150
|
+
if (row.is_plugin === true) out.is_plugin = true;
|
|
151
|
+
if (row.is_debug_label === true) out.is_debug_label = true;
|
|
152
|
+
return out;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function summarizeProfile(
|
|
156
|
+
rawJson: string,
|
|
157
|
+
profile: ProfilingInfo,
|
|
158
|
+
requestData: Record<string, unknown>,
|
|
159
|
+
durationMs: number,
|
|
160
|
+
frequency: number,
|
|
161
|
+
eventPlayerName: string | undefined,
|
|
162
|
+
): Record<string, unknown> {
|
|
163
|
+
const funcs = typeIs(profile.Functions, "table") ? profile.Functions : [];
|
|
164
|
+
const nodes = typeIs(profile.Nodes, "table") ? profile.Nodes : [];
|
|
165
|
+
const categories = typeIs(profile.Categories, "table") ? profile.Categories : [];
|
|
166
|
+
const maxFunctions = normalizeMaxFunctions(requestData.max_functions);
|
|
167
|
+
const minTotalUs = normalizeMinTotalUs(requestData);
|
|
168
|
+
const includeNative = requestData.include_native === true;
|
|
169
|
+
const includePlugin = requestData.include_plugin === true;
|
|
170
|
+
const filter = typeIs(requestData.filter, "string") && requestData.filter !== "" ? requestData.filter as string : undefined;
|
|
171
|
+
|
|
172
|
+
const rows: FunctionRow[] = [];
|
|
173
|
+
const debugRows: FunctionRow[] = [];
|
|
174
|
+
let omittedNative = 0;
|
|
175
|
+
let omittedPlugin = 0;
|
|
176
|
+
let omittedBelowThreshold = 0;
|
|
177
|
+
let omittedByFilter = 0;
|
|
178
|
+
|
|
179
|
+
for (let i = 0; i < funcs.size(); i++) {
|
|
180
|
+
const func = funcs[i];
|
|
181
|
+
if (!typeIs(func, "table")) continue;
|
|
182
|
+
const info = func as FunctionInfo;
|
|
183
|
+
const totalUs = typeIs(info.TotalDuration, "number") ? info.TotalDuration : 0;
|
|
184
|
+
const name = functionDisplayName(info);
|
|
185
|
+
const row: FunctionRow = {
|
|
186
|
+
function_index: i + 1,
|
|
187
|
+
name,
|
|
188
|
+
source: typeIs(info.Source, "string") ? info.Source : undefined,
|
|
189
|
+
line: typeIs(info.Line, "number") ? info.Line : undefined,
|
|
190
|
+
total_us: totalUs,
|
|
191
|
+
is_native: isNativeFunction(info) ? true : undefined,
|
|
192
|
+
is_plugin: isPluginFunction(info) ? true : undefined,
|
|
193
|
+
is_debug_label: isDebugLabel(info, filter) ? true : undefined,
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
if (!includeNative && row.is_native === true) {
|
|
197
|
+
omittedNative += 1;
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
if (!includePlugin && row.is_plugin === true) {
|
|
201
|
+
omittedPlugin += 1;
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
if (totalUs < minTotalUs) {
|
|
205
|
+
omittedBelowThreshold += 1;
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
if (filter !== undefined) {
|
|
209
|
+
const text = `${row.name} ${row.source ?? ""}`;
|
|
210
|
+
if (!stringContains(text, filter)) {
|
|
211
|
+
omittedByFilter += 1;
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
rows.push(row);
|
|
216
|
+
if (row.is_debug_label === true) debugRows.push(row);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
rows.sort((a, b) => a.total_us > b.total_us);
|
|
220
|
+
debugRows.sort((a, b) => a.total_us > b.total_us);
|
|
221
|
+
|
|
222
|
+
const topFunctions: Record<string, unknown>[] = [];
|
|
223
|
+
for (let i = 0; i < math.min(maxFunctions, rows.size()); i++) {
|
|
224
|
+
topFunctions.push(compactFunction(rows[i], i + 1, durationMs));
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const debugLabels: Record<string, unknown>[] = [];
|
|
228
|
+
for (let i = 0; i < math.min(maxFunctions, debugRows.size()); i++) {
|
|
229
|
+
debugLabels.push(compactFunction(debugRows[i], i + 1, durationMs));
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const categoryNames: string[] = [];
|
|
233
|
+
for (let i = 0; i < categories.size(); i++) {
|
|
234
|
+
const category = categories[i];
|
|
235
|
+
if (typeIs(category, "table")) {
|
|
236
|
+
const name = (category as CategoryInfo).Name;
|
|
237
|
+
if (typeIs(name, "string")) categoryNames.push(name);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const omitted: Record<string, number> = {};
|
|
242
|
+
let hasOmitted = false;
|
|
243
|
+
if (omittedNative > 0) {
|
|
244
|
+
omitted.native = omittedNative;
|
|
245
|
+
hasOmitted = true;
|
|
246
|
+
}
|
|
247
|
+
if (omittedPlugin > 0) {
|
|
248
|
+
omitted.plugin = omittedPlugin;
|
|
249
|
+
hasOmitted = true;
|
|
250
|
+
}
|
|
251
|
+
if (omittedBelowThreshold > 0) {
|
|
252
|
+
omitted.below_min_total_us = omittedBelowThreshold;
|
|
253
|
+
hasOmitted = true;
|
|
254
|
+
}
|
|
255
|
+
if (omittedByFilter > 0) {
|
|
256
|
+
omitted.filtered_out = omittedByFilter;
|
|
257
|
+
hasOmitted = true;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const result: Record<string, unknown> = {
|
|
261
|
+
ok: true,
|
|
262
|
+
duration_ms: durationMs,
|
|
263
|
+
frequency,
|
|
264
|
+
applied: {
|
|
265
|
+
filter: filter ?? undefined,
|
|
266
|
+
min_total_us: minTotalUs,
|
|
267
|
+
include_native: includeNative,
|
|
268
|
+
include_plugin: includePlugin,
|
|
269
|
+
max_functions: maxFunctions,
|
|
270
|
+
sort: "total_us_desc",
|
|
271
|
+
},
|
|
272
|
+
json_bytes: rawJson.size(),
|
|
273
|
+
counts: {
|
|
274
|
+
categories: categories.size(),
|
|
275
|
+
nodes: nodes.size(),
|
|
276
|
+
functions: funcs.size(),
|
|
277
|
+
},
|
|
278
|
+
top_functions: topFunctions,
|
|
279
|
+
debug_labels: debugLabels,
|
|
280
|
+
};
|
|
281
|
+
if (categoryNames.size() > 0) result.categories = categoryNames;
|
|
282
|
+
if (hasOmitted) result.omitted = omitted;
|
|
283
|
+
if (profile.Version !== undefined) result.version = profile.Version;
|
|
284
|
+
if (profile.SessionStartTime !== undefined || profile.SessionEndTime !== undefined) {
|
|
285
|
+
result.session = {
|
|
286
|
+
start_time: profile.SessionStartTime,
|
|
287
|
+
end_time: profile.SessionEndTime,
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
if (eventPlayerName !== undefined) result.player = eventPlayerName;
|
|
291
|
+
if (requestData.__mcp_include_raw_json === true) result.raw_json = rawJson;
|
|
292
|
+
return result;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function captureScriptProfiler(requestData: Record<string, unknown>): unknown {
|
|
296
|
+
if (!RunService.IsRunning()) {
|
|
297
|
+
return {
|
|
298
|
+
error: "runtime_target_required",
|
|
299
|
+
message: "Script profiler capture requires a running playtest target such as target=\"server\" or target=\"client-1\".",
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const serviceOrError = getProfilerService();
|
|
304
|
+
if (!serviceOrError.IsA) return serviceOrError;
|
|
305
|
+
const service = serviceOrError as ScriptProfilerServiceLike;
|
|
306
|
+
|
|
307
|
+
const durationMs = normalizeDurationMs(requestData.duration_ms);
|
|
308
|
+
const frequency = normalizeFrequency(requestData.frequency);
|
|
309
|
+
const isServer = RunService.IsServer();
|
|
310
|
+
const isClient = RunService.IsClient() && !isServer;
|
|
311
|
+
const player = isClient ? localPlayer() : undefined;
|
|
312
|
+
if (!isServer && !player) {
|
|
313
|
+
return {
|
|
314
|
+
error: "client_player_unavailable",
|
|
315
|
+
message: "Could not resolve Players.LocalPlayer for client profiling.",
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
let rawJson: string | undefined;
|
|
320
|
+
let eventPlayerName: string | undefined;
|
|
321
|
+
const connection = service.OnNewData.Connect((playerArg: Player | undefined, jsonString: string) => {
|
|
322
|
+
if (rawJson !== undefined) return;
|
|
323
|
+
rawJson = jsonString;
|
|
324
|
+
if (playerArg) eventPlayerName = playerArg.Name;
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
const [startOk, startErr] = pcall(() => {
|
|
328
|
+
if (isServer) {
|
|
329
|
+
service.ServerStart(frequency);
|
|
330
|
+
} else {
|
|
331
|
+
service.ClientStart(player!, frequency);
|
|
332
|
+
}
|
|
333
|
+
});
|
|
334
|
+
if (!startOk) {
|
|
335
|
+
connection.Disconnect();
|
|
336
|
+
return {
|
|
337
|
+
error: "script_profiler_start_failed",
|
|
338
|
+
message: tostring(startErr),
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
task.wait(durationMs / 1000);
|
|
343
|
+
|
|
344
|
+
const [stopOk, stopErr] = pcall(() => {
|
|
345
|
+
if (isServer) {
|
|
346
|
+
service.ServerStop();
|
|
347
|
+
service.ServerRequestData();
|
|
348
|
+
} else {
|
|
349
|
+
service.ClientStop(player!);
|
|
350
|
+
service.ClientRequestData(player!);
|
|
351
|
+
}
|
|
352
|
+
});
|
|
353
|
+
if (!stopOk) {
|
|
354
|
+
connection.Disconnect();
|
|
355
|
+
return {
|
|
356
|
+
error: "script_profiler_stop_failed",
|
|
357
|
+
message: tostring(stopErr),
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const requestedAt = tick();
|
|
362
|
+
while (rawJson === undefined && tick() - requestedAt < 5) {
|
|
363
|
+
task.wait(0.05);
|
|
364
|
+
}
|
|
365
|
+
connection.Disconnect();
|
|
366
|
+
|
|
367
|
+
if (rawJson === undefined) {
|
|
368
|
+
return {
|
|
369
|
+
error: "script_profiler_data_timeout",
|
|
370
|
+
message: "ScriptProfilerService did not emit OnNewData after requesting profiler data.",
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const [decodeOk, decoded] = pcall(() => HttpService.JSONDecode(rawJson!));
|
|
375
|
+
if (!decodeOk) {
|
|
376
|
+
return {
|
|
377
|
+
error: "script_profiler_decode_failed",
|
|
378
|
+
message: tostring(decoded),
|
|
379
|
+
json_bytes: rawJson.size(),
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
return summarizeProfile(rawJson, decoded as ProfilingInfo, requestData, durationMs, frequency, eventPlayerName);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
export = { captureScriptProfiler };
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { HttpService,
|
|
1
|
+
import { HttpService, Players, RunService } from "@rbxts/services";
|
|
2
2
|
import StopPlayMonitor from "../StopPlayMonitor";
|
|
3
3
|
|
|
4
4
|
interface StudioTestServiceMultiplayer extends StudioTestService {
|
|
@@ -10,29 +10,8 @@ interface StudioTestServiceMultiplayer extends StudioTestService {
|
|
|
10
10
|
}
|
|
11
11
|
|
|
12
12
|
const StudioTestService = game.GetService("StudioTestService") as StudioTestServiceMultiplayer;
|
|
13
|
-
const ServerScriptService = game.GetService("ServerScriptService");
|
|
14
|
-
const ScriptEditorService = game.GetService("ScriptEditorService");
|
|
15
|
-
|
|
16
|
-
// NAV_SIGNAL flows from the edit DM to the play-server DM via the injected
|
|
17
|
-
// __MCP_CommandListener Script + LogService.MessageOut. Stop signaling moved
|
|
18
|
-
// off this path entirely (see StopPlayMonitor) because cross-DM MessageOut
|
|
19
|
-
// reflection from edit -> play-server does not work in practice.
|
|
20
|
-
const NAV_SIGNAL = "__MCP_NAV__";
|
|
21
|
-
const NAV_RESULT = "__MCP_NAV_RESULT__";
|
|
22
|
-
|
|
23
|
-
interface OutputEntry {
|
|
24
|
-
message: string;
|
|
25
|
-
messageType: string;
|
|
26
|
-
timestamp: number;
|
|
27
|
-
}
|
|
28
13
|
|
|
29
14
|
let testRunning = false;
|
|
30
|
-
let outputBuffer: OutputEntry[] = [];
|
|
31
|
-
let logConnection: RBXScriptConnection | undefined;
|
|
32
|
-
let testResult: unknown;
|
|
33
|
-
let testError: string | undefined;
|
|
34
|
-
let stopListenerScript: Script | undefined;
|
|
35
|
-
let navResultCallback: ((json: string) => void) | undefined;
|
|
36
15
|
|
|
37
16
|
type MultiplayerPhase = "idle" | "starting" | "running" | "completed" | "failed";
|
|
38
17
|
|
|
@@ -87,95 +66,6 @@ function normalizeNumPlayers(value: unknown): number | undefined {
|
|
|
87
66
|
return n;
|
|
88
67
|
}
|
|
89
68
|
|
|
90
|
-
function buildCommandListenerSource(): string {
|
|
91
|
-
return `local LogService = game:GetService("LogService")
|
|
92
|
-
local PathfindingService = game:GetService("PathfindingService")
|
|
93
|
-
local Players = game:GetService("Players")
|
|
94
|
-
local HttpService = game:GetService("HttpService")
|
|
95
|
-
local NAV_SIG = "${NAV_SIGNAL}"
|
|
96
|
-
local NAV_RES = "${NAV_RESULT}"
|
|
97
|
-
LogService.MessageOut:Connect(function(msg)
|
|
98
|
-
if string.sub(msg, 1, #NAV_SIG + 1) == NAV_SIG .. ":" then
|
|
99
|
-
local json = string.sub(msg, #NAV_SIG + 2)
|
|
100
|
-
task.spawn(function()
|
|
101
|
-
local ok, d = pcall(function() return HttpService:JSONDecode(json) end)
|
|
102
|
-
if not ok or not d then
|
|
103
|
-
print(NAV_RES .. ':{"success":false,"error":"parse_error"}')
|
|
104
|
-
return
|
|
105
|
-
end
|
|
106
|
-
local ps = Players:GetPlayers()
|
|
107
|
-
if #ps == 0 then
|
|
108
|
-
print(NAV_RES .. ':{"success":false,"error":"no_players"}')
|
|
109
|
-
return
|
|
110
|
-
end
|
|
111
|
-
local char = ps[1].Character or ps[1].CharacterAdded:Wait()
|
|
112
|
-
local hum = char:FindFirstChildOfClass("Humanoid")
|
|
113
|
-
local root = char:FindFirstChild("HumanoidRootPart")
|
|
114
|
-
if not hum or not root then
|
|
115
|
-
print(NAV_RES .. ':{"success":false,"error":"no_humanoid"}')
|
|
116
|
-
return
|
|
117
|
-
end
|
|
118
|
-
local target
|
|
119
|
-
if d.instancePath then
|
|
120
|
-
local parts = string.split(d.instancePath, ".")
|
|
121
|
-
local cur = game
|
|
122
|
-
for i = 2, #parts do
|
|
123
|
-
cur = cur:FindFirstChild(parts[i])
|
|
124
|
-
if not cur then
|
|
125
|
-
print(NAV_RES .. ':{"success":false,"error":"instance_not_found"}')
|
|
126
|
-
return
|
|
127
|
-
end
|
|
128
|
-
end
|
|
129
|
-
if cur:IsA("BasePart") then target = cur.Position
|
|
130
|
-
elseif cur:IsA("Model") and cur.PrimaryPart then target = cur.PrimaryPart.Position
|
|
131
|
-
else target = cur:GetPivot().Position end
|
|
132
|
-
else
|
|
133
|
-
target = Vector3.new(d.x or 0, d.y or 0, d.z or 0)
|
|
134
|
-
end
|
|
135
|
-
local path = PathfindingService:CreatePath({AgentRadius=2,AgentHeight=5,AgentCanJump=true})
|
|
136
|
-
local pok = pcall(function() path:ComputeAsync(root.Position, target) end)
|
|
137
|
-
local method = "direct"
|
|
138
|
-
if pok and path.Status == Enum.PathStatus.Success then
|
|
139
|
-
method = "pathfinding"
|
|
140
|
-
for _, wp in ipairs(path:GetWaypoints()) do
|
|
141
|
-
hum:MoveTo(wp.Position)
|
|
142
|
-
if wp.Action == Enum.PathWaypointAction.Jump then hum.Jump = true end
|
|
143
|
-
hum.MoveToFinished:Wait()
|
|
144
|
-
end
|
|
145
|
-
else
|
|
146
|
-
hum:MoveTo(target)
|
|
147
|
-
hum.MoveToFinished:Wait()
|
|
148
|
-
end
|
|
149
|
-
local fp = root.Position
|
|
150
|
-
print(NAV_RES .. ':{"success":true,"method":"' .. method .. '","position":[' .. fp.X .. ',' .. fp.Y .. ',' .. fp.Z .. ']}')
|
|
151
|
-
end)
|
|
152
|
-
end
|
|
153
|
-
end)`;
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
function injectStopListener() {
|
|
157
|
-
const listener = new Instance("Script");
|
|
158
|
-
listener.Name = "__MCP_CommandListener";
|
|
159
|
-
listener.Parent = ServerScriptService;
|
|
160
|
-
|
|
161
|
-
const source = buildCommandListenerSource();
|
|
162
|
-
const [seOk] = pcall(() => {
|
|
163
|
-
ScriptEditorService.UpdateSourceAsync(listener, () => source);
|
|
164
|
-
});
|
|
165
|
-
if (!seOk) {
|
|
166
|
-
(listener as unknown as { Source: string }).Source = source;
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
stopListenerScript = listener;
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
function cleanupStopListener() {
|
|
173
|
-
if (stopListenerScript) {
|
|
174
|
-
pcall(() => stopListenerScript!.Destroy());
|
|
175
|
-
stopListenerScript = undefined;
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
|
|
179
69
|
function startPlaytest(requestData: Record<string, unknown>) {
|
|
180
70
|
const mode = requestData.mode as string | undefined;
|
|
181
71
|
const numPlayers = requestData.numPlayers as number | undefined;
|
|
@@ -194,11 +84,6 @@ function startPlaytest(requestData: Record<string, unknown>) {
|
|
|
194
84
|
// Reset it so subsequent starts don't hit a false "already running".
|
|
195
85
|
if (testRunning && !RunService.IsRunning()) {
|
|
196
86
|
testRunning = false;
|
|
197
|
-
if (logConnection) {
|
|
198
|
-
logConnection.Disconnect();
|
|
199
|
-
logConnection = undefined;
|
|
200
|
-
}
|
|
201
|
-
cleanupStopListener();
|
|
202
87
|
// Runtime eval bridges are created by the play server/client plugin
|
|
203
88
|
// peers and disappear with the play DataModels.
|
|
204
89
|
}
|
|
@@ -208,31 +93,6 @@ function startPlaytest(requestData: Record<string, unknown>) {
|
|
|
208
93
|
}
|
|
209
94
|
|
|
210
95
|
testRunning = true;
|
|
211
|
-
outputBuffer = [];
|
|
212
|
-
testResult = undefined;
|
|
213
|
-
testError = undefined;
|
|
214
|
-
|
|
215
|
-
cleanupStopListener();
|
|
216
|
-
|
|
217
|
-
logConnection = LogService.MessageOut.Connect((message, messageType) => {
|
|
218
|
-
if (message.sub(1, NAV_SIGNAL.size()) === NAV_SIGNAL) return;
|
|
219
|
-
if (message.sub(1, NAV_RESULT.size() + 1) === `${NAV_RESULT}:`) {
|
|
220
|
-
if (navResultCallback) {
|
|
221
|
-
navResultCallback(message.sub(NAV_RESULT.size() + 2));
|
|
222
|
-
}
|
|
223
|
-
return;
|
|
224
|
-
}
|
|
225
|
-
outputBuffer.push({
|
|
226
|
-
message,
|
|
227
|
-
messageType: messageType.Name,
|
|
228
|
-
timestamp: tick(),
|
|
229
|
-
});
|
|
230
|
-
});
|
|
231
|
-
|
|
232
|
-
const [injected, injErr] = pcall(() => injectStopListener());
|
|
233
|
-
if (!injected) {
|
|
234
|
-
warn(`[robloxstudio-mcp] Failed to inject stop listener: ${injErr}`);
|
|
235
|
-
}
|
|
236
96
|
|
|
237
97
|
task.spawn(() => {
|
|
238
98
|
const [ok, result] = pcall(() => {
|
|
@@ -242,19 +102,11 @@ function startPlaytest(requestData: Record<string, unknown>) {
|
|
|
242
102
|
return StudioTestService.ExecuteRunModeAsync({});
|
|
243
103
|
});
|
|
244
104
|
|
|
245
|
-
if (ok) {
|
|
246
|
-
|
|
247
|
-
} else {
|
|
248
|
-
testError = tostring(result);
|
|
105
|
+
if (!ok) {
|
|
106
|
+
warn(`[robloxstudio-mcp] Playtest ended with error: ${result}`);
|
|
249
107
|
}
|
|
250
108
|
|
|
251
|
-
if (logConnection) {
|
|
252
|
-
logConnection.Disconnect();
|
|
253
|
-
logConnection = undefined;
|
|
254
|
-
}
|
|
255
109
|
testRunning = false;
|
|
256
|
-
|
|
257
|
-
cleanupStopListener();
|
|
258
110
|
});
|
|
259
111
|
|
|
260
112
|
const response: Record<string, unknown> = {
|
|
@@ -324,16 +176,6 @@ function stopPlaytest(_requestData: Record<string, unknown>) {
|
|
|
324
176
|
return { success: true, message: "Playtest stopped." };
|
|
325
177
|
}
|
|
326
178
|
|
|
327
|
-
function getPlaytestOutput(_requestData: Record<string, unknown>) {
|
|
328
|
-
return {
|
|
329
|
-
isRunning: testRunning,
|
|
330
|
-
output: [...outputBuffer],
|
|
331
|
-
outputCount: outputBuffer.size(),
|
|
332
|
-
testResult: testResult !== undefined ? tostring(testResult) : undefined,
|
|
333
|
-
testError,
|
|
334
|
-
};
|
|
335
|
-
}
|
|
336
|
-
|
|
337
179
|
function multiplayerTestStart(requestData: Record<string, unknown>) {
|
|
338
180
|
if (RunService.IsRunning()) {
|
|
339
181
|
return { error: "multiplayer_test_start must be called on the edit DataModel. Route with target=edit." };
|
|
@@ -497,60 +339,12 @@ function multiplayerTestEnd(requestData: Record<string, unknown>) {
|
|
|
497
339
|
};
|
|
498
340
|
}
|
|
499
341
|
|
|
500
|
-
function characterNavigation(requestData: Record<string, unknown>) {
|
|
501
|
-
if (!testRunning) {
|
|
502
|
-
return { error: "Playtest must be running. Start a playtest in 'play' mode first." };
|
|
503
|
-
}
|
|
504
|
-
|
|
505
|
-
const position = requestData.position as number[] | undefined;
|
|
506
|
-
const instancePath = requestData.instancePath as string | undefined;
|
|
507
|
-
const waitForCompletion = (requestData.waitForCompletion as boolean) ?? true;
|
|
508
|
-
const timeout = (requestData.timeout as number) ?? 25;
|
|
509
|
-
|
|
510
|
-
if (!position && !instancePath) {
|
|
511
|
-
return { error: "Either position [x, y, z] or instancePath is required" };
|
|
512
|
-
}
|
|
513
|
-
|
|
514
|
-
let navData: string;
|
|
515
|
-
if (position) {
|
|
516
|
-
navData = HttpService.JSONEncode({ x: position[0], y: position[1], z: position[2] });
|
|
517
|
-
} else {
|
|
518
|
-
navData = HttpService.JSONEncode({ instancePath });
|
|
519
|
-
}
|
|
520
|
-
|
|
521
|
-
warn(`${NAV_SIGNAL}:${navData}`);
|
|
522
|
-
|
|
523
|
-
if (!waitForCompletion) {
|
|
524
|
-
return { success: true, message: "Navigation command sent" };
|
|
525
|
-
}
|
|
526
|
-
|
|
527
|
-
let result: string | undefined;
|
|
528
|
-
navResultCallback = (json: string) => {
|
|
529
|
-
result = json;
|
|
530
|
-
};
|
|
531
|
-
|
|
532
|
-
const startTime = tick();
|
|
533
|
-
while (!result && tick() - startTime < timeout) {
|
|
534
|
-
task.wait(0.2);
|
|
535
|
-
}
|
|
536
|
-
navResultCallback = undefined;
|
|
537
|
-
|
|
538
|
-
if (result) {
|
|
539
|
-
const [ok, parsed] = pcall(() => HttpService.JSONDecode(result!));
|
|
540
|
-
if (ok) return parsed;
|
|
541
|
-
return { success: true, rawResult: result };
|
|
542
|
-
}
|
|
543
|
-
return { error: `Navigation timed out after ${timeout} seconds` };
|
|
544
|
-
}
|
|
545
|
-
|
|
546
342
|
export = {
|
|
547
343
|
startPlaytest,
|
|
548
344
|
stopPlaytest,
|
|
549
|
-
getPlaytestOutput,
|
|
550
345
|
multiplayerTestStart,
|
|
551
346
|
multiplayerTestState,
|
|
552
347
|
multiplayerTestAddPlayers,
|
|
553
348
|
multiplayerTestLeaveClient,
|
|
554
349
|
multiplayerTestEnd,
|
|
555
|
-
characterNavigation,
|
|
556
350
|
};
|
|
@@ -6,6 +6,7 @@ import ServerUrlSettings from "../modules/ServerUrlSettings";
|
|
|
6
6
|
import { cleanupLegacyEditBridges, ensureRuntimeBridgeInstalled } from "../modules/EvalBridges";
|
|
7
7
|
import RuntimeLogBuffer from "../modules/RuntimeLogBuffer";
|
|
8
8
|
import StopPlayMonitor from "../modules/StopPlayMonitor";
|
|
9
|
+
import BreakpointHandlers from "../modules/handlers/BreakpointHandlers";
|
|
9
10
|
import * as RenderMonitor from "../modules/RenderMonitor";
|
|
10
11
|
|
|
11
12
|
// Track render-loop liveness so input/screenshot tools can report "window
|
|
@@ -22,8 +23,22 @@ RuntimeLogBuffer.install();
|
|
|
22
23
|
// edit DM (write the flag) and the play-server DM (read+act on the flag) can
|
|
23
24
|
// access plugin:SetSetting/GetSetting.
|
|
24
25
|
StopPlayMonitor.init(plugin);
|
|
26
|
+
BreakpointHandlers.init(plugin);
|
|
25
27
|
ServerUrlSettings.init(plugin);
|
|
26
28
|
|
|
29
|
+
function applyRememberedServerUrl(): void {
|
|
30
|
+
const rememberedServerUrl = ServerUrlSettings.readServerUrl();
|
|
31
|
+
if (rememberedServerUrl === undefined) return;
|
|
32
|
+
|
|
33
|
+
const conn = State.getActiveConnection();
|
|
34
|
+
conn.serverUrl = rememberedServerUrl;
|
|
35
|
+
const port = ServerUrlSettings.extractPort(rememberedServerUrl);
|
|
36
|
+
if (port !== undefined) conn.port = port;
|
|
37
|
+
ClientBroker.setServerUrl(rememberedServerUrl);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
applyRememberedServerUrl();
|
|
41
|
+
|
|
27
42
|
UI.init(plugin);
|
|
28
43
|
const elements = UI.getElements();
|
|
29
44
|
|
|
@@ -91,11 +106,11 @@ task.delay(2, () => {
|
|
|
91
106
|
if (conn && !conn.isActive) {
|
|
92
107
|
if (role === "server") {
|
|
93
108
|
const inheritedServerUrl = ServerUrlSettings.readServerUrl() ?? ClientBroker.DEFAULT_MCP_URL;
|
|
94
|
-
conn.serverUrl = inheritedServerUrl;
|
|
95
|
-
elements.urlInput.Text =
|
|
96
|
-
const
|
|
97
|
-
if (
|
|
98
|
-
ClientBroker.setServerUrl(
|
|
109
|
+
conn.serverUrl = ServerUrlSettings.normalizeServerUrl(inheritedServerUrl);
|
|
110
|
+
elements.urlInput.Text = conn.serverUrl;
|
|
111
|
+
const port = ServerUrlSettings.extractPort(conn.serverUrl);
|
|
112
|
+
if (port !== undefined) conn.port = port;
|
|
113
|
+
ClientBroker.setServerUrl(conn.serverUrl);
|
|
99
114
|
}
|
|
100
115
|
// Defensive default: in invisible play-DM UIs, the input field
|
|
101
116
|
// may not be populated by the time we activate.
|