diff --git a/MCPForUnity/Editor/Tools/ExecuteCode.cs b/MCPForUnity/Editor/Tools/ExecuteCode.cs index 229c084d4..bef08d49a 100644 --- a/MCPForUnity/Editor/Tools/ExecuteCode.cs +++ b/MCPForUnity/Editor/Tools/ExecuteCode.cs @@ -32,11 +32,32 @@ public static class ExecuteCode private static string[] _cachedAssemblyPaths; private static string[] _cachedCodeDomAssemblyPaths; + // Every compile emits a fresh in-memory "MCPDynamic" assembly, and Mono cannot unload + // one, so recompiling an identical snippet leaks an assembly per call until the next + // domain reload (see issue #1351). Cache the compiled output keyed on the wrapped + // source so repeated calls reuse one assembly. + private const int MaxCompiledCacheEntries = 64; + private static readonly Dictionary _compiledCache = + new Dictionary(StringComparer.Ordinal); + + private readonly struct CompiledSnippet + { + public CompiledSnippet(Assembly assembly, string compiler) + { + Assembly = assembly; + Compiler = compiler; + } + + public Assembly Assembly { get; } + public string Compiler { get; } + } + [UnityEditor.InitializeOnLoadMethod] private static void OnDomainReload() { _cachedAssemblyPaths = null; _cachedCodeDomAssemblyPaths = null; + _compiledCache.Clear(); RoslynCompiler.ResetCache(); } @@ -181,6 +202,11 @@ private static object HandleReplay(JObject @params) private static object CompileAndExecute(string code, string compiler) { string wrappedSource = WrapUserCode(code); + + string cacheKey = compiler + "\n" + wrappedSource; + if (_compiledCache.TryGetValue(cacheKey, out CompiledSnippet cached)) + return InvokeCompiled(cached.Assembly, cached.Compiler); + string[] assemblyPaths = GetAssemblyPaths(); Assembly compiled; @@ -222,6 +248,12 @@ private static object CompileAndExecute(string code, string compiler) break; } + // Bound the cache so a stream of genuinely distinct snippets cannot itself become + // the leak. Clearing wholesale is enough: the entries are only a compile shortcut. + if (_compiledCache.Count >= MaxCompiledCacheEntries) + _compiledCache.Clear(); + _compiledCache[cacheKey] = new CompiledSnippet(compiled, usedCompiler); + return InvokeCompiled(compiled, usedCompiler); } diff --git a/MCPForUnity/Editor/Tools/ManageScene.cs b/MCPForUnity/Editor/Tools/ManageScene.cs index c3c66b3fa..fbc324356 100644 --- a/MCPForUnity/Editor/Tools/ManageScene.cs +++ b/MCPForUnity/Editor/Tools/ManageScene.cs @@ -171,7 +171,9 @@ public static object HandleCommand(JObject @params) int? buildIndex = cmd.buildIndex; // bool loadAdditive = @params["loadAdditive"]?.ToObject() ?? false; // Example for future extension - // Ensure path is relative to Assets/, removing any leading "Assets/" + // Paths are relative to a project root folder — "Assets" by default, or "Packages" + // when the caller addresses a scene shipped inside a package (see issue #1197). + string rootFolder = "Assets"; string relativeDir = path ?? string.Empty; if (!string.IsNullOrEmpty(relativeDir)) { @@ -180,6 +182,11 @@ public static object HandleCommand(JObject @params) { relativeDir = relativeDir.Substring("Assets/".Length).TrimStart('/'); } + else if (relativeDir.StartsWith("Packages/", StringComparison.OrdinalIgnoreCase)) + { + rootFolder = "Packages"; + relativeDir = relativeDir.Substring("Packages/".Length).TrimStart('/'); + } // If path ends with .unity, it's a full scene path — extract just the directory if (relativeDir.EndsWith(".unity", StringComparison.OrdinalIgnoreCase)) { @@ -202,15 +209,17 @@ public static object HandleCommand(JObject @params) } string sceneFileName = string.IsNullOrEmpty(name) ? null : $"{name}.unity"; - // Construct full system path correctly: ProjectRoot/Assets/relativeDir/sceneFileName - string fullPathDir = Path.Combine(Application.dataPath, relativeDir); // Combine with Assets path (Application.dataPath ends in Assets) + // Construct full system path correctly: ProjectRoot//relativeDir/sceneFileName + string fullPathDir = rootFolder == "Assets" + ? Path.Combine(Application.dataPath, relativeDir) // Application.dataPath ends in Assets + : Path.Combine(GetProjectRoot(), rootFolder, relativeDir); string fullPath = string.IsNullOrEmpty(sceneFileName) ? null : Path.Combine(fullPathDir, sceneFileName); - // Ensure relativePath always starts with "Assets/" and uses forward slashes + // Ensure relativePath is project-rooted ("Assets/..." or "Packages/...") with forward slashes string relativePath = string.IsNullOrEmpty(sceneFileName) ? null - : AssetPathUtility.NormalizeSeparators(Path.Combine("Assets", relativeDir, sceneFileName)); + : AssetPathUtility.NormalizeSeparators(Path.Combine(rootFolder, relativeDir, sceneFileName)); // Ensure directory exists for 'create' if (action == "create" && !string.IsNullOrEmpty(fullPathDir)) @@ -245,8 +254,7 @@ public static object HandleCommand(JObject @params) string loadPath = relativePath; if (string.IsNullOrEmpty(loadPath) && !string.IsNullOrEmpty(path)) loadPath = AssetPathUtility.NormalizeSeparators( - path.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase) - ? path : "Assets/" + path); + IsProjectRooted(path) ? path : "Assets/" + path); if (!string.IsNullOrEmpty(loadPath)) { if (cmd.additive == true) @@ -375,17 +383,7 @@ private static object CreateScene(string fullPath, string relativePath) private static object LoadScene(string relativePath) { - if ( - !File.Exists( - Path.Combine( - Application.dataPath.Substring( - 0, - Application.dataPath.Length - "Assets".Length - ), - relativePath - ) - ) - ) + if (!SceneAssetExists(relativePath)) { return new ErrorResponse($"Scene file not found at '{relativePath}'."); } @@ -1597,12 +1595,56 @@ private static void ScheduleAssetImportWhenFileExists(string assetsRelativePath, } + // ── Path helpers ─────────────────────────────────────────────────── + + private static string GetProjectRoot() + { + return Application.dataPath.Substring(0, Application.dataPath.Length - "Assets".Length); + } + + /// + /// True when the path already carries a project root folder that Unity's asset APIs + /// understand, so it must not be re-rooted under Assets/. + /// + internal static bool IsProjectRooted(string path) + { + if (string.IsNullOrEmpty(path)) return false; + string normalized = AssetPathUtility.NormalizeSeparators(path).TrimStart('/'); + return normalized.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase) + || normalized.StartsWith("Packages/", StringComparison.OrdinalIgnoreCase); + } + + /// + /// Existence check that works for both roots, accepting either answer. + /// The AssetDatabase is the only one that resolves "Packages/..." — embedded packages + /// live in Library/PackageCache, not under the project root, so File.Exists misses them + /// (ManageAsset.cs carries the same note). File.Exists still covers an Assets/ scene + /// written to disk but not yet imported, which the AssetDatabase does not know about + /// until a refresh. This guard only exists to produce a clearer error than + /// EditorSceneManager.OpenScene would, so erring toward accepting is the safe direction. + /// + internal static bool SceneAssetExists(string projectRelativePath) + { + if (string.IsNullOrEmpty(projectRelativePath)) return false; + + if (AssetDatabase.LoadAssetAtPath(projectRelativePath) != null) return true; + + try + { + return File.Exists(Path.Combine(GetProjectRoot(), projectRelativePath)); + } + catch (ArgumentException) + { + // Invalid path characters — treat as not found rather than throwing. + return false; + } + } + // ── Multi-scene editing ──────────────────────────────────────────── private static object LoadSceneAdditive(string scenePath) { - string projectRoot = Application.dataPath.Substring(0, Application.dataPath.Length - "Assets".Length); - if (!File.Exists(Path.Combine(projectRoot, scenePath))) + if (!SceneAssetExists(scenePath)) return new ErrorResponse($"Scene not found: '{scenePath}'"); var existing = SceneManager.GetSceneByPath(scenePath); diff --git a/MCPForUnity/Editor/Tools/ReadConsole.cs b/MCPForUnity/Editor/Tools/ReadConsole.cs index 155484b6f..b7ee1772a 100644 --- a/MCPForUnity/Editor/Tools/ReadConsole.cs +++ b/MCPForUnity/Editor/Tools/ReadConsole.cs @@ -423,25 +423,44 @@ bool includeStacktrace // --- Internal Helpers --- - // Mapping bits from LogEntry.mode. These may vary by Unity version. + // Mapping bits from LogEntry.mode, mirroring UnityEditor.ConsoleWindow.Mode. + // These values are stable from 2021.3 through 6000.x. private const int ModeBitError = 1 << 0; private const int ModeBitAssert = 1 << 1; - private const int ModeBitWarning = 1 << 2; - private const int ModeBitLog = 1 << 3; - private const int ModeBitException = 1 << 4; // often combined with Error bits - private const int ModeBitScriptingError = 1 << 9; - private const int ModeBitScriptingWarning = 1 << 10; - private const int ModeBitScriptingLog = 1 << 11; - private const int ModeBitScriptingException = 1 << 18; - private const int ModeBitScriptingAssertion = 1 << 22; - - private static LogType GetLogTypeFromMode(int mode) + private const int ModeBitLog = 1 << 2; + private const int ModeBitFatal = 1 << 4; + private const int ModeBitAssetImportError = 1 << 6; + private const int ModeBitAssetImportWarning = 1 << 7; + private const int ModeBitScriptingError = 1 << 8; + private const int ModeBitScriptingWarning = 1 << 9; + private const int ModeBitScriptingLog = 1 << 10; + private const int ModeBitScriptCompileError = 1 << 11; + private const int ModeBitScriptCompileWarning = 1 << 12; + private const int ModeBitStickyError = 1 << 13; + private const int ModeBitScriptingException = 1 << 17; + private const int ModeBitScriptingAssertion = 1 << 21; + private const int ModeBitVisualScriptingError = 1 << 22; + + private const int ModeMaskError = ModeBitError + | ModeBitFatal + | ModeBitAssetImportError + | ModeBitScriptingError + | ModeBitScriptCompileError + | ModeBitStickyError + | ModeBitVisualScriptingError; + + private const int ModeMaskWarning = ModeBitAssetImportWarning + | ModeBitScriptingWarning + | ModeBitScriptCompileWarning; + + internal static LogType GetLogTypeFromMode(int mode) { - // Preserve Unity's real type (no remapping); bits may vary by version - if ((mode & (ModeBitException | ModeBitScriptingException)) != 0) return LogType.Exception; - if ((mode & (ModeBitError | ModeBitScriptingError)) != 0) return LogType.Error; + // Preserve Unity's real type (no remapping). Order matters: an exception + // also carries the Error bit, and an assertion also carries Assert. + if ((mode & ModeBitScriptingException) != 0) return LogType.Exception; if ((mode & (ModeBitAssert | ModeBitScriptingAssertion)) != 0) return LogType.Assert; - if ((mode & (ModeBitWarning | ModeBitScriptingWarning)) != 0) return LogType.Warning; + if ((mode & ModeMaskError) != 0) return LogType.Error; + if ((mode & ModeMaskWarning) != 0) return LogType.Warning; return LogType.Log; } diff --git a/MCPForUnity/Runtime/Helpers/ScreenshotUtility.cs b/MCPForUnity/Runtime/Helpers/ScreenshotUtility.cs index 79b6a6160..5d01042ec 100644 --- a/MCPForUnity/Runtime/Helpers/ScreenshotUtility.cs +++ b/MCPForUnity/Runtime/Helpers/ScreenshotUtility.cs @@ -3,6 +3,7 @@ using System.IO; using System.Linq; using UnityEngine; +using UnityEngine.Experimental.Rendering; namespace MCPForUnity.Runtime.Helpers //The reason for having another Runtime Utilities in additional to Editor Utilities is to avoid Editor-only dependencies in this runtime code. @@ -579,14 +580,23 @@ public static Texture2D DownscaleTexture(Texture2D source, int maxEdge) int dstW = Mathf.Max(1, Mathf.RoundToInt(srcW * scale)); int dstH = Mathf.Max(1, Mathf.RoundToInt(srcH * scale)); + // Match the temporary RT's read/write mode to the source texture rather than the + // project default. In a Linear-colorspace project a Default (sRGB) RT applies an + // sRGB encode on store while the blit samples a linear-flagged capture without a + // matching decode, which washes the image out (see issue #1328). + bool srcIsSrgb = GraphicsFormatUtility.IsSRGBFormat(source.graphicsFormat); + RenderTextureReadWrite readWrite = srcIsSrgb + ? RenderTextureReadWrite.sRGB + : RenderTextureReadWrite.Linear; + RenderTexture prevActive = RenderTexture.active; - var rt = RenderTexture.GetTemporary(dstW, dstH, 0, RenderTextureFormat.ARGB32); + var rt = RenderTexture.GetTemporary(dstW, dstH, 0, RenderTextureFormat.ARGB32, readWrite); rt.filterMode = FilterMode.Bilinear; try { Graphics.Blit(source, rt); RenderTexture.active = rt; - var dst = new Texture2D(dstW, dstH, TextureFormat.RGBA32, false); + var dst = new Texture2D(dstW, dstH, TextureFormat.RGBA32, false, linear: !srcIsSrgb); dst.ReadPixels(new Rect(0, 0, dstW, dstH), 0, 0); dst.Apply(); return dst; diff --git a/Server/src/main.py b/Server/src/main.py index a3f20cbee..b057f3d96 100644 --- a/Server/src/main.py +++ b/Server/src/main.py @@ -319,6 +319,7 @@ def _build_instructions(project_scoped_tools: bool) -> str: - Resources are addressed by URI, never by name. A resource's name and URI are NOT interchangeable: names use underscores (e.g. editor_state) while URIs use slashes (e.g. mcpforunity://editor/state). Do NOT build a URI by swapping separators in the name — you will 404. - These instructions always spell resources as full mcpforunity:// URIs — read one exactly as written. If you only have a name (from resources/list or another tool's output), look its URI up in resources/list rather than guessing it. - Resource payloads are wrapped: the content lives under a top-level `data` object, so field paths are `data.
.` (e.g. `data.advice.ready_for_tools`), not bare top-level fields. +- The mcpforunity:// URI names the resource, not the server. Some clients take a separate server key on a resource read — in Codex, tools are exposed as mcp__unityMCP__* but resources/read wants server: "unityMCP". If a read fails with an unknown-server error, list resources first and use the key exactly as returned. Script Management: - After creating or modifying scripts (by your own tools or the `manage_script` tool) use `read_console` to check for compilation errors before proceeding diff --git a/Server/src/services/tools/manage_scene.py b/Server/src/services/tools/manage_scene.py index 2861e632d..38fe53c18 100644 --- a/Server/src/services/tools/manage_scene.py +++ b/Server/src/services/tools/manage_scene.py @@ -42,7 +42,7 @@ async def manage_scene( "validate", ], "Perform CRUD operations on Unity scenes and control the Scene View camera."], name: Annotated[str, "Scene name."] | None = None, - path: Annotated[str, "Scene path."] | None = None, + path: Annotated[str, "Scene path, under Assets/ or Packages/. A bare path is treated as relative to Assets/."] | None = None, build_index: Annotated[int | str, "Unity build index (quote as string, e.g., '0')."] | None = None, # --- scene_view_frame params --- diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ExecuteCodeTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ExecuteCodeTests.cs index 091fee72c..dc3c4e360 100644 --- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ExecuteCodeTests.cs +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ExecuteCodeTests.cs @@ -546,5 +546,42 @@ private static JObject Execute(string code) })); } + + // ──────────────────── Compiled-assembly cache (issue #1351) ──────────────────── + + [Test] + public void Execute_SameSnippetTwice_DoesNotLoadASecondAssembly() + { + // Every compile Assembly.Load()s a fresh "MCPDynamic" image that Mono cannot + // unload, so an uncached recompile leaks one assembly per call. + const string snippet = "return 41 + 1;"; + + // Warm the cache: this call is expected to add exactly one assembly. + var first = Execute(snippet); + Assert.IsTrue(first.Value("success"), first.ToString()); + + int loadedBefore = AppDomain.CurrentDomain.GetAssemblies().Length; + + var second = Execute(snippet); + Assert.IsTrue(second.Value("success"), second.ToString()); + Assert.AreEqual(42, second["data"]["result"].Value()); + + int loadedAfter = AppDomain.CurrentDomain.GetAssemblies().Length; + Assert.AreEqual(loadedBefore, loadedAfter, + "Re-executing an identical snippet loaded another assembly; the compile cache did not hit."); + } + + [Test] + public void Execute_DifferentSnippets_StillCompileIndependently() + { + var a = Execute("return 1;"); + var b = Execute("return 2;"); + + Assert.IsTrue(a.Value("success"), a.ToString()); + Assert.IsTrue(b.Value("success"), b.ToString()); + Assert.AreEqual(1, a["data"]["result"].Value()); + Assert.AreEqual(2, b["data"]["result"].Value()); + } + } } diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageScenePackagePathTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageScenePackagePathTests.cs new file mode 100644 index 000000000..b21413d01 --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageScenePackagePathTests.cs @@ -0,0 +1,90 @@ +using System.IO; +using NUnit.Framework; +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEngine; +using MCPForUnity.Editor.Tools; + +namespace MCPForUnity.Tests.EditMode.Tools +{ + /// + /// Scenes that live under Packages/ used to be re-rooted under Assets/, so a valid + /// package scene path was rewritten to "Assets/Packages/..." and could never resolve + /// (issue #1197). + /// + [TestFixture] + public class ManageScenePackagePathTests + { + [TestCase("Assets/Scenes/Main.unity", true)] + [TestCase("assets/scenes/main.unity", true)] + [TestCase("Packages/com.example.pkg/Samples/Demo.unity", true)] + [TestCase("packages/com.example.pkg/Samples/Demo.unity", true)] + [TestCase("Scenes/Main.unity", false)] + [TestCase("com.example.pkg/Samples/Demo.unity", false)] + [TestCase("", false)] + [TestCase(null, false)] + public void IsProjectRooted_RecognisesBothRoots(string path, bool expected) + { + Assert.AreEqual(expected, ManageScene.IsProjectRooted(path)); + } + + [Test] + public void Load_MissingPackageScene_ReportsThePackagePath_NotAnAssetsRewrite() + { + var p = new JObject + { + ["action"] = "load", + ["path"] = "Packages/com.example.doesnotexist/Samples/Demo.unity" + }; + + var r = ManageScene.HandleCommand(p) as JObject + ?? JObject.FromObject(ManageScene.HandleCommand(p)); + + Assert.IsFalse(r.Value("success"), r.ToString()); + + string message = r.Value("message") ?? r.ToString(); + StringAssert.Contains("Packages/com.example.doesnotexist/Samples/Demo.unity", message); + StringAssert.DoesNotContain("Assets/Packages", message); + } + + [Test] + public void SceneAssetExists_ReturnsFalse_ForUnknownPaths() + { + Assert.IsFalse(ManageScene.SceneAssetExists("Packages/com.example.doesnotexist/A.unity")); + Assert.IsFalse(ManageScene.SceneAssetExists("Assets/DoesNotExist/A.unity")); + Assert.IsFalse(ManageScene.SceneAssetExists(null)); + } + + /// + /// The AssetDatabase does not know about a file written to disk until it is imported. + /// Swapping File.Exists for an AssetDatabase lookup would have made such a scene + /// unloadable, so the check accepts either answer. + /// + [Test] + public void SceneAssetExists_FindsUnimportedFileOnDisk() + { + string dir = Path.Combine(Application.dataPath, "ManageScenePackagePathTests_Tmp"); + string relative = "Assets/ManageScenePackagePathTests_Tmp/NotImported.unity"; + string full = Path.Combine(dir, "NotImported.unity"); + + Directory.CreateDirectory(dir); + try + { + // Written directly, deliberately without AssetDatabase.Refresh(). + File.WriteAllText(full, "%YAML 1.1\n"); + + Assert.IsNull(AssetDatabase.LoadAssetAtPath(relative), + "sanity: the AssetDatabase must not know about this file yet"); + Assert.IsTrue(ManageScene.SceneAssetExists(relative), + "a scene present on disk must still be found"); + } + finally + { + if (Directory.Exists(dir)) Directory.Delete(dir, recursive: true); + string meta = dir + ".meta"; + if (File.Exists(meta)) File.Delete(meta); + AssetDatabase.Refresh(); + } + } + } +} diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageScenePackagePathTests.cs.meta b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageScenePackagePathTests.cs.meta new file mode 100644 index 000000000..9b934715c --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageScenePackagePathTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 1d1fc0b908824016a27ec1ff4febb27f +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ReadConsoleTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ReadConsoleTests.cs index 261ccdafb..190948348 100644 --- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ReadConsoleTests.cs +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ReadConsoleTests.cs @@ -112,5 +112,45 @@ public void HandleCommand_Get_PreservesMultilineMessageBody() StringAssert.Contains($"{firstLine}\n\n{secondLine}", message); StringAssert.DoesNotContain("UnityEngine.Debug", message); } + + // ──────────────────── LogEntry.mode severity mapping (issue #1348) ──────────────────── + + // 0x804400 and 0x804100 were captured from a real Unity 6000.5.4f1 console via + // reflection in issue #1348; 0x804200 is the same envelope with the ScriptingWarning + // bit. The remaining cases exercise one ConsoleWindow.Mode bit each. The old table + // was off by one for every scripting bit, surfacing Logs as Warnings and Warnings + // as Errors. + [TestCase(0x804400, LogType.Log, TestName = "ScriptingLog bit (1<<10) maps to Log")] + [TestCase(0x804200, LogType.Warning, TestName = "ScriptingWarning bit (1<<9) maps to Warning")] + [TestCase(0x804100, LogType.Error, TestName = "ScriptingError bit (1<<8) maps to Error")] + [TestCase(1 << 2, LogType.Log, TestName = "Log bit (1<<2) maps to Log")] + [TestCase(1 << 0, LogType.Error, TestName = "Error bit (1<<0) maps to Error")] + [TestCase(1 << 1, LogType.Assert, TestName = "Assert bit (1<<1) maps to Assert")] + [TestCase(1 << 4, LogType.Error, TestName = "Fatal bit (1<<4) maps to Error")] + [TestCase(1 << 6, LogType.Error, TestName = "AssetImportError bit (1<<6) maps to Error")] + [TestCase(1 << 7, LogType.Warning, TestName = "AssetImportWarning bit (1<<7) maps to Warning")] + [TestCase(1 << 11, LogType.Error, TestName = "ScriptCompileError bit (1<<11) maps to Error")] + [TestCase(1 << 12, LogType.Warning, TestName = "ScriptCompileWarning bit (1<<12) maps to Warning")] + [TestCase(1 << 17, LogType.Exception, TestName = "ScriptingException bit (1<<17) maps to Exception")] + [TestCase(1 << 21, LogType.Assert, TestName = "ScriptingAssertion bit (1<<21) maps to Assert")] + public void GetLogTypeFromMode_MapsUnityConsoleModeBits(int mode, LogType expected) + { + Assert.AreEqual(expected, ReadConsole.GetLogTypeFromMode(mode)); + } + + [Test] + public void GetLogTypeFromMode_ExceptionWins_WhenCombinedWithErrorBit() + { + // Unity sets the Error bit alongside ScriptingException; Exception must win. + int mode = (1 << 17) | (1 << 0); + Assert.AreEqual(LogType.Exception, ReadConsole.GetLogTypeFromMode(mode)); + } + + [Test] + public void GetLogTypeFromMode_UnknownBits_FallBackToLog() + { + Assert.AreEqual(LogType.Log, ReadConsole.GetLogTypeFromMode(1 << 14)); + } + } } diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ScreenshotUtilityDownscaleTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ScreenshotUtilityDownscaleTests.cs new file mode 100644 index 000000000..93f5d10f9 --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ScreenshotUtilityDownscaleTests.cs @@ -0,0 +1,103 @@ +using NUnit.Framework; +using UnityEngine; +using UnityEngine.Rendering; +using MCPForUnity.Runtime.Helpers; + +namespace MCPForUnityTests.Editor.Tools +{ + /// + /// DownscaleTexture blitted through a RenderTexture created with the project-default + /// read/write mode. In a Linear-colorspace project that is sRGB, so a linear-flagged + /// capture picked up an sRGB encode on store and the inline preview came back washed + /// out while the on-disk PNG stayed correct (issue #1328). + /// + [TestFixture] + public class ScreenshotUtilityDownscaleTests + { + [SetUp] + public void SkipWithoutGraphics() + { + if (SystemInfo.graphicsDeviceType == GraphicsDeviceType.Null) + Assert.Ignore("Requires a graphics device; Graphics.Blit is unavailable under -nographics."); + } + + [Test] + public void DownscaleTexture_LinearSource_PreservesPixelValues() + { + // The regression only reproduces in a Linear-colorspace project; in Gamma this + // asserts the (already correct) identity round-trip. + var source = new Texture2D(64, 64, TextureFormat.RGBA32, false, linear: true); + var fill = new Color32(11, 11, 11, 255); // the dark value reported in #1328 + var pixels = new Color32[64 * 64]; + for (int i = 0; i < pixels.Length; i++) pixels[i] = fill; + source.SetPixels32(pixels); + source.Apply(); + + Texture2D result = null; + try + { + result = ScreenshotUtility.DownscaleTexture(source, 32); + + Assert.AreEqual(32, result.width); + Assert.AreEqual(32, result.height); + + Color32 got = result.GetPixels32()[result.width * result.height / 2]; + Assert.AreEqual(fill.r, got.r, 2, $"Red shifted {fill.r} -> {got.r} (sRGB re-encode?)"); + Assert.AreEqual(fill.g, got.g, 2, $"Green shifted {fill.g} -> {got.g} (sRGB re-encode?)"); + Assert.AreEqual(fill.b, got.b, 2, $"Blue shifted {fill.b} -> {got.b} (sRGB re-encode?)"); + } + finally + { + if (result != null) Object.DestroyImmediate(result); + Object.DestroyImmediate(source); + } + } + + [Test] + public void DownscaleTexture_SrgbSource_PreservesPixelValues() + { + var source = new Texture2D(64, 64, TextureFormat.RGBA32, false, linear: false); + var fill = new Color32(128, 64, 32, 255); + var pixels = new Color32[64 * 64]; + for (int i = 0; i < pixels.Length; i++) pixels[i] = fill; + source.SetPixels32(pixels); + source.Apply(); + + Texture2D result = null; + try + { + result = ScreenshotUtility.DownscaleTexture(source, 16); + + Color32 got = result.GetPixels32()[result.width * result.height / 2]; + Assert.AreEqual(fill.r, got.r, 2); + Assert.AreEqual(fill.g, got.g, 2); + Assert.AreEqual(fill.b, got.b, 2); + } + finally + { + if (result != null) Object.DestroyImmediate(result); + Object.DestroyImmediate(source); + } + } + + [Test] + public void DownscaleTexture_NeverUpscales() + { + var source = new Texture2D(8, 8, TextureFormat.RGBA32, false, linear: true); + source.Apply(); + + Texture2D result = null; + try + { + result = ScreenshotUtility.DownscaleTexture(source, 512); + Assert.AreEqual(8, result.width); + Assert.AreEqual(8, result.height); + } + finally + { + if (result != null) Object.DestroyImmediate(result); + Object.DestroyImmediate(source); + } + } + } +} diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ScreenshotUtilityDownscaleTests.cs.meta b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ScreenshotUtilityDownscaleTests.cs.meta new file mode 100644 index 000000000..d229c7424 --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ScreenshotUtilityDownscaleTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b7c1f8ebb1fa48668b4424a327fbcc6e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/website/docs/guides/troubleshooting.md b/website/docs/guides/troubleshooting.md index baedd4e7d..76ac65bb5 100644 --- a/website/docs/guides/troubleshooting.md +++ b/website/docs/guides/troubleshooting.md @@ -146,6 +146,42 @@ Unity AI Assistant bundles `System.Collections.Immutable` v10, while MCP for Uni --- +## Unity 6.5: Editor hangs on load and the bridge never connects + +If Unity 6000.5.x spins at ~100% CPU on startup and never opens the Editor window, check `Packages/manifest.json` for `com.unity.ai.assistant` (and `com.unity.ai.inference`, `com.unity.asset-manager-for-unity`). + +**Symptoms:** +- The Editor never finishes loading, so MCP for Unity never arms its bridge — which looks like an MCP connection failure +- Stack traces sit inside `AssetDatabase::InitialRefresh` → `SourceAssetScanner::Refresh` → `GuidDB::ValidateChangedGUIDs` + +**Cause:** +The pre-release AI packages can livelock `AssetDatabase::InitialRefresh`. This happens before any MCP for Unity assembly is loaded, so no MCP code is involved. + +**Fix:** remove those packages from `manifest.json`, **delete `Packages/packages-lock.json`** (it re-resolves them otherwise), then clear `Library/`. Disabling the package is not enough — the AI packages re-add each other. + +This is a Unity bug (UUM-132096), not an MCP for Unity one. + +*Reported by [@100yenadmin](https://github.com/CoplayDev/unity-mcp/issues/1219).* + +--- + +## Codex: `resources/read failed: unknown MCP server` + +The `mcpforunity://` URI names the *resource*, not the server. Some clients take a separate server key on a resource read. + +In Codex, tools are exposed as `mcp__unityMCP__*`, but `resources/read` wants the discovery key on its own: + +``` +server: "unityMCP" +uri: "mcpforunity://custom-tools" +``` + +If a read fails with an unknown-server error, list resources first and use the key exactly as returned. + +*Reported by [@drewclifton](https://github.com/CoplayDev/unity-mcp/issues/1220).* + +--- + ## "No Unity Instances Found" :::tip When in doubt, restart your client diff --git a/website/docs/reference/tools/core/manage_scene.md b/website/docs/reference/tools/core/manage_scene.md index 8e571028c..6092e83e2 100644 --- a/website/docs/reference/tools/core/manage_scene.md +++ b/website/docs/reference/tools/core/manage_scene.md @@ -20,7 +20,7 @@ Performs CRUD operations on Unity scenes. Read-only actions: get_hierarchy, get_ |------|------|----------|-------------| | `action` | `Literal['create', 'load', 'save', 'get_hierarchy', 'get_active', 'get_build_settings', 'scene_view_frame', 'close_scene', 'set_active_scene', 'get_loaded_scenes', 'move_to_scene', 'validate']` | yes | Perform CRUD operations on Unity scenes and control the Scene View camera. | | `name` | `str \| None` | — | Scene name. | -| `path` | `str \| None` | — | Scene path. | +| `path` | `str \| None` | — | Scene path, under Assets/ or Packages/. A bare path is treated as relative to Assets/. | | `build_index` | `int \| str \| None` | — | Unity build index (quote as string, e.g., '0'). | | `scene_view_target` | `str \| int \| None` | — | GameObject reference for scene_view_frame (name, path, or instance ID). | | `parent` | `str \| int \| None` | — | Optional parent GameObject reference (name/path/instanceID) to list direct children. | @@ -56,7 +56,22 @@ A `dict` containing the Unity response. The exact shape depends on the action. } ``` -Paths are relative to `Assets/`. Forward slashes only. +A bare path is relative to `Assets/`. Forward slashes only. + +### Load a scene shipped inside a package + +> Open the demo scene from `com.example.pkg`. + +```json +{ + "action": "load", + "path": "Packages/com.example.pkg/Samples/Demo.unity" +} +``` + +A path that already starts with `Assets/` or `Packages/` is used as-is. Package scenes +resolve through the AssetDatabase, so this works for embedded packages and for packages +Unity keeps in `Library/PackageCache`. ### Get the scene hierarchy (paged)