From a6ad10b01ccd0dacf84841583720cff661510c76 Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Mon, 27 Apr 2026 01:31:07 +0000 Subject: [PATCH 01/20] chore: set beta version to 9.6.9-beta.1 after release v9.6.8 --- MCPForUnity/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/MCPForUnity/package.json b/MCPForUnity/package.json index acd3591b7..f303d645c 100644 --- a/MCPForUnity/package.json +++ b/MCPForUnity/package.json @@ -1,6 +1,6 @@ { "name": "com.coplaydev.unity-mcp", - "version": "9.6.8", + "version": "9.6.9-beta.1", "displayName": "MCP for Unity", "description": "A bridge that connects AI assistants to Unity via the MCP (Model Context Protocol). Allows AI clients like Claude Code, Cursor, and VSCode to directly control your Unity Editor for enhanced development workflows.\n\nFeatures automated setup wizard, cross-platform support, and seamless integration with popular AI development tools.\n\nJoin Our Discord: https://discord.gg/y4p8KfzrN4", "unity": "2021.3", From 56a14af834b38c2782f31d84a37ea4e210ddbc77 Mon Sep 17 00:00:00 2001 From: Kenner Miner Date: Mon, 30 Mar 2026 13:04:09 +0800 Subject: [PATCH 02/20] Fix game_view screenshots to capture UI Toolkit overlays Use ScreenCapture.CaptureScreenshotAsTexture() for game_view screenshots when include_image=true and in Play mode. This captures the final composited frame including UI Toolkit overlays, which camera.Render() misses since UI Toolkit renders at the compositor level after camera rendering. The camera-based path is still used when a specific camera is requested or when not in Play mode. Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude Co-Authored-By: Happy --- MCPForUnity/Editor/Tools/ManageScene.cs | 78 ++++++++++++++++--- .../Runtime/Helpers/ScreenshotUtility.cs | 69 ++++++++++++++++ 2 files changed, 137 insertions(+), 10 deletions(-) diff --git a/MCPForUnity/Editor/Tools/ManageScene.cs b/MCPForUnity/Editor/Tools/ManageScene.cs index 7567a78a2..cbcfa1553 100644 --- a/MCPForUnity/Editor/Tools/ManageScene.cs +++ b/MCPForUnity/Editor/Tools/ManageScene.cs @@ -589,22 +589,80 @@ private static object CaptureScreenshot(SceneCommand cmd) } } - // When a specific camera is requested or include_image is true, always use camera-based capture - // (synchronous, gives us bytes in memory for base64). - if (targetCamera != null || includeImage) + // When include_image is requested but no specific camera, use composited capture + // (ScreenCapture.CaptureScreenshotAsTexture) which captures UI Toolkit overlays. + // When a specific camera IS requested, use camera-based capture. + if (targetCamera != null) { + if (!Application.isBatchMode) EnsureGameView(); + + ScreenshotCaptureResult result = ScreenshotUtility.CaptureFromCameraToAssetsFolder( + targetCamera, fileName, resolvedSuperSize, ensureUniqueFileName: true, + includeImage: includeImage, maxResolution: maxResolution); + + AssetDatabase.ImportAsset(result.AssetsRelativePath, ImportAssetOptions.ForceSynchronousImport); + string message = $"Screenshot captured to '{result.AssetsRelativePath}' (camera: {targetCamera.name})."; + + var data = new Dictionary + { + { "path", result.AssetsRelativePath }, + { "fullPath", result.FullPath }, + { "superSize", result.SuperSize }, + { "isAsync", false }, + { "camera", targetCamera.name }, + { "captureSource", "game_view" }, + }; + if (includeImage && result.ImageBase64 != null) + { + data["imageBase64"] = result.ImageBase64; + data["imageWidth"] = result.ImageWidth; + data["imageHeight"] = result.ImageHeight; + } + return new SuccessResponse(message, data); + } + + if (includeImage && Application.isPlaying) + { + if (!Application.isBatchMode) EnsureGameView(); + + ScreenshotCaptureResult result = ScreenshotUtility.CaptureComposited( + fileName, resolvedSuperSize, ensureUniqueFileName: true, + includeImage: true, maxResolution: maxResolution); + + AssetDatabase.ImportAsset(result.AssetsRelativePath, ImportAssetOptions.ForceSynchronousImport); + string cameraName = Camera.main != null ? Camera.main.name : "composited"; + string message = $"Screenshot captured to '{result.AssetsRelativePath}' (camera: {cameraName})."; + + var data = new Dictionary + { + { "path", result.AssetsRelativePath }, + { "fullPath", result.FullPath }, + { "superSize", result.SuperSize }, + { "isAsync", false }, + { "camera", cameraName }, + { "captureSource", "game_view" }, + }; + if (result.ImageBase64 != null) + { + data["imageBase64"] = result.ImageBase64; + data["imageWidth"] = result.ImageWidth; + data["imageHeight"] = result.ImageHeight; + } + return new SuccessResponse(message, data); + } + + if (includeImage) + { + // Not in play mode — fall back to camera-based capture + targetCamera = Camera.main; if (targetCamera == null) { - targetCamera = Camera.main; - if (targetCamera == null) - { - var allCams = UnityFindObjectsCompat.FindAll(); - targetCamera = allCams.Length > 0 ? allCams[0] : null; - } + var allCams = UnityFindObjectsCompat.FindAll(); + targetCamera = allCams.Length > 0 ? allCams[0] : null; } if (targetCamera == null) { - return new ErrorResponse("No camera found in the scene. Add a Camera to use screenshot with camera or include_image."); + return new ErrorResponse("No camera found in the scene. Add a Camera to use screenshot with include_image outside of Play mode."); } if (!Application.isBatchMode) EnsureGameView(); diff --git a/MCPForUnity/Runtime/Helpers/ScreenshotUtility.cs b/MCPForUnity/Runtime/Helpers/ScreenshotUtility.cs index 188e612ba..93195bc85 100644 --- a/MCPForUnity/Runtime/Helpers/ScreenshotUtility.cs +++ b/MCPForUnity/Runtime/Helpers/ScreenshotUtility.cs @@ -224,6 +224,75 @@ public static ScreenshotCaptureResult CaptureFromCameraToAssetsFolder( return result; } + /// + /// Captures a screenshot using ScreenCapture.CaptureScreenshotAsTexture, which captures the + /// final composited frame including UI Toolkit overlays, post-processing, etc. + /// Falls back to camera-based capture if ScreenCapture is unavailable. + /// + public static ScreenshotCaptureResult CaptureComposited( + string fileName = null, + int superSize = 1, + bool ensureUniqueFileName = true, + bool includeImage = false, + int maxResolution = 0) + { + ScreenshotCaptureResult result = PrepareCaptureResult(fileName, superSize, ensureUniqueFileName, isAsync: false); + Texture2D tex = null; + Texture2D downscaled = null; + string imageBase64 = null; + int imgW = 0, imgH = 0; + try + { + tex = ScreenCapture.CaptureScreenshotAsTexture(result.SuperSize); + if (tex == null) + { + // Fallback to camera-based if ScreenCapture fails + var cam = Camera.main; + if (cam != null) + return CaptureFromCameraToAssetsFolder(cam, fileName, superSize, ensureUniqueFileName, includeImage, maxResolution); + throw new InvalidOperationException("ScreenCapture.CaptureScreenshotAsTexture returned null and no fallback camera available."); + } + + int width = tex.width; + int height = tex.height; + + byte[] png = tex.EncodeToPNG(); + File.WriteAllBytes(result.FullPath, png); + + if (includeImage) + { + int targetMax = maxResolution > 0 ? maxResolution : 640; + if (width > targetMax || height > targetMax) + { + downscaled = DownscaleTexture(tex, targetMax); + byte[] smallPng = downscaled.EncodeToPNG(); + imageBase64 = System.Convert.ToBase64String(smallPng); + imgW = downscaled.width; + imgH = downscaled.height; + } + else + { + imageBase64 = System.Convert.ToBase64String(png); + imgW = width; + imgH = height; + } + } + } + finally + { + DestroyTexture(tex); + DestroyTexture(downscaled); + } + + if (includeImage && imageBase64 != null) + { + return new ScreenshotCaptureResult( + result.FullPath, result.AssetsRelativePath, result.SuperSize, false, + imageBase64, imgW, imgH); + } + return result; + } + /// /// Renders a camera to a Texture2D without saving to disk. Used for multi-angle captures. /// Returns the base64-encoded PNG, downscaled to fit within . From 7aa4315d1633b725b89963f4c575301dd1fb4e70 Mon Sep 17 00:00:00 2001 From: Kenner Miner Date: Mon, 6 Apr 2026 11:10:46 +0800 Subject: [PATCH 03/20] fix: address screenshot PR feedback --- MCPForUnity/Editor/Tools/ManageScene.cs | 79 +++++++------------ .../Runtime/Helpers/ScreenshotUtility.cs | 11 ++- 2 files changed, 38 insertions(+), 52 deletions(-) diff --git a/MCPForUnity/Editor/Tools/ManageScene.cs b/MCPForUnity/Editor/Tools/ManageScene.cs index cbcfa1553..0ed201442 100644 --- a/MCPForUnity/Editor/Tools/ManageScene.cs +++ b/MCPForUnity/Editor/Tools/ManageScene.cs @@ -602,23 +602,7 @@ private static object CaptureScreenshot(SceneCommand cmd) AssetDatabase.ImportAsset(result.AssetsRelativePath, ImportAssetOptions.ForceSynchronousImport); string message = $"Screenshot captured to '{result.AssetsRelativePath}' (camera: {targetCamera.name})."; - - var data = new Dictionary - { - { "path", result.AssetsRelativePath }, - { "fullPath", result.FullPath }, - { "superSize", result.SuperSize }, - { "isAsync", false }, - { "camera", targetCamera.name }, - { "captureSource", "game_view" }, - }; - if (includeImage && result.ImageBase64 != null) - { - data["imageBase64"] = result.ImageBase64; - data["imageWidth"] = result.ImageWidth; - data["imageHeight"] = result.ImageHeight; - } - return new SuccessResponse(message, data); + return new SuccessResponse(message, BuildScreenshotResponseData(result, targetCamera.name, includeImage)); } if (includeImage && Application.isPlaying) @@ -632,23 +616,7 @@ private static object CaptureScreenshot(SceneCommand cmd) AssetDatabase.ImportAsset(result.AssetsRelativePath, ImportAssetOptions.ForceSynchronousImport); string cameraName = Camera.main != null ? Camera.main.name : "composited"; string message = $"Screenshot captured to '{result.AssetsRelativePath}' (camera: {cameraName})."; - - var data = new Dictionary - { - { "path", result.AssetsRelativePath }, - { "fullPath", result.FullPath }, - { "superSize", result.SuperSize }, - { "isAsync", false }, - { "camera", cameraName }, - { "captureSource", "game_view" }, - }; - if (result.ImageBase64 != null) - { - data["imageBase64"] = result.ImageBase64; - data["imageWidth"] = result.ImageWidth; - data["imageHeight"] = result.ImageHeight; - } - return new SuccessResponse(message, data); + return new SuccessResponse(message, BuildScreenshotResponseData(result, cameraName, includeImage: true)); } if (includeImage) @@ -673,23 +641,7 @@ private static object CaptureScreenshot(SceneCommand cmd) AssetDatabase.ImportAsset(result.AssetsRelativePath, ImportAssetOptions.ForceSynchronousImport); string message = $"Screenshot captured to '{result.AssetsRelativePath}' (camera: {targetCamera.name})."; - - var data = new Dictionary - { - { "path", result.AssetsRelativePath }, - { "fullPath", result.FullPath }, - { "superSize", result.SuperSize }, - { "isAsync", false }, - { "camera", targetCamera.name }, - { "captureSource", "game_view" }, - }; - if (includeImage && result.ImageBase64 != null) - { - data["imageBase64"] = result.ImageBase64; - data["imageWidth"] = result.ImageWidth; - data["imageHeight"] = result.ImageHeight; - } - return new SuccessResponse(message, data); + return new SuccessResponse(message, BuildScreenshotResponseData(result, targetCamera.name, includeImage)); } // Default path: use ScreenCapture API if available, camera fallback otherwise @@ -746,6 +698,31 @@ private static object CaptureScreenshot(SceneCommand cmd) } } + private static Dictionary BuildScreenshotResponseData( + ScreenshotCaptureResult result, + string cameraName, + bool includeImage) + { + var data = new Dictionary + { + { "path", result.AssetsRelativePath }, + { "fullPath", result.FullPath }, + { "superSize", result.SuperSize }, + { "isAsync", false }, + { "camera", cameraName }, + { "captureSource", "game_view" }, + }; + + if (includeImage && result.ImageBase64 != null) + { + data["imageBase64"] = result.ImageBase64; + data["imageWidth"] = result.ImageWidth; + data["imageHeight"] = result.ImageHeight; + } + + return data; + } + private static object CaptureSceneViewScreenshot( SceneCommand cmd, string fileName, diff --git a/MCPForUnity/Runtime/Helpers/ScreenshotUtility.cs b/MCPForUnity/Runtime/Helpers/ScreenshotUtility.cs index 93195bc85..3a2c63463 100644 --- a/MCPForUnity/Runtime/Helpers/ScreenshotUtility.cs +++ b/MCPForUnity/Runtime/Helpers/ScreenshotUtility.cs @@ -236,6 +236,15 @@ public static ScreenshotCaptureResult CaptureComposited( bool includeImage = false, int maxResolution = 0) { + if (!IsScreenCaptureModuleAvailable) + { + var fallbackCamera = FindAvailableCamera(); + if (fallbackCamera != null) + return CaptureFromCameraToAssetsFolder(fallbackCamera, fileName, superSize, ensureUniqueFileName, includeImage, maxResolution); + + throw new InvalidOperationException("ScreenCapture module is unavailable and no fallback camera found."); + } + ScreenshotCaptureResult result = PrepareCaptureResult(fileName, superSize, ensureUniqueFileName, isAsync: false); Texture2D tex = null; Texture2D downscaled = null; @@ -247,7 +256,7 @@ public static ScreenshotCaptureResult CaptureComposited( if (tex == null) { // Fallback to camera-based if ScreenCapture fails - var cam = Camera.main; + var cam = FindAvailableCamera(); if (cam != null) return CaptureFromCameraToAssetsFolder(cam, fileName, superSize, ensureUniqueFileName, includeImage, maxResolution); throw new InvalidOperationException("ScreenCapture.CaptureScreenshotAsTexture returned null and no fallback camera available."); From 8d51737f97a650a69047c48bce7a697859195bc0 Mon Sep 17 00:00:00 2001 From: PaulLubos Date: Thu, 2 Apr 2026 10:07:26 +0200 Subject: [PATCH 04/20] Add configurable init_timeout for PlayMode test initialization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PlayMode tests require entering play mode which triggers a domain reload. On large projects this can take >15s, causing the hardcoded 15s init timeout to auto-fail the test job before tests actually start. This adds an `init_timeout` parameter to `run_tests` that flows through the Python server → C# RunTests handler → TestJobManager. When set, the per-job timeout overrides the 15s default. The value is persisted across domain reloads via SessionState. Changes: - Python: Add `init_timeout` param to `run_tests()` function signature - C# RunTests: Read `initTimeout` param and pass to `StartJob()` - C# TestJobManager: Per-job `InitTimeoutMs` field with fallback to `DefaultInitializationTimeoutMs` (15s), persisted in SessionState Co-Authored-By: Claude Opus 4.6 (1M context) --- MCPForUnity/Editor/Services/TestJobManager.cs | 18 ++++++++++++------ MCPForUnity/Editor/Tools/RunTests.cs | 3 ++- Server/src/services/tools/run_tests.py | 5 +++++ docs/development/README-DEV-zh.md | 1 + docs/development/README-DEV.md | 1 + 5 files changed, 21 insertions(+), 7 deletions(-) diff --git a/MCPForUnity/Editor/Services/TestJobManager.cs b/MCPForUnity/Editor/Services/TestJobManager.cs index b7bb32fc3..4869f4f3e 100644 --- a/MCPForUnity/Editor/Services/TestJobManager.cs +++ b/MCPForUnity/Editor/Services/TestJobManager.cs @@ -40,6 +40,7 @@ internal sealed class TestJob public List FailuresSoFar { get; set; } public string Error { get; set; } public TestRunResult Result { get; set; } + public long InitTimeoutMs { get; set; } } /// @@ -50,7 +51,7 @@ internal static class TestJobManager // Keep this small to avoid ballooning payloads during polling. private const int FailureCap = 25; private const long StuckThresholdMs = 60_000; - private const long InitializationTimeoutMs = 15_000; // 15 seconds to call OnRunStarted, else fail + private const long DefaultInitializationTimeoutMs = 15_000; // 15 seconds default; override per-job via run_tests init_timeout param private const int MaxJobsToKeep = 10; private const long MinPersistIntervalMs = 1000; // Throttle persistence to reduce overhead @@ -139,6 +140,7 @@ private sealed class PersistedJob public long? last_finished_unix_ms { get; set; } public List failures_so_far { get; set; } public string error { get; set; } + public long init_timeout_ms { get; set; } } private static TestJobStatus ParseStatus(string status) @@ -201,6 +203,7 @@ private static void TryRestoreFromSessionState() LastFinishedUnixMs = pj.last_finished_unix_ms, FailuresSoFar = pj.failures_so_far ?? new List(), Error = pj.error, + InitTimeoutMs = pj.init_timeout_ms, // Intentionally not persisted to avoid ballooning SessionState. Result = null }; @@ -273,7 +276,8 @@ private static void PersistToSessionState(bool force = false) last_finished_test_full_name = j.LastFinishedTestFullName, last_finished_unix_ms = j.LastFinishedUnixMs, failures_so_far = (j.FailuresSoFar ?? new List()).Take(FailureCap).ToList(), - error = j.Error + error = j.Error, + init_timeout_ms = j.InitTimeoutMs }) .ToList(); @@ -294,7 +298,7 @@ private static void PersistToSessionState(bool force = false) } } - public static string StartJob(TestMode mode, TestFilterOptions filterOptions = null) + public static string StartJob(TestMode mode, TestFilterOptions filterOptions = null, long initTimeoutMs = 0) { string jobId = Guid.NewGuid().ToString("N"); long started = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); @@ -316,7 +320,8 @@ public static string StartJob(TestMode mode, TestFilterOptions filterOptions = n LastFinishedUnixMs = null, FailuresSoFar = new List(), Error = null, - Result = null + Result = null, + InitTimeoutMs = initTimeoutMs }; // Single lock scope for check-and-set to avoid TOCTOU race @@ -491,9 +496,10 @@ internal static TestJob GetJob(string jobId) if (job.Status == TestJobStatus.Running && job.TotalTests == null) { long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); - if (!EditorApplication.isCompiling && !EditorApplication.isUpdating && now - job.StartedUnixMs > InitializationTimeoutMs) + long initTimeout = job.InitTimeoutMs > 0 ? job.InitTimeoutMs : DefaultInitializationTimeoutMs; + if (!EditorApplication.isCompiling && !EditorApplication.isUpdating && now - job.StartedUnixMs > initTimeout) { - McpLog.Warn($"[TestJobManager] Job {jobId} failed to initialize within {InitializationTimeoutMs}ms, auto-failing"); + McpLog.Warn($"[TestJobManager] Job {jobId} failed to initialize within {initTimeout}ms, auto-failing"); job.Status = TestJobStatus.Failed; job.Error = "Test job failed to initialize (tests did not start within timeout)"; job.FinishedUnixMs = now; diff --git a/MCPForUnity/Editor/Tools/RunTests.cs b/MCPForUnity/Editor/Tools/RunTests.cs index 3c93e97d2..14e229c8a 100644 --- a/MCPForUnity/Editor/Tools/RunTests.cs +++ b/MCPForUnity/Editor/Tools/RunTests.cs @@ -45,7 +45,8 @@ public static Task HandleCommand(JObject @params) bool includeFailedTests = p.GetBool("includeFailedTests"); var filterOptions = GetFilterOptions(@params); - string jobId = TestJobManager.StartJob(parsedMode.Value, filterOptions); + long initTimeoutMs = p.GetInt("initTimeout") ?? 0; + string jobId = TestJobManager.StartJob(parsedMode.Value, filterOptions, initTimeoutMs); return Task.FromResult(new SuccessResponse("Test job started.", new { diff --git a/Server/src/services/tools/run_tests.py b/Server/src/services/tools/run_tests.py index 05a478888..b672b08a2 100644 --- a/Server/src/services/tools/run_tests.py +++ b/Server/src/services/tools/run_tests.py @@ -167,6 +167,9 @@ async def run_tests( "Include details for failed/skipped tests only (default: false)"] = False, include_details: Annotated[bool, "Include details for all tests (default: false)"] = False, + init_timeout: Annotated[int | None, + "Initialization timeout in milliseconds. PlayMode tests may need longer " + "due to domain reload (default: 15000). Recommended: 120000 for PlayMode."] = None, ) -> RunTestsStartResponse | MCPResponse: unity_instance = await get_unity_instance_from_context(ctx) @@ -197,6 +200,8 @@ def _coerce_string_list(value) -> list[str] | None: params["includeFailedTests"] = True if include_details: params["includeDetails"] = True + if init_timeout is not None and init_timeout > 0: + params["initTimeout"] = init_timeout response = await unity_transport.send_with_unity_instance( async_send_command_with_retry, diff --git a/docs/development/README-DEV-zh.md b/docs/development/README-DEV-zh.md index 21b7d0ca7..e2810e338 100644 --- a/docs/development/README-DEV-zh.md +++ b/docs/development/README-DEV-zh.md @@ -126,6 +126,7 @@ uv run python -m cli.main editor tests --failed-only ``` run_tests(mode="EditMode") +run_tests(mode="PlayMode", init_timeout=120000) # PlayMode 由于域重载可能需要更长的初始化时间 get_test_job(job_id="", wait_timeout=60) ``` diff --git a/docs/development/README-DEV.md b/docs/development/README-DEV.md index ffe7d15bf..9df5864f1 100644 --- a/docs/development/README-DEV.md +++ b/docs/development/README-DEV.md @@ -126,6 +126,7 @@ uv run python -m cli.main editor tests --failed-only ``` run_tests(mode="EditMode") +run_tests(mode="PlayMode", init_timeout=120000) # PlayMode may need longer init due to domain reload get_test_job(job_id="", wait_timeout=60) ``` From e8e3b45882d7d120c2f8ec2ece8752993f60d53f Mon Sep 17 00:00:00 2001 From: PaulLubos Date: Thu, 2 Apr 2026 15:53:11 +0200 Subject: [PATCH 05/20] Address code review: input validation and tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Clamp initTimeoutMs in StartJob: negative values → 0, cap at 600s - Python: reject init_timeout <= 0 with explicit error before calling Unity - Add 3 C# EditMode tests for per-job InitTimeoutMs behavior (custom timeout, default timeout auto-fail, persist/restore) - Add 4 Python tests for init_timeout forwarding and validation Co-Authored-By: Claude Opus 4.6 (1M context) --- MCPForUnity/Editor/Services/TestJobManager.cs | 5 + Server/src/services/tools/run_tests.py | 3 + .../tests/integration/test_run_tests_async.py | 60 +++++++ .../TestJobManagerInitTimeoutTests.cs | 165 ++++++++++++++++++ .../TestJobManagerInitTimeoutTests.cs.meta | 2 + 5 files changed, 235 insertions(+) create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/TestJobManagerInitTimeoutTests.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/TestJobManagerInitTimeoutTests.cs.meta diff --git a/MCPForUnity/Editor/Services/TestJobManager.cs b/MCPForUnity/Editor/Services/TestJobManager.cs index 4869f4f3e..d162476e8 100644 --- a/MCPForUnity/Editor/Services/TestJobManager.cs +++ b/MCPForUnity/Editor/Services/TestJobManager.cs @@ -52,6 +52,7 @@ internal static class TestJobManager private const int FailureCap = 25; private const long StuckThresholdMs = 60_000; private const long DefaultInitializationTimeoutMs = 15_000; // 15 seconds default; override per-job via run_tests init_timeout param + private const long MaxInitializationTimeoutMs = 600_000; // 10 minutes hard cap private const int MaxJobsToKeep = 10; private const long MinPersistIntervalMs = 1000; // Throttle persistence to reduce overhead @@ -300,6 +301,10 @@ private static void PersistToSessionState(bool force = false) public static string StartJob(TestMode mode, TestFilterOptions filterOptions = null, long initTimeoutMs = 0) { + // Clamp to valid range: non-positive values mean "use default", cap at 10 minutes + if (initTimeoutMs < 0) initTimeoutMs = 0; + if (initTimeoutMs > MaxInitializationTimeoutMs) initTimeoutMs = MaxInitializationTimeoutMs; + string jobId = Guid.NewGuid().ToString("N"); long started = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); string modeStr = mode.ToString(); diff --git a/Server/src/services/tools/run_tests.py b/Server/src/services/tools/run_tests.py index b672b08a2..0426e63b5 100644 --- a/Server/src/services/tools/run_tests.py +++ b/Server/src/services/tools/run_tests.py @@ -171,6 +171,9 @@ async def run_tests( "Initialization timeout in milliseconds. PlayMode tests may need longer " "due to domain reload (default: 15000). Recommended: 120000 for PlayMode."] = None, ) -> RunTestsStartResponse | MCPResponse: + if init_timeout is not None and init_timeout <= 0: + return MCPResponse(success=False, error="init_timeout must be a positive integer (milliseconds) or None") + unity_instance = await get_unity_instance_from_context(ctx) gate = await preflight(ctx, requires_no_tests=True, wait_for_no_compile=True, refresh_if_dirty=True) diff --git a/Server/tests/integration/test_run_tests_async.py b/Server/tests/integration/test_run_tests_async.py index 5c005a6ff..a8098ea6c 100644 --- a/Server/tests/integration/test_run_tests_async.py +++ b/Server/tests/integration/test_run_tests_async.py @@ -33,6 +33,66 @@ async def fake_send_with_unity_instance(send_fn, unity_instance, command_type, p assert resp.data.job_id == "abc123" +@pytest.mark.asyncio +async def test_run_tests_forwards_init_timeout(monkeypatch): + from services.tools.run_tests import run_tests + + captured = {} + + async def fake_send_with_unity_instance(send_fn, unity_instance, command_type, params, **kwargs): + captured["params"] = params + return {"success": True, "data": {"job_id": "abc123", "status": "running", "mode": "PlayMode"}} + + import services.tools.run_tests as mod + monkeypatch.setattr( + mod.unity_transport, "send_with_unity_instance", fake_send_with_unity_instance) + + resp = await run_tests( + DummyContext(), + mode="PlayMode", + init_timeout=120000, + ) + assert captured["params"]["initTimeout"] == 120000 + assert resp.success is True + + +@pytest.mark.asyncio +async def test_run_tests_omits_init_timeout_when_none(monkeypatch): + from services.tools.run_tests import run_tests + + captured = {} + + async def fake_send_with_unity_instance(send_fn, unity_instance, command_type, params, **kwargs): + captured["params"] = params + return {"success": True, "data": {"job_id": "abc123", "status": "running", "mode": "EditMode"}} + + import services.tools.run_tests as mod + monkeypatch.setattr( + mod.unity_transport, "send_with_unity_instance", fake_send_with_unity_instance) + + resp = await run_tests(DummyContext(), mode="EditMode") + assert "initTimeout" not in captured["params"] + assert resp.success is True + + +@pytest.mark.asyncio +async def test_run_tests_rejects_negative_init_timeout(): + from services.tools.run_tests import run_tests + + resp = await run_tests(DummyContext(), mode="EditMode", init_timeout=-1) + assert resp.success is False + assert "init_timeout" in resp.error + + +@pytest.mark.asyncio +async def test_run_tests_rejects_zero_init_timeout(): + from services.tools.run_tests import run_tests + + resp = await run_tests(DummyContext(), mode="EditMode", init_timeout=0) + assert resp.success is False + assert "init_timeout" in resp.error + + @pytest.mark.asyncio async def test_get_test_job_forwards_job_id(monkeypatch): from services.tools.run_tests import get_test_job diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/TestJobManagerInitTimeoutTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/TestJobManagerInitTimeoutTests.cs new file mode 100644 index 000000000..8bacfb34c --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/TestJobManagerInitTimeoutTests.cs @@ -0,0 +1,165 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using NUnit.Framework; +using MCPForUnity.Editor.Services; + +namespace MCPForUnityTests.Editor.Services +{ + /// + /// Tests for TestJobManager's per-job InitTimeoutMs feature. + /// Uses reflection to manipulate internal state since StartJob triggers a real test run. + /// + public class TestJobManagerInitTimeoutTests + { + private FieldInfo _jobsField; + private FieldInfo _currentJobIdField; + private MethodInfo _getJobMethod; + private MethodInfo _persistMethod; + private MethodInfo _restoreMethod; + private Type _testJobType; + + private string _originalJobId; + private object _originalJobs; + + [SetUp] + public void SetUp() + { + var asm = typeof(MCPServiceLocator).Assembly; + var managerType = asm.GetType("MCPForUnity.Editor.Services.TestJobManager"); + Assert.NotNull(managerType, "Could not find TestJobManager"); + + _testJobType = asm.GetType("MCPForUnity.Editor.Services.TestJob"); + Assert.NotNull(_testJobType, "Could not find TestJob"); + + _jobsField = managerType.GetField("Jobs", BindingFlags.NonPublic | BindingFlags.Static); + Assert.NotNull(_jobsField, "Could not find Jobs field"); + + _currentJobIdField = managerType.GetField("_currentJobId", BindingFlags.NonPublic | BindingFlags.Static); + Assert.NotNull(_currentJobIdField, "Could not find _currentJobId field"); + + _getJobMethod = managerType.GetMethod("GetJob", BindingFlags.NonPublic | BindingFlags.Static); + Assert.NotNull(_getJobMethod, "Could not find GetJob method"); + + _persistMethod = managerType.GetMethod("PersistToSessionState", BindingFlags.NonPublic | BindingFlags.Static); + Assert.NotNull(_persistMethod, "Could not find PersistToSessionState method"); + + _restoreMethod = managerType.GetMethod("TryRestoreFromSessionState", BindingFlags.NonPublic | BindingFlags.Static); + Assert.NotNull(_restoreMethod, "Could not find TryRestoreFromSessionState method"); + + // Snapshot original state + _originalJobId = _currentJobIdField.GetValue(null) as string; + // We'll restore _currentJobId in TearDown; Jobs dictionary is shared static state + } + + [TearDown] + public void TearDown() + { + // Restore original state + _currentJobIdField.SetValue(null, _originalJobId); + // Clean up any test jobs we inserted + var jobs = _jobsField.GetValue(null) as System.Collections.IDictionary; + jobs?.Remove("test-init-timeout-job"); + jobs?.Remove("test-init-timeout-default"); + jobs?.Remove("test-init-timeout-persist"); + } + + [Test] + public void GetJob_WithCustomInitTimeout_UsesPerJobTimeout() + { + // Arrange: insert a job with a custom init timeout and a start time far enough in the + // past to exceed the default 15s but within the custom 120s. + var jobs = _jobsField.GetValue(null) as System.Collections.IDictionary; + long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + + var job = Activator.CreateInstance(_testJobType); + _testJobType.GetProperty("JobId").SetValue(job, "test-init-timeout-job"); + _testJobType.GetProperty("Status").SetValue(job, TestJobStatus.Running); + _testJobType.GetProperty("Mode").SetValue(job, "PlayMode"); + _testJobType.GetProperty("StartedUnixMs").SetValue(job, now - 30_000); // 30s ago + _testJobType.GetProperty("LastUpdateUnixMs").SetValue(job, now - 30_000); + _testJobType.GetProperty("TotalTests").SetValue(job, null); // Not initialized yet + _testJobType.GetProperty("InitTimeoutMs").SetValue(job, 120_000L); // 120s custom timeout + _testJobType.GetProperty("FailuresSoFar").SetValue(job, new List()); + + jobs["test-init-timeout-job"] = job; + _currentJobIdField.SetValue(null, "test-init-timeout-job"); + + // Act: GetJob should NOT auto-fail because 30s < 120s custom timeout + var result = _getJobMethod.Invoke(null, new object[] { "test-init-timeout-job" }); + + // Assert: job should still be running + var status = (TestJobStatus)_testJobType.GetProperty("Status").GetValue(result); + Assert.AreEqual(TestJobStatus.Running, status, + "Job with 120s custom timeout should not auto-fail after 30s"); + } + + [Test] + public void GetJob_WithDefaultTimeout_AutoFailsAfter15Seconds() + { + // Arrange: insert a job with InitTimeoutMs=0 (use default) and start time 20s ago + var jobs = _jobsField.GetValue(null) as System.Collections.IDictionary; + long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + + var job = Activator.CreateInstance(_testJobType); + _testJobType.GetProperty("JobId").SetValue(job, "test-init-timeout-default"); + _testJobType.GetProperty("Status").SetValue(job, TestJobStatus.Running); + _testJobType.GetProperty("Mode").SetValue(job, "EditMode"); + _testJobType.GetProperty("StartedUnixMs").SetValue(job, now - 20_000); // 20s ago + _testJobType.GetProperty("LastUpdateUnixMs").SetValue(job, now - 20_000); + _testJobType.GetProperty("TotalTests").SetValue(job, null); + _testJobType.GetProperty("InitTimeoutMs").SetValue(job, 0L); // Use default + _testJobType.GetProperty("FailuresSoFar").SetValue(job, new List()); + + jobs["test-init-timeout-default"] = job; + _currentJobIdField.SetValue(null, "test-init-timeout-default"); + + // Act: GetJob should auto-fail because 20s > 15s default + var result = _getJobMethod.Invoke(null, new object[] { "test-init-timeout-default" }); + + // Assert: job should be failed + var status = (TestJobStatus)_testJobType.GetProperty("Status").GetValue(result); + Assert.AreEqual(TestJobStatus.Failed, status, + "Job with default timeout should auto-fail after 20s"); + } + + [Test] + public void InitTimeoutMs_SurvivesPersistAndRestore() + { + // Arrange: insert a job with custom InitTimeoutMs + var jobs = _jobsField.GetValue(null) as System.Collections.IDictionary; + long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + + var job = Activator.CreateInstance(_testJobType); + _testJobType.GetProperty("JobId").SetValue(job, "test-init-timeout-persist"); + _testJobType.GetProperty("Status").SetValue(job, TestJobStatus.Running); + _testJobType.GetProperty("Mode").SetValue(job, "PlayMode"); + _testJobType.GetProperty("StartedUnixMs").SetValue(job, now); + _testJobType.GetProperty("LastUpdateUnixMs").SetValue(job, now); + _testJobType.GetProperty("TotalTests").SetValue(job, null); + _testJobType.GetProperty("InitTimeoutMs").SetValue(job, 90_000L); + _testJobType.GetProperty("FailuresSoFar").SetValue(job, new List()); + + jobs["test-init-timeout-persist"] = job; + _currentJobIdField.SetValue(null, "test-init-timeout-persist"); + + // Act: persist then restore (simulates domain reload) + _persistMethod.Invoke(null, new object[] { true }); + // Clear in-memory state + jobs.Remove("test-init-timeout-persist"); + _currentJobIdField.SetValue(null, null); + // Restore from SessionState + _restoreMethod.Invoke(null, null); + + // Assert: restored job should have the same InitTimeoutMs + var restoredJobs = _jobsField.GetValue(null) as System.Collections.IDictionary; + Assert.IsTrue(restoredJobs.Contains("test-init-timeout-persist"), + "Job should be restored from SessionState"); + + var restoredJob = restoredJobs["test-init-timeout-persist"]; + var restoredTimeout = (long)_testJobType.GetProperty("InitTimeoutMs").GetValue(restoredJob); + Assert.AreEqual(90_000L, restoredTimeout, + "InitTimeoutMs should survive persist/restore cycle"); + } + } +} diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/TestJobManagerInitTimeoutTests.cs.meta b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/TestJobManagerInitTimeoutTests.cs.meta new file mode 100644 index 000000000..663e2f94c --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/TestJobManagerInitTimeoutTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: e0b6718bcbe94429595354c108865f67 \ No newline at end of file From 3df5a8476559df6686fb0b897f79a87a300aca6c Mon Sep 17 00:00:00 2001 From: PaulLubos Date: Thu, 2 Apr 2026 16:22:58 +0200 Subject: [PATCH 06/20] Remove unused field, guard test against compile/update flakiness - Remove unused _originalJobs field - Add Assume.That guards for EditorApplication.isCompiling/isUpdating so the test is skipped (inconclusive) rather than producing misleading results when the editor is mid-compilation Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Tests/EditMode/Services/TestJobManagerInitTimeoutTests.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/TestJobManagerInitTimeoutTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/TestJobManagerInitTimeoutTests.cs index 8bacfb34c..a1db0599a 100644 --- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/TestJobManagerInitTimeoutTests.cs +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/TestJobManagerInitTimeoutTests.cs @@ -20,7 +20,6 @@ public class TestJobManagerInitTimeoutTests private Type _testJobType; private string _originalJobId; - private object _originalJobs; [SetUp] public void SetUp() From 233e88e8acce47058cec18fb7716fcfeaa4d27c5 Mon Sep 17 00:00:00 2001 From: PaulLubos Date: Wed, 29 Apr 2026 09:29:41 +0200 Subject: [PATCH 07/20] Clear SessionState in TestJobManagerInitTimeoutTests TearDown The persist/restore test writes synthetic jobs to SessionState, which survives domain reloads and would be re-hydrated by TestJobManager's [InitializeOnLoadMethod] hook. Flush the cleaned in-memory state back to SessionState in TearDown so test artifacts don't leak across runs. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Tests/EditMode/Services/TestJobManagerInitTimeoutTests.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/TestJobManagerInitTimeoutTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/TestJobManagerInitTimeoutTests.cs index a1db0599a..0e7bb8fcc 100644 --- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/TestJobManagerInitTimeoutTests.cs +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/TestJobManagerInitTimeoutTests.cs @@ -61,6 +61,10 @@ public void TearDown() jobs?.Remove("test-init-timeout-job"); jobs?.Remove("test-init-timeout-default"); jobs?.Remove("test-init-timeout-persist"); + // Flush cleaned state to SessionState so synthetic jobs don't survive domain reloads. + // The persist test writes to SessionState; without this, the stub job would be + // restored on the next [InitializeOnLoadMethod] and pollute later test runs. + _persistMethod.Invoke(null, new object[] { true }); } [Test] From ddc446b3a602ce0009656081cad6f055abf61d58 Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Wed, 29 Apr 2026 15:01:38 +0000 Subject: [PATCH 08/20] chore: update Unity package to beta version 9.6.9-beta.2 --- MCPForUnity/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/MCPForUnity/package.json b/MCPForUnity/package.json index f303d645c..a74b94720 100644 --- a/MCPForUnity/package.json +++ b/MCPForUnity/package.json @@ -1,6 +1,6 @@ { "name": "com.coplaydev.unity-mcp", - "version": "9.6.9-beta.1", + "version": "9.6.9-beta.2", "displayName": "MCP for Unity", "description": "A bridge that connects AI assistants to Unity via the MCP (Model Context Protocol). Allows AI clients like Claude Code, Cursor, and VSCode to directly control your Unity Editor for enhanced development workflows.\n\nFeatures automated setup wizard, cross-platform support, and seamless integration with popular AI development tools.\n\nJoin Our Discord: https://discord.gg/y4p8KfzrN4", "unity": "2021.3", From 744eeb0d117de8a0c8603875f64f6fc3f5c3858e Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Wed, 29 Apr 2026 20:54:47 +0000 Subject: [PATCH 09/20] chore: update Unity package to beta version 9.6.9-beta.3 --- MCPForUnity/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/MCPForUnity/package.json b/MCPForUnity/package.json index a74b94720..bf6bca25d 100644 --- a/MCPForUnity/package.json +++ b/MCPForUnity/package.json @@ -1,6 +1,6 @@ { "name": "com.coplaydev.unity-mcp", - "version": "9.6.9-beta.2", + "version": "9.6.9-beta.3", "displayName": "MCP for Unity", "description": "A bridge that connects AI assistants to Unity via the MCP (Model Context Protocol). Allows AI clients like Claude Code, Cursor, and VSCode to directly control your Unity Editor for enhanced development workflows.\n\nFeatures automated setup wizard, cross-platform support, and seamless integration with popular AI development tools.\n\nJoin Our Discord: https://discord.gg/y4p8KfzrN4", "unity": "2021.3", From 1fba42998c9a6bcd2478d83c0ff20a107899bd27 Mon Sep 17 00:00:00 2001 From: Shutong Wu <51266340+Scriptwonder@users.noreply.github.com> Date: Sun, 3 May 2026 18:16:10 -0400 Subject: [PATCH 10/20] Update0503 1.Add Compat based scripts revolving around UnityCompatShims.cs, that will document our current API Compatibility changes in several files. 2.Add custom screenshot folder selection --- CLAUDE.md | 5 + .../Helpers/EditorWindowScreenshotUtility.cs | 22 +- .../Editor/Helpers/ScreenshotPreferences.cs | 49 ++++ .../Helpers/ScreenshotPreferences.cs.meta | 11 + .../Editor/Helpers/UnityTypeResolver.cs | 3 +- .../Editor/Tools/Animation/ClipCreate.cs | 3 +- MCPForUnity/Editor/Tools/CommandRegistry.cs | 3 +- MCPForUnity/Editor/Tools/ExecuteCode.cs | 3 +- MCPForUnity/Editor/Tools/ManageScene.cs | 166 +++++++---- MCPForUnity/Editor/Tools/ManageUI.cs | 51 +++- .../Tools/Physics/PhysicsSettingsOps.cs | 56 ++-- .../Tools/Physics/PhysicsSimulationOps.cs | 27 +- MCPForUnity/Editor/Tools/UnityReflect.cs | 3 +- .../Components/Advanced/McpAdvancedSection.cs | 70 +++++ .../Advanced/McpAdvancedSection.uxml | 9 + .../Components/Tools/McpToolsSection.cs | 31 ++- .../Runtime/Helpers/ScreenshotUtility.cs | 112 ++++++-- .../Runtime/Helpers/UnityAssembliesCompat.cs | 121 ++++++++ .../Helpers/UnityAssembliesCompat.cs.meta | 2 + .../Runtime/Helpers/UnityCompatShims.cs | 41 +++ .../Runtime/Helpers/UnityCompatShims.cs.meta | 2 + .../Runtime/Helpers/UnityFindObjectsCompat.cs | 108 +++++++- .../Runtime/Helpers/UnityObjectIdCompat.cs | 2 + .../Runtime/Helpers/UnityPhysicsCompat.cs | 261 ++++++++++++++++++ .../Helpers/UnityPhysicsCompat.cs.meta | 2 + Server/src/cli/commands/camera.py | 15 +- Server/src/services/tools/manage_camera.py | 5 + Server/src/services/tools/manage_ui.py | 7 + Server/src/services/tools/utils.py | 3 + Server/uv.lock | 2 +- 30 files changed, 1017 insertions(+), 178 deletions(-) create mode 100644 MCPForUnity/Editor/Helpers/ScreenshotPreferences.cs create mode 100644 MCPForUnity/Editor/Helpers/ScreenshotPreferences.cs.meta create mode 100644 MCPForUnity/Runtime/Helpers/UnityAssembliesCompat.cs create mode 100644 MCPForUnity/Runtime/Helpers/UnityAssembliesCompat.cs.meta create mode 100644 MCPForUnity/Runtime/Helpers/UnityCompatShims.cs create mode 100644 MCPForUnity/Runtime/Helpers/UnityCompatShims.cs.meta create mode 100644 MCPForUnity/Runtime/Helpers/UnityPhysicsCompat.cs create mode 100644 MCPForUnity/Runtime/Helpers/UnityPhysicsCompat.cs.meta diff --git a/CLAUDE.md b/CLAUDE.md index 11c17935e..8b0c0e395 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -140,6 +140,11 @@ Use `CommandRegistry.InvokeCommandAsync` to call other tools from within a handl var result = await CommandRegistry.InvokeCommandAsync("read_console", consoleParams); ``` +### Unity API Compatibility Shims +We support a wide Unity version range (2021+ → 6.x → CoreCLR 6.8). When an API is renamed, deprecated, or removed across versions, **don't sprinkle `#if UNITY_x_y_OR_NEWER` at every call site** — add a shim in `MCPForUnity/Runtime/Helpers/Unity*Compat.cs` and route every caller through it. + +The catalog of active shims, the policy for when to add one, what does NOT belong in a shim, and the reflection-cache pattern all live in **`MCPForUnity/Runtime/Helpers/UnityCompatShims.cs`** — the XML doc on that empty marker class is the source of truth and ships inside the UPM package, so end-users can `F12`/Go-to-definition into it. Sources for current deprecations: Unity 6.x upgrade guides and the [CoreCLR 2026 thread](https://discussions.unity.com/t/path-to-coreclr-2026-upgrade-guide/1714279). + ## Commands ### Running Tests diff --git a/MCPForUnity/Editor/Helpers/EditorWindowScreenshotUtility.cs b/MCPForUnity/Editor/Helpers/EditorWindowScreenshotUtility.cs index 229b9336c..ee688718b 100644 --- a/MCPForUnity/Editor/Helpers/EditorWindowScreenshotUtility.cs +++ b/MCPForUnity/Editor/Helpers/EditorWindowScreenshotUtility.cs @@ -16,7 +16,6 @@ namespace MCPForUnity.Editor.Helpers /// internal static class EditorWindowScreenshotUtility { - private const string ScreenshotsFolderName = "Screenshots"; // Keep capture synchronous so callers can immediately return the screenshot payload. // The short sleep gives Unity a chance to flush repaint work before GrabPixels reads the viewport. private const int RepaintSettlingDelayMs = 75; @@ -40,7 +39,7 @@ internal static class EditorWindowScreenshotUtility /// Maximum edge length for the inline image payload. /// Captured viewport width in pixels. /// Captured viewport height in pixels. - public static ScreenshotCaptureResult CaptureSceneViewViewportToAssets( + public static ScreenshotCaptureResult CaptureSceneViewViewportToProject( SceneView sceneView, string fileName, int superSize, @@ -48,7 +47,8 @@ public static ScreenshotCaptureResult CaptureSceneViewViewportToAssets( bool includeImage, int maxResolution, out int viewportWidth, - out int viewportHeight) + out int viewportHeight, + string folderOverride = null) { if (sceneView == null) throw new ArgumentNullException(nameof(sceneView)); @@ -70,7 +70,7 @@ public static ScreenshotCaptureResult CaptureSceneViewViewportToAssets( { captured = CaptureViewRect(sceneView, viewportRectPixels); - var result = PrepareCaptureResult(fileName, effectiveSuperSize, ensureUniqueFileName); + var result = PrepareCaptureResult(fileName, effectiveSuperSize, ensureUniqueFileName, folderOverride); byte[] png = captured.EncodeToPNG(); File.WriteAllBytes(result.FullPath, png); @@ -97,7 +97,7 @@ public static ScreenshotCaptureResult CaptureSceneViewViewportToAssets( return new ScreenshotCaptureResult( result.FullPath, - result.AssetsRelativePath, + result.ProjectRelativePath, result.SuperSize, false, imageBase64, @@ -317,11 +317,11 @@ private static void FlipTextureVertically(Texture2D texture) texture.Apply(); } - private static ScreenshotCaptureResult PrepareCaptureResult(string fileName, int superSize, bool ensureUniqueFileName) + private static ScreenshotCaptureResult PrepareCaptureResult(string fileName, int superSize, bool ensureUniqueFileName, string folderOverride) { int size = Mathf.Max(1, superSize); string resolvedName = BuildFileName(fileName); - string folder = Path.Combine(Application.dataPath, ScreenshotsFolderName); + string folder = ScreenshotUtility.ResolveFolderAbsolute(folderOverride); Directory.CreateDirectory(folder); string fullPath = Path.Combine(folder, resolvedName); @@ -331,8 +331,12 @@ private static ScreenshotCaptureResult PrepareCaptureResult(string fileName, int } string normalizedFullPath = fullPath.Replace('\\', '/'); - string assetsRelativePath = "Assets/" + normalizedFullPath.Substring(Application.dataPath.Length).TrimStart('/'); - return new ScreenshotCaptureResult(normalizedFullPath, assetsRelativePath, size, false); + string projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, "..")).Replace('\\', '/'); + string normalizedRoot = projectRoot.EndsWith("/") ? projectRoot : projectRoot + "/"; + string projectRelativePath = normalizedFullPath.StartsWith(normalizedRoot, StringComparison.OrdinalIgnoreCase) + ? normalizedFullPath.Substring(normalizedRoot.Length) + : normalizedFullPath; + return new ScreenshotCaptureResult(normalizedFullPath, projectRelativePath, size, false); } private static string BuildFileName(string fileName) diff --git a/MCPForUnity/Editor/Helpers/ScreenshotPreferences.cs b/MCPForUnity/Editor/Helpers/ScreenshotPreferences.cs new file mode 100644 index 000000000..b12c97449 --- /dev/null +++ b/MCPForUnity/Editor/Helpers/ScreenshotPreferences.cs @@ -0,0 +1,49 @@ +using MCPForUnity.Runtime.Helpers; +using UnityEditor; + +namespace MCPForUnity.Editor.Helpers +{ + /// + /// Per-user EditorPrefs override for the default screenshot output folder. + /// Resolution priority used by callers: + /// 1. Per-call output_folder tool parameter + /// 2. (this preference) + /// 3. built-in fallback + /// + public static class ScreenshotPreferences + { + public const string EditorPrefsKey = "MCPForUnity_ScreenshotsFolder"; + + /// + /// User-configured default folder, or empty string when unset. + /// Stored as a project-relative path (e.g. "Assets/Screenshots", "Captures"). + /// + public static string DefaultFolder + { + get => EditorPrefs.GetString(EditorPrefsKey, string.Empty); + set + { + if (string.IsNullOrWhiteSpace(value)) + { + EditorPrefs.DeleteKey(EditorPrefsKey); + } + else + { + EditorPrefs.SetString(EditorPrefsKey, value.Trim()); + } + } + } + + /// + /// Resolves the effective folder: caller override → user pref → built-in default. + /// Returns a project-relative path string suitable for . + /// + public static string Resolve(string callerOverride) + { + if (!string.IsNullOrWhiteSpace(callerOverride)) return callerOverride.Trim(); + string pref = DefaultFolder; + if (!string.IsNullOrWhiteSpace(pref)) return pref; + return ScreenshotUtility.DefaultFolder; + } + } +} diff --git a/MCPForUnity/Editor/Helpers/ScreenshotPreferences.cs.meta b/MCPForUnity/Editor/Helpers/ScreenshotPreferences.cs.meta new file mode 100644 index 000000000..c1668314b --- /dev/null +++ b/MCPForUnity/Editor/Helpers/ScreenshotPreferences.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7c4f1a8e9b3d4d2eaf5c1d7b9e2f4a6c +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Helpers/UnityTypeResolver.cs b/MCPForUnity/Editor/Helpers/UnityTypeResolver.cs index feb0b5c7a..8a6f80169 100644 --- a/MCPForUnity/Editor/Helpers/UnityTypeResolver.cs +++ b/MCPForUnity/Editor/Helpers/UnityTypeResolver.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Linq; using System.Reflection; +using MCPForUnity.Runtime.Helpers; using UnityEngine; #if UNITY_EDITOR using UnityEditor; @@ -150,7 +151,7 @@ private static void Cache(Type t) private static List FindCandidates(string query, Type requiredBaseType) { bool isShort = !query.Contains('.'); - var loaded = AppDomain.CurrentDomain.GetAssemblies(); + var loaded = UnityAssembliesCompat.GetLoadedAssemblies(); #if UNITY_EDITOR // Names of Player (runtime) script assemblies diff --git a/MCPForUnity/Editor/Tools/Animation/ClipCreate.cs b/MCPForUnity/Editor/Tools/Animation/ClipCreate.cs index 47a386c96..b091dce37 100644 --- a/MCPForUnity/Editor/Tools/Animation/ClipCreate.cs +++ b/MCPForUnity/Editor/Tools/Animation/ClipCreate.cs @@ -4,6 +4,7 @@ using System.Linq; using Newtonsoft.Json.Linq; using MCPForUnity.Editor.Helpers; +using MCPForUnity.Runtime.Helpers; using UnityEditor; using UnityEngine; @@ -491,7 +492,7 @@ private static Type ResolveType(string typeName) if (type != null) return type; // Fallback: search all loaded assemblies - foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies()) + foreach (var assembly in UnityAssembliesCompat.GetLoadedAssemblies()) { type = assembly.GetType(typeName); if (type != null) return type; diff --git a/MCPForUnity/Editor/Tools/CommandRegistry.cs b/MCPForUnity/Editor/Tools/CommandRegistry.cs index ca39ea51c..c22ab00bb 100644 --- a/MCPForUnity/Editor/Tools/CommandRegistry.cs +++ b/MCPForUnity/Editor/Tools/CommandRegistry.cs @@ -5,6 +5,7 @@ using System.Threading.Tasks; using MCPForUnity.Editor.Helpers; using MCPForUnity.Editor.Resources; +using MCPForUnity.Runtime.Helpers; using Newtonsoft.Json; using Newtonsoft.Json.Linq; @@ -59,7 +60,7 @@ private static void AutoDiscoverCommands() { try { - var allTypes = AppDomain.CurrentDomain.GetAssemblies() + var allTypes = UnityAssembliesCompat.GetLoadedAssemblies() .Where(a => !a.IsDynamic) .SelectMany(a => { diff --git a/MCPForUnity/Editor/Tools/ExecuteCode.cs b/MCPForUnity/Editor/Tools/ExecuteCode.cs index c393e6660..d53c04e28 100644 --- a/MCPForUnity/Editor/Tools/ExecuteCode.cs +++ b/MCPForUnity/Editor/Tools/ExecuteCode.cs @@ -6,6 +6,7 @@ using System.Reflection; using System.Text; using MCPForUnity.Editor.Helpers; +using MCPForUnity.Runtime.Helpers; using Microsoft.CSharp; using Newtonsoft.Json.Linq; using UnityEngine; @@ -360,7 +361,7 @@ private static string[] ResolveAssemblyPaths() { var paths = new HashSet(StringComparer.OrdinalIgnoreCase); - foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies()) + foreach (var assembly in UnityAssembliesCompat.GetLoadedAssemblies()) { try { diff --git a/MCPForUnity/Editor/Tools/ManageScene.cs b/MCPForUnity/Editor/Tools/ManageScene.cs index 7567a78a2..ca5d92478 100644 --- a/MCPForUnity/Editor/Tools/ManageScene.cs +++ b/MCPForUnity/Editor/Tools/ManageScene.cs @@ -32,6 +32,7 @@ private sealed class SceneCommand public string captureSource { get; set; } // "game_view" (default) or "scene_view" public bool? includeImage { get; set; } public int? maxResolution { get; set; } + public string outputFolder { get; set; } // optional override; null falls back to user pref / Assets/Screenshots public string batch { get; set; } // "surround" or "orbit" for multi-angle batch capture public JToken viewTarget { get; set; } // GO reference or [x,y,z] to focus on before capture public Vector3? viewPosition { get; set; } // camera position for view-based capture @@ -109,6 +110,7 @@ private static SceneCommand ToSceneCommand(JObject p) captureSource = toolParams.Get("capture_source"), includeImage = ParamCoercion.CoerceBoolNullable(p["includeImage"] ?? p["include_image"]), maxResolution = ParamCoercion.CoerceIntNullable(p["maxResolution"] ?? p["max_resolution"]), + outputFolder = (p["outputFolder"] ?? p["output_folder"])?.ToString(), batch = (p["batch"])?.ToString(), viewTarget = p["viewTarget"] ?? p["view_target"], viewPosition = VectorParsing.ParseVector3(p["viewPosition"] ?? p["view_position"]), @@ -609,16 +611,27 @@ private static object CaptureScreenshot(SceneCommand cmd) if (!Application.isBatchMode) EnsureGameView(); - ScreenshotCaptureResult result = ScreenshotUtility.CaptureFromCameraToAssetsFolder( - targetCamera, fileName, resolvedSuperSize, ensureUniqueFileName: true, - includeImage: includeImage, maxResolution: maxResolution); + string folderOverride = ScreenshotPreferences.Resolve(cmd.outputFolder); + ScreenshotCaptureResult result; + try + { + result = ScreenshotUtility.CaptureFromCameraToProjectFolder( + targetCamera, fileName, resolvedSuperSize, ensureUniqueFileName: true, + includeImage: includeImage, maxResolution: maxResolution, + folderOverride: folderOverride); + } + catch (InvalidOperationException ex) + { + return new ErrorResponse(ex.Message); + } - AssetDatabase.ImportAsset(result.AssetsRelativePath, ImportAssetOptions.ForceSynchronousImport); - string message = $"Screenshot captured to '{result.AssetsRelativePath}' (camera: {targetCamera.name})."; + if (ScreenshotUtility.IsUnderAssets(result.ProjectRelativePath)) + AssetDatabase.ImportAsset(result.ProjectRelativePath, ImportAssetOptions.ForceSynchronousImport); + string message = $"Screenshot captured to '{result.ProjectRelativePath}' (camera: {targetCamera.name})."; var data = new Dictionary { - { "path", result.AssetsRelativePath }, + { "path", result.ProjectRelativePath }, { "fullPath", result.FullPath }, { "superSize", result.SuperSize }, { "isAsync", false }, @@ -662,19 +675,33 @@ private static object CaptureScreenshot(SceneCommand cmd) if (!Application.isBatchMode) EnsureGameView(); - ScreenshotCaptureResult defaultResult = ScreenshotUtility.CaptureToAssetsFolder(fileName, resolvedSuperSize, ensureUniqueFileName: true); + string defaultFolderOverride = ScreenshotPreferences.Resolve(cmd.outputFolder); + ScreenshotCaptureResult defaultResult; + try + { + defaultResult = ScreenshotUtility.CaptureToProjectFolder( + fileName, resolvedSuperSize, ensureUniqueFileName: true, + folderOverride: defaultFolderOverride); + } + catch (InvalidOperationException ex) + { + return new ErrorResponse(ex.Message); + } - if (defaultResult.IsAsync) - ScheduleAssetImportWhenFileExists(defaultResult.AssetsRelativePath, defaultResult.FullPath, timeoutSeconds: 30.0); - else - AssetDatabase.ImportAsset(defaultResult.AssetsRelativePath, ImportAssetOptions.ForceSynchronousImport); + if (ScreenshotUtility.IsUnderAssets(defaultResult.ProjectRelativePath)) + { + if (defaultResult.IsAsync) + ScheduleAssetImportWhenFileExists(defaultResult.ProjectRelativePath, defaultResult.FullPath, timeoutSeconds: 30.0); + else + AssetDatabase.ImportAsset(defaultResult.ProjectRelativePath, ImportAssetOptions.ForceSynchronousImport); + } string verb = defaultResult.IsAsync ? "Screenshot requested" : "Screenshot captured"; return new SuccessResponse( - $"{verb} to '{defaultResult.AssetsRelativePath}'.", + $"{verb} to '{defaultResult.ProjectRelativePath}'.", new { - path = defaultResult.AssetsRelativePath, + path = defaultResult.ProjectRelativePath, fullPath = defaultResult.FullPath, superSize = defaultResult.SuperSize, isAsync = defaultResult.IsAsync, @@ -718,22 +745,35 @@ private static object CaptureSceneViewScreenshot( try { - ScreenshotCaptureResult result = EditorWindowScreenshotUtility.CaptureSceneViewViewportToAssets( - sceneView, - fileName, - resolvedSuperSize, - ensureUniqueFileName: true, - includeImage: includeImage, - maxResolution: maxResolution, - out int viewportWidth, - out int viewportHeight); - - AssetDatabase.ImportAsset(result.AssetsRelativePath, ImportAssetOptions.ForceSynchronousImport); + string sceneViewFolderOverride = ScreenshotPreferences.Resolve(cmd.outputFolder); + ScreenshotCaptureResult result; + int viewportWidth; + int viewportHeight; + try + { + result = EditorWindowScreenshotUtility.CaptureSceneViewViewportToProject( + sceneView, + fileName, + resolvedSuperSize, + ensureUniqueFileName: true, + includeImage: includeImage, + maxResolution: maxResolution, + out viewportWidth, + out viewportHeight, + folderOverride: sceneViewFolderOverride); + } + catch (InvalidOperationException ex) when (ex.Message.StartsWith("Screenshot folder", StringComparison.Ordinal)) + { + return new ErrorResponse(ex.Message); + } + + if (ScreenshotUtility.IsUnderAssets(result.ProjectRelativePath)) + AssetDatabase.ImportAsset(result.ProjectRelativePath, ImportAssetOptions.ForceSynchronousImport); string sceneViewName = sceneView.titleContent?.text ?? "Scene"; var data = new Dictionary { - { "path", result.AssetsRelativePath }, + { "path", result.ProjectRelativePath }, { "fullPath", result.FullPath }, { "superSize", result.SuperSize }, { "isAsync", false }, @@ -758,7 +798,7 @@ private static object CaptureSceneViewScreenshot( } return new SuccessResponse( - $"Scene View screenshot captured to '{result.AssetsRelativePath}' (scene view: {sceneViewName}).", + $"Scene View screenshot captured to '{result.ProjectRelativePath}' (scene view: {sceneViewName}).", data); } catch (Exception e) @@ -879,14 +919,14 @@ private static object CaptureSurroundBatch(SceneCommand cmd) var (compositeB64, compW, compH) = ScreenshotUtility.ComposeContactSheet(tiles, tileLabels); - string screenshotsFolder = Path.Combine(Application.dataPath, "Screenshots"); + string outputFolder = ResolveAbsoluteOutputFolder(cmd.outputFolder); return new SuccessResponse( $"Captured {shotMeta.Count} multi-angle screenshots as contact sheet ({compW}x{compH}). Scene bounds center: ({center.x:F1}, {center.y:F1}, {center.z:F1}), radius: {radius:F1}.", new { sceneCenter = new[] { center.x, center.y, center.z }, sceneRadius = radius, - screenshotsFolder = screenshotsFolder, + outputFolder = outputFolder, imageBase64 = compositeB64, imageWidth = compW, imageHeight = compH, @@ -1026,7 +1066,7 @@ private static object CaptureOrbitBatch(SceneCommand cmd) // Compose all tiles into a single contact-sheet grid image var (compositeB64, compW, compH) = ScreenshotUtility.ComposeContactSheet(tiles, tileLabels); - string screenshotsFolder = Path.Combine(Application.dataPath, "Screenshots"); + string outputFolder = ResolveAbsoluteOutputFolder(cmd.outputFolder); return new SuccessResponse( $"Captured {shotMeta.Count} orbit screenshots as contact sheet ({compW}x{compH}, {azimuthCount} azimuths x {elevations.Length} elevations). Center: ({center.x:F1}, {center.y:F1}, {center.z:F1}), radius: {radius:F1}.", new @@ -1036,7 +1076,7 @@ private static object CaptureOrbitBatch(SceneCommand cmd) orbitAngles = azimuthCount, orbitElevations = elevations, orbitFov = fov, - screenshotsFolder = screenshotsFolder, + outputFolder = outputFolder, imageBase64 = compositeB64, imageWidth = compW, imageHeight = compH, @@ -1057,7 +1097,7 @@ private static object CaptureOrbitBatch(SceneCommand cmd) /// /// Captures a single screenshot from a temporary camera placed at view_position and aimed at view_target. - /// Returns inline base64 PNG and also saves the image to Assets/Screenshots/. + /// Returns inline base64 PNG and also saves the image to <projectRoot>/Captures/. /// private static object CapturePositionedScreenshot(SceneCommand cmd) { @@ -1118,13 +1158,23 @@ private static object CapturePositionedScreenshot(SceneCommand cmd) var (b64, w, h) = ScreenshotUtility.RenderCameraToBase64(tempCam, maxRes); - // Save to disk - string screenshotsFolder = Path.Combine(Application.dataPath, "Screenshots"); - Directory.CreateDirectory(screenshotsFolder); + // Resolve output folder (per-call override → user pref → built-in default). + string resolvedFolderSpec = ScreenshotPreferences.Resolve(cmd.outputFolder); + string folderAbsolute; + try + { + folderAbsolute = ScreenshotUtility.ResolveFolderAbsolute(resolvedFolderSpec); + } + catch (InvalidOperationException ex) + { + return new ErrorResponse(ex.Message); + } + Directory.CreateDirectory(folderAbsolute); + string fileName = !string.IsNullOrEmpty(cmd.fileName) ? (cmd.fileName.EndsWith(".png", System.StringComparison.OrdinalIgnoreCase) ? cmd.fileName : cmd.fileName + ".png") : $"screenshot-{DateTime.Now:yyyyMMdd-HHmmss}.png"; - string fullPath = Path.Combine(screenshotsFolder, fileName); + string fullPath = Path.Combine(folderAbsolute, fileName); // Ensure unique filename if (File.Exists(fullPath)) { @@ -1133,15 +1183,22 @@ private static object CapturePositionedScreenshot(SceneCommand cmd) int counter = 1; while (File.Exists(fullPath)) { - fullPath = Path.Combine(screenshotsFolder, $"{baseName}_{counter}{ext}"); + fullPath = Path.Combine(folderAbsolute, $"{baseName}_{counter}{ext}"); counter++; } } byte[] pngBytes = System.Convert.FromBase64String(b64); File.WriteAllBytes(fullPath, pngBytes); - string assetsRelativePath = "Assets/Screenshots/" + Path.GetFileName(fullPath); - AssetDatabase.ImportAsset(assetsRelativePath, ImportAssetOptions.ForceSynchronousImport); + string projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, "..")).Replace('\\', '/'); + string normalizedFull = fullPath.Replace('\\', '/'); + string normalizedRoot = projectRoot.EndsWith("/") ? projectRoot : projectRoot + "/"; + string projectRelativePath = normalizedFull.StartsWith(normalizedRoot, StringComparison.OrdinalIgnoreCase) + ? normalizedFull.Substring(normalizedRoot.Length) + : normalizedFull; + + if (ScreenshotUtility.IsUnderAssets(projectRelativePath)) + AssetDatabase.ImportAsset(projectRelativePath, ImportAssetOptions.ForceSynchronousImport); var data = new Dictionary { @@ -1149,14 +1206,15 @@ private static object CapturePositionedScreenshot(SceneCommand cmd) { "imageWidth", w }, { "imageHeight", h }, { "viewPosition", new[] { camPos.x, camPos.y, camPos.z } }, - { "screenshotsFolder", screenshotsFolder }, - { "path", assetsRelativePath }, + { "outputFolder", folderAbsolute.Replace('\\', '/') }, + { "path", projectRelativePath }, + { "fullPath", normalizedFull }, }; if (targetPos.HasValue) data["viewTarget"] = new[] { targetPos.Value.x, targetPos.Value.y, targetPos.Value.z }; return new SuccessResponse( - $"Positioned screenshot captured (max {maxRes}px) and saved to '{assetsRelativePath}'.", + $"Positioned screenshot captured (max {maxRes}px) and saved to '{projectRelativePath}'.", data ); } @@ -1171,6 +1229,24 @@ private static object CapturePositionedScreenshot(SceneCommand cmd) } } + /// + /// Resolves the per-call/per-pref/built-in screenshot folder spec to an absolute path. + /// Falls back to the built-in default when validation fails (used by paths that just want + /// to surface the folder name in metadata without performing a capture). + /// + private static string ResolveAbsoluteOutputFolder(string callerOverride) + { + string spec = ScreenshotPreferences.Resolve(callerOverride); + try + { + return ScreenshotUtility.ResolveFolderAbsolute(spec).Replace('\\', '/'); + } + catch + { + return ScreenshotUtility.ResolveFolderAbsolute(ScreenshotUtility.DefaultFolder).Replace('\\', '/'); + } + } + private static string GetDirectionLabel(float azimuthDeg) { float a = ((azimuthDeg % 360f) + 360f) % 360f; @@ -1457,7 +1533,6 @@ private static void ScheduleAssetImportWhenFileExists(string assetsRelativePath, if (File.Exists(fullPath)) { hasSeenFile = true; - AssetDatabase.ImportAsset(assetsRelativePath, ImportAssetOptions.ForceSynchronousImport); McpLog.Debug($"[ManageScene] Imported asset at '{assetsRelativePath}'."); EditorApplication.update -= tick; @@ -1467,7 +1542,6 @@ private static void ScheduleAssetImportWhenFileExists(string assetsRelativePath, catch (Exception e) { failureCount++; - if (failureCount <= maxLoggedFailures) { McpLog.Warn($"[ManageScene] Exception while importing asset '{assetsRelativePath}' from '{fullPath}' (attempt {failureCount}): {e}"); @@ -1477,14 +1551,9 @@ private static void ScheduleAssetImportWhenFileExists(string assetsRelativePath, if (EditorApplication.timeSinceStartup - start > timeoutSeconds) { if (!hasSeenFile) - { McpLog.Warn($"[ManageScene] Timed out waiting for file '{fullPath}' (asset: '{assetsRelativePath}') after {timeoutSeconds:F1} seconds. The asset was not imported."); - } else - { McpLog.Warn($"[ManageScene] Timed out importing asset '{assetsRelativePath}' from '{fullPath}' after {timeoutSeconds:F1} seconds. The file existed but the asset was not imported."); - } - EditorApplication.update -= tick; } }; @@ -1492,6 +1561,7 @@ private static void ScheduleAssetImportWhenFileExists(string assetsRelativePath, EditorApplication.update += tick; } + // ── Multi-scene editing ──────────────────────────────────────────── private static object LoadSceneAdditive(string scenePath) diff --git a/MCPForUnity/Editor/Tools/ManageUI.cs b/MCPForUnity/Editor/Tools/ManageUI.cs index ec1a57c81..099b19ae9 100644 --- a/MCPForUnity/Editor/Tools/ManageUI.cs +++ b/MCPForUnity/Editor/Tools/ManageUI.cs @@ -860,12 +860,24 @@ private static object RenderUI(JObject @params) bool includeImage = p.GetBool("include_image") || p.GetBool("includeImage"); int maxResolution = p.GetInt("max_resolution") ?? p.GetInt("maxResolution") ?? 640; string fileName = p.Get("file_name") ?? p.Get("fileName"); + string outputFolderOverride = p.Get("output_folder") ?? p.Get("outputFolder"); if (string.IsNullOrEmpty(target) && string.IsNullOrEmpty(uxmlPath)) { return new ErrorResponse("Either 'target' (GameObject with UIDocument) or 'path' (UXML asset path) is required."); } + string resolvedFolderSpec = ScreenshotPreferences.Resolve(outputFolderOverride); + string resolvedFolderAbs; + try + { + resolvedFolderAbs = ScreenshotUtility.ResolveFolderAbsolute(resolvedFolderSpec); + } + catch (InvalidOperationException ex) + { + return new ErrorResponse(ex.Message); + } + // ── Play-mode capture via ScreenCapture coroutine ────────────────────── // PanelSettings.targetTexture is read in the same frame it is assigned, // so the RT is always blank in a synchronous tool call. In play mode we @@ -882,11 +894,10 @@ private static object RenderUI(JObject @params) if (!resolvedPlayName.EndsWith(".png", StringComparison.OrdinalIgnoreCase)) resolvedPlayName += ".png"; - string playFolder = Path.Combine(Application.dataPath, "Screenshots"); - Directory.CreateDirectory(playFolder); - string playFullPath = Path.Combine(playFolder, resolvedPlayName).Replace('\\', '/'); + Directory.CreateDirectory(resolvedFolderAbs); + string playFullPath = Path.Combine(resolvedFolderAbs, resolvedPlayName).Replace('\\', '/'); playFullPath = EnsureUniqueFilePath(playFullPath); - string playAssetsRelPath = "Assets/Screenshots/" + Path.GetFileName(playFullPath); + string playProjectRelPath = ToProjectRelativePathLocal(playFullPath); // ── Case 1: capture is ready ────────────────────────────────────── if (s_pendingCaptureDone && s_pendingCaptureTex != null) @@ -901,11 +912,12 @@ private static object RenderUI(JObject @params) UnityEngine.Object.DestroyImmediate(captureTex); File.WriteAllBytes(playFullPath, capturePng); - AssetDatabase.ImportAsset(playAssetsRelPath, ImportAssetOptions.ForceSynchronousImport); + if (ScreenshotUtility.IsUnderAssets(playProjectRelPath)) + AssetDatabase.ImportAsset(playProjectRelPath, ImportAssetOptions.ForceSynchronousImport); var playData = new Dictionary { - { "path", playAssetsRelPath }, + { "path", playProjectRelPath }, { "fullPath", playFullPath }, { "width", captureW }, { "height", captureH }, @@ -944,7 +956,7 @@ private static object RenderUI(JObject @params) } } - return new SuccessResponse($"UI render saved to '{playAssetsRelPath}'.", playData); + return new SuccessResponse($"UI render saved to '{playProjectRelPath}'.", playData); } // ── Case 2: start a new capture ─────────────────────────────────── @@ -1134,20 +1146,20 @@ private static object RenderUI(JObject @params) if (!resolvedName.EndsWith(".png", StringComparison.OrdinalIgnoreCase)) resolvedName += ".png"; - string folder = Path.Combine(Application.dataPath, "Screenshots"); - Directory.CreateDirectory(folder); - string fullPath = Path.Combine(folder, resolvedName).Replace('\\', '/'); + Directory.CreateDirectory(resolvedFolderAbs); + string fullPath = Path.Combine(resolvedFolderAbs, resolvedName).Replace('\\', '/'); fullPath = EnsureUniqueFilePath(fullPath); byte[] png = tex.EncodeToPNG(); File.WriteAllBytes(fullPath, png); - string assetsRelPath = "Assets/Screenshots/" + Path.GetFileName(fullPath); - AssetDatabase.ImportAsset(assetsRelPath, ImportAssetOptions.ForceSynchronousImport); + string projectRelPath = ToProjectRelativePathLocal(fullPath); + if (ScreenshotUtility.IsUnderAssets(projectRelPath)) + AssetDatabase.ImportAsset(projectRelPath, ImportAssetOptions.ForceSynchronousImport); var data = new Dictionary { - { "path", assetsRelPath }, + { "path", projectRelPath }, { "fullPath", fullPath }, { "width", width }, { "height", height }, @@ -1191,10 +1203,10 @@ private static object RenderUI(JObject @params) UnityEngine.Object.DestroyImmediate(tex); string msg = hasContent - ? $"UI rendered to '{assetsRelPath}'." + ? $"UI rendered to '{projectRelPath}'." : rtJustAssigned ? $"RenderTexture assigned to PanelSettings. Call render_ui again to capture the rendered content." - : $"UI render saved to '{assetsRelPath}' (no visible content detected)."; + : $"UI render saved to '{projectRelPath}' (no visible content detected)."; return new SuccessResponse(msg, data); } @@ -1811,6 +1823,15 @@ private static string EnsureUniqueFilePath(string path) return candidate; } + private static string ToProjectRelativePathLocal(string normalizedFullPath) + { + string projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, "..")).Replace('\\', '/'); + string normalizedRoot = projectRoot.EndsWith("/") ? projectRoot : projectRoot + "/"; + return normalizedFullPath.StartsWith(normalizedRoot, StringComparison.OrdinalIgnoreCase) + ? normalizedFullPath.Substring(normalizedRoot.Length) + : normalizedFullPath; + } + private static string ColorToHex(Color c) { return $"#{ColorUtility.ToHtmlStringRGBA(c)}"; diff --git a/MCPForUnity/Editor/Tools/Physics/PhysicsSettingsOps.cs b/MCPForUnity/Editor/Tools/Physics/PhysicsSettingsOps.cs index 69bc247e6..52af8145d 100644 --- a/MCPForUnity/Editor/Tools/Physics/PhysicsSettingsOps.cs +++ b/MCPForUnity/Editor/Tools/Physics/PhysicsSettingsOps.cs @@ -3,6 +3,7 @@ using UnityEditor; using UnityEngine; using MCPForUnity.Editor.Helpers; +using MCPForUnity.Runtime.Helpers; namespace MCPForUnity.Editor.Tools.Physics { @@ -12,12 +13,7 @@ public static object Ping(JObject @params) { var gravity3d = UnityEngine.Physics.gravity; var gravity2d = Physics2D.gravity; - -#if UNITY_2022_2_OR_NEWER - var simMode = UnityEngine.Physics.simulationMode.ToString(); -#else - var simMode = UnityEngine.Physics.autoSimulation ? "FixedUpdate" : "Script"; -#endif + var simMode = UnityPhysicsCompat.GetPhysicsSimulationMode().ToString(); return new { @@ -59,7 +55,7 @@ public static object GetSettings(JObject @params) queriesHitTriggers = Physics2D.queriesHitTriggers, queriesStartInColliders = Physics2D.queriesStartInColliders, callbacksOnDisable = Physics2D.callbacksOnDisable, - autoSyncTransforms = Physics2D.autoSyncTransforms + autoSyncTransforms = UnityPhysicsCompat.GetPhysics2DAutoSyncTransforms() } }; } @@ -68,11 +64,7 @@ public static object GetSettings(JObject @params) return new ErrorResponse($"Invalid dimension: '{dimension}'. Use '3d' or '2d'."); var g3 = UnityEngine.Physics.gravity; -#if UNITY_2022_2_OR_NEWER - var simMode = UnityEngine.Physics.simulationMode.ToString(); -#else - var simMode = UnityEngine.Physics.autoSimulation ? "FixedUpdate" : "Script"; -#endif + var simMode = UnityPhysicsCompat.GetPhysicsSimulationMode().ToString(); return new { @@ -90,7 +82,8 @@ public static object GetSettings(JObject @params) defaultMaxAngularSpeed = UnityEngine.Physics.defaultMaxAngularSpeed, queriesHitTriggers = UnityEngine.Physics.queriesHitTriggers, queriesHitBackfaces = UnityEngine.Physics.queriesHitBackfaces, - simulationMode = simMode + simulationMode = simMode, + autoSyncTransforms = UnityPhysicsCompat.GetPhysicsAutoSyncTransforms() } }; } @@ -118,7 +111,8 @@ public static object SetSettings(JObject @params) "gravity", "defaultcontactoffset", "sleepthreshold", "defaultsolveriterations", "defaultsolvervelocityiterations", "bouncethreshold", "defaultmaxangularspeed", - "querieshittriggers", "querieshitbackfaces", "simulationmode" + "querieshittriggers", "querieshitbackfaces", "simulationmode", + "autosynctransforms" }; private static object SetSettings3D(JObject settings) @@ -184,32 +178,28 @@ private static object SetSettings3D(JObject settings) changed.Add("queriesHitBackfaces"); break; case "simulationmode": -#if UNITY_2022_2_OR_NEWER { string modeStr = prop.Value.ToString(); - if (System.Enum.TryParse(modeStr, true, out var mode)) + if (!System.Enum.TryParse(modeStr, true, out var mode) + || mode == UnityPhysicsCompat.SimulationMode.Unknown) { - UnityEngine.Physics.simulationMode = mode; - changed.Add("simulationMode"); + return new ErrorResponse( + $"Invalid simulationMode: '{modeStr}'. Valid: FixedUpdate, Update, Script."); } - else + if (!UnityPhysicsCompat.TrySetPhysicsSimulationMode(mode)) { return new ErrorResponse( - $"Invalid simulationMode: '{modeStr}'. Valid: FixedUpdate, Update, Script."); + $"simulationMode '{modeStr}' is not supported on this Unity version."); } - break; - } -#else - { - string modeStr = prop.Value.ToString().ToLowerInvariant(); - if (modeStr == "fixedupdate") UnityEngine.Physics.autoSimulation = true; - else if (modeStr == "script") UnityEngine.Physics.autoSimulation = false; - else return new ErrorResponse( - $"Invalid simulationMode: '{prop.Value}'. Valid: FixedUpdate, Script."); changed.Add("simulationMode"); break; } -#endif + case "autosynctransforms": + if (UnityPhysicsCompat.TrySetPhysicsAutoSyncTransforms(prop.Value.Value())) + { + changed.Add("autoSyncTransforms"); + } + break; } } @@ -281,8 +271,10 @@ private static object SetSettings2D(JObject settings) changed.Add("callbacksOnDisable"); break; case "autosynctransforms": - Physics2D.autoSyncTransforms = prop.Value.Value(); - changed.Add("autoSyncTransforms"); + if (UnityPhysicsCompat.TrySetPhysics2DAutoSyncTransforms(prop.Value.Value())) + { + changed.Add("autoSyncTransforms"); + } break; } } diff --git a/MCPForUnity/Editor/Tools/Physics/PhysicsSimulationOps.cs b/MCPForUnity/Editor/Tools/Physics/PhysicsSimulationOps.cs index 8bf4d752a..fc922110f 100644 --- a/MCPForUnity/Editor/Tools/Physics/PhysicsSimulationOps.cs +++ b/MCPForUnity/Editor/Tools/Physics/PhysicsSimulationOps.cs @@ -29,10 +29,9 @@ public static object SimulateStep(JObject @params) else { UnityEngine.Physics.SyncTransforms(); -#if UNITY_2022_2_OR_NEWER - var prevMode = UnityEngine.Physics.simulationMode; - if (prevMode != SimulationMode.Script) - UnityEngine.Physics.simulationMode = SimulationMode.Script; + var prevMode = UnityPhysicsCompat.GetPhysicsSimulationMode(); + if (prevMode != UnityPhysicsCompat.SimulationMode.Script) + UnityPhysicsCompat.TrySetPhysicsSimulationMode(UnityPhysicsCompat.SimulationMode.Script); try { for (int i = 0; i < steps; i++) @@ -40,22 +39,12 @@ public static object SimulateStep(JObject @params) } finally { - UnityEngine.Physics.simulationMode = prevMode; - } -#else - bool wasAuto = UnityEngine.Physics.autoSimulation; - if (wasAuto) - UnityEngine.Physics.autoSimulation = false; - try - { - for (int i = 0; i < steps; i++) - UnityEngine.Physics.Simulate(stepSize); - } - finally - { - UnityEngine.Physics.autoSimulation = wasAuto; + if (prevMode != UnityPhysicsCompat.SimulationMode.Unknown + && prevMode != UnityPhysicsCompat.SimulationMode.Script) + { + UnityPhysicsCompat.TrySetPhysicsSimulationMode(prevMode); + } } -#endif } // Collect rigidbody states after simulation diff --git a/MCPForUnity/Editor/Tools/UnityReflect.cs b/MCPForUnity/Editor/Tools/UnityReflect.cs index 0af4f6187..06aee0d7f 100644 --- a/MCPForUnity/Editor/Tools/UnityReflect.cs +++ b/MCPForUnity/Editor/Tools/UnityReflect.cs @@ -6,6 +6,7 @@ using System.Runtime.CompilerServices; using System.Text.RegularExpressions; using MCPForUnity.Editor.Helpers; +using MCPForUnity.Runtime.Helpers; using Newtonsoft.Json.Linq; using UnityEditor; @@ -81,7 +82,7 @@ private static Dictionary GetAssemblyTypeCache() return _assemblyTypeCache; _assemblyTypeCache = new Dictionary(); - foreach (var asm in AppDomain.CurrentDomain.GetAssemblies()) + foreach (var asm in UnityAssembliesCompat.GetLoadedAssemblies()) { try { diff --git a/MCPForUnity/Editor/Windows/Components/Advanced/McpAdvancedSection.cs b/MCPForUnity/Editor/Windows/Components/Advanced/McpAdvancedSection.cs index eec0f9a74..6f9069519 100644 --- a/MCPForUnity/Editor/Windows/Components/Advanced/McpAdvancedSection.cs +++ b/MCPForUnity/Editor/Windows/Components/Advanced/McpAdvancedSection.cs @@ -30,6 +30,9 @@ public class McpAdvancedSection private Toggle devModeForceRefreshToggle; private Toggle allowLanHttpBindToggle; private Toggle allowInsecureRemoteHttpToggle; + private TextField screenshotsFolderOverride; + private Button browseScreenshotsFolderButton; + private Button clearScreenshotsFolderButton; private TextField deploySourcePath; private Button browseDeploySourceButton; private Button clearDeploySourceButton; @@ -73,6 +76,9 @@ private void CacheUIElements() devModeForceRefreshToggle = Root.Q("dev-mode-force-refresh-toggle"); allowLanHttpBindToggle = Root.Q("allow-lan-http-bind-toggle"); allowInsecureRemoteHttpToggle = Root.Q("allow-insecure-remote-http-toggle"); + screenshotsFolderOverride = Root.Q("screenshots-folder-override"); + browseScreenshotsFolderButton = Root.Q