@chrrxs/robloxstudio-mcp 2.15.1 → 2.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -737,6 +737,12 @@ var init_http_server = __esm({
737
737
  execute_luau: (tools, body) => tools.executeLuau(body.code, body.target, body.instance_id),
738
738
  eval_server_runtime: (tools, body) => tools.evalServerRuntime(body.code, body.instance_id),
739
739
  eval_client_runtime: (tools, body) => tools.evalClientRuntime(body.code, body.target, body.instance_id),
740
+ set_network_profile: (tools, body) => tools.setNetworkProfile(body.profile, body.target, body.overrides, body.instance_id),
741
+ get_simulation_state: (tools, body) => tools.getSimulationState(body.include, body.target, body.instance_id),
742
+ reset_simulation_state: (tools, body) => tools.resetSimulationState(body.target, body.network, body.deviceSimulator, body.instance_id),
743
+ get_device_simulator_state: (tools, body) => tools.getDeviceSimulatorState(body.target, body.deviceId, body.includeDeviceList, body.instance_id),
744
+ set_device_simulator: (tools, body) => tools.setDeviceSimulator(body.target, body.deviceId, body.orientation, body.resolution, body.pixelDensity, body.scalingMode, body.stopSimulation, body.instance_id),
745
+ capture_device_matrix: (tools, body) => tools.captureDeviceMatrix(body.entries, body.target, body.format, body.quality, body.settleSeconds, body.restoreAfter, body.instance_id),
740
746
  start_playtest: (tools, body) => tools.startPlaytest(body.mode, body.numPlayers, body.instance_id),
741
747
  stop_playtest: (tools, body) => tools.stopPlaytest(body.instance_id),
742
748
  get_playtest_output: (tools, body) => tools.getPlaytestOutput(body.target, body.instance_id),
@@ -1261,21 +1267,21 @@ var init_opencloud_client = __esm({
1261
1267
  clearTimeout(timeoutId);
1262
1268
  if (!response.ok) {
1263
1269
  const errorBody = await response.text();
1264
- let errorMessage;
1270
+ let errorMessage2;
1265
1271
  try {
1266
1272
  const errorJson = JSON.parse(errorBody);
1267
- errorMessage = errorJson.detail || errorJson.message || errorBody;
1273
+ errorMessage2 = errorJson.detail || errorJson.message || errorBody;
1268
1274
  } catch {
1269
- errorMessage = errorBody;
1275
+ errorMessage2 = errorBody;
1270
1276
  }
1271
1277
  if (response.status === 401) {
1272
1278
  throw new Error("Invalid or expired API key");
1273
1279
  } else if (response.status === 403) {
1274
- throw new Error(`API key lacks required permissions: ${errorMessage}`);
1280
+ throw new Error(`API key lacks required permissions: ${errorMessage2}`);
1275
1281
  } else if (response.status === 429) {
1276
1282
  throw new Error("Rate limit exceeded. Please try again later.");
1277
1283
  } else {
1278
- throw new Error(`Open Cloud API error (${response.status}): ${errorMessage}`);
1284
+ throw new Error(`Open Cloud API error (${response.status}): ${errorMessage2}`);
1279
1285
  }
1280
1286
  }
1281
1287
  return await response.json();
@@ -1410,21 +1416,21 @@ var init_opencloud_client = __esm({
1410
1416
  clearTimeout(timeoutId);
1411
1417
  if (!response.ok) {
1412
1418
  const errorBody = await response.text();
1413
- let errorMessage;
1419
+ let errorMessage2;
1414
1420
  try {
1415
1421
  const errorJson = JSON.parse(errorBody);
1416
- errorMessage = errorJson.detail || errorJson.message || errorBody;
1422
+ errorMessage2 = errorJson.detail || errorJson.message || errorBody;
1417
1423
  } catch {
1418
- errorMessage = errorBody;
1424
+ errorMessage2 = errorBody;
1419
1425
  }
1420
1426
  if (response.status === 401) {
1421
1427
  throw new Error("Invalid or expired API key");
1422
1428
  } else if (response.status === 403) {
1423
- throw new Error(`API key lacks required permissions: ${errorMessage}`);
1429
+ throw new Error(`API key lacks required permissions: ${errorMessage2}`);
1424
1430
  } else if (response.status === 429) {
1425
1431
  throw new Error("Rate limit exceeded. Please try again later.");
1426
1432
  } else {
1427
- throw new Error(`Open Cloud API error (${response.status}): ${errorMessage}`);
1433
+ throw new Error(`Open Cloud API error (${response.status}): ${errorMessage2}`);
1428
1434
  }
1429
1435
  }
1430
1436
  return await response.json();
@@ -2570,223 +2576,340 @@ function encodeImageFromRgbaResponse(response, format, quality) {
2570
2576
  mimeType: "image/jpeg"
2571
2577
  };
2572
2578
  }
2573
- function luaLongQuote(s) {
2574
- let level = 0;
2575
- while (s.includes(`]${"=".repeat(level)}]`))
2576
- level++;
2577
- const eq = "=".repeat(level);
2578
- return `[${eq}[
2579
- ${s}
2580
- ]${eq}]`;
2579
+ function sleep(ms) {
2580
+ return new Promise((resolve2) => setTimeout(resolve2, ms));
2581
2581
  }
2582
- function evalCountLines(s) {
2583
- return s.split("\n").length;
2582
+ function errorMessage(error) {
2583
+ return error instanceof Error ? error.message : String(error);
2584
+ }
2585
+ function normalizeNetworkProfile(profile, overrides) {
2586
+ if (!["great", "good", "poor", "custom"].includes(profile)) {
2587
+ throw new Error('profile must be "great", "good", "poor", or "custom"');
2588
+ }
2589
+ const values = profile === "custom" ? {} : { ...NETWORK_PROFILES[profile] };
2590
+ if (overrides !== void 0) {
2591
+ if (typeof overrides !== "object" || overrides === null || Array.isArray(overrides)) {
2592
+ throw new Error("overrides must be an object when provided");
2593
+ }
2594
+ const allowed = new Set(NETWORK_PROFILE_KEYS);
2595
+ for (const [key, value] of Object.entries(overrides)) {
2596
+ if (!allowed.has(key)) {
2597
+ throw new Error(`Unsupported network override "${key}". Allowed: ${NETWORK_PROFILE_KEYS.join(", ")}`);
2598
+ }
2599
+ if (typeof value !== "number" || !Number.isFinite(value)) {
2600
+ throw new Error(`Network override "${key}" must be a finite number`);
2601
+ }
2602
+ if (value < 0) {
2603
+ throw new Error(`Network override "${key}" must be greater than or equal to 0`);
2604
+ }
2605
+ if ((key === "InboundNetworkLossPercent" || key === "OutboundNetworkLossPercent") && value > MAX_NETWORK_PACKET_LOSS_PERCENT) {
2606
+ throw new Error(`Network override "${key}" cannot exceed ${MAX_NETWORK_PACKET_LOSS_PERCENT}; Roblox engine limits packet loss simulation to 0.5%.`);
2607
+ }
2608
+ values[key] = value;
2609
+ }
2610
+ }
2611
+ if (Object.keys(values).length === 0) {
2612
+ throw new Error("custom profile requires at least one override");
2613
+ }
2614
+ return values;
2584
2615
  }
2585
- function buildModuleScriptInvokeWrapper(opts) {
2586
- const userLines = evalCountLines(opts.userCode);
2587
- const wrapped = `return ((function()
2588
- local __mcp_traceback
2589
- local __mcp_remap
2590
- local __mcp_LINE_OFFSET = ${EVAL_WRAPPER_LINE_OFFSET}
2591
- local __mcp_USER_LINES = ${userLines}
2592
- local __mcp_output = {}
2593
- local __mcp_real_print = print
2594
- local __mcp_real_warn = warn
2595
- local print = function(...)
2596
- __mcp_real_print(...)
2597
- local args = {...}
2598
- local parts = table.create(#args)
2599
- for i, a in ipairs(args) do parts[i] = tostring(a) end
2600
- table.insert(__mcp_output, table.concat(parts, "\\t"))
2616
+ function buildNetworkProfileLuau(profile, values) {
2617
+ const valuesJson = JSON.stringify(values);
2618
+ const keysJson = JSON.stringify(NETWORK_PROFILE_KEYS);
2619
+ return `
2620
+ local HttpService = game:GetService("HttpService")
2621
+ local ns = settings():GetService("NetworkSettings")
2622
+ local keys = HttpService:JSONDecode(${JSON.stringify(keysJson)})
2623
+ local desired = HttpService:JSONDecode(${JSON.stringify(valuesJson)})
2624
+ local before = {}
2625
+ for _, key in ipairs(keys) do
2626
+ before[key] = ns[key]
2627
+ end
2628
+ for key, value in pairs(desired) do
2629
+ ns[key] = value
2630
+ end
2631
+ local after = {}
2632
+ for _, key in ipairs(keys) do
2633
+ after[key] = ns[key]
2634
+ end
2635
+ return HttpService:JSONEncode({
2636
+ profile = ${JSON.stringify(profile)},
2637
+ applied = desired,
2638
+ before = before,
2639
+ after = after,
2640
+ })
2641
+ `.trim();
2642
+ }
2643
+ function buildNetworkStateLuau(operation) {
2644
+ const keysJson = JSON.stringify(NETWORK_PROFILE_KEYS);
2645
+ const resetJson = JSON.stringify(ZERO_NETWORK_PROFILE);
2646
+ return `
2647
+ local HttpService = game:GetService("HttpService")
2648
+ local ns = settings():GetService("NetworkSettings")
2649
+ local operation = ${JSON.stringify(operation)}
2650
+ local keys = HttpService:JSONDecode(${JSON.stringify(keysJson)})
2651
+ local resetValues = HttpService:JSONDecode(${JSON.stringify(resetJson)})
2652
+
2653
+ local function readState()
2654
+ local state = {}
2655
+ for _, key in ipairs(keys) do
2656
+ state[key] = ns[key]
2601
2657
  end
2602
- local warn = function(...)
2603
- __mcp_real_warn(...)
2604
- local args = {...}
2605
- local parts = table.create(#args)
2606
- for i, a in ipairs(args) do parts[i] = tostring(a) end
2607
- table.insert(__mcp_output, "[warn] " .. table.concat(parts, "\\t"))
2658
+ return state
2659
+ end
2660
+
2661
+ if operation == "get" then
2662
+ return HttpService:JSONEncode({
2663
+ success = true,
2664
+ state = readState(),
2665
+ })
2666
+ end
2667
+
2668
+ if operation == "reset" then
2669
+ local before = readState()
2670
+ for key, value in pairs(resetValues) do
2671
+ ns[key] = value
2608
2672
  end
2609
- local function __mcp_run()
2610
- ${opts.userCode}
2673
+ return HttpService:JSONEncode({
2674
+ success = true,
2675
+ applied = resetValues,
2676
+ before = before,
2677
+ after = readState(),
2678
+ })
2679
+ end
2680
+
2681
+ error("Unsupported network simulation operation: " .. tostring(operation), 0)
2682
+ `.trim();
2683
+ }
2684
+ function normalizeDeviceSimulatorResolution(value) {
2685
+ if (value === void 0)
2686
+ return void 0;
2687
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
2688
+ throw new Error("resolution must be an object with positive integer width and height");
2689
+ }
2690
+ const resolution = value;
2691
+ const width = resolution.width;
2692
+ const height = resolution.height;
2693
+ if (!Number.isInteger(width) || !Number.isInteger(height) || width <= 0 || height <= 0) {
2694
+ throw new Error("resolution.width and resolution.height must be positive integers");
2695
+ }
2696
+ return { width, height };
2697
+ }
2698
+ function normalizeDeviceSimulatorSettings(input) {
2699
+ const settings = {};
2700
+ if (input.deviceId !== void 0) {
2701
+ if (typeof input.deviceId !== "string" || input.deviceId.trim() === "") {
2702
+ throw new Error("deviceId must be a non-empty string");
2703
+ }
2704
+ settings.deviceId = input.deviceId;
2705
+ }
2706
+ if (input.orientation !== void 0) {
2707
+ if (typeof input.orientation !== "string" || input.orientation.trim() === "") {
2708
+ throw new Error("orientation must be a non-empty string");
2709
+ }
2710
+ settings.orientation = input.orientation;
2711
+ }
2712
+ const resolution = normalizeDeviceSimulatorResolution(input.resolution);
2713
+ if (resolution !== void 0)
2714
+ settings.resolution = resolution;
2715
+ if (input.pixelDensity !== void 0) {
2716
+ if (typeof input.pixelDensity !== "number" || !Number.isFinite(input.pixelDensity) || input.pixelDensity <= 0) {
2717
+ throw new Error("pixelDensity must be a positive finite number");
2718
+ }
2719
+ settings.pixelDensity = input.pixelDensity;
2720
+ }
2721
+ if (input.scalingMode !== void 0) {
2722
+ if (typeof input.scalingMode !== "string" || input.scalingMode.trim() === "") {
2723
+ throw new Error("scalingMode must be a non-empty string");
2724
+ }
2725
+ settings.scalingMode = input.scalingMode;
2726
+ }
2727
+ return settings;
2728
+ }
2729
+ function hasDeviceSimulatorSettings(settings) {
2730
+ return settings.deviceId !== void 0 || settings.orientation !== void 0 || settings.resolution !== void 0 || settings.pixelDensity !== void 0 || settings.scalingMode !== void 0;
2731
+ }
2732
+ function buildDeviceSimulatorLuau(operation, options) {
2733
+ const payload = JSON.stringify({ operation, ...options });
2734
+ return `
2735
+ local HttpService = game:GetService("HttpService")
2736
+ local simulator = game:GetService("StudioDeviceSimulatorService")
2737
+ local opts = HttpService:JSONDecode(${JSON.stringify(payload)})
2738
+
2739
+ local function plain(value)
2740
+ local valueType = typeof(value)
2741
+ if valueType == "Vector2" then
2742
+ return { x = value.X, y = value.Y, width = value.X, height = value.Y }
2611
2743
  end
2612
- __mcp_remap = function(s)
2613
- -- Two chunk-name formats can reference our payload: the
2614
- -- ModuleScript path "Workspace.__MCPEvalPayload:N" and the
2615
- -- loadstring chunk "[string \\"return ((function()...\\"]:N" (if
2616
- -- the IIFE happens to compile via loadstring). Normalize both to
2617
- -- "user_code:N" with the offset stripped AND clamped to user
2618
- -- range, otherwise unclosed constructs report nonsense lines deep
2619
- -- in the wrapper. Strip the "Workspace." parent prefix too so the
2620
- -- final output reads "user_code:N" not "Workspace.user_code:N".
2621
- local function __mcp_user_line(payload_n)
2622
- local user_n = payload_n - __mcp_LINE_OFFSET
2623
- if user_n < 1 then return "1" end
2624
- if user_n > __mcp_USER_LINES then return tostring(__mcp_USER_LINES) .. " (at end of input)" end
2625
- return tostring(user_n)
2744
+ if valueType == "EnumItem" then
2745
+ return value.Name
2746
+ end
2747
+ if type(value) == "table" then
2748
+ local out = {}
2749
+ for k, v in pairs(value) do
2750
+ out[tostring(k)] = plain(v)
2626
2751
  end
2627
- s = string.gsub(s, "Workspace%.__MCPEvalPayload:(%d+)", function(num)
2628
- local n = tonumber(num)
2629
- if n then return "user_code:" .. __mcp_user_line(n) end
2630
- return "user_code:" .. num
2631
- end)
2632
- s = string.gsub(s, "__MCPEvalPayload:(%d+)", function(num)
2633
- local n = tonumber(num)
2634
- if n then return "user_code:" .. __mcp_user_line(n) end
2635
- return "user_code:" .. num
2636
- end)
2637
- s = string.gsub(s, '%[string "[^"]+"%]:(%d+)', function(num)
2638
- local n = tonumber(num)
2639
- if n then return "user_code:" .. __mcp_user_line(n) end
2640
- return "user_code:" .. num
2641
- end)
2642
- return s
2752
+ return out
2643
2753
  end
2644
- __mcp_traceback = function(err)
2645
- local raw = debug.traceback(tostring(err), 2)
2646
- local kept = {}
2647
- for line in string.gmatch(raw, "[^\\n]+") do
2648
- local num_str = string.match(line, "__MCPEvalPayload:(%d+)")
2649
- or string.match(line, '%[string "[^"]+"%]:(%d+)')
2650
- local n = num_str and tonumber(num_str)
2651
- -- Strip "in function '__mcp_run'" annotation BEFORE filtering:
2652
- -- user-code frames all carry that suffix (their source is
2653
- -- hosted inside __mcp_run), so a naive "__mcp_" filter would
2654
- -- drop every user frame and leave only the error header.
2655
- line = (string.gsub(line, " in function '__mcp_run'", ""))
2656
- local skip = string.find(line, "MCPPlugin", 1, true)
2657
- or string.find(line, "__mcp_", 1, true)
2658
- or string.find(line, "in function 'xpcall'", 1, true)
2659
- -- Drop wrapper preamble/postamble frames whose line falls
2660
- -- outside the user-code range \u2014 those are wrapper internals.
2661
- if n and (n <= __mcp_LINE_OFFSET or n > __mcp_LINE_OFFSET + __mcp_USER_LINES) then
2662
- skip = true
2754
+ return value
2755
+ end
2756
+
2757
+ local function getDeviceInfo(deviceId)
2758
+ local ok, info = pcall(function()
2759
+ return simulator:GetDeviceInfoAsync(deviceId)
2760
+ end)
2761
+ if ok then
2762
+ return plain(info), nil
2763
+ end
2764
+ return nil, tostring(info)
2765
+ end
2766
+
2767
+ local function normalizeDeviceList(rawList)
2768
+ local devices = {}
2769
+ local ids = {}
2770
+ for _, entry in ipairs(rawList) do
2771
+ local item
2772
+ local id
2773
+ if type(entry) == "table" then
2774
+ item = plain(entry)
2775
+ id = item.DeviceId or item.deviceId or item.Id or item.id or item[1]
2776
+ else
2777
+ id = tostring(entry)
2778
+ item = { DeviceId = id }
2779
+ end
2780
+ if id ~= nil then
2781
+ id = tostring(id)
2782
+ local info = getDeviceInfo(id)
2783
+ if type(info) == "table" then
2784
+ item = info
2785
+ if item.DeviceId == nil then item.DeviceId = id end
2663
2786
  end
2664
- if not skip then
2665
- table.insert(kept, __mcp_remap(line))
2787
+ if item.IsCustom ~= true then
2788
+ ids[id] = true
2789
+ table.insert(devices, item)
2666
2790
  end
2667
2791
  end
2668
- return table.concat(kept, "\\n")
2669
2792
  end
2670
- local ok, errOrValue = xpcall(__mcp_run, __mcp_traceback)
2671
- return { ok = ok, value = errOrValue, output = __mcp_output }
2672
- end)())`;
2673
- return `
2674
- local HttpService = game:GetService("HttpService")
2675
- local bf = game:GetService("${opts.service}"):FindFirstChild("${opts.bridgeName}")
2676
- if not bf then
2677
- return HttpService:JSONEncode({
2678
- bridge = "missing",
2679
- error = ${luaLongQuote(opts.missingError)},
2680
- })
2793
+ return devices, ids
2681
2794
  end
2682
- -- Outer-scope mirror of the in-IIFE __mcp_remap. Applied to parser errors
2683
- -- we pull out of LogService (those never pass through the IIFE) and to
2684
- -- the canned engine error string. Same offset as the IIFE's
2685
- -- __mcp_LINE_OFFSET; covers both chunk-name formats.
2686
- local __mcp_USER_LINES_OUTER = ${userLines}
2687
- local function __mcp_outer_user_line(payload_n)
2688
- local user_n = payload_n - ${EVAL_WRAPPER_LINE_OFFSET}
2689
- if user_n < 1 then return "1" end
2690
- if user_n > __mcp_USER_LINES_OUTER then return tostring(__mcp_USER_LINES_OUTER) .. " (at end of input)" end
2691
- return tostring(user_n)
2795
+
2796
+ local function getDeviceList()
2797
+ local rawList = simulator:GetDeviceListAsync()
2798
+ return normalizeDeviceList(rawList)
2692
2799
  end
2693
- local function __mcp_outer_remap(s)
2694
- s = string.gsub(s, "Workspace%.__MCPEvalPayload:(%d+)", function(num)
2695
- local n = tonumber(num)
2696
- if n then return "user_code:" .. __mcp_outer_user_line(n) end
2697
- return "user_code:" .. num
2698
- end)
2699
- s = string.gsub(s, "__MCPEvalPayload:(%d+)", function(num)
2700
- local n = tonumber(num)
2701
- if n then return "user_code:" .. __mcp_outer_user_line(n) end
2702
- return "user_code:" .. num
2703
- end)
2704
- s = string.gsub(s, '%[string "[^"]+"%]:(%d+)', function(num)
2705
- local n = tonumber(num)
2706
- if n then return "user_code:" .. __mcp_outer_user_line(n) end
2707
- return "user_code:" .. num
2708
- end)
2709
- return s
2800
+
2801
+ local function assertBuiltInDeviceExists(deviceId)
2802
+ local _, ids = getDeviceList()
2803
+ if ids[deviceId] then return end
2804
+ local available = {}
2805
+ for id in pairs(ids) do table.insert(available, id) end
2806
+ table.sort(available)
2807
+ error('deviceId "' .. tostring(deviceId) .. '" is not an available built-in device. Use get_device_simulator_state to list supported device IDs. Available: ' .. table.concat(available, ", "), 0)
2710
2808
  end
2711
- -- JSON-encode tables; otherwise tostring. Cycles or non-serializable
2712
- -- values fall back to tostring instead of erroring. This is what makes
2713
- -- eval_server_runtime / eval_client_runtime return structured table data
2714
- -- (matching execute_luau) instead of "table: 0xaddr".
2715
- local function __mcp_format(v)
2716
- if typeof(v) == "table" then
2717
- local ok, encoded = pcall(function() return HttpService:JSONEncode(v) end)
2718
- if ok then return encoded end
2809
+
2810
+ local function enumByName(enumType, raw, label)
2811
+ local name = tostring(raw)
2812
+ name = string.match(name, "([^%.]+)$") or name
2813
+ local available = {}
2814
+ for _, item in ipairs(enumType:GetEnumItems()) do
2815
+ table.insert(available, item.Name)
2816
+ if item.Name == name then
2817
+ return item, item.Name
2818
+ end
2719
2819
  end
2720
- return tostring(v)
2820
+ error(label .. ' "' .. tostring(raw) .. '" is not valid. Available: ' .. table.concat(available, ", "), 0)
2721
2821
  end
2722
- local USER_CODE = ${luaLongQuote(wrapped)}
2723
- local m = Instance.new("ModuleScript")
2724
- m.Name = "__MCPEvalPayload"
2725
- local okSet, setErr = pcall(function() m.Source = USER_CODE end)
2726
- if not okSet then
2727
- m:Destroy()
2728
- return HttpService:JSONEncode({ bridge = "ok", ok = false, error = "ModuleScript Source set failed: " .. tostring(setErr) })
2822
+
2823
+ local function tryActiveGetter(state, key, fn)
2824
+ local ok, value = pcall(fn)
2825
+ if ok then
2826
+ state[key] = plain(value)
2827
+ else
2828
+ state.unavailable = state.unavailable or {}
2829
+ state.unavailable[key] = tostring(value)
2830
+ end
2729
2831
  end
2730
- m.Parent = workspace
2731
- local bridgeOk, inner = bf:Invoke(m)
2732
- m:Destroy()
2733
- if not bridgeOk then
2734
- local errMsg = tostring(inner)
2735
- -- pcall(require, payload) collapses parse/compile failures into the
2736
- -- canned engine string below. The real parser diagnostic was emitted
2737
- -- to LogService just before. Walk GetLogHistory backward for the most
2738
- -- recent ERR entry tagged at our payload path and substitute.
2739
- if errMsg == "Requested module experienced an error while loading" then
2740
- -- The parser diagnostic is emitted to LogService on the next
2741
- -- engine frame, not synchronously with pcall(require). task.wait(0)
2742
- -- yields too early; 50ms is enough to let the frame complete and
2743
- -- the message land in GetLogHistory.
2744
- task.wait(0.05)
2745
- local LogService = game:GetService("LogService")
2746
- local hist = LogService:GetLogHistory()
2747
- for i = #hist, 1, -1 do
2748
- local e = hist[i]
2749
- if e.messageType == Enum.MessageType.MessageError and string.sub(e.message, 1, 27) == "Workspace.__MCPEvalPayload:" then
2750
- errMsg = e.message
2751
- break
2752
- end
2753
- end
2832
+
2833
+ local function readState(includeDeviceList, requestedDeviceId)
2834
+ local activeDeviceId = tostring(simulator:GetDeviceAsync())
2835
+ local state = {
2836
+ activeDeviceId = activeDeviceId,
2837
+ isSimulating = activeDeviceId ~= "default",
2838
+ }
2839
+
2840
+ if includeDeviceList then
2841
+ local devices = getDeviceList()
2842
+ state.devices = devices
2843
+ end
2844
+
2845
+ if requestedDeviceId ~= nil then
2846
+ assertBuiltInDeviceExists(requestedDeviceId)
2847
+ state.deviceInfo = plain(simulator:GetDeviceInfoAsync(requestedDeviceId))
2848
+ end
2849
+
2850
+ if state.isSimulating then
2851
+ tryActiveGetter(state, "resolution", function() return simulator:GetResolutionAsync() end)
2852
+ tryActiveGetter(state, "pixelDensity", function() return simulator:GetPixelDensityAsync() end)
2853
+ tryActiveGetter(state, "orientation", function() return simulator:GetOrientationAsync() end)
2854
+ tryActiveGetter(state, "scalingMode", function() return simulator:GetScalingModeAsync() end)
2754
2855
  end
2755
- return HttpService:JSONEncode({ bridge = "ok", ok = false, error = __mcp_outer_remap(errMsg) })
2856
+
2857
+ return state
2756
2858
  end
2757
- -- inner is the {ok, value, output} table from our IIFE. Defensive: if it's
2758
- -- somehow not a table (caller bypassed the wrapper), fall back to old shape.
2759
- if typeof(inner) ~= "table" then
2760
- return HttpService:JSONEncode({
2761
- bridge = "ok",
2762
- ok = true,
2763
- result = if inner == nil then nil else __mcp_format(inner),
2764
- })
2859
+
2860
+ local function applySettings(settings)
2861
+ local applied = {}
2862
+ if settings.deviceId ~= nil then
2863
+ assertBuiltInDeviceExists(settings.deviceId)
2864
+ simulator:SetDeviceAsync(settings.deviceId)
2865
+ applied.deviceId = settings.deviceId
2866
+ end
2867
+ if settings.orientation ~= nil then
2868
+ local item, name = enumByName(Enum.ScreenOrientation, settings.orientation, "orientation")
2869
+ simulator:SetOrientationAsync(item)
2870
+ applied.orientation = name
2871
+ end
2872
+ if settings.resolution ~= nil then
2873
+ simulator:SetResolutionAsync(settings.resolution.width, settings.resolution.height)
2874
+ applied.resolution = { width = settings.resolution.width, height = settings.resolution.height }
2875
+ end
2876
+ if settings.pixelDensity ~= nil then
2877
+ simulator:SetPixelDensityAsync(settings.pixelDensity)
2878
+ applied.pixelDensity = settings.pixelDensity
2879
+ end
2880
+ if settings.scalingMode ~= nil then
2881
+ local item, name = enumByName(Enum.DeviceSimulatorScalingMode, settings.scalingMode, "scalingMode")
2882
+ simulator:SetScalingModeAsync(item)
2883
+ applied.scalingMode = name
2884
+ end
2885
+ return applied
2765
2886
  end
2766
- return HttpService:JSONEncode({
2767
- bridge = "ok",
2768
- ok = inner.ok == true,
2769
- result = if inner.ok and inner.value ~= nil then __mcp_format(inner.value) else nil,
2770
- error = if not inner.ok then tostring(inner.value) else nil,
2771
- output = inner.output or {},
2772
- })
2773
- `;
2774
- }
2775
- function parseBridgeResponse(response) {
2776
- const r = response;
2777
- if (r && typeof r.returnValue === "string") {
2778
- try {
2779
- const parsed = JSON.parse(r.returnValue);
2780
- return JSON.stringify(parsed);
2781
- } catch {
2782
- }
2783
- }
2784
- return JSON.stringify(response);
2785
- }
2786
- function sleep(ms) {
2787
- return new Promise((resolve2) => setTimeout(resolve2, ms));
2887
+
2888
+ if opts.operation == "get" then
2889
+ return readState(opts.includeDeviceList ~= false, opts.deviceId)
2890
+ end
2891
+
2892
+ if opts.operation == "set" then
2893
+ local before = readState(false, nil)
2894
+ local applied
2895
+ if opts.stopSimulation == true then
2896
+ simulator:StopSimulationAsync()
2897
+ applied = { stopSimulation = true }
2898
+ else
2899
+ applied = applySettings(opts.settings or {})
2900
+ end
2901
+ return {
2902
+ success = true,
2903
+ applied = applied,
2904
+ before = before,
2905
+ after = readState(false, nil),
2906
+ }
2907
+ end
2908
+
2909
+ error("Unsupported device simulator operation: " .. tostring(opts.operation), 0)
2910
+ `.trim();
2788
2911
  }
2789
- var SERVER_LOCAL_NAME, CLIENT_LOCAL_NAME, EVAL_WRAPPER_LINE_OFFSET, RobloxStudioTools;
2912
+ var MAX_INLINE_IMAGE_BYTES, MAX_DEVICE_MATRIX_ENTRIES, MAX_NETWORK_PACKET_LOSS_PERCENT, NETWORK_PROFILE_KEYS, NETWORK_PROFILES, ZERO_NETWORK_PROFILE, SIMULATION_PERSISTENCE_NOTES, RobloxStudioTools;
2790
2913
  var init_tools = __esm({
2791
2914
  "../core/dist/tools/index.js"() {
2792
2915
  "use strict";
@@ -2797,9 +2920,56 @@ var init_tools = __esm({
2797
2920
  init_roblox_cookie_client();
2798
2921
  init_jpeg_encoder();
2799
2922
  init_png_encoder();
2800
- SERVER_LOCAL_NAME = "__MCP_ServerEvalLocal";
2801
- CLIENT_LOCAL_NAME = "__MCP_ClientEvalBridge";
2802
- EVAL_WRAPPER_LINE_OFFSET = 23;
2923
+ MAX_INLINE_IMAGE_BYTES = 6e6;
2924
+ MAX_DEVICE_MATRIX_ENTRIES = 6;
2925
+ MAX_NETWORK_PACKET_LOSS_PERCENT = 0.5;
2926
+ NETWORK_PROFILE_KEYS = [
2927
+ "InboundNetworkMinDelayMs",
2928
+ "OutboundNetworkMinDelayMs",
2929
+ "InboundNetworkJitterMs",
2930
+ "OutboundNetworkJitterMs",
2931
+ "InboundNetworkLossPercent",
2932
+ "OutboundNetworkLossPercent"
2933
+ ];
2934
+ NETWORK_PROFILES = {
2935
+ great: {
2936
+ InboundNetworkMinDelayMs: 15,
2937
+ OutboundNetworkMinDelayMs: 15,
2938
+ InboundNetworkJitterMs: 0,
2939
+ OutboundNetworkJitterMs: 0,
2940
+ InboundNetworkLossPercent: 0,
2941
+ OutboundNetworkLossPercent: 0
2942
+ },
2943
+ good: {
2944
+ InboundNetworkMinDelayMs: 50,
2945
+ OutboundNetworkMinDelayMs: 50,
2946
+ InboundNetworkJitterMs: 10,
2947
+ OutboundNetworkJitterMs: 10,
2948
+ InboundNetworkLossPercent: 0,
2949
+ OutboundNetworkLossPercent: 0
2950
+ },
2951
+ poor: {
2952
+ InboundNetworkMinDelayMs: 150,
2953
+ OutboundNetworkMinDelayMs: 150,
2954
+ InboundNetworkJitterMs: 100,
2955
+ OutboundNetworkJitterMs: 100,
2956
+ InboundNetworkLossPercent: 0.5,
2957
+ OutboundNetworkLossPercent: 0.5
2958
+ }
2959
+ };
2960
+ ZERO_NETWORK_PROFILE = {
2961
+ InboundNetworkMinDelayMs: 0,
2962
+ OutboundNetworkMinDelayMs: 0,
2963
+ InboundNetworkJitterMs: 0,
2964
+ OutboundNetworkJitterMs: 0,
2965
+ InboundNetworkLossPercent: 0,
2966
+ OutboundNetworkLossPercent: 0
2967
+ };
2968
+ SIMULATION_PERSISTENCE_NOTES = [
2969
+ "Normal Play client changes can write back to edit state.",
2970
+ "Multiplayer clients inherit baseline at startup but are isolated afterward.",
2971
+ "StudioTestService client device simulator state may appear stale on fresh clients, so reset after client startup is required."
2972
+ ];
2803
2973
  RobloxStudioTools = class _RobloxStudioTools {
2804
2974
  client;
2805
2975
  bridge;
@@ -2899,6 +3069,160 @@ var init_tools = __esm({
2899
3069
  _clientRolesForInstance(instanceId) {
2900
3070
  return this._rolesForInstance(instanceId).filter((role) => /^client-\d+$/.test(role)).sort((a, b) => Number(a.slice("client-".length)) - Number(b.slice("client-".length)));
2901
3071
  }
3072
+ _resolveDeviceSimulatorSingleTarget(target, instance_id, toolName) {
3073
+ const selectedTarget = target ?? "edit";
3074
+ if (selectedTarget === "server" || selectedTarget === "all" || selectedTarget === "all-clients" || selectedTarget === "edit-proxy") {
3075
+ throw new Error(`${toolName} target must be "edit" or "client-N" (got: ${selectedTarget})`);
3076
+ }
3077
+ if (selectedTarget !== "edit" && !/^client-\d+$/.test(selectedTarget)) {
3078
+ throw new Error(`${toolName} target must be "edit" or "client-N" (got: ${selectedTarget})`);
3079
+ }
3080
+ const resolved = this._resolveSingleTarget(selectedTarget, instance_id);
3081
+ return { ...resolved, selectedTarget };
3082
+ }
3083
+ _resolveDeviceSimulatorSetTargets(target, instance_id) {
3084
+ const selectedTarget = target ?? "edit";
3085
+ if (selectedTarget === "all-clients") {
3086
+ const instanceId = this._resolveInstanceIdOnly(instance_id);
3087
+ const roles = this._clientRolesForInstance(instanceId);
3088
+ if (roles.length === 0) {
3089
+ throw new RoutingFailure({
3090
+ code: "target_role_not_present_on_instance",
3091
+ message: `instance "${instanceId}" has no connected playtest client roles. Start a playtest first.`,
3092
+ data: {
3093
+ instances: this.bridge.getPublicInstances(),
3094
+ count: this.bridge.getInstances().length
3095
+ }
3096
+ });
3097
+ }
3098
+ return { instanceId, selectedTarget, roles };
3099
+ }
3100
+ const resolved = this._resolveDeviceSimulatorSingleTarget(selectedTarget, instance_id, "set_device_simulator");
3101
+ return { instanceId: resolved.instanceId, selectedTarget, roles: [resolved.role] };
3102
+ }
3103
+ _normalizeSimulationInclude(include) {
3104
+ const selectedInclude = include ?? "both";
3105
+ if (selectedInclude !== "network" && selectedInclude !== "deviceSimulator" && selectedInclude !== "both") {
3106
+ throw new Error(`get_simulation_state include must be "network", "deviceSimulator", or "both" (got: ${selectedInclude})`);
3107
+ }
3108
+ return selectedInclude;
3109
+ }
3110
+ _resolveSimulationTargets(target, instance_id, toolName) {
3111
+ const selectedTarget = target ?? "edit-and-clients";
3112
+ if (selectedTarget === "server" || selectedTarget === "all" || selectedTarget === "edit-proxy") {
3113
+ throw new Error(`${toolName} target must be "edit", "client-N", "all-clients", or "edit-and-clients" (got: ${selectedTarget})`);
3114
+ }
3115
+ const instanceId = this._resolveInstanceIdOnly(instance_id);
3116
+ const connectedRoles = this._rolesForInstance(instanceId);
3117
+ const clientRoles = this._clientRolesForInstance(instanceId);
3118
+ const warnings = [];
3119
+ let roles;
3120
+ if (selectedTarget === "edit") {
3121
+ if (!connectedRoles.includes("edit")) {
3122
+ throw new RoutingFailure({
3123
+ code: "target_role_not_present_on_instance",
3124
+ message: `instance "${instanceId}" has no role "edit". Available roles: ${connectedRoles.join(", ") || "none"}.`,
3125
+ data: {
3126
+ instances: this.bridge.getPublicInstances(),
3127
+ count: this.bridge.getInstances().length
3128
+ }
3129
+ });
3130
+ }
3131
+ roles = ["edit"];
3132
+ } else if (selectedTarget === "all-clients") {
3133
+ roles = clientRoles;
3134
+ if (roles.length === 0) {
3135
+ warnings.push(`No connected playtest client roles found for instance "${instanceId}".`);
3136
+ }
3137
+ } else if (selectedTarget === "edit-and-clients") {
3138
+ roles = [];
3139
+ if (connectedRoles.includes("edit")) {
3140
+ roles.push("edit");
3141
+ } else {
3142
+ warnings.push(`No edit role found for instance "${instanceId}".`);
3143
+ }
3144
+ roles.push(...clientRoles);
3145
+ } else if (/^client-\d+$/.test(selectedTarget)) {
3146
+ if (!clientRoles.includes(selectedTarget)) {
3147
+ throw new RoutingFailure({
3148
+ code: "target_role_not_present_on_instance",
3149
+ message: `instance "${instanceId}" has no role "${selectedTarget}". Available client roles: ${clientRoles.join(", ") || "none"}.`,
3150
+ data: {
3151
+ instances: this.bridge.getPublicInstances(),
3152
+ count: this.bridge.getInstances().length
3153
+ }
3154
+ });
3155
+ }
3156
+ roles = [selectedTarget];
3157
+ } else {
3158
+ throw new Error(`${toolName} target must be "edit", "client-N", "all-clients", or "edit-and-clients" (got: ${selectedTarget})`);
3159
+ }
3160
+ return { instanceId, selectedTarget, roles, warnings };
3161
+ }
3162
+ _parseExecuteLuauJsonResponse(response, toolName) {
3163
+ const r = response;
3164
+ if (r?.success === false) {
3165
+ throw new Error(r.error || r.message || `${toolName} Luau execution failed`);
3166
+ }
3167
+ if (typeof r?.returnValue !== "string") {
3168
+ return response;
3169
+ }
3170
+ if (r.returnValue === "") {
3171
+ return {};
3172
+ }
3173
+ try {
3174
+ return JSON.parse(r.returnValue);
3175
+ } catch {
3176
+ throw new Error(`${toolName} returned non-JSON data: ${r.returnValue}`);
3177
+ }
3178
+ }
3179
+ async _executeNetworkStateOperation(instanceId, role, operation) {
3180
+ const code = buildNetworkStateLuau(operation);
3181
+ const response = await this.client.request("/api/execute-luau", { code }, instanceId, role);
3182
+ return this._parseExecuteLuauJsonResponse(response, `network simulation ${operation}`);
3183
+ }
3184
+ async _executeDeviceSimulatorOperation(instanceId, role, operation, options) {
3185
+ const code = buildDeviceSimulatorLuau(operation, options);
3186
+ const response = await this.client.request("/api/execute-luau", { code }, instanceId, role);
3187
+ return this._parseExecuteLuauJsonResponse(response, `device simulator ${operation}`);
3188
+ }
3189
+ _settingsFromDeviceSimulatorState(state) {
3190
+ const s = state;
3191
+ if (!s || s.isSimulating !== true || typeof s.activeDeviceId !== "string" || s.activeDeviceId === "default") {
3192
+ return { stopSimulation: true };
3193
+ }
3194
+ return normalizeDeviceSimulatorSettings({
3195
+ deviceId: s.activeDeviceId,
3196
+ orientation: s.orientation,
3197
+ resolution: s.resolution,
3198
+ pixelDensity: s.pixelDensity,
3199
+ scalingMode: s.scalingMode
3200
+ });
3201
+ }
3202
+ _deviceSimulatorStateWithoutDeviceList(state) {
3203
+ if (typeof state !== "object" || state === null || Array.isArray(state)) {
3204
+ return state;
3205
+ }
3206
+ const { devices: _devices, ...rest } = state;
3207
+ return rest;
3208
+ }
3209
+ _assertCanRestoreDeviceSimulatorState(state) {
3210
+ const s = state;
3211
+ if (!s || s.isSimulating !== true || typeof s.activeDeviceId !== "string" || s.activeDeviceId === "default") {
3212
+ return;
3213
+ }
3214
+ const devices = Array.isArray(s.devices) ? s.devices : [];
3215
+ const isBuiltIn = devices.some((entry) => {
3216
+ if (typeof entry !== "object" || entry === null || Array.isArray(entry))
3217
+ return false;
3218
+ const device = entry;
3219
+ const id = device.DeviceId ?? device.deviceId ?? device.Id ?? device.id;
3220
+ return id === s.activeDeviceId && device.IsCustom !== true;
3221
+ });
3222
+ if (!isBuiltIn) {
3223
+ throw new Error(`capture_device_matrix cannot safely restore active custom device "${s.activeDeviceId}". Switch the simulator to default or a built-in preset first, or pass restoreAfter=false only if you intentionally accept changing the simulator state.`);
3224
+ }
3225
+ }
2902
3226
  async _waitForRuntimeRoles(instanceId, opts, timeoutSec = 30) {
2903
3227
  const deadline = Date.now() + timeoutSec * 1e3;
2904
3228
  while (Date.now() < deadline) {
@@ -3476,18 +3800,12 @@ ${code}`
3476
3800
  if (!code) {
3477
3801
  throw new Error("Code is required for eval_server_runtime");
3478
3802
  }
3479
- const wrapper = buildModuleScriptInvokeWrapper({
3480
- service: "ServerScriptService",
3481
- bridgeName: SERVER_LOCAL_NAME,
3482
- missingError: "ServerEvalBridge not found. The bridge runs inside the play DM, so a playtest must be running. The bridge installs automatically (including for manually-started playtests); if a playtest is running and you still see this, reconnect the plugin in the edit window so the bridge reinstalls, then start the playtest again.",
3483
- userCode: code
3484
- });
3485
- const response = await this._callSingle("/api/execute-luau", { code: wrapper }, "server", instance_id);
3803
+ const response = await this._callSingle("/api/eval-runtime", { code }, "server", instance_id);
3486
3804
  return {
3487
3805
  content: [
3488
3806
  {
3489
3807
  type: "text",
3490
- text: parseBridgeResponse(response)
3808
+ text: JSON.stringify(response)
3491
3809
  }
3492
3810
  ]
3493
3811
  };
@@ -3500,22 +3818,375 @@ ${code}`
3500
3818
  if (!clientTarget.startsWith("client-")) {
3501
3819
  throw new Error(`eval_client_runtime requires target=client-N (got: ${clientTarget})`);
3502
3820
  }
3503
- const wrapper = buildModuleScriptInvokeWrapper({
3504
- service: "ReplicatedStorage",
3505
- bridgeName: CLIENT_LOCAL_NAME,
3506
- missingError: "ClientEvalBridge not found. The bridge runs inside the play DM, so a playtest must be running. The bridge installs automatically (including for manually-started playtests); if a playtest is running and you still see this, reconnect the plugin in the edit window so the bridge reinstalls, then start the playtest again.",
3507
- userCode: code
3508
- });
3509
- const response = await this._callSingle("/api/execute-luau", { code: wrapper }, clientTarget, instance_id);
3821
+ const response = await this._callSingle("/api/eval-runtime", { code }, clientTarget, instance_id);
3510
3822
  return {
3511
3823
  content: [
3512
3824
  {
3513
3825
  type: "text",
3514
- text: parseBridgeResponse(response)
3826
+ text: JSON.stringify(response)
3515
3827
  }
3516
3828
  ]
3517
3829
  };
3518
3830
  }
3831
+ async setNetworkProfile(profile, target, overrides, instance_id) {
3832
+ const values = normalizeNetworkProfile(profile, overrides);
3833
+ const instanceId = this._resolveInstanceIdOnly(instance_id);
3834
+ const clientRoles = this._clientRolesForInstance(instanceId);
3835
+ const selectedTarget = target ?? "client-1";
3836
+ let targetRoles;
3837
+ if (selectedTarget === "all-clients") {
3838
+ targetRoles = clientRoles;
3839
+ } else if (/^client-\d+$/.test(selectedTarget)) {
3840
+ if (!clientRoles.includes(selectedTarget)) {
3841
+ throw new RoutingFailure({
3842
+ code: "target_role_not_present_on_instance",
3843
+ message: `instance "${instanceId}" has no role "${selectedTarget}". Available client roles: ${clientRoles.join(", ") || "none"}.`,
3844
+ data: {
3845
+ instances: this.bridge.getPublicInstances(),
3846
+ count: this.bridge.getInstances().length
3847
+ }
3848
+ });
3849
+ }
3850
+ targetRoles = [selectedTarget];
3851
+ } else {
3852
+ throw new Error(`set_network_profile target must be "client-N" or "all-clients" (got: ${selectedTarget})`);
3853
+ }
3854
+ if (targetRoles.length === 0) {
3855
+ throw new RoutingFailure({
3856
+ code: "target_role_not_present_on_instance",
3857
+ message: `instance "${instanceId}" has no connected playtest client roles. Start a playtest first.`,
3858
+ data: {
3859
+ instances: this.bridge.getPublicInstances(),
3860
+ count: this.bridge.getInstances().length
3861
+ }
3862
+ });
3863
+ }
3864
+ const code = buildNetworkProfileLuau(profile, values);
3865
+ const responses = await Promise.allSettled(targetRoles.map(async (role) => {
3866
+ const response = await this.client.request("/api/execute-luau", { code }, instanceId, role);
3867
+ const result = this._parseExecuteLuauJsonResponse(response, "set_network_profile");
3868
+ return { role, result };
3869
+ }));
3870
+ const body = {
3871
+ profile,
3872
+ target: selectedTarget,
3873
+ applied: values,
3874
+ targets: {}
3875
+ };
3876
+ const targetResults = body.targets;
3877
+ const failures = [];
3878
+ for (let i = 0; i < responses.length; i++) {
3879
+ const role = targetRoles[i];
3880
+ const response = responses[i];
3881
+ if (response.status === "fulfilled") {
3882
+ targetResults[role] = response.value.result;
3883
+ } else {
3884
+ const message = errorMessage(response.reason);
3885
+ targetResults[role] = { error: message };
3886
+ failures.push(`${role}: ${message}`);
3887
+ }
3888
+ }
3889
+ if (failures.length > 0) {
3890
+ throw new Error(`set_network_profile failed for ${failures.join("; ")}. Partial result: ${JSON.stringify(body)}`);
3891
+ }
3892
+ return {
3893
+ content: [
3894
+ {
3895
+ type: "text",
3896
+ text: JSON.stringify(body)
3897
+ }
3898
+ ]
3899
+ };
3900
+ }
3901
+ async getSimulationState(include, target, instance_id) {
3902
+ const selectedInclude = this._normalizeSimulationInclude(include);
3903
+ const includeNetwork = selectedInclude === "network" || selectedInclude === "both";
3904
+ const includeDeviceSimulator = selectedInclude === "deviceSimulator" || selectedInclude === "both";
3905
+ const resolved = this._resolveSimulationTargets(target, instance_id, "get_simulation_state");
3906
+ const roleEntries = await Promise.all(resolved.roles.map(async (role) => {
3907
+ const state = {};
3908
+ const errors = {};
3909
+ if (includeNetwork) {
3910
+ try {
3911
+ state.network = await this._executeNetworkStateOperation(resolved.instanceId, role, "get");
3912
+ } catch (error) {
3913
+ errors.network = errorMessage(error);
3914
+ }
3915
+ }
3916
+ if (includeDeviceSimulator) {
3917
+ try {
3918
+ state.deviceSimulator = await this._executeDeviceSimulatorOperation(resolved.instanceId, role, "get", { includeDeviceList: false });
3919
+ } catch (error) {
3920
+ errors.deviceSimulator = errorMessage(error);
3921
+ }
3922
+ }
3923
+ if (Object.keys(errors).length > 0) {
3924
+ state.errors = errors;
3925
+ }
3926
+ return { role, state };
3927
+ }));
3928
+ const roles = {};
3929
+ for (const entry of roleEntries) {
3930
+ roles[entry.role] = entry.state;
3931
+ }
3932
+ return {
3933
+ content: [{
3934
+ type: "text",
3935
+ text: JSON.stringify({
3936
+ include: selectedInclude,
3937
+ target: resolved.selectedTarget,
3938
+ roles,
3939
+ warnings: resolved.warnings,
3940
+ persistenceNotes: SIMULATION_PERSISTENCE_NOTES
3941
+ })
3942
+ }]
3943
+ };
3944
+ }
3945
+ async resetSimulationState(target, network, deviceSimulator, instance_id) {
3946
+ const resetNetwork = network !== false;
3947
+ const resetDeviceSimulator = deviceSimulator !== false;
3948
+ if (!resetNetwork && !resetDeviceSimulator) {
3949
+ throw new Error("reset_simulation_state requires network=true and/or deviceSimulator=true; both default to true");
3950
+ }
3951
+ const resolved = this._resolveSimulationTargets(target, instance_id, "reset_simulation_state");
3952
+ const roleEntries = await Promise.all(resolved.roles.map(async (role) => {
3953
+ const result = {};
3954
+ const errors = {};
3955
+ if (resetNetwork) {
3956
+ try {
3957
+ result.network = await this._executeNetworkStateOperation(resolved.instanceId, role, "reset");
3958
+ } catch (error) {
3959
+ errors.network = errorMessage(error);
3960
+ }
3961
+ }
3962
+ if (resetDeviceSimulator) {
3963
+ try {
3964
+ result.deviceSimulator = await this._executeDeviceSimulatorOperation(resolved.instanceId, role, "set", { stopSimulation: true });
3965
+ } catch (error) {
3966
+ errors.deviceSimulator = errorMessage(error);
3967
+ }
3968
+ }
3969
+ if (Object.keys(errors).length > 0) {
3970
+ result.errors = errors;
3971
+ }
3972
+ return { role, result };
3973
+ }));
3974
+ const roles = {};
3975
+ const failures = [];
3976
+ for (const entry of roleEntries) {
3977
+ roles[entry.role] = entry.result;
3978
+ const errors = entry.result.errors;
3979
+ if (errors) {
3980
+ for (const [kind, message] of Object.entries(errors)) {
3981
+ failures.push(`${entry.role}.${kind}: ${message}`);
3982
+ }
3983
+ }
3984
+ }
3985
+ const body = {
3986
+ target: resolved.selectedTarget,
3987
+ network: resetNetwork,
3988
+ deviceSimulator: resetDeviceSimulator,
3989
+ roles,
3990
+ warnings: resolved.warnings,
3991
+ persistenceNotes: SIMULATION_PERSISTENCE_NOTES
3992
+ };
3993
+ if (failures.length > 0) {
3994
+ throw new Error(`reset_simulation_state failed for ${failures.join("; ")}. Partial result: ${JSON.stringify(body)}`);
3995
+ }
3996
+ return {
3997
+ content: [{
3998
+ type: "text",
3999
+ text: JSON.stringify(body)
4000
+ }]
4001
+ };
4002
+ }
4003
+ async getDeviceSimulatorState(target, deviceId, includeDeviceList, instance_id) {
4004
+ if (deviceId !== void 0 && (typeof deviceId !== "string" || deviceId.trim() === "")) {
4005
+ throw new Error("deviceId must be a non-empty string when provided");
4006
+ }
4007
+ const resolved = this._resolveDeviceSimulatorSingleTarget(target, instance_id, "get_device_simulator_state");
4008
+ const state = await this._executeDeviceSimulatorOperation(resolved.instanceId, resolved.role, "get", {
4009
+ includeDeviceList: includeDeviceList !== false,
4010
+ deviceId
4011
+ });
4012
+ return {
4013
+ content: [{
4014
+ type: "text",
4015
+ text: JSON.stringify({
4016
+ target: resolved.selectedTarget,
4017
+ role: resolved.role,
4018
+ ...state
4019
+ })
4020
+ }]
4021
+ };
4022
+ }
4023
+ async setDeviceSimulator(target, deviceId, orientation, resolution, pixelDensity, scalingMode, stopSimulation, instance_id) {
4024
+ const settings = normalizeDeviceSimulatorSettings({ deviceId, orientation, resolution, pixelDensity, scalingMode });
4025
+ if (stopSimulation === true && hasDeviceSimulatorSettings(settings)) {
4026
+ throw new Error("stopSimulation=true cannot be combined with deviceId, orientation, resolution, pixelDensity, or scalingMode");
4027
+ }
4028
+ if (stopSimulation !== true && !hasDeviceSimulatorSettings(settings)) {
4029
+ throw new Error("set_device_simulator requires stopSimulation=true or at least one simulator setting");
4030
+ }
4031
+ const resolved = this._resolveDeviceSimulatorSetTargets(target, instance_id);
4032
+ const responses = await Promise.allSettled(resolved.roles.map(async (role) => {
4033
+ const result = await this._executeDeviceSimulatorOperation(resolved.instanceId, role, "set", stopSimulation === true ? { stopSimulation: true } : { settings });
4034
+ return { role, result };
4035
+ }));
4036
+ const body = {
4037
+ target: resolved.selectedTarget,
4038
+ targets: {}
4039
+ };
4040
+ const targets = body.targets;
4041
+ const failures = [];
4042
+ for (let i = 0; i < responses.length; i++) {
4043
+ const role = resolved.roles[i];
4044
+ const response = responses[i];
4045
+ if (response.status === "fulfilled") {
4046
+ targets[role] = response.value.result;
4047
+ } else {
4048
+ const message = errorMessage(response.reason);
4049
+ targets[role] = { error: message };
4050
+ failures.push(`${role}: ${message}`);
4051
+ }
4052
+ }
4053
+ if (failures.length > 0) {
4054
+ throw new Error(`set_device_simulator failed for ${failures.join("; ")}. Partial result: ${JSON.stringify(body)}`);
4055
+ }
4056
+ return {
4057
+ content: [{
4058
+ type: "text",
4059
+ text: JSON.stringify(body)
4060
+ }]
4061
+ };
4062
+ }
4063
+ async captureDeviceMatrix(entries, target, format, quality, settleSeconds, restoreAfter, instance_id) {
4064
+ if (!Array.isArray(entries) || entries.length === 0) {
4065
+ throw new Error("capture_device_matrix requires a non-empty entries array");
4066
+ }
4067
+ if (entries.length > MAX_DEVICE_MATRIX_ENTRIES) {
4068
+ throw new Error(`capture_device_matrix supports at most ${MAX_DEVICE_MATRIX_ENTRIES} entries per call; split larger matrices into multiple calls`);
4069
+ }
4070
+ const matrixEntries = entries.map((entry, index) => {
4071
+ if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
4072
+ throw new Error(`entries[${index}] must be an object`);
4073
+ }
4074
+ const raw = entry;
4075
+ if (raw.label !== void 0 && typeof raw.label !== "string") {
4076
+ throw new Error(`entries[${index}].label must be a string when provided`);
4077
+ }
4078
+ return {
4079
+ ...normalizeDeviceSimulatorSettings({
4080
+ deviceId: raw.deviceId,
4081
+ orientation: raw.orientation,
4082
+ resolution: raw.resolution,
4083
+ pixelDensity: raw.pixelDensity,
4084
+ scalingMode: raw.scalingMode
4085
+ }),
4086
+ label: raw.label
4087
+ };
4088
+ });
4089
+ const resolved = this._resolveDeviceSimulatorSingleTarget(target, instance_id, "capture_device_matrix");
4090
+ if (resolved.role.startsWith("client-") && await this._isMultiplayerTestRunning(resolved.instanceId)) {
4091
+ throw new Error("capture_device_matrix does not support StudioTestService multiplayer client targets because Roblox scopes temporary screenshot textures per client process");
4092
+ }
4093
+ const settleMs = settleSeconds === void 0 ? 300 : Math.max(0, Math.floor(settleSeconds * 1e3));
4094
+ const shouldRestore = restoreAfter !== false;
4095
+ const before = await this._executeDeviceSimulatorOperation(resolved.instanceId, resolved.role, "get", { includeDeviceList: shouldRestore });
4096
+ if (shouldRestore) {
4097
+ this._assertCanRestoreDeviceSimulatorState(before);
4098
+ }
4099
+ const summary = {
4100
+ target: resolved.selectedTarget,
4101
+ role: resolved.role,
4102
+ restoreAfter: shouldRestore,
4103
+ before: this._deviceSimulatorStateWithoutDeviceList(before),
4104
+ entries: []
4105
+ };
4106
+ const entrySummaries = summary.entries;
4107
+ const content = [];
4108
+ const failures = [];
4109
+ try {
4110
+ for (let i = 0; i < matrixEntries.length; i++) {
4111
+ const entry = matrixEntries[i];
4112
+ const label = entry.label ?? `entry-${i + 1}`;
4113
+ const entrySummary = {
4114
+ index: i,
4115
+ label,
4116
+ settings: entry
4117
+ };
4118
+ entrySummaries.push(entrySummary);
4119
+ try {
4120
+ const { label: _label, ...settings } = entry;
4121
+ const applied = await this._executeDeviceSimulatorOperation(resolved.instanceId, resolved.role, "set", { settings });
4122
+ entrySummary.applied = applied;
4123
+ if (settleMs > 0)
4124
+ await sleep(settleMs);
4125
+ const capture = await this._captureViewportImage(resolved.instanceId, resolved.role, format, quality);
4126
+ if (capture.success) {
4127
+ entrySummary.screenshot = {
4128
+ width: capture.width,
4129
+ height: capture.height,
4130
+ format: capture.format,
4131
+ quality: capture.quality,
4132
+ mimeType: capture.mimeType
4133
+ };
4134
+ content.push({
4135
+ type: "text",
4136
+ text: `capture_device_matrix ${i + 1}/${matrixEntries.length} ${label}: ${capture.message}`
4137
+ });
4138
+ content.push({
4139
+ type: "image",
4140
+ data: capture.data,
4141
+ mimeType: capture.mimeType
4142
+ });
4143
+ } else {
4144
+ entrySummary.error = capture.error;
4145
+ failures.push(`${label}: ${capture.error}`);
4146
+ content.push({
4147
+ type: "text",
4148
+ text: `capture_device_matrix ${i + 1}/${matrixEntries.length} ${label}: ${capture.error}`
4149
+ });
4150
+ }
4151
+ } catch (error) {
4152
+ const message = errorMessage(error);
4153
+ entrySummary.error = message;
4154
+ failures.push(`${label}: ${message}`);
4155
+ content.push({
4156
+ type: "text",
4157
+ text: `capture_device_matrix ${i + 1}/${matrixEntries.length} ${label}: ${message}`
4158
+ });
4159
+ }
4160
+ }
4161
+ } finally {
4162
+ if (shouldRestore) {
4163
+ try {
4164
+ const restoreSettings = this._settingsFromDeviceSimulatorState(before);
4165
+ if ("stopSimulation" in restoreSettings) {
4166
+ summary.restore = await this._executeDeviceSimulatorOperation(resolved.instanceId, resolved.role, "set", { stopSimulation: true });
4167
+ } else {
4168
+ summary.restore = await this._executeDeviceSimulatorOperation(resolved.instanceId, resolved.role, "set", { settings: restoreSettings });
4169
+ }
4170
+ } catch (error) {
4171
+ const message = errorMessage(error);
4172
+ summary.restoreError = message;
4173
+ failures.push(`restore: ${message}`);
4174
+ }
4175
+ }
4176
+ }
4177
+ if (failures.length > 0) {
4178
+ throw new Error(`capture_device_matrix failed for ${failures.join("; ")}. Partial result: ${JSON.stringify(summary)}`);
4179
+ }
4180
+ return {
4181
+ content: [
4182
+ {
4183
+ type: "text",
4184
+ text: JSON.stringify(summary)
4185
+ },
4186
+ ...content
4187
+ ]
4188
+ };
4189
+ }
3519
4190
  async getRuntimeLogs(target, since, tail, filter, instance_id) {
3520
4191
  const tgt = target ?? "all";
3521
4192
  const data = {};
@@ -3529,11 +4200,19 @@ ${code}`
3529
4200
  if (!resolved.ok)
3530
4201
  throw new RoutingFailure(resolved.error);
3531
4202
  if (resolved.mode === "single") {
4203
+ const originPeerReliable2 = await this._isMultiplayerTestRunning(resolved.targetInstanceId);
3532
4204
  const response = await this.client.request("/api/get-runtime-logs", data, resolved.targetInstanceId, resolved.targetRole);
3533
- response.peer = resolved.targetRole;
4205
+ response.capturedBy = resolved.targetRole;
4206
+ delete response.peer;
4207
+ response.originPeerReliable = originPeerReliable2;
4208
+ response.peerAttribution = originPeerReliable2 ? "guaranteed_multiplayer" : "unavailable_shared_logservice";
4209
+ if (originPeerReliable2)
4210
+ response.peer = resolved.targetRole;
3534
4211
  if (Array.isArray(response.entries)) {
3535
4212
  for (const e of response.entries) {
3536
- if (e.peer !== void 0)
4213
+ e.capturedBy = resolved.targetRole;
4214
+ delete e.peer;
4215
+ if (originPeerReliable2)
3537
4216
  e.peer = resolved.targetRole;
3538
4217
  }
3539
4218
  }
@@ -3542,35 +4221,38 @@ ${code}`
3542
4221
  };
3543
4222
  }
3544
4223
  const targets = resolved.targets.filter((t) => t.targetRole !== "edit-proxy");
4224
+ const originPeerReliable = targets.length > 0 ? await this._isMultiplayerTestRunning(targets[0].targetInstanceId) : false;
3545
4225
  const responses = await Promise.allSettled(targets.map(async (t) => {
3546
4226
  const r = await this.client.request("/api/get-runtime-logs", data, t.targetInstanceId, t.targetRole);
3547
- return { ...r, peer: t.targetRole };
4227
+ return { ...r, capturedBy: t.targetRole };
3548
4228
  }));
3549
4229
  const merged = [];
3550
- const perPeerNextSince = {};
3551
- const perPeerErrors = {};
4230
+ const perCaptureNextSince = {};
4231
+ const perCaptureErrors = {};
3552
4232
  let totalDropped = 0;
3553
4233
  for (const r of responses) {
3554
4234
  if (r.status !== "fulfilled")
3555
4235
  continue;
3556
4236
  const v = r.value;
3557
- const peer = v.peer ?? "unknown";
4237
+ const capturedBy = v.capturedBy ?? "unknown";
3558
4238
  if (v.error) {
3559
- perPeerErrors[peer] = v.error;
4239
+ perCaptureErrors[capturedBy] = v.error;
3560
4240
  continue;
3561
4241
  }
3562
4242
  if (v.nextSince !== void 0)
3563
- perPeerNextSince[peer] = v.nextSince;
4243
+ perCaptureNextSince[capturedBy] = v.nextSince;
3564
4244
  totalDropped += v.totalDropped ?? 0;
3565
4245
  for (const e of v.entries ?? []) {
3566
- merged.push({ ...e, peer });
4246
+ const entry = { ...e };
4247
+ delete entry.peer;
4248
+ merged.push({ ...entry, capturedBy });
3567
4249
  }
3568
4250
  }
3569
4251
  merged.sort((a, b) => a.ts !== b.ts ? a.ts - b.ts : a.seq - b.seq);
3570
4252
  const DEDUP_WINDOW = 2;
3571
4253
  const deduped = [];
3572
4254
  for (const e of merged) {
3573
- const isDup = deduped.some((d) => d.message === e.message && d.level === e.level && Math.abs(d.ts - e.ts) <= DEDUP_WINDOW && d.peer !== e.peer);
4255
+ const isDup = deduped.some((d) => d.message === e.message && d.level === e.level && Math.abs(d.ts - e.ts) <= DEDUP_WINDOW && d.capturedBy !== e.capturedBy);
3574
4256
  if (!isDup)
3575
4257
  deduped.push(e);
3576
4258
  }
@@ -3578,13 +4260,21 @@ ${code}`
3578
4260
  if (tail !== void 0 && deduped.length > tail) {
3579
4261
  final = deduped.slice(deduped.length - tail);
3580
4262
  }
4263
+ const finalEntries = originPeerReliable ? final.map((e) => ({ ...e, peer: e.capturedBy })) : final;
3581
4264
  const body = {
3582
- entries: final,
4265
+ entries: finalEntries,
3583
4266
  totalDropped,
3584
- perPeerNextSince
4267
+ perCaptureNextSince,
4268
+ originPeerReliable,
4269
+ peerAttribution: originPeerReliable ? "guaranteed_multiplayer" : "unavailable_shared_logservice"
3585
4270
  };
3586
- if (Object.keys(perPeerErrors).length > 0) {
3587
- body.perPeerErrors = perPeerErrors;
4271
+ if (originPeerReliable) {
4272
+ body.perPeerNextSince = perCaptureNextSince;
4273
+ }
4274
+ if (Object.keys(perCaptureErrors).length > 0) {
4275
+ body.perCaptureErrors = perCaptureErrors;
4276
+ if (originPeerReliable)
4277
+ body.perPeerErrors = perCaptureErrors;
3588
4278
  }
3589
4279
  return {
3590
4280
  content: [{ type: "text", text: JSON.stringify(body) }]
@@ -3708,15 +4398,19 @@ ${code}`
3708
4398
  return false;
3709
4399
  }
3710
4400
  async _isMultiplayerTestRunning(instanceId) {
3711
- if (!this._rolesForInstance(instanceId).includes("edit"))
3712
- return false;
3713
- try {
3714
- const editState = await this.client.request("/api/multiplayer-test-state", {}, instanceId, "edit");
3715
- const phase = editState?.session?.phase;
3716
- return phase === "starting" || phase === "running";
3717
- } catch {
3718
- return false;
4401
+ const roles = this._rolesForInstance(instanceId);
4402
+ const hasServer = roles.includes("server");
4403
+ const clientCount = roles.filter((role) => role.startsWith("client-")).length;
4404
+ if (roles.includes("edit")) {
4405
+ try {
4406
+ const editState = await this.client.request("/api/multiplayer-test-state", {}, instanceId, "edit");
4407
+ const phase = editState?.session?.phase;
4408
+ if (phase === "starting" || phase === "running")
4409
+ return true;
4410
+ } catch {
4411
+ }
3719
4412
  }
4413
+ return hasServer && clientCount >= 2;
3720
4414
  }
3721
4415
  async _waitForMultiplayerStart(instanceId, clientCount, timeoutSec = 30) {
3722
4416
  const deadline = Date.now() + timeoutSec * 1e3;
@@ -4833,16 +5527,15 @@ ${code}`
4833
5527
  }, tgt, instance_id);
4834
5528
  return { content: [{ type: "text", text: JSON.stringify(response) }] };
4835
5529
  }
4836
- async captureScreenshot(instance_id, format, quality) {
4837
- const { instanceId, clientRole } = this._resolveRuntime(instance_id);
5530
+ async _captureViewportImage(instanceId, targetRole, format, quality) {
4838
5531
  let response;
4839
- if (clientRole) {
4840
- const begin = await this._callSingle("/api/capture-begin", {}, clientRole, instanceId);
5532
+ if (targetRole.startsWith("client-")) {
5533
+ const begin = await this._callSingle("/api/capture-begin", {}, targetRole, instanceId);
4841
5534
  if (begin.error) {
4842
- return { content: [{ type: "text", text: begin.error }] };
5535
+ return { success: false, error: begin.error };
4843
5536
  }
4844
5537
  if (!begin.contentId) {
4845
- return { content: [{ type: "text", text: "Screenshot capture failed: no content id returned from client." }] };
5538
+ return { success: false, error: "Screenshot capture failed: no content id returned from client." };
4846
5539
  }
4847
5540
  response = await this._callSingle("/api/capture-read", { contentId: begin.contentId }, "edit", instanceId);
4848
5541
  } else {
@@ -4850,55 +5543,66 @@ ${code}`
4850
5543
  }
4851
5544
  if (response.error) {
4852
5545
  let text = response.error;
4853
- if (clientRole && response.error.includes("Failed to load texture, unexpected format") && await this._isMultiplayerTestRunning(instanceId)) {
5546
+ if (targetRole.startsWith("client-") && response.error.includes("Failed to load texture, unexpected format") && await this._isMultiplayerTestRunning(instanceId)) {
4854
5547
  text = `Screenshot capture reached the multiplayer client, but Roblox returned a temporary screenshot texture that the edit peer cannot read in StudioTestService multiplayer sessions. Regular start_playtest capture works because the temporary rbxtemp:// handle is readable from the edit process; multiplayer client handles appear to be scoped to the client process. Raw error: ${response.error}`;
4855
5548
  }
4856
- return {
4857
- content: [{
4858
- type: "text",
4859
- text
4860
- }]
4861
- };
5549
+ return { success: false, error: text };
4862
5550
  }
4863
5551
  const w = response.width;
4864
5552
  const h = response.height;
4865
5553
  if (w === void 0 || h === void 0) {
4866
- return { content: [{ type: "text", text: "Screenshot response missing dimensions." }] };
5554
+ return { success: false, error: "Screenshot response missing dimensions." };
4867
5555
  }
4868
5556
  const fmt = format === "png" ? "png" : "jpeg";
4869
5557
  const q = quality === void 0 ? 92 : Math.max(1, Math.min(100, Math.floor(quality)));
4870
- const MAX_IMAGE_BYTES = 6e6;
4871
5558
  const encoded = encodeImageFromRgbaResponse(response, fmt, q);
4872
5559
  let { buffer } = encoded;
4873
5560
  const { mimeType } = encoded;
4874
5561
  let usedQ = q;
4875
5562
  let note = "";
4876
- if (buffer.length > MAX_IMAGE_BYTES) {
5563
+ if (buffer.length > MAX_INLINE_IMAGE_BYTES) {
4877
5564
  if (fmt === "png") {
4878
5565
  const mb = (buffer.length / 1048576).toFixed(1);
4879
5566
  return {
4880
- content: [{
4881
- type: "text",
4882
- text: `PNG screenshot is ${mb}MB, over the ~${(MAX_IMAGE_BYTES / 1048576).toFixed(0)}MB inline image limit. Use the default jpeg format (optionally with a "quality" value) or make the Studio window smaller for a lossless capture.`
4883
- }]
5567
+ success: false,
5568
+ error: `PNG screenshot is ${mb}MB, over the ~${(MAX_INLINE_IMAGE_BYTES / 1048576).toFixed(0)}MB inline image limit. Use the default jpeg format (optionally with a "quality" value) or make the Studio window smaller for a lossless capture.`
4884
5569
  };
4885
5570
  }
4886
- while (buffer.length > MAX_IMAGE_BYTES && usedQ > 25) {
5571
+ while (buffer.length > MAX_INLINE_IMAGE_BYTES && usedQ > 25) {
4887
5572
  usedQ = Math.max(25, usedQ - 20);
4888
5573
  buffer = encodeImageFromRgbaResponse(response, "jpeg", usedQ).buffer;
4889
5574
  }
4890
5575
  note = ` \u2014 auto-reduced to q${usedQ} to fit the inline size limit; enlarge the Studio window or capture a smaller region for finer detail`;
4891
5576
  }
5577
+ const message = `Screenshot ${w}x${h}px (${fmt}${fmt === "jpeg" ? ` q${usedQ}` : ""})${note}. For simulate_mouse_input, x/y are pixel coordinates in this exact image with (0,0) at the top-left; it is not downscaled, so use coordinates as you read them off the image.`;
5578
+ return {
5579
+ success: true,
5580
+ width: w,
5581
+ height: h,
5582
+ format: fmt,
5583
+ quality: fmt === "jpeg" ? usedQ : void 0,
5584
+ note,
5585
+ data: buffer.toString("base64"),
5586
+ mimeType,
5587
+ message
5588
+ };
5589
+ }
5590
+ async captureScreenshot(instance_id, format, quality) {
5591
+ const { instanceId, clientRole } = this._resolveRuntime(instance_id);
5592
+ const capture = await this._captureViewportImage(instanceId, clientRole ?? "edit", format, quality);
5593
+ if (!capture.success) {
5594
+ return { content: [{ type: "text", text: capture.error }] };
5595
+ }
4892
5596
  return {
4893
5597
  content: [
4894
5598
  {
4895
5599
  type: "text",
4896
- text: `Screenshot ${w}x${h}px (${fmt}${fmt === "jpeg" ? ` q${usedQ}` : ""})${note}. For simulate_mouse_input, x/y are pixel coordinates in this exact image with (0,0) at the top-left; it is not downscaled, so use coordinates as you read them off the image.`
5600
+ text: capture.message
4897
5601
  },
4898
5602
  {
4899
5603
  type: "image",
4900
- data: buffer.toString("base64"),
4901
- mimeType
5604
+ data: capture.data,
5605
+ mimeType: capture.mimeType
4902
5606
  }
4903
5607
  ]
4904
5608
  };
@@ -6060,7 +6764,7 @@ var init_definitions = __esm({
6060
6764
  {
6061
6765
  name: "eval_server_runtime",
6062
6766
  category: "write",
6063
- description: "Execute Luau on the server peer in the running game's Script VM (shares require cache with user game scripts, unlike execute_luau target=server which runs in plugin context). Requires a running playtest; the bridge is installed automatically (including for playtests started manually via the Studio Play button).",
6767
+ description: "Execute Luau on the server peer in the running game's Script VM (shares require cache with user game scripts, unlike execute_luau target=server which runs in plugin context). Requires a running playtest; the runtime bridge is created automatically inside the play DataModel, including for playtests started manually via the Studio Play button.",
6064
6768
  inputSchema: {
6065
6769
  type: "object",
6066
6770
  properties: {
@@ -6079,7 +6783,7 @@ var init_definitions = __esm({
6079
6783
  {
6080
6784
  name: "eval_client_runtime",
6081
6785
  category: "write",
6082
- description: "Execute Luau on a client peer in the running game's LocalScript VM (shares require cache with user game scripts, unlike execute_luau target=client-N which runs in plugin context). Requires a running playtest; the bridge is installed automatically (including for playtests started manually via the Studio Play button).",
6786
+ description: "Execute Luau on a client peer in the running game's LocalScript VM (shares require cache with user game scripts, unlike execute_luau target=client-N which runs in plugin context). Requires a running playtest; the runtime bridge is created automatically inside the play DataModel, including for playtests started manually via the Studio Play button.",
6083
6787
  inputSchema: {
6084
6788
  type: "object",
6085
6789
  properties: {
@@ -6209,6 +6913,280 @@ var init_definitions = __esm({
6209
6913
  }
6210
6914
  }
6211
6915
  },
6916
+ {
6917
+ name: "set_network_profile",
6918
+ category: "write",
6919
+ description: `Apply simulated network conditions to active playtest client peers via NetworkSettings in plugin context. Requires a running playtest and targets only client peers: pass target="client-1", "client-2", etc., or target="all-clients". Presets: great = 30ms total latency (15ms in / 15ms out), 0ms jitter, 0% packet loss; good = 100ms total latency (50ms in / 50ms out), 10ms jitter, 0% packet loss; poor = 300ms (150ms in / 150ms out), 100ms jitter, 0.5% packet loss. profile="custom" applies only the numeric overrides provided; packet loss values above Roblox's 0.5% engine limit are rejected.`,
6920
+ inputSchema: {
6921
+ type: "object",
6922
+ properties: {
6923
+ profile: {
6924
+ type: "string",
6925
+ enum: ["great", "good", "poor", "custom"],
6926
+ description: "Network condition preset. Presets set all six simulation fields; custom requires overrides."
6927
+ },
6928
+ target: {
6929
+ type: "string",
6930
+ description: 'Client target: "client-1" (default), "client-2", etc., or "all-clients" to apply to every connected playtest client.'
6931
+ },
6932
+ overrides: {
6933
+ type: "object",
6934
+ additionalProperties: false,
6935
+ properties: {
6936
+ InboundNetworkMinDelayMs: {
6937
+ type: "number",
6938
+ minimum: 0,
6939
+ description: "Server-to-client minimum latency in milliseconds."
6940
+ },
6941
+ OutboundNetworkMinDelayMs: {
6942
+ type: "number",
6943
+ minimum: 0,
6944
+ description: "Client-to-server minimum latency in milliseconds."
6945
+ },
6946
+ InboundNetworkJitterMs: {
6947
+ type: "number",
6948
+ minimum: 0,
6949
+ description: "Server-to-client latency jitter in milliseconds."
6950
+ },
6951
+ OutboundNetworkJitterMs: {
6952
+ type: "number",
6953
+ minimum: 0,
6954
+ description: "Client-to-server latency jitter in milliseconds."
6955
+ },
6956
+ InboundNetworkLossPercent: {
6957
+ type: "number",
6958
+ minimum: 0,
6959
+ maximum: 0.5,
6960
+ description: "Server-to-client packet loss percentage. Roblox engine limit is 0.5%; larger values are rejected."
6961
+ },
6962
+ OutboundNetworkLossPercent: {
6963
+ type: "number",
6964
+ minimum: 0,
6965
+ maximum: 0.5,
6966
+ description: "Client-to-server packet loss percentage. Roblox engine limit is 0.5%; larger values are rejected."
6967
+ }
6968
+ },
6969
+ description: "Optional exact NetworkSettings property overrides. For preset profiles, overrides replace preset fields. For custom, only these properties are applied."
6970
+ },
6971
+ instance_id: {
6972
+ type: "string",
6973
+ description: "Which connected Studio place to target. Required when multiple places are connected; omit when one. Use get_connected_instances to list available IDs."
6974
+ }
6975
+ },
6976
+ required: ["profile"]
6977
+ }
6978
+ },
6979
+ {
6980
+ name: "get_simulation_state",
6981
+ category: "read",
6982
+ description: 'Inspect current NetworkSettings and/or StudioDeviceSimulatorService state for edit and connected playtest clients only. Defaults to include="both" and target="edit-and-clients"; server peers are skipped. Use before diagnosing network or device-sensitive tests, especially because normal Play can write client simulator changes back to edit and StudioTestService clients can inherit stale device simulator state.',
6983
+ inputSchema: {
6984
+ type: "object",
6985
+ properties: {
6986
+ include: {
6987
+ type: "string",
6988
+ enum: ["network", "deviceSimulator", "both"],
6989
+ description: 'Simulation state to inspect: "network", "deviceSimulator", or "both" (default both).'
6990
+ },
6991
+ target: {
6992
+ type: "string",
6993
+ description: 'Simulation target scope: "edit-and-clients" (default), "edit", "all-clients", or a specific "client-N". Server peers are never included.'
6994
+ },
6995
+ instance_id: {
6996
+ type: "string",
6997
+ description: "Which connected Studio place to target. Required when multiple places are connected; omit when one. Use get_connected_instances to list available IDs."
6998
+ }
6999
+ }
7000
+ }
7001
+ },
7002
+ {
7003
+ name: "reset_simulation_state",
7004
+ category: "write",
7005
+ description: 'Reset reachable simulation state to a clean baseline for deterministic tests. Defaults to target="edit-and-clients" and resets both network and device simulator state. Network reset sets all six simulated NetworkSettings fields to 0; device reset calls StopSimulationAsync(). Call before tests, after starting Play or multiplayer, before stopping, and again on edit after stopping.',
7006
+ inputSchema: {
7007
+ type: "object",
7008
+ properties: {
7009
+ target: {
7010
+ type: "string",
7011
+ description: 'Simulation target scope: "edit-and-clients" (default), "edit", "all-clients", or a specific "client-N". Server peers are skipped.'
7012
+ },
7013
+ network: {
7014
+ type: "boolean",
7015
+ description: "Reset simulated NetworkSettings fields to 0 (default true)."
7016
+ },
7017
+ deviceSimulator: {
7018
+ type: "boolean",
7019
+ description: "Stop Studio device simulation with StopSimulationAsync() (default true)."
7020
+ },
7021
+ instance_id: {
7022
+ type: "string",
7023
+ description: "Which connected Studio place to target. Required when multiple places are connected; omit when one. Use get_connected_instances to list available IDs."
7024
+ }
7025
+ }
7026
+ }
7027
+ },
7028
+ {
7029
+ name: "get_device_simulator_state",
7030
+ category: "read",
7031
+ description: 'Inspect StudioDeviceSimulatorService state and supported built-in device presets. Defaults to target="edit"; also supports a regular playtest client target such as "client-1". Server targets are not supported. When no simulated device is active, active-only fields are omitted and isSimulating=false.',
7032
+ inputSchema: {
7033
+ type: "object",
7034
+ properties: {
7035
+ target: {
7036
+ type: "string",
7037
+ description: 'Device simulator target: "edit" (default) or a regular playtest client like "client-1". Server targets are rejected.'
7038
+ },
7039
+ deviceId: {
7040
+ type: "string",
7041
+ description: "Optional built-in device preset ID to inspect with GetDeviceInfoAsync."
7042
+ },
7043
+ includeDeviceList: {
7044
+ type: "boolean",
7045
+ description: "Include the built-in device preset list from GetDeviceListAsync (default true)."
7046
+ },
7047
+ instance_id: {
7048
+ type: "string",
7049
+ description: "Which connected Studio place to target. Required when multiple places are connected; omit when one. Use get_connected_instances to list available IDs."
7050
+ }
7051
+ }
7052
+ }
7053
+ },
7054
+ {
7055
+ name: "set_device_simulator",
7056
+ category: "write",
7057
+ description: 'Set or stop StudioDeviceSimulatorService using built-in device presets only. Defaults to target="edit"; supports "client-N" and "all-clients"; rejects server targets. Applies deviceId first, then orientation, resolution, pixelDensity, and scalingMode overrides.',
7058
+ inputSchema: {
7059
+ type: "object",
7060
+ properties: {
7061
+ target: {
7062
+ type: "string",
7063
+ description: 'Device simulator target: "edit" (default), "client-1", "client-2", etc., or "all-clients".'
7064
+ },
7065
+ deviceId: {
7066
+ type: "string",
7067
+ description: "Built-in device preset ID from get_device_simulator_state."
7068
+ },
7069
+ orientation: {
7070
+ type: "string",
7071
+ description: 'ScreenOrientation enum name, e.g. "LandscapeRight", "LandscapeLeft", "Portrait", or a full Enum.ScreenOrientation.* string.'
7072
+ },
7073
+ resolution: {
7074
+ type: "object",
7075
+ additionalProperties: false,
7076
+ properties: {
7077
+ width: {
7078
+ type: "number",
7079
+ description: "Viewport width in pixels."
7080
+ },
7081
+ height: {
7082
+ type: "number",
7083
+ description: "Viewport height in pixels."
7084
+ }
7085
+ },
7086
+ required: ["width", "height"],
7087
+ description: "Optional resolution override applied after the device preset."
7088
+ },
7089
+ pixelDensity: {
7090
+ type: "number",
7091
+ description: "Optional positive pixel density override applied after the device preset."
7092
+ },
7093
+ scalingMode: {
7094
+ type: "string",
7095
+ description: 'DeviceSimulatorScalingMode enum name, e.g. "ScaleToPhysicalSize", or a full Enum.DeviceSimulatorScalingMode.* string.'
7096
+ },
7097
+ stopSimulation: {
7098
+ type: "boolean",
7099
+ description: "Stop device simulation. When true, do not pass other simulator setters."
7100
+ },
7101
+ instance_id: {
7102
+ type: "string",
7103
+ description: "Which connected Studio place to target. Required when multiple places are connected; omit when one. Use get_connected_instances to list available IDs."
7104
+ }
7105
+ }
7106
+ }
7107
+ },
7108
+ {
7109
+ name: "capture_device_matrix",
7110
+ category: "write",
7111
+ description: 'Apply up to 6 ordered Studio device simulator settings, capture each viewport screenshot, and restore the previous simulator state by default when the prior state is default or a built-in preset. Custom device persistence is intentionally unsupported. Defaults to target="edit"; supports regular playtest client targets but not server or all-clients targets.',
7112
+ inputSchema: {
7113
+ type: "object",
7114
+ properties: {
7115
+ entries: {
7116
+ type: "array",
7117
+ maxItems: 6,
7118
+ description: "Ordered device capture entries. Each entry may set a deviceId and optional simulator overrides before capture.",
7119
+ items: {
7120
+ type: "object",
7121
+ additionalProperties: false,
7122
+ properties: {
7123
+ label: {
7124
+ type: "string",
7125
+ description: "Optional label included in the screenshot metadata."
7126
+ },
7127
+ deviceId: {
7128
+ type: "string",
7129
+ description: "Built-in device preset ID from get_device_simulator_state."
7130
+ },
7131
+ orientation: {
7132
+ type: "string",
7133
+ description: "ScreenOrientation enum name or full Enum.ScreenOrientation.* string."
7134
+ },
7135
+ resolution: {
7136
+ type: "object",
7137
+ additionalProperties: false,
7138
+ properties: {
7139
+ width: {
7140
+ type: "number",
7141
+ description: "Viewport width in pixels."
7142
+ },
7143
+ height: {
7144
+ type: "number",
7145
+ description: "Viewport height in pixels."
7146
+ }
7147
+ },
7148
+ required: ["width", "height"]
7149
+ },
7150
+ pixelDensity: {
7151
+ type: "number",
7152
+ description: "Optional positive pixel density override."
7153
+ },
7154
+ scalingMode: {
7155
+ type: "string",
7156
+ description: "DeviceSimulatorScalingMode enum name or full Enum.DeviceSimulatorScalingMode.* string."
7157
+ }
7158
+ }
7159
+ }
7160
+ },
7161
+ target: {
7162
+ type: "string",
7163
+ description: 'Device simulator target: "edit" (default) or a regular playtest client such as "client-1". all-clients and server targets are rejected.'
7164
+ },
7165
+ format: {
7166
+ type: "string",
7167
+ enum: ["jpeg", "png"],
7168
+ description: 'Screenshot image format. "jpeg" (default) is compact; "png" is lossless but may exceed inline size limits.'
7169
+ },
7170
+ quality: {
7171
+ type: "number",
7172
+ description: "JPEG quality 1-100 (default 92). Ignored for png."
7173
+ },
7174
+ settleSeconds: {
7175
+ type: "number",
7176
+ description: "Seconds to wait after applying each simulator entry before capturing (default 0.3)."
7177
+ },
7178
+ restoreAfter: {
7179
+ type: "boolean",
7180
+ description: "Restore the previous default or built-in preset simulator state after the matrix finishes (default true). Custom active devices are not preserved."
7181
+ },
7182
+ instance_id: {
7183
+ type: "string",
7184
+ description: "Which connected Studio place to target. Required when multiple places are connected; omit when one. Use get_connected_instances to list available IDs."
7185
+ }
7186
+ },
7187
+ required: ["entries"]
7188
+ }
7189
+ },
6212
7190
  {
6213
7191
  name: "multiplayer_test_start",
6214
7192
  category: "write",
@@ -6318,17 +7296,17 @@ var init_definitions = __esm({
6318
7296
  {
6319
7297
  name: "get_runtime_logs",
6320
7298
  category: "read",
6321
- description: "Read the in-memory log buffer captured by the plugin on each peer's LogService.MessageOut. Each peer (edit, server, client-N) captures ~64 KB of recent prints; oldest entries drop when over budget. Drop-oldest semantics preserve the recent tail, unlike get_console_output's 10 KB drop-newest cap. Caveat: peer tag reflects which peer's plugin captured the entry, not which peer's script originated it - LogService reflects prints across peers in Studio Play and origin is undetectable from inside MessageOut. target=all (default) merges all peers and dedups same-message-and-level entries captured within 2s across different peers.",
7299
+ description: "Read the in-memory log buffers captured by Studio plugin peers. Each buffer captures ~64 KB of recent LogService.MessageOut entries; oldest entries drop when over budget. Entries include capturedBy for the plugin buffer that observed the log. In ordinary Studio play/run sessions, LogService reflects logs across edit/server/client, so script-origin peer is not reliable and entries omit peer. In StudioTestService multiplayer sessions only, peer attribution is reliable and entries also include peer. target=all (default) merges buffers and dedups same-message-and-level entries captured within 2s across different buffers.",
6322
7300
  inputSchema: {
6323
7301
  type: "object",
6324
7302
  properties: {
6325
7303
  target: {
6326
7304
  type: "string",
6327
- description: 'Peer to read from: "edit", "server", "client-N", or "all" (default). "all" merges all peers and dedups cross-peer reflections within a 2s window.'
7305
+ description: 'Capture buffer to read from: "edit", "server", "client-N", or "all" (default). "all" merges buffers and dedups cross-buffer reflections within a 2s window.'
6328
7306
  },
6329
7307
  since: {
6330
7308
  type: "number",
6331
- description: "Return only entries with seq > since. Pass back the previous response's nextSince (single-peer) or perPeerNextSince entry (target=all) for incremental polling."
7309
+ description: "Return only entries with seq > since. Pass back the previous response's nextSince (single target) or perCaptureNextSince entry (target=all) for incremental polling."
6332
7310
  },
6333
7311
  tail: {
6334
7312
  type: "number",
@@ -7351,7 +8329,7 @@ function handleVariantConflict({ pluginsFolder, otherAssetName, replace, log = c
7351
8329
  warn(`
7352
8330
  [install-plugin] WARNING: ${otherAssetName} is already present in ${pluginsFolder}.
7353
8331
  Only one MCP plugin variant should be present. If both variants are in the Studio Plugins folder, Studio loads both and runtime routing can become unpredictable.
7354
- Re-run with --replace-variant to remove ${otherAssetName}, or delete it manually.
8332
+ Delete ${otherAssetName} manually or use the default CLI installer behavior to replace it.
7355
8333
  `);
7356
8334
  }
7357
8335
  var init_install_plugin_helpers = __esm({
@@ -7473,6 +8451,27 @@ function bundledAssetPath() {
7473
8451
  ];
7474
8452
  return candidates.find((candidate) => existsSync3(candidate)) ?? null;
7475
8453
  }
8454
+ function packageVersion() {
8455
+ const currentDir = dirname2(fileURLToPath(import.meta.url));
8456
+ const pkg = JSON.parse(readFileSync3(join3(currentDir, "..", "package.json"), "utf8"));
8457
+ if (!pkg.version) {
8458
+ throw new Error("Package version not found");
8459
+ }
8460
+ return pkg.version;
8461
+ }
8462
+ function bundledPluginVersion(source) {
8463
+ const match = readFileSync3(source, "utf8").match(/local CURRENT_VERSION = "([^"]+)"/);
8464
+ return match ? match[1] : null;
8465
+ }
8466
+ function assertBundledPluginVersion(source) {
8467
+ const expected = packageVersion();
8468
+ const actual = bundledPluginVersion(source);
8469
+ if (actual !== expected) {
8470
+ throw new Error(
8471
+ `Bundled ${ASSET_NAME} version ${actual ?? "unknown"} does not match package version ${expected}. Run npm run build:plugin before starting with --auto-install-plugin.`
8472
+ );
8473
+ }
8474
+ }
7476
8475
  function filesMatch(a, b) {
7477
8476
  if (!existsSync3(b)) return false;
7478
8477
  const aBytes = readFileSync3(a);
@@ -7482,11 +8481,12 @@ function filesMatch(a, b) {
7482
8481
  async function installBundledPlugin(options = {}) {
7483
8482
  const log = options.log ?? console.log;
7484
8483
  const warn = options.warn ?? console.warn;
7485
- const replaceVariant = options.replaceVariant ?? process.argv.includes("--replace-variant");
8484
+ const replaceVariant = options.replaceVariant ?? true;
7486
8485
  const source = bundledAssetPath();
7487
8486
  if (!source) {
7488
8487
  throw new Error(`Bundled ${ASSET_NAME} not found in package`);
7489
8488
  }
8489
+ assertBundledPluginVersion(source);
7490
8490
  const pluginsFolder = prepareInstall({ replaceVariant, log, warn });
7491
8491
  const dest = join3(pluginsFolder, ASSET_NAME);
7492
8492
  if (filesMatch(source, dest)) return;
@@ -7495,10 +8495,22 @@ async function installBundledPlugin(options = {}) {
7495
8495
  }
7496
8496
  async function installPlugin(options = {}) {
7497
8497
  const dev = options.dev ?? process.argv.includes("--dev");
7498
- const replaceVariant = options.replaceVariant ?? process.argv.includes("--replace-variant");
8498
+ const replaceVariant = options.replaceVariant ?? true;
7499
8499
  const log = options.log ?? console.log;
7500
8500
  const warn = options.warn ?? console.warn;
7501
8501
  const pluginsFolder = prepareInstall({ replaceVariant, log, warn });
8502
+ const bundled = bundledAssetPath();
8503
+ if (bundled) {
8504
+ assertBundledPluginVersion(bundled);
8505
+ const dest2 = join3(pluginsFolder, ASSET_NAME);
8506
+ if (filesMatch(bundled, dest2)) {
8507
+ log(`${ASSET_NAME} already installed.`);
8508
+ return;
8509
+ }
8510
+ copyFileSync(bundled, dest2);
8511
+ log(`Installed bundled ${ASSET_NAME} to ${dest2}`);
8512
+ return;
8513
+ }
7502
8514
  log(dev ? "Fetching latest dev prerelease..." : "Fetching latest release...");
7503
8515
  const release = dev ? await findDevRelease() : await fetchJson(`https://api.github.com/repos/${REPO}/releases/latest`);
7504
8516
  const asset = release.assets?.find((a) => a.name === ASSET_NAME);
@@ -7528,7 +8540,7 @@ init_dist();
7528
8540
  import { createRequire } from "module";
7529
8541
  if (process.argv.includes("--install-plugin")) {
7530
8542
  const { installPlugin: installPlugin2 } = await Promise.resolve().then(() => (init_install_plugin(), install_plugin_exports));
7531
- installPlugin2().catch((err) => {
8543
+ await installPlugin2().catch((err) => {
7532
8544
  console.error(err instanceof Error ? err.message : String(err));
7533
8545
  process.exitCode = 1;
7534
8546
  });
@@ -7536,7 +8548,6 @@ if (process.argv.includes("--install-plugin")) {
7536
8548
  if (process.argv.includes("--auto-install-plugin")) {
7537
8549
  const { installBundledPlugin: installBundledPlugin2 } = await Promise.resolve().then(() => (init_install_plugin(), install_plugin_exports));
7538
8550
  await installBundledPlugin2({
7539
- replaceVariant: process.argv.includes("--replace-variant"),
7540
8551
  log: (message) => console.error(`[install-plugin] ${message}`),
7541
8552
  warn: (message) => console.error(message)
7542
8553
  }).catch((err) => {