chore(unity): 固定Unity CLI Loop本地包并移除UnitySkills依赖

This commit is contained in:
Liubing\LB
2026-09-28 18:14:59 +08:00
parent 0eb43b2b40
commit c4ae02def9
952 changed files with 70137 additions and 11 deletions

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: d1b9a8f05a3f041e2b4960ecd070c408
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,176 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
using UnityEditor;
using UnityEditorInternal;
using UnityEngine;
namespace io.github.hatayama.uLoopMCP
{
/// <summary>
/// Validates Assembly Definition (.asmdef) configuration before compilation.
/// </summary>
public class AssemblyDefinitionDuplicationValidationService
{
private static readonly Regex AsmdefNameRegex =
new("\"name\"\\s*:\\s*\"(?<name>[^\"]+)\"", RegexOptions.Compiled);
private static readonly Regex DuplicateAsmdefConsoleRegex = new(
"^Assembly with name '(?<name>[^']+)' already exists \\((?<path>[^)]+)\\)$",
RegexOptions.Compiled
);
public ValidationResult ValidateNoDuplicateAsmdefNamesFromConsoleErrors()
{
LogRetrievalService retrievalService = new();
LogDisplayDto logData = retrievalService.GetLogsWithSearch(
McpLogType.Error,
"Assembly with name '",
useRegex: false,
searchInStackTrace: false
);
Dictionary<string, List<string>> pathsByAsmName = new(StringComparer.Ordinal);
foreach (LogEntryDto entry in logData.LogEntries)
{
if (string.IsNullOrEmpty(entry.Message))
{
continue;
}
Match match = DuplicateAsmdefConsoleRegex.Match(entry.Message.Trim());
if (!match.Success)
{
continue;
}
string asmName = match.Groups["name"].Value;
string assetPath = match.Groups["path"].Value;
if (string.IsNullOrEmpty(asmName) || string.IsNullOrEmpty(assetPath))
{
continue;
}
// Prevent false positives from stale console logs by verifying the asset still exists.
UnityEngine.Object obj = AssetDatabase.LoadAssetAtPath<UnityEngine.Object>(assetPath);
if (obj == null)
{
continue;
}
if (!pathsByAsmName.TryGetValue(asmName, out List<string> paths))
{
paths = new List<string>();
pathsByAsmName.Add(asmName, paths);
}
if (!paths.Contains(assetPath))
{
paths.Add(assetPath);
}
}
if (pathsByAsmName.Count == 0)
{
return ValidationResult.Success();
}
string details = string.Join(
"\n",
pathsByAsmName
.OrderBy(kvp => kvp.Key, StringComparer.Ordinal)
.Take(5)
.Select(d =>
{
string paths = string.Join("\n ", d.Value.Take(8));
return $"- {d.Key}\n {paths}";
})
);
string message =
$"{McpConstants.ERROR_MESSAGE_DUPLICATE_ASMDEF}\n" +
"Detected from Console errors:\n" +
$"{details}\n" +
"Fix: ensure each .asmdef has a unique \"name\".";
return ValidationResult.Failure(message);
}
public ValidationResult ValidateNoDuplicateAsmdefNames()
{
string[] asmdefGuids = AssetDatabase.FindAssets("t:AssemblyDefinitionAsset");
Dictionary<string, List<string>> pathsByAsmName = new(StringComparer.Ordinal);
foreach (string guid in asmdefGuids)
{
string assetPath = AssetDatabase.GUIDToAssetPath(guid);
AssemblyDefinitionAsset asmdef = AssetDatabase.LoadAssetAtPath<AssemblyDefinitionAsset>(assetPath);
if (asmdef == null)
{
continue;
}
string asmName = GetAsmdefName(asmdef);
if (!pathsByAsmName.TryGetValue(asmName, out List<string> paths))
{
paths = new List<string>();
pathsByAsmName.Add(asmName, paths);
}
paths.Add(assetPath);
}
KeyValuePair<string, List<string>>[] duplicates = pathsByAsmName
.Where(kvp => kvp.Value.Count > 1)
.OrderBy(kvp => kvp.Key, StringComparer.Ordinal)
.ToArray();
if (duplicates.Length == 0)
{
return ValidationResult.Success();
}
string details = string.Join(
"\n",
duplicates
.Take(5)
.Select(d =>
{
string paths = string.Join("\n ", d.Value.Take(8));
return $"- {d.Key}\n {paths}";
})
);
string message =
$"{McpConstants.ERROR_MESSAGE_DUPLICATE_ASMDEF}\n" +
"Duplicates (first 5 groups):\n" +
$"{details}\n" +
"Fix: ensure each .asmdef has a unique \"name\".";
return ValidationResult.Failure(message);
}
private static string GetAsmdefName(AssemblyDefinitionAsset asmdef)
{
string json = asmdef.text;
if (string.IsNullOrEmpty(json))
{
return asmdef.name;
}
Match match = AsmdefNameRegex.Match(json);
if (match.Success)
{
string name = match.Groups["name"].Value;
if (!string.IsNullOrEmpty(name))
{
return name;
}
}
return asmdef.name;
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 8b32dc95c226e4b38a7d42dcbf289199
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,151 @@
using System.Threading.Tasks;
using Newtonsoft.Json;
namespace io.github.hatayama.uLoopMCP
{
/// <summary>
/// Application service responsible for client notification processing
/// Single responsibility: Sending notifications to MCP clients
/// Related classes: McpBridgeServer, CustomToolManager
/// </summary>
public static class ClientNotificationService
{
/// <summary>
/// Send tool change notification to clients
/// </summary>
/// <remarks>
/// This notification serves dual purposes in this project:
/// 1. Original MCP purpose: Notify that available tools have changed
/// 2. uLoopMCP additional purpose: Signal that Unity is ready after Domain Reload
///
/// After Domain Reload completes, this notification is sent to indicate Unity
/// has finished initialization and can reliably process requests. TypeScript side
/// uses this as a "Unity ready" signal. The name doesn't perfectly match this
/// secondary purpose, but it avoids adding custom notification complexity.
/// </remarks>
public static void SendToolsChangedNotification()
{
McpBridgeServer currentServer = McpServerController.CurrentServer;
if (currentServer == null)
{
return;
}
// Send only MCP standard notification
var notificationParams = new
{
timestamp = System.DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ss.fffZ"),
message = "Unity tools have been updated"
};
var mcpNotification = new
{
jsonrpc = McpServerConfig.JSONRPC_VERSION,
method = "notifications/tools/list_changed",
@params = notificationParams
};
string mcpNotificationJson = JsonConvert.SerializeObject(mcpNotification);
currentServer.SendNotificationToClients(mcpNotificationJson);
// Log recording
System.Diagnostics.StackTrace stackTrace = new System.Diagnostics.StackTrace(true);
string callerInfo = stackTrace.GetFrame(1)?.GetMethod()?.Name ?? "Unknown";
VibeLogger.LogInfo(
"tools_list_changed_notification",
"Sent tools changed notification to MCP clients",
new { caller = callerInfo, timestamp = notificationParams.timestamp }
);
}
/// <summary>
/// Check if server is running and send tool change notification
/// </summary>
public static void TriggerToolChangeNotification()
{
if (McpServerController.IsServerRunning)
{
SendToolsChangedNotification();
}
}
/// <summary>
/// Send tool notification with frame delay after compilation
/// </summary>
/// <returns>Task for notification sending process</returns>
public static async Task SendToolNotificationAfterCompilationAsync()
{
// Frame delay for Unity editor stabilization after Domain Reload
await EditorDelay.DelayFrame(1);
CustomToolManager.NotifyToolChanges();
}
/// <summary>
/// Send notification to specific clients
/// </summary>
/// <param name="notification">JSON data of notification to send</param>
public static void SendNotificationToClients(string notification)
{
McpBridgeServer currentServer = McpServerController.CurrentServer;
if (currentServer == null)
{
return;
}
currentServer.SendNotificationToClients(notification);
}
/// <summary>
/// Log before server stop
/// </summary>
/// <param name="correlationId">Tracking ID for related operations</param>
/// <param name="port">Port number of server to stop</param>
public static void LogServerStoppingBeforeDomainReload(string correlationId, int port)
{
VibeLogger.LogInfo(
"domain_reload_server_stopping",
"Stopping MCP server before domain reload",
new { port = port },
correlationId
);
}
/// <summary>
/// Log server stop completion
/// </summary>
/// <param name="correlationId">Tracking ID for related operations</param>
public static void LogServerStoppedAfterDomainReload(string correlationId)
{
VibeLogger.LogInfo(
"domain_reload_server_stopped",
"MCP server stopped successfully",
new { tcp_port_released = true },
correlationId
);
}
/// <summary>
/// Log server stop error
/// </summary>
/// <param name="correlationId">Tracking ID for related operations</param>
/// <param name="ex">Exception that occurred</param>
/// <param name="port">Port number attempted to stop</param>
public static void LogServerShutdownError(string correlationId, System.Exception ex, int port)
{
VibeLogger.LogException(
"domain_reload_server_shutdown_error",
ex,
new
{
port = port,
server_was_running = true
},
correlationId,
"Critical error during server shutdown before assembly reload. This may cause port conflicts on restart.",
"Investigate server shutdown process and ensure proper TCP port release."
);
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: d310cf0c71df949279b413297c985681
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,24 @@
using System.Threading.Tasks;
using System.Threading;
namespace io.github.hatayama.uLoopMCP
{
/// <summary>
/// Compilation execution service
/// Single function: Execute Unity project compilation
/// Related classes: CompileController, CompileUseCase, CompileTool
/// </summary>
public class CompilationExecutionService
{
/// <summary>
/// Execute compilation asynchronously
/// </summary>
/// <param name="forceRecompile">Force recompile flag</param>
/// <returns>Compilation result</returns>
public async Task<CompileResult> ExecuteCompilationAsync(bool forceRecompile, CancellationToken ct)
{
using CompileController compileController = new();
return await compileController.TryCompileAsync(forceRecompile, ct);
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 827a060642b094394a80d172dfa403f6
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,60 @@
using System.IO;
using UnityEditor;
using UnityEditor.Compilation;
namespace io.github.hatayama.uLoopMCP
{
/// <summary>
/// Application service responsible for compilation lock file management.
/// Single responsibility: Create/delete lock file during compilation for CLI detection.
/// Related classes: DomainReloadDetectionService (similar pattern for domain reload)
/// </summary>
[InitializeOnLoad]
public static class CompilationLockService
{
private const string LOCK_FILE_NAME = "compiling.lock";
private static string LockFilePath => Path.Combine(UnityEngine.Application.dataPath, "..", "Temp", LOCK_FILE_NAME);
static CompilationLockService()
{
CompilationPipeline.compilationStarted += OnCompilationStarted;
CompilationPipeline.compilationFinished += OnCompilationFinished;
}
private static void OnCompilationStarted(object context)
{
CreateLockFile();
}
private static void OnCompilationFinished(object context)
{
DeleteLockFile();
}
private static void CreateLockFile()
{
string lockPath = LockFilePath;
string tempDir = Path.GetDirectoryName(lockPath);
if (!Directory.Exists(tempDir))
{
return;
}
File.WriteAllText(lockPath, System.DateTime.UtcNow.ToString("o"));
}
/// <summary>
/// Delete lock file. Called on server startup to handle crash recovery.
/// </summary>
public static void DeleteLockFile()
{
string lockPath = LockFilePath;
if (File.Exists(lockPath))
{
File.Delete(lockPath);
}
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 7cba3d0bab93f4b8a9c90c2c18bbbf1b
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,49 @@
using UnityEditor;
namespace io.github.hatayama.uLoopMCP
{
/// <summary>
/// Compilation state validation service
/// Single function: Validate state before compilation execution
/// Related classes: CompileTool, CompileUseCase, McpSessionManager
/// </summary>
public class CompilationStateValidationService
{
/// <summary>
/// Validate state before compilation execution
/// </summary>
/// <returns>Validation result</returns>
public ValidationResult ValidateCompilationState()
{
if (EditorApplication.isCompiling)
{
return ValidationResult.Failure(
"Compilation is already in progress. Please wait for the current compilation to finish."
);
}
if (McpEditorSettings.GetIsDomainReloadInProgress())
{
return ValidationResult.Failure(
"Cannot compile while domain reload is in progress. Please wait for the domain reload to complete."
);
}
if (EditorApplication.isUpdating)
{
return ValidationResult.Failure(
"Cannot compile while editor is updating. Please wait for the update to complete."
);
}
AssemblyDefinitionDuplicationValidationService asmdefValidationService = new();
ValidationResult asmdefValidation = asmdefValidationService.ValidateNoDuplicateAsmdefNamesFromConsoleErrors();
if (!asmdefValidation.IsValid)
{
return asmdefValidation;
}
return ValidationResult.Success();
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 02db968680595492b8928dc6bc856413
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,68 @@
using System;
using System.IO;
using System.Text;
using Newtonsoft.Json;
using UnityEngine;
namespace io.github.hatayama.uLoopMCP
{
/// <summary>
/// Persist compile responses for delayed retrieval after domain reload.
/// </summary>
public static class CompileResultPersistenceService
{
// Concurrent clients may still be waiting on recent result files.
// Only delete files older than this threshold (longer than the 90-second wait timeout)
// to avoid destroying results that active waiters need.
private static readonly TimeSpan StaleResultThreshold = TimeSpan.FromMinutes(2);
private static string ProjectRootPath => Path.GetFullPath(Path.Combine(Application.dataPath, ".."));
private static string CompileResultDirectoryPath => Path.Combine(
ProjectRootPath,
McpConstants.TEMP_DIR,
McpConstants.ULOOPMCP_DIR,
McpConstants.COMPILE_RESULTS_DIR
);
public static void ClearStaleResults()
{
if (!Directory.Exists(CompileResultDirectoryPath))
{
return;
}
string searchPattern = $"*{McpConstants.JSON_FILE_EXTENSION}";
string[] resultFiles = Directory.GetFiles(CompileResultDirectoryPath, searchPattern);
DateTime staleThreshold = DateTime.UtcNow - StaleResultThreshold;
foreach (string resultFilePath in resultFiles)
{
FileInfo fileInfo = new FileInfo(resultFilePath);
if (fileInfo.LastWriteTimeUtc < staleThreshold)
{
File.Delete(resultFilePath);
}
}
}
public static void SaveResult(string requestId, CompileResponse response)
{
Debug.Assert(!string.IsNullOrWhiteSpace(requestId), "requestId must not be null or empty");
Debug.Assert(response != null, "response must not be null");
if (!Directory.Exists(CompileResultDirectoryPath))
{
Directory.CreateDirectory(CompileResultDirectoryPath);
}
string sanitizedFileName = Path.GetFileName(requestId);
Debug.Assert(sanitizedFileName == requestId,
$"requestId must not contain path separators: '{requestId}'");
string resultJson = JsonConvert.SerializeObject(response, Formatting.None);
string fileName = $"{sanitizedFileName}{McpConstants.JSON_FILE_EXTENSION}";
string filePath = Path.Combine(CompileResultDirectoryPath, fileName);
File.WriteAllText(filePath, resultJson, Encoding.UTF8);
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: baa3261503cd245b09d0570f38d02c63
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,438 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using UnityEditor;
using UnityEngine;
namespace io.github.hatayama.uLoopMCP
{
/// <summary>
/// Application service responsible for monitoring connected LLM tools
/// Single responsibility: Track connected/disconnected tools and persist state
/// Related classes: McpBridgeServer, McpEditorSettings, ConnectedLLMToolData
/// </summary>
[InitializeOnLoad]
public static class ConnectedToolsMonitoringService
{
private static List<ConnectedLLMToolData> _connectedTools = new();
private static List<ConnectedLLMToolData> _toolsBackup;
private static CancellationTokenSource _cleanupCancellationTokenSource;
// Events for UI notification
public static event System.Action OnConnectedToolsChanged;
static ConnectedToolsMonitoringService()
{
Initialize();
}
/// <summary>
/// Initialize the monitoring service
/// </summary>
private static void Initialize()
{
SubscribeToServerEvents();
if (ShouldRestorePersistedToolsForReconnect())
{
RestoreConnectedToolsFromSettings();
return;
}
SynchronizeConnectedToolsWithCurrentServer();
}
/// <summary>
/// Subscribe to server lifecycle events
/// </summary>
private static void SubscribeToServerEvents()
{
McpBridgeServer.OnServerStopping += OnServerStopping;
McpBridgeServer.OnServerStarted += OnServerStarted;
McpBridgeServer.OnToolConnected += OnToolConnected;
McpBridgeServer.OnToolDisconnected += OnToolDisconnected;
McpBridgeServer.OnAllToolsCleared += OnAllToolsCleared;
}
/// <summary>
/// Handle server stopping event - keep the last live snapshot for reconnecting UI
/// </summary>
private static void OnServerStopping()
{
_toolsBackup = _connectedTools
.Where(tool => tool != null && !string.IsNullOrEmpty(tool.Name) && tool.Name != McpConstants.UNKNOWN_CLIENT_NAME)
.ToList();
SyncConnectedToolsToSettings();
}
/// <summary>
/// Handle server started event - restore the reconnecting snapshot first, then cleanup stale tools
/// </summary>
private static void OnServerStarted()
{
if (_toolsBackup != null && _toolsBackup.Count > 0)
{
RestoreConnectedTools(_toolsBackup);
_toolsBackup = null;
return;
}
if (ShouldRestorePersistedToolsForReconnect())
{
ConnectedLLMToolData[] persistedTools = McpEditorSettings.GetConnectedLLMTools();
if (persistedTools != null && persistedTools.Length > 0)
{
RestoreConnectedTools(persistedTools.ToList());
return;
}
}
SynchronizeConnectedToolsWithCurrentServer();
}
/// <summary>
/// Handle tool connected event - add tool to connected list
/// </summary>
private static void OnToolConnected(ConnectedClient client)
{
AddConnectedTool(client);
}
/// <summary>
/// Handle tool disconnected event - remove tool from connected list
/// </summary>
private static void OnToolDisconnected(string toolName)
{
RemoveConnectedTool(toolName);
}
/// <summary>
/// Handle all tools cleared event - clear all connected tools
/// </summary>
private static void OnAllToolsCleared()
{
ClearConnectedTools();
}
/// <summary>
/// Add a connected LLM tool
/// </summary>
public static void AddConnectedTool(ConnectedClient client)
{
if (client.ClientName == McpConstants.UNKNOWN_CLIENT_NAME)
{
return;
}
// Remove existing tool if present, then add
_connectedTools.RemoveAll(tool => tool.Name == client.ClientName);
ConnectedLLMToolData toolData = new(
client.ClientName,
client.Endpoint,
client.Port,
client.ConnectedAt
);
_connectedTools.Add(toolData);
// Persist to settings
McpEditorSettings.AddConnectedLLMTool(toolData);
// Notify UI
OnConnectedToolsChanged?.Invoke();
}
/// <summary>
/// Remove a connected LLM tool
/// </summary>
public static void RemoveConnectedTool(string toolName)
{
_connectedTools.RemoveAll(tool => tool.Name == toolName);
// Persist to settings
McpEditorSettings.RemoveConnectedLLMTool(toolName);
// Notify UI
OnConnectedToolsChanged?.Invoke();
}
/// <summary>
/// Clear all connected LLM tools
/// </summary>
public static void ClearConnectedTools()
{
_connectedTools.Clear();
// Persist to settings
McpEditorSettings.ClearConnectedLLMTools();
// Notify UI
OnConnectedToolsChanged?.Invoke();
}
/// <summary>
/// Get connected tools as ConnectedClient objects for UI display, sorted by name
/// </summary>
public static IEnumerable<ConnectedClient> GetConnectedToolsAsClients()
{
return _connectedTools.OrderBy(tool => tool.Name).Select(tool => ConvertToConnectedClient(tool));
}
/// <summary>
/// Convert stored tool data to ConnectedClient for UI display
/// </summary>
private static ConnectedClient ConvertToConnectedClient(ConnectedLLMToolData toolData)
{
return new ConnectedClient(toolData.Endpoint, null, toolData.Port, toolData.Name);
}
/// <summary>
/// Restore connected tools from backup after server restart.
/// The reconnecting UI should keep the last live snapshot until clients rejoin.
/// </summary>
private static void RestoreConnectedTools(List<ConnectedLLMToolData> backup)
{
if (backup == null || backup.Count == 0)
{
return;
}
RestoreConnectedToolsImmediately(backup);
_cleanupCancellationTokenSource?.Cancel();
_cleanupCancellationTokenSource?.Dispose();
_cleanupCancellationTokenSource = new CancellationTokenSource();
DelayedCleanupAsync(_cleanupCancellationTokenSource.Token).ContinueWith(task =>
{
if (task.IsFaulted && !task.IsCanceled)
{
EditorApplication.delayCall += () =>
{
Debug.LogError($"[uLoopMCP] Failed to perform delayed cleanup: {task.Exception?.GetBaseException().Message}");
};
}
}, TaskScheduler.FromCurrentSynchronizationContext());
}
private static void RestoreConnectedToolsImmediately(IEnumerable<ConnectedLLMToolData> tools)
{
List<ConnectedLLMToolData> restoredTools = tools
.Where(tool => tool != null && !string.IsNullOrEmpty(tool.Name) && tool.Name != McpConstants.UNKNOWN_CLIENT_NAME)
.GroupBy(tool => tool.Name)
.Select(group => group.Last())
.OrderBy(tool => tool.Name)
.ToList();
_connectedTools = restoredTools;
SyncConnectedToolsToSettings();
OnConnectedToolsChanged?.Invoke();
}
private static async Task DelayedCleanupAsync(CancellationToken cancellationToken = default)
{
try
{
await TimerDelay.Wait(8000, cancellationToken);
}
catch (OperationCanceledException)
{
return;
}
if (!McpServerController.IsServerRunning || cancellationToken.IsCancellationRequested)
{
return;
}
IReadOnlyCollection<ConnectedClient> actualConnectedClients = McpServerController.CurrentServer?.GetConnectedClients();
if (actualConnectedClients == null || cancellationToken.IsCancellationRequested)
{
return;
}
HashSet<string> actualClientNames = new HashSet<string>(
actualConnectedClients
.Where(client => client.ClientName != McpConstants.UNKNOWN_CLIENT_NAME)
.Select(client => client.ClientName)
);
List<ConnectedLLMToolData> toolsToRemove = _connectedTools
.Where(tool => !actualClientNames.Contains(tool.Name))
.ToList();
foreach (ConnectedLLMToolData tool in toolsToRemove)
{
RemoveConnectedTool(tool.Name);
}
}
/// <summary>
/// Rebuild connected tools from the current live server state.
/// The connected tools UI must reflect live MCP connections only.
/// </summary>
private static void SynchronizeConnectedToolsWithCurrentServer()
{
IReadOnlyCollection<ConnectedClient> liveClients = GetLiveConnectedClients();
ReplaceConnectedTools(liveClients);
}
private static bool ShouldRestorePersistedToolsForReconnect()
{
return McpEditorSettings.GetShowReconnectingUI() || McpEditorSettings.GetShowPostCompileReconnectingUI();
}
/// <summary>
/// Get live clients from the current server, ignoring persisted settings.
/// </summary>
private static IReadOnlyCollection<ConnectedClient> GetLiveConnectedClients()
{
if (!McpServerController.IsServerRunning)
{
return Array.Empty<ConnectedClient>();
}
IReadOnlyCollection<ConnectedClient> connectedClients = McpServerController.CurrentServer?.GetConnectedClients();
return connectedClients ?? Array.Empty<ConnectedClient>();
}
/// <summary>
/// Replace the in-memory and persisted connected tools state with the current live clients.
/// </summary>
private static void ReplaceConnectedTools(IEnumerable<ConnectedClient> connectedClients)
{
List<ConnectedLLMToolData> tools = connectedClients
.Where(client => client != null && client.ClientName != McpConstants.UNKNOWN_CLIENT_NAME)
.Select(client => new ConnectedLLMToolData(
client.ClientName,
client.Endpoint,
client.Port,
client.ConnectedAt
))
.GroupBy(tool => tool.Name)
.Select(group => group.Last())
.OrderBy(tool => tool.Name)
.ToList();
_connectedTools = tools;
ConnectedLLMToolData[] toolsArray = _connectedTools
.Where(tool => tool != null && !string.IsNullOrEmpty(tool.Name))
.ToArray();
SaveConnectedToolsWhenChanged(
toolsArray,
McpEditorSettings.GetConnectedLLMTools,
McpEditorSettings.SetConnectedLLMTools);
OnConnectedToolsChanged?.Invoke();
}
/// <summary>
/// Restore the persisted connected tools snapshot for reconnecting UI only.
/// This path must not be used for normal startup because it can reintroduce stale entries.
/// </summary>
private static void RestoreConnectedToolsFromSettings()
{
ConnectedLLMToolData[] savedTools = McpEditorSettings.GetConnectedLLMTools();
if (savedTools == null || savedTools.Length == 0)
{
_connectedTools = new List<ConnectedLLMToolData>();
return;
}
RestoreConnectedToolsImmediately(savedTools);
}
private static void SyncConnectedToolsToSettings()
{
ConnectedLLMToolData[] toolsArray = _connectedTools
.Where(tool => tool != null && !string.IsNullOrEmpty(tool.Name) && tool.Name != McpConstants.UNKNOWN_CLIENT_NAME)
.ToArray();
SaveConnectedToolsWhenChanged(
toolsArray,
McpEditorSettings.GetConnectedLLMTools,
McpEditorSettings.SetConnectedLLMTools);
}
internal static bool SaveConnectedToolsWhenChanged(
ConnectedLLMToolData[] toolsArray,
Func<ConnectedLLMToolData[]> loadPersistedTools,
Action<ConnectedLLMToolData[]> savePersistedTools)
{
Debug.Assert(toolsArray != null, "toolsArray must not be null");
Debug.Assert(loadPersistedTools != null, "loadPersistedTools must not be null");
Debug.Assert(savePersistedTools != null, "savePersistedTools must not be null");
ConnectedLLMToolData[] persistedTools = loadPersistedTools() ?? Array.Empty<ConnectedLLMToolData>();
if (AreConnectedToolSnapshotsEquivalent(toolsArray, persistedTools))
{
return false;
}
savePersistedTools(toolsArray);
return true;
}
private static bool AreConnectedToolSnapshotsEquivalent(
IReadOnlyList<ConnectedLLMToolData> first,
IReadOnlyList<ConnectedLLMToolData> second)
{
if (first == null || second == null)
{
return first == second;
}
if (first.Count != second.Count)
{
return false;
}
for (int i = 0; i < first.Count; i++)
{
ConnectedLLMToolData firstTool = first[i];
ConnectedLLMToolData secondTool = second[i];
if (!AreConnectedToolsEquivalent(firstTool, secondTool))
{
return false;
}
}
return true;
}
private static bool AreConnectedToolsEquivalent(
ConnectedLLMToolData first,
ConnectedLLMToolData second)
{
if (first == null || second == null)
{
return first == second;
}
return string.Equals(first.Name, second.Name, StringComparison.Ordinal)
&& string.Equals(first.Endpoint, second.Endpoint, StringComparison.Ordinal)
&& first.Port == second.Port;
}
internal static void ReplaceConnectedToolsForTests(IReadOnlyCollection<ConnectedClient> connectedClients)
{
ReplaceConnectedTools(connectedClients);
}
internal static void RestorePersistedConnectedToolsForTests()
{
RestoreConnectedToolsFromSettings();
}
internal static void ResetStateForTests()
{
_connectedTools = new List<ConnectedLLMToolData>();
_toolsBackup = null;
_cleanupCancellationTokenSource?.Cancel();
_cleanupCancellationTokenSource?.Dispose();
_cleanupCancellationTokenSource = null;
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: c691b46b7229e43c5807d2614f83568a
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,222 @@
using System.IO;
using UnityEditor;
using UnityEngine;
namespace io.github.hatayama.uLoopMCP
{
/// <summary>
/// Application service responsible for Domain Reload detection and state management
/// Single responsibility: Domain Reload lifecycle management
/// Related classes: McpSessionManager, McpServerController
/// </summary>
[InitializeOnLoad]
public static class DomainReloadDetectionService
{
private static bool IsBackgroundUnityProcess()
{
bool isBatchMode = Application.isBatchMode;
bool isAssetImportWorker = AssetDatabase.IsAssetImportWorkerProcess();
return isBatchMode || isAssetImportWorker;
}
static DomainReloadDetectionService()
{
if (IsBackgroundUnityProcess())
{
VibeLogger.LogInfo("domain_reload_hook_skip", "Skipping domain reload hooks in background Unity process.");
return;
}
AssemblyReloadEvents.beforeAssemblyReload += OnBeforeAssemblyReload;
AssemblyReloadEvents.afterAssemblyReload += OnAfterAssemblyReload;
}
private static void OnBeforeAssemblyReload()
{
if (IsBackgroundUnityProcess())
{
return;
}
CreateLockFile();
}
private static void OnAfterAssemblyReload()
{
// Lock file is deleted by McpBridgeServer when server startup completes
// to avoid a gap between domain reload end and server ready
}
private const string LOCK_FILE_NAME = "domainreload.lock";
private static string LockFilePath => Path.Combine(UnityEngine.Application.dataPath, "..", "Temp", LOCK_FILE_NAME);
/// <summary>
/// Execute Domain Reload start processing
/// </summary>
/// <param name="correlationId">Tracking ID for related operations</param>
/// <param name="serverIsRunning">Whether server is running</param>
/// <param name="serverPort">Server port number</param>
public static void StartDomainReload(string correlationId, bool serverIsRunning, int? serverPort)
{
if (IsBackgroundUnityProcess())
{
VibeLogger.LogInfo("domain_reload_start_ignored", "background_process", correlationId: correlationId);
return;
}
// Create lock file for external process detection (e.g., CLI tools)
CreateLockFile();
// Save session state if server is running
if (serverIsRunning && serverPort.HasValue)
{
int port = serverPort.Value;
McpEditorSettings.UpdateSettings(s => s with
{
isDomainReloadInProgress = true,
isServerRunning = true,
customPort = port,
isAfterCompile = true,
isReconnecting = true,
showReconnectingUI = true,
showPostCompileReconnectingUI = true
});
}
else
{
McpEditorSettings.SetIsDomainReloadInProgress(true);
}
McpEditorDomainReloadStateProvider.SetDomainReloadInProgressFromMainThread(true);
// Log recording
VibeLogger.LogInfo(
"domain_reload_start",
"Domain reload starting",
new
{
server_running = serverIsRunning,
server_port = serverPort
},
correlationId
);
}
/// <summary>
/// Execute Domain Reload completion processing
/// </summary>
/// <param name="correlationId">Tracking ID for related operations</param>
public static void CompleteDomainReload(string correlationId)
{
if (IsBackgroundUnityProcess())
{
VibeLogger.LogInfo("domain_reload_complete_ignored", "background_process", correlationId: correlationId);
return;
}
// Lock file is deleted by McpBridgeServer when server startup completes
// to avoid a gap between domain reload completion and server ready
// Clear Domain Reload completion flag
McpEditorSettings.ClearDomainReloadFlag();
McpEditorDomainReloadStateProvider.SetDomainReloadInProgressFromMainThread(false);
// Log recording
VibeLogger.LogInfo(
"domain_reload_complete",
"Domain reload completed - starting server recovery process",
new { session_server_port = McpEditorSettings.GetCustomPort() },
correlationId
);
}
internal static void RollbackDomainReloadStart(string correlationId)
{
if (IsBackgroundUnityProcess())
{
VibeLogger.LogInfo("domain_reload_rollback_ignored", "background_process", correlationId: correlationId);
return;
}
McpEditorSettings.UpdateSettings(s => s with
{
isDomainReloadInProgress = false,
isAfterCompile = false,
isReconnecting = false,
showReconnectingUI = false,
showPostCompileReconnectingUI = false
});
McpEditorDomainReloadStateProvider.SetDomainReloadInProgressFromMainThread(false);
DeleteLockFile();
VibeLogger.LogWarning(
"domain_reload_start_rollback",
"Rolled back domain reload start state after pre-reload failure.",
correlationId: correlationId
);
}
/// <summary>
/// Check if currently in Domain Reload
/// </summary>
/// <returns>True if Domain Reload is in progress</returns>
public static bool IsDomainReloadInProgress()
{
return McpEditorSettings.GetIsDomainReloadInProgress();
}
/// <summary>
/// Check if reconnection UI display is required
/// </summary>
/// <returns>True if reconnection UI display is required</returns>
public static bool ShouldShowReconnectingUI()
{
return McpEditorSettings.GetShowReconnectingUI();
}
/// <summary>
/// Check if in after-compile state
/// </summary>
/// <returns>True if after compile</returns>
public static bool IsAfterCompile()
{
return McpEditorSettings.GetIsAfterCompile();
}
private static void CreateLockFile()
{
string lockPath = LockFilePath;
string tempDir = Path.GetDirectoryName(lockPath);
if (!Directory.Exists(tempDir))
{
return;
}
File.WriteAllText(lockPath, System.DateTime.UtcNow.ToString("o"));
}
/// <summary>
/// Delete lock file to signal Domain Reload completion.
/// </summary>
public static void DeleteLockFile()
{
string lockPath = LockFilePath;
if (File.Exists(lockPath))
{
File.Delete(lockPath);
}
}
/// <summary>
/// Check if Domain Reload lock file exists.
/// Used by external processes to detect Domain Reload state.
/// </summary>
/// <returns>True if lock file exists</returns>
public static bool IsLockFilePresent()
{
return File.Exists(LockFilePath);
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 551d705a408284a048b775c7bc655fbe
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,45 @@
using UnityEditor;
namespace io.github.hatayama.uLoopMCP
{
/// <summary>
/// Application service responsible for initialization notifications and dialogs.
/// Handles user confirmations, error notifications, and status updates.
///
/// Related classes:
/// - UnityEditor.EditorUtility: Provides dialog functionality
/// - McpServerController: Uses this service for user interactions
/// </summary>
public class InitializationNotificationService
{
/// <summary>
/// Shows an invalid port error dialog to the user.
/// </summary>
/// <param name="port">The invalid port number</param>
/// <returns>Success indicator</returns>
public ServiceResult<bool> ShowInvalidPortDialog(int port)
{
EditorUtility.DisplayDialog(
"Invalid Port",
$"Port {port} is not valid for server startup.\n\nPort must be 1024 or higher and not a reserved system port.",
"OK"
);
return ServiceResult<bool>.SuccessResult(true);
}
/// <summary>
/// Shows a configuration validation error dialog.
/// </summary>
/// <param name="errorMessage">The validation error message</param>
/// <returns>Success indicator</returns>
public ServiceResult<bool> ShowValidationErrorDialog(string errorMessage)
{
EditorUtility.DisplayDialog(
"Server Configuration Error",
$"Server configuration validation failed:\n\n{errorMessage}",
"OK"
);
return ServiceResult<bool>.SuccessResult(true);
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: cd9cbc0fca6884497a0955425558a337
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,37 @@
using System.Linq;
namespace io.github.hatayama.uLoopMCP
{
/// <summary>
/// Log filtering service
/// Single function: Filter and limit log entries
/// Related classes: GetLogsTool, GetLogsUseCase, LogEntry
/// </summary>
public class LogFilteringService
{
/// <summary>
/// Filter log entries and apply limits
/// </summary>
/// <param name="entries">Log entry array</param>
/// <param name="maxCount">Maximum retrieval count</param>
/// <param name="includeStackTrace">Whether to include stack trace</param>
/// <returns>Filtered log entry array</returns>
public LogEntry[] FilterAndLimitLogs(LogEntryDto[] entries, int maxCount, bool includeStackTrace)
{
// Get the most recent logs, limited by maxCount
LogEntryDto[] limitedEntries = entries.Length > maxCount
? entries.Skip(entries.Length - maxCount).ToArray()
: entries;
// Reverse to show newest first
limitedEntries = limitedEntries.Reverse().ToArray();
// Convert from LogEntryDto to LogEntry
return limitedEntries.Select(entry => new LogEntry(
type: entry.LogType,
message: entry.Message,
stackTrace: includeStackTrace ? entry.StackTrace : null
)).ToArray();
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: f5e96519978e44ecfa6ed2daf1f7fa73
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,42 @@
using System;
namespace io.github.hatayama.uLoopMCP
{
/// <summary>
/// Log retrieval service
/// Single function: Retrieve Unity Console logs
/// Related classes: LogGetter, GetLogsTool, GetLogsUseCase
/// </summary>
public class LogRetrievalService
{
/// <summary>
/// Retrieve logs of specified log type
/// </summary>
/// <param name="logType">Log type to retrieve</param>
/// <returns>Log data</returns>
public LogDisplayDto GetLogs(string logType)
{
if (string.Equals(logType, McpLogType.All, StringComparison.OrdinalIgnoreCase))
{
return LogGetter.GetAllConsoleLogs();
}
else
{
return LogGetter.GetConsoleLogsByType(logType);
}
}
/// <summary>
/// Retrieve logs with search conditions
/// </summary>
/// <param name="logType">Log type to retrieve</param>
/// <param name="searchText">Search text</param>
/// <param name="useRegex">Whether to use regular expressions</param>
/// <param name="searchInStackTrace">Whether to search within stack trace</param>
/// <returns>Log data</returns>
public LogDisplayDto GetLogsWithSearch(string logType, string searchText, bool useRegex, bool searchInStackTrace)
{
return LogGetter.SearchConsoleLogs(logType, searchText, useRegex, searchInStackTrace);
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 4efed97f61be0421f99a7da3afe1522a
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,67 @@
namespace io.github.hatayama.uLoopMCP
{
/// <summary>
/// Application service responsible for MCP server configuration management.
/// Handles configuration validation, port resolution, and configuration updating.
///
/// Related classes:
/// - McpServerController: Uses this service for server configuration
/// - McpEditorSettings: Provides configuration storage
/// - McpPortValidator: Validates port configurations
/// </summary>
public class McpServerConfigurationService
{
/// <summary>
/// Validates server configuration settings.
/// </summary>
/// <param name="port">Port number to validate</param>
/// <returns>Validation result with details</returns>
public ServiceResult<bool> ValidateConfiguration(int port)
{
// Validate port range
if (port < 1 || port > 65535)
{
return ServiceResult<bool>.FailureResult($"Port {port} is outside valid range (1-65535)");
}
// Check for reserved ports
if (port < 1024)
{
return ServiceResult<bool>.FailureResult($"Port {port} is reserved and may require administrator privileges");
}
// Additional validation could include:
// - Port availability check
// - Firewall restrictions
// - OS-specific limitations
return ServiceResult<bool>.SuccessResult(true);
}
/// <summary>
/// Resolves the actual port to use for server startup.
/// </summary>
/// <param name="requestedPort">Requested port number (-1 for default)</param>
/// <returns>Resolved port number</returns>
public ServiceResult<int> ResolvePort(int requestedPort)
{
try
{
int actualPort = requestedPort == -1 ? McpEditorSettings.GetCustomPort() : requestedPort;
// Validate the resolved port
var validation = ValidateConfiguration(actualPort);
if (!validation.Success)
{
return ServiceResult<int>.FailureResult($"Resolved port {actualPort} is invalid: {validation.ErrorMessage}");
}
return ServiceResult<int>.SuccessResult(actualPort);
}
catch (System.Exception ex)
{
return ServiceResult<int>.FailureResult($"Failed to resolve port: {ex.Message}");
}
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 6d53dac596ccc4288952e1eae3f53428
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,92 @@
namespace io.github.hatayama.uLoopMCP
{
/// <summary>
/// Application service responsible for MCP server startup operations.
/// Handles server instance creation, startup, and lifecycle management.
///
/// Related classes:
/// - McpBridgeServer: The actual server instance being managed
/// - McpSessionManager: Manages server session state
/// - McpServerController: Coordinates overall server management
/// </summary>
public class McpServerStartupService
{
/// <summary>
/// Creates and starts a new MCP server instance.
/// </summary>
/// <param name="port">Port number to start the server on</param>
/// <returns>The created server instance</returns>
public ServiceResult<McpBridgeServer> StartServer(
int port,
bool clearServerStartingLockWhenReady = true)
{
try
{
McpBridgeServer server = new();
server.StartServer(port, clearServerStartingLockWhenReady);
return ServiceResult<McpBridgeServer>.SuccessResult(server);
}
catch (System.Exception ex)
{
return ServiceResult<McpBridgeServer>.FailureResult($"Failed to start server: {ex.Message}");
}
}
/// <summary>
/// Stops and disposes of the given server instance.
/// </summary>
/// <param name="server">Server instance to stop</param>
/// <returns>Success indicator</returns>
public ServiceResult<bool> StopServer(McpBridgeServer server)
{
try
{
if (server != null)
{
server.Dispose();
}
return ServiceResult<bool>.SuccessResult(true);
}
catch (System.Exception ex)
{
return ServiceResult<bool>.FailureResult($"Failed to stop server: {ex.Message}");
}
}
/// <summary>
/// Updates session manager with server state.
/// On shutdown (isRunning=false), only the running flag is cleared — customPort is preserved
/// so recovery can rebind to the same port after domain reload or editor restart.
/// </summary>
/// <param name="isRunning">Whether the server is running</param>
/// <param name="port">Server port number (only written when isRunning=true)</param>
/// <param name="projectRootPath">Project root for fast project validation during startup</param>
/// <returns>Success indicator</returns>
public ServiceResult<bool> UpdateSessionState(
bool isRunning,
int port = -1,
string projectRootPath = null)
{
if (!isRunning)
{
McpEditorSettings.ClearServerSession();
return ServiceResult<bool>.SuccessResult(true);
}
if (port > 0 && !string.IsNullOrWhiteSpace(projectRootPath))
{
string serverSessionId = System.Guid.NewGuid().ToString("N");
McpEditorSettings.SetRunningServerSession(port, projectRootPath, serverSessionId);
return ServiceResult<bool>.SuccessResult(true);
}
McpEditorSettings.SetIsServerRunning(true);
if (port > 0)
{
McpEditorSettings.SetCustomPort(port);
}
return ServiceResult<bool>.SuccessResult(true);
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 397aac884f23b4c3d92c433bd4f8afb5
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,100 @@
using UnityEditor;
namespace io.github.hatayama.uLoopMCP
{
/// <summary>
/// Play Mode compilation preparation service
/// Single function: Determine preparation action based on Play Mode state and settings
/// Related classes: CompileUseCase, CompilationStateValidationService
/// </summary>
public class PlayModeCompilationPreparationService
{
private const string EDITOR_PREFS_KEY = "ScriptCompilationDuringPlay";
/// <summary>
/// Determine preparation action based on Play Mode state and Unity settings
/// </summary>
/// <returns>Preparation result indicating what action to take before compilation</returns>
public PreparationResult DeterminePreparationAction()
{
bool isPlaying = EditorApplication.isPlaying;
if (!isPlaying)
{
return PreparationResult.Proceed();
}
int settingValue = EditorPrefs.GetInt(EDITOR_PREFS_KEY, 0);
ScriptChangesDuringPlayOptions setting = (ScriptChangesDuringPlayOptions)settingValue;
return setting switch
{
ScriptChangesDuringPlayOptions.RecompileAndContinuePlaying
=> PreparationResult.Proceed(),
ScriptChangesDuringPlayOptions.StopPlayingAndRecompile
=> PreparationResult.NeedsStopPlayMode(),
ScriptChangesDuringPlayOptions.RecompileAfterFinishedPlaying
=> PreparationResult.CannotProceed(
"Cannot compile while in Play Mode. Unity's 'Script Changes While Playing' is set to " +
"'Recompile After Finished Playing'. Stop Play Mode manually or change the setting."),
_ => PreparationResult.Proceed()
};
}
/// <summary>
/// Stop Play Mode to allow compilation
/// </summary>
public void StopPlayMode()
{
if (EditorApplication.isPaused)
{
EditorApplication.isPaused = false;
}
EditorApplication.isPlaying = false;
}
}
/// <summary>
/// Data model representing preparation result for compilation
/// </summary>
public class PreparationResult
{
/// <summary>
/// Whether compilation can proceed
/// </summary>
public bool CanProceed { get; }
/// <summary>
/// Whether Play Mode needs to be stopped before compilation
/// </summary>
public bool NeedsPlayModeStop { get; }
/// <summary>
/// Error message when compilation cannot proceed
/// </summary>
public string ErrorMessage { get; }
private PreparationResult(bool canProceed, bool needsStop, string error)
{
CanProceed = canProceed;
NeedsPlayModeStop = needsStop;
ErrorMessage = error;
}
/// <summary>
/// Create result indicating compilation can proceed immediately
/// </summary>
public static PreparationResult Proceed() => new(true, false, null);
/// <summary>
/// Create result indicating Play Mode must be stopped first
/// </summary>
public static PreparationResult NeedsStopPlayMode() => new(true, true, null);
/// <summary>
/// Create result indicating compilation cannot proceed
/// </summary>
/// <param name="reason">Reason why compilation cannot proceed</param>
public static PreparationResult CannotProceed(string reason) => new(false, false, reason);
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: c713840e82aca460a855e363370ecdb0
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,59 @@
namespace io.github.hatayama.uLoopMCP
{
/// <summary>
/// Application service responsible for port allocation and conflict resolution.
/// Handles port finding, validation, and conflict management.
///
/// Related classes:
/// - NetworkUtility: Provides port availability checking
/// - McpPortValidator: Validates port configurations
/// - McpPortChangeUpdater: Updates configurations on port changes
/// </summary>
public class PortAllocationService
{
/// <summary>
/// Finds an available port starting from the given port number.
/// </summary>
/// <param name="startPort">The starting port number to check</param>
/// <returns>The first available port number</returns>
public ServiceResult<int> FindAvailablePort(int startPort)
{
try
{
int availablePort = NetworkUtility.FindAvailablePort(startPort);
if (availablePort <= 0 || availablePort > 65535)
{
return ServiceResult<int>.FailureResult($"Invalid port number returned: {availablePort}");
}
return ServiceResult<int>.SuccessResult(availablePort);
}
catch (System.Exception ex)
{
return ServiceResult<int>.FailureResult($"Failed to find available port: {ex.Message}");
}
}
/// <summary>
/// Handles port conflict resolution with user confirmation.
/// </summary>
/// <param name="requestedPort">Originally requested port</param>
/// <param name="availablePort">Available alternative port</param>
/// <returns>True if user accepted the alternative port</returns>
public ServiceResult<bool> HandlePortConflict(int requestedPort, int availablePort)
{
bool userConfirmed = UnityEditor.EditorUtility.DisplayDialog(
"uLoopMCP - Port Conflict",
$"Port {requestedPort} is already in use.\n\nWould you like to use port {availablePort} instead?",
"OK",
"Cancel"
);
if (userConfirmed)
{
McpPortChangeUpdater.UpdateAllConfigurationsForPortChange(availablePort, "Server port conflict resolution");
}
return ServiceResult<bool>.SuccessResult(userConfirmed);
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 6a46a2b756eb1449b8abbecea6b4fcb7
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,13 @@
namespace io.github.hatayama.uLoopMCP
{
/// <summary>
/// Unity EditorPrefs "ScriptCompilationDuringPlay" value options
/// Maps to Unity Editor's Preferences > General > Script Changes While Playing setting
/// </summary>
public enum ScriptChangesDuringPlayOptions
{
RecompileAndContinuePlaying = 0,
RecompileAfterFinishedPlaying = 1,
StopPlayingAndRecompile = 2
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 2c82b7d324ea643f49436a0ed6ccfcc1
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,46 @@
using UnityEditor;
namespace io.github.hatayama.uLoopMCP
{
/// <summary>
/// Application service responsible for security validation before server operations.
/// Handles editor state validation and security checks.
///
/// Related classes:
/// - UnityEditor.EditorApplication: Provides editor state information
/// - McpPortValidator: Validates port security
/// </summary>
public class SecurityValidationService
{
/// <summary>
/// Validates that the Unity Editor is in a safe state for server operations.
/// </summary>
/// <returns>Validation result with error details if invalid</returns>
public ValidationResult ValidateEditorState()
{
if (EditorApplication.isCompiling)
{
return new ValidationResult(false,
"Cannot start MCP server while Unity is compiling. Please wait for compilation to complete.");
}
return new ValidationResult(true);
}
/// <summary>
/// Validates port security settings.
/// </summary>
/// <param name="port">Port number to validate</param>
/// <returns>Validation result with security details</returns>
public ValidationResult ValidatePortSecurity(int port)
{
if (!McpPortValidator.ValidatePort(port, "for MCP server"))
{
return new ValidationResult(false,
$"Port number must be between 1 and 65535. Received: {port}");
}
return new ValidationResult(true);
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 40aa5c61b29b440d2af6fc600a6c4304
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,167 @@
using System;
using System.IO;
using System.Threading;
namespace io.github.hatayama.uLoopMCP
{
/// <summary>
/// Application service responsible for server starting lock file management.
/// Single responsibility: Create/delete lock file during server startup for CLI detection.
/// Related classes: CompilationLockService, DomainReloadDetectionService (similar patterns)
/// </summary>
public static class ServerStartingLockService
{
private const string LOCK_FILE_NAME = "serverstarting.lock";
private const int FILE_OPERATION_RETRY_COUNT = 3;
private const int FILE_OPERATION_RETRY_DELAY_MILLISECONDS = 50;
private static string LockFilePath => Path.Combine(UnityEngine.Application.dataPath, "..", "Temp", LOCK_FILE_NAME);
internal static Action<string> OnOwnedLockFileClaimedForDeletionForTests { get; set; }
/// <summary>
/// Create lock file to signal server is starting.
/// </summary>
public static string CreateLockFile()
{
string lockPath = LockFilePath;
string tempDir = Path.GetDirectoryName(lockPath);
if (!Directory.Exists(tempDir))
{
return null;
}
string ownershipToken = System.Guid.NewGuid().ToString("N");
for (int attempt = 0; attempt < FILE_OPERATION_RETRY_COUNT; attempt++)
{
try
{
File.WriteAllText(lockPath, ownershipToken);
return ownershipToken;
}
catch (IOException)
{
}
catch (UnauthorizedAccessException)
{
}
if (attempt < FILE_OPERATION_RETRY_COUNT - 1)
{
Thread.Sleep(FILE_OPERATION_RETRY_DELAY_MILLISECONDS);
}
}
VibeLogger.LogWarning("server_starting_lock_create_failed", $"Failed to create lock file: {lockPath}");
return null;
}
/// <summary>
/// Delete lock file. Called when server startup completes or on crash recovery.
/// </summary>
public static void DeleteLockFile(string ownershipToken = null)
{
string lockPath = LockFilePath;
if (File.Exists(lockPath))
{
if (string.IsNullOrEmpty(ownershipToken))
{
TryDeleteLockFile(lockPath);
return;
}
string claimedLockPath = TryClaimOwnedLockFileForDeletion(lockPath, ownershipToken);
if (string.IsNullOrEmpty(claimedLockPath))
{
return;
}
TryDeleteLockFile(claimedLockPath);
}
}
public static void DeleteOwnedLockFile(string ownershipToken)
{
if (string.IsNullOrEmpty(ownershipToken))
{
return;
}
DeleteLockFile(ownershipToken);
}
private static string TryClaimOwnedLockFileForDeletion(string lockPath, string ownershipToken)
{
for (int attempt = 0; attempt < FILE_OPERATION_RETRY_COUNT; attempt++)
{
try
{
using FileStream stream = new FileStream(lockPath, FileMode.Open, FileAccess.Read, FileShare.Delete);
using StreamReader reader = new StreamReader(stream);
string existingOwnershipToken = reader.ReadToEnd();
if (!string.Equals(existingOwnershipToken, ownershipToken, System.StringComparison.Ordinal))
{
return null;
}
string claimedLockPath = CreateClaimedLockFilePath(lockPath);
File.Move(lockPath, claimedLockPath);
// Why: tests need a deterministic point after the old generation has been
// detached from the canonical lock path but before we remove the claimed file.
// Why not observe this with FileSystemWatcher: the editor test runner missed the
// rename often enough to make the race coverage flaky on Windows.
OnOwnedLockFileClaimedForDeletionForTests?.Invoke(claimedLockPath);
return claimedLockPath;
}
catch (IOException)
{
}
catch (UnauthorizedAccessException)
{
}
if (attempt < FILE_OPERATION_RETRY_COUNT - 1)
{
Thread.Sleep(FILE_OPERATION_RETRY_DELAY_MILLISECONDS);
}
}
VibeLogger.LogWarning("server_starting_lock_claim_failed", $"Failed to claim owned lock file: {lockPath}");
return null;
}
private static string CreateClaimedLockFilePath(string lockPath)
{
return $"{lockPath}.{Guid.NewGuid():N}.owneddelete";
}
private static void TryDeleteLockFile(string lockPath)
{
for (int attempt = 0; attempt < FILE_OPERATION_RETRY_COUNT; attempt++)
{
try
{
File.Delete(lockPath);
if (!File.Exists(lockPath))
{
return;
}
}
catch (IOException)
{
}
catch (UnauthorizedAccessException)
{
}
if (attempt < FILE_OPERATION_RETRY_COUNT - 1)
{
Thread.Sleep(FILE_OPERATION_RETRY_DELAY_MILLISECONDS);
}
}
VibeLogger.LogWarning("server_starting_lock_delete_failed", $"Failed to delete lock file: {lockPath}");
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 608437d7bfb014480a1167c3fa138ff2
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,172 @@
using System.Threading.Tasks;
using System.Threading;
using UnityEditor;
namespace io.github.hatayama.uLoopMCP
{
/// <summary>
/// Application service responsible for session recovery processing
/// Single responsibility: Server session recovery and retry control
/// Related classes: McpSessionManager, McpBridgeServer, NetworkUtility
/// </summary>
public static class SessionRecoveryService
{
private const int MAX_RETRIES = 3;
/// <summary>
/// Restore server state if needed
/// </summary>
/// <returns>Recovery process result</returns>
public static ValidationResult RestoreServerStateIfNeeded()
{
bool wasRunning = McpEditorSettings.GetIsServerRunning();
int savedPort = McpEditorSettings.GetCustomPort();
bool isAfterCompile = McpEditorSettings.GetIsAfterCompile();
// If server is already running
McpBridgeServer currentServer = McpServerController.CurrentServer;
if (currentServer?.IsRunning == true)
{
// Server is running, clean up lock files
CompilationLockService.DeleteLockFile();
DomainReloadDetectionService.DeleteLockFile();
// Why: only the startup generation that created serverstarting.lock knows whether
// the canonical lock still belongs to it or has already been replaced by a newer
// generation. Why not delete it here: a stale domain-reload recovery path can race
// with an active startup/prewarm sequence and tear down another generation's lock.
if (isAfterCompile)
{
McpEditorSettings.ClearAfterCompileFlag();
}
return ValidationResult.Success();
}
// Clear after-compile flag
if (isAfterCompile)
{
McpEditorSettings.ClearAfterCompileFlag();
}
// If server was running and is currently stopped, delegate to centralized controller logic
if (wasRunning && (currentServer == null || !currentServer.IsRunning))
{
_ = McpServerController.StartRecoveryIfNeededAsync(savedPort, isAfterCompile, CancellationToken.None).ContinueWith(task =>
{
if (task.IsFaulted)
{
VibeLogger.LogError("server_startup_restore_failed",
$"Failed to restore server: {task.Exception?.GetBaseException().Message}");
}
}, TaskScheduler.FromCurrentSynchronizationContext());
}
return ValidationResult.Success();
}
/// <summary>
/// Attempt server recovery with retry mechanism
/// </summary>
/// <param name="port">Port number to recover</param>
/// <param name="retryCount">Current retry count</param>
/// <returns>Recovery process result</returns>
public static ValidationResult TryRestoreServerWithRetry(int port, int retryCount)
{
try
{
// Stop existing server instance
McpBridgeServer currentServer = McpServerController.CurrentServer;
if (currentServer != null)
{
currentServer.Dispose();
}
// Find available port
int availablePort = NetworkUtility.FindAvailablePort(port);
// Start new server
var newServer = new McpBridgeServer();
newServer.StartServer(availablePort);
// Update session state
McpEditorSettings.UpdateSettings(s => s with
{
customPort = availablePort,
isReconnecting = false
});
return ValidationResult.Success();
}
catch (System.Exception ex)
{
if (retryCount < MAX_RETRIES)
{
// Execute retry
_ = RetryServerRestoreAsync(port, retryCount).ContinueWith(task =>
{
if (task.IsFaulted)
{
VibeLogger.LogError("server_restore_retry_failed",
$"Failed to retry server restore (attempt {retryCount}): {task.Exception?.GetBaseException().Message}");
}
}, TaskScheduler.FromCurrentSynchronizationContext());
return ValidationResult.Success();
}
else
{
// Clear session when maximum retry count is reached
McpEditorSettings.ClearServerSession();
return ValidationResult.Failure($"Failed to restore server after {MAX_RETRIES} retries: {ex.Message}");
}
}
}
/// <summary>
/// Retry server recovery (asynchronous)
/// </summary>
/// <param name="port">Port number to recover</param>
/// <param name="retryCount">Current retry count</param>
private static async Task RetryServerRestoreAsync(int port, int retryCount)
{
await EditorDelay.DelayFrame(5);
_ = McpServerController.StartRecoveryIfNeededAsync(port, false, CancellationToken.None).ContinueWith(task =>
{
if (task.IsFaulted)
{
VibeLogger.LogError("server_startup_restore_failed",
$"Failed to restore server: {task.Exception?.GetBaseException().Message}");
}
}, TaskScheduler.FromCurrentSynchronizationContext());
}
/// <summary>
/// Clear reconnection flag
/// </summary>
public static void ClearReconnectingFlag()
{
bool wasReconnecting = McpEditorSettings.GetIsReconnecting();
bool wasShowingUI = McpEditorSettings.GetShowReconnectingUI();
if (wasReconnecting || wasShowingUI)
{
McpEditorSettings.ClearReconnectingFlags();
}
}
/// <summary>
/// Start reconnection UI display timeout
/// </summary>
/// <returns>Task for timeout processing</returns>
public static async Task StartReconnectionUITimeoutAsync()
{
int timeoutFrames = McpConstants.RECONNECTION_TIMEOUT_SECONDS * 60;
await EditorDelay.DelayFrame(timeoutFrames);
bool isStillShowingUI = McpEditorSettings.GetShowReconnectingUI();
if (isStillShowingUI)
{
McpEditorSettings.ClearReconnectingFlags();
}
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 29da543035f2246c6aa314cb5b85e5f1
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,44 @@
using System.Threading.Tasks;
namespace io.github.hatayama.uLoopMCP
{
/// <summary>
/// Test execution service
/// Single function: Execute tests using Unity Test Runner
/// Related classes: PlayModeTestExecuter, RunTestsUseCase, RunTestsTool
/// </summary>
public class TestExecutionService
{
#if ULOOPMCP_HAS_TEST_FRAMEWORK
/// <summary>
/// Execute tests in PlayMode
/// </summary>
/// <param name="filter">Test execution filter</param>
/// <returns>Test execution result</returns>
public virtual async Task<SerializableTestResult> ExecutePlayModeTestAsync(TestExecutionFilter filter)
{
return await PlayModeTestExecuter.ExecutePlayModeTest(filter);
}
/// <summary>
/// Execute tests in EditMode
/// </summary>
/// <param name="filter">Test execution filter</param>
/// <returns>Test execution result</returns>
public virtual async Task<SerializableTestResult> ExecuteEditModeTestAsync(TestExecutionFilter filter)
{
return await PlayModeTestExecuter.ExecuteEditModeTest(filter);
}
#else
public virtual Task<SerializableTestResult> ExecutePlayModeTestAsync(TestExecutionFilter filter)
{
return Task.FromResult(SerializableTestResult.CreateTestFrameworkUnavailable());
}
public virtual Task<SerializableTestResult> ExecuteEditModeTestAsync(TestExecutionFilter filter)
{
return Task.FromResult(SerializableTestResult.CreateTestFrameworkUnavailable());
}
#endif
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: c70b2e00167314720ae17ebabbef34d7
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,217 @@
using System.Collections.Generic;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
using UnityEngine.SceneManagement;
namespace io.github.hatayama.uLoopMCP
{
public class TestExecutionStateValidationService
{
private const string UnsavedEditorChangesFailureMessage =
"Tests cannot run while the editor has unsaved scene or prefab changes. Save or discard these changes before running tests.";
private const string UnsavedEditorChangesSaveFailureMessage =
"Tests cannot save unsaved scene or prefab changes before running tests.";
protected virtual bool IsPlaying => EditorApplication.isPlaying;
protected virtual bool IsCompiling => EditorApplication.isCompiling;
protected virtual bool IsDomainReloadInProgress => DomainReloadDetectionService.IsDomainReloadInProgress();
protected virtual bool IsUpdating => EditorApplication.isUpdating;
protected virtual string[] DetectUnsavedEditorChanges()
{
return DetectCurrentUnsavedEditorChanges();
}
protected virtual ValidationResult SaveUnsavedEditorChanges()
{
return SaveCurrentUnsavedEditorChanges();
}
public virtual ValidationResult Validate(RunTestMode testMode, bool saveBeforeRun)
{
if (IsCompiling)
{
return ValidationResult.Failure("Tests cannot run while compilation is in progress");
}
if (IsDomainReloadInProgress)
{
return ValidationResult.Failure("Tests cannot run while domain reload is in progress");
}
if (IsUpdating)
{
return ValidationResult.Failure("Tests cannot run while the editor is updating");
}
if (testMode == RunTestMode.EditMode && IsPlaying)
{
return ValidationResult.Failure("EditMode tests cannot run during play mode");
}
if (saveBeforeRun)
{
ValidationResult saveResult = SaveUnsavedEditorChanges();
if (!saveResult.IsValid)
{
return saveResult;
}
}
string[] unsavedEditorChanges = DetectUnsavedEditorChanges();
Debug.Assert(unsavedEditorChanges != null, "Unsaved editor change detection must return an array");
if (unsavedEditorChanges.Length > 0)
{
return ValidationResult.Failure(CreateUnsavedEditorChangesFailureMessage(unsavedEditorChanges));
}
return ValidationResult.Success();
}
private static string[] DetectCurrentUnsavedEditorChanges()
{
List<string> unsavedEditorChanges = new List<string>();
AddDirtyLoadedScenes(unsavedEditorChanges);
AddDirtyPrefabStage(unsavedEditorChanges);
return unsavedEditorChanges.ToArray();
}
private static ValidationResult SaveCurrentUnsavedEditorChanges()
{
List<string> failedChanges = new List<string>();
SaveDirtyLoadedScenes(failedChanges);
SaveDirtyPrefabStage(failedChanges);
if (failedChanges.Count > 0)
{
return ValidationResult.Failure(CreateUnsavedEditorChangesSaveFailureMessage(failedChanges.ToArray()));
}
return ValidationResult.Success();
}
private static void AddDirtyLoadedScenes(List<string> unsavedEditorChanges)
{
Debug.Assert(unsavedEditorChanges != null, "unsavedEditorChanges must not be null");
for (int i = 0; i < SceneManager.sceneCount; i++)
{
Scene scene = SceneManager.GetSceneAt(i);
if (!scene.IsValid() || !scene.isLoaded || !scene.isDirty)
{
continue;
}
unsavedEditorChanges.Add("Scene: " + GetSceneDisplayPath(scene));
}
}
private static void SaveDirtyLoadedScenes(List<string> failedChanges)
{
Debug.Assert(failedChanges != null, "failedChanges must not be null");
for (int i = 0; i < SceneManager.sceneCount; i++)
{
Scene scene = SceneManager.GetSceneAt(i);
if (!scene.IsValid() || !scene.isLoaded || !scene.isDirty)
{
continue;
}
if (string.IsNullOrEmpty(scene.path) || !EditorSceneManager.SaveScene(scene))
{
failedChanges.Add("Scene: " + GetSceneDisplayPath(scene));
}
}
}
private static void AddDirtyPrefabStage(List<string> unsavedEditorChanges)
{
Debug.Assert(unsavedEditorChanges != null, "unsavedEditorChanges must not be null");
PrefabStage prefabStage = PrefabStageUtility.GetCurrentPrefabStage();
if (prefabStage == null || !prefabStage.scene.IsValid() || !prefabStage.scene.isDirty)
{
return;
}
unsavedEditorChanges.Add("Prefab Stage: " + GetPrefabStageDisplayPath(prefabStage));
}
private static void SaveDirtyPrefabStage(List<string> failedChanges)
{
Debug.Assert(failedChanges != null, "failedChanges must not be null");
PrefabStage prefabStage = PrefabStageUtility.GetCurrentPrefabStage();
if (prefabStage == null || !prefabStage.scene.IsValid() || !prefabStage.scene.isDirty)
{
return;
}
if (!SavePrefabStage(prefabStage))
{
failedChanges.Add("Prefab Stage: " + GetPrefabStageDisplayPath(prefabStage));
}
}
private static bool SavePrefabStage(PrefabStage prefabStage)
{
Debug.Assert(prefabStage != null, "prefabStage must not be null");
if (string.IsNullOrEmpty(prefabStage.assetPath))
{
return false;
}
bool success;
PrefabUtility.SaveAsPrefabAsset(prefabStage.prefabContentsRoot, prefabStage.assetPath, out success);
if (success)
{
prefabStage.ClearDirtiness();
}
return success;
}
private static string GetSceneDisplayPath(Scene scene)
{
if (!string.IsNullOrEmpty(scene.path))
{
return scene.path;
}
if (!string.IsNullOrEmpty(scene.name))
{
return scene.name;
}
return "Untitled scene";
}
private static string GetPrefabStageDisplayPath(PrefabStage prefabStage)
{
Debug.Assert(prefabStage != null, "prefabStage must not be null");
if (!string.IsNullOrEmpty(prefabStage.assetPath))
{
return prefabStage.assetPath;
}
return GetSceneDisplayPath(prefabStage.scene);
}
private static string CreateUnsavedEditorChangesFailureMessage(string[] unsavedEditorChanges)
{
Debug.Assert(unsavedEditorChanges != null, "unsavedEditorChanges must not be null");
Debug.Assert(unsavedEditorChanges.Length > 0, "unsavedEditorChanges must not be empty");
return UnsavedEditorChangesFailureMessage + " Unsaved changes: " + string.Join(", ", unsavedEditorChanges);
}
private static string CreateUnsavedEditorChangesSaveFailureMessage(string[] failedChanges)
{
Debug.Assert(failedChanges != null, "failedChanges must not be null");
Debug.Assert(failedChanges.Length > 0, "failedChanges must not be empty");
return UnsavedEditorChangesSaveFailureMessage + " Unsaved changes that failed to save: " + string.Join(", ", failedChanges);
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 8dfdb70f9061248eeb326b4403df2c8a
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,30 @@
using System;
namespace io.github.hatayama.uLoopMCP
{
/// <summary>
/// Test filter creation service
/// Single function: Create filters for test execution
/// Related classes: RunTestsTool, RunTestsUseCase, TestExecutionFilter
/// </summary>
public class TestFilterCreationService
{
/// <summary>
/// Create test execution filter
/// </summary>
/// <param name="filterType">Filter type</param>
/// <param name="filterValue">Filter value</param>
/// <returns>Test execution filter</returns>
public TestExecutionFilter CreateFilter(TestFilterType filterType, string filterValue)
{
return filterType switch
{
TestFilterType.all => TestExecutionFilter.All(),
TestFilterType.exact => TestExecutionFilter.ByTestName(filterValue),
TestFilterType.regex => TestExecutionFilter.ByClassName(filterValue),
TestFilterType.assembly => TestExecutionFilter.ByAssemblyName(filterValue),
_ => throw new ArgumentException($"Unsupported filter type: {filterType}")
};
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 44dc30cabe96d4382898be0f153bc200
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,43 @@
using System;
using UnityEngine;
namespace io.github.hatayama.uLoopMCP
{
/// <summary>
/// Connected LLM Tool data structure for persistence
/// Related classes:
/// - ConnectedClient: Connected client information
/// - McpEditorWindow: UI tool display control
/// - McpServerController: Server lifecycle management
/// - McpBridgeServer: Actual client connection management
/// </summary>
[Serializable]
public class ConnectedLLMToolData
{
[SerializeField] public string Name;
[SerializeField] public string Endpoint;
[SerializeField] public int Port;
[SerializeField] public string ConnectedAtString;
// Unity doesn't serialize DateTime directly, so we use a string representation
public DateTime ConnectedAt
{
get => string.IsNullOrEmpty(ConnectedAtString) ? DateTime.Now :
DateTime.TryParse(ConnectedAtString, out DateTime result) ? result : DateTime.Now;
set => ConnectedAtString = value.ToString("yyyy-MM-ddTHH:mm:ss.fffzzz");
}
public ConnectedLLMToolData()
{
// Default constructor for Unity serialization
}
public ConnectedLLMToolData(string name, string endpoint, int port, DateTime connectedAt)
{
Name = name;
Endpoint = endpoint;
Port = port;
ConnectedAt = connectedAt;
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: a4320ff5004aa40e9ae756b620cc25a1
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 5839e0dfd0a8a40999c35ae1ad13837b
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: b379fad4ee2e948afb07c137164a1067
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,294 @@
using System;
using UnityEditor;
using UnityEngine;
using System.Text.RegularExpressions;
using System.Collections.Generic;
namespace io.github.hatayama.uLoopMCP
{
/// <summary>
/// A class that provides a general-purpose static API for retrieving console logs.
/// Uses ConsoleLogRetriever to access Unity's console logs directly via reflection.
/// </summary>
[InitializeOnLoad]
public static class LogGetter
{
private static readonly ConsoleLogRetriever LogRetriever;
// Unity classifies csc.rsp compiler diagnostics as LogType.Log internally,
// so without message-based detection they would be invisible to Error/Warning filters.
// Note: Debug.Log messages matching ": error CSxxxx" / ": warning CSxxxx" will also
// be treated as compiler diagnostics. This is by design.
private static readonly Regex CompilerErrorPattern = new Regex(@":\s*error CS\d+\b", RegexOptions.Compiled);
private static readonly Regex CompilerWarningPattern = new Regex(@":\s*warning CS\d+\b", RegexOptions.Compiled);
static LogGetter()
{
LogRetriever = new ConsoleLogRetriever();
}
private static string NormalizeMcpLogType(string mcpLogType)
{
if (string.Equals(mcpLogType, McpLogType.Error, StringComparison.OrdinalIgnoreCase))
{
return McpLogType.Error;
}
if (string.Equals(mcpLogType, McpLogType.Warning, StringComparison.OrdinalIgnoreCase))
{
return McpLogType.Warning;
}
if (string.Equals(mcpLogType, McpLogType.Log, StringComparison.OrdinalIgnoreCase))
{
return McpLogType.Log;
}
if (string.Equals(mcpLogType, McpLogType.All, StringComparison.OrdinalIgnoreCase))
{
return McpLogType.All;
}
return McpLogType.Log;
}
private static bool IsAssertionLikeEntry(LogEntryDto entry)
{
if (entry == null)
{
return false;
}
if (string.IsNullOrEmpty(entry.StackTrace))
{
return false;
}
if (entry.StackTrace.IndexOf("Debug:LogAssertion", StringComparison.Ordinal) >= 0)
{
return true;
}
if (entry.StackTrace.IndexOf("Debug.LogAssertion", StringComparison.Ordinal) >= 0)
{
return true;
}
if (entry.StackTrace.IndexOf("Debug:Assert", StringComparison.Ordinal) >= 0)
{
return true;
}
return entry.StackTrace.IndexOf("Debug.Assert", StringComparison.Ordinal) >= 0;
}
private static bool IsCompilerDiagnosticMessage(LogEntryDto entry, Regex pattern)
{
if (entry == null)
{
return false;
}
if (!string.Equals(entry.LogType, McpLogType.Log, StringComparison.OrdinalIgnoreCase))
{
return false;
}
return pattern.IsMatch(entry.Message);
}
private static bool IsErrorFamilyEntry(LogEntryDto entry)
{
if (entry == null)
{
return false;
}
if (string.Equals(entry.LogType, McpLogType.Error, StringComparison.OrdinalIgnoreCase))
{
return true;
}
if (IsAssertionLikeEntry(entry))
{
return true;
}
return IsCompilerDiagnosticMessage(entry, CompilerErrorPattern);
}
private static bool IsWarningFamilyEntry(LogEntryDto entry)
{
if (entry == null)
{
return false;
}
if (string.Equals(entry.LogType, McpLogType.Warning, StringComparison.OrdinalIgnoreCase))
{
return true;
}
return IsCompilerDiagnosticMessage(entry, CompilerWarningPattern);
}
private static List<LogEntryDto> GetFamilyEntries(
List<LogEntryDto> allEntries,
Func<LogEntryDto, bool> familyPredicate,
string targetLogType)
{
List<LogEntryDto> familyEntries = new(allEntries.Count);
foreach (LogEntryDto entry in allEntries)
{
if (!familyPredicate(entry))
{
continue;
}
if (string.Equals(entry.LogType, targetLogType, StringComparison.OrdinalIgnoreCase))
{
familyEntries.Add(entry);
continue;
}
LogEntryDto normalizedEntry = new(targetLogType, entry.Message, entry.StackTrace);
familyEntries.Add(normalizedEntry);
}
return familyEntries;
}
private static List<LogEntryDto> GetLogsByMcpLogType(string logType)
{
string normalizedLogType = NormalizeMcpLogType(logType);
if (string.Equals(normalizedLogType, McpLogType.All, StringComparison.Ordinal))
{
return LogRetriever.GetAllLogs();
}
if (string.Equals(normalizedLogType, McpLogType.Error, StringComparison.Ordinal))
{
List<LogEntryDto> allEntries = LogRetriever.GetAllLogs();
return GetFamilyEntries(allEntries, IsErrorFamilyEntry, McpLogType.Error);
}
if (string.Equals(normalizedLogType, McpLogType.Warning, StringComparison.Ordinal))
{
List<LogEntryDto> allEntries = LogRetriever.GetAllLogs();
return GetFamilyEntries(allEntries, IsWarningFamilyEntry, McpLogType.Warning);
}
UnityEngine.LogType unityLogType = ConvertMcpLogTypeToLogType(normalizedLogType);
return LogRetriever.GetLogsByType(unityLogType);
}
/// <summary>
/// Converts McpLogType to Unity's LogType
/// </summary>
/// <param name="mcpLogType">MCP log type</param>
/// <returns>Corresponding Unity LogType</returns>
private static LogType ConvertMcpLogTypeToLogType(string mcpLogType)
{
string normalizedLogType = NormalizeMcpLogType(mcpLogType);
return normalizedLogType switch
{
McpLogType.Error => LogType.Error,
McpLogType.Warning => LogType.Warning,
McpLogType.Log => LogType.Log,
_ => LogType.Log // Default for unknown types, None will be handled separately
};
}
/// <summary>
/// Retrieves all console logs and returns them as a LogDisplayDto.
/// </summary>
/// <returns>The retrieved log data.</returns>
public static LogDisplayDto GetAllConsoleLogs()
{
System.Collections.Generic.List<LogEntryDto> logEntries = LogRetriever.GetAllLogs();
return new LogDisplayDto(logEntries.ToArray(), logEntries.Count);
}
/// <summary>
/// Directly retrieves an array of console log entries.
/// </summary>
/// <returns>An array of log entries.</returns>
public static LogEntryDto[] GetConsoleLogEntries()
{
return LogRetriever.GetAllLogs().ToArray();
}
/// <summary>
/// Filters and retrieves console logs by log type.
/// </summary>
/// <param name="logType">The log type to filter by (if "All", all types are retrieved).</param>
/// <returns>The filtered log data.</returns>
public static LogDisplayDto GetConsoleLogsByType(string logType)
{
List<LogEntryDto> allEntries = GetLogsByMcpLogType(logType);
return new LogDisplayDto(allEntries.ToArray(), allEntries.Count);
}
/// <summary>
/// Filters and retrieves console logs by log type and searches within message content.
/// </summary>
/// <param name="logType">The log type to filter by (if "All", all types are included).</param>
/// <param name="searchText">The text to search for within messages (if null or empty, no search is performed).</param>
/// <param name="useRegex">Whether to use regular expression for search.</param>
/// <param name="searchInStackTrace">Whether to search within stack trace as well.</param>
/// <returns>The filtered log data.</returns>
public static LogDisplayDto SearchConsoleLogs(string logType, string searchText, bool useRegex, bool searchInStackTrace)
{
// Get logs based on type
List<LogEntryDto> allEntries = GetLogsByMcpLogType(logType);
// Filter by search text if provided
if (!string.IsNullOrEmpty(searchText))
{
if (useRegex)
{
Regex regex = new Regex(searchText);
allEntries = allEntries.FindAll(entry =>
{
bool messageMatch = regex.IsMatch(entry.Message);
bool stackTraceMatch = searchInStackTrace && !string.IsNullOrEmpty(entry.StackTrace) && regex.IsMatch(entry.StackTrace);
return messageMatch || stackTraceMatch;
});
}
else
{
allEntries = allEntries.FindAll(entry =>
{
bool messageMatch = entry.Message.IndexOf(searchText, System.StringComparison.OrdinalIgnoreCase) >= 0;
bool stackTraceMatch = searchInStackTrace && !string.IsNullOrEmpty(entry.StackTrace) &&
entry.StackTrace.IndexOf(searchText, System.StringComparison.OrdinalIgnoreCase) >= 0;
return messageMatch || stackTraceMatch;
});
}
}
return new LogDisplayDto(allEntries.ToArray(), allEntries.Count);
}
/// <summary>
/// Gets the total number of console logs.
/// </summary>
/// <returns>The total number of logs.</returns>
public static int GetConsoleLogCount()
{
return LogRetriever.GetLogCount();
}
/// <summary>
/// Clears the logs of the custom log manager.
/// </summary>
public static void ClearCustomLogs()
{
// This method is no longer needed since we're using ConsoleLogRetriever
// Console logs are managed by Unity itself
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: ee011c0c8ecfb45ad97c3fcb83616734
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,31 @@
using System;
namespace io.github.hatayama.uLoopMCP
{
[Serializable]
public record LogEntryDto
{
public readonly string Message;
public readonly string LogType;
public readonly string StackTrace;
public LogEntryDto(string logType, string message, string stackTrace)
{
Message = message;
LogType = logType;
StackTrace = stackTrace;
}
}
public record LogDisplayDto
{
public readonly LogEntryDto[] LogEntries;
public readonly int TotalCount;
public LogDisplayDto(LogEntryDto[] logEntries, int totalCount)
{
LogEntries = logEntries;
TotalCount = totalCount;
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 35b4f6a13137f4470be3a63dc705bf1f
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 2f6dcaf4dfa124399873d244ef893c1c
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,492 @@
using System;
using System.Collections.Generic;
using System.Reflection;
using System.Text;
using UnityEditor;
using UnityEngine;
namespace io.github.hatayama.uLoopMCP
{
/// <summary>
/// Implementation of console log retrieval using reflection to access Unity's internal APIs
/// </summary>
public class ConsoleLogRetriever
{
private const string GetFilteringTextMethodName = "GetFilteringText";
private const string SetFilteringTextMethodName = "SetFilteringText";
private readonly Type _logEntriesType;
private readonly MethodInfo _getFilteringTextMethod;
private readonly MethodInfo _setFilteringTextMethod;
/// <summary>
/// Initializes the retriever with necessary reflection types
/// </summary>
public ConsoleLogRetriever()
{
Assembly editorAssembly = Assembly.GetAssembly(typeof(EditorWindow));
_logEntriesType = editorAssembly.GetType("UnityEditor.LogEntries");
editorAssembly.GetType("UnityEditor.ConsoleWindow");
if (_logEntriesType == null)
{
throw new InvalidOperationException("LogEntries type not found. Unity version compatibility issue.");
}
_getFilteringTextMethod = _logEntriesType.GetMethod(GetFilteringTextMethodName, BindingFlags.Public | BindingFlags.Static);
_setFilteringTextMethod = _logEntriesType.GetMethod(SetFilteringTextMethodName, BindingFlags.Public | BindingFlags.Static);
}
/// <summary>
/// Gets all console logs regardless of current mask settings
/// </summary>
public List<LogEntryDto> GetAllLogs()
{
// Save original mask to restore later
int originalMask = GetCurrentMask();
using (ConsoleFilteringTextScope filteringTextScope = CreateFilteringTextScope())
{
try
{
// Temporarily set mask to show all log types (0x387 = Error + Warning + Log + Base)
SetMask(7); // This will convert to Unity's 0x387
List<LogEntryDto> logs = new();
int logCount = GetLogCount();
for (int i = 0; i < logCount; i++)
{
LogEntryDto entry = GetLogEntryAt(i);
if (entry != null)
{
logs.Add(entry);
}
else
{
Debug.LogWarning($"GetAllLogs: Failed to get log at index {i}");
}
}
return logs;
}
finally
{
// Always restore original mask, even if an exception occurred
RestoreOriginalMask(originalMask);
}
}
}
/// <summary>
/// Restores the original mask by converting Unity mask back to simple mask format
/// </summary>
private void RestoreOriginalMask(int originalUnityMask)
{
// Use consoleFlags property to restore the exact Unity mask
PropertyInfo consoleFlagsProperty = _logEntriesType.GetProperty("consoleFlags", BindingFlags.Public | BindingFlags.Static);
if (consoleFlagsProperty != null)
{
consoleFlagsProperty.SetValue(null, originalUnityMask);
}
}
/// <summary>
/// Converts Unity LogType to McpLogType
/// </summary>
private string ConvertLogTypeToMcpLogType(LogType logType)
{
return logType switch
{
LogType.Error => McpLogType.Error,
LogType.Assert => McpLogType.Error,
LogType.Exception => McpLogType.Error,
LogType.Warning => McpLogType.Warning,
LogType.Log => McpLogType.Log,
_ => McpLogType.Log // Default unknown types to Log
};
}
/// <summary>
/// Gets logs of a specific type, temporarily adjusting mask if necessary
/// </summary>
public List<LogEntryDto> GetLogsByType(LogType logType)
{
// Save original mask to restore later
int originalMask = GetCurrentMask();
using (ConsoleFilteringTextScope filteringTextScope = CreateFilteringTextScope())
{
try
{
// Temporarily enable the specific log type we want
int targetMask = GetMaskForLogType(logType);
if (targetMask > 0)
{
SetMask(targetMask);
}
else
{
// If unknown type, show all types
SetMask(7);
}
List<LogEntryDto> logs = new();
int logCount = GetLogCount();
string targetMcpLogType = ConvertLogTypeToMcpLogType(logType);
for (int i = 0; i < logCount; i++)
{
LogEntryDto entry = GetLogEntryAt(i);
if (entry != null && entry.LogType == targetMcpLogType)
{
logs.Add(entry);
}
}
return logs;
}
finally
{
// Always restore original mask
RestoreOriginalMask(originalMask);
}
}
}
/// <summary>
/// Creates a scope that temporarily disables Console text filtering during log retrieval.
/// </summary>
private ConsoleFilteringTextScope CreateFilteringTextScope()
{
if (_getFilteringTextMethod == null || _setFilteringTextMethod == null)
{
return null;
}
return new ConsoleFilteringTextScope(GetFilteringText, SetFilteringText);
}
/// <summary>
/// Gets the current Unity Console text filter through Unity's internal API.
/// </summary>
private string GetFilteringText()
{
Debug.Assert(_getFilteringTextMethod != null, "Filtering text getter must be available before use.");
object result = _getFilteringTextMethod.Invoke(null, null);
return result?.ToString() ?? string.Empty;
}
/// <summary>
/// Sets the current Unity Console text filter through Unity's internal API.
/// </summary>
private void SetFilteringText(string filteringText)
{
Debug.Assert(_setFilteringTextMethod != null, "Filtering text setter must be available before use.");
Debug.Assert(filteringText != null, "Filtering text must not be null.");
_setFilteringTextMethod.Invoke(null, new object[] { filteringText });
}
/// <summary>
/// Gets the current console mask settings using reflection
/// </summary>
public int GetCurrentMask()
{
// Use the consoleFlags property discovered in the investigation
PropertyInfo consoleFlagsProperty = _logEntriesType.GetProperty("consoleFlags", BindingFlags.Public | BindingFlags.Static);
if (consoleFlagsProperty != null)
{
object result = consoleFlagsProperty.GetValue(null);
return result != null ? (int)result : 0;
}
// Fallback: try to get from ConsoleWindow if available
return GetMaskFromConsoleWindow();
}
/// <summary>
/// Sets the console mask settings using reflection
/// </summary>
public void SetMask(int mask)
{
// Based on observed Unity Console mask patterns:
// Base: 0x7, Log: +0x80, Warning: +0x100, Error: +0x200
int unityMask = ConvertToUnityMask(mask);
// Use the consoleFlags property
PropertyInfo consoleFlagsProperty = _logEntriesType.GetProperty("consoleFlags", BindingFlags.Public | BindingFlags.Static);
if (consoleFlagsProperty != null)
{
consoleFlagsProperty.SetValue(null, unityMask);
return;
}
Debug.LogWarning("Could not find consoleFlags property");
}
/// <summary>
/// Converts simple mask (1=Error, 2=Warning, 4=Log) to Unity's internal mask format
/// Preserves all existing console settings (Clear options, Collapse, Error Pause, etc.)
/// </summary>
private int ConvertToUnityMask(int simpleMask)
{
// Get current mask to preserve existing console settings
int currentMask = GetCurrentMask();
// Preserve all console settings except log type flags
// Clear log type bits (0x80=Log, 0x100=Warning, 0x200=Error) but keep everything else
int preservedSettings = currentMask & ~(0x80 | 0x100 | 0x200);
// Start with preserved settings (includes Clear options, Collapse, Error Pause, etc.)
int unityMask = preservedSettings;
if ((simpleMask & 1) != 0) // Error
unityMask |= 0x200;
if ((simpleMask & 2) != 0) // Warning
unityMask |= 0x100;
if ((simpleMask & 4) != 0) // Log
unityMask |= 0x80;
return unityMask;
}
/// <summary>
/// Gets the total count of console entries using reflection
/// </summary>
public int GetLogCount()
{
MethodInfo getCount = _logEntriesType.GetMethod("GetCount", BindingFlags.Public | BindingFlags.Static);
if (getCount != null)
{
object result = getCount.Invoke(null, null);
return result != null ? (int)result : 0;
}
return 0;
}
/// <summary>
/// Gets a log entry at the specified index using reflection
/// </summary>
private LogEntryDto GetLogEntryAt(int index)
{
// Get LogEntry type from Unity's internal assembly
Assembly editorAssembly = Assembly.GetAssembly(typeof(EditorWindow));
Type logEntryType = editorAssembly.GetType("UnityEditor.LogEntry");
if (logEntryType == null)
{
Debug.LogError("LogEntry type not found");
return null;
}
// Create LogEntry instance
object logEntryInstance = Activator.CreateInstance(logEntryType);
// Use GetEntryInternal method discovered in investigation
MethodInfo getEntryInternal = _logEntriesType.GetMethod("GetEntryInternal", BindingFlags.Public | BindingFlags.Static);
if (getEntryInternal == null)
{
Debug.LogError("GetEntryInternal method not found");
return null;
}
// Call GetEntryInternal(int row, LogEntry outputEntry)
object[] parameters = new object[] { index, logEntryInstance };
bool success = (bool)getEntryInternal.Invoke(null, parameters);
if (!success)
{
Debug.LogWarning($"GetEntryInternal failed for index {index}");
return null;
}
// Extract data from LogEntry instance using reflection
string fullMessage = GetFieldValue(logEntryInstance, "message")?.ToString() ?? "";
int mode = (int)(GetFieldValue(logEntryInstance, "mode") ?? 0);
int callstackTextStart = (int)(GetFieldValue(logEntryInstance, "callstackTextStartUTF8") ?? 0);
LogType logType = GetLogTypeFromMode(mode);
// Separate message and stack trace using Unity's internal boundary
(string message, string stackTrace) = SeparateMessageAndStackTrace(fullMessage, callstackTextStart);
string mcpLogType = ConvertLogTypeToMcpLogType(logType);
return new LogEntryDto(mcpLogType, message, stackTrace);
}
/// <summary>
/// Helper method to get field value from object using reflection
/// </summary>
private object GetFieldValue(object obj, string fieldName)
{
Type type = obj.GetType();
FieldInfo field = type.GetField(fieldName, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
return field?.GetValue(obj);
}
/// <summary>
/// Converts a UTF-8 byte position to a character position in a string.
/// Unity's LogEntry.callstackTextStartUTF8 provides byte offset, but
/// string.Substring() requires character offset.
/// </summary>
private int ConvertUtf8BytePositionToCharPosition(string text, int utf8BytePosition)
{
if (string.IsNullOrEmpty(text) || utf8BytePosition <= 0)
{
return 0;
}
byte[] utf8Bytes = Encoding.UTF8.GetBytes(text);
if (utf8BytePosition >= utf8Bytes.Length)
{
return text.Length;
}
string decodedPortion = Encoding.UTF8.GetString(utf8Bytes, 0, utf8BytePosition);
return decodedPortion.Length;
}
/// <summary>
/// Separates message and stack trace using Unity's internal callstack boundary
/// </summary>
private (string message, string stackTrace) SeparateMessageAndStackTrace(string fullMessage, int callstackTextStartUtf8)
{
if (string.IsNullOrEmpty(fullMessage)) return ("", "");
int charPosition = ConvertUtf8BytePositionToCharPosition(fullMessage, callstackTextStartUtf8);
if (charPosition <= 0 || charPosition >= fullMessage.Length)
{
return (fullMessage.Trim(), "");
}
string message = fullMessage.Substring(0, charPosition).Trim();
string stackTrace = fullMessage.Substring(charPosition).Trim();
return (message, stackTrace);
}
/// <summary>
/// Converts Unity's internal log mode to a LogType enum
/// </summary>
private LogType GetLogTypeFromMode(int mode)
{
// Analyze the observed patterns:
// 0x804400 = Log
// 0x804200 = Warning
// 0x804100 = Error
// Extract bits 8-11 (shift right 8, mask 4 bits)
int logType = (mode >> 8) & 0xF;
LogType result = logType switch
{
0x01 => LogType.Error, // 0x804100 -> (0x4100 >> 8) & 0xF = 0x41 & 0xF = 0x1
0x02 => LogType.Warning, // 0x804200 -> (0x4200 >> 8) & 0xF = 0x42 & 0xF = 0x2
0x04 => LogType.Log, // 0x804400 -> (0x4400 >> 8) & 0xF = 0x44 & 0xF = 0x4
_ => DetermineLogTypeFromModeAnalysis(mode)
};
return result;
}
/// <summary>
/// Analyzes mode values to determine log type when standard mapping fails
/// </summary>
private LogType DetermineLogTypeFromModeAnalysis(int mode)
{
// Analyze the mode values we've seen:
// 8406016 (0x802000) - appears to be Log type
// 8405504 (0x801E00) - appears to be Warning type
// 8405248 (0x801D00) - appears to be Error type
// Let's try different bit extraction strategies
// int lowerBits = mode & 0x7; // Lower 3 bits
// int midBits = (mode >> 8) & 0x7; // Bits 8-10
// int higherBits = (mode >> 16) & 0x7; // Bits 16-18
// Based on observed patterns, try to determine type
if (mode == 8406016) return LogType.Log; // This is a test log message
if (mode == 8405504) return LogType.Warning; // This is a test warning message
if (mode == 8405248) return LogType.Error; // This is a test error message
return LogType.Log; // Default to Log for unknown types
}
/// <summary>
/// Gets the appropriate mask value for a specific log type
/// </summary>
private int GetMaskForLogType(LogType logType)
{
return logType switch
{
LogType.Error => 1,
LogType.Warning => 2,
LogType.Log => 4,
_ => 0
};
}
/// <summary>
/// Fallback method to get mask from ConsoleWindow
/// </summary>
private int GetMaskFromConsoleWindow()
{
// Implementation would access ConsoleWindow's filter state
return 7; // Default to show all
}
}
/// <summary>
/// Temporarily clears Unity Console text filtering and restores the original filter on dispose.
/// </summary>
internal sealed class ConsoleFilteringTextScope : IDisposable
{
private readonly Action<string> _setFilteringText;
private readonly string _originalFilteringText;
private readonly bool _shouldRestoreFilteringText;
private bool _disposed;
public ConsoleFilteringTextScope(Func<string> getFilteringText, Action<string> setFilteringText)
{
Debug.Assert(getFilteringText != null, "Filtering text getter must not be null.");
Debug.Assert(setFilteringText != null, "Filtering text setter must not be null.");
_setFilteringText = setFilteringText;
_originalFilteringText = getFilteringText() ?? string.Empty;
_shouldRestoreFilteringText = !string.IsNullOrEmpty(_originalFilteringText);
if (!_shouldRestoreFilteringText)
{
return;
}
// Unity applies this text filter to LogEntries.GetCount/GetEntryInternal, so clear it for raw retrieval.
_setFilteringText(string.Empty);
}
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
if (!_shouldRestoreFilteringText)
{
return;
}
_setFilteringText(_originalFilteringText);
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 935eee5e6013c4b46a7a71cd09908e4b
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,66 @@
using UnityEngine;
#if UNITY_6000_0_OR_NEWER
using UnityEditor;
#endif
namespace io.github.hatayama.uLoopMCP
{
/// <summary>
/// Universal Console Utility wrapper
/// Uses Unity 6+ standard API when available, fallback to custom implementation for older versions
/// </summary>
public static class ConsoleUtility
{
/// <summary>
/// Gets console log counts by type (Universal API)
/// </summary>
/// <param name="errorCount">Number of error logs</param>
/// <param name="warningCount">Number of warning logs</param>
/// <param name="logCount">Number of info logs</param>
public static void GetConsoleLogCounts(out int errorCount, out int warningCount, out int logCount)
{
#if UNITY_6000_0_OR_NEWER
ConsoleWindowUtility.GetConsoleLogCounts(out errorCount, out warningCount, out logCount);
#else
GenericConsoleWindowUtility.GetConsoleLogCounts(out errorCount, out warningCount, out logCount);
#endif
}
/// <summary>
/// Clears the Unity Editor Console (Universal API)
/// Unity does not provide a public API for clearing the Editor Console Window,
/// so we use reflection-based implementation for all Unity versions
/// Note: Debug.ClearDeveloperConsole() is for runtime developer console, not Editor Console
/// </summary>
public static void ClearConsole()
{
GenericConsoleWindowUtility.ClearConsole();
}
/// <summary>
/// Gets Unity version info for debugging
/// </summary>
/// <returns>Unity version and API info</returns>
public static string GetVersionInfo()
{
#if UNITY_6000_0_OR_NEWER
return $"Unity {Application.unityVersion} - Using Unity 6+ Standard ConsoleWindowUtility API";
#else
return $"Unity {Application.unityVersion} - Using Custom LogByReflection ConsoleWindowUtility API";
#endif
}
/// <summary>
/// Checks if Unity 6+ standard API is being used
/// </summary>
/// <returns>True if using Unity 6+ standard API</returns>
public static bool IsUsingStandardAPI()
{
#if UNITY_6000_0_OR_NEWER
return true;
#else
return false;
#endif
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 184730250e2b448bfb9ad96a5e88515f
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,144 @@
using System;
using System.Collections.Generic;
using System.Reflection;
using UnityEditor;
using UnityEngine;
namespace io.github.hatayama.uLoopMCP
{
/// <summary>
/// Unity 6 ConsoleWindowUtility API recreation for older Unity versions
/// Provides event-driven console log monitoring and simple count retrieval
/// </summary>
public static class GenericConsoleWindowUtility
{
private static ConsoleLogRetriever logRetriever;
static GenericConsoleWindowUtility()
{
Initialize();
}
/// <summary>
/// Initialize the utility and start monitoring
/// </summary>
private static void Initialize()
{
logRetriever = new ConsoleLogRetriever();
}
/// <summary>
/// Gets console log counts by type (Unity 6 compatible)
/// </summary>
/// <param name="errorCount">Number of error logs</param>
/// <param name="warningCount">Number of warning logs</param>
/// <param name="logCount">Number of info logs</param>
public static void GetConsoleLogCounts(out int errorCount, out int warningCount, out int logCount)
{
errorCount = 0;
warningCount = 0;
logCount = 0;
if (logRetriever == null) return;
try
{
// Save current mask state
int originalMask = logRetriever.GetCurrentMask();
try
{
// Temporarily set mask to show all log types to get accurate counts
logRetriever.SetMask(7); // Show all: Error(1) + Warning(2) + Log(4) = 7
// Use Unity's internal GetCount method which respects Console clear state
int totalCount = logRetriever.GetLogCount();
if (totalCount == 0)
{
// Console is empty, all counts are 0
return;
}
// Get all logs and count by type
List<LogEntryDto> allLogs = logRetriever.GetAllLogs();
foreach (LogEntryDto log in allLogs)
{
switch (log.LogType)
{
case McpLogType.Error:
errorCount++;
break;
case McpLogType.Warning:
warningCount++;
break;
case McpLogType.Log:
logCount++;
break;
}
}
}
finally
{
// Always restore original mask
logRetriever.SetMask(GetSimpleMaskFromUnityMask(originalMask));
}
}
catch (Exception ex)
{
Debug.LogError($"Console log count retrieval failed: {ex.Message}");
// Don't suppress this exception - caller needs to know count retrieval failed
throw new InvalidOperationException(
"Failed to retrieve console log counts. Console monitoring may be compromised.", ex);
}
}
/// <summary>
/// Clears the Unity Console (Unity 6 compatible)
/// </summary>
public static void ClearConsole()
{
try
{
Type _logEntriesType = Assembly.GetAssembly(typeof(EditorWindow))
.GetType("UnityEditor.LogEntries");
MethodInfo clearMethod = _logEntriesType.GetMethod("Clear",
BindingFlags.Public | BindingFlags.Static);
if (clearMethod != null)
{
clearMethod.Invoke(null, null);
}
}
catch (Exception ex)
{
Debug.LogError($"Console clear operation failed: {ex.Message}");
// Don't suppress this exception - caller needs to know clear failed
throw new InvalidOperationException("Failed to clear Unity console. Console operations may be compromised.", ex);
}
}
/// <summary>
/// Converts Unity internal mask back to simple mask format
/// </summary>
/// <param name="unityMask">Unity's internal mask value</param>
/// <returns>Simple mask (1=Error, 2=Warning, 4=Log)</returns>
private static int GetSimpleMaskFromUnityMask(int unityMask)
{
int simpleMask = 0;
if ((unityMask & 0x200) != 0) // Error bit
simpleMask |= 1;
if ((unityMask & 0x100) != 0) // Warning bit
simpleMask |= 2;
if ((unityMask & 0x80) != 0) // Log bit
simpleMask |= 4;
return simpleMask;
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 66a31c5e325b544b99c3bffebe644d20
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 4c10a25db7b2d4dfb89453370d51afe2
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,210 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Reflection;
using UnityEngine;
using UnityEditor;
namespace io.github.hatayama.uLoopMCP
{
/// <summary>
/// Serializes component properties to ComponentPropertyInfo format
/// Related classes:
/// - ComponentSerializer: Uses this to serialize properties
/// </summary>
public class ComponentPropertySerializer
{
public ComponentPropertyInfo[] SerializeProperties(Component component)
{
if (component == null)
return new ComponentPropertyInfo[0];
List<ComponentPropertyInfo> propertyInfos = new List<ComponentPropertyInfo>();
// Use SerializedObject to get only Inspector-visible properties
SerializedObject serializedObject = new SerializedObject(component);
SerializedProperty iterator = serializedObject.GetIterator();
if (iterator.NextVisible(true))
{
do
{
// m_Script is an internal reference, not a user-facing property
if (iterator.name == "m_Script")
continue;
object value = GetSerializedPropertyValue(iterator);
if (value != null)
{
ComponentPropertyInfo info = new ComponentPropertyInfo
{
name = iterator.displayName,
type = iterator.propertyType.ToString(),
value = SerializeValue(value)
};
propertyInfos.Add(info);
}
} while (iterator.NextVisible(false));
}
return propertyInfos.ToArray();
}
/// <summary>
/// Extract value from SerializedProperty based on its type
/// </summary>
private object GetSerializedPropertyValue(SerializedProperty property)
{
switch (property.propertyType)
{
case SerializedPropertyType.Integer:
return property.intValue;
case SerializedPropertyType.Boolean:
return property.boolValue;
case SerializedPropertyType.Float:
return property.floatValue;
case SerializedPropertyType.String:
return property.stringValue;
case SerializedPropertyType.Color:
return property.colorValue;
case SerializedPropertyType.Vector2:
return property.vector2Value;
case SerializedPropertyType.Vector3:
return property.vector3Value;
case SerializedPropertyType.Vector4:
return property.vector4Value;
case SerializedPropertyType.Rect:
return property.rectValue;
case SerializedPropertyType.Bounds:
return property.boundsValue;
case SerializedPropertyType.Quaternion:
return property.quaternionValue;
case SerializedPropertyType.Enum:
return property.enumNames[property.enumValueIndex];
case SerializedPropertyType.LayerMask:
return property.intValue;
case SerializedPropertyType.ObjectReference:
return GetObjectReferenceValue(property);
default:
return null; // Unsupported property types
}
}
private object GetObjectReferenceValue(SerializedProperty property)
{
UnityEngine.Debug.Assert(property != null, "SerializedProperty must exist before reading object references.");
UnityEngine.Debug.Assert(property.propertyType == SerializedPropertyType.ObjectReference, "Object reference serialization requires an ObjectReference property.");
UnityEngine.Object obj = property.objectReferenceValue;
if (obj == null)
{
if (HasStoredObjectReferenceId(property))
{
return new { name = "Missing", type = "Missing", entityId = GetStoredObjectReferenceId(property) };
}
return new { name = "None", type = "None", entityId = "0" };
}
return new { name = obj.name, type = obj.GetType().Name, entityId = GetObjectId(obj) };
}
private static bool HasStoredObjectReferenceId(SerializedProperty property)
{
#if UNITY_6000_4_OR_NEWER
return property.objectReferenceEntityIdValue != UnityEngine.EntityId.None;
#else
return property.objectReferenceInstanceIDValue != 0;
#endif
}
private static string GetStoredObjectReferenceId(SerializedProperty property)
{
#if UNITY_6000_4_OR_NEWER
ulong entityId = UnityEngine.EntityId.ToULong(property.objectReferenceEntityIdValue);
return entityId.ToString(CultureInfo.InvariantCulture);
#else
int instanceId = property.objectReferenceInstanceIDValue;
return instanceId.ToString(CultureInfo.InvariantCulture);
#endif
}
private static string GetObjectId(UnityEngine.Object obj)
{
UnityEngine.Debug.Assert(obj != null, "Unity Object must exist before reading its identifier.");
#if UNITY_6000_4_OR_NEWER
ulong entityId = UnityEngine.EntityId.ToULong(obj.GetEntityId());
return entityId.ToString(CultureInfo.InvariantCulture);
#else
int instanceId = obj.GetInstanceID();
return instanceId.ToString(CultureInfo.InvariantCulture);
#endif
}
private object SerializeValue(object value)
{
if (value == null)
return null;
Type valueType = value.GetType();
// Unity types need special serialization
if (valueType == typeof(Vector2))
{
Vector2 v = (Vector2)value;
return new { x = v.x, y = v.y };
}
else if (valueType == typeof(Vector3))
{
Vector3 v = (Vector3)value;
return new { x = v.x, y = v.y, z = v.z };
}
else if (valueType == typeof(Vector4))
{
Vector4 v = (Vector4)value;
return new { x = v.x, y = v.y, z = v.z, w = v.w };
}
else if (valueType == typeof(Quaternion))
{
Quaternion q = (Quaternion)value;
return new
{
x = q.x,
y = q.y,
z = q.z,
w = q.w,
eulerAngles = new { x = q.eulerAngles.x, y = q.eulerAngles.y, z = q.eulerAngles.z }
};
}
else if (valueType == typeof(Color))
{
Color c = (Color)value;
return new { r = c.r, g = c.g, b = c.b, a = c.a };
}
else if (valueType == typeof(Rect))
{
Rect r = (Rect)value;
return new { x = r.x, y = r.y, width = r.width, height = r.height };
}
else if (valueType == typeof(Bounds))
{
Bounds b = (Bounds)value;
return new
{
center = new { x = b.center.x, y = b.center.y, z = b.center.z },
size = new { x = b.size.x, y = b.size.y, z = b.size.z }
};
}
else if (valueType.IsEnum)
{
return value.ToString();
}
return value;
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: be3bd04b30c4d45ba8d8b1b7072ba4cf
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,50 @@
using System.Collections.Generic;
using UnityEngine;
namespace io.github.hatayama.uLoopMCP
{
/// <summary>
/// Serializes Unity component information to ComponentInfo format
/// Related classes:
/// - GameObjectFinderService: Uses this to serialize components
/// - ComponentPropertySerializer: Serializes individual properties (TODO)
/// </summary>
public class ComponentSerializer
{
private readonly ComponentPropertySerializer _propertySerializer;
public ComponentSerializer()
{
_propertySerializer = new ComponentPropertySerializer();
}
public ComponentInfo[] SerializeComponents(GameObject gameObject)
{
if (gameObject == null)
return new ComponentInfo[0];
Component[] components = gameObject.GetComponents<Component>();
List<ComponentInfo> componentInfos = new List<ComponentInfo>();
foreach (Component component in components)
{
if (component == null)
continue;
string componentTypeName = component.GetType().Name;
ComponentPropertyInfo[] properties = _propertySerializer.SerializeProperties(component);
ComponentInfo info = new ComponentInfo
{
type = componentTypeName,
fullTypeName = component.GetType().FullName,
properties = properties
};
componentInfos.Add(info);
}
return componentInfos.ToArray();
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 1c85218799e2c4dddb513d8634c3f4fd
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,17 @@
using UnityEngine;
namespace io.github.hatayama.uLoopMCP
{
/// <summary>
/// Value object containing GameObject search results
/// </summary>
public class GameObjectDetails
{
public bool Found { get; set; }
public string ErrorMessage { get; set; }
public GameObject GameObject { get; set; }
public string Name { get; set; }
public string Path { get; set; }
public bool IsActive { get; set; }
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 8cc42570cd82f4d0fb30ebfcac03cc3d
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,218 @@
using System.Collections.Generic;
using System.Linq;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
using UnityEngine.SceneManagement;
namespace io.github.hatayama.uLoopMCP
{
/// <summary>
/// Core service for finding GameObjects and extracting component information
/// Related classes:
/// - ComponentSerializer: Serializes component properties to ComponentInfo
/// - FindGameObjectsCommand: API command for advanced search
/// - GameObjectSearchFilters: Filtering logic for search operations
/// </summary>
public class GameObjectFinderService
{
private string GetFullPath(GameObject gameObject)
{
if (gameObject.transform.parent == null)
{
return gameObject.name;
}
return GetFullPath(gameObject.transform.parent.gameObject) + "/" + gameObject.name;
}
public GameObjectDetails[] FindGameObjectsAdvanced(GameObjectSearchOptions options)
{
List<GameObjectDetails> results = new List<GameObjectDetails>();
// Handle hierarchy path search separately
if (options.SearchMode == SearchMode.Path && !string.IsNullOrEmpty(options.NamePattern))
{
GameObject found = FindGameObjectByPath(options.NamePattern);
if (found != null)
{
GameObjectDetails details = new GameObjectDetails
{
Found = true,
GameObject = found,
Name = found.name,
Path = GetFullPath(found),
IsActive = found.activeSelf
};
results.Add(details);
}
return results.ToArray();
}
// Get all root GameObjects from all loaded scenes
List<GameObject> allGameObjects = GetAllGameObjects(options.IncludeInactive);
foreach (GameObject gameObject in allGameObjects)
{
if (MatchesAllCriteria(gameObject, options))
{
GameObjectDetails details = new GameObjectDetails
{
Found = true,
GameObject = gameObject,
Name = gameObject.name,
Path = GetFullPath(gameObject),
IsActive = gameObject.activeSelf
};
results.Add(details);
if (results.Count >= options.MaxResults)
break;
}
}
return results.ToArray();
}
private GameObject FindGameObjectByPath(string path)
{
// GameObject.Find() does not work in Prefab Stage; Transform.Find() intentionally
// returns inactive children too — active/inactive filtering is handled by the caller.
PrefabStage prefabStage = PrefabStageUtility.GetCurrentPrefabStage();
if (prefabStage == null)
{
return GameObject.Find(path);
}
GameObject prefabRoot = prefabStage.prefabContentsRoot;
string trimmedPath = path.TrimStart('/');
if (trimmedPath == prefabRoot.name)
{
return prefabRoot;
}
if (trimmedPath.StartsWith(prefabRoot.name + "/"))
{
string relativePath = trimmedPath.Substring(prefabRoot.name.Length + 1);
Transform found = prefabRoot.transform.Find(relativePath);
return found != null ? found.gameObject : null;
}
Transform directFind = prefabRoot.transform.Find(trimmedPath);
return directFind != null ? directFind.gameObject : null;
}
private List<GameObject> GetAllGameObjects(bool includeInactive)
{
List<GameObject> allGameObjects = new List<GameObject>();
// Prefab Stage has its own scene not visible via SceneManager
PrefabStage prefabStage = PrefabStageUtility.GetCurrentPrefabStage();
if (prefabStage != null)
{
GameObject prefabRoot = prefabStage.prefabContentsRoot;
AddGameObjectAndChildren(prefabRoot, allGameObjects, includeInactive);
return allGameObjects;
}
for (int i = 0; i < SceneManager.sceneCount; i++)
{
Scene scene = SceneManager.GetSceneAt(i);
if (scene.isLoaded)
{
GameObject[] rootObjects = scene.GetRootGameObjects();
foreach (GameObject root in rootObjects)
{
AddGameObjectAndChildren(root, allGameObjects, includeInactive);
}
}
}
return allGameObjects;
}
private void AddGameObjectAndChildren(GameObject gameObject, List<GameObject> list, bool includeInactive)
{
if (!includeInactive && !gameObject.activeInHierarchy)
return;
list.Add(gameObject);
foreach (Transform child in gameObject.transform)
{
AddGameObjectAndChildren(child.gameObject, list, includeInactive);
}
}
private bool MatchesAllCriteria(GameObject gameObject, GameObjectSearchOptions options)
{
// Check name pattern
if (!GameObjectSearchFilters.MatchesNamePattern(gameObject, options.NamePattern, options.SearchMode))
return false;
// Check required components
if (!GameObjectSearchFilters.HasRequiredComponents(gameObject, options.RequiredComponents))
return false;
// Check tag
if (!GameObjectSearchFilters.MatchesTag(gameObject, options.Tag))
return false;
// Check layer
if (!GameObjectSearchFilters.MatchesLayer(gameObject, options.Layer))
return false;
// Check active state
if (!GameObjectSearchFilters.MatchesActiveState(gameObject, options.IncludeInactive))
return false;
return true;
}
/// <summary>
/// Get currently selected GameObjects in Unity Editor
/// </summary>
/// <param name="includeInactive">Whether to include inactive GameObjects in results</param>
/// <returns>Array of GameObjectDetails for selected objects</returns>
public GameObjectDetails[] FindSelectedGameObjects(bool includeInactive)
{
GameObject[] selectedGameObjects = Selection.gameObjects;
if (selectedGameObjects == null || selectedGameObjects.Length == 0)
{
return new GameObjectDetails[0];
}
List<GameObjectDetails> results = new List<GameObjectDetails>();
foreach (GameObject gameObject in selectedGameObjects)
{
if (gameObject == null)
{
continue;
}
if (!includeInactive && !gameObject.activeInHierarchy)
{
continue;
}
GameObjectDetails details = new GameObjectDetails
{
Found = true,
GameObject = gameObject,
Name = gameObject.name,
Path = GetFullPath(gameObject),
IsActive = gameObject.activeSelf
};
results.Add(details);
}
return results.ToArray();
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 59f87961d45464bad9bd399eec9f75ce
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,83 @@
using System.Linq;
using System.Text.RegularExpressions;
using UnityEngine;
namespace io.github.hatayama.uLoopMCP
{
/// <summary>
/// Filters for GameObject search operations
/// </summary>
public static class GameObjectSearchFilters
{
public static bool MatchesNamePattern(GameObject gameObject, string pattern, SearchMode searchMode)
{
if (string.IsNullOrEmpty(pattern))
return true;
switch (searchMode)
{
case SearchMode.Exact:
return gameObject.name == pattern;
case SearchMode.Path:
// Path search is handled separately in GameObjectFinderService
return false;
case SearchMode.Regex:
return Regex.IsMatch(gameObject.name, pattern);
case SearchMode.Contains:
return gameObject.name.Contains(pattern);
default:
return false;
}
}
public static bool HasRequiredComponents(GameObject gameObject, string[] componentTypes)
{
if (componentTypes == null || componentTypes.Length == 0)
return true;
foreach (string componentType in componentTypes)
{
if (string.IsNullOrEmpty(componentType))
continue;
Component component = gameObject.GetComponent(componentType);
if (component == null)
return false;
}
return true;
}
public static bool MatchesTag(GameObject gameObject, string tag)
{
if (string.IsNullOrEmpty(tag))
return true;
// Handle untagged objects
if (gameObject.tag == "Untagged" && tag != "Untagged")
return false;
return gameObject.CompareTag(tag);
}
public static bool MatchesLayer(GameObject gameObject, int? layer)
{
if (!layer.HasValue)
return true;
return gameObject.layer == layer.Value;
}
public static bool MatchesActiveState(GameObject gameObject, bool includeInactive)
{
if (includeInactive)
return true;
return gameObject.activeInHierarchy;
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: cb368ff1e6aa348498cc6806488b21ad
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,16 @@
namespace io.github.hatayama.uLoopMCP
{
/// <summary>
/// Options for advanced GameObject search
/// </summary>
public class GameObjectSearchOptions
{
public string NamePattern { get; set; } = "";
public SearchMode SearchMode { get; set; } = SearchMode.Exact;
public string[] RequiredComponents { get; set; } = new string[0];
public string Tag { get; set; } = "";
public int? Layer { get; set; } = null;
public bool IncludeInactive { get; set; } = false;
public int MaxResults { get; set; } = 100;
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: e23fcae79cb2040e5881282169e08b48
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 73707137a029147528c295df48ab8a0c
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,42 @@
using System;
namespace io.github.hatayama.uLoopMCP
{
/// <summary>
/// Context information about the hierarchy
/// </summary>
[Serializable]
public class HierarchyContext
{
/// <summary>
/// Type of scene context
/// </summary>
public readonly string sceneType;
/// <summary>
/// Name of the current scene or prefab
/// </summary>
public readonly string sceneName;
/// <summary>
/// Total number of nodes in the hierarchy
/// </summary>
public readonly int nodeCount;
/// <summary>
/// Maximum depth found in the hierarchy
/// </summary>
public readonly int maxDepth;
/// <summary>
/// Constructor for HierarchyContext
/// </summary>
public HierarchyContext(string sceneType, string sceneName, int nodeCount, int maxDepth)
{
this.sceneType = sceneType ?? "unknown";
this.sceneName = sceneName ?? string.Empty;
this.nodeCount = nodeCount;
this.maxDepth = maxDepth;
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 7ed6a6e0a0fd14971b724f3ad56f8b72
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,69 @@
using System;
namespace io.github.hatayama.uLoopMCP
{
/// <summary>
/// Represents a single node in the Unity Hierarchy
/// Immutable data structure for AI-friendly JSON serialization
/// </summary>
[Serializable]
public class HierarchyNode
{
/// <summary>
/// Session-unique Unity object identifier
/// </summary>
public readonly string id;
/// <summary>
/// GameObject name
/// </summary>
public readonly string name;
/// <summary>
/// Parent node's session-unique Unity object identifier (null for root objects)
/// </summary>
public readonly string parent;
/// <summary>
/// Depth level in hierarchy (0 for root)
/// </summary>
public readonly int depth;
/// <summary>
/// Whether the GameObject is active
/// </summary>
public readonly bool isActive;
/// <summary>
/// List of component type names attached to this GameObject
/// </summary>
public readonly string[] components;
// Additional optional metadata for grouping/ordering
public readonly int? siblingIndex;
public readonly string tag;
public readonly int? layer;
/// <summary>
/// Scene name this GameObject belongs to
/// </summary>
public readonly string sceneName;
/// <summary>
/// Constructor for HierarchyNode
/// </summary>
public HierarchyNode(string id, string name, string parent, int depth, bool isActive, string[] components, string sceneName = "", int? siblingIndex = null, string tag = null, int? layer = null)
{
this.id = id ?? string.Empty;
this.name = name ?? string.Empty;
this.parent = parent;
this.depth = depth;
this.isActive = isActive;
this.components = components ?? Array.Empty<string>();
this.sceneName = sceneName ?? string.Empty;
this.siblingIndex = siblingIndex;
this.tag = tag;
this.layer = layer;
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 655eb05c029cd4caeb1d9d467f9b4ce6
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,58 @@
using System;
using System.Collections.Generic;
using Newtonsoft.Json;
namespace io.github.hatayama.uLoopMCP
{
/// <summary>
/// Represents a single node in the Unity Hierarchy with nested structure
/// Immutable data structure for AI-friendly JSON serialization with direct parent-child relationships
/// </summary>
[Serializable]
public class HierarchyNodeNested
{
/// <summary>
/// GameObject name
/// </summary>
public readonly string name;
/// <summary>
/// Whether the GameObject is active
/// </summary>
public readonly bool isActive;
/// <summary>
/// List of component type names attached to this GameObject
/// </summary>
public string[] components;
public int[] componentsIdx;
/// <summary>
/// Child nodes directly nested under this node
/// </summary>
[JsonProperty(Order = 1000)]
public readonly List<HierarchyNodeNested> children;
// Optional compact metadata
public readonly int? siblingIndex;
public readonly string tag;
public readonly int? layer;
public string path;
/// <summary>
/// Constructor for HierarchyNodeNested
/// </summary>
public HierarchyNodeNested(string name, bool isActive, string[] components, List<HierarchyNodeNested> children = null, int? siblingIndex = null, string tag = null, int? layer = null, string path = null, int[] componentsIdx = null)
{
this.name = name ?? string.Empty;
this.isActive = isActive;
this.components = components ?? Array.Empty<string>();
this.componentsIdx = componentsIdx;
this.children = children ?? new List<HierarchyNodeNested>();
this.siblingIndex = siblingIndex;
this.tag = tag;
this.layer = layer;
this.path = path;
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: ae969ca0e3c5d4b7e8e5af3e7c1f4380
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,34 @@
namespace io.github.hatayama.uLoopMCP
{
/// <summary>
/// Options for hierarchy traversal
/// </summary>
public class HierarchyOptions
{
/// <summary>
/// Include inactive GameObjects in the result
/// </summary>
public bool IncludeInactive { get; set; } = true;
/// <summary>
/// Maximum depth to traverse (-1 for unlimited)
/// </summary>
public int MaxDepth { get; set; } = -1;
/// <summary>
/// Root path to start traversal from (null for all root objects)
/// </summary>
public string RootPath { get; set; }
/// <summary>
/// Include component information in the result
/// </summary>
public bool IncludeComponents { get; set; } = true;
/// <summary>
/// Use selected GameObject(s) as root(s) for hierarchy traversal.
/// When true, RootPath is ignored.
/// </summary>
public bool UseSelection { get; set; } = false;
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 714d16d365bf34784af2c3f6105c0ae2
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,190 @@
using System.Collections.Generic;
using System.Linq;
namespace io.github.hatayama.uLoopMCP
{
/// <summary>
/// Serializer for converting flat hierarchy nodes to scene-grouped nested structures
/// Related classes:
/// - HierarchyNode: Flat hierarchy structure (collected by service)
/// - HierarchyNodeNested: Nested hierarchy structure (export model)
/// - SceneHierarchyGroup: Scene-grouped container with stats and LUTs (export model)
/// </summary>
public class HierarchySerializer
{
public HierarchySerializationResult BuildGroups(List<HierarchyNode> nodes, HierarchyContext context, HierarchySerializationOptions options)
{
if (nodes == null)
{
nodes = new List<HierarchyNode>();
}
if (options == null)
{
options = new HierarchySerializationOptions();
}
int nodeCount = nodes.Count;
int maxDepth = nodes.Any() ? nodes.Max(n => n.depth) : 0;
HierarchyContext updatedContext = new HierarchyContext(
context.sceneType,
context.sceneName,
nodeCount,
maxDepth
);
// Group by scene
List<SceneHierarchyGroup> groups = nodes
.GroupBy(n => n.sceneName)
.Select(g => BuildGroupForScene(g.Key ?? string.Empty, g.ToList(), options))
.ToList();
return new HierarchySerializationResult(groups, updatedContext);
}
private SceneHierarchyGroup BuildGroupForScene(string sceneName, List<HierarchyNode> sceneNodes, HierarchySerializationOptions options)
{
// Build nested structure per scene
Dictionary<string, HierarchyNodeNested> nodeDict = new Dictionary<string, HierarchyNodeNested>();
foreach (HierarchyNode flat in sceneNodes)
{
HierarchyNodeNested nested = new HierarchyNodeNested(
name: flat.name,
isActive: flat.isActive,
components: flat.components,
children: null,
siblingIndex: flat.siblingIndex,
tag: flat.tag,
layer: flat.layer
);
nodeDict[flat.id] = nested;
}
List<HierarchyNodeNested> roots = new List<HierarchyNodeNested>();
foreach (HierarchyNode flat in sceneNodes)
{
HierarchyNodeNested nested = nodeDict[flat.id];
if (flat.parent == null)
{
roots.Add(nested);
}
else if (nodeDict.TryGetValue(flat.parent, out HierarchyNodeNested parentNested))
{
parentNested.children.Add(nested);
}
else
{
roots.Add(nested);
}
}
// Stats
int rootCount = roots.Count;
int groupNodeCount = sceneNodes.Count;
int groupMaxDepth = sceneNodes.Any() ? sceneNodes.Max(n => n.depth) : 0;
SceneHierarchyStats stats = new SceneHierarchyStats(rootCount, groupNodeCount, groupMaxDepth);
// Optional LUTs
List<string> componentsLut = null;
if (ShouldUseComponentsLut(options, sceneNodes))
{
componentsLut = BuildComponentsLutAndApply(sceneNodes, nodeDict);
}
if (options.IncludePaths)
{
// Assign direct string paths per node; no LUT
AssignStringPaths(roots);
}
return new SceneHierarchyGroup(sceneName, stats, roots, componentsLut);
}
private static bool ShouldUseComponentsLut(HierarchySerializationOptions options, List<HierarchyNode> sceneNodes)
{
if (options == null) return false;
if (options.UseComponentsLut == "true") return true;
if (options.UseComponentsLut == "false") return false;
// auto heuristic: high duplication of component names
List<string> all = new List<string>();
foreach (var n in sceneNodes)
{
if (n.components != null && n.components.Length > 0)
{
all.AddRange(n.components);
}
}
if (all.Count < 50) return false;
int unique = all.Distinct().Count();
return unique * 2 < all.Count; // more than 50% duplicates
}
private static List<string> BuildComponentsLutAndApply(List<HierarchyNode> sceneNodes, Dictionary<string, HierarchyNodeNested> nodeDict)
{
Dictionary<string, int> lutIndex = new Dictionary<string, int>();
List<string> lut = new List<string>();
foreach (HierarchyNode flat in sceneNodes)
{
if (flat.components == null) continue;
int[] idx = new int[flat.components.Length];
for (int i = 0; i < flat.components.Length; i++)
{
string comp = flat.components[i];
if (!lutIndex.TryGetValue(comp, out int index))
{
index = lut.Count;
lutIndex[comp] = index;
lut.Add(comp);
}
idx[i] = index;
}
HierarchyNodeNested nested = nodeDict[flat.id];
nested.componentsIdx = idx;
nested.components = null;
}
return lut;
}
private static void AssignStringPaths(List<HierarchyNodeNested> roots)
{
void Traverse(HierarchyNodeNested node, string parentPath)
{
string path = string.IsNullOrEmpty(parentPath) ? node.name : parentPath + "/" + node.name;
node.path = path;
foreach (HierarchyNodeNested child in node.children)
{
Traverse(child, path);
}
}
foreach (HierarchyNodeNested root in roots)
{
Traverse(root, string.Empty);
}
}
}
public class HierarchySerializationOptions
{
public bool IncludePaths { get; set; }
public string UseComponentsLut { get; set; } = "auto"; // auto|true|false
}
public class HierarchySerializationResult
{
public readonly List<SceneHierarchyGroup> Groups;
public readonly HierarchyContext Context;
public HierarchySerializationResult(List<SceneHierarchyGroup> groups, HierarchyContext context)
{
this.Groups = groups ?? new List<SceneHierarchyGroup>();
this.Context = context;
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 1c91e560ecb964cf1b0f7df1451175c4
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,395 @@
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using UnityEngine;
using UnityEngine.SceneManagement;
using UnityEditor;
using UnityEditor.SceneManagement;
namespace io.github.hatayama.uLoopMCP
{
/// <summary>
/// Service for retrieving Unity Hierarchy information
/// Reusable logic separated from command implementation
/// </summary>
public class HierarchyService
{
/// <summary>
/// Get all hierarchy nodes based on options
/// </summary>
public List<HierarchyNode> GetHierarchyNodes(HierarchyOptions options)
{
List<HierarchyNode> nodes = new List<HierarchyNode>();
GameObject[] rootObjects = options.UseSelection
? GetSelectedRootGameObjects()
: GetRootGameObjects(options.RootPath);
foreach (GameObject root in rootObjects)
{
if (!options.IncludeInactive && !root.activeInHierarchy)
continue;
TraverseHierarchy(root, null, 0, options, nodes);
}
return nodes;
}
/// <summary>
/// Get selected GameObjects as roots, filtering out descendants
/// to avoid duplicate traversal when parent and child are both selected.
/// </summary>
private GameObject[] GetSelectedRootGameObjects()
{
GameObject[] selected = Selection.gameObjects;
if (selected == null || selected.Length == 0)
{
return System.Array.Empty<GameObject>();
}
List<GameObject> roots = new List<GameObject>();
foreach (GameObject obj in selected)
{
if (obj == null)
{
continue;
}
bool isDescendantOfAnotherSelected = false;
foreach (GameObject other in selected)
{
if (other == null || other == obj)
{
continue;
}
if (obj.transform.IsChildOf(other.transform))
{
isDescendantOfAnotherSelected = true;
break;
}
}
if (!isDescendantOfAnotherSelected)
{
roots.Add(obj);
}
}
return roots.ToArray();
}
/// <summary>
/// Get current scene context information
/// </summary>
public HierarchyContext GetCurrentContext()
{
string sceneType = "editor";
string sceneName = "Unknown";
// Check if in Prefab Edit Mode
PrefabStage prefabStage = PrefabStageUtility.GetCurrentPrefabStage();
if (prefabStage != null)
{
sceneType = "prefab";
sceneName = prefabStage.assetPath;
}
else if (Application.isPlaying)
{
sceneType = "runtime";
sceneName = BuildSceneNameSummary();
}
else
{
sceneType = "editor";
sceneName = BuildSceneNameSummary();
}
return new HierarchyContext(sceneType, sceneName, 0, 0);
}
private string BuildSceneNameSummary()
{
int count = SceneManager.sceneCount;
if (count <= 0)
{
return string.Empty;
}
System.Collections.Generic.List<string> names = new System.Collections.Generic.List<string>();
for (int i = 0; i < count; i++)
{
Scene scene = SceneManager.GetSceneAt(i);
if (scene.isLoaded)
{
names.Add(scene.name);
}
}
GameObject[] ddolRoots = GetDontDestroyOnLoadRootObjects();
if (ddolRoots.Length > 0 && !names.Contains("DontDestroyOnLoad"))
{
names.Add("DontDestroyOnLoad");
}
if (names.Count <= 1)
{
return names.Count == 1 ? names[0] : string.Empty;
}
string joined = string.Join(", ", names.ToArray());
return $"Multiple({names.Count}): {joined}";
}
private GameObject[] GetRootGameObjects(string rootPath)
{
// Check if in Prefab Edit Mode
PrefabStage prefabStage = PrefabStageUtility.GetCurrentPrefabStage();
if (prefabStage != null)
{
GameObject prefabRoot = prefabStage.prefabContentsRoot;
if (!string.IsNullOrEmpty(rootPath))
{
if (prefabRoot.name == rootPath)
{
return new[] { prefabRoot };
}
string localPath = NormalizeRootRelativePath(rootPath, prefabRoot.name);
Transform found = string.IsNullOrEmpty(localPath)
? prefabRoot.transform
: prefabRoot.transform.Find(localPath);
if (found != null)
{
return new[] { found.gameObject };
}
return System.Array.Empty<GameObject>();
}
return new[] { prefabRoot };
}
// Normal scene mode: iterate all loaded scenes (additive included)
List<GameObject> results = new List<GameObject>();
GameObject[] ddolRoots = GetDontDestroyOnLoadRootObjects();
int sceneCount = SceneManager.sceneCount;
if (!string.IsNullOrEmpty(rootPath))
{
for (int i = 0; i < sceneCount; i++)
{
Scene scene = SceneManager.GetSceneAt(i);
if (!scene.isLoaded)
{
continue;
}
GameObject[] roots = scene.GetRootGameObjects();
AppendMatchesForSceneRoot(results, roots, rootPath);
}
if (ddolRoots.Length > 0)
{
AppendMatchesForSceneRoot(results, ddolRoots, rootPath);
}
return results.ToArray();
}
for (int i = 0; i < sceneCount; i++)
{
Scene scene = SceneManager.GetSceneAt(i);
if (!scene.isLoaded)
{
continue;
}
GameObject[] roots = scene.GetRootGameObjects();
results.AddRange(roots);
}
if (ddolRoots.Length > 0)
{
results.AddRange(ddolRoots);
}
return results.ToArray();
}
private static string NormalizeRootRelativePath(string rootPath, string rootName)
{
if (string.IsNullOrEmpty(rootPath))
{
return string.Empty;
}
string trimmed = rootPath.TrimStart('/');
if (string.IsNullOrEmpty(trimmed))
{
return string.Empty;
}
if (trimmed.StartsWith(rootName + "/"))
{
return trimmed.Substring(rootName.Length + 1);
}
if (trimmed == rootName)
{
return string.Empty;
}
return trimmed;
}
private static void AppendMatchesForSceneRoot(System.Collections.Generic.List<GameObject> results, GameObject[] roots, string rootPath)
{
if (roots == null)
{
return;
}
for (int i = 0; i < roots.Length; i++)
{
GameObject root = roots[i];
if (root == null)
{
continue;
}
if (root.name == rootPath)
{
results.Add(root);
continue;
}
string localPath = NormalizeRootRelativePath(rootPath, root.name);
if (string.IsNullOrEmpty(localPath))
{
results.Add(root);
continue;
}
Transform found = root.transform.Find(localPath);
if (found != null)
{
results.Add(found.gameObject);
}
}
}
private static GameObject[] GetDontDestroyOnLoadRootObjects()
{
if (!Application.isPlaying)
{
return System.Array.Empty<GameObject>();
}
GameObject probe = null;
try
{
probe = new GameObject("__mcp_ddol_probe__");
UnityEngine.Object.DontDestroyOnLoad(probe);
Scene ddolScene = probe.scene;
if (!ddolScene.IsValid())
{
return System.Array.Empty<GameObject>();
}
GameObject[] roots = ddolScene.GetRootGameObjects();
if (roots == null || roots.Length == 0)
{
return System.Array.Empty<GameObject>();
}
System.Collections.Generic.List<GameObject> filtered = new System.Collections.Generic.List<GameObject>();
for (int i = 0; i < roots.Length; i++)
{
GameObject root = roots[i];
if (root == null || root == probe)
{
continue;
}
filtered.Add(root);
}
return filtered.ToArray();
}
finally
{
#if UNITY_EDITOR
if (probe != null)
{
if (Application.isPlaying)
{
UnityEngine.Object.Destroy(probe);
}
else
{
UnityEngine.Object.DestroyImmediate(probe);
}
}
#endif
}
}
private void TraverseHierarchy(GameObject obj, string parentId, int depth, HierarchyOptions options, List<HierarchyNode> nodes)
{
// Check depth limit
if (options.MaxDepth >= 0 && depth > options.MaxDepth)
return;
// Get components
string[] componentNames = new string[0];
if (options.IncludeComponents)
{
Component[] components = obj.GetComponents<Component>();
componentNames = components
.Where(c => c != null)
.Select(c => c.GetType().Name)
.ToArray();
}
// Create node
string currentId = GetObjectId(obj);
HierarchyNode node = new HierarchyNode(
id: currentId,
name: obj.name,
parent: parentId,
depth: depth,
isActive: obj.activeSelf,
components: componentNames,
sceneName: obj.scene.name,
siblingIndex: obj.transform.GetSiblingIndex(),
tag: obj.tag,
layer: obj.layer
);
nodes.Add(node);
// Traverse children
foreach (Transform child in obj.transform)
{
if (!options.IncludeInactive && !child.gameObject.activeInHierarchy)
continue;
TraverseHierarchy(child.gameObject, currentId, depth + 1, options, nodes);
}
}
private static string GetObjectId(UnityEngine.Object obj)
{
UnityEngine.Debug.Assert(obj != null, "Unity Object must exist before reading its identifier.");
#if UNITY_6000_4_OR_NEWER
ulong entityId = UnityEngine.EntityId.ToULong(obj.GetEntityId());
return entityId.ToString(CultureInfo.InvariantCulture);
#else
int instanceId = obj.GetInstanceID();
return instanceId.ToString(CultureInfo.InvariantCulture);
#endif
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 9217527cf4dd54605b9f1269a129523a
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,50 @@
using System;
using System.Collections.Generic;
namespace io.github.hatayama.uLoopMCP
{
/// <summary>
/// Scene-level grouping of hierarchy nodes for AI-friendly JSON export
/// </summary>
[Serializable]
public class SceneHierarchyGroup
{
public readonly string sceneName;
public readonly SceneHierarchyStats stats;
// Optional: lookup table for component names to reduce redundancy
public readonly List<string> componentsLut;
public readonly List<HierarchyNodeNested> roots;
public SceneHierarchyGroup(
string sceneName,
SceneHierarchyStats stats,
List<HierarchyNodeNested> roots,
List<string> componentsLut = null)
{
this.sceneName = sceneName ?? string.Empty;
this.stats = stats ?? new SceneHierarchyStats(0, 0, 0);
this.roots = roots ?? new List<HierarchyNodeNested>();
this.componentsLut = componentsLut; // may be null when not used
}
}
[Serializable]
public class SceneHierarchyStats
{
public readonly int rootCount;
public readonly int nodeCount;
public readonly int maxDepth;
public SceneHierarchyStats(int rootCount, int nodeCount, int maxDepth)
{
this.rootCount = rootCount;
this.nodeCount = nodeCount;
this.maxDepth = maxDepth;
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 76f1fbd4e38ca460eb431f6379ead8bc
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 275b65ca4254f4f68a23f32857120920
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,271 @@
#if ULOOPMCP_HAS_TEST_FRAMEWORK
using System;
using System.IO;
using System.Text;
using System.Xml;
using UnityEditor;
using UnityEditor.TestTools.TestRunner.Api;
using UnityEngine;
namespace io.github.hatayama.uLoopMCP
{
/// <summary>
/// Class to export test results as NUnit XML.
/// </summary>
public static class NUnitXmlResultExporter
{
/// <summary>
/// Logs the test result as XML.
/// </summary>
public static void LogTestResultAsXml(ITestResultAdaptor testResult)
{
GenerateNUnitXml(testResult);
// Test Result XML generated
}
/// <summary>
/// Saves the test result as an XML file.
/// </summary>
public static string SaveTestResultAsXml(ITestResultAdaptor testResult)
{
string timestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss");
string fileName = $"{timestamp}.xml";
// Save to .uloop/outputs/TestResults folder at project root (same level as Assets)
DirectoryInfo parentDir = Directory.GetParent(Application.dataPath);
if (parentDir == null)
{
throw new System.InvalidOperationException("Unable to determine project root directory");
}
string projectRoot = parentDir.FullName;
string testResultsDir = Path.Combine(projectRoot, McpConstants.OUTPUT_ROOT_DIR, McpConstants.TEST_RESULTS_DIR);
// Create directory if it doesn't exist
if (!Directory.Exists(testResultsDir))
{
Directory.CreateDirectory(testResultsDir);
}
string filePath = Path.Combine(testResultsDir, fileName);
string xmlContent = GenerateNUnitXml(testResult);
File.WriteAllText(filePath, xmlContent, Encoding.UTF8);
// Refresh Assets folder.
AssetDatabase.Refresh();
// Test result XML saved
return filePath;
}
/// <summary>
/// Generates NUnit format XML.
/// </summary>
private static string GenerateNUnitXml(ITestResultAdaptor testResult)
{
XmlDocument doc = new XmlDocument();
// Add XML declaration.
XmlDeclaration declaration = doc.CreateXmlDeclaration("1.0", "UTF-8", null);
doc.AppendChild(declaration);
// Create root element.
XmlElement testRun = doc.CreateElement("test-run");
testRun.SetAttribute("id", "2");
testRun.SetAttribute("testcasecount", CountTestCases(testResult).ToString());
testRun.SetAttribute("result", GetOverallResult(testResult));
testRun.SetAttribute("total", CountTestCases(testResult).ToString());
testRun.SetAttribute("passed", CountPassed(testResult).ToString());
testRun.SetAttribute("failed", CountFailed(testResult).ToString());
testRun.SetAttribute("skipped", CountSkipped(testResult).ToString());
testRun.SetAttribute("start-time", testResult.StartTime.ToString("yyyy-MM-dd HH:mm:ss"));
testRun.SetAttribute("end-time", testResult.EndTime.ToString("yyyy-MM-dd HH:mm:ss"));
testRun.SetAttribute("duration", testResult.Duration.ToString("F3"));
doc.AppendChild(testRun);
// Add test suite.
XmlElement testSuite = CreateTestSuiteElement(doc, testResult);
testRun.AppendChild(testSuite);
// Return formatted XML.
using (StringWriter stringWriter = new StringWriter())
{
XmlWriterSettings settings = new XmlWriterSettings
{
Indent = true,
IndentChars = " ",
NewLineChars = "\n",
NewLineHandling = NewLineHandling.Replace
};
using (XmlWriter xmlWriter = XmlWriter.Create(stringWriter, settings))
{
doc.Save(xmlWriter);
}
return stringWriter.ToString();
}
}
/// <summary>
/// Creates a test suite element (recursive).
/// </summary>
private static XmlElement CreateTestSuiteElement(XmlDocument doc, ITestResultAdaptor result)
{
if (result.Test.IsSuite)
{
XmlElement suite = doc.CreateElement("test-suite");
suite.SetAttribute("type", result.Test.TypeInfo?.FullName ?? "TestSuite");
suite.SetAttribute("name", result.Test.Name);
suite.SetAttribute("fullname", result.Test.FullName);
suite.SetAttribute("result", result.TestStatus.ToString());
suite.SetAttribute("start-time", result.StartTime.ToString("yyyy-MM-dd HH:mm:ss"));
suite.SetAttribute("end-time", result.EndTime.ToString("yyyy-MM-dd HH:mm:ss"));
suite.SetAttribute("duration", result.Duration.ToString("F3"));
suite.SetAttribute("total", CountTestCases(result).ToString());
suite.SetAttribute("passed", CountPassed(result).ToString());
suite.SetAttribute("failed", CountFailed(result).ToString());
suite.SetAttribute("skipped", CountSkipped(result).ToString());
// Add child elements.
if (result.Children != null)
{
foreach (ITestResultAdaptor child in result.Children)
{
XmlElement childElement = CreateTestSuiteElement(doc, child);
suite.AppendChild(childElement);
}
}
return suite;
}
else
{
// Test case.
XmlElement testCase = doc.CreateElement("test-case");
testCase.SetAttribute("name", result.Test.Name);
testCase.SetAttribute("fullname", result.Test.FullName);
testCase.SetAttribute("result", result.TestStatus.ToString());
testCase.SetAttribute("start-time", result.StartTime.ToString("yyyy-MM-dd HH:mm:ss"));
testCase.SetAttribute("end-time", result.EndTime.ToString("yyyy-MM-dd HH:mm:ss"));
testCase.SetAttribute("duration", result.Duration.ToString("F3"));
// If failed, add message and stack trace.
if (result.TestStatus == TestStatus.Failed)
{
XmlElement failure = doc.CreateElement("failure");
if (!string.IsNullOrEmpty(result.Message))
{
XmlElement message = doc.CreateElement("message");
message.InnerText = result.Message;
failure.AppendChild(message);
}
if (!string.IsNullOrEmpty(result.StackTrace))
{
XmlElement stackTrace = doc.CreateElement("stack-trace");
stackTrace.InnerText = result.StackTrace;
failure.AppendChild(stackTrace);
}
testCase.AppendChild(failure);
}
return testCase;
}
}
/// <summary>
/// Gets the overall result.
/// </summary>
private static string GetOverallResult(ITestResultAdaptor result)
{
if (CountFailed(result) > 0)
return "Failed";
if (CountSkipped(result) > 0 && CountPassed(result) == 0)
return "Skipped";
return "Passed";
}
/// <summary>
/// Counts the number of test cases.
/// </summary>
private static int CountTestCases(ITestResultAdaptor result)
{
if (!result.Test.IsSuite)
return 1;
int count = 0;
if (result.Children != null)
{
foreach (ITestResultAdaptor child in result.Children)
{
count += CountTestCases(child);
}
}
return count;
}
/// <summary>
/// Counts the number of passed tests.
/// </summary>
private static int CountPassed(ITestResultAdaptor result)
{
if (!result.Test.IsSuite)
return result.TestStatus == TestStatus.Passed ? 1 : 0;
int count = 0;
if (result.Children != null)
{
foreach (ITestResultAdaptor child in result.Children)
{
count += CountPassed(child);
}
}
return count;
}
/// <summary>
/// Counts the number of failed tests.
/// </summary>
private static int CountFailed(ITestResultAdaptor result)
{
if (!result.Test.IsSuite)
return result.TestStatus == TestStatus.Failed ? 1 : 0;
int count = 0;
if (result.Children != null)
{
foreach (ITestResultAdaptor child in result.Children)
{
count += CountFailed(child);
}
}
return count;
}
/// <summary>
/// Counts the number of skipped tests.
/// </summary>
private static int CountSkipped(ITestResultAdaptor result)
{
if (!result.Test.IsSuite)
return result.TestStatus == TestStatus.Skipped ? 1 : 0;
int count = 0;
if (result.Children != null)
{
foreach (ITestResultAdaptor child in result.Children)
{
count += CountSkipped(child);
}
}
return count;
}
}
}
#endif

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 0805925b4a65547c6b8f44ab274cd917
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,145 @@
#if ULOOPMCP_HAS_TEST_FRAMEWORK
using System;
using System.Threading.Tasks;
using UnityEditor.TestTools.TestRunner.Api;
using UnityEngine;
namespace io.github.hatayama.uLoopMCP
{
/// <summary>
/// Unified test executor with domain reload control and filtering support
/// Supports both EditMode and PlayMode tests with advanced filtering capabilities
/// </summary>
public static class PlayModeTestExecuter
{
/// <summary>
/// Execute PlayMode tests asynchronously with domain reload control
/// </summary>
/// <param name="filter">Test execution filter (null for all tests)</param>
/// <returns>Test execution result</returns>
public static async Task<SerializableTestResult> ExecutePlayModeTest(
TestExecutionFilter filter = null)
{
using DomainReloadDisableScope scope = new();
return await ExecuteTestWithEventNotification(TestMode.PlayMode, filter);
}
/// <summary>
/// Execute EditMode tests asynchronously
/// </summary>
/// <param name="filter">Test execution filter (null for all tests)</param>
/// <returns>Test execution result</returns>
public static async Task<SerializableTestResult> ExecuteEditModeTest(
TestExecutionFilter filter = null)
{
return await ExecuteTestWithEventNotification(TestMode.EditMode, filter);
}
/// <summary>
/// Execute test with event-based result notification
/// </summary>
private static async Task<SerializableTestResult> ExecuteTestWithEventNotification(
TestMode testMode,
TestExecutionFilter filter)
{
TaskCompletionSource<SerializableTestResult> tcs = new();
using UnifiedTestCallback callback = new();
callback.OnTestCompleted += (result, rawResult) =>
{
if (!tcs.Task.IsCompleted)
{
// Auto-save XML when tests fail for detailed failure diagnosis
if (result.failedCount > 0 && rawResult != null)
{
result.xmlPath = NUnitXmlResultExporter.SaveTestResultAsXml(rawResult);
}
tcs.SetResult(result);
}
};
StartTestExecution(testMode, filter, callback);
return await tcs.Task;
}
/// <summary>
/// Start test execution with callback handler
/// </summary>
private static void StartTestExecution(TestMode testMode, TestExecutionFilter filter, UnifiedTestCallback callback)
{
TestRunnerApi testRunnerApi = ScriptableObject.CreateInstance<TestRunnerApi>();
callback.SetTestRunnerApi(testRunnerApi);
testRunnerApi.RegisterCallbacks(callback);
Filter unityFilter = CreateUnityFilter(testMode, filter);
testRunnerApi.Execute(new ExecutionSettings(unityFilter));
}
/// <summary>
/// Create Unity Filter from TestExecutionFilter
/// </summary>
private static Filter CreateUnityFilter(TestMode testMode, TestExecutionFilter filter)
{
Filter unityFilter = new Filter
{
testMode = testMode
};
if (filter != null && filter.FilterType != TestExecutionFilterType.All)
{
switch (filter.FilterType)
{
case TestExecutionFilterType.Exact:
// Use testNames for exact matching (individual test methods)
unityFilter.testNames = new string[] { filter.FilterValue };
break;
case TestExecutionFilterType.Regex:
// Use groupNames with regex for pattern matching (classes, namespaces)
unityFilter.groupNames = new string[] { filter.FilterValue };
break;
case TestExecutionFilterType.AssemblyName:
unityFilter.assemblyNames = new string[] { filter.FilterValue };
break;
}
}
return unityFilter;
}
}
/// <summary>
/// Unified callback handler for both EditMode and PlayMode test execution
/// </summary>
internal class UnifiedTestCallback : ICallbacks, IDisposable
{
public event Action<SerializableTestResult, ITestResultAdaptor> OnTestCompleted;
private TestRunnerApi _testRunnerApi;
public void SetTestRunnerApi(TestRunnerApi testRunnerApi)
{
_testRunnerApi = testRunnerApi;
}
public void RunStarted(ITestAdaptor tests)
{
}
public void RunFinished(ITestResultAdaptor result)
{
SerializableTestResult serializableResult = SerializableTestResult.FromTestResult(result);
OnTestCompleted?.Invoke(serializableResult, result);
}
public void TestStarted(ITestAdaptor test) { }
public void TestFinished(ITestResultAdaptor result) { }
public void Dispose()
{
OnTestCompleted = null;
_testRunnerApi?.UnregisterCallbacks(this);
}
}
}
#endif

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 1d2e59a7065014da0a2a747d461a264a
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,156 @@
using System;
#if ULOOPMCP_HAS_TEST_FRAMEWORK
using UnityEditor.TestTools.TestRunner.Api;
#endif
using UnityEngine;
namespace io.github.hatayama.uLoopMCP
{
/// <summary>
/// Serializable test result for domain reload survival
/// </summary>
[Serializable]
public class SerializableTestResult
{
[SerializeField] public bool success;
[SerializeField] public string message;
[SerializeField] public string completedAt;
[SerializeField] public int testCount;
[SerializeField] public int passedCount;
[SerializeField] public int failedCount;
[SerializeField] public int skippedCount;
[SerializeField] public string xmlPath;
public static SerializableTestResult CreateTestFrameworkUnavailable()
{
return new SerializableTestResult
{
success = false,
message = RunTestsResponse.TestFrameworkUnavailableMessage,
completedAt = DateTime.UtcNow.ToString("o"),
testCount = 0,
passedCount = 0,
failedCount = 0,
skippedCount = 0,
xmlPath = null
};
}
#if ULOOPMCP_HAS_TEST_FRAMEWORK
/// <summary>
/// Convert from ITestResultAdaptor to SerializableTestResult
/// </summary>
public static SerializableTestResult FromTestResult(ITestResultAdaptor result)
{
if (result == null)
{
return new SerializableTestResult
{
success = true,
message = "PlayMode test execution completed (detailed results not available)",
completedAt = DateTime.UtcNow.ToString("o"),
testCount = 1,
passedCount = 1,
failedCount = 0,
skippedCount = 0,
xmlPath = null
};
}
int totalTests = CountTotalTests(result);
int passedTests = CountPassedTests(result);
int failedTests = CountFailedTests(result);
int skippedTests = CountSkippedTests(result);
string message;
bool success;
if (totalTests == 0)
{
message = "No tests found matching the specified filter criteria";
success = false;
}
else
{
message = $"Test execution completed with status: {result.TestStatus}";
success = result.TestStatus == TestStatus.Passed;
}
return new SerializableTestResult
{
success = success,
message = message,
completedAt = DateTime.UtcNow.ToString("o"),
testCount = totalTests,
passedCount = passedTests,
failedCount = failedTests,
skippedCount = skippedTests,
xmlPath = null
};
}
/// <summary>
/// Count total tests
/// </summary>
private static int CountTotalTests(ITestResultAdaptor result)
{
int count = 0;
CountTestsByStatus(result, ref count, null);
return count;
}
/// <summary>
/// Count passed tests
/// </summary>
private static int CountPassedTests(ITestResultAdaptor result)
{
int count = 0;
CountTestsByStatus(result, ref count, TestStatus.Passed);
return count;
}
/// <summary>
/// Count failed tests
/// </summary>
private static int CountFailedTests(ITestResultAdaptor result)
{
int count = 0;
CountTestsByStatus(result, ref count, TestStatus.Failed);
return count;
}
/// <summary>
/// Count skipped tests
/// </summary>
private static int CountSkippedTests(ITestResultAdaptor result)
{
int count = 0;
CountTestsByStatus(result, ref count, TestStatus.Skipped);
return count;
}
/// <summary>
/// Recursively count tests by status
/// </summary>
private static void CountTestsByStatus(ITestResultAdaptor result, ref int count, TestStatus? targetStatus)
{
if (!result.Test.IsSuite)
{
if (targetStatus == null || result.TestStatus == targetStatus)
{
count++;
}
return;
}
if (result.Children != null)
{
foreach (ITestResultAdaptor child in result.Children)
{
CountTestsByStatus(child, ref count, targetStatus);
}
}
}
#endif
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 4249d7720b7654e0da01cd8a32969143
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,90 @@
namespace io.github.hatayama.uLoopMCP
{
/// <summary>
/// Type of test execution filter.
/// </summary>
public enum TestExecutionFilterType
{
/// <summary>
/// Run all tests without any filter.
/// </summary>
All,
/// <summary>
/// Filter by exact test method name (complete match).
/// </summary>
Exact,
/// <summary>
/// Filter by regex pattern (class name or namespace).
/// </summary>
Regex,
/// <summary>
/// Filter by assembly name.
/// </summary>
AssemblyName
}
/// <summary>
/// Class that holds test execution filter information.
/// </summary>
public class TestExecutionFilter
{
/// <summary>
/// The type of filter to apply (TestName, ClassName, Namespace, or AssemblyName).
/// This determines how the FilterValue will be interpreted.
/// </summary>
public TestExecutionFilterType FilterType { get; }
/// <summary>
/// The specific value to filter tests by, based on the FilterType.
/// Examples:
/// • When FilterType is Exact: "io.github.hatayama.uLoopMCP.ConsoleLogRetrieverTests.GetAllLogs_WithMaskAllOff_StillReturnsAllLogs"
/// • When FilterType is Regex: "io.github.hatayama.uLoopMCP.ConsoleLogRetrieverTests" or "io.github.hatayama.uLoopMCP"
/// • When FilterType is AssemblyName: "uLoopMCP.Tests.Editor"
/// </summary>
public string FilterValue { get; }
/// <summary>
/// Creates a test execution filter.
/// </summary>
public TestExecutionFilter(TestExecutionFilterType filterType, string filterValue)
{
FilterType = filterType;
FilterValue = filterValue;
}
/// <summary>
/// Creates a filter by exact test method name.
/// </summary>
public static TestExecutionFilter ByTestName(string testName)
{
return new TestExecutionFilter(TestExecutionFilterType.Exact, testName);
}
/// <summary>
/// Creates a filter by regex pattern (class name or namespace).
/// </summary>
public static TestExecutionFilter ByClassName(string className)
{
return new TestExecutionFilter(TestExecutionFilterType.Regex, className);
}
/// <summary>
/// Creates a filter by assembly name.
/// </summary>
public static TestExecutionFilter ByAssemblyName(string assemblyName)
{
return new TestExecutionFilter(TestExecutionFilterType.AssemblyName, assemblyName);
}
/// <summary>
/// Creates a filter to run all tests without any filter.
/// </summary>
public static TestExecutionFilter All()
{
return new TestExecutionFilter(TestExecutionFilterType.All, string.Empty);
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 05a14757d8184488aa9b57cca4ba1e30
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: bedde66928084edabc2d846f2a9af778
timeCreated: 1751015418

View File

@@ -0,0 +1,405 @@
using UnityEditor;
using UnityEditor.Compilation;
using System.Collections.Generic;
using System.Linq;
using System;
using System.Threading.Tasks;
using System.Threading;
namespace io.github.hatayama.uLoopMCP
{
/// <summary>
/// A class that asynchronously executes Unity's compilation process and monitors the results.
/// It handles starting compilation, monitoring its progress, and retrieving the results.
/// </summary>
public class CompileController : IDisposable
{
private bool _isCompiling = false;
private List<CompilerMessage> _compileMessages = new();
private TaskCompletionSource<CompileResult> _currentCompileTask;
private bool _isForceCompile = false;
/// <summary>
/// Event that occurs when compilation is complete.
/// </summary>
public event Action<CompileResult> OnCompileCompleted;
/// <summary>
/// Event that occurs when compilation starts.
/// </summary>
public event Action<string> OnCompileStarted;
/// <summary>
/// Event that occurs when assembly compilation is complete.
/// </summary>
public event Action<string, CompilerMessage[]> OnAssemblyCompiled;
/// <summary>
/// Gets whether a compilation is currently in progress.
/// </summary>
public bool IsCompiling => _isCompiling;
/// <summary>
/// Gets the current list of compiler messages.
/// </summary>
public IReadOnlyList<CompilerMessage> CompileMessages => _compileMessages.AsReadOnly();
/// <summary>
/// Executes compilation asynchronously.
/// </summary>
/// <param name="forceRecompile">Whether to force a recompile.</param>
/// <returns>The compilation result.</returns>
/// <exception cref="InvalidOperationException">Thrown when the task is not found during compilation.</exception>
public async Task<CompileResult> TryCompileAsync(bool forceRecompile = false)
{
return await TryCompileAsync(forceRecompile, CancellationToken.None);
}
public async Task<CompileResult> TryCompileAsync(bool forceRecompile, CancellationToken ct)
{
if (_isCompiling)
{
// If compilation is already in progress, wait for the current task.
if (_currentCompileTask != null)
{
return await _currentCompileTask.Task;
}
throw new InvalidOperationException("Compilation is in progress, but the task could not be found.");
}
CompilationStateValidationService validationService = new();
ValidationResult validation = validationService.ValidateCompilationState();
if (!validation.IsValid)
{
return new CompileResult(
success: false,
errorCount: 0,
warningCount: 0,
completedAt: DateTime.Now,
messages: new CompilerMessage[0],
errors: new CompilerMessage[0],
warnings: new CompilerMessage[0],
isIndeterminate: true,
message: validation.ErrorMessage
);
}
_isCompiling = true;
_compileMessages.Clear();
_currentCompileTask = new TaskCompletionSource<CompileResult>();
_isForceCompile = forceRecompile;
// Execute asset refresh.
AssetDatabase.Refresh();
// Register events.
CompilationPipeline.compilationFinished += HandleCompileFinished;
CompilationPipeline.assemblyCompilationFinished += HandleAssemblyFinished;
string startMessage = forceRecompile ? "Forced recompile started after asset refresh..." : "Compilation started after asset refresh...";
OnCompileStarted?.Invoke(startMessage);
if (forceRecompile)
{
CompilationPipeline.RequestScriptCompilation(RequestScriptCompilationOptions.CleanBuildCache);
}
else
{
CompilationPipeline.RequestScriptCompilation();
}
_ = WatchCompileStartAsync(ct);
return await _currentCompileTask.Task;
}
private async Task WatchCompileStartAsync(CancellationToken ct)
{
int waitedMs = 0;
while (waitedMs < McpConstants.COMPILE_START_TIMEOUT_MS)
{
if (ct.IsCancellationRequested)
{
AbortCompile("Compilation request was cancelled before it started.");
return;
}
if (EditorApplication.isCompiling)
{
return;
}
if (_currentCompileTask == null || _currentCompileTask.Task.IsCompleted)
{
return;
}
// Do not pass CancellationToken here: this method is fire-and-forget and must not throw.
// Cancellation is handled by explicit checks above to avoid leaving _currentCompileTask pending.
await TimerDelay.Wait(McpConstants.COMPILE_START_POLL_INTERVAL_MS);
waitedMs += McpConstants.COMPILE_START_POLL_INTERVAL_MS;
}
AssemblyDefinitionDuplicationValidationService asmdefValidationService = new();
ValidationResult asmdefValidation = asmdefValidationService.ValidateNoDuplicateAsmdefNames();
if (!asmdefValidation.IsValid)
{
AbortCompile(asmdefValidation.ErrorMessage);
return;
}
AbortCompile(
"Compilation did not start. Possible causes: editor update/reload locks, Auto Refresh disabled, or no script changes."
);
}
private void AbortCompile(string reason)
{
if (_currentCompileTask == null || _currentCompileTask.Task.IsCompleted)
{
return;
}
// Unregister events.
CompilationPipeline.compilationFinished -= HandleCompileFinished;
CompilationPipeline.assemblyCompilationFinished -= HandleAssemblyFinished;
_isCompiling = false;
CompileResult result = new CompileResult(
success: false,
errorCount: 0,
warningCount: 0,
completedAt: DateTime.Now,
messages: new CompilerMessage[0],
errors: new CompilerMessage[0],
warnings: new CompilerMessage[0],
isIndeterminate: true,
message: reason
);
OnCompileCompleted?.Invoke(result);
TaskCompletionSource<CompileResult> task = _currentCompileTask;
_currentCompileTask = null;
task.SetResult(result);
}
/// <summary>
/// Clears the compiler messages.
/// </summary>
public void ClearMessages()
{
_compileMessages.Clear();
}
/// <summary>
/// Handler for when compilation is complete.
/// </summary>
/// <param name="context">The compilation context.</param>
private void HandleCompileFinished(object context)
{
// Unregister events.
CompilationPipeline.compilationFinished -= HandleCompileFinished;
CompilationPipeline.assemblyCompilationFinished -= HandleAssemblyFinished;
_isCompiling = false;
CompileResult result = CreateCompileResult();
OnCompileCompleted?.Invoke(result);
// Set the result on the TaskCompletionSource.
TaskCompletionSource<CompileResult> task = _currentCompileTask;
_currentCompileTask = null;
task?.SetResult(result);
// Reset force compile flag for future compilations
_isForceCompile = false;
}
/// <summary>
/// Handler for when assembly compilation is complete.
/// </summary>
/// <param name="asmPath">The assembly path.</param>
/// <param name="messages">The compiler messages.</param>
private void HandleAssemblyFinished(string asmPath, CompilerMessage[] messages)
{
string assemblyName = System.IO.Path.GetFileName(asmPath);
foreach (CompilerMessage message in messages)
{
_compileMessages.Add(message);
}
OnAssemblyCompiled?.Invoke(assemblyName, messages);
}
/// <summary>
/// Creates the compilation result.
/// </summary>
/// <returns>The compilation result.</returns>
private CompileResult CreateCompileResult()
{
int errorCount = _compileMessages.Count(m => m.type == CompilerMessageType.Error);
int warningCount = _compileMessages.Count(m => m.type == CompilerMessageType.Warning);
// For force compile, don't include messages in response
// User should use get-logs tool after domain reload completes
if (_isForceCompile)
{
return new CompileResult(
success: null, // Success status is indeterminate during force compile
errorCount: errorCount,
warningCount: warningCount,
completedAt: DateTime.Now,
messages: new CompilerMessage[0],
errors: new CompilerMessage[0],
warnings: new CompilerMessage[0],
isIndeterminate: true,
message: "Force compilation executed. Use get-logs tool to retrieve compilation messages."
);
}
CompilerMessage[] errors = _compileMessages.Where(m => m.type == CompilerMessageType.Error).ToArray();
CompilerMessage[] warnings = _compileMessages.Where(m => m.type == CompilerMessageType.Warning).ToArray();
return new CompileResult(
success: errorCount == 0,
errorCount: errorCount,
warningCount: warningCount,
completedAt: DateTime.Now,
messages: _compileMessages.ToArray(),
errors: errors,
warnings: warnings
);
}
/// <summary>
/// Cleans up resources.
/// </summary>
public void Cleanup()
{
// Unregister events just in case.
CompilationPipeline.compilationFinished -= HandleCompileFinished;
CompilationPipeline.assemblyCompilationFinished -= HandleAssemblyFinished;
// If there is an incomplete task, cancel it.
if (_currentCompileTask != null && !_currentCompileTask.Task.IsCompleted)
{
_currentCompileTask.SetCanceled();
_currentCompileTask = null;
}
}
/// <summary>
/// Releases resources.
/// </summary>
public void Dispose()
{
Cleanup();
_compileMessages?.Clear();
_compileMessages = null;
// Clear all events.
OnCompileCompleted = null;
OnCompileStarted = null;
OnAssemblyCompiled = null;
}
}
/// <summary>
/// A class that represents the result of a compilation.
/// Includes information on errors, warnings, and the completion time.
/// </summary>
public class CompileResult
{
/// <summary>
/// Whether the compilation was successful. Null indicates indeterminate status.
/// </summary>
public bool? Success { get; }
/// <summary>
/// The number of errors.
/// </summary>
public int ErrorCount { get; }
/// <summary>
/// The number of warnings.
/// </summary>
public int WarningCount { get; }
/// <summary>
/// The time of compilation completion.
/// </summary>
public DateTime CompletedAt { get; }
/// <summary>
/// All compiler messages.
/// </summary>
public CompilerMessage[] Messages { get; }
/// <summary>
/// Error messages only.
/// </summary>
public CompilerMessage[] Errors { get; }
/// <summary>
/// Warning messages only.
/// </summary>
public CompilerMessage[] Warnings { get; }
/// <summary>
/// Whether the compilation result is indeterminate (cannot be determined).
/// </summary>
public bool IsIndeterminate { get; }
/// <summary>
/// Optional message for additional information
/// </summary>
public string Message { get; }
/// <summary>
/// Alias for error messages (for backward compatibility).
/// </summary>
public CompilerMessage[] error => Errors;
/// <summary>
/// Alias for warning messages (for backward compatibility).
/// </summary>
public CompilerMessage[] warning => Warnings;
/// <summary>
/// Initializes the compilation result.
/// </summary>
/// <param name="success">The compilation success flag. Null indicates indeterminate status.</param>
/// <param name="errorCount">The number of errors.</param>
/// <param name="warningCount">The number of warnings.</param>
/// <param name="completedAt">The completion time.</param>
/// <param name="messages">All messages.</param>
/// <param name="errors">The error messages.</param>
/// <param name="warnings">The warning messages.</param>
/// <param name="isIndeterminate">Whether the result is indeterminate.</param>
public CompileResult(
bool? success,
int errorCount,
int warningCount,
DateTime completedAt,
CompilerMessage[] messages,
CompilerMessage[] errors,
CompilerMessage[] warnings,
bool isIndeterminate = false,
string message = null
)
{
Success = success;
ErrorCount = errorCount;
WarningCount = warningCount;
CompletedAt = completedAt;
Messages = messages;
Errors = errors;
Warnings = warnings;
IsIndeterminate = isIndeterminate;
Message = message;
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 99ed5b71d644a42f2a4c2db2c0b33271
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

Some files were not shown because too many files have changed in this diff Show More