diff --git a/.github/workflows/beta-release.yml b/.github/workflows/beta-release.yml index 457dc736b..d5df5a337 100644 --- a/.github/workflows/beta-release.yml +++ b/.github/workflows/beta-release.yml @@ -13,9 +13,21 @@ on: - "MCPForUnity/**" jobs: + unity_tests: + name: Unity tests gate + if: github.actor != 'github-actions[bot]' + uses: ./.github/workflows/unity-tests.yml + secrets: inherit + + python_tests: + name: Python tests gate + if: github.actor != 'github-actions[bot]' + uses: ./.github/workflows/python-tests.yml + update_unity_beta_version: name: Update Unity package to beta version runs-on: ubuntu-latest + needs: [unity_tests, python_tests] # Avoid running when the workflow's own automation merges the PR # created by this workflow (prevents a version-bump loop). if: github.actor != 'github-actions[bot]' @@ -143,6 +155,7 @@ jobs: publish_pypi_prerelease: name: Publish beta to PyPI (pre-release) runs-on: ubuntu-latest + needs: [unity_tests, python_tests] # Avoid double-publish when the bot merges the version bump PR if: github.actor != 'github-actions[bot]' environment: diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index 55e82c495..134728bec 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -6,7 +6,19 @@ on: paths: - Server/** - .github/workflows/python-tests.yml + pull_request: + branches: [main, beta] + paths: + - Server/** + - .github/workflows/python-tests.yml workflow_dispatch: {} + workflow_call: + inputs: + ref: + description: "Git ref to test (defaults to the triggering ref)." + type: string + required: false + default: "" jobs: test: @@ -15,6 +27,8 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@v4 + with: + ref: ${{ inputs.ref || github.ref }} - name: Install uv uses: astral-sh/setup-uv@v4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index af5309aed..4815e7ca7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,9 +19,23 @@ on: required: true jobs: + unity_tests: + name: Unity tests gate + uses: ./.github/workflows/unity-tests.yml + with: + ref: beta + secrets: inherit + + python_tests: + name: Python tests gate + uses: ./.github/workflows/python-tests.yml + with: + ref: beta + bump: name: Bump version, tag, and create release runs-on: ubuntu-latest + needs: [unity_tests, python_tests] permissions: contents: write pull-requests: write diff --git a/.github/workflows/unity-tests.yml b/.github/workflows/unity-tests.yml index fbb96d389..e4fbed4f3 100644 --- a/.github/workflows/unity-tests.yml +++ b/.github/workflows/unity-tests.yml @@ -2,17 +2,43 @@ name: Unity Tests on: workflow_dispatch: {} + workflow_call: + inputs: + ref: + description: "Git ref to test (defaults to the triggering ref)." + type: string + required: false + default: "" push: branches: ["**"] paths: - TestProjects/UnityMCPTests/** - MCPForUnity/Editor/** + - MCPForUnity/Runtime/** + - .github/workflows/unity-tests.yml + # Fork PRs: maintainer applies the 'safe-to-test' label after reviewing + # the diff. The workflow runs with UNITY_LICENSE in scope against the + # PR's head SHA. Re-pushed commits do NOT auto-trigger — maintainer must + # remove and re-apply the label to re-run after additional review. + pull_request_target: + types: [labeled] + branches: [main, beta] + paths: + - TestProjects/UnityMCPTests/** + - MCPForUnity/Editor/** + - MCPForUnity/Runtime/** - .github/workflows/unity-tests.yml jobs: testAllModes: name: Test in ${{ matrix.testMode }} runs-on: ubuntu-latest + permissions: + contents: read + if: > + github.event_name != 'pull_request_target' || + (github.event.pull_request.head.repo.full_name != github.repository && + github.event.label.name == 'safe-to-test') strategy: fail-fast: false matrix: @@ -27,6 +53,8 @@ jobs: uses: actions/checkout@v4 with: lfs: true + ref: ${{ inputs.ref || github.event.pull_request.head.sha || github.ref }} + persist-credentials: false - name: Detect Unity license secrets id: detect @@ -107,7 +135,7 @@ jobs: fi - uses: actions/upload-artifact@v4 - if: always() && steps.detect.outputs.unity_ok == 'true' + if: always() && steps.detect.outputs.unity_ok == 'true' && steps.tests.outcome != 'skipped' with: name: Test results for ${{ matrix.testMode }} path: ${{ steps.tests.outputs.artifactsPath }} 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/PortManager.cs b/MCPForUnity/Editor/Helpers/PortManager.cs index 11a8d7eef..77f824535 100644 --- a/MCPForUnity/Editor/Helpers/PortManager.cs +++ b/MCPForUnity/Editor/Helpers/PortManager.cs @@ -1,4 +1,5 @@ using System; +using System.Diagnostics; using System.IO; using System.Net; using System.Net.Sockets; @@ -33,10 +34,13 @@ public class PortConfig public int unity_port; public string created_date; public string project_path; + public int pid; } /// /// Get the port to use from storage, or return the default if none has been saved yet. + /// When the stored port is in use by another process (multi-instance scenario), + /// falls back to the default port instead of returning an occupied port. /// /// Port number to use public static int GetPortWithFallback() @@ -46,7 +50,15 @@ public static int GetPortWithFallback() storedConfig.unity_port > 0 && string.Equals(storedConfig.project_path ?? string.Empty, Application.dataPath ?? string.Empty, StringComparison.OrdinalIgnoreCase)) { - return storedConfig.unity_port; + // Only return the stored port if it's actually available. + // When another instance of the same project is running, the stored + // port will be occupied; falling back to DefaultPort lets Start()'s + // SocketException handler pick a truly free port via DiscoverNewPort(). + if (IsPortAvailable(storedConfig.unity_port)) + { + return storedConfig.unity_port; + } + if (IsDebugEnabled()) McpLog.Info($"Stored port {storedConfig.unity_port} is occupied, falling back to default"); } return DefaultPort; @@ -122,12 +134,10 @@ public static bool IsPortAvailable(int port) try { var testListener = new TcpListener(IPAddress.Loopback, port); -#if UNITY_EDITOR_OSX - // On macOS, SO_REUSEADDR (the default) lets multiple processes bind the same - // port — including AssetImportWorkers. ExclusiveAddressUse prevents this so - // the test bind fails when another process already holds the port. + // ExclusiveAddressUse prevents SO_REUSEADDR from allowing multiple + // processes (including AssetImportWorkers) to bind the same port. + // The test bind fails when another process already holds the port. try { testListener.Server.ExclusiveAddressUse = true; } catch { } -#endif testListener.Start(); testListener.Stop(); } @@ -202,23 +212,38 @@ private static void SavePort(int port) { try { + int pid = 0; + try { pid = System.Diagnostics.Process.GetCurrentProcess().Id; } catch { } + var portConfig = new PortConfig { unity_port = port, created_date = DateTime.UtcNow.ToString("O"), - project_path = Application.dataPath + project_path = Application.dataPath, + pid = pid }; string registryDir = GetRegistryDirectory(); Directory.CreateDirectory(registryDir); - string registryFile = GetRegistryFilePath(); string json = JsonConvert.SerializeObject(portConfig, Formatting.Indented); + byte[] utf8Bytes = new System.Text.UTF8Encoding(false).GetBytes(json); + // Write to hashed, project-scoped file - File.WriteAllText(registryFile, json, new System.Text.UTF8Encoding(false)); + string registryFile = GetRegistryFilePath(); + File.WriteAllBytes(registryFile, utf8Bytes); // Also write to legacy stable filename to avoid hash/case drift across reloads string legacy = Path.Combine(GetRegistryDirectory(), RegistryFileName); - File.WriteAllText(legacy, json, new System.Text.UTF8Encoding(false)); + File.WriteAllBytes(legacy, utf8Bytes); + + // Write instance-scoped file so multiple instances of the same project + // can be tracked independently. + if (pid > 0) + { + string hash = ComputeProjectHash(Application.dataPath); + string instanceFile = Path.Combine(registryDir, $"unity-mcp-port-{hash}-{pid}.json"); + File.WriteAllBytes(instanceFile, utf8Bytes); + } if (IsDebugEnabled()) McpLog.Info($"Saved port {port} to storage"); } 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/Services/HttpAutoStartHandler.cs b/MCPForUnity/Editor/Services/HttpAutoStartHandler.cs index 981dc2141..0a57fc94b 100644 --- a/MCPForUnity/Editor/Services/HttpAutoStartHandler.cs +++ b/MCPForUnity/Editor/Services/HttpAutoStartHandler.cs @@ -17,37 +17,44 @@ namespace MCPForUnity.Editor.Services [InitializeOnLoad] internal static class HttpAutoStartHandler { - private const string SessionInitKey = "HttpAutoStartHandler.SessionInitialized"; + private static bool _autoStartExecuted = false; + private static int _retryCount = 0; + private const int MaxRetries = 120; static HttpAutoStartHandler() { - // SessionState resets on editor process start but persists across domain reloads. - // Only run once per session — let HttpBridgeReloadHandler handle reload-resume cases. - if (SessionState.GetBool(SessionInitKey, false)) return; - if (Application.isBatchMode && string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("UNITY_MCP_ALLOW_BATCH"))) { return; } - // Only check lightweight EditorPrefs here — services like EditorConfigurationCache - // and MCPServiceLocator may not be initialized yet on fresh editor launch. - bool autoStartEnabled = EditorPrefs.GetBool(EditorPrefKeys.AutoStartOnLoad, false); - if (!autoStartEnabled) return; - - SessionState.SetBool(SessionInitKey, true); - - // Delay to let the editor and services finish initialization. - EditorApplication.delayCall += OnEditorReady; + // EditorPrefs may not be initialized during [InitializeOnLoad] / + // EditorApplication.delayCall. Use update to poll until it's ready. + _retryCount = 0; + _autoStartExecuted = false; + EditorApplication.update += TryAutoStartOnce; } - private static void OnEditorReady() + private static void TryAutoStartOnce() { + if (_autoStartExecuted) return; + try { bool autoStartEnabled = EditorPrefs.GetBool(EditorPrefKeys.AutoStartOnLoad, false); - if (!autoStartEnabled) return; + _retryCount++; + if (!autoStartEnabled) + { + if (_retryCount >= MaxRetries) + { + EditorApplication.update -= TryAutoStartOnce; + } + return; + } + + EditorApplication.update -= TryAutoStartOnce; + _autoStartExecuted = true; bool useHttp = EditorConfigurationCache.Instance.UseHttpTransport; if (!useHttp) return; diff --git a/MCPForUnity/Editor/Services/TestJobManager.cs b/MCPForUnity/Editor/Services/TestJobManager.cs index b7bb32fc3..d162476e8 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,8 @@ 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 long MaxInitializationTimeoutMs = 600_000; // 10 minutes hard cap private const int MaxJobsToKeep = 10; private const long MinPersistIntervalMs = 1000; // Throttle persistence to reduce overhead @@ -139,6 +141,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 +204,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 +277,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,8 +299,12 @@ 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) { + // 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(); @@ -316,7 +325,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 +501,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/Services/Transport/Transports/StdioBridgeHost.cs b/MCPForUnity/Editor/Services/Transport/Transports/StdioBridgeHost.cs index 0e9674d65..fde25aa31 100644 --- a/MCPForUnity/Editor/Services/Transport/Transports/StdioBridgeHost.cs +++ b/MCPForUnity/Editor/Services/Transport/Transports/StdioBridgeHost.cs @@ -334,14 +334,12 @@ public static void Start() private static TcpListener CreateConfiguredListener(int port) { var newListener = new TcpListener(IPAddress.Loopback, port); -#if UNITY_EDITOR_OSX - // SO_REUSEADDR is intentionally NOT set. On macOS it allows multiple - // processes (including AssetImportWorkers) to bind the same port, - // causing connections to land on a worker that can't process commands. - // The ExclusiveAddressUse flag prevents this; port-busy conflicts are - // handled by the retry/fallback logic in Start() and the reload handler. + // SO_REUSEADDR allows multiple processes to bind the same port on + // Linux/macOS, causing connections to land on a worker/old instance + // that can't process commands. ExclusiveAddressUse prevents this; + // port-busy conflicts are handled by the retry/fallback logic in + // Start() and the reload handler. try { newListener.Server.ExclusiveAddressUse = true; } catch { } -#endif try { newListener.Server.LingerState = new LingerOption(true, 0); @@ -412,11 +410,25 @@ public static void Stop() { dir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".unity-mcp"); } - string statusFile = Path.Combine(dir, $"unity-mcp-status-{ComputeProjectHash(Application.dataPath)}.json"); - if (File.Exists(statusFile)) + string projectHash = ComputeProjectHash(Application.dataPath); + + string projectFile = Path.Combine(dir, $"unity-mcp-status-{projectHash}.json"); + if (File.Exists(projectFile)) + { + File.Delete(projectFile); + if (IsDebugEnabled()) McpLog.Info($"Deleted project status file: {projectFile}"); + } + + // Clean up instance-specific file so stale entries don't accumulate. + int pid = s_CachedProcessId; + if (pid > 0) { - File.Delete(statusFile); - if (IsDebugEnabled()) McpLog.Info($"Deleted status file: {statusFile}"); + string instanceFile = Path.Combine(dir, $"unity-mcp-status-{projectHash}-{pid}.json"); + if (File.Exists(instanceFile)) + { + File.Delete(instanceFile); + if (IsDebugEnabled()) McpLog.Info($"Deleted instance status file: {instanceFile}"); + } } } catch (Exception ex) @@ -1015,6 +1027,13 @@ private static bool IsValidJson(string text) } + private static readonly int s_CachedProcessId = GetProcessId(); + + private static int GetProcessId() + { + try { return Process.GetCurrentProcess().Id; } catch { return 0; } + } + public static void WriteHeartbeat(bool reloading, string reason = null) { try @@ -1025,7 +1044,6 @@ public static void WriteHeartbeat(bool reloading, string reason = null) dir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".unity-mcp"); } Directory.CreateDirectory(dir); - string filePath = Path.Combine(dir, $"unity-mcp-status-{ComputeProjectHash(Application.dataPath)}.json"); string projectName = "Unknown"; try @@ -1050,6 +1068,7 @@ public static void WriteHeartbeat(bool reloading, string reason = null) var payload = new { unity_port = currentUnityPort, + pid = s_CachedProcessId, reloading, reason = reason ?? (reloading ? "reloading" : "ready"), seq = heartbeatSeq, @@ -1058,7 +1077,21 @@ public static void WriteHeartbeat(bool reloading, string reason = null) unity_version = Application.unityVersion, last_heartbeat = DateTime.UtcNow.ToString("O") }; - File.WriteAllText(filePath, JsonConvert.SerializeObject(payload), new System.Text.UTF8Encoding(false)); + string json = JsonConvert.SerializeObject(payload); + byte[] utf8Bytes = new System.Text.UTF8Encoding(false).GetBytes(json); + + // Project-scoped file: used by clients that look for any instance of this project. + string projectFile = Path.Combine(dir, $"unity-mcp-status-{ComputeProjectHash(Application.dataPath)}.json"); + File.WriteAllBytes(projectFile, utf8Bytes); + + // Instance-scoped file: allows clients to discover and select specific + // instances when multiple copies of the same project are running. + if (s_CachedProcessId > 0) + { + string instanceFile = Path.Combine(dir, + $"unity-mcp-status-{ComputeProjectHash(Application.dataPath)}-{s_CachedProcessId}.json"); + File.WriteAllBytes(instanceFile, utf8Bytes); + } } catch (Exception) { 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..aa3c78602 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"]), @@ -589,36 +591,78 @@ 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(); + + string folderOverride = ScreenshotPreferences.Resolve(cmd.outputFolder); + ScreenshotCaptureResult result = ScreenshotUtility.CaptureFromCameraToProjectFolder( + targetCamera, fileName, resolvedSuperSize, ensureUniqueFileName: true, + includeImage: includeImage, maxResolution: maxResolution, + folderOverride: folderOverride); + + if (ScreenshotUtility.IsUnderAssets(result.ProjectRelativePath)) + AssetDatabase.ImportAsset(result.ProjectRelativePath, ImportAssetOptions.ForceSynchronousImport); + string message = $"Screenshot captured to '{result.ProjectRelativePath}' (camera: {targetCamera.name})."; + return new SuccessResponse(message, BuildScreenshotResponseData(result, targetCamera.name, includeImage)); + } + + if (includeImage && Application.isPlaying) + { + if (!Application.isBatchMode) EnsureGameView(); + + string folderOverride = ScreenshotPreferences.Resolve(cmd.outputFolder); + ScreenshotCaptureResult result = ScreenshotUtility.CaptureComposited( + fileName, resolvedSuperSize, ensureUniqueFileName: true, + includeImage: true, maxResolution: maxResolution, + folderOverride: folderOverride); + + if (ScreenshotUtility.IsUnderAssets(result.ProjectRelativePath)) + AssetDatabase.ImportAsset(result.ProjectRelativePath, ImportAssetOptions.ForceSynchronousImport); + string cameraName = Camera.main != null ? Camera.main.name : "composited"; + string message = $"Screenshot captured to '{result.ProjectRelativePath}' (camera: {cameraName})."; + return new SuccessResponse(message, BuildScreenshotResponseData(result, cameraName, includeImage: true)); + } + + 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(); - 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})."; + 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); + } + 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 }, @@ -631,7 +675,7 @@ private static object CaptureScreenshot(SceneCommand cmd) 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 @@ -662,19 +706,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, @@ -688,6 +746,31 @@ private static object CaptureScreenshot(SceneCommand cmd) } } + private static Dictionary BuildScreenshotResponseData( + ScreenshotCaptureResult result, + string cameraName, + bool includeImage) + { + var data = new Dictionary + { + { "path", result.ProjectRelativePath }, + { "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, @@ -718,22 +801,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 +854,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 +975,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 +1122,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 +1132,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 +1153,8 @@ 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 the resolved screenshot folder + /// (caller's output_folder override -> ScreenshotPreferences.DefaultFolder -> built-in Assets/Screenshots). /// private static object CapturePositionedScreenshot(SceneCommand cmd) { @@ -1118,13 +1215,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 +1240,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 +1263,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 +1286,17 @@ private static object CapturePositionedScreenshot(SceneCommand cmd) } } + /// + /// Resolves the per-call/per-pref/built-in screenshot folder spec to an absolute path. + /// Propagates validation errors from + /// so callers can surface them rather than silently writing somewhere else. + /// + private static string ResolveAbsoluteOutputFolder(string callerOverride) + { + string spec = ScreenshotPreferences.Resolve(callerOverride); + return ScreenshotUtility.ResolveFolderAbsolute(spec).Replace('\\', '/'); + } + private static string GetDirectionLabel(float azimuthDeg) { float a = ((azimuthDeg % 360f) + 360f) % 360f; @@ -1457,7 +1583,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 +1592,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 +1601,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 +1611,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..f67bcff40 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 = ScreenshotUtility.ToProjectRelativePath(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 = ScreenshotUtility.ToProjectRelativePath(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); } 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/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/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..8a4c98c16 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