using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Text; using System.Xml; using MCPForUnity.Editor.Helpers; using MCPForUnity.Runtime.Helpers; using Newtonsoft.Json.Linq; using UnityEditor; using UnityEngine; using UnityEngine.UIElements; namespace MCPForUnity.Editor.Tools { [McpForUnityTool("manage_ui", AutoRegister = false, Group = "ui")] public static class ManageUI { private static readonly HashSet ValidExtensions = new(StringComparer.OrdinalIgnoreCase) { ".uxml", ".uss" }; // UTF-8 without BOM — UI Builder in Unity 6 can fail to open UXML files with a BOM. private static readonly Encoding Utf8NoBom = new UTF8Encoding(false); static ManageUI() { EditorApplication.quitting += CleanupRenderTextures; AssemblyReloadEvents.beforeAssemblyReload += CleanupRenderTextures; } private static void CleanupRenderTextures() { foreach (var kvp in s_panelRTs) { if (kvp.Value == null) continue; string assetPath = AssetDatabase.GetAssetPath(kvp.Value); kvp.Value.Release(); if (!string.IsNullOrEmpty(assetPath)) AssetDatabase.DeleteAsset(assetPath); else UnityEngine.Object.DestroyImmediate(kvp.Value); } s_panelRTs.Clear(); } public static object HandleCommand(JObject @params) { string action = @params["action"]?.ToString()?.ToLowerInvariant(); if (string.IsNullOrEmpty(action)) { return new ErrorResponse("Action is required"); } try { switch (action) { case "ping": return new SuccessResponse("pong", new { tool = "manage_ui" }); case "create": return CreateFile(@params); case "read": return ReadFile(@params); case "update": return UpdateFile(@params); case "attach_ui_document": return AttachUIDocument(@params); case "create_panel_settings": return CreatePanelSettings(@params); case "update_panel_settings": return UpdatePanelSettings(@params); case "get_visual_tree": return GetVisualTree(@params); case "render_ui": return RenderUI(@params); case "link_stylesheet": return LinkStylesheet(@params); case "delete": return DeleteFile(@params); case "list": return ListUIAssets(@params); case "detach_ui_document": return DetachUIDocument(@params); case "modify_visual_element": return ModifyVisualElement(@params); default: return new ErrorResponse($"Unknown action: {action}"); } } catch (Exception ex) { return new ErrorResponse(ex.Message, new { stackTrace = ex.StackTrace }); } } private static string ValidatePath(string path, out string error) { error = null; if (string.IsNullOrEmpty(path)) { error = "'path' parameter is required."; return null; } path = AssetPathUtility.SanitizeAssetPath(path); if (path == null) { error = "Invalid path: contains traversal sequences."; return null; } string ext = Path.GetExtension(path); if (!ValidExtensions.Contains(ext)) { error = $"Invalid file extension '{ext}'. Must be .uxml or .uss."; return null; } return path; } private static object CreateFile(JObject @params) { var p = new ToolParams(@params); string path = ValidatePath(p.Get("path"), out string pathError); if (pathError != null) return new ErrorResponse(pathError); string contents; try { contents = GetDecodedContents(p); } catch (ArgumentException ex) { return new ErrorResponse(ex.Message); } if (contents == null) { return new ErrorResponse("'contents' parameter is required for create."); } string fullPath = Path.Combine(Application.dataPath, path.Substring("Assets/".Length)); fullPath = fullPath.Replace('/', Path.DirectorySeparatorChar); if (File.Exists(fullPath)) { return new ErrorResponse($"File already exists at {path}. Use 'update' action to overwrite."); } string dir = Path.GetDirectoryName(fullPath); if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) { Directory.CreateDirectory(dir); } bool isUxml = path.EndsWith(".uxml", StringComparison.OrdinalIgnoreCase); var validationWarnings = new List(); if (isUxml) { string xmlError = ValidateUxmlContent(contents, validationWarnings); if (xmlError != null) { return new ErrorResponse($"UXML validation failed — file was NOT written. {xmlError}"); } contents = EnsureEditorExtensionMode(contents); } File.WriteAllText(fullPath, contents, Utf8NoBom); AssetDatabase.ImportAsset(path, ImportAssetOptions.ForceUpdate); if (isUxml) { ValidateUxmlPostImport(path, validationWarnings); } string ext = Path.GetExtension(path).TrimStart('.'); if (validationWarnings.Count > 0) { return new SuccessResponse( $"Created {ext} file at {path} with {validationWarnings.Count} warning(s)", new { path, validationWarnings }); } return new SuccessResponse($"Created {ext} file at {path}", new { path }); } private static object ReadFile(JObject @params) { var p = new ToolParams(@params); string path = ValidatePath(p.Get("path"), out string pathError); if (pathError != null) return new ErrorResponse(pathError); string fullPath = Path.Combine(Application.dataPath, path.Substring("Assets/".Length)); fullPath = fullPath.Replace('/', Path.DirectorySeparatorChar); if (!File.Exists(fullPath)) { return new ErrorResponse($"File not found: {path}"); } string contents = File.ReadAllText(fullPath, Encoding.UTF8); string encoded = Convert.ToBase64String(Encoding.UTF8.GetBytes(contents)); return new SuccessResponse($"Read {Path.GetExtension(path).TrimStart('.')} file at {path}", new { path, contents, encodedContents = encoded, contentsEncoded = true, lengthBytes = Encoding.UTF8.GetByteCount(contents) }); } private static object UpdateFile(JObject @params) { var p = new ToolParams(@params); string path = ValidatePath(p.Get("path"), out string pathError); if (pathError != null) return new ErrorResponse(pathError); string contents; try { contents = GetDecodedContents(p); } catch (ArgumentException ex) { return new ErrorResponse(ex.Message); } if (contents == null) { return new ErrorResponse("'contents' parameter is required for update."); } string fullPath = Path.Combine(Application.dataPath, path.Substring("Assets/".Length)); fullPath = fullPath.Replace('/', Path.DirectorySeparatorChar); if (!File.Exists(fullPath)) { return new ErrorResponse($"File not found: {path}. Use 'create' action for new files."); } bool isUxml = path.EndsWith(".uxml", StringComparison.OrdinalIgnoreCase); var validationWarnings = new List(); if (isUxml) { string xmlError = ValidateUxmlContent(contents, validationWarnings); if (xmlError != null) { return new ErrorResponse($"UXML validation failed — file was NOT updated. {xmlError}"); } contents = EnsureEditorExtensionMode(contents); } File.WriteAllText(fullPath, contents, Utf8NoBom); AssetDatabase.ImportAsset(path, ImportAssetOptions.ForceUpdate); if (isUxml) { ValidateUxmlPostImport(path, validationWarnings); } string ext = Path.GetExtension(path).TrimStart('.'); if (validationWarnings.Count > 0) { return new SuccessResponse( $"Updated {ext} file at {path} with {validationWarnings.Count} warning(s)", new { path, validationWarnings }); } return new SuccessResponse($"Updated {ext} file at {path}", new { path }); } private static object AttachUIDocument(JObject @params) { var p = new ToolParams(@params); var targetResult = p.GetRequired("target"); var targetError = targetResult.GetOrError(out string target); if (targetError != null) return targetError; var sourceResult = p.GetRequired("source_asset"); var sourceError = sourceResult.GetOrError(out string sourceAssetPath); if (sourceError != null) return sourceError; sourceAssetPath = AssetPathUtility.SanitizeAssetPath(sourceAssetPath); if (sourceAssetPath == null) { return new ErrorResponse("Invalid source_asset path."); } // Find the GameObject var goInstruction = new JObject { ["find"] = target }; GameObject go = ObjectResolver.Resolve(goInstruction, typeof(GameObject)) as GameObject; if (go == null) { return new ErrorResponse($"Could not find target GameObject: {target}"); } // Load the VisualTreeAsset var vta = AssetDatabase.LoadAssetAtPath(sourceAssetPath); if (vta == null) { return new ErrorResponse($"Could not load VisualTreeAsset at: {sourceAssetPath}"); } // Load or create PanelSettings string panelSettingsPath = p.Get("panel_settings") ?? p.Get("panelSettings"); PanelSettings panelSettings = null; if (!string.IsNullOrEmpty(panelSettingsPath)) { panelSettingsPath = AssetPathUtility.SanitizeAssetPath(panelSettingsPath); if (panelSettingsPath != null) { panelSettings = AssetDatabase.LoadAssetAtPath(panelSettingsPath); } if (panelSettings == null) { return new ErrorResponse($"Could not load PanelSettings at: {panelSettingsPath}"); } } else { // Find existing or create default PanelSettings string[] guids = AssetDatabase.FindAssets("t:PanelSettings"); if (guids.Length > 0) { string existingPath = AssetDatabase.GUIDToAssetPath(guids[0]); panelSettings = AssetDatabase.LoadAssetAtPath(existingPath); } if (panelSettings == null) { panelSettings = CreateDefaultPanelSettings("Assets/UI/DefaultPanelSettings.asset"); if (panelSettings == null) { return new ErrorResponse("Failed to create default PanelSettings."); } } } Undo.RecordObject(go, "Attach UIDocument"); // Add or get UIDocument component var uiDoc = go.GetComponent(); if (uiDoc == null) { uiDoc = Undo.AddComponent(go); } uiDoc.visualTreeAsset = vta; uiDoc.panelSettings = panelSettings; int sortOrder = p.GetInt("sort_order") ?? 0; uiDoc.sortingOrder = sortOrder; EditorUtility.SetDirty(go); return new SuccessResponse($"Attached UIDocument to {go.name}", new { gameObject = go.name, sourceAsset = sourceAssetPath, panelSettings = AssetDatabase.GetAssetPath(panelSettings), sortOrder }); } private static object CreatePanelSettings(JObject @params) { var p = new ToolParams(@params); var pathResult = p.GetRequired("path"); var pathError = pathResult.GetOrError(out string path); if (pathError != null) return pathError; path = AssetPathUtility.SanitizeAssetPath(path); if (path == null) { return new ErrorResponse("Invalid path: contains traversal sequences."); } if (!path.EndsWith(".asset", StringComparison.OrdinalIgnoreCase)) { path += ".asset"; } if (AssetDatabase.LoadAssetAtPath(path) != null) { return new ErrorResponse($"PanelSettings already exists at {path}"); } var ps = CreateDefaultPanelSettings(path); if (ps == null) { return new ErrorResponse("Failed to create PanelSettings asset."); } // Apply any settings passed as a flat dict JToken settingsToken = p.GetRaw("settings"); var changes = new List(); if (settingsToken is JObject settingsObj) { ApplyPanelSettingsProperties(ps, settingsObj, changes); } else { // Legacy: support top-level scale_mode / reference_resolution string scaleMode = p.Get("scale_mode"); if (!string.IsNullOrEmpty(scaleMode)) { if (Enum.TryParse(scaleMode, true, out var mode)) { ps.scaleMode = mode; changes.Add("scaleMode"); } } JToken refResToken = p.GetRaw("reference_resolution"); if (refResToken is JObject refRes) { int w = refRes["width"]?.ToObject() ?? 1920; int h = refRes["height"]?.ToObject() ?? 1080; ps.referenceResolution = new Vector2Int(w, h); changes.Add("referenceResolution"); } } EditorUtility.SetDirty(ps); AssetDatabase.SaveAssets(); return new SuccessResponse($"Created PanelSettings at {path}", new { path, applied = changes }); } private static object UpdatePanelSettings(JObject @params) { var p = new ToolParams(@params); var pathResult = p.GetRequired("path"); var pathError = pathResult.GetOrError(out string path); if (pathError != null) return pathError; path = AssetPathUtility.SanitizeAssetPath(path); if (path == null) return new ErrorResponse("Invalid path: contains traversal sequences."); if (!path.EndsWith(".asset", StringComparison.OrdinalIgnoreCase)) path += ".asset"; var ps = AssetDatabase.LoadAssetAtPath(path); if (ps == null) return new ErrorResponse($"No PanelSettings found at {path}"); JToken settingsToken = p.GetRaw("settings"); if (settingsToken is not JObject settingsObj || settingsObj.Count == 0) return new ErrorResponse("'settings' dict is required with at least one property to update."); var changes = new List(); ApplyPanelSettingsProperties(ps, settingsObj, changes); if (changes.Count == 0) return new ErrorResponse("No recognised properties were applied. Check the key names."); EditorUtility.SetDirty(ps); AssetDatabase.SaveAssets(); return new SuccessResponse($"Updated PanelSettings at {path}", new { path, applied = changes }); } private static PanelSettings CreateDefaultPanelSettings(string path) { string dir = Path.GetDirectoryName(path); if (!string.IsNullOrEmpty(dir)) { EnsureFolderExists(dir); } var ps = ScriptableObject.CreateInstance(); AssetDatabase.CreateAsset(ps, path); AssetDatabase.SaveAssets(); return ps; } /// /// Generic, data-driven applicator for PanelSettings properties. /// Accepts a flat JObject where each key maps to a PanelSettings property. /// Recognised keys (case-insensitive matching via snake_case/camelCase): /// scaleMode, referenceResolution, screenMatchMode, match, /// referenceDpi, fallbackDpi, sortingOrder, targetDisplay, /// clearColor, colorClearValue, clearDepthStencil, /// themeStyleSheet, dynamicAtlasSettings. /// private static void ApplyPanelSettingsProperties(PanelSettings ps, JObject settings, List changes) { foreach (var prop in settings) { string key = NormalizeKey(prop.Key); JToken val = prop.Value; switch (key) { // ── Enum properties ───────────────────────────────────── case "scalemode": if (TryParseEnum(val, out var sm)) { ps.scaleMode = sm; changes.Add("scaleMode"); } break; case "screenmatchmode": if (TryParseEnum(val, out var smm)) { ps.screenMatchMode = smm; changes.Add("screenMatchMode"); } break; // ── Numeric properties ────────────────────────────────── case "match": if (TryFloat(val, out float matchVal)) { ps.match = Mathf.Clamp01(matchVal); changes.Add("match"); } break; case "referencedpi": if (TryFloat(val, out float refDpi)) { ps.referenceDpi = refDpi; changes.Add("referenceDpi"); } break; case "fallbackdpi": if (TryFloat(val, out float fbDpi)) { ps.fallbackDpi = fbDpi; changes.Add("fallbackDpi"); } break; case "sortingorder": if (TryInt(val, out int so)) { ps.sortingOrder = so; changes.Add("sortingOrder"); } break; case "targetdisplay": if (TryInt(val, out int td)) { ps.targetDisplay = td; changes.Add("targetDisplay"); } break; // ── Bool properties ────────────────────────────────────── case "clearcolor": ps.clearColor = ParamCoercion.CoerceBool(val, false); changes.Add("clearColor"); break; case "cleardepthstencil": ps.clearDepthStencil = ParamCoercion.CoerceBool(val, false); changes.Add("clearDepthStencil"); break; // ── Composite properties ──────────────────────────────── case "referenceresolution": if (val is JObject resObj) { int w = resObj["width"]?.ToObject() ?? ps.referenceResolution.x; int h = resObj["height"]?.ToObject() ?? ps.referenceResolution.y; ps.referenceResolution = new Vector2Int(w, h); changes.Add("referenceResolution"); } break; case "colorclearvalue": if (TryParseColor(val, out Color clr)) { ps.colorClearValue = clr; changes.Add("colorClearValue"); } break; case "dynamicatlassettings": if (val is JObject daObj) { ApplyDynamicAtlasSettings(ps, daObj, changes); } break; // ── Asset reference properties ────────────────────────── case "themestylesheet": { string tsPath = val?.ToString(); if (!string.IsNullOrEmpty(tsPath)) { var ts = AssetDatabase.LoadAssetAtPath(tsPath); if (ts != null) { ps.themeStyleSheet = ts; changes.Add("themeStyleSheet"); } } break; } // unknown keys are silently ignored } } } private static void ApplyDynamicAtlasSettings(PanelSettings ps, JObject da, List changes) { var daCopy = ps.dynamicAtlasSettings; if (da["minAtlasSize"] != null && TryInt(da["minAtlasSize"], out int minSize)) daCopy.minAtlasSize = minSize; if (da["maxAtlasSize"] != null && TryInt(da["maxAtlasSize"], out int maxSize)) daCopy.maxAtlasSize = maxSize; if (da["maxSubTextureSize"] != null && TryInt(da["maxSubTextureSize"], out int maxSub)) daCopy.maxSubTextureSize = maxSub; if (da["activeFilters"] != null && TryParseEnum(da["activeFilters"], out var af)) daCopy.activeFilters = af; ps.dynamicAtlasSettings = daCopy; changes.Add("dynamicAtlasSettings"); } // ── Tiny helpers to keep the switch compact ───────────────────────── private static string NormalizeKey(string key) { // Strip underscores and lowercase so "scale_mode", "scaleMode", "ScaleMode" // all match the same case label. return key.Replace("_", "").ToLowerInvariant(); } private static bool TryParseEnum(JToken token, out T result) where T : struct, Enum { result = default; string s = token?.ToString(); return !string.IsNullOrEmpty(s) && Enum.TryParse(s, true, out result); } private static bool TryFloat(JToken token, out float result) { result = 0f; if (token == null) return false; if (token.Type == JTokenType.Float || token.Type == JTokenType.Integer) { result = token.ToObject(); return true; } return float.TryParse(token.ToString(), out result); } private static bool TryInt(JToken token, out int result) { result = 0; if (token == null) return false; if (token.Type == JTokenType.Integer) { result = token.ToObject(); return true; } return int.TryParse(token.ToString(), out result); } private static bool TryParseColor(JToken token, out Color color) { color = Color.clear; if (token == null) return false; // Accept "#RRGGBB", "#RRGGBBAA", or {r,g,b,a} object if (token.Type == JTokenType.String) { return ColorUtility.TryParseHtmlString(token.ToString(), out color); } if (token is JObject cObj) { color = new Color( cObj["r"]?.ToObject() ?? 0f, cObj["g"]?.ToObject() ?? 0f, cObj["b"]?.ToObject() ?? 0f, cObj["a"]?.ToObject() ?? 1f ); return true; } return false; } private static void EnsureFolderExists(string assetFolderPath) { if (AssetDatabase.IsValidFolder(assetFolderPath)) return; string[] parts = assetFolderPath.Replace('\\', '/').Split('/'); string current = parts[0]; // "Assets" for (int i = 1; i < parts.Length; i++) { string next = current + "/" + parts[i]; if (!AssetDatabase.IsValidFolder(next)) { AssetDatabase.CreateFolder(current, parts[i]); } current = next; } } private static object GetVisualTree(JObject @params) { var p = new ToolParams(@params); var targetResult = p.GetRequired("target"); var targetError = targetResult.GetOrError(out string target); if (targetError != null) return targetError; int maxDepth = p.GetInt("max_depth") ?? 10; var goInstruction = new JObject { ["find"] = target }; GameObject go = ObjectResolver.Resolve(goInstruction, typeof(GameObject)) as GameObject; if (go == null) { return new ErrorResponse($"Could not find target GameObject: {target}"); } var uiDoc = go.GetComponent(); if (uiDoc == null) { return new ErrorResponse($"GameObject {go.name} has no UIDocument component."); } var root = uiDoc.rootVisualElement; if (root == null) { return new SuccessResponse($"UIDocument on {go.name} has no visual tree (not yet built).", new { gameObject = go.name, sourceAsset = uiDoc.visualTreeAsset != null ? AssetDatabase.GetAssetPath(uiDoc.visualTreeAsset) : null, tree = (object)null }); } var tree = SerializeVisualElement(root, 0, maxDepth); return new SuccessResponse($"Visual tree for UIDocument on {go.name}", new { gameObject = go.name, sourceAsset = uiDoc.visualTreeAsset != null ? AssetDatabase.GetAssetPath(uiDoc.visualTreeAsset) : null, tree }); } private static object SerializeVisualElement(VisualElement element, int depth, int maxDepth) { var result = new Dictionary { ["type"] = element.GetType().Name, ["name"] = element.name ?? "", ["classes"] = new List(element.GetClasses()), }; // Include basic computed style info var style = new Dictionary(); var resolved = element.resolvedStyle; if (resolved.width > 0) style["width"] = resolved.width; if (resolved.height > 0) style["height"] = resolved.height; if (resolved.color != Color.clear) style["color"] = ColorToHex(resolved.color); if (resolved.backgroundColor != Color.clear) style["backgroundColor"] = ColorToHex(resolved.backgroundColor); if (resolved.fontSize > 0) style["fontSize"] = resolved.fontSize; if (style.Count > 0) result["resolvedStyle"] = style; // Include text content for labels/buttons if (element is TextElement textEl && !string.IsNullOrEmpty(textEl.text)) { result["text"] = textEl.text; } // Serialize children if (depth < maxDepth && element.childCount > 0) { var children = new List(); foreach (var child in element.Children()) { children.Add(SerializeVisualElement(child, depth + 1, maxDepth)); } result["children"] = children; } else if (element.childCount > 0) { result["childCount"] = element.childCount; result["truncated"] = true; } return result; } // ---- Render UI ---- // Persistent RenderTextures keyed by PanelSettings instance ID so the panel // renders into them automatically every frame. private static readonly Dictionary s_panelRTs = new(); // Play-mode coroutine capture state. Only one capture is in-flight at a // time; concurrent render_ui calls while a capture is pending are rejected // with an explicit error. private static Texture2D s_pendingCaptureTex; private static bool s_pendingCaptureDone; private static bool s_pendingCaptureStarted; private static object RenderUI(JObject @params) { var p = new ToolParams(@params); string target = p.Get("target"); string uxmlPath = p.Get("path"); int width = p.GetInt("width") ?? 1920; int height = p.GetInt("height") ?? 1080; 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 // dispatch a WaitForEndOfFrame coroutine that uses ScreenCapture, which // captures the fully-composited game view (including UI Toolkit overlays). // First call: queues the capture and returns "pending". // Second call: result is ready – save PNG and return data. if (Application.isPlaying) { // Build the output paths (used by both the pending and ready branches) string resolvedPlayName = string.IsNullOrWhiteSpace(fileName) ? $"ui-render-{DateTime.Now:yyyyMMdd-HHmmss}.png" : fileName.Trim(); if (!resolvedPlayName.EndsWith(".png", StringComparison.OrdinalIgnoreCase)) resolvedPlayName += ".png"; Directory.CreateDirectory(resolvedFolderAbs); string playFullPath = Path.Combine(resolvedFolderAbs, resolvedPlayName).Replace('\\', '/'); playFullPath = EnsureUniqueFilePath(playFullPath); string playProjectRelPath = ScreenshotUtility.ToProjectRelativePath(playFullPath); // ── Case 1: capture is ready ────────────────────────────────────── if (s_pendingCaptureDone && s_pendingCaptureTex != null) { var captureTex = s_pendingCaptureTex; s_pendingCaptureDone = false; s_pendingCaptureTex = null; int captureW = captureTex.width; int captureH = captureTex.height; byte[] capturePng = captureTex.EncodeToPNG(); UnityEngine.Object.DestroyImmediate(captureTex); File.WriteAllBytes(playFullPath, capturePng); if (ScreenshotUtility.IsUnderAssets(playProjectRelPath)) AssetDatabase.ImportAsset(playProjectRelPath, ImportAssetOptions.ForceSynchronousImport); var playData = new Dictionary { { "path", playProjectRelPath }, { "fullPath", playFullPath }, { "width", captureW }, { "height", captureH }, { "hasContent", true }, }; if (!string.IsNullOrEmpty(target)) playData["gameObject"] = target; if (!string.IsNullOrEmpty(uxmlPath)) playData["sourceAsset"] = uxmlPath; if (includeImage) { int targetMax = maxResolution > 0 ? maxResolution : 640; Texture2D downscaled = null; try { var fullTex = new Texture2D(captureW, captureH, TextureFormat.RGBA32, false); fullTex.LoadImage(capturePng); if (captureW > targetMax || captureH > targetMax) { downscaled = ScreenshotUtility.DownscaleTexture(fullTex, targetMax); playData["imageBase64"] = Convert.ToBase64String(downscaled.EncodeToPNG()); playData["imageWidth"] = downscaled.width; playData["imageHeight"] = downscaled.height; } else { playData["imageBase64"] = Convert.ToBase64String(capturePng); playData["imageWidth"] = captureW; playData["imageHeight"] = captureH; } UnityEngine.Object.DestroyImmediate(fullTex); } finally { if (downscaled != null) UnityEngine.Object.DestroyImmediate(downscaled); } } return new SuccessResponse($"UI render saved to '{playProjectRelPath}'.", playData); } // ── Case 2: start a new capture ─────────────────────────────────── // Only one capture in flight at a time. If one is already pending, // reject rather than silently overwriting the state. if (s_pendingCaptureStarted) { return new ErrorResponse( "Cannot capture: another capture is already in progress.", new { retry_after_ms = 100, reason = "capture_in_progress" }); } s_pendingCaptureDone = false; s_pendingCaptureTex = null; s_pendingCaptureStarted = true; ScreenshotCapturer.Begin(1, tex => { s_pendingCaptureTex = tex; s_pendingCaptureDone = true; s_pendingCaptureStarted = false; }); return new SuccessResponse( "Play-mode screenshot capture queued (WaitForEndOfFrame). Call render_ui again to retrieve the rendered image.", new Dictionary { { "pending", true }, { "gameObject", (object)target ?? uxmlPath }, { "note", "A screen capture was scheduled for the end of this frame. Call render_ui once more to get the result." } }); } // ── End play-mode branch ──────────────────────────────────────────────── // Resolve UIDocument UIDocument uiDoc = null; GameObject tempGo = null; PanelSettings tempPs = null; try { if (!string.IsNullOrEmpty(target)) { var goInstruction = new JObject { ["find"] = target }; GameObject go = ObjectResolver.Resolve(goInstruction, typeof(GameObject)) as GameObject; if (go == null) return new ErrorResponse($"Could not find target GameObject: {target}"); uiDoc = go.GetComponent(); if (uiDoc == null) return new ErrorResponse($"GameObject '{go.name}' has no UIDocument component."); } else { uxmlPath = AssetPathUtility.SanitizeAssetPath(uxmlPath); if (uxmlPath == null) return new ErrorResponse("Invalid UXML path."); var vta = AssetDatabase.LoadAssetAtPath(uxmlPath); if (vta == null) return new ErrorResponse($"Could not load VisualTreeAsset at: {uxmlPath}"); tempGo = new GameObject("__MCP_UI_Render_Temp__"); tempGo.hideFlags = HideFlags.HideAndDontSave; uiDoc = tempGo.AddComponent(); string[] guids = AssetDatabase.FindAssets("t:PanelSettings"); PanelSettings ps = null; if (guids.Length > 0) ps = AssetDatabase.LoadAssetAtPath(AssetDatabase.GUIDToAssetPath(guids[0])); if (ps == null) { ps = CreateDefaultPanelSettings("Assets/UI/DefaultPanelSettings.asset"); tempPs = ps; } uiDoc.panelSettings = ps; uiDoc.visualTreeAsset = vta; } if (uiDoc.panelSettings == null) return new ErrorResponse("UIDocument has no PanelSettings assigned."); var panelSettings = uiDoc.panelSettings; int psId = panelSettings.GetInstanceIDCompat(); // Check if we already have a persistent RT assigned to this PanelSettings. // If the RT exists and its size matches, the panel has been rendering into it. // If not, create one and assign it — content will be available on the next call. // Look up from our cache rather than panelSettings.targetTexture, // because we set targetTexture = null after each successful read // to restore on-screen rendering. The RT itself stays alive in s_panelRTs. bool rtJustAssigned = false; RenderTexture rt = null; if (s_panelRTs.TryGetValue(psId, out var cachedRt) && cachedRt != null) { if (cachedRt.width == width && cachedRt.height == height) { rt = cachedRt; // Re-attach if it was detached after the previous read if (panelSettings.targetTexture != rt) { panelSettings.targetTexture = rt; rtJustAssigned = true; uiDoc.rootVisualElement?.MarkDirtyRepaint(); EditorUtility.SetDirty(panelSettings); UnityEditorInternal.InternalEditorUtility.RepaintAllViews(); Canvas.ForceUpdateCanvases(); } } else { // Size changed — release the old RT panelSettings.targetTexture = null; string oldPath = AssetDatabase.GetAssetPath(cachedRt); cachedRt.Release(); if (!string.IsNullOrEmpty(oldPath)) AssetDatabase.DeleteAsset(oldPath); else UnityEngine.Object.DestroyImmediate(cachedRt); s_panelRTs.Remove(psId); } } if (rt == null) { // Create RT as an asset so PanelSettings can serialize the reference properly rt = new RenderTexture(width, height, 32, RenderTextureFormat.ARGB32); rt.name = $"MCP_UI_Render_{psId}"; rt.Create(); string rtFolder = "Assets/UI"; if (!AssetDatabase.IsValidFolder(rtFolder)) AssetDatabase.CreateFolder("Assets", "UI"); string rtAssetPath = $"{rtFolder}/RT_MCP_UI_Render_{psId}.renderTexture"; AssetDatabase.CreateAsset(rt, rtAssetPath); AssetDatabase.SaveAssets(); panelSettings.targetTexture = rt; s_panelRTs[psId] = rt; rtJustAssigned = true; // Mark dirty and force editor repaint so the panel renders into the RT uiDoc.rootVisualElement?.MarkDirtyRepaint(); EditorUtility.SetDirty(panelSettings); UnityEditorInternal.InternalEditorUtility.RepaintAllViews(); // Force a synchronous layout + repaint pass Canvas.ForceUpdateCanvases(); } // Read pixels from the RT RenderTexture prevActive = RenderTexture.active; RenderTexture.active = rt; var tex = new Texture2D(width, height, TextureFormat.RGBA32, false); tex.ReadPixels(new Rect(0, 0, width, height), 0, 0); tex.Apply(); RenderTexture.active = prevActive; // Restore targetTexture to null so the UI renders back to the // actual display / camera. The RT stays cached in s_panelRTs // and will be re-attached on the next render_ui call. if (!rtJustAssigned) { panelSettings.targetTexture = null; EditorUtility.SetDirty(panelSettings); } // Check if any content was rendered bool hasContent = false; var pixels = tex.GetPixels32(); for (int i = 0; i < pixels.Length; i += Mathf.Max(1, pixels.Length / 100)) { if (pixels[i].a > 0) { hasContent = true; break; } } // Save to Screenshots folder string resolvedName = string.IsNullOrWhiteSpace(fileName) ? $"ui-render-{DateTime.Now:yyyyMMdd-HHmmss}.png" : fileName.Trim(); if (!resolvedName.EndsWith(".png", StringComparison.OrdinalIgnoreCase)) resolvedName += ".png"; Directory.CreateDirectory(resolvedFolderAbs); string fullPath = Path.Combine(resolvedFolderAbs, resolvedName).Replace('\\', '/'); fullPath = EnsureUniqueFilePath(fullPath); byte[] png = tex.EncodeToPNG(); File.WriteAllBytes(fullPath, png); string projectRelPath = ScreenshotUtility.ToProjectRelativePath(fullPath); if (ScreenshotUtility.IsUnderAssets(projectRelPath)) AssetDatabase.ImportAsset(projectRelPath, ImportAssetOptions.ForceSynchronousImport); var data = new Dictionary { { "path", projectRelPath }, { "fullPath", fullPath }, { "width", width }, { "height", height }, { "hasContent", hasContent }, }; if (rtJustAssigned) data["note"] = "RenderTexture was just assigned to PanelSettings. Call render_ui again to capture the rendered UI."; if (!string.IsNullOrEmpty(target)) data["gameObject"] = target; if (!string.IsNullOrEmpty(uxmlPath)) data["sourceAsset"] = uxmlPath; if (includeImage) { int targetMax = maxResolution > 0 ? maxResolution : 640; Texture2D downscaled = null; try { if (width > targetMax || height > targetMax) { downscaled = ScreenshotUtility.DownscaleTexture(tex, targetMax); data["imageBase64"] = Convert.ToBase64String(downscaled.EncodeToPNG()); data["imageWidth"] = downscaled.width; data["imageHeight"] = downscaled.height; } else { data["imageBase64"] = Convert.ToBase64String(png); data["imageWidth"] = width; data["imageHeight"] = height; } } finally { if (downscaled != null) UnityEngine.Object.DestroyImmediate(downscaled); } } UnityEngine.Object.DestroyImmediate(tex); string msg = hasContent ? $"UI rendered to '{projectRelPath}'." : rtJustAssigned ? $"RenderTexture assigned to PanelSettings. Call render_ui again to capture the rendered content." : $"UI render saved to '{projectRelPath}' (no visible content detected)."; return new SuccessResponse(msg, data); } finally { if (tempGo != null) UnityEngine.Object.DestroyImmediate(tempGo); if (tempPs != null) { string tempPsPath = AssetDatabase.GetAssetPath(tempPs); if (!string.IsNullOrEmpty(tempPsPath)) AssetDatabase.DeleteAsset(tempPsPath); else UnityEngine.Object.DestroyImmediate(tempPs, true); } } } // ---- Link Stylesheet ---- private static object LinkStylesheet(JObject @params) { var p = new ToolParams(@params); string uxmlPathRaw = p.Get("path"); string uxmlPath = ValidatePath(uxmlPathRaw, out string pathError); if (pathError != null) return new ErrorResponse(pathError); // Validate the UXML path is actually a .uxml if (!uxmlPath.EndsWith(".uxml", StringComparison.OrdinalIgnoreCase)) return new ErrorResponse("'path' must point to a .uxml file."); string stylesheetPath = p.Get("stylesheet"); if (string.IsNullOrEmpty(stylesheetPath)) return new ErrorResponse("'stylesheet' parameter is required."); stylesheetPath = AssetPathUtility.SanitizeAssetPath(stylesheetPath); if (stylesheetPath == null) return new ErrorResponse("Invalid stylesheet path: contains traversal sequences."); if (!stylesheetPath.EndsWith(".uss", StringComparison.OrdinalIgnoreCase)) return new ErrorResponse("'stylesheet' must point to a .uss file."); // Read the UXML file string fullPath = Path.Combine(Application.dataPath, uxmlPath.Substring("Assets/".Length)).Replace('/', Path.DirectorySeparatorChar); if (!File.Exists(fullPath)) return new ErrorResponse($"UXML file not found: {uxmlPath}"); string content = File.ReadAllText(fullPath, Encoding.UTF8); // Check if stylesheet is already linked if (content.Contains($"src=\"{stylesheetPath}\"") || content.Contains($"src=\"project://database/{stylesheetPath}\"")) { return new SuccessResponse($"Stylesheet already linked in '{uxmlPath}'.", new { path = uxmlPath, stylesheet = stylesheetPath, alreadyLinked = true }); } // Find the insertion point (after the opening or tag) int insertIdx = FindUxmlBodyStart(content); if (insertIdx < 0) return new ErrorResponse("Could not find insertion point. Ensure UXML has a root or element."); string styleTag = $"\n "; content = content.Insert(insertIdx, styleTag); File.WriteAllText(fullPath, content, Utf8NoBom); AssetDatabase.ImportAsset(uxmlPath, ImportAssetOptions.ForceUpdate); return new SuccessResponse($"Linked stylesheet '{stylesheetPath}' to '{uxmlPath}'.", new { path = uxmlPath, stylesheet = stylesheetPath }); } // ---- Delete ---- private static object DeleteFile(JObject @params) { var p = new ToolParams(@params); string path = ValidatePath(p.Get("path"), out string pathError); if (pathError != null) return new ErrorResponse(pathError); string fullPath = Path.Combine(Application.dataPath, path.Substring("Assets/".Length)); fullPath = fullPath.Replace('/', Path.DirectorySeparatorChar); if (!File.Exists(fullPath)) { return new ErrorResponse($"File not found: {path}"); } try { bool success = AssetDatabase.DeleteAsset(path); if (!success) { return new ErrorResponse($"Failed to delete file through AssetDatabase: '{path}'"); } // Fallback: if file still exists after AssetDatabase.DeleteAsset if (File.Exists(fullPath)) { File.Delete(fullPath); } return new SuccessResponse($"Deleted {Path.GetExtension(path).TrimStart('.')} file at {path}", new { path }); } catch (Exception e) { return new ErrorResponse($"Failed to delete '{path}': {e.Message}"); } } // ---- List UI Assets ---- private static object ListUIAssets(JObject @params) { var p = new ToolParams(@params); string scope = p.Get("path") ?? "Assets"; string filterType = p.Get("filter_type") ?? p.Get("filterType"); int pageSize = p.GetInt("page_size") ?? p.GetInt("pageSize") ?? 50; int pageNumber = p.GetInt("page_number") ?? p.GetInt("pageNumber") ?? 1; scope = AssetPathUtility.SanitizeAssetPath(scope); if (scope == null) { return new ErrorResponse("Invalid path: contains traversal sequences."); } string[] folderScope = AssetDatabase.IsValidFolder(scope) ? new[] { scope } : null; // Find UXML and USS assets based on filter var allAssets = new List(); bool includeUxml = string.IsNullOrEmpty(filterType) || filterType.Equals("uxml", StringComparison.OrdinalIgnoreCase) || filterType.Equals("VisualTreeAsset", StringComparison.OrdinalIgnoreCase); bool includeUss = string.IsNullOrEmpty(filterType) || filterType.Equals("uss", StringComparison.OrdinalIgnoreCase) || filterType.Equals("StyleSheet", StringComparison.OrdinalIgnoreCase); bool includePanelSettings = string.IsNullOrEmpty(filterType) || filterType.Equals("PanelSettings", StringComparison.OrdinalIgnoreCase); if (includeUxml) { string[] guids = AssetDatabase.FindAssets("t:VisualTreeAsset", folderScope); foreach (string guid in guids) { string assetPath = AssetDatabase.GUIDToAssetPath(guid); if (!string.IsNullOrEmpty(assetPath)) { allAssets.Add(new Dictionary { ["path"] = assetPath, ["type"] = "uxml", ["name"] = Path.GetFileName(assetPath), }); } } } if (includeUss) { string[] guids = AssetDatabase.FindAssets("t:StyleSheet", folderScope); foreach (string guid in guids) { string assetPath = AssetDatabase.GUIDToAssetPath(guid); if (!string.IsNullOrEmpty(assetPath)) { allAssets.Add(new Dictionary { ["path"] = assetPath, ["type"] = "uss", ["name"] = Path.GetFileName(assetPath), }); } } } if (includePanelSettings) { string[] guids = AssetDatabase.FindAssets("t:PanelSettings", folderScope); foreach (string guid in guids) { string assetPath = AssetDatabase.GUIDToAssetPath(guid); if (!string.IsNullOrEmpty(assetPath)) { allAssets.Add(new Dictionary { ["path"] = assetPath, ["type"] = "PanelSettings", ["name"] = Path.GetFileName(assetPath), }); } } } int total = allAssets.Count; int startIndex = (pageNumber - 1) * pageSize; var paged = allAssets.Skip(startIndex).Take(pageSize).ToList(); return new SuccessResponse( $"Found {total} UI asset(s). Returning page {pageNumber} ({paged.Count} items).", new { total, pageSize, pageNumber, assets = paged, }); } // ---- Detach UIDocument ---- private static object DetachUIDocument(JObject @params) { var p = new ToolParams(@params); var targetResult = p.GetRequired("target"); var targetError = targetResult.GetOrError(out string target); if (targetError != null) return targetError; var goInstruction = new JObject { ["find"] = target }; GameObject go = ObjectResolver.Resolve(goInstruction, typeof(GameObject)) as GameObject; if (go == null) { return new ErrorResponse($"Could not find target GameObject: {target}"); } var uiDoc = go.GetComponent(); if (uiDoc == null) { return new ErrorResponse($"GameObject '{go.name}' has no UIDocument component."); } string sourceAsset = uiDoc.visualTreeAsset != null ? AssetDatabase.GetAssetPath(uiDoc.visualTreeAsset) : null; Undo.DestroyObjectImmediate(uiDoc); EditorUtility.SetDirty(go); return new SuccessResponse($"Removed UIDocument from {go.name}", new { gameObject = go.name, removedSourceAsset = sourceAsset, }); } // ---- Modify Visual Element ---- private static object ModifyVisualElement(JObject @params) { var p = new ToolParams(@params); var targetResult = p.GetRequired("target"); var targetError = targetResult.GetOrError(out string target); if (targetError != null) return targetError; string elementName = p.Get("element_name") ?? p.Get("elementName"); if (string.IsNullOrEmpty(elementName)) { return new ErrorResponse("'element_name' parameter is required."); } var goInstruction = new JObject { ["find"] = target }; GameObject go = ObjectResolver.Resolve(goInstruction, typeof(GameObject)) as GameObject; if (go == null) { return new ErrorResponse($"Could not find target GameObject: {target}"); } var uiDoc = go.GetComponent(); if (uiDoc == null) { return new ErrorResponse($"GameObject '{go.name}' has no UIDocument component."); } var root = uiDoc.rootVisualElement; if (root == null) { return new ErrorResponse($"UIDocument on {go.name} has no visual tree (not yet built)."); } // Find the target element by name var element = root.Q(elementName); if (element == null) { return new ErrorResponse($"Visual element with name '{elementName}' not found in the visual tree."); } var modifications = new List(); // Set text content (Label, Button, etc.) string text = p.Get("text"); if (text != null && element is TextElement textEl) { textEl.text = text; modifications.Add($"text='{text}'"); } else if (text != null) { return new ErrorResponse($"Element '{elementName}' ({element.GetType().Name}) does not support text content."); } // Add CSS classes JToken addClassesToken = p.GetRaw("add_classes") ?? p.GetRaw("addClasses"); if (addClassesToken is JArray addArr) { foreach (var cls in addArr) { string className = cls.ToString(); if (!element.ClassListContains(className)) { element.AddToClassList(className); modifications.Add($"+class '{className}'"); } } } // Remove CSS classes JToken removeClassesToken = p.GetRaw("remove_classes") ?? p.GetRaw("removeClasses"); if (removeClassesToken is JArray removeArr) { foreach (var cls in removeArr) { string className = cls.ToString(); if (element.ClassListContains(className)) { element.RemoveFromClassList(className); modifications.Add($"-class '{className}'"); } } } // Toggle CSS classes JToken toggleClassesToken = p.GetRaw("toggle_classes") ?? p.GetRaw("toggleClasses"); if (toggleClassesToken is JArray toggleArr) { foreach (var cls in toggleArr) { string className = cls.ToString(); element.ToggleInClassList(className); modifications.Add($"~class '{className}'"); } } // Set inline styles JToken styleToken = p.GetRaw("style") ?? p.GetRaw("inline_style") ?? p.GetRaw("inlineStyle"); if (styleToken is JObject styleObj) { ApplyInlineStyles(element, styleObj, modifications); } // Set enabled/disabled bool? enabled = p.GetNullableBool("enabled"); if (enabled.HasValue) { element.SetEnabled(enabled.Value); modifications.Add($"enabled={enabled.Value}"); } // Set visibility string visibility = p.Get("visible"); if (visibility != null) { bool isVisible = visibility.Equals("true", StringComparison.OrdinalIgnoreCase) || visibility == "1"; element.style.display = isVisible ? DisplayStyle.Flex : DisplayStyle.None; modifications.Add($"visible={isVisible}"); } // Set tooltip string tooltip = p.Get("tooltip"); if (tooltip != null) { element.tooltip = tooltip; modifications.Add($"tooltip='{tooltip}'"); } // Filter out [skipped] entries so they don't count as real modifications var applied = modifications.Where(m => !m.StartsWith("[skipped]")).ToList(); var skipped = modifications.Where(m => m.StartsWith("[skipped]")).ToList(); if (applied.Count == 0) { string msg = skipped.Count > 0 ? $"No modifications applied. Skipped unsupported styles: {string.Join(", ", skipped)}" : "No modifications specified. Provide at least one of: text, add_classes, remove_classes, toggle_classes, style, enabled, visible, tooltip."; return new ErrorResponse(msg); } var responseData = new Dictionary { { "gameObject", go.name }, { "elementName", elementName }, { "elementType", element.GetType().Name }, { "modifications", applied }, { "currentClasses", new List(element.GetClasses()) }, }; if (skipped.Count > 0) responseData["skipped"] = skipped; return new SuccessResponse( $"Modified element '{elementName}' on {go.name}: {string.Join(", ", applied)}", responseData); } private static void ApplyInlineStyles(VisualElement element, JObject styleObj, List modifications) { foreach (var prop in styleObj) { string key = prop.Key; JToken val = prop.Value; switch (key.ToLowerInvariant()) { case "backgroundcolor": case "background-color": if (ColorUtility.TryParseHtmlString(val.ToString(), out Color bgColor)) { element.style.backgroundColor = bgColor; modifications.Add($"backgroundColor={val}"); } break; case "color": if (ColorUtility.TryParseHtmlString(val.ToString(), out Color fgColor)) { element.style.color = fgColor; modifications.Add($"color={val}"); } break; case "fontsize": case "font-size": element.style.fontSize = val.ToObject(); modifications.Add($"fontSize={val}"); break; case "width": element.style.width = val.ToObject(); modifications.Add($"width={val}"); break; case "height": element.style.height = val.ToObject(); modifications.Add($"height={val}"); break; case "opacity": element.style.opacity = val.ToObject(); modifications.Add($"opacity={val}"); break; case "display": if (Enum.TryParse(val.ToString(), true, out var display)) { element.style.display = display; modifications.Add($"display={val}"); } break; case "visibility": if (Enum.TryParse(val.ToString(), true, out var vis)) { element.style.visibility = vis; modifications.Add($"visibility={val}"); } break; case "flexgrow": case "flex-grow": element.style.flexGrow = val.ToObject(); modifications.Add($"flexGrow={val}"); break; case "flexshrink": case "flex-shrink": element.style.flexShrink = val.ToObject(); modifications.Add($"flexShrink={val}"); break; case "marginleft": case "margin-left": element.style.marginLeft = val.ToObject(); modifications.Add($"marginLeft={val}"); break; case "marginright": case "margin-right": element.style.marginRight = val.ToObject(); modifications.Add($"marginRight={val}"); break; case "margintop": case "margin-top": element.style.marginTop = val.ToObject(); modifications.Add($"marginTop={val}"); break; case "marginbottom": case "margin-bottom": element.style.marginBottom = val.ToObject(); modifications.Add($"marginBottom={val}"); break; case "paddingleft": case "padding-left": element.style.paddingLeft = val.ToObject(); modifications.Add($"paddingLeft={val}"); break; case "paddingright": case "padding-right": element.style.paddingRight = val.ToObject(); modifications.Add($"paddingRight={val}"); break; case "paddingtop": case "padding-top": element.style.paddingTop = val.ToObject(); modifications.Add($"paddingTop={val}"); break; case "paddingbottom": case "padding-bottom": element.style.paddingBottom = val.ToObject(); modifications.Add($"paddingBottom={val}"); break; case "borderradius": case "border-radius": float radius = val.ToObject(); element.style.borderTopLeftRadius = radius; element.style.borderTopRightRadius = radius; element.style.borderBottomLeftRadius = radius; element.style.borderBottomRightRadius = radius; modifications.Add($"borderRadius={val}"); break; default: modifications.Add($"[skipped] {key} (unsupported inline style)"); break; } } } private static bool? GetNullableBool(this ToolParams p, string key) { var raw = p.GetRaw(key); if (raw == null) return null; if (raw.Type == JTokenType.Boolean) return raw.ToObject(); string s = raw.ToString(); if (bool.TryParse(s, out bool result)) return result; return null; } /// /// Finds the index right after the closing '>' of the root UXML element opening tag. /// Returns -1 if not found or if the root tag is self-closing. /// private static int FindUxmlBodyStart(string content) { int searchFrom = 0; while (true) { int idx = content.IndexOf(") int commentStart = content.LastIndexOf("", commentStart + 4, StringComparison.Ordinal); if (commentEnd >= 0 && commentEnd + 3 > idx) { searchFrom = commentEnd + 3; continue; } } int closeTag = content.IndexOf('>', idx); if (closeTag < 0) return -1; // Self-closing tag cannot have children if (closeTag > 0 && content[closeTag - 1] == '/') return -1; return closeTag + 1; } } // ---- Helpers ---- private static string EnsureUniqueFilePath(string path) { if (!File.Exists(path)) return path; string dir = Path.GetDirectoryName(path) ?? string.Empty; string baseName = Path.GetFileNameWithoutExtension(path); string ext = Path.GetExtension(path); int counter = 1; string candidate; do { candidate = Path.Combine(dir, $"{baseName}-{counter}{ext}").Replace('\\', '/'); counter++; } while (File.Exists(candidate)); return candidate; } private static string ColorToHex(Color c) { return $"#{ColorUtility.ToHtmlStringRGBA(c)}"; } private static string GetDecodedContents(ToolParams p) { bool isEncoded = p.GetBool("contents_encoded") || p.GetBool("contentsEncoded"); if (isEncoded) { string encoded = p.Get("encoded_contents") ?? p.Get("encodedContents"); if (!string.IsNullOrEmpty(encoded)) { try { return Encoding.UTF8.GetString(Convert.FromBase64String(encoded)); } catch (FormatException ex) { throw new ArgumentException( "Parameter 'encodedContents' must be valid base64 when 'contentsEncoded' is true.", ex); } } } return p.Get("contents"); } /// /// Validates UXML content before writing to disk. /// Returns null if valid, or an error message if malformed. /// Populates warnings list with non-fatal issues. /// Uses XmlParserContext to pre-declare common UXML namespace prefixes /// (ui, uie, engine, editor) since Unity's parser is more lenient than System.Xml. /// /// /// Ensures the root UXML element has editor-extension-mode attribute. /// UI Builder requires this to open the file. Injects "False" if missing. /// private static string EnsureEditorExtensionMode(string contents) { if (contents.Contains("editor-extension-mode")) return contents; int idx = contents.IndexOf("', idx); if (closeTag < 0) return contents; bool selfClosing = contents[closeTag - 1] == '/'; int insertPos = selfClosing ? closeTag - 1 : closeTag; return contents.Substring(0, insertPos) + " editor-extension-mode=\"False\"" + contents.Substring(insertPos); } private static string ValidateUxmlContent(string contents, List warnings) { if (string.IsNullOrWhiteSpace(contents)) return "UXML content is empty."; var nt = new NameTable(); var nsMgr = new XmlNamespaceManager(nt); nsMgr.AddNamespace("ui", "UnityEngine.UIElements"); nsMgr.AddNamespace("uie", "UnityEditor.UIElements"); nsMgr.AddNamespace("engine", "UnityEngine.UIElements"); nsMgr.AddNamespace("editor", "UnityEditor.UIElements"); var ctx = new XmlParserContext(nt, nsMgr, null, XmlSpace.Default); string rootLocalName = null; try { using (var reader = XmlReader.Create(new StringReader(contents), null, ctx)) { while (reader.Read()) { if (reader.NodeType == XmlNodeType.Element && rootLocalName == null) rootLocalName = reader.LocalName; } } } catch (XmlException ex) { return $"Malformed XML at line {ex.LineNumber}, position {ex.LinePosition}: {ex.Message}"; } if (rootLocalName == null) return "UXML content has no root element."; if (rootLocalName != "UXML") warnings.Add($"Root element is <{rootLocalName}>, expected or ."); if (!contents.Contains("UnityEngine.UIElements")) { warnings.Add("Missing namespace declaration xmlns:ui=\"UnityEngine.UIElements\". " + "UI Builder may fail to open this file."); } return null; } /// /// Validates a UXML asset after import by attempting to load it as a VisualTreeAsset. /// private static void ValidateUxmlPostImport(string assetPath, List warnings) { var asset = AssetDatabase.LoadAssetAtPath(assetPath); if (asset == null) { warnings.Add("Unity failed to parse the UXML file. " + "The file was written but UI Builder will not be able to open it. " + "Check the console for details."); } } } }