diff --git a/Assets/Tests/Demo/uLoopMCP.Tests.Demo.asmdef b/Assets/Tests/Demo/uLoopMCP.Tests.Demo.asmdef
index a5dd7cbf1..489fb12e4 100644
--- a/Assets/Tests/Demo/uLoopMCP.Tests.Demo.asmdef
+++ b/Assets/Tests/Demo/uLoopMCP.Tests.Demo.asmdef
@@ -6,7 +6,9 @@
"GUID:c956a21f824994ef087b6de566690b3d",
"GUID:4307f53044263cf4b835bd812fc161a4"
],
- "includePlatforms": [],
+ "includePlatforms": [
+ "Editor"
+ ],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": false,
diff --git a/Assets/Tests/Editor/DomainReloadRecoveryUseCaseTests.cs b/Assets/Tests/Editor/DomainReloadRecoveryUseCaseTests.cs
index 0c6c9d383..190728d9e 100644
--- a/Assets/Tests/Editor/DomainReloadRecoveryUseCaseTests.cs
+++ b/Assets/Tests/Editor/DomainReloadRecoveryUseCaseTests.cs
@@ -103,6 +103,8 @@ public void ExecuteBeforeDomainReload_ShouldPreferInstanceState_WhenInstanceIsRu
// Assert
Assert.IsTrue(result.Success, "ExecuteBeforeDomainReload should succeed");
Assert.IsFalse(server.IsRunning, "Running server instance should be stopped before domain reload");
+ Assert.That(server.StopCallCount, Is.EqualTo(1));
+ Assert.That(server.DisposeCallCount, Is.EqualTo(0));
}
[Test]
@@ -183,6 +185,10 @@ private sealed class TestServerInstance : IUnityCliLoopServerInstance
{
public bool IsRunning { get; private set; }
+ public int StopCallCount { get; private set; }
+
+ public int DisposeCallCount { get; private set; }
+
public string Endpoint => "test";
public void StartServer()
@@ -192,11 +198,13 @@ public void StartServer()
public void StopServer()
{
+ StopCallCount++;
IsRunning = false;
}
public void Dispose()
{
+ DisposeCallCount++;
IsRunning = false;
}
}
diff --git a/Assets/Tests/Editor/DynamicCodeToolTests/DynamicCodeExecutionFacadeTests.cs b/Assets/Tests/Editor/DynamicCodeToolTests/DynamicCodeExecutionFacadeTests.cs
index c8c9fcb06..2fa708a95 100644
--- a/Assets/Tests/Editor/DynamicCodeToolTests/DynamicCodeExecutionFacadeTests.cs
+++ b/Assets/Tests/Editor/DynamicCodeToolTests/DynamicCodeExecutionFacadeTests.cs
@@ -70,6 +70,23 @@ await facade.ExecuteAsync(
Assert.That(provider.CreatedExecutors[0].DisposeCallCount, Is.EqualTo(1));
}
+ [Test]
+ public void ResetServerScopedServicesBeforeDomainReload_ShouldSignalShutdownWithoutWaitingForRuntimeDrain()
+ {
+ // Tests that domain reload reset does not leave a pending drain task that can block Unity teardown.
+ DynamicCodeServicesRegistry registry = new();
+ FakeShutdownAwareRuntime runtime = new();
+ registry.SetRuntimeFacadeForTests(runtime);
+
+ registry.ResetServerScopedServicesBeforeDomainReload();
+
+ Assert.That(runtime.ShutdownCallCount, Is.EqualTo(1));
+ Assert.That(runtime.DisposeCallCount, Is.EqualTo(0));
+ Assert.That(registry.GetServerScopedDrainTaskForTests().IsCompleted, Is.True);
+
+ runtime.CompleteShutdown();
+ }
+
private static DynamicCodeExecutionRequest CreateRequest(
DynamicCodeSecurityLevel securityLevel,
string code)
@@ -138,5 +155,47 @@ public void Dispose()
}
}
+ ///
+ /// Test support type used by editor and play mode fixtures.
+ ///
+ private sealed class FakeShutdownAwareRuntime : IShutdownAwareDynamicCodeExecutionRuntime, System.IDisposable
+ {
+ private readonly TaskCompletionSource _shutdownCompletionSource = new(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ public int ShutdownCallCount { get; private set; }
+
+ public int DisposeCallCount { get; private set; }
+
+ public Task ExecuteAsync(
+ DynamicCodeExecutionRequest request,
+ CancellationToken cancellationToken = default)
+ {
+ throw new System.NotSupportedException();
+ }
+
+ public Task<(bool Entered, ExecutionResult Result)> TryExecuteIfIdleAsync(
+ DynamicCodeExecutionRequest request,
+ CancellationToken cancellationToken = default)
+ {
+ throw new System.NotSupportedException();
+ }
+
+ public Task ShutdownAsync()
+ {
+ ShutdownCallCount++;
+ return _shutdownCompletionSource.Task;
+ }
+
+ public void CompleteShutdown()
+ {
+ _shutdownCompletionSource.SetResult(true);
+ }
+
+ public void Dispose()
+ {
+ DisposeCallCount++;
+ }
+ }
+
}
}
diff --git a/Assets/Tests/Editor/DynamicCodeToolTests/ExternalCompilerPathResolverTests.cs b/Assets/Tests/Editor/DynamicCodeToolTests/ExternalCompilerPathResolverTests.cs
index e2c34cb17..cdcd0beed 100644
--- a/Assets/Tests/Editor/DynamicCodeToolTests/ExternalCompilerPathResolverTests.cs
+++ b/Assets/Tests/Editor/DynamicCodeToolTests/ExternalCompilerPathResolverTests.cs
@@ -73,6 +73,7 @@ public void ResolveScriptingRootPath_WhenLegacyLayoutExists_ShouldReturnContents
string contentsPath = CreateDirectory("Contents");
CreateDirectory(Path.Combine("Contents", "NetCoreRuntime"));
CreateDirectory(Path.Combine("Contents", "DotNetSdkRoslyn"));
+ CreateFile(Path.Combine("Contents", "DotNetSdkRoslyn", "csc.dll"));
string resolvedScriptingRootPath = ExternalCompilerPathResolver.ResolveScriptingRootPath(contentsPath);
@@ -82,10 +83,26 @@ public void ResolveScriptingRootPath_WhenLegacyLayoutExists_ShouldReturnContents
[Test]
public void ResolveScriptingRootPath_WhenResourcesScriptingLayoutExists_ShouldReturnResourcesScriptingPath()
{
+ // Verifies Unity's Resources/Scripting compiler layout is preferred when present.
string contentsPath = CreateDirectory("Contents");
string expectedScriptingRootPath = CreateDirectory(Path.Combine("Contents", "Resources", "Scripting"));
CreateDirectory(Path.Combine("Contents", "Resources", "Scripting", "NetCoreRuntime"));
CreateDirectory(Path.Combine("Contents", "Resources", "Scripting", "DotNetSdkRoslyn"));
+ CreateFile(Path.Combine("Contents", "Resources", "Scripting", "DotNetSdkRoslyn", "csc.dll"));
+
+ string resolvedScriptingRootPath = ExternalCompilerPathResolver.ResolveScriptingRootPath(contentsPath);
+
+ Assert.That(resolvedScriptingRootPath, Is.EqualTo(expectedScriptingRootPath));
+ }
+
+ [Test]
+ public void ResolveScriptingRootPath_WhenResourcesScriptingDotNetSdkLayoutExists_ShouldReturnResourcesScriptingPath()
+ {
+ // Verifies Unity 6.5 DotNetSdk compiler layouts are accepted under Resources/Scripting.
+ string contentsPath = CreateDirectory("Contents");
+ string expectedScriptingRootPath = CreateDirectory(Path.Combine("Contents", "Resources", "Scripting"));
+ CreateDirectory(Path.Combine("Contents", "Resources", "Scripting", "NetCoreRuntime"));
+ CreateDirectory(Path.Combine("Contents", "Resources", "Scripting", "DotNetSdk", "sdk", "8.0.318", "Roslyn", "bincore"));
string resolvedScriptingRootPath = ExternalCompilerPathResolver.ResolveScriptingRootPath(contentsPath);
@@ -95,12 +112,15 @@ public void ResolveScriptingRootPath_WhenResourcesScriptingLayoutExists_ShouldRe
[Test]
public void ResolveScriptingRootPath_WhenBothLayoutsExist_ShouldPreferResourcesScriptingLayout()
{
+ // Verifies the current Resources/Scripting layout wins over the legacy contents-root layout.
string contentsPath = CreateDirectory("Contents");
CreateDirectory(Path.Combine("Contents", "NetCoreRuntime"));
CreateDirectory(Path.Combine("Contents", "DotNetSdkRoslyn"));
+ CreateFile(Path.Combine("Contents", "DotNetSdkRoslyn", "csc.dll"));
string expectedScriptingRootPath = CreateDirectory(Path.Combine("Contents", "Resources", "Scripting"));
CreateDirectory(Path.Combine("Contents", "Resources", "Scripting", "NetCoreRuntime"));
CreateDirectory(Path.Combine("Contents", "Resources", "Scripting", "DotNetSdkRoslyn"));
+ CreateFile(Path.Combine("Contents", "Resources", "Scripting", "DotNetSdkRoslyn", "csc.dll"));
string resolvedScriptingRootPath = ExternalCompilerPathResolver.ResolveScriptingRootPath(contentsPath);
@@ -114,17 +134,66 @@ public void ResolveScriptingRootPath_WhenKnownLayoutsAreMissing_ShouldDiscoverNe
string expectedScriptingRootPath = CreateDirectory(Path.Combine("Contents", "PlaybackEngines", "Custom", "Scripting"));
CreateDirectory(Path.Combine("Contents", "PlaybackEngines", "Custom", "Scripting", "NetCoreRuntime"));
CreateDirectory(Path.Combine("Contents", "PlaybackEngines", "Custom", "Scripting", "DotNetSdkRoslyn"));
+ CreateFile(Path.Combine("Contents", "PlaybackEngines", "Custom", "Scripting", "DotNetSdkRoslyn", "csc.dll"));
string resolvedScriptingRootPath = ExternalCompilerPathResolver.ResolveScriptingRootPath(contentsPath);
Assert.That(resolvedScriptingRootPath, Is.EqualTo(expectedScriptingRootPath));
}
+ [Test]
+ public void ResolveCompilerDirectoryPath_WhenLegacyLayoutExists_ShouldReturnDotNetSdkRoslynPath()
+ {
+ // Verifies legacy compiler roots keep resolving to DotNetSdkRoslyn.
+ string scriptingRootPath = CreateDirectory("Scripting");
+ string expectedCompilerDirectoryPath = CreateDirectory(Path.Combine("Scripting", "DotNetSdkRoslyn"));
+ CreateFile(Path.Combine("Scripting", "DotNetSdkRoslyn", "csc.dll"));
+
+ string resolvedCompilerDirectoryPath = ExternalCompilerPathResolver.ResolveCompilerDirectoryPath(scriptingRootPath);
+
+ Assert.That(resolvedCompilerDirectoryPath, Is.EqualTo(expectedCompilerDirectoryPath));
+ }
+
+ [Test]
+ public void ResolveCompilerDirectoryPath_WhenLegacyLayoutIsIncomplete_ShouldUseDotNetSdkLayout()
+ {
+ // Verifies stale legacy compiler roots fall back to the versioned DotNetSdk layout.
+ string scriptingRootPath = CreateDirectory("Scripting");
+ CreateDirectory(Path.Combine("Scripting", "DotNetSdkRoslyn"));
+ string expectedCompilerDirectoryPath = CreateDirectory(Path.Combine("Scripting", "DotNetSdk", "sdk", "8.0.318", "Roslyn", "bincore"));
+
+ string resolvedCompilerDirectoryPath = ExternalCompilerPathResolver.ResolveCompilerDirectoryPath(scriptingRootPath);
+
+ Assert.That(resolvedCompilerDirectoryPath, Is.EqualTo(expectedCompilerDirectoryPath));
+ }
+
+ [Test]
+ public void ResolveCompilerDirectoryPath_WhenDotNetSdkLayoutHasMultipleSdkVersions_ShouldChooseHighestSdkRoslynBincorePath()
+ {
+ // Verifies Unity 6.5 SDK layouts choose the newest versioned Roslyn compiler directory.
+ string scriptingRootPath = CreateDirectory("Scripting");
+ CreateDirectory(Path.Combine("Scripting", "DotNetSdk", "sdk", "8.0.100", "Roslyn", "bincore"));
+ string expectedCompilerDirectoryPath = CreateDirectory(Path.Combine("Scripting", "DotNetSdk", "sdk", "8.0.318", "Roslyn", "bincore"));
+ CreateDirectory(Path.Combine("Scripting", "DotNetSdk", "sdk", "current", "Roslyn", "bincore"));
+
+ string resolvedCompilerDirectoryPath = ExternalCompilerPathResolver.ResolveCompilerDirectoryPath(scriptingRootPath);
+
+ Assert.That(resolvedCompilerDirectoryPath, Is.EqualTo(expectedCompilerDirectoryPath));
+ }
+
private string CreateDirectory(string relativePath)
{
string directoryPath = Path.Combine(_tempDirectoryPath, relativePath);
Directory.CreateDirectory(directoryPath);
return directoryPath;
}
+
+ private string CreateFile(string relativePath)
+ {
+ string filePath = Path.Combine(_tempDirectoryPath, relativePath);
+ Directory.CreateDirectory(Path.GetDirectoryName(filePath));
+ File.WriteAllText(filePath, string.Empty);
+ return filePath;
+ }
}
}
diff --git a/Assets/Tests/Editor/FindGameObjectsToolTests.cs b/Assets/Tests/Editor/FindGameObjectsToolTests.cs
index a13e904ce..84d00a8f8 100644
--- a/Assets/Tests/Editor/FindGameObjectsToolTests.cs
+++ b/Assets/Tests/Editor/FindGameObjectsToolTests.cs
@@ -1,3 +1,4 @@
+using System.Globalization;
using System.IO;
using System.Threading.Tasks;
using NUnit.Framework;
@@ -640,11 +641,13 @@ public async Task ExecuteAsync_ReturnsObjectReferenceProperties()
Assert.That(probeAnchor, Is.Not.Null, "MeshRenderer should have Probe Anchor property");
Assert.That(probeAnchor.type, Is.EqualTo("ObjectReference"));
- // Value should be a structured object with name, type, instanceId
+ string expectedEntityId = GetExpectedObjectId(anchorTarget.transform);
+
+ // Value should be a structured object with name, type, entityId
JObject valueObj = JObject.FromObject(probeAnchor.value);
Assert.That(valueObj["name"].ToString(), Is.EqualTo("AnchorTarget"));
Assert.That(valueObj["type"].ToString(), Is.EqualTo("Transform"));
- Assert.That(valueObj["instanceId"].Value(), Is.EqualTo(anchorTarget.transform.GetInstanceID()));
+ Assert.That(valueObj["entityId"].ToString(), Is.EqualTo(expectedEntityId));
}
finally
{
@@ -682,7 +685,19 @@ public async Task ExecuteAsync_ReturnsNoneForUnsetObjectReference()
JObject valueObj = JObject.FromObject(probeAnchor.value);
Assert.That(valueObj["name"].ToString(), Is.EqualTo("None"));
Assert.That(valueObj["type"].ToString(), Is.EqualTo("None"));
- Assert.That(valueObj["instanceId"].Value(), Is.EqualTo(0));
+ Assert.That(valueObj["entityId"].ToString(), Is.EqualTo("0"));
+ }
+
+ private static string GetExpectedObjectId(Object obj)
+ {
+ UnityEngine.Debug.Assert(obj != null, "Unity Object must exist before reading its identifier.");
+
+#if UNITY_6000_4_OR_NEWER
+ return obj.GetEntityId().ToString();
+#else
+ int instanceId = obj.GetInstanceID();
+ return instanceId.ToString(CultureInfo.InvariantCulture);
+#endif
}
[Test]
diff --git a/Assets/Tests/Editor/HierarchySerializerTests.cs b/Assets/Tests/Editor/HierarchySerializerTests.cs
index d3b3143e5..e411ba19a 100644
--- a/Assets/Tests/Editor/HierarchySerializerTests.cs
+++ b/Assets/Tests/Editor/HierarchySerializerTests.cs
@@ -24,8 +24,8 @@ public void BuildGroups_WithValidNodes_ReturnsCorrectGroups()
// Arrange
List nodes = new()
{
- new(1, "Root", null, 0, true, new[] { "Transform" }, "SceneA"),
- new(2, "Child", 1, 1, true, new[] { "Transform", "MeshRenderer" }, "SceneA")
+ new("1", "Root", null, 0, true, new[] { "Transform" }, "SceneA"),
+ new("2", "Child", "1", 1, true, new[] { "Transform", "MeshRenderer" }, "SceneA")
};
HierarchyContext context = new("editor", "TestScene", 0, 0);
@@ -77,10 +77,10 @@ public void BuildGroups_CalculatesCorrectMaxDepth()
// Arrange
List nodes = new()
{
- new(1, "Root", null, 0, true, new string[0]),
- new(2, "Level1", 1, 1, true, new string[0]),
- new(3, "Level2", 2, 2, true, new string[0]),
- new(4, "Level3", 3, 3, true, new string[0])
+ new("1", "Root", null, 0, true, new string[0]),
+ new("2", "Level1", "1", 1, true, new string[0]),
+ new("3", "Level2", "2", 2, true, new string[0]),
+ new("4", "Level3", "3", 3, true, new string[0])
};
HierarchyContext context = new("editor", "DeepScene", 0, 0);
@@ -92,4 +92,4 @@ public void BuildGroups_CalculatesCorrectMaxDepth()
Assert.That(result.Context.maxDepth, Is.EqualTo(3));
}
}
-}
\ No newline at end of file
+}
diff --git a/Assets/Tests/Editor/HierarchyServiceTests.cs b/Assets/Tests/Editor/HierarchyServiceTests.cs
index b69720614..293a8324d 100644
--- a/Assets/Tests/Editor/HierarchyServiceTests.cs
+++ b/Assets/Tests/Editor/HierarchyServiceTests.cs
@@ -133,6 +133,22 @@ public void GetHierarchyNodes_WithComponents_IncludesComponentNames()
Assert.That(rootNode.components, Contains.Item("Rigidbody"));
Assert.That(rootNode.components, Contains.Item("Transform"));
}
+
+#if UNITY_6000_4_OR_NEWER
+ [Test]
+ public void GetHierarchyNodes_WithEntityId_UsesUnityEntityIdString()
+ {
+ // Verifies Unity 6.0.4+ hierarchy IDs use the EntityId public string representation.
+ HierarchyOptions options = new();
+ string expectedObjectId = testRoot.GetEntityId().ToString();
+
+ List nodes = service.GetHierarchyNodes(options);
+
+ HierarchyNode rootNode = nodes.Find(n => n.name == testRoot.name);
+ Assert.That(rootNode, Is.Not.Null);
+ Assert.That(rootNode.id, Is.EqualTo(expectedObjectId));
+ }
+#endif
[Test]
public void GetHierarchyNodes_WithRootPathIncludingRootName_ReturnsChild()
@@ -219,4 +235,4 @@ public void GetHierarchyNodes_WithUseSelectionAndParentChildSelection_FiltersDes
Assert.That(childNode.parent, Is.EqualTo(rootNode.id), "Child should be traversed as descendant of root, not as separate root");
}
}
-}
\ No newline at end of file
+}
diff --git a/Assets/Tests/Editor/OnionAssemblyDependencyTests.cs b/Assets/Tests/Editor/OnionAssemblyDependencyTests.cs
index 787490ed5..57eb54f14 100644
--- a/Assets/Tests/Editor/OnionAssemblyDependencyTests.cs
+++ b/Assets/Tests/Editor/OnionAssemblyDependencyTests.cs
@@ -93,6 +93,35 @@ public void CliConstants_WhenLoaded_CompileUnderDomainAssembly()
Assert.That(constantsAssemblyName, Is.EqualTo(DomainAssemblyName));
}
+ [Test]
+ public void RuntimeAsmdef_WhenLoaded_TargetsEditorOnly()
+ {
+ // Tests that runtime overlay code remains excluded from player builds.
+ string[] includePlatforms = ReadIncludePlatforms("Packages/src/Runtime/uLoopMCP.Runtime.asmdef");
+
+ Assert.That(includePlatforms, Is.EquivalentTo(new[] { "Editor" }));
+ }
+
+ [Test]
+ public void ProjectAsmdefsReferencingEditorOnlyPackageAssemblies_WhenLoaded_TargetEditorOnly()
+ {
+ // Tests that assemblies depending on package editor code stay out of player builds.
+ HashSet editorOnlyPackageAssemblyNames = ReadProductionPackageAsmdefPaths()
+ .Where(IsEditorOnlyAsmdef)
+ .Select(ReadAsmdefName)
+ .ToHashSet(StringComparer.Ordinal);
+
+ string[] offendingAssemblyNames = ReadProjectAsmdefPaths()
+ .Where(path => !IsUnityIncludeTestsAsmdef(path))
+ .Where(path => !IsEditorOnlyAsmdef(path))
+ .Where(path => ReadResolvedReferencesFromAbsolutePath(path).Any(editorOnlyPackageAssemblyNames.Contains))
+ .Select(ReadAsmdefName)
+ .OrderBy(assemblyName => assemblyName)
+ .ToArray();
+
+ Assert.That(offendingAssemblyNames, Is.Empty);
+ }
+
[Test]
public void CompilationDiagnosticMessageParser_WhenLoaded_CompilesUnderDomainAssembly()
{
@@ -1187,12 +1216,43 @@ private static string[] ReadRawReferences(string asmdefPath)
return asmdef["references"]?.Values().ToArray() ?? new string[0];
}
+ private static string[] ReadIncludePlatforms(string relativeAsmdefPath)
+ {
+ string asmdefPath = Path.Combine(UnityCliLoopPathResolver.GetProjectRoot(), relativeAsmdefPath);
+ return ReadIncludePlatformsFromAbsolutePath(asmdefPath);
+ }
+
+ private static string[] ReadIncludePlatformsFromAbsolutePath(string asmdefPath)
+ {
+ JObject asmdef = JObject.Parse(File.ReadAllText(asmdefPath));
+ return asmdef["includePlatforms"]?.Values().ToArray() ?? new string[0];
+ }
+
+ private static bool IsEditorOnlyAsmdef(string asmdefPath)
+ {
+ string[] includePlatforms = ReadIncludePlatformsFromAbsolutePath(asmdefPath);
+ return includePlatforms.SequenceEqual(new[] { "Editor" });
+ }
+
+ private static bool IsUnityIncludeTestsAsmdef(string asmdefPath)
+ {
+ JObject asmdef = JObject.Parse(File.ReadAllText(asmdefPath));
+ string[] defineConstraints = asmdef["defineConstraints"]?.Values().ToArray() ?? new string[0];
+ return defineConstraints.Contains("UNITY_INCLUDE_TESTS");
+ }
+
private static string[] ReadProductionAsmdefPaths()
{
string editorRoot = Path.Combine(UnityCliLoopPathResolver.GetProjectRoot(), "Packages", "src", "Editor");
return Directory.GetFiles(editorRoot, "*.asmdef", SearchOption.AllDirectories);
}
+ private static string[] ReadProductionPackageAsmdefPaths()
+ {
+ string packageSourceRoot = Path.Combine(UnityCliLoopPathResolver.GetProjectRoot(), "Packages", "src");
+ return Directory.GetFiles(packageSourceRoot, "*.asmdef", SearchOption.AllDirectories);
+ }
+
private static string[] ReadPresentationSourcePaths()
{
string presentationRoot = Path.Combine(UnityCliLoopPathResolver.GetProjectRoot(), "Packages", "src", "Editor", "Presentation");
diff --git a/Assets/Tests/Editor/UnityCliLoopServerControllerStartupLockTests.cs b/Assets/Tests/Editor/UnityCliLoopServerControllerStartupLockTests.cs
index 81b66dd49..1582ef3b9 100644
--- a/Assets/Tests/Editor/UnityCliLoopServerControllerStartupLockTests.cs
+++ b/Assets/Tests/Editor/UnityCliLoopServerControllerStartupLockTests.cs
@@ -103,7 +103,8 @@ public async Task StartRecoveryIfNeededAsync_WhenReadinessSucceeds_ShouldPublish
new DomainReloadDetectionFileService(editorSettingsService, stateStore),
editorSettingsService,
stateStore,
- readinessProbe);
+ readinessProbe,
+ new TestDomainReloadLifecycle());
await service.StartRecoveryIfNeededAsync(isAfterCompile: false, CancellationToken.None);
@@ -155,7 +156,8 @@ private static UnityCliLoopServerControllerService CreateControllerService(TestR
new DomainReloadDetectionFileService(editorSettingsService, stateStore),
editorSettingsService,
stateStore,
- readinessProbe);
+ readinessProbe,
+ new TestDomainReloadLifecycle());
}
private static ServerReadinessStateStore CreateTestStateStore()
@@ -193,6 +195,16 @@ public Task ProbeAsync(CancellationToken ct)
}
}
+ ///
+ /// Test support type that keeps domain reload lifecycle behavior side-effect free.
+ ///
+ private sealed class TestDomainReloadLifecycle : IUnityCliLoopServerDomainReloadLifecycle
+ {
+ public void PrepareForDomainReload()
+ {
+ }
+ }
+
///
/// Test support type used by editor and play mode fixtures.
///
diff --git a/Assets/Tests/Editor/UnityCliLoopServerStartupProtectionTests.cs b/Assets/Tests/Editor/UnityCliLoopServerStartupProtectionTests.cs
index 32e691963..00f52a6bd 100644
--- a/Assets/Tests/Editor/UnityCliLoopServerStartupProtectionTests.cs
+++ b/Assets/Tests/Editor/UnityCliLoopServerStartupProtectionTests.cs
@@ -57,6 +57,18 @@ public void OnBeforeAssemblyReload_ShouldClearStartupProtectionBeforeRecovery()
}
}
+ [Test]
+ public void OnBeforeAssemblyReload_ShouldPrepareDomainReloadLifecycle()
+ {
+ // Tests that bundled server-scoped services are reset through the domain-reload lifecycle hook.
+ TestDomainReloadLifecycle domainReloadLifecycle = new();
+ UnityCliLoopServerControllerService service = CreateControllerService(domainReloadLifecycle);
+
+ service.OnBeforeAssemblyReload();
+
+ Assert.That(domainReloadLifecycle.PrepareCallCount, Is.EqualTo(1));
+ }
+
[Test]
public void PrepareForServerShutdown_ShouldClearStartupProtectionBeforeShutdown()
{
@@ -82,6 +94,11 @@ private static UnityCliLoopEditorSettingsData CloneSettings(UnityCliLoopEditorSe
}
private static UnityCliLoopServerControllerService CreateControllerService()
+ {
+ return CreateControllerService(new TestDomainReloadLifecycle());
+ }
+
+ private static UnityCliLoopServerControllerService CreateControllerService(TestDomainReloadLifecycle domainReloadLifecycle)
{
TestServerInstanceFactory serverInstanceFactory = new();
UnityCliLoopServerLifecycleRegistryService lifecycleRegistry =
@@ -95,7 +112,8 @@ private static UnityCliLoopServerControllerService CreateControllerService()
new DomainReloadDetectionFileService(editorSettingsService, stateStore),
editorSettingsService,
stateStore,
- new TestReadinessProbe());
+ new TestReadinessProbe(),
+ domainReloadLifecycle);
}
private static ServerReadinessStateStore CreateTestStateStore()
@@ -118,6 +136,19 @@ public System.Threading.Tasks.Task ProbeAsync(System.Threading.CancellationToken
}
}
+ ///
+ /// Test support type that records domain reload lifecycle calls.
+ ///
+ private sealed class TestDomainReloadLifecycle : IUnityCliLoopServerDomainReloadLifecycle
+ {
+ public int PrepareCallCount { get; private set; }
+
+ public void PrepareForDomainReload()
+ {
+ PrepareCallCount++;
+ }
+ }
+
///
/// Test support type used by editor and play mode fixtures.
///
diff --git a/Packages/src/Cli~/dist/darwin-amd64/uloop b/Packages/src/Cli~/dist/darwin-amd64/uloop
index 2f8683356..f654257f6 100755
Binary files a/Packages/src/Cli~/dist/darwin-amd64/uloop and b/Packages/src/Cli~/dist/darwin-amd64/uloop differ
diff --git a/Packages/src/Cli~/dist/darwin-arm64/uloop b/Packages/src/Cli~/dist/darwin-arm64/uloop
index c4e54f596..2127ab6fa 100755
Binary files a/Packages/src/Cli~/dist/darwin-arm64/uloop and b/Packages/src/Cli~/dist/darwin-arm64/uloop differ
diff --git a/Packages/src/Cli~/dist/windows-amd64/uloop.exe b/Packages/src/Cli~/dist/windows-amd64/uloop.exe
index 81e2ae427..3262275cb 100755
Binary files a/Packages/src/Cli~/dist/windows-amd64/uloop.exe and b/Packages/src/Cli~/dist/windows-amd64/uloop.exe differ
diff --git a/Packages/src/Cli~/internal/cli/skills.go b/Packages/src/Cli~/internal/cli/skills.go
index d5834b3c4..98a0084a2 100644
--- a/Packages/src/Cli~/internal/cli/skills.go
+++ b/Packages/src/Cli~/internal/cli/skills.go
@@ -11,15 +11,16 @@ import (
)
const (
- skillsCommandName = "skills"
- managedSkillsDir = "unity-cli-loop"
- skillFileName = "SKILL.md"
- uloopSettingsDir = ".uloop"
- toolSettingsFile = "settings.tools.json"
- manifestFileName = "manifest.json"
- packageName = "io.github.hatayama.uloopmcp"
- packageNameAlias = "io.github.hatayama.uLoopMCP"
- skillSearchMaxDepth = 3
+ skillsCommandName = "skills"
+ managedSkillsDir = "unity-cli-loop"
+ skillFileName = "SKILL.md"
+ uloopSettingsDir = ".uloop"
+ toolSettingsFile = "settings.tools.json"
+ manifestFileName = "manifest.json"
+ packageName = "io.github.hatayama.uloopmcp"
+ packageNameAlias = "io.github.hatayama.uLoopMCP"
+ skillSearchMaxDepth = 3
+ groupSkillsByDefault = false
utf16LittleEndianBOMFirstByte = 0xff
utf16LittleEndianBOMSecondByte = 0xfe
@@ -185,6 +186,13 @@ func isKnownSkillsSubcommand(subcommand string) bool {
}
}
+func groupManagedSkillsForOptions(options skillCommandOptions) bool {
+ if options.flat {
+ return false
+ }
+ return groupSkillsByDefault
+}
+
func unknownSkillsSubcommandError(subcommand string, context errorContext) cliError {
return (&argumentError{
message: "Unknown skills command: " + subcommand,
@@ -239,7 +247,7 @@ func runSkillsList(projectRoot string, skills []skillDefinition, options skillCo
writeFormat(stdout, "Location: %s\n", baseDir)
writeLine(stdout, strings.Repeat("=", 50))
for _, skill := range skills {
- status, err := getSkillStatus(baseDir, skill, !options.flat)
+ status, err := getSkillStatus(baseDir, skill, groupManagedSkillsForOptions(options))
if err != nil {
writeClassifiedError(stderr, err, errorContext{projectRoot: projectRoot, command: skillsCommandName})
return 1
@@ -257,7 +265,7 @@ func runSkillsInstall(projectRoot string, skills []skillDefinition, options skil
writeFormat(stdout, "Installing uloop skills (%s)...\n", skillLocationName(options.global))
writeLine(stdout, "")
for _, target := range options.targets {
- result, err := installSkillsForTarget(projectRoot, target, skills, options.global, !options.flat)
+ result, err := installSkillsForTarget(projectRoot, target, skills, options.global, groupManagedSkillsForOptions(options))
if err != nil {
writeClassifiedError(stderr, err, errorContext{projectRoot: projectRoot, command: skillsCommandName})
return 1
@@ -284,7 +292,8 @@ func runSkillsUninstall(projectRoot string, skills []skillDefinition, options sk
writeFormat(stdout, "Uninstalling uloop skills (%s)...\n", skillLocationName(options.global))
writeLine(stdout, "")
for _, target := range options.targets {
- removed, notFound, err := uninstallSkillsForTarget(projectRoot, target, skills, options.global, !options.flat)
+ grouped := groupManagedSkillsForOptions(options)
+ removed, notFound, err := uninstallSkillsForTarget(projectRoot, target, skills, options.global, grouped)
if err != nil {
writeClassifiedError(stderr, err, errorContext{projectRoot: projectRoot, command: skillsCommandName})
return 1
diff --git a/Packages/src/Cli~/internal/cli/skills_targets.go b/Packages/src/Cli~/internal/cli/skills_targets.go
index 520f4f302..cd807bf73 100644
--- a/Packages/src/Cli~/internal/cli/skills_targets.go
+++ b/Packages/src/Cli~/internal/cli/skills_targets.go
@@ -87,12 +87,22 @@ func removeDeprecatedSkillDir(baseDir string, skillName string, grouped bool) (b
}
func removeSkillFromAllLayouts(baseDir string, skillName string) error {
+ _, err := removeSkillFromAllLayoutsIfExists(baseDir, skillName)
+ return err
+}
+
+func removeSkillFromAllLayoutsIfExists(baseDir string, skillName string) (bool, error) {
+ removed := false
for _, grouped := range []bool{true, false} {
- if _, err := removeDirIfExists(getPreferredSkillDir(baseDir, skillName, grouped)); err != nil {
- return err
+ exists, err := removeDirIfExists(getPreferredSkillDir(baseDir, skillName, grouped))
+ if err != nil {
+ return removed, err
+ }
+ if exists {
+ removed = true
}
}
- return nil
+ return removed, nil
}
func removeDirIfExists(path string) (bool, error) {
diff --git a/Packages/src/Cli~/internal/cli/skills_test.go b/Packages/src/Cli~/internal/cli/skills_test.go
index 1155a52a2..916c04695 100644
--- a/Packages/src/Cli~/internal/cli/skills_test.go
+++ b/Packages/src/Cli~/internal/cli/skills_test.go
@@ -507,6 +507,112 @@ name: uloop-sample
}
}
+// Tests that the public install command writes skills to the flat layout by default.
+func TestRunSkillsInstallDefaultsToFlatLayout(t *testing.T) {
+ projectRoot := t.TempDir()
+ sourceDir := filepath.Join(projectRoot, "source", "Skill")
+ writeSkillFile(t, sourceDir, `---
+name: uloop-sample
+---
+
+# sample
+`)
+
+ skill := skillDefinition{
+ name: "uloop-sample",
+ content: []byte("---\nname: uloop-sample\n---\n\n# sample\n"),
+ sourceDirectory: sourceDir,
+ }
+ target := targetConfigs["claude"]
+ options := skillCommandOptions{
+ targets: []skillTarget{target},
+ }
+ stdout := &bytes.Buffer{}
+ stderr := &bytes.Buffer{}
+
+ code := runSkillsInstall(projectRoot, []skillDefinition{skill}, options, stdout, stderr)
+
+ if code != 0 {
+ t.Fatalf("install should succeed: code=%d stderr=%s", code, stderr.String())
+ }
+ baseDir, err := getSkillsBaseDir(projectRoot, target, false)
+ if err != nil {
+ t.Fatalf("getSkillsBaseDir failed: %v", err)
+ }
+ flatDir := getPreferredSkillDir(baseDir, skill.name, false)
+ groupedDir := getPreferredSkillDir(baseDir, skill.name, true)
+ if _, err := os.Stat(filepath.Join(flatDir, "SKILL.md")); err != nil {
+ t.Fatalf("flat skill should be installed: %v", err)
+ }
+ if _, err := os.Stat(groupedDir); err == nil {
+ t.Fatal("grouped skill should not be installed by default")
+ }
+}
+
+// Tests that the public list command reads the flat layout by default.
+func TestRunSkillsListDefaultsToFlatLayout(t *testing.T) {
+ projectRoot := t.TempDir()
+ skill := skillDefinition{
+ name: "uloop-sample",
+ content: []byte("---\nname: uloop-sample\n---\n\n# sample\n"),
+ }
+ target := targetConfigs["claude"]
+ baseDir, err := getSkillsBaseDir(projectRoot, target, false)
+ if err != nil {
+ t.Fatalf("getSkillsBaseDir failed: %v", err)
+ }
+ writeSkillFile(t, getPreferredSkillDir(baseDir, skill.name, false), string(skill.content))
+ options := skillCommandOptions{
+ targets: []skillTarget{target},
+ }
+ stdout := &bytes.Buffer{}
+ stderr := &bytes.Buffer{}
+
+ code := runSkillsList(projectRoot, []skillDefinition{skill}, options, stdout, stderr)
+
+ if code != 0 {
+ t.Fatalf("list should succeed: code=%d stderr=%s", code, stderr.String())
+ }
+ if !strings.Contains(stdout.String(), "uloop-sample (installed)") {
+ t.Fatalf("flat skill should be reported as installed: %s", stdout.String())
+ }
+}
+
+// Tests that the public uninstall command removes only the flat layout by default.
+func TestRunSkillsUninstallDefaultsToFlatLayout(t *testing.T) {
+ projectRoot := t.TempDir()
+ skill := skillDefinition{
+ name: "uloop-sample",
+ content: []byte("sample"),
+ }
+ target := targetConfigs["claude"]
+ baseDir, err := getSkillsBaseDir(projectRoot, target, false)
+ if err != nil {
+ t.Fatalf("getSkillsBaseDir failed: %v", err)
+ }
+ flatDir := getPreferredSkillDir(baseDir, skill.name, false)
+ groupedDir := getPreferredSkillDir(baseDir, skill.name, true)
+ writeSkillFile(t, flatDir, "# flat\n")
+ writeSkillFile(t, groupedDir, "# grouped\n")
+ options := skillCommandOptions{
+ targets: []skillTarget{target},
+ }
+ stdout := &bytes.Buffer{}
+ stderr := &bytes.Buffer{}
+
+ code := runSkillsUninstall(projectRoot, []skillDefinition{skill}, options, stdout, stderr)
+
+ if code != 0 {
+ t.Fatalf("uninstall should succeed: code=%d stderr=%s", code, stderr.String())
+ }
+ if _, err := os.Stat(flatDir); err == nil {
+ t.Fatal("flat skill should be removed")
+ }
+ if _, err := os.Stat(groupedDir); err != nil {
+ t.Fatalf("grouped skill should remain: %v", err)
+ }
+}
+
// Tests that uninstalling uses only the selected layout and leaves the other layout intact.
func TestUninstallSkillsForTargetUsesSelectedLayoutOnly(t *testing.T) {
projectRoot := t.TempDir()
diff --git a/Packages/src/Editor/Application/UnityCliLoopServerApplicationService.cs b/Packages/src/Editor/Application/UnityCliLoopServerApplicationService.cs
index d74fd08e6..2d8de8428 100644
--- a/Packages/src/Editor/Application/UnityCliLoopServerApplicationService.cs
+++ b/Packages/src/Editor/Application/UnityCliLoopServerApplicationService.cs
@@ -38,6 +38,14 @@ public interface IUnityCliLoopServerLifecycleSource
event Action ServerLoopExited;
}
+ ///
+ /// Handles server-scoped cleanup that must happen before Unity tears down editor assemblies.
+ ///
+ public interface IUnityCliLoopServerDomainReloadLifecycle
+ {
+ void PrepareForDomainReload();
+ }
+
///
/// Defines the control operations needed for Unity CLI Loop Server behavior.
///
diff --git a/Packages/src/Editor/Application/UseCases/DomainReloadRecoveryUseCase.cs b/Packages/src/Editor/Application/UseCases/DomainReloadRecoveryUseCase.cs
index 7beea1d85..6b60207d7 100644
--- a/Packages/src/Editor/Application/UseCases/DomainReloadRecoveryUseCase.cs
+++ b/Packages/src/Editor/Application/UseCases/DomainReloadRecoveryUseCase.cs
@@ -71,7 +71,7 @@ public ServiceResult ExecuteBeforeDomainReload(IUnityCliLoopServerInstan
LogServerStoppingBeforeDomainReload(correlationId);
// 4.2. Stop server
- currentServer.Dispose();
+ currentServer.StopServer();
LogServerStoppedAfterDomainReload(correlationId);
diff --git a/Packages/src/Editor/CompositionRoot/UnityCliLoopApplicationRegistration.cs b/Packages/src/Editor/CompositionRoot/UnityCliLoopApplicationRegistration.cs
index 4cd630739..4dbfc2696 100644
--- a/Packages/src/Editor/CompositionRoot/UnityCliLoopApplicationRegistration.cs
+++ b/Packages/src/Editor/CompositionRoot/UnityCliLoopApplicationRegistration.cs
@@ -17,7 +17,7 @@ internal UnityCliLoopApplicationServices Register()
UnityCliLoopEditorSettingsRepository editorSettingsRepository = new();
UnityCliLoopEditorSettingsService editorSettingsService = new(editorSettingsRepository);
ServerReadinessStateStore serverReadinessStateStore = new(UnityCliLoopPathResolver.GetProjectRoot());
- UnityCliLoopFirstPartyServerLifecycleBinding serverReadinessProbe = new(new ProjectIpcWarmupClient());
+ UnityCliLoopFirstPartyServerLifecycleBinding firstPartyServerLifecycle = new(new ProjectIpcWarmupClient());
ULoopSettingsRepository uLoopSettingsRepository = new(
toolSettingsService,
editorSettingsService);
@@ -53,7 +53,8 @@ internal UnityCliLoopApplicationServices Register()
domainReloadDetectionService,
editorSettingsService,
serverReadinessStateStore,
- serverReadinessProbe);
+ firstPartyServerLifecycle,
+ firstPartyServerLifecycle);
UnityCliLoopServerApplicationService applicationService = new(controllerService);
UnityCliLoopServerApplicationFacade.RegisterService(applicationService);
controllerService.InitializeForEditorStartup();
diff --git a/Packages/src/Editor/CompositionRoot/UnityCliLoopFirstPartyServerLifecycleBinding.cs b/Packages/src/Editor/CompositionRoot/UnityCliLoopFirstPartyServerLifecycleBinding.cs
index 82705faf3..28a71868c 100644
--- a/Packages/src/Editor/CompositionRoot/UnityCliLoopFirstPartyServerLifecycleBinding.cs
+++ b/Packages/src/Editor/CompositionRoot/UnityCliLoopFirstPartyServerLifecycleBinding.cs
@@ -14,7 +14,9 @@ namespace io.github.hatayama.UnityCliLoop.CompositionRoot
///
/// Resets bundled tool lifecycle state and proves get-version IPC readiness before the server is published as ready.
///
- internal sealed class UnityCliLoopFirstPartyServerLifecycleBinding : IUnityCliLoopServerReadinessProbe
+ internal sealed class UnityCliLoopFirstPartyServerLifecycleBinding :
+ IUnityCliLoopServerReadinessProbe,
+ IUnityCliLoopServerDomainReloadLifecycle
{
private readonly ProjectIpcWarmupClient _projectIpcWarmupClient;
@@ -31,6 +33,11 @@ public Task ProbeAsync(CancellationToken ct)
return ResetServerScopedServicesAndWarmProjectIpcAsync(ct);
}
+ public void PrepareForDomainReload()
+ {
+ FirstPartyToolsEditorStartup.ResetServerScopedServicesBeforeDomainReload();
+ }
+
private async Task ResetServerScopedServicesAndWarmProjectIpcAsync(CancellationToken ct)
{
FirstPartyToolsEditorStartup.ResetServerScopedServices();
diff --git a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCodeServices.cs b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCodeServices.cs
index b604ebac1..d30334c92 100644
--- a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCodeServices.cs
+++ b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCodeServices.cs
@@ -57,6 +57,38 @@ internal void ResetServerScopedServices()
}
}
+ internal void ResetServerScopedServicesBeforeDomainReload()
+ {
+ IDynamicCodeExecutionRuntime runtimeFacade;
+
+ lock (_serverScopedServicesLock)
+ {
+ runtimeFacade = _runtimeFacade;
+ _runtimeFacade = null;
+ _serverScopedDrainTask = Task.CompletedTask;
+ }
+
+ SignalRuntimeShutdownBeforeDomainReload(runtimeFacade);
+ SharedRoslynCompilerWorkerHost.ShutdownForServerReset();
+ }
+
+ internal void SetRuntimeFacadeForTests(IDynamicCodeExecutionRuntime runtimeFacade)
+ {
+ lock (_serverScopedServicesLock)
+ {
+ _runtimeFacade = runtimeFacade;
+ _serverScopedDrainTask = Task.CompletedTask;
+ }
+ }
+
+ internal Task GetServerScopedDrainTaskForTests()
+ {
+ lock (_serverScopedServicesLock)
+ {
+ return _serverScopedDrainTask;
+ }
+ }
+
private IDynamicCodeExecutionRuntime GetRuntimeFacade()
{
lock (_serverScopedServicesLock)
@@ -88,6 +120,21 @@ private static Task ShutdownRuntimeAsync(IDynamicCodeExecutionRuntime runtimeFac
return Task.CompletedTask;
}
+ private static void SignalRuntimeShutdownBeforeDomainReload(IDynamicCodeExecutionRuntime runtimeFacade)
+ {
+ if (runtimeFacade is IShutdownAwareDynamicCodeExecutionRuntime shutdownAwareRuntime)
+ {
+ Task shutdownTask = shutdownAwareRuntime.ShutdownAsync();
+ _ = ObserveDrainTask(shutdownTask);
+ return;
+ }
+
+ if (runtimeFacade is IDisposable disposableRuntime)
+ {
+ disposableRuntime.Dispose();
+ }
+ }
+
private static Task ChainDrainTask(Task currentDrainTask, Task nextDrainTask)
{
Task observedCurrentDrainTask = ObserveDrainTask(currentDrainTask);
@@ -181,5 +228,10 @@ internal static void ResetServerScopedServices()
{
GetRegistry().ResetServerScopedServices();
}
+
+ internal static void ResetServerScopedServicesBeforeDomainReload()
+ {
+ GetRegistry().ResetServerScopedServicesBeforeDomainReload();
+ }
}
}
diff --git a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/ExternalCompilerPathResolver.cs b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/ExternalCompilerPathResolver.cs
index ec00c92f9..cb8787b2e 100644
--- a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/ExternalCompilerPathResolver.cs
+++ b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCompilation/ExternalCompilerPathResolver.cs
@@ -11,6 +11,20 @@ namespace io.github.hatayama.UnityCliLoop.FirstPartyTools
///
internal static class ExternalCompilerPathResolver
{
+ private const string NetCoreRuntimeDirectoryName = "NetCoreRuntime";
+ private const string DotNetSdkRoslynDirectoryName = "DotNetSdkRoslyn";
+ private const string DotNetSdkDirectoryName = "DotNetSdk";
+ private const string DotNetSdkSdkDirectoryName = "sdk";
+ private const string RoslynDirectoryName = "Roslyn";
+ private const string CompilerBincoreDirectoryName = "bincore";
+ private const string CompilerDllFileName = "csc.dll";
+ private const string CompilerRuntimeConfigFileName = "csc.runtimeconfig.json";
+ private const string CompilerDepsFileName = "csc.deps.json";
+ private const string CodeAnalysisDllFileName = "Microsoft.CodeAnalysis.dll";
+ private const string CodeAnalysisCSharpDllFileName = "Microsoft.CodeAnalysis.CSharp.dll";
+ private const string NetCoreRuntimeSharedDirectoryName = "shared";
+ private const string NetCoreRuntimeSharedFrameworkName = "Microsoft.NETCore.App";
+
public static ExternalCompilerPaths Resolve()
{
string editorPath = EditorApplication.applicationPath;
@@ -29,23 +43,34 @@ public static ExternalCompilerPaths Resolve()
string dotnetHostFileName = UnityEngine.Application.platform == UnityEngine.RuntimePlatform.WindowsEditor
? "dotnet.exe"
: "dotnet";
+ string effectiveScriptingRootPath = scriptingRootPath ?? contentsPath;
+ string compilerDirectoryPath = ResolveCompilerDirectoryPath(effectiveScriptingRootPath);
List missingComponents = new();
if (string.IsNullOrEmpty(scriptingRootPath))
{
- missingComponents.Add(Path.Combine(contentsPath, "NetCoreRuntime"));
- missingComponents.Add(Path.Combine(contentsPath, "DotNetSdkRoslyn"));
- missingComponents.Add(Path.Combine(contentsPath, "Resources", "Scripting", "NetCoreRuntime"));
- missingComponents.Add(Path.Combine(contentsPath, "Resources", "Scripting", "DotNetSdkRoslyn"));
+ missingComponents.Add(Path.Combine(contentsPath, NetCoreRuntimeDirectoryName));
+ missingComponents.Add(Path.Combine(contentsPath, DotNetSdkRoslynDirectoryName));
+ missingComponents.Add(Path.Combine(contentsPath, DotNetSdkDirectoryName, DotNetSdkSdkDirectoryName, "*", RoslynDirectoryName, CompilerBincoreDirectoryName));
+ missingComponents.Add(Path.Combine(contentsPath, "Resources", "Scripting", NetCoreRuntimeDirectoryName));
+ missingComponents.Add(Path.Combine(contentsPath, "Resources", "Scripting", DotNetSdkRoslynDirectoryName));
+ missingComponents.Add(Path.Combine(contentsPath, "Resources", "Scripting", DotNetSdkDirectoryName, DotNetSdkSdkDirectoryName, "*", RoslynDirectoryName, CompilerBincoreDirectoryName));
+ }
+
+ if (string.IsNullOrEmpty(compilerDirectoryPath))
+ {
+ compilerDirectoryPath = Path.Combine(effectiveScriptingRootPath, DotNetSdkRoslynDirectoryName);
+ missingComponents.Add(compilerDirectoryPath);
+ missingComponents.Add(Path.Combine(effectiveScriptingRootPath, DotNetSdkDirectoryName, DotNetSdkSdkDirectoryName, "*", RoslynDirectoryName, CompilerBincoreDirectoryName));
}
- string dotnetHostPath = Path.Combine(scriptingRootPath ?? contentsPath, "NetCoreRuntime", dotnetHostFileName);
- string compilerDllPath = Path.Combine(scriptingRootPath ?? contentsPath, "DotNetSdkRoslyn", "csc.dll");
- string compilerRuntimeConfigPath = Path.Combine(scriptingRootPath ?? contentsPath, "DotNetSdkRoslyn", "csc.runtimeconfig.json");
- string compilerDepsFilePath = Path.Combine(scriptingRootPath ?? contentsPath, "DotNetSdkRoslyn", "csc.deps.json");
- string codeAnalysisDllPath = Path.Combine(scriptingRootPath ?? contentsPath, "DotNetSdkRoslyn", "Microsoft.CodeAnalysis.dll");
- string codeAnalysisCSharpDllPath = Path.Combine(scriptingRootPath ?? contentsPath, "DotNetSdkRoslyn", "Microsoft.CodeAnalysis.CSharp.dll");
- string netCoreRuntimeSharedRootPath = Path.Combine(scriptingRootPath ?? contentsPath, "NetCoreRuntime", "shared", "Microsoft.NETCore.App");
+ string dotnetHostPath = Path.Combine(effectiveScriptingRootPath, NetCoreRuntimeDirectoryName, dotnetHostFileName);
+ string compilerDllPath = Path.Combine(compilerDirectoryPath, CompilerDllFileName);
+ string compilerRuntimeConfigPath = Path.Combine(compilerDirectoryPath, CompilerRuntimeConfigFileName);
+ string compilerDepsFilePath = Path.Combine(compilerDirectoryPath, CompilerDepsFileName);
+ string codeAnalysisDllPath = Path.Combine(compilerDirectoryPath, CodeAnalysisDllFileName);
+ string codeAnalysisCSharpDllPath = Path.Combine(compilerDirectoryPath, CodeAnalysisCSharpDllFileName);
+ string netCoreRuntimeSharedRootPath = Path.Combine(effectiveScriptingRootPath, NetCoreRuntimeDirectoryName, NetCoreRuntimeSharedDirectoryName, NetCoreRuntimeSharedFrameworkName);
string netCoreRuntimeSharedDirectoryPath = ResolveNetCoreRuntimeSharedDirectoryPath(netCoreRuntimeSharedRootPath);
if (!File.Exists(dotnetHostPath))
@@ -162,8 +187,80 @@ internal static string ResolveNetCoreRuntimeSharedDirectoryPath(string netCoreRu
private static bool ContainsExternalCompilerLayout(string rootPath)
{
- return Directory.Exists(Path.Combine(rootPath, "NetCoreRuntime"))
- && Directory.Exists(Path.Combine(rootPath, "DotNetSdkRoslyn"));
+ return Directory.Exists(Path.Combine(rootPath, NetCoreRuntimeDirectoryName))
+ && !string.IsNullOrEmpty(ResolveCompilerDirectoryPath(rootPath));
+ }
+
+ internal static string ResolveCompilerDirectoryPath(string scriptingRootPath)
+ {
+ if (string.IsNullOrEmpty(scriptingRootPath))
+ {
+ return null;
+ }
+
+ string legacyCompilerDirectoryPath = Path.Combine(scriptingRootPath, DotNetSdkRoslynDirectoryName);
+ if (IsUsableCompilerDirectory(legacyCompilerDirectoryPath))
+ {
+ return legacyCompilerDirectoryPath;
+ }
+
+ return ResolveDotNetSdkCompilerDirectoryPath(scriptingRootPath);
+ }
+
+ private static bool IsUsableCompilerDirectory(string compilerDirectoryPath)
+ {
+ return Directory.Exists(compilerDirectoryPath)
+ && File.Exists(Path.Combine(compilerDirectoryPath, CompilerDllFileName));
+ }
+
+ private static string ResolveDotNetSdkCompilerDirectoryPath(string scriptingRootPath)
+ {
+ string sdkRootPath = Path.Combine(scriptingRootPath, DotNetSdkDirectoryName, DotNetSdkSdkDirectoryName);
+ if (!Directory.Exists(sdkRootPath))
+ {
+ return null;
+ }
+
+ List sdkDirectoryPaths = Directory.GetDirectories(sdkRootPath).ToList();
+ sdkDirectoryPaths.Sort(CompareSdkDirectoryPathsDescending);
+
+ foreach (string sdkDirectoryPath in sdkDirectoryPaths)
+ {
+ string compilerDirectoryPath = Path.Combine(sdkDirectoryPath, RoslynDirectoryName, CompilerBincoreDirectoryName);
+ if (Directory.Exists(compilerDirectoryPath))
+ {
+ return compilerDirectoryPath;
+ }
+ }
+
+ return null;
+ }
+
+ private static int CompareSdkDirectoryPathsDescending(string leftPath, string rightPath)
+ {
+ string leftVersionText = Path.GetFileName(leftPath);
+ string rightVersionText = Path.GetFileName(rightPath);
+ bool leftIsVersion = Version.TryParse(leftVersionText, out Version leftVersion);
+ bool rightIsVersion = Version.TryParse(rightVersionText, out Version rightVersion);
+
+ if (leftIsVersion && rightIsVersion)
+ {
+ int versionComparison = rightVersion.CompareTo(leftVersion);
+ if (versionComparison != 0)
+ {
+ return versionComparison;
+ }
+ }
+ else if (leftIsVersion)
+ {
+ return -1;
+ }
+ else if (rightIsVersion)
+ {
+ return 1;
+ }
+
+ return string.Compare(rightVersionText, leftVersionText, StringComparison.Ordinal);
}
private static string ResolveScriptingRootPathByScan(string contentsPath)
diff --git a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/ExecuteDynamicCodeEditorStartup.cs b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/ExecuteDynamicCodeEditorStartup.cs
index a2570c750..48f19353c 100644
--- a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/ExecuteDynamicCodeEditorStartup.cs
+++ b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/ExecuteDynamicCodeEditorStartup.cs
@@ -18,5 +18,10 @@ internal static void ResetServerScopedServices()
{
DynamicCodeServices.ResetServerScopedServices();
}
+
+ internal static void ResetServerScopedServicesBeforeDomainReload()
+ {
+ DynamicCodeServices.ResetServerScopedServicesBeforeDomainReload();
+ }
}
}
diff --git a/Packages/src/Editor/FirstPartyTools/FindGameObjects/GameObjectFinder/ComponentPropertySerializer.cs b/Packages/src/Editor/FirstPartyTools/FindGameObjects/GameObjectFinder/ComponentPropertySerializer.cs
index 72df09796..358296411 100644
--- a/Packages/src/Editor/FirstPartyTools/FindGameObjects/GameObjectFinder/ComponentPropertySerializer.cs
+++ b/Packages/src/Editor/FirstPartyTools/FindGameObjects/GameObjectFinder/ComponentPropertySerializer.cs
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
+using System.Globalization;
using System.Reflection;
using UnityEngine;
using UnityEditor;
@@ -84,21 +85,61 @@ private object GetSerializedPropertyValue(SerializedProperty property)
case SerializedPropertyType.LayerMask:
return property.intValue;
case SerializedPropertyType.ObjectReference:
- UnityEngine.Object obj = property.objectReferenceValue;
- if (obj == null)
- {
- // objectReferenceInstanceIDValue != 0 means a broken (Missing) reference
- if (property.objectReferenceInstanceIDValue != 0)
- {
- return new { name = "Missing", type = "Missing", instanceId = property.objectReferenceInstanceIDValue };
- }
- return new { name = "None", type = "None", instanceId = 0 };
- }
- return new { name = obj.name, type = obj.GetType().Name, instanceId = obj.GetInstanceID() };
+ 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
+ return property.objectReferenceEntityIdValue.ToString();
+#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
+ return obj.GetEntityId().ToString();
+#else
+ int instanceId = obj.GetInstanceID();
+ return instanceId.ToString(CultureInfo.InvariantCulture);
+#endif
+ }
private object SerializeValue(object value)
@@ -163,4 +204,4 @@ private object SerializeValue(object value)
return value;
}
}
-}
\ No newline at end of file
+}
diff --git a/Packages/src/Editor/FirstPartyTools/FirstPartyToolsEditorStartup.cs b/Packages/src/Editor/FirstPartyTools/FirstPartyToolsEditorStartup.cs
index d613ee74a..1a648b7ac 100644
--- a/Packages/src/Editor/FirstPartyTools/FirstPartyToolsEditorStartup.cs
+++ b/Packages/src/Editor/FirstPartyTools/FirstPartyToolsEditorStartup.cs
@@ -24,6 +24,11 @@ public static void ResetServerScopedServices()
ExecuteDynamicCodeEditorStartup.ResetServerScopedServices();
}
+ public static void ResetServerScopedServicesBeforeDomainReload()
+ {
+ ExecuteDynamicCodeEditorStartup.ResetServerScopedServicesBeforeDomainReload();
+ }
+
public static string CreateExecuteDynamicCodeReadinessProbeCode()
{
// Why: composition root can only depend on the bundled-tool facade assembly,
diff --git a/Packages/src/Editor/FirstPartyTools/GetHierarchy/HierarchyAnalyzer/HierarchyNode.cs b/Packages/src/Editor/FirstPartyTools/GetHierarchy/HierarchyAnalyzer/HierarchyNode.cs
index 0854de6c2..5455a1330 100644
--- a/Packages/src/Editor/FirstPartyTools/GetHierarchy/HierarchyAnalyzer/HierarchyNode.cs
+++ b/Packages/src/Editor/FirstPartyTools/GetHierarchy/HierarchyAnalyzer/HierarchyNode.cs
@@ -10,9 +10,9 @@ namespace io.github.hatayama.UnityCliLoop.FirstPartyTools
public class HierarchyNode
{
///
- /// Unity's GetInstanceID() value - unique within session
+ /// Session-unique Unity object identifier
///
- public readonly int id;
+ public readonly string id;
///
/// GameObject name
@@ -20,9 +20,9 @@ public class HierarchyNode
public readonly string name;
///
- /// Parent node's instance ID (null for root objects)
+ /// Parent node's session-unique Unity object identifier (null for root objects)
///
- public readonly int? parent;
+ public readonly string parent;
///
/// Depth level in hierarchy (0 for root)
@@ -52,9 +52,9 @@ public class HierarchyNode
///
/// Constructor for HierarchyNode
///
- public HierarchyNode(int id, string name, int? parent, int depth, bool isActive, string[] components, string sceneName = "", int? siblingIndex = null, string tag = null, int? layer = null)
+ 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;
+ this.id = id ?? string.Empty;
this.name = name ?? string.Empty;
this.parent = parent;
this.depth = depth;
@@ -66,4 +66,4 @@ public HierarchyNode(int id, string name, int? parent, int depth, bool isActive,
this.layer = layer;
}
}
-}
\ No newline at end of file
+}
diff --git a/Packages/src/Editor/FirstPartyTools/GetHierarchy/HierarchyAnalyzer/HierarchySerializer.cs b/Packages/src/Editor/FirstPartyTools/GetHierarchy/HierarchyAnalyzer/HierarchySerializer.cs
index 42bc97d7a..3ac662151 100644
--- a/Packages/src/Editor/FirstPartyTools/GetHierarchy/HierarchyAnalyzer/HierarchySerializer.cs
+++ b/Packages/src/Editor/FirstPartyTools/GetHierarchy/HierarchyAnalyzer/HierarchySerializer.cs
@@ -35,7 +35,7 @@ public HierarchySerializationResult BuildGroups(List nodes, Hiera
);
// Group by scene
- var groups = nodes
+ List groups = nodes
.GroupBy(n => n.sceneName)
.Select(g => BuildGroupForScene(g.Key ?? string.Empty, g.ToList(), options))
.ToList();
@@ -46,7 +46,7 @@ public HierarchySerializationResult BuildGroups(List nodes, Hiera
private SceneHierarchyGroup BuildGroupForScene(string sceneName, List sceneNodes, HierarchySerializationOptions options)
{
// Build nested structure per scene
- Dictionary nodeDict = new();
+ Dictionary nodeDict = new();
foreach (HierarchyNode flat in sceneNodes)
{
@@ -70,7 +70,7 @@ private SceneHierarchyGroup BuildGroupForScene(string sceneName, List all = new();
- foreach (var n in sceneNodes)
+ foreach (HierarchyNode node in sceneNodes)
{
- if (n.components != null && n.components.Length > 0)
+ if (node.components != null && node.components.Length > 0)
{
- all.AddRange(n.components);
+ all.AddRange(node.components);
}
}
if (all.Count < 50) return false;
@@ -121,7 +121,7 @@ private static bool ShouldUseComponentsLut(HierarchySerializationOptions options
return unique * 2 < all.Count; // more than 50% duplicates
}
- private static List BuildComponentsLutAndApply(List sceneNodes, Dictionary nodeDict)
+ private static List BuildComponentsLutAndApply(List sceneNodes, Dictionary nodeDict)
{
Dictionary lutIndex = new();
List lut = new();
@@ -193,4 +193,4 @@ public HierarchySerializationResult(List groups, HierarchyC
this.Context = context;
}
}
-}
\ No newline at end of file
+}
diff --git a/Packages/src/Editor/FirstPartyTools/GetHierarchy/HierarchyAnalyzer/HierarchyService.cs b/Packages/src/Editor/FirstPartyTools/GetHierarchy/HierarchyAnalyzer/HierarchyService.cs
index 8ca67f083..e28a4bf6f 100644
--- a/Packages/src/Editor/FirstPartyTools/GetHierarchy/HierarchyAnalyzer/HierarchyService.cs
+++ b/Packages/src/Editor/FirstPartyTools/GetHierarchy/HierarchyAnalyzer/HierarchyService.cs
@@ -1,4 +1,5 @@
using System.Collections.Generic;
+using System.Globalization;
using System.Linq;
using UnityEngine;
using UnityEngine.SceneManagement;
@@ -334,7 +335,7 @@ private static GameObject[] GetDontDestroyOnLoadRootObjects()
}
}
- private void TraverseHierarchy(GameObject obj, int? parentId, int depth, HierarchyOptions options, List nodes)
+ private void TraverseHierarchy(GameObject obj, string parentId, int depth, HierarchyOptions options, List nodes)
{
// Check depth limit
if (options.MaxDepth >= 0 && depth > options.MaxDepth)
@@ -352,8 +353,9 @@ private void TraverseHierarchy(GameObject obj, int? parentId, int depth, Hierarc
}
// Create node
+ string currentId = GetObjectId(obj);
HierarchyNode node = new(
- id: obj.GetInstanceID(),
+ id: currentId,
name: obj.name,
parent: parentId,
depth: depth,
@@ -368,7 +370,6 @@ private void TraverseHierarchy(GameObject obj, int? parentId, int depth, Hierarc
nodes.Add(node);
// Traverse children
- int currentId = obj.GetInstanceID();
foreach (Transform child in obj.transform)
{
if (!options.IncludeInactive && !child.gameObject.activeInHierarchy)
@@ -377,5 +378,17 @@ private void TraverseHierarchy(GameObject obj, int? parentId, int depth, Hierarc
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
+ return obj.GetEntityId().ToString();
+#else
+ int instanceId = obj.GetInstanceID();
+ return instanceId.ToString(CultureInfo.InvariantCulture);
+#endif
+ }
}
}
diff --git a/Packages/src/Editor/Infrastructure/Server/UnityCliLoopServerController.cs b/Packages/src/Editor/Infrastructure/Server/UnityCliLoopServerController.cs
index c969c4fc7..4fb85cd3d 100644
--- a/Packages/src/Editor/Infrastructure/Server/UnityCliLoopServerController.cs
+++ b/Packages/src/Editor/Infrastructure/Server/UnityCliLoopServerController.cs
@@ -26,6 +26,7 @@ public sealed class UnityCliLoopServerControllerService :
private readonly SessionRecoveryService _sessionRecoveryService;
private readonly ServerReadinessStateStore _stateStore;
private readonly IUnityCliLoopServerReadinessProbe _readinessProbe;
+ private readonly IUnityCliLoopServerDomainReloadLifecycle _domainReloadLifecycle;
private IUnityCliLoopServerInstance _bridgeServer;
private readonly SemaphoreSlim _startupSemaphore = new SemaphoreSlim(1, 1);
private long _startupProtectionUntilTicks = 0;
@@ -37,7 +38,8 @@ internal UnityCliLoopServerControllerService(
IDomainReloadDetectionService domainReloadDetectionService,
UnityCliLoopEditorSettingsService editorSettingsService,
ServerReadinessStateStore stateStore,
- IUnityCliLoopServerReadinessProbe readinessProbe)
+ IUnityCliLoopServerReadinessProbe readinessProbe,
+ IUnityCliLoopServerDomainReloadLifecycle domainReloadLifecycle)
{
System.Diagnostics.Debug.Assert(serverInstanceFactory != null, "serverInstanceFactory must not be null");
System.Diagnostics.Debug.Assert(serverLifecycleRegistry != null, "serverLifecycleRegistry must not be null");
@@ -45,6 +47,7 @@ internal UnityCliLoopServerControllerService(
System.Diagnostics.Debug.Assert(editorSettingsService != null, "editorSettingsService must not be null");
System.Diagnostics.Debug.Assert(stateStore != null, "stateStore must not be null");
System.Diagnostics.Debug.Assert(readinessProbe != null, "readinessProbe must not be null");
+ System.Diagnostics.Debug.Assert(domainReloadLifecycle != null, "domainReloadLifecycle must not be null");
_serverInstanceFactory = serverInstanceFactory ?? throw new ArgumentNullException(nameof(serverInstanceFactory));
_serverLifecycleRegistry = serverLifecycleRegistry ?? throw new ArgumentNullException(nameof(serverLifecycleRegistry));
@@ -52,6 +55,7 @@ internal UnityCliLoopServerControllerService(
_editorSettingsService = editorSettingsService ?? throw new ArgumentNullException(nameof(editorSettingsService));
_stateStore = stateStore ?? throw new ArgumentNullException(nameof(stateStore));
_readinessProbe = readinessProbe ?? throw new ArgumentNullException(nameof(readinessProbe));
+ _domainReloadLifecycle = domainReloadLifecycle ?? throw new ArgumentNullException(nameof(domainReloadLifecycle));
_sessionRecoveryService = new SessionRecoveryService(
this,
_domainReloadDetectionService,
@@ -289,6 +293,7 @@ internal async Task StopServerWithUseCaseAsync()
internal void OnBeforeAssemblyReload()
{
ClearStartupProtection();
+ _domainReloadLifecycle.PrepareForDomainReload();
string generationId = ServerReadinessStateStore.CreateGenerationId();
WriteServerState(ServerReadinessPhase.Reloading, generationId, "domain-reload-before", null);
diff --git a/Packages/src/Runtime/uLoopMCP.Runtime.asmdef b/Packages/src/Runtime/uLoopMCP.Runtime.asmdef
index 065693474..28b3d7590 100644
--- a/Packages/src/Runtime/uLoopMCP.Runtime.asmdef
+++ b/Packages/src/Runtime/uLoopMCP.Runtime.asmdef
@@ -2,7 +2,9 @@
"name": "uLoopMCP.Runtime",
"rootNamespace": "io.github.hatayama.UnityCliLoop.Runtime",
"references": [],
- "includePlatforms": [],
+ "includePlatforms": [
+ "Editor"
+ ],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": false,