@chrrxs/robloxstudio-mcp-inspector 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.
@@ -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, LogService, Players, RunService } from "@rbxts/services";
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
- testResult = result;
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 = inheritedServerUrl;
96
- const [portStr] = conn.serverUrl.match(":(%d+)$");
97
- if (portStr) conn.port = tonumber(portStr) ?? conn.port;
98
- ClientBroker.setServerUrl(inheritedServerUrl);
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.