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..168167852 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,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 +1159,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 +1184,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 +1207,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 +1230,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 +1527,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 +1536,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 +1545,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 +1555,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/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