From 1596ea4069c8ae2db73563774c0e405800b5ebb4 Mon Sep 17 00:00:00 2001 From: Yunus Date: Thu, 8 Oct 2026 14:16:15 +0800 Subject: [PATCH] feat(manage_editor): control fixed Game View resolution with render readback --- .../Editor/Helpers/GameViewSizeControl.cs | 335 ++++++++++++++++++ .../Helpers/GameViewSizeControl.cs.meta | 11 + MCPForUnity/Editor/Tools/ManageEditor.cs | 10 +- Server/src/cli/commands/editor.py | 29 ++ Server/src/services/tools/manage_editor.py | 18 +- Server/tests/test_cli.py | 28 ++ Server/tests/test_manage_editor.py | 55 +++ .../Tools/GameViewSizeControlTests.cs | 287 +++++++++++++++ .../Tools/GameViewSizeControlTests.cs.meta | 11 + website/docs/guides/cli-examples.md | 2 + website/docs/guides/cli.md | 12 + .../reference/tools/core/manage_editor.md | 47 ++- 12 files changed, 839 insertions(+), 6 deletions(-) create mode 100644 MCPForUnity/Editor/Helpers/GameViewSizeControl.cs create mode 100644 MCPForUnity/Editor/Helpers/GameViewSizeControl.cs.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/GameViewSizeControlTests.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/GameViewSizeControlTests.cs.meta diff --git a/MCPForUnity/Editor/Helpers/GameViewSizeControl.cs b/MCPForUnity/Editor/Helpers/GameViewSizeControl.cs new file mode 100644 index 000000000..c3f1247f2 --- /dev/null +++ b/MCPForUnity/Editor/Helpers/GameViewSizeControl.cs @@ -0,0 +1,335 @@ +using System; +using System.Linq; +using System.Reflection; +using System.Threading.Tasks; +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEngine; + +namespace MCPForUnity.Editor.Helpers +{ + /// + /// Fixed Game View presets and observed render-target dimensions. Unity's + /// undocumented Editor APIs stay here and fail explicitly if they change. + /// + internal static class GameViewSizeControl + { + // Unity clamps fixed presets to at least 10 pixels and Game View render + // targets to at most 8192 (also subject to GPU and VRAM limits). + internal const int MinDimension = 10; + internal const int MaxDimension = 8192; + internal const string OwnershipKeyPrefix = "MCPForUnity.GameViewSize."; + private const double ReadbackTimeoutSeconds = 5; + private static bool _settingSize; + + internal static object Get() + { + try + { + var api = new GameViewApi(); + var view = api.FindView(); + return new SuccessResponse("Current Game View size. render_size is the allocated render target, not the preset or window size.", + api.Read(view)); + } + catch (Exception ex) + { + return Failure(ex, false); + } + } + + internal static object Set(ToolParams parameters) + { + foreach (string name in new[] { "width", "height" }) + { + var token = parameters.GetRaw(name); + if (token?.Type != JTokenType.Integer || !parameters.GetInt(name).HasValue + || parameters.GetInt(name).Value < MinDimension || parameters.GetInt(name).Value > MaxDimension) + return new ErrorResponse($"{name} must be an integer between {MinDimension} and {MaxDimension}."); + } + if (_settingSize) + return new ErrorResponse("game_view_size_busy", new { message = "Another Game View size change is waiting for readback." }); + + bool changed = false; + try + { + var api = new GameViewApi(); + var view = api.FindView(); + int width = parameters.GetInt("width").Value; + int height = parameters.GetInt("height").Value; + object groupType = api.CurrentGroupType; + _settingSize = true; + changed = true; + int index = api.SetOwnedPreset(groupType, width, height); + api.Select(view, index); + return WaitForReadback(api, view, groupType, index, width, height); + } + catch (Exception ex) + { + _settingSize = false; + return Failure(ex, changed); + } + } + + /// Wait for Unity to allocate the selected render target without advancing gameplay or taking focus. + private static Task WaitForReadback(GameViewApi api, EditorWindow view, object groupType, + int selectedIndex, int width, int height) + { + var completion = new TaskCompletionSource(); + double started = EditorApplication.timeSinceStartup; + int settledUpdates = 0; + EditorApplication.CallbackFunction update = null; + void Finish(object result) + { + EditorApplication.update -= update; + AssemblyReloadEvents.beforeAssemblyReload -= Interrupted; + EditorApplication.quitting -= Interrupted; + _settingSize = false; + completion.TrySetResult(result); + } + void Interrupted() + { + Finish(new ErrorResponse("game_view_size_interrupted", new + { + preset_may_have_changed = true, + message = "Editor shutdown or assembly reload interrupted readback. Query the Game View size after reconnecting." + })); + } + update = () => + { + try + { + if (view == null || !Equals(api.CurrentGroupType, groupType) + || api.SelectedIndex(view) != selectedIndex) + { + Finish(new ErrorResponse("game_view_size_changed", new + { + preset_may_have_changed = true, + message = "The Game View closed, its platform group changed, or another preset was selected while waiting." + })); + return; + } + JObject state = api.Read(view, width, height); + if (state.Value("mode") != "FixedResolution" + || (int)state["selected_size"]["width"] != width + || (int)state["selected_size"]["height"] != height) + { + state["preset_may_have_changed"] = true; + state["message"] = "The selected preset was edited while waiting for render-target readback."; + Finish(new ErrorResponse("game_view_size_changed", state)); + return; + } + settledUpdates = state.Value("settled") ? settledUpdates + 1 : 0; + if (settledUpdates >= 2) + { + bool matches = state.Value("matches_requested"); + Finish(new SuccessResponse(matches + ? "Fixed Game View size applied and render-target dimensions verified." + : "Preset applied, but Unity limited the render-target dimensions. Check render_size before verifying the layout.", state)); + } + else if (EditorApplication.timeSinceStartup - started >= ReadbackTimeoutSeconds) + { + state["preset_may_have_changed"] = true; + state["message"] = "The preset was applied, but render-target readback did not settle. Make the Game View visible and query its size again."; + Finish(new ErrorResponse("game_view_size_readback_timeout", state)); + } + else + { + view.Repaint(); + } + } + catch (Exception ex) + { + Finish(Failure(ex, true)); + } + }; + EditorApplication.update += update; + AssemblyReloadEvents.beforeAssemblyReload += Interrupted; + EditorApplication.quitting += Interrupted; + view.Repaint(); + return completion.Task; + } + + private static ErrorResponse Failure(Exception ex, bool changed) + { + if (ex is TargetInvocationException invocation && invocation.InnerException != null) + ex = invocation.InnerException; + return new ErrorResponse(ex is MissingMemberException || ex is TypeLoadException + ? "game_view_size_unsupported" : "game_view_size_unavailable", new + { + message = ex.Message, + unity_version = Application.unityVersion, + preset_may_have_changed = changed + }); + } + + /// Resolves the complete internal API before changing any preset. + internal sealed class GameViewApi + { + private const BindingFlags Instance = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic; + private readonly Type _viewType; + private readonly object _sizes; + private readonly PropertyInfo _currentGroupType, _selectedIndex, _targetRenderSize; + private readonly PropertyInfo _baseText, _width, _height, _sizeType; + private readonly FieldInfo _targetTexture; + private readonly MethodInfo _getGroup, _getSize, _getTotalCount, _getBuiltinCount, _addSize, _save; + private readonly MethodInfo _updateZoom; + private readonly PropertyInfo _targetSize; + private readonly ConstructorInfo _newSize; + private readonly object _fixedResolution; + + internal GameViewApi() + { + var assembly = typeof(EditorWindow).Assembly; + _viewType = assembly.GetType("UnityEditor.GameView", true); + var sizesType = assembly.GetType("UnityEditor.GameViewSizes", true); + var groupType = assembly.GetType("UnityEditor.GameViewSizeGroup", true); + var sizeType = assembly.GetType("UnityEditor.GameViewSize", true); + var kindType = assembly.GetType("UnityEditor.GameViewSizeType", true); + var playModeViewType = assembly.GetType("UnityEditor.PlayModeView", true); + var singleton = sizesType.BaseType.GetProperty("instance", BindingFlags.Static | BindingFlags.Public); + if (singleton == null) throw new MissingMemberException(sizesType.FullName, "instance"); + _sizes = singleton.GetValue(null); + _currentGroupType = RequireProperty(sizesType, "currentGroupType"); + _selectedIndex = RequireProperty(_viewType, "selectedSizeIndex", true); + _targetRenderSize = RequireProperty(_viewType, "targetRenderSize"); + _targetSize = RequireProperty(playModeViewType, "targetSize", true); + _baseText = RequireProperty(sizeType, "baseText", true); + _width = RequireProperty(sizeType, "width", true); + _height = RequireProperty(sizeType, "height", true); + _sizeType = RequireProperty(sizeType, "sizeType"); + _targetTexture = playModeViewType.GetField("m_TargetTexture", Instance); + if (_targetTexture == null || _targetTexture.FieldType != typeof(RenderTexture)) + throw new MissingMemberException(playModeViewType.FullName, "m_TargetTexture"); + _getGroup = RequireMethod(sizesType, "GetGroup", _currentGroupType.PropertyType); + _getSize = RequireMethod(groupType, "GetGameViewSize", typeof(int)); + _getTotalCount = RequireMethod(groupType, "GetTotalCount"); + _getBuiltinCount = RequireMethod(groupType, "GetBuiltinCount"); + _addSize = RequireMethod(groupType, "AddCustomSize", sizeType); + _save = RequireMethod(sizesType, "SaveToHDD"); + _updateZoom = RequireMethod(_viewType, "UpdateZoomAreaAndParent"); + _newSize = sizeType.GetConstructor(new[] { kindType, typeof(int), typeof(int), typeof(string) }); + if (_newSize == null) throw new MissingMethodException(sizeType.FullName, ".ctor"); + _fixedResolution = Enum.Parse(kindType, "FixedResolution"); + } + + internal static PropertyInfo RequireProperty(Type type, string name, bool writable = false) + { + var property = type.GetProperty(name, Instance); + if (property?.GetGetMethod(true) == null || (writable && property.GetSetMethod(true) == null)) + throw new MissingMemberException(type.FullName, name); + return property; + } + + private static MethodInfo RequireMethod(Type type, string name, params Type[] arguments) + { + return type.GetMethod(name, Instance, null, arguments, null) + ?? throw new MissingMethodException(type.FullName, name); + } + + internal object CurrentGroupType => _currentGroupType.GetValue(_sizes); + internal int SelectedIndex(EditorWindow view) => (int)_selectedIndex.GetValue(view); + + internal EditorWindow FindView() + { + if (Application.isBatchMode) + throw new InvalidOperationException("Game View size actions require a graphical Editor, not batch mode."); + var views = UnityEngine.Resources.FindObjectsOfTypeAll(_viewType).Cast().ToArray(); + if (EditorWindow.focusedWindow != null && _viewType.IsInstanceOfType(EditorWindow.focusedWindow)) + return EditorWindow.focusedWindow; + if (views.Length == 1) return views[0]; + throw new InvalidOperationException(views.Length == 0 + ? "Open a Game View before reading or setting its size." + : "Multiple Game Views are open. Focus the one to read or resize."); + } + + /// Change only the exact preset recorded as ours in shared Editor preferences. + internal int SetOwnedPreset(object groupType, int width, int height) + { + object group = _getGroup.Invoke(_sizes, new[] { groupType }); + string key = OwnershipKeyPrefix + groupType; + JObject ownership = null; + try { ownership = JObject.Parse(EditorPrefs.GetString(key, "{}")); } + catch (Newtonsoft.Json.JsonException) { /* A damaged ownership record must not authorize overwriting a preset. */ } + int total = (int)_getTotalCount.Invoke(group, null); + int builtin = (int)_getBuiltinCount.Invoke(group, null); + int index = -1; + object ownedSize = null; + for (int i = builtin; i < total; i++) + { + object size = _getSize.Invoke(group, new object[] { i }); + if (ownership != null && (string)_baseText.GetValue(size) == (string)ownership["name"] + && JToken.DeepEquals(JToken.FromObject(_width.GetValue(size)), ownership["width"]) + && JToken.DeepEquals(JToken.FromObject(_height.GetValue(size)), ownership["height"]) + && Equals(_sizeType.GetValue(size), _fixedResolution)) + { + if (ownedSize != null) + throw new InvalidOperationException("The MCP-owned preset was duplicated. Remove the duplicate in the Game View menu before resizing."); + ownedSize = size; + index = i; + } + } + string name = $"MCP {width}x{height} [{Guid.NewGuid().ToString("N").Substring(0, 8)}]"; + if (ownedSize == null) + { + ownedSize = _newSize.Invoke(new object[] { _fixedResolution, width, height, name }); + index = total; + _addSize.Invoke(group, new[] { ownedSize }); + } + else + { + if ((int)_width.GetValue(ownedSize) == width && (int)_height.GetValue(ownedSize) == height) + return index; + _width.SetValue(ownedSize, width); + _height.SetValue(ownedSize, height); + _baseText.SetValue(ownedSize, name); + } + // Track the exact name and dimensions, not a prefix or a shifting index. + // If a user edits that preset later, preserve it and create a new owned entry. + EditorPrefs.SetString(key, new JObject { ["name"] = name, ["width"] = width, ["height"] = height }.ToString()); + _save.Invoke(_sizes, null); + return index; + } + + internal void Select(EditorWindow view, int index) + { + // Unity's normal selection callback skips refresh when the index is + // unchanged, which is precisely the case when reusing our preset. + _selectedIndex.SetValue(view, index); + _updateZoom.Invoke(view, null); + _targetSize.SetValue(view, _targetRenderSize.GetValue(view)); + view.Repaint(); + } + + internal JObject Read(EditorWindow view, int? width = null, int? height = null) + { + object groupType = CurrentGroupType; + int index = SelectedIndex(view); + object group = _getGroup.Invoke(_sizes, new[] { groupType }); + object size = _getSize.Invoke(group, new object[] { index }); + Vector2 target = (Vector2)_targetRenderSize.GetValue(view); + var texture = _targetTexture.GetValue(view) as RenderTexture; + var targetSize = Dimensions((int)Math.Round(target.x, MidpointRounding.AwayFromZero), + (int)Math.Round(target.y, MidpointRounding.AwayFromZero)); + JObject renderSize = texture != null && texture.IsCreated() ? Dimensions(texture.width, texture.height) : null; + bool settled = renderSize != null && JToken.DeepEquals(renderSize, targetSize); + JObject requested = width.HasValue ? Dimensions(width.Value, height.Value) : null; + return new JObject + { + ["window_id"] = view.GetInstanceID(), + ["platform_group"] = groupType.ToString(), + ["selected_index"] = index, + ["preset_name"] = (string)_baseText.GetValue(size), + ["mode"] = _sizeType.GetValue(size).ToString(), + ["selected_size"] = Dimensions((int)_width.GetValue(size), (int)_height.GetValue(size)), + ["requested_size"] = requested, + ["target_size"] = targetSize, + ["render_size"] = renderSize, + ["settled"] = settled, + ["matches_requested"] = requested == null ? JValue.CreateNull() : new JValue(settled && JToken.DeepEquals(renderSize, requested)) + }; + } + + private static JObject Dimensions(int width, int height) => new JObject { ["width"] = width, ["height"] = height }; + } + } +} diff --git a/MCPForUnity/Editor/Helpers/GameViewSizeControl.cs.meta b/MCPForUnity/Editor/Helpers/GameViewSizeControl.cs.meta new file mode 100644 index 000000000..df120b856 --- /dev/null +++ b/MCPForUnity/Editor/Helpers/GameViewSizeControl.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 5eaac672eabc40a89a2218a3a649e5c8 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Tools/ManageEditor.cs b/MCPForUnity/Editor/Tools/ManageEditor.cs index 6d2b27be7..5b6377e6c 100644 --- a/MCPForUnity/Editor/Tools/ManageEditor.cs +++ b/MCPForUnity/Editor/Tools/ManageEditor.cs @@ -43,12 +43,20 @@ public static object HandleCommand(JObject @params) } string action = actionResult.Value.ToLowerInvariant(); + if (action != "set_game_view_size" && (p.Has("width") || p.Has("height"))) + return new ErrorResponse("width and height are only valid for set_game_view_size."); + // Parameters for specific actions string tagName = p.Get("tagName"); string layerName = p.Get("layerName"); // Route action switch (action) { + case "get_game_view_size": + return GameViewSizeControl.Get(); + case "set_game_view_size": + return GameViewSizeControl.Set(p); + // Play Mode Control case "play": try @@ -171,7 +179,7 @@ public static object HandleCommand(JObject @params) default: return new ErrorResponse( - $"Unknown action: '{action}'. Supported actions: play, pause, stop, set_active_tool, add_tag, remove_tag, add_layer, remove_layer, deploy_package, restore_package, undo, redo. For prefab editing (open/save/close prefab stage), use manage_prefabs. Use MCP resources for reading editor state, project info, tags, layers, selection, windows, prefab stage, and active tool." + $"Unknown action: '{action}'. Supported actions: play, pause, stop, set_active_tool, add_tag, remove_tag, add_layer, remove_layer, deploy_package, restore_package, undo, redo, get_game_view_size, set_game_view_size. For prefab editing (open/save/close prefab stage), use manage_prefabs. Use MCP resources for reading editor state, project info, tags, layers, selection, windows, prefab stage, and active tool." ); } } diff --git a/Server/src/cli/commands/editor.py b/Server/src/cli/commands/editor.py index 5b0dce795..342d99c6a 100644 --- a/Server/src/cli/commands/editor.py +++ b/Server/src/cli/commands/editor.py @@ -50,6 +50,35 @@ def stop(): print_success("Stopped play mode") +@editor.command("game-view-size") +@click.option("--width", type=click.IntRange(10, 8192), default=None, + help="Fixed width in pixels. Supply with --height to set the size.") +@click.option("--height", type=click.IntRange(10, 8192), default=None, + help="Fixed height in pixels. Supply with --width to set the size.") +@handle_unity_errors +def game_view_size(width: Optional[int], height: Optional[int]): + """Read or set the open Game View's fixed render resolution. + + Requires a focused Game View, or exactly one open Game View. Make it visible + before setting dimensions so Unity can repaint and verify its render target. Reuses one + MCP-owned preset per platform group in shared Editor preferences; other + presets and focus stay intact. Actual size can be limited by the GPU. + + \b + Examples: + unity-mcp editor game-view-size + unity-mcp editor game-view-size --width 2400 --height 1080 + """ + if (width is None) != (height is None): + raise click.UsageError("Supply both --width and --height, or neither to read the size.") + config = get_config() + params: dict[str, Any] = {"action": "get_game_view_size"} + if width is not None: + params.update(action="set_game_view_size", width=width, height=height) + result = run_command("manage_editor", params, config) + click.echo(format_output(result, config.format)) + + @editor.command("console") @click.option( "--type", "-t", diff --git a/Server/src/services/tools/manage_editor.py b/Server/src/services/tools/manage_editor.py index ef8fc667f..0be95c6a5 100644 --- a/Server/src/services/tools/manage_editor.py +++ b/Server/src/services/tools/manage_editor.py @@ -2,6 +2,7 @@ from fastmcp import Context from mcp.types import ToolAnnotations +from pydantic import Field from services.registry import mcp_for_unity_tool from core.telemetry import is_telemetry_enabled, record_tool_usage @@ -10,7 +11,7 @@ from transport.legacy.unity_connection import async_send_command_with_retry @mcp_for_unity_tool( - description="Controls and queries the Unity editor's state and settings. Read-only actions: telemetry_status, telemetry_ping. Modifying actions: play, pause, stop, set_active_tool, add_tag, remove_tag, add_layer, remove_layer, deploy_package, restore_package, undo, redo. For prefab editing (open/save/close prefab stage), use manage_prefabs. deploy_package copies the configured MCPForUnity source folder into the project's installed package location (triggers recompile, no confirmation dialog). restore_package reverts to the pre-deployment backup. undo/redo perform Unity editor undo/redo and return the affected group name.", + description="Controls and queries the Unity editor's state and settings. Read-only actions: telemetry_status, telemetry_ping, get_game_view_size. Modifying actions: play, pause, stop, set_active_tool, add_tag, remove_tag, add_layer, remove_layer, deploy_package, restore_package, undo, redo, set_game_view_size. Game View size actions require a graphical Editor and an open Game View (focused, or the only open Game View). Make it visible before setting its size so Unity can repaint. set_game_view_size requires integer width and height (10..8192); it reuses one MCP-owned preset per platform group in shared Editor preferences and waits for render-target size readback. It preserves other presets and does not change focus. Read requested_size, target_size, render_size and settled separately: GPU limits can reduce the actual size; a timeout can leave the new preset selected. For prefab editing (open/save/close prefab stage), use manage_prefabs. deploy_package copies the configured MCPForUnity source folder into the project's installed package location (triggers recompile, no confirmation dialog). restore_package reverts to the pre-deployment backup. undo/redo perform Unity editor undo/redo and return the affected group name.", annotations=ToolAnnotations( title="Manage Editor", readOnlyHint=False, @@ -19,13 +20,17 @@ ) async def manage_editor( ctx: Context, - action: Annotated[Literal["telemetry_status", "telemetry_ping", "play", "pause", "stop", "set_active_tool", "add_tag", "remove_tag", "add_layer", "remove_layer", "deploy_package", "restore_package", "undo", "redo"], "Get and update the Unity Editor state. deploy_package copies the configured MCPForUnity source into the project's package location (triggers recompile). restore_package reverts the last deployment from backup. undo/redo perform editor undo/redo. For prefab editing (open/save/close prefab stage), use manage_prefabs."], + action: Annotated[Literal["telemetry_status", "telemetry_ping", "play", "pause", "stop", "set_active_tool", "add_tag", "remove_tag", "add_layer", "remove_layer", "deploy_package", "restore_package", "undo", "redo", "get_game_view_size", "set_game_view_size"], "Get and update the Unity Editor state. deploy_package copies the configured MCPForUnity source into the project's package location (triggers recompile). restore_package reverts the last deployment from backup. undo/redo perform editor undo/redo. For prefab editing (open/save/close prefab stage), use manage_prefabs."], tool_name: Annotated[str, "Tool name when setting active tool"] | None = None, tag_name: Annotated[str, "Tag name when adding and removing tags"] | None = None, layer_name: Annotated[str, "Layer name when adding and removing layers"] | None = None, + width: Annotated[int, Field(strict=True, ge=10, le=8192), + "Fixed Game View width in pixels (10..8192), required for set_game_view_size"] | None = None, + height: Annotated[int, Field(strict=True, ge=10, le=8192), + "Fixed Game View height in pixels (10..8192), required for set_game_view_size"] | None = None, ) -> dict[str, Any]: # Get active instance from request state (injected by middleware) unity_instance = await get_unity_instance_from_context(ctx) @@ -39,12 +44,21 @@ async def manage_editor( record_tool_usage("diagnostic_ping", True, 1.0, None) return {"success": True, "message": "telemetry ping queued"} + if action == "set_game_view_size": + for name, value in (("width", width), ("height", height)): + if type(value) is not int or not 10 <= value <= 8192: + return {"success": False, "message": f"{name} must be an integer between 10 and 8192."} + elif width is not None or height is not None: + return {"success": False, "message": "width and height are only valid for set_game_view_size."} + # Prepare parameters, removing None values params = { "action": action, "toolName": tool_name, "tagName": tag_name, "layerName": layer_name, + "width": width, + "height": height, } params = {k: v for k, v in params.items() if v is not None} diff --git a/Server/tests/test_cli.py b/Server/tests/test_cli.py index 689c4d4a9..06a4785d9 100644 --- a/Server/tests/test_cli.py +++ b/Server/tests/test_cli.py @@ -1596,3 +1596,31 @@ def test_texture_sprite_color_and_pattern_precedence(self, runner, mock_unity_re if __name__ == "__main__": pytest.main([__file__, "-v"]) + + +class TestGameViewSizeCommand: + def test_get_size(self, runner, mock_unity_response): + with patch("cli.commands.editor.run_command", return_value=mock_unity_response) as run: + result = runner.invoke(cli, ["editor", "game-view-size"]) + assert result.exit_code == 0, result.output + assert run.call_args.args[:2] == ("manage_editor", {"action": "get_game_view_size"}) + + @pytest.mark.parametrize("width,height", [(2400, 1080), (1080, 2400), (10, 8192)]) + def test_set_size(self, runner, mock_unity_response, width, height): + with patch("cli.commands.editor.run_command", return_value=mock_unity_response) as run: + result = runner.invoke(cli, ["editor", "game-view-size", "--width", str(width), "--height", str(height)]) + assert result.exit_code == 0, result.output + assert run.call_args.args[:2] == ("manage_editor", {"action": "set_game_view_size", "width": width, "height": height}) + + @pytest.mark.parametrize("options", [ + ["--width", "2400"], ["--height", "1080"], + ["--width", "9", "--height", "1080"], + ["--width", "2400", "--height", "8193"], + ["--width", "1080.5", "--height", "1080"], + ["--width", "true", "--height", "1080"], + ]) + def test_invalid_size_does_not_contact_unity(self, runner, options): + with patch("cli.commands.editor.run_command") as run: + result = runner.invoke(cli, ["editor", "game-view-size", *options]) + assert result.exit_code != 0 + run.assert_not_called() diff --git a/Server/tests/test_manage_editor.py b/Server/tests/test_manage_editor.py index bcfc2851b..a88febc46 100644 --- a/Server/tests/test_manage_editor.py +++ b/Server/tests/test_manage_editor.py @@ -95,3 +95,58 @@ def test_undo_omits_none_params(mock_unity): assert "layerName" not in params + + +@pytest.mark.parametrize("width,height", [(2400, 1080), (1080, 2400), (10, 10), (8192, 8192)]) +def test_set_game_view_size_forwards_dimensions_and_instance(mock_unity, width, height): + result = asyncio.run(manage_editor(SimpleNamespace(), action="set_game_view_size", width=width, height=height)) + assert result["success"] is True + assert mock_unity["unity_instance"] == "unity-instance-1" + assert mock_unity["params"] == {"action": "set_game_view_size", "width": width, "height": height} + + +@pytest.mark.parametrize("invalid", [None, 0, -1, 9, 8193, 2**63, True, 1080.5, "1080"]) +@pytest.mark.parametrize("dimension", ["width", "height"]) +def test_set_game_view_size_rejects_invalid_before_transport(mock_unity, dimension, invalid): + dimensions = {"width": 2400, "height": 1080, dimension: invalid} + result = asyncio.run(manage_editor(SimpleNamespace(), action="set_game_view_size", **dimensions)) + assert result["success"] is False + assert dimension in result["message"] + assert "params" not in mock_unity + + +def test_get_game_view_size_forwards_without_dimensions(mock_unity): + result = asyncio.run(manage_editor(SimpleNamespace(), action="get_game_view_size")) + assert result["success"] is True + assert mock_unity["params"] == {"action": "get_game_view_size"} + + +@pytest.mark.parametrize("action", ["get_game_view_size", "play"]) +def test_dimensions_are_not_silently_ignored(mock_unity, action): + result = asyncio.run(manage_editor(SimpleNamespace(), action=action, width=2400, height=1080)) + assert result["success"] is False + assert "params" not in mock_unity + + +@pytest.mark.parametrize("response", [ + {"success": True, "data": {"requested_size": {"width": 2400, "height": 1080}, "render_size": {"width": 1200, "height": 540}, "matches_requested": False}}, + {"success": False, "code": "game_view_size_readback_timeout", "data": {"preset_may_have_changed": True, "settled": False}}, +]) +def test_size_readback_and_partial_failure_are_preserved(monkeypatch, mock_unity, response): + monkeypatch.setattr(manage_editor_mod, "send_with_unity_instance", AsyncMock(return_value=response)) + result = asyncio.run(manage_editor(SimpleNamespace(), action="set_game_view_size", width=2400, height=1080)) + assert result["success"] == response["success"] + assert result["data"] == response["data"] + if not response["success"]: + assert result["code"] == response["code"] + + +def test_dimension_schema_rejects_coercion(): + from typing import get_type_hints + from pydantic import TypeAdapter, ValidationError + + adapter = TypeAdapter(get_type_hints(manage_editor, include_extras=True)["width"]) + for invalid in [True, 1080.0, "1080", 9, 8193]: + with pytest.raises(ValidationError): + adapter.validate_python(invalid) + assert adapter.validate_python(2400) == 2400 diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/GameViewSizeControlTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/GameViewSizeControlTests.cs new file mode 100644 index 000000000..c043289c2 --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/GameViewSizeControlTests.cs @@ -0,0 +1,287 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Threading.Tasks; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Tools; +using Newtonsoft.Json.Linq; +using NUnit.Framework; +using UnityEditor; +using UnityEngine; +using UnityEngine.TestTools; + +namespace MCPForUnity.Tests.EditMode.Tools +{ + public class GameViewSizeValidationTests + { + [TestCase("null")] + [TestCase("0")] + [TestCase("9")] + [TestCase("8193")] + [TestCase("9223372036854775808")] + [TestCase("1080.5")] + [TestCase("true")] + [TestCase("\"1080\"")] + public void InvalidDimensionsFailBeforeEditorAccess(string invalid) + { + foreach (string dimension in new[] { "width", "height" }) + { + var args = new JObject { ["action"] = "set_game_view_size", ["width"] = 2400, ["height"] = 1080 }; + args[dimension] = JToken.Parse(invalid); + var result = JObject.FromObject(ManageEditor.HandleCommand(args)); + Assert.IsFalse(result.Value("success")); + StringAssert.Contains(dimension, result.Value("error")); + } + } + + [Test] + public void MissingDimensionsFailBeforeEditorAccess() + { + var result = JObject.FromObject(ManageEditor.HandleCommand(new JObject { ["action"] = "set_game_view_size" })); + Assert.IsFalse(result.Value("success")); + StringAssert.Contains("width", result.Value("error")); + } + + [Test] + public void DimensionsOnReadAreRejected() + { + var result = JObject.FromObject(ManageEditor.HandleCommand(new JObject { ["action"] = "get_game_view_size", ["width"] = 2400 })); + Assert.IsFalse(result.Value("success")); + } + + [Test] + public void MissingInternalMemberFailsExplicitly() + { + var error = Assert.Throws(() => + GameViewSizeControl.GameViewApi.RequireProperty(typeof(EditorWindow), "NoSuchGameViewProperty")); + StringAssert.Contains("NoSuchGameViewProperty", error.Message); + } + } + + /// + /// Real internal-API tests. Restore both the in-memory singleton and its exact + /// preference file, since a disposable project alone does not isolate presets. + /// + public class GameViewSizeControlTests + { + private const BindingFlags Instance = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic; + private GameViewSizeControl.GameViewApi _api; + private UnityEngine.Object _sizes; + private string _sizesJson, _filePath; + private byte[] _fileBytes; + private readonly Dictionary _preferences = new Dictionary(); + private readonly Dictionary _selections = new Dictionary(); + private readonly Dictionary _zoomStates = new Dictionary(); + private EditorWindow _createdView, _previousFocus; + private GameObject _camera; + private Type _sizesType, _viewType; + + [SetUp] + public void SnapshotPreferences() + { + _api = new GameViewSizeControl.GameViewApi(); + var assembly = typeof(EditorWindow).Assembly; + _sizesType = assembly.GetType("UnityEditor.GameViewSizes", true); + _viewType = assembly.GetType("UnityEditor.GameView", true); + _sizes = (UnityEngine.Object)_sizesType.BaseType.GetProperty("instance", BindingFlags.Public | BindingFlags.Static).GetValue(null); + _sizesJson = EditorJsonUtility.ToJson(_sizes); + _filePath = (string)_sizesType.BaseType.GetMethod("GetFilePath", BindingFlags.NonPublic | BindingFlags.Static).Invoke(null, null); + _fileBytes = File.Exists(_filePath) ? File.ReadAllBytes(_filePath) : null; + foreach (string group in Enum.GetNames(_api.CurrentGroupType.GetType())) + { + string key = GameViewSizeControl.OwnershipKeyPrefix + group; + _preferences[key] = EditorPrefs.HasKey(key) ? EditorPrefs.GetString(key) : null; + } + foreach (var view in Resources.FindObjectsOfTypeAll(_viewType).Cast()) + { + _api.SelectedIndex(view); + _selections[view] = (int[])((int[])_viewType.GetField("m_SelectedSizes", Instance).GetValue(view)).Clone(); + _zoomStates[view] = JsonUtility.ToJson(_viewType.GetField("m_ZoomArea", Instance).GetValue(view)); + } + _previousFocus = EditorWindow.focusedWindow; + } + + [TearDown] + public void RestorePreferences() + { + if (_createdView != null) _createdView.Close(); + if (_camera != null) UnityEngine.Object.DestroyImmediate(_camera); + if (_sizes != null) + { + EditorJsonUtility.FromJsonOverwrite(_sizesJson, _sizes); + _sizesType.GetMethod("Changed", Instance).Invoke(_sizes, null); + foreach (var selection in _selections) + { + if (selection.Key != null) + { + _viewType.GetField("m_SelectedSizes", Instance).SetValue(selection.Key, selection.Value); + _api.Select(selection.Key, _api.SelectedIndex(selection.Key)); + JsonUtility.FromJsonOverwrite(_zoomStates[selection.Key], _viewType.GetField("m_ZoomArea", Instance).GetValue(selection.Key)); + } + } + if (_fileBytes != null) File.WriteAllBytes(_filePath, _fileBytes); + else if (File.Exists(_filePath)) File.Delete(_filePath); + } + foreach (var preference in _preferences) + { + if (preference.Value == null) EditorPrefs.DeleteKey(preference.Key); + else EditorPrefs.SetString(preference.Key, preference.Value); + } + _preferences.Clear(); + _selections.Clear(); + _zoomStates.Clear(); + if (_previousFocus != null) _previousFocus.Focus(); + } + + private object Group(object groupType) => _sizesType.GetMethod("GetGroup", Instance).Invoke(_sizes, new[] { groupType }); + private static int Count(object group) => (int)group.GetType().GetMethod("GetTotalCount", Instance).Invoke(group, null); + private static object Size(object group, int index) => group.GetType().GetMethod("GetGameViewSize", Instance).Invoke(group, new object[] { index }); + private static string SizeJson(object size) => Newtonsoft.Json.JsonConvert.SerializeObject(size); + + [Test] + public void RepeatedAndChangedDimensionsReuseOnePresetAndPreserveOtherPresets() + { + object groupType = _api.CurrentGroupType; + object group = Group(groupType); + EditorPrefs.DeleteKey(GameViewSizeControl.OwnershipKeyPrefix + groupType); + int before = Count(group); + string[] originals = Enumerable.Range(0, before).Select(i => SizeJson(Size(group, i))).ToArray(); + int index = _api.SetOwnedPreset(groupType, 2400, 1080); + Assert.AreEqual(before, index); + Assert.AreEqual(before + 1, Count(group)); + Assert.AreEqual(index, _api.SetOwnedPreset(groupType, 2400, 1080)); + Assert.AreEqual(index, _api.SetOwnedPreset(groupType, 1080, 2400)); + Assert.AreEqual(before + 1, Count(group)); + CollectionAssert.AreEqual(originals, Enumerable.Range(0, before).Select(i => SizeJson(Size(group, i))).ToArray()); + var size = Size(group, index); + Assert.AreEqual(1080, size.GetType().GetProperty("width").GetValue(size)); + StringAssert.StartsWith("MCP 1080x2400 [", (string)size.GetType().GetProperty("baseText").GetValue(size)); + } + + [Test] + public void UserEditedOwnedPresetIsPreserved() + { + object groupType = _api.CurrentGroupType; + object group = Group(groupType); + int index = _api.SetOwnedPreset(groupType, 2400, 1080); + object size = Size(group, index); + size.GetType().GetProperty("baseText").SetValue(size, "MCP user preset"); + string edited = SizeJson(size); + int count = Count(group); + int replacement = _api.SetOwnedPreset(groupType, 1080, 2400); + Assert.AreEqual(count, replacement); + Assert.AreEqual(count + 1, Count(group)); + Assert.AreEqual(edited, SizeJson(Size(group, index))); + } + + [Test] + public void PlatformGroupsHaveIndependentOwnership() + { + var kind = _api.CurrentGroupType.GetType(); + var standalone = Enum.Parse(kind, "Standalone"); + var android = Enum.Parse(kind, "Android"); + int first = _api.SetOwnedPreset(standalone, 2400, 1080); + string standaloneState = SizeJson(Size(Group(standalone), first)); + _api.SetOwnedPreset(android, 1080, 2400); + Assert.AreEqual(standaloneState, SizeJson(Size(Group(standalone), first))); + Assert.IsTrue(EditorPrefs.HasKey(GameViewSizeControl.OwnershipKeyPrefix + "Standalone")); + Assert.IsTrue(EditorPrefs.HasKey(GameViewSizeControl.OwnershipKeyPrefix + "Android")); + } + + [Test] + public void CorruptOwnershipDoesNotAuthorizeOverwriting() + { + object groupType = _api.CurrentGroupType; + object group = Group(groupType); + EditorPrefs.SetString(GameViewSizeControl.OwnershipKeyPrefix + groupType, "not-json"); + int before = Count(group); + string[] originals = Enumerable.Range(0, before).Select(i => SizeJson(Size(group, i))).ToArray(); + _api.SetOwnedPreset(groupType, 2400, 1080); + CollectionAssert.AreEqual(originals, Enumerable.Range(0, before).Select(i => SizeJson(Size(group, i))).ToArray()); + } + + [UnityTest] + public IEnumerator LandscapeThenPortraitReadsActualAllocatedTexture() + { + if (Application.isBatchMode) Assert.Ignore("Requires a visible graphical Game View and GPU-backed render texture."); + _createdView = (EditorWindow)ScriptableObject.CreateInstance(_viewType); + _createdView.Show(); + _createdView.Focus(); + _camera = new GameObject("MCP Game View size test camera", typeof(Camera)); + yield return null; + foreach (var dimensions in new[] { new Vector2Int(2400, 1080), new Vector2Int(1080, 2400) }) + { + _createdView.Focus(); + var task = ManageEditor.HandleCommand(new JObject + { + ["action"] = "set_game_view_size", ["width"] = dimensions.x, ["height"] = dimensions.y + }) as Task; + Assert.IsNotNull(task, "Valid set action must wait for render-target readback."); + while (!task.IsCompleted) yield return null; + var response = JObject.FromObject(task.Result); + Assert.IsTrue(response.Value("success"), response.ToString()); + var texture = (RenderTexture)_viewType.BaseType.GetField("m_TargetTexture", Instance).GetValue(_createdView); + Assert.IsNotNull(texture); + Assert.IsTrue(texture.IsCreated()); + Assert.AreEqual(texture.width, (int)response["data"]["render_size"]["width"]); + Assert.AreEqual(texture.height, (int)response["data"]["render_size"]["height"]); + Assert.AreEqual(dimensions.x, texture.width, "Fixture GPU should support the requested landscape/portrait dimensions."); + Assert.AreEqual(dimensions.y, texture.height); + Assert.IsTrue((bool)response["data"]["settled"]); + Assert.IsTrue((bool)response["data"]["matches_requested"]); + Assert.AreSame(_createdView, EditorWindow.focusedWindow, "The operation must not take focus away."); + var read = JObject.FromObject(ManageEditor.HandleCommand(new JObject { ["action"] = "get_game_view_size" })); + Assert.AreEqual(texture.width, (int)read["data"]["render_size"]["width"]); + } + } + + [UnityTest] + public IEnumerator ConcurrentPresetEditIsNotReportedAsGpuClamping() + { + if (Application.isBatchMode) Assert.Ignore("Requires a graphical Editor window."); + _createdView = (EditorWindow)ScriptableObject.CreateInstance(_viewType); + _createdView.Show(); + _createdView.Focus(); + var task = ManageEditor.HandleCommand(new JObject + { + ["action"] = "set_game_view_size", ["width"] = 2400, ["height"] = 1080 + }) as Task; + Assert.IsNotNull(task); + object size = Size(Group(_api.CurrentGroupType), _api.SelectedIndex(_createdView)); + size.GetType().GetProperty("width").SetValue(size, 1024); + while (!task.IsCompleted) yield return null; + var response = JObject.FromObject(task.Result); + Assert.AreEqual("game_view_size_changed", (string)response["code"]); + Assert.IsTrue((bool)response["data"]["preset_may_have_changed"]); + Assert.AreEqual(1024, (int)response["data"]["selected_size"]["width"]); + Assert.IsFalse(response.Value("success")); + } + + [UnityTest] + public IEnumerator ClosedViewInterruptsReadbackAndReleasesBusyState() + { + if (Application.isBatchMode) Assert.Ignore("Requires a graphical Editor window."); + _createdView = (EditorWindow)ScriptableObject.CreateInstance(_viewType); + _createdView.Show(); + _createdView.Focus(); + var task = ManageEditor.HandleCommand(new JObject + { + ["action"] = "set_game_view_size", ["width"] = 2400, ["height"] = 1080 + }) as Task; + Assert.IsNotNull(task); + var busy = JObject.FromObject(ManageEditor.HandleCommand(new JObject + { + ["action"] = "set_game_view_size", ["width"] = 1080, ["height"] = 2400 + })); + Assert.AreEqual("game_view_size_busy", (string)busy["code"]); + _createdView.Close(); + while (!task.IsCompleted) yield return null; + var response = JObject.FromObject(task.Result); + Assert.AreEqual("game_view_size_changed", (string)response["code"]); + Assert.IsTrue((bool)response["data"]["preset_may_have_changed"]); + } + } +} diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/GameViewSizeControlTests.cs.meta b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/GameViewSizeControlTests.cs.meta new file mode 100644 index 000000000..c36ad43cd --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/GameViewSizeControlTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f11cd75e25dc434c999cae68cb5a9208 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/website/docs/guides/cli-examples.md b/website/docs/guides/cli-examples.md index 41618eb43..d0b13e03d 100644 --- a/website/docs/guides/cli-examples.md +++ b/website/docs/guides/cli-examples.md @@ -54,6 +54,8 @@ unity-mcp instance current # Show current instance unity-mcp editor play|pause|stop # Control play mode unity-mcp editor console [--clear] # Get/clear console logs unity-mcp editor refresh [--compile] # Refresh assets +unity-mcp editor game-view-size # Read actual Game View size +unity-mcp editor game-view-size --width 2400 --height 1080 # Set visible Game View unity-mcp editor menu "Edit/Project Settings..." # Execute menu item unity-mcp editor add-tag "TagName" # Add tag unity-mcp editor add-layer "LayerName" # Add layer diff --git a/website/docs/guides/cli.md b/website/docs/guides/cli.md index 9fac634e3..67d2d8079 100644 --- a/website/docs/guides/cli.md +++ b/website/docs/guides/cli.md @@ -157,6 +157,10 @@ unity-mcp editor play unity-mcp editor pause unity-mcp editor stop +# Read or set the visible Game View resolution +unity-mcp editor game-view-size +unity-mcp editor game-view-size --width 2400 --height 1080 + # Refresh assets unity-mcp editor refresh unity-mcp editor refresh --compile @@ -181,6 +185,14 @@ unity-mcp tool list unity-mcp custom_tool list ``` +For fixed Game View dimensions, supply both `--width` and `--height` as integers +from 10 through 8192. Make the Game View visible; when multiple Game Views are +open, focus the intended one. The command reuses an MCP-owned preset in shared +Editor preferences and preserves other presets. Check `render_size`, `settled` +and `matches_requested` in the response: the selected preset alone does not prove +Unity rendered the requested size. A readback timeout can leave the new preset +selected. See [Game View size details](../reference/tools/core/manage_editor.md#verify-a-mobile-layout-at-a-fixed-resolution). + ### Testing ```bash diff --git a/website/docs/reference/tools/core/manage_editor.md b/website/docs/reference/tools/core/manage_editor.md index 61e308760..60b582f42 100644 --- a/website/docs/reference/tools/core/manage_editor.md +++ b/website/docs/reference/tools/core/manage_editor.md @@ -12,16 +12,18 @@ description: "Controls and queries the Unity editor's state and settings." ## Description -Controls and queries the Unity editor's state and settings. Read-only actions: telemetry_status, telemetry_ping. Modifying actions: play, pause, stop, set_active_tool, add_tag, remove_tag, add_layer, remove_layer, deploy_package, restore_package, undo, redo. For prefab editing (open/save/close prefab stage), use manage_prefabs. deploy_package copies the configured MCPForUnity source folder into the project's installed package location (triggers recompile, no confirmation dialog). restore_package reverts to the pre-deployment backup. undo/redo perform Unity editor undo/redo and return the affected group name. +Controls and queries the Unity editor's state and settings. Read-only actions: telemetry_status, telemetry_ping, get_game_view_size. Modifying actions: play, pause, stop, set_active_tool, add_tag, remove_tag, add_layer, remove_layer, deploy_package, restore_package, undo, redo, set_game_view_size. Game View size actions require a graphical Editor and an open Game View (focused, or the only open Game View). Make it visible before setting its size so Unity can repaint. set_game_view_size requires integer width and height (10..8192); it reuses one MCP-owned preset per platform group in shared Editor preferences and waits for render-target size readback. It preserves other presets and does not change focus. Read requested_size, target_size, render_size and settled separately: GPU limits can reduce the actual size; a timeout can leave the new preset selected. For prefab editing (open/save/close prefab stage), use manage_prefabs. deploy_package copies the configured MCPForUnity source folder into the project's installed package location (triggers recompile, no confirmation dialog). restore_package reverts to the pre-deployment backup. undo/redo perform Unity editor undo/redo and return the affected group name. ## Parameters | Name | Type | Required | Description | |------|------|----------|-------------| -| `action` | `Literal['telemetry_status', 'telemetry_ping', 'play', 'pause', 'stop', 'set_active_tool', 'add_tag', 'remove_tag', 'add_layer', 'remove_layer', 'deploy_package', 'restore_package', 'undo', 'redo']` | yes | Get and update the Unity Editor state. deploy_package copies the configured MCPForUnity source into the project's package location (triggers recompile). restore_package reverts the last deployment from backup. undo/redo perform editor undo/redo. For prefab editing (open/save/close prefab stage), use manage_prefabs. | +| `action` | `Literal['telemetry_status', 'telemetry_ping', 'play', 'pause', 'stop', 'set_active_tool', 'add_tag', 'remove_tag', 'add_layer', 'remove_layer', 'deploy_package', 'restore_package', 'undo', 'redo', 'get_game_view_size', 'set_game_view_size']` | yes | Get and update the Unity Editor state. deploy_package copies the configured MCPForUnity source into the project's package location (triggers recompile). restore_package reverts the last deployment from backup. undo/redo perform editor undo/redo. For prefab editing (open/save/close prefab stage), use manage_prefabs. | | `tool_name` | `str \| None` | — | Tool name when setting active tool | | `tag_name` | `str \| None` | — | Tag name when adding and removing tags | | `layer_name` | `str \| None` | — | Layer name when adding and removing layers | +| `width` | `int \| None` | — | Fixed Game View width in pixels (10..8192), required for set_game_view_size | +| `height` | `int \| None` | — | Fixed Game View height in pixels (10..8192), required for set_game_view_size | ## Returns @@ -30,6 +32,45 @@ A `dict` containing the Unity response. The exact shape depends on the action. ## Examples -*No examples yet. Add usage examples here — they will be preserved across regenerations.* +### Verify a mobile layout at a fixed resolution + +Make the Game View visible in a graphical Editor. If multiple Game Views are +open, focus the one to use. These actions never open a window or change focus. + +```python +manage_editor(action="set_game_view_size", width=2400, height=1080) +manage_editor(action="get_game_view_size") +``` + +Width and height must both be integers from 10 through 8192. Unity clamps fixed +presets below 10 pixels and limits Game View render targets to at most 8192; +GPU limits and available memory can reduce the actual target further. + +Check the returned fields before judging the layout: + +- `requested_size`: the dimensions supplied to the set action; null for a read. +- `selected_size`, `preset_name`, `mode`: the selected preset, which can differ + from the rendered result. Reads also work with existing aspect-ratio presets. +- `target_size`: the render target size calculated by Unity after its limits. +- `render_size`: dimensions read from the allocated, created render texture; + null when no render target is available. +- `settled`: whether that texture matches Unity's calculated target. +- `matches_requested`: whether settled render dimensions match the request; + null for a read. A successful set can return false if Unity limited the size. + +The set action waits up to five seconds for readback. A hidden or minimized Game +View may not repaint. On `game_view_size_readback_timeout`, the preset can already +be applied: make the view visible and read its size again. Closing the window, +selecting another preset or changing its platform group interrupts the wait. +The command does not advance frames, enter Play Mode or restore the previous size. + +Presets are stored in Unity's Editor preferences, shared across projects. The tool +reuses one clearly named MCP preset per platform group. Ownership is tracked by +an exact name and dimensions under `MCPForUnity.GameViewSize.` in EditorPrefs; +other presets, including similarly named ones, stay intact. If you rename or +resize the managed preset manually, the next request preserves it and creates a +new managed entry. Removing the ownership preference likewise relinquishes the +old preset. Aspect-ratio setting, choosing user presets by name and temporary +screenshot-size switching are outside these actions.