From 7fa4cae9091666a7e2b7d0f5068c6cb26fa2c4db Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 8 Oct 2026 09:19:07 +0900 Subject: [PATCH 01/11] Add failing tests for where a project's compiled assemblies live A Multiplayer Play Mode Virtual Player has no Library/ScriptAssemblies of its own; it loads the main project's. These tests pin the layout a project root resolves to, including Virtual Player roots and lookalikes, before the implementation exists. --- .../HotReload/CompiledAssemblyLayoutTests.cs | 169 ++++++++++++++++++ .../CompiledAssemblyLayoutTests.cs.meta | 11 ++ .../ScriptPath/CompiledAssemblyLayout.cs | 33 ++++ .../ScriptPath/CompiledAssemblyLayout.cs.meta | 11 ++ 4 files changed, 224 insertions(+) create mode 100644 Assets/Tests/Editor/HotReload/CompiledAssemblyLayoutTests.cs create mode 100644 Assets/Tests/Editor/HotReload/CompiledAssemblyLayoutTests.cs.meta create mode 100644 Packages/src/Editor/FirstPartyTools/Common/ScriptPath/CompiledAssemblyLayout.cs create mode 100644 Packages/src/Editor/FirstPartyTools/Common/ScriptPath/CompiledAssemblyLayout.cs.meta diff --git a/Assets/Tests/Editor/HotReload/CompiledAssemblyLayoutTests.cs b/Assets/Tests/Editor/HotReload/CompiledAssemblyLayoutTests.cs new file mode 100644 index 000000000..7fafd9ee3 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/CompiledAssemblyLayoutTests.cs @@ -0,0 +1,169 @@ +using System; +using System.IO; + +using NUnit.Framework; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload +{ + /// + /// Covers where the compiled assemblies of a project root live: under the root itself for an + /// ordinary project, and under the main project for a Multiplayer Play Mode Virtual Player. + /// + public sealed class CompiledAssemblyLayoutTests + { + // Why under the temp directory: Resolve makes the root absolute, so an absolute fixture keeps + // the expected values independent of the current directory. Nothing is created on disk. + private string _workspace; + + [SetUp] + public void SetUp() + { + _workspace = Path.Combine(Path.GetTempPath(), "uloop-test-" + Guid.NewGuid().ToString("N")); + } + + private static string Full(params string[] parts) + { + return Path.GetFullPath(Path.Combine(parts)); + } + + /// + /// What: an ordinary project reads its compiled assemblies from its own Library/ScriptAssemblies. + /// + [Test] + public void Resolve_OrdinaryRoot_ReadsItsOwnScriptAssemblies() + { + string projectRoot = Path.Combine(_workspace, "project"); + + CompiledAssemblyLayout layout = CompiledAssemblyLayout.Resolve(projectRoot); + + string expectedDirectory = Full(projectRoot, "Library", "ScriptAssemblies"); + Assert.That(layout.IsVirtualPlayer, Is.False); + Assert.That(layout.ProjectRoot, Is.EqualTo(Full(projectRoot))); + Assert.That(layout.MainProjectRoot, Is.EqualTo(layout.ProjectRoot)); + Assert.That(layout.CompiledAssembliesDirectory, Is.EqualTo(expectedDirectory)); + Assert.That(layout.DllPath("A"), Is.EqualTo(Path.Combine(expectedDirectory, "A.dll"))); + Assert.That(layout.PdbPath("A"), Is.EqualTo(Path.Combine(expectedDirectory, "A.pdb"))); + } + + /// + /// What: a Virtual Player root reads the main project's Library/ScriptAssemblies and keeps + /// its own root as ProjectRoot. + /// + [Test] + public void Resolve_VirtualPlayerRoot_ReadsTheMainProjectsScriptAssemblies() + { + string mainRoot = Path.Combine(_workspace, "project"); + string playerRoot = Path.Combine(mainRoot, "Library", "VP", "mppm1"); + + CompiledAssemblyLayout layout = CompiledAssemblyLayout.Resolve(playerRoot); + + string expectedDirectory = Full(mainRoot, "Library", "ScriptAssemblies"); + Assert.That(layout.IsVirtualPlayer, Is.True); + Assert.That(layout.ProjectRoot, Is.EqualTo(Full(playerRoot))); + Assert.That(layout.MainProjectRoot, Is.EqualTo(Full(mainRoot))); + Assert.That(layout.CompiledAssembliesDirectory, Is.EqualTo(expectedDirectory)); + Assert.That(layout.DllPath("A"), Is.EqualTo(Path.Combine(expectedDirectory, "A.dll"))); + Assert.That(layout.PdbPath("A"), Is.EqualTo(Path.Combine(expectedDirectory, "A.pdb"))); + } + + /// + /// What: a trailing separator on a Virtual Player root gives the same layout, and ProjectRoot + /// does not keep the separator. + /// + [Test] + public void Resolve_VirtualPlayerRootWithTrailingSeparator_GivesTheSameLayout() + { + string mainRoot = Path.Combine(_workspace, "project"); + string playerRoot = Path.Combine(mainRoot, "Library", "VP", "mppm1"); + + CompiledAssemblyLayout layout = CompiledAssemblyLayout.Resolve(playerRoot + Path.DirectorySeparatorChar); + + Assert.That(layout.IsVirtualPlayer, Is.True); + Assert.That(layout.ProjectRoot, Is.EqualTo(Full(playerRoot))); + Assert.That(layout.MainProjectRoot, Is.EqualTo(Full(mainRoot))); + Assert.That(layout.CompiledAssembliesDirectory, Is.EqualTo(Full(mainRoot, "Library", "ScriptAssemblies"))); + } + + /// + /// What: roots that only look like a Virtual Player root (no Library, a lowercase vp, the VP + /// directory itself, a parent other than VP, a grandparent other than Library) are ordinary + /// projects that read their own Library/ScriptAssemblies. + /// + [TestCase("VP", "mppm1")] + [TestCase("Library", "vp", "mppm1")] + [TestCase("Library", "VP")] + [TestCase("Library", "Other", "mppm1")] + [TestCase("Other", "VP", "mppm1")] + public void Resolve_LookalikeRoot_IsAnOrdinaryProject(params string[] relativeParts) + { + string[] parts = new string[relativeParts.Length + 1]; + parts[0] = Path.Combine(_workspace, "project"); + Array.Copy(relativeParts, 0, parts, 1, relativeParts.Length); + string projectRoot = Path.Combine(parts); + + CompiledAssemblyLayout layout = CompiledAssemblyLayout.Resolve(projectRoot); + + Assert.That(layout.IsVirtualPlayer, Is.False); + Assert.That(layout.MainProjectRoot, Is.EqualTo(Full(projectRoot))); + Assert.That(layout.CompiledAssembliesDirectory, Is.EqualTo(Full(projectRoot, "Library", "ScriptAssemblies"))); + } + + /// + /// What: a relative Virtual Player root is made absolute before it is recognized. + /// + [Test] + public void Resolve_RelativeVirtualPlayerRoot_IsRecognizedAndMadeAbsolute() + { + string playerRoot = Path.Combine("workspace", "project", "Library", "VP", "mppm1"); + + CompiledAssemblyLayout layout = CompiledAssemblyLayout.Resolve(playerRoot); + + Assert.That(layout.IsVirtualPlayer, Is.True); + Assert.That(layout.ProjectRoot, Is.EqualTo(Path.GetFullPath(playerRoot))); + Assert.That(Path.IsPathRooted(layout.ProjectRoot), Is.True); + Assert.That( + layout.CompiledAssembliesDirectory, + Is.EqualTo(Full("workspace", "project", "Library", "ScriptAssemblies"))); + } + + /// + /// What: a root with no parent (a drive or "/" alone) is an ordinary project and stays as given, + /// instead of throwing. + /// + [Test] + public void Resolve_RootWithoutAParent_IsAnOrdinaryProject() + { + string fileSystemRoot = Path.GetPathRoot(Path.GetTempPath()); + + CompiledAssemblyLayout layout = CompiledAssemblyLayout.Resolve(fileSystemRoot); + + Assert.That(layout.IsVirtualPlayer, Is.False); + Assert.That(layout.ProjectRoot, Is.EqualTo(fileSystemRoot)); + Assert.That(layout.MainProjectRoot, Is.EqualTo(fileSystemRoot)); + } + + /// + /// What: on Windows, a Virtual Player root is recognized with backslashes and with forward slashes. + /// + [Test] + public void Resolve_WindowsSeparators_RecognizeAVirtualPlayer() + { + if (Path.DirectorySeparatorChar != '\\') + { + Assert.Pass("Windows separator handling applies only on Windows."); + return; + } + + Assert.That( + CompiledAssemblyLayout.Resolve(@"C:\workspace\project\Library\VP\mppm1").IsVirtualPlayer, + Is.True, + "Backslash separators must be recognized."); + Assert.That( + CompiledAssemblyLayout.Resolve("C:/workspace/project/Library/VP/mppm1").IsVirtualPlayer, + Is.True, + "Forward-slash separators must be recognized."); + } + } +} diff --git a/Assets/Tests/Editor/HotReload/CompiledAssemblyLayoutTests.cs.meta b/Assets/Tests/Editor/HotReload/CompiledAssemblyLayoutTests.cs.meta new file mode 100644 index 000000000..d01d9b92e --- /dev/null +++ b/Assets/Tests/Editor/HotReload/CompiledAssemblyLayoutTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 8bfdafdffafc544ef8436062ef170430 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/Common/ScriptPath/CompiledAssemblyLayout.cs b/Packages/src/Editor/FirstPartyTools/Common/ScriptPath/CompiledAssemblyLayout.cs new file mode 100644 index 000000000..ad701b789 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Common/ScriptPath/CompiledAssemblyLayout.cs @@ -0,0 +1,33 @@ +using System; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Decides where the compiled assemblies of a project root live. + /// + internal sealed class CompiledAssemblyLayout + { + internal static CompiledAssemblyLayout Resolve(string projectRoot) + { + throw new NotImplementedException(); + } + + internal string ProjectRoot => throw new NotImplementedException(); + + internal bool IsVirtualPlayer => throw new NotImplementedException(); + + internal string MainProjectRoot => throw new NotImplementedException(); + + internal string CompiledAssembliesDirectory => throw new NotImplementedException(); + + internal string DllPath(string assemblyName) + { + throw new NotImplementedException(); + } + + internal string PdbPath(string assemblyName) + { + throw new NotImplementedException(); + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/Common/ScriptPath/CompiledAssemblyLayout.cs.meta b/Packages/src/Editor/FirstPartyTools/Common/ScriptPath/CompiledAssemblyLayout.cs.meta new file mode 100644 index 000000000..5b7e60449 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Common/ScriptPath/CompiledAssemblyLayout.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 6e5ee652390f74fdaa571cb77458b0d9 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: From 223e1e4dbdac1a0cde28cd298d2cd7bdd79b14ac Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 8 Oct 2026 09:19:57 +0900 Subject: [PATCH 02/11] Resolve a project's compiled assembly directory in one place CompiledAssemblyLayout recognizes a Multiplayer Play Mode Virtual Player root (
/Library/VP/) and points it at the main project's Library/ScriptAssemblies, which is where the player actually loads its assemblies from. The old root check moves into it, so its tests move to CompiledAssemblyLayoutTests with absolute fixtures. --- .../HotReloadVirtualPlayerProjectTests.cs | 117 +----------------- .../ScriptPath/CompiledAssemblyLayout.cs | 70 +++++++++-- .../Shared/HotReloadVirtualPlayerProject.cs | 49 +------- 3 files changed, 66 insertions(+), 170 deletions(-) diff --git a/Assets/Tests/Editor/HotReload/HotReloadVirtualPlayerProjectTests.cs b/Assets/Tests/Editor/HotReload/HotReloadVirtualPlayerProjectTests.cs index 268d3b594..d2f227ccd 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadVirtualPlayerProjectTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadVirtualPlayerProjectTests.cs @@ -7,8 +7,8 @@ namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload { /// - /// EditMode coverage for recognizing a Multiplayer Play Mode Virtual Player by its project root, - /// and for the missing-assembly reason worded for it. + /// EditMode coverage for the missing-assembly reason worded for an ordinary project and for a + /// Multiplayer Play Mode Virtual Player. /// public class HotReloadVirtualPlayerProjectTests { @@ -17,119 +17,6 @@ public class HotReloadVirtualPlayerProjectTests private static readonly string OrdinaryProjectRoot = Path.Combine("workspace", "project"); - /// - /// What: a player directory directly under Library/VP is recognized as a Virtual Player root. - /// - [Test] - public void IsVirtualPlayerProjectRoot_PlayerDirectoryUnderLibraryVP_ReturnsTrue() - { - Assert.That(HotReloadVirtualPlayerProject.IsVirtualPlayerProjectRoot(VirtualPlayerRoot), Is.True); - } - - /// - /// What: a trailing separator does not shift the parent lookup up by one directory. - /// - [Test] - public void IsVirtualPlayerProjectRoot_TrailingSeparator_ReturnsTrue() - { - string rootWithTrailingSeparator = VirtualPlayerRoot + Path.DirectorySeparatorChar; - - Assert.That( - HotReloadVirtualPlayerProject.IsVirtualPlayerProjectRoot(rootWithTrailingSeparator), - Is.True); - } - - /// - /// What: an ordinary project root is not taken for a Virtual Player. - /// - [Test] - public void IsVirtualPlayerProjectRoot_OrdinaryProjectRoot_ReturnsFalse() - { - Assert.That(HotReloadVirtualPlayerProject.IsVirtualPlayerProjectRoot(OrdinaryProjectRoot), Is.False); - } - - /// - /// What: the Library/VP directory itself is not a Virtual Player root. - /// - [Test] - public void IsVirtualPlayerProjectRoot_TheVPDirectoryItself_ReturnsFalse() - { - string virtualPlayersDirectory = Path.Combine("workspace", "project", "Library", "VP"); - - Assert.That( - HotReloadVirtualPlayerProject.IsVirtualPlayerProjectRoot(virtualPlayersDirectory), - Is.False); - } - - /// - /// What: a directory under Library whose parent is not named VP is not a Virtual Player root. - /// - [Test] - public void IsVirtualPlayerProjectRoot_ParentIsNotVP_ReturnsFalse() - { - string projectRoot = Path.Combine("workspace", "project", "Library", "Other", "mppm0a1b2c3d"); - - Assert.That(HotReloadVirtualPlayerProject.IsVirtualPlayerProjectRoot(projectRoot), Is.False); - } - - /// - /// What: a directory under a VP directory that is not inside Library is not a Virtual Player root. - /// - [Test] - public void IsVirtualPlayerProjectRoot_GrandparentIsNotLibrary_ReturnsFalse() - { - string projectRoot = Path.Combine("workspace", "project", "Other", "VP", "mppm0a1b2c3d"); - - Assert.That(HotReloadVirtualPlayerProject.IsVirtualPlayerProjectRoot(projectRoot), Is.False); - } - - /// - /// What: a path without a parent or without a grandparent, including one made only of a - /// separator, is answered false instead of throwing. - /// - [Test] - public void IsVirtualPlayerProjectRoot_PathTooShortToHaveAGrandparent_ReturnsFalse() - { - string playerDirectoryOnly = "mppm0a1b2c3d"; - string virtualPlayersAndPlayerDirectory = Path.Combine("VP", "mppm0a1b2c3d"); - string separatorOnly = Path.DirectorySeparatorChar.ToString(); - - Assert.That( - HotReloadVirtualPlayerProject.IsVirtualPlayerProjectRoot(playerDirectoryOnly), - Is.False, - "A path without a parent must not be taken for a Virtual Player root."); - Assert.That( - HotReloadVirtualPlayerProject.IsVirtualPlayerProjectRoot(virtualPlayersAndPlayerDirectory), - Is.False, - "A path without a grandparent must not be taken for a Virtual Player root."); - Assert.That( - HotReloadVirtualPlayerProject.IsVirtualPlayerProjectRoot(separatorOnly), - Is.False, - "A path made only of a separator must not be taken for a Virtual Player root."); - } - - /// - /// What: on Windows, a Virtual Player root is recognized with backslashes and with forward slashes. - /// - [Test] - public void IsVirtualPlayerProjectRoot_WindowsSeparators_ReturnsTrue() - { - if (Path.DirectorySeparatorChar != '\\') - { - Assert.Pass("Windows separator handling applies only on Windows."); - return; - } - - Assert.That( - HotReloadVirtualPlayerProject.IsVirtualPlayerProjectRoot(@"C:\workspace\project\Library\VP\mppm0a1b2c3d"), - Is.True, - "Backslash separators must be recognized."); - Assert.That( - HotReloadVirtualPlayerProject.IsVirtualPlayerProjectRoot("C:/workspace/project/Library/VP/mppm0a1b2c3d"), - Is.True, - "Forward-slash separators must be recognized."); - } - /// /// What: for a Virtual Player, the reason says hot reload cannot patch it and that a compile /// brings the edit in, instead of asking for a compile first, and the failure is marked as a diff --git a/Packages/src/Editor/FirstPartyTools/Common/ScriptPath/CompiledAssemblyLayout.cs b/Packages/src/Editor/FirstPartyTools/Common/ScriptPath/CompiledAssemblyLayout.cs index ad701b789..338eb096f 100644 --- a/Packages/src/Editor/FirstPartyTools/Common/ScriptPath/CompiledAssemblyLayout.cs +++ b/Packages/src/Editor/FirstPartyTools/Common/ScriptPath/CompiledAssemblyLayout.cs @@ -1,33 +1,85 @@ using System; +using System.IO; + +using UnityEngine; namespace io.github.hatayama.UnityCliLoop.FirstPartyTools { /// - /// Decides where the compiled assemblies of a project root live. + /// Decides where the compiled assemblies of a project root live. An ordinary project reads its + /// own Library/ScriptAssemblies; a Multiplayer Play Mode Virtual Player, whose root is + /// <main project>/Library/VP/<player>, reads the main project's, because it has none + /// of its own. /// internal sealed class CompiledAssemblyLayout { - internal static CompiledAssemblyLayout Resolve(string projectRoot) + // Why these are spelled here: this assembly cannot reference hot reload's constants, since + // the dependency runs from hot reload to this assembly only. + private const string LibraryDirectoryName = "Library"; + private const string VirtualPlayersDirectoryName = "VP"; + private const string ScriptAssembliesDirectoryName = "ScriptAssemblies"; + + private CompiledAssemblyLayout( + string projectRoot, + bool isVirtualPlayer, + string mainProjectRoot, + string compiledAssembliesDirectory) { - throw new NotImplementedException(); + ProjectRoot = projectRoot; + IsVirtualPlayer = isVirtualPlayer; + MainProjectRoot = mainProjectRoot; + CompiledAssembliesDirectory = compiledAssembliesDirectory; } - internal string ProjectRoot => throw new NotImplementedException(); + /// The project root made absolute, without a trailing separator. + internal string ProjectRoot { get; } + + /// True when ProjectRoot is a Multiplayer Play Mode Virtual Player's root. + internal bool IsVirtualPlayer { get; } - internal bool IsVirtualPlayer => throw new NotImplementedException(); + /// The main project's root for a Virtual Player; ProjectRoot otherwise. + internal string MainProjectRoot { get; } - internal string MainProjectRoot => throw new NotImplementedException(); + /// The absolute directory the project's compiled assemblies are read from. + internal string CompiledAssembliesDirectory { get; } - internal string CompiledAssembliesDirectory => throw new NotImplementedException(); + internal static CompiledAssemblyLayout Resolve(string projectRoot) + { + Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty."); + + string fullRoot = Path.GetFullPath(projectRoot); + // Why trim first: with a trailing separator, Path.GetDirectoryName returns the same + // directory, so every parent lookup below would land one level too low. + string trimmedRoot = fullRoot.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + // A root made only of separators has nothing left after the trim; it stays as it was. + string root = trimmedRoot.Length == 0 ? fullRoot : trimmedRoot; + string virtualPlayersDirectory = Path.GetDirectoryName(root); + string libraryDirectory = string.IsNullOrEmpty(virtualPlayersDirectory) + ? null + : Path.GetDirectoryName(virtualPlayersDirectory); + string mainRoot = string.IsNullOrEmpty(libraryDirectory) ? null : Path.GetDirectoryName(libraryDirectory); + // Why a main root is required: /Library/VP/x has no project above Library to read from. + bool isVirtualPlayer = !string.IsNullOrEmpty(mainRoot) + && string.Equals(Path.GetFileName(virtualPlayersDirectory), VirtualPlayersDirectoryName, StringComparison.Ordinal) + && string.Equals(Path.GetFileName(libraryDirectory), LibraryDirectoryName, StringComparison.Ordinal); + string compiledAssembliesOwner = isVirtualPlayer ? mainRoot : root; + return new CompiledAssemblyLayout( + root, + isVirtualPlayer, + compiledAssembliesOwner, + Path.Combine(compiledAssembliesOwner, LibraryDirectoryName, ScriptAssembliesDirectoryName)); + } internal string DllPath(string assemblyName) { - throw new NotImplementedException(); + Debug.Assert(!string.IsNullOrEmpty(assemblyName), "assemblyName must not be null or empty."); + return Path.Combine(CompiledAssembliesDirectory, assemblyName + ".dll"); } internal string PdbPath(string assemblyName) { - throw new NotImplementedException(); + Debug.Assert(!string.IsNullOrEmpty(assemblyName), "assemblyName must not be null or empty."); + return Path.Combine(CompiledAssembliesDirectory, assemblyName + ".pdb"); } } } diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadVirtualPlayerProject.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadVirtualPlayerProject.cs index 53fd63a77..c2dbbe699 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadVirtualPlayerProject.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadVirtualPlayerProject.cs @@ -1,56 +1,13 @@ -using System; -using System.IO; - using UnityEngine; namespace io.github.hatayama.UnityCliLoop.FirstPartyTools { /// - /// Recognizes a Multiplayer Play Mode Virtual Player by its project root, and words the - /// missing-assembly reason for it. + /// Words the missing-assembly reason for an ordinary project and for a Multiplayer Play Mode + /// Virtual Player. /// internal static class HotReloadVirtualPlayerProject { - private const string LibraryDirectoryName = "Library"; - private const string VirtualPlayersDirectoryName = "VP"; - - // A Virtual Player's project root is
/Library/VP/. - internal static bool IsVirtualPlayerProjectRoot(string projectRoot) - { - Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty."); - - // Why trim first: with a trailing separator, Path.GetDirectoryName returns the same - // directory, so every parent lookup below would land one level too low. - string trimmedRoot = projectRoot.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); - // A root made only of separators has no parent, and Path.GetDirectoryName rejects the empty string. - if (trimmedRoot.Length == 0) - { - return false; - } - - string virtualPlayersDirectory = Path.GetDirectoryName(trimmedRoot); - if (string.IsNullOrEmpty(virtualPlayersDirectory)) - { - return false; - } - - if (!string.Equals( - Path.GetFileName(virtualPlayersDirectory), - VirtualPlayersDirectoryName, - StringComparison.Ordinal)) - { - return false; - } - - string libraryDirectory = Path.GetDirectoryName(virtualPlayersDirectory); - if (string.IsNullOrEmpty(libraryDirectory)) - { - return false; - } - - return string.Equals(Path.GetFileName(libraryDirectory), LibraryDirectoryName, StringComparison.Ordinal); - } - // Why the text and the kinds come from one decision: a reason that names a Virtual Player // has to arrive with VirtualPlayer set, or the next step would tell the player to compile // its own project, which has no assemblies to compile. @@ -59,7 +16,7 @@ internal static HotReloadFailureDescription DescribeMissingCompiledAssembly(stri Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty."); Debug.Assert(!string.IsNullOrEmpty(dllPath), "dllPath must not be null or empty."); - if (!IsVirtualPlayerProjectRoot(projectRoot)) + if (!CompiledAssemblyLayout.Resolve(projectRoot).IsVirtualPlayer) { return HotReloadFailureDescription.CompiledAssemblyMissing( "Compiled assembly not found at '" + dllPath + "'. Compile the project first.", From 1d6af14fbf11afc78d3fe2d186bb0adce190adec Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 8 Oct 2026 09:24:53 +0900 Subject: [PATCH 03/11] Build every hot reload compiled-assembly path from CompiledAssemblyLayout The eight places that joined the project root with Library/ScriptAssemblies now ask CompiledAssemblyLayout, so a Virtual Player reads the main project's compiled assemblies while its hot reload state stays under its own root. The patching assembly gains a reference to the script path assembly for this. The Virtual Player missing-assembly test now expects the main project to be named as the one to compile; the reason text follows in the next commit. --- .../HotReloadVirtualPlayerProjectTests.cs | 19 +++++++++------ .../Common/ScriptPath/AssemblyInfo.cs | 1 + .../HotReload/HotReloadCallSiteScanner.cs | 6 ++--- .../HotReloadChangedFileAggregator.cs | 8 +++---- .../HotReloadNewSourceMembershipValidator.cs | 5 +--- .../HotReload/HotReloadPatchTargetSupport.cs | 11 +++++---- .../Patching/HotReloadShimReferenceBuilder.cs | 3 +-- .../HotReload/Patching/ReferencePublicizer.cs | 23 ++++++++----------- ...artyTools.HotReload.Patching.Editor.asmdef | 3 ++- .../Shared/HotReloadSourceSnapshotter.cs | 10 ++++---- .../HotReload/Shared/HotReloadTypeHome.cs | 5 +--- .../Shared/HotReloadVirtualPlayerProject.cs | 8 ++++--- 12 files changed, 48 insertions(+), 54 deletions(-) diff --git a/Assets/Tests/Editor/HotReload/HotReloadVirtualPlayerProjectTests.cs b/Assets/Tests/Editor/HotReload/HotReloadVirtualPlayerProjectTests.cs index d2f227ccd..52f1a7c94 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadVirtualPlayerProjectTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadVirtualPlayerProjectTests.cs @@ -18,17 +18,18 @@ public class HotReloadVirtualPlayerProjectTests private static readonly string OrdinaryProjectRoot = Path.Combine("workspace", "project"); /// - /// What: for a Virtual Player, the reason says hot reload cannot patch it and that a compile - /// brings the edit in, instead of asking for a compile first, and the failure is marked as a - /// Virtual Player's so the next step names the main Editor's project. + /// What: for a Virtual Player, the reason says the main Editor's project has not compiled the + /// assembly and asks for that project to be compiled, instead of this one, and the failure is + /// marked as a Virtual Player's so the next step names the main Editor's project. /// [Test] - public void DescribeMissingCompiledAssembly_VirtualPlayerRoot_SaysHotReloadCannotPatchAVirtualPlayer() + public void DescribeMissingCompiledAssembly_VirtualPlayerRoot_SaysTheMainProjectHasNotCompiledTheAssembly() { - string dllPath = Path.Combine(VirtualPlayerRoot, "Library", "ScriptAssemblies", "Sample.dll"); + CompiledAssemblyLayout layout = CompiledAssemblyLayout.Resolve(VirtualPlayerRoot); + string dllPath = layout.DllPath("Sample"); HotReloadFailureDescription failure = - HotReloadVirtualPlayerProject.DescribeMissingCompiledAssembly(VirtualPlayerRoot, dllPath); + HotReloadVirtualPlayerProject.DescribeMissingCompiledAssembly(layout, dllPath); string reason = failure.Message; Assert.That( @@ -39,6 +40,8 @@ public void DescribeMissingCompiledAssembly_VirtualPlayerRoot_SaysHotReloadCanno Assert.That(reason, Does.Contain("main Editor")); Assert.That(reason, Does.Contain("compile")); Assert.That(reason, Does.Not.Contain("Compile the project first")); + Assert.That(reason, Does.Contain("Compile the main Editor's project first")); + Assert.That(reason, Does.Contain("has not compiled")); } /// @@ -51,7 +54,9 @@ public void DescribeMissingCompiledAssembly_OrdinaryRoot_KeepsTheCompileFirstTex string dllPath = Path.Combine(OrdinaryProjectRoot, "Library", "ScriptAssemblies", "Sample.dll"); HotReloadFailureDescription failure = - HotReloadVirtualPlayerProject.DescribeMissingCompiledAssembly(OrdinaryProjectRoot, dllPath); + HotReloadVirtualPlayerProject.DescribeMissingCompiledAssembly( + CompiledAssemblyLayout.Resolve(OrdinaryProjectRoot), + dllPath); Assert.That(failure.Kinds, Is.EqualTo(HotReloadFailureKinds.CompiledAssemblyMissing)); Assert.That( diff --git a/Packages/src/Editor/FirstPartyTools/Common/ScriptPath/AssemblyInfo.cs b/Packages/src/Editor/FirstPartyTools/Common/ScriptPath/AssemblyInfo.cs index 8f57186ca..ee1a792cc 100644 --- a/Packages/src/Editor/FirstPartyTools/Common/ScriptPath/AssemblyInfo.cs +++ b/Packages/src/Editor/FirstPartyTools/Common/ScriptPath/AssemblyInfo.cs @@ -2,6 +2,7 @@ [assembly: InternalsVisibleTo("UnityCLILoop.FirstPartyTools.HotReload.Shared.Editor")] [assembly: InternalsVisibleTo("UnityCLILoop.FirstPartyTools.HotReload.Editor")] +[assembly: InternalsVisibleTo("UnityCLILoop.FirstPartyTools.HotReload.Patching.Editor")] [assembly: InternalsVisibleTo("UnityCLILoop.FirstPartyTools.PausePoint.Editor")] [assembly: InternalsVisibleTo("UnityCLILoop.Infrastructure")] [assembly: InternalsVisibleTo("UnityCLILoop.Tests.Editor.HotReload")] diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCallSiteScanner.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCallSiteScanner.cs index 4d66c3d96..030713d35 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCallSiteScanner.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCallSiteScanner.cs @@ -107,12 +107,10 @@ public static HotReloadCallSiteScanResult FindCallSites( HashSet scanAssemblyNames = CollectScanAssemblyNames(targets); int examinedCallSiteCount = 0; + CompiledAssemblyLayout layout = CompiledAssemblyLayout.Resolve(projectRoot); foreach (string assemblyName in scanAssemblyNames) { - string dllPath = Path.Combine( - projectRoot, - HotReloadConstants.ScriptAssembliesRelativeDirectory, - assemblyName + HotReloadConstants.CompiledAssemblyExtension); + string dllPath = layout.DllPath(assemblyName); // Why skip (not assert): an assembly that has not been written to ScriptAssemblies // cannot contain call sites, so it cannot be a caller. Missing here is "not compiled diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadChangedFileAggregator.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadChangedFileAggregator.cs index a7da610db..e3bb7d24a 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadChangedFileAggregator.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadChangedFileAggregator.cs @@ -121,6 +121,7 @@ internal static List CollectSnapshotAssemblies( Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty."); Debug.Assert(assemblies != null, "assemblies must not be null."); + CompiledAssemblyLayout layout = CompiledAssemblyLayout.Resolve(projectRoot); List snapshotAssemblies = new List(); foreach (UnityCompilationAssembly assembly in assemblies) { @@ -135,11 +136,8 @@ internal static List CollectSnapshotAssemblies( continue; } - string dllPath = Path.Combine( - projectRoot, - HotReloadConstants.ScriptAssembliesRelativeDirectory, - assembly.name + HotReloadConstants.CompiledAssemblyExtension); - string pdbPath = Path.ChangeExtension(dllPath, ".pdb"); + string dllPath = layout.DllPath(assembly.name); + string pdbPath = layout.PdbPath(assembly.name); if (!File.Exists(dllPath) || !File.Exists(pdbPath)) { continue; diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadNewSourceMembershipValidator.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadNewSourceMembershipValidator.cs index 14a3bb998..72fe13b7e 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadNewSourceMembershipValidator.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadNewSourceMembershipValidator.cs @@ -112,10 +112,7 @@ private static HotReloadFailureDescription DescribeMembershipChange( "The resolved assembly is no longer present in the compilation pipeline. Compile the project and retry hot reload."); } - string targetDllPath = Path.Combine( - projectRoot, - HotReloadConstants.ScriptAssembliesRelativeDirectory, - evidence.AssemblyName + HotReloadConstants.CompiledAssemblyExtension); + string targetDllPath = CompiledAssemblyLayout.Resolve(projectRoot).DllPath(evidence.AssemblyName); HotReloadFailureDescription compiledAssemblyChange = DescribeCompiledAssemblyChange( evidence, targetDllPath); diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadPatchTargetSupport.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadPatchTargetSupport.cs index ddb491c09..e04a9a774 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadPatchTargetSupport.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadPatchTargetSupport.cs @@ -97,15 +97,16 @@ internal static HotReloadPatchTargetResolution ResolvePatchTarget( } } - string projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, "..")); - HotReloadTypeHome home = domain.ResolveTypeHome(projectRoot, assemblyName); + CompiledAssemblyLayout layout = + CompiledAssemblyLayout.Resolve(Path.GetFullPath(Path.Combine(Application.dataPath, ".."))); + HotReloadTypeHome home = domain.ResolveTypeHome(layout.ProjectRoot, assemblyName); if (!File.Exists(home.DllPath)) { outcomes.Add( HotReloadMethodOutcome.FailedBecause( "(file)", - HotReloadVirtualPlayerProject.DescribeMissingCompiledAssembly(projectRoot, home.DllPath), + HotReloadVirtualPlayerProject.DescribeMissingCompiledAssembly(layout, home.DllPath), assemblyResolvePath)); return HotReloadPatchTargetResolution.EarlyExit( new HotReloadFileProcessResult(outcomes, warnings, 0)); @@ -124,7 +125,7 @@ internal static HotReloadPatchTargetResolution ResolvePatchTarget( { HotReloadFailureDescription membershipFailure = HotReloadNewSourceMembershipValidator.TryCapture( editorStateSnapshotCapture, - projectRoot, + layout.ProjectRoot, projectRelativePath, assemblyName, compilationAssembly, @@ -174,7 +175,7 @@ internal static HotReloadPatchTargetResolution ResolvePatchTarget( assemblyName, compilationAssembly, home, - projectRoot, + layout.ProjectRoot, unchangedDecision, newSourceMembershipEvidence); } diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Patching/HotReloadShimReferenceBuilder.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Patching/HotReloadShimReferenceBuilder.cs index 2fa9235e0..0af6c3b20 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Patching/HotReloadShimReferenceBuilder.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Patching/HotReloadShimReferenceBuilder.cs @@ -416,8 +416,7 @@ private static List BuildShimReferencePaths( IReadOnlyList introducedTypeArtifactHomes) { string projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, "..")); - string scriptAssembliesDirectory = Path.GetFullPath( - Path.Combine(projectRoot, HotReloadConstants.ScriptAssembliesRelativeDirectory)); + string scriptAssembliesDirectory = CompiledAssemblyLayout.Resolve(projectRoot).CompiledAssembliesDirectory; // Derive Cecil search dirs from Unity's actual compile references so publicize // resolves netstandard/engine modules without hardcoding Editor Contents layouts. diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Patching/ReferencePublicizer.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Patching/ReferencePublicizer.cs index 5a373a3d1..dd3c2fd5d 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Patching/ReferencePublicizer.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Patching/ReferencePublicizer.cs @@ -276,31 +276,27 @@ private static void AssertIsPublicizableSourcePath(string fullSourceDllPath) ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; string normalizedSource = NormalizePathForComparison(fullSourceDllPath); - bool underAcceptedDirectory = IsUnderProjectDirectory( + bool underAcceptedDirectory = IsUnderDirectory( normalizedSource, - projectRoot, - HotReloadConstants.ScriptAssembliesRelativeDirectory, + CompiledAssemblyLayout.Resolve(projectRoot).CompiledAssembliesDirectory, comparison) - || IsUnderProjectDirectory( + || IsUnderDirectory( normalizedSource, - projectRoot, - HotReloadConstants.IntroducedTypeArtifactsRelativeDirectory, + Path.Combine(projectRoot, HotReloadConstants.IntroducedTypeArtifactsRelativeDirectory), comparison); Debug.Assert( underAcceptedDirectory, - "ReferencePublicizer only accepts DLLs under Library/ScriptAssemblies/ or " + "ReferencePublicizer only accepts DLLs under the compiled assemblies directory or " + "Library/UloopHotReload/IntroducedTypes/."); } - private static bool IsUnderProjectDirectory( + private static bool IsUnderDirectory( string normalizedSourcePath, - string projectRoot, - string relativeDirectory, + string absoluteDirectory, StringComparison comparison) { - string normalizedDirectory = NormalizePathForComparison( - Path.GetFullPath(Path.Combine(projectRoot, relativeDirectory))); + string normalizedDirectory = NormalizePathForComparison(Path.GetFullPath(absoluteDirectory)); return normalizedSourcePath.StartsWith(normalizedDirectory + "/", comparison); } @@ -320,8 +316,7 @@ private static DefaultAssemblyResolver CreateAssemblyResolver( resolver.AddSearchDirectory(Path.GetDirectoryName(sourceDllPath)); string projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, "..")); - resolver.AddSearchDirectory( - Path.Combine(projectRoot, HotReloadConstants.ScriptAssembliesRelativeDirectory)); + resolver.AddSearchDirectory(CompiledAssemblyLayout.Resolve(projectRoot).CompiledAssembliesDirectory); foreach (string searchDirectory in resolverSearchDirectories) { diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Patching/UnityCLILoop.FirstPartyTools.HotReload.Patching.Editor.asmdef b/Packages/src/Editor/FirstPartyTools/HotReload/Patching/UnityCLILoop.FirstPartyTools.HotReload.Patching.Editor.asmdef index 1991b71e7..f2f1bb992 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Patching/UnityCLILoop.FirstPartyTools.HotReload.Patching.Editor.asmdef +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Patching/UnityCLILoop.FirstPartyTools.HotReload.Patching.Editor.asmdef @@ -5,7 +5,8 @@ "GUID:384dc39e13234d8aae3b1b1c44c9507e", "GUID:1e3293e9639242278dcb27724b34beb4", "GUID:fc3fd32eddbee40e39c2d76dc184957b", - "GUID:afe86dd49995e46baa33e099a4d2ee1d" + "GUID:afe86dd49995e46baa33e099a4d2ee1d", + "GUID:24d448abf7c10486d9578018b3a3c54b" ], "includePlatforms": [ "Editor" diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotter.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotter.cs index 782b7e4f3..6f0e49fa0 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotter.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotter.cs @@ -89,11 +89,11 @@ internal static void CaptureAssemblyIfNeeded( return; } - string dllPath = Path.Combine( - projectRoot, - HotReloadConstants.ScriptAssembliesRelativeDirectory, - assembly.name + HotReloadConstants.CompiledAssemblyExtension); - string pdbPath = Path.ChangeExtension(dllPath, ".pdb"); + // Why projectRoot stays the snapshot owner: only the compiled assemblies move to the main + // project for a Virtual Player; its snapshots stay under its own root. + CompiledAssemblyLayout layout = CompiledAssemblyLayout.Resolve(projectRoot); + string dllPath = layout.DllPath(assembly.name); + string pdbPath = layout.PdbPath(assembly.name); if (!File.Exists(dllPath) || !File.Exists(pdbPath)) { return; diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadTypeHome.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadTypeHome.cs index 006595bb3..a65e43a04 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadTypeHome.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadTypeHome.cs @@ -87,10 +87,7 @@ public static HotReloadTypeHome ScriptAssembliesUnderProject(string projectRoot, Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty."); Debug.Assert(!string.IsNullOrEmpty(assemblyName), "assemblyName must not be null or empty."); - string dllPath = Path.Combine( - projectRoot, - HotReloadConstants.ScriptAssembliesRelativeDirectory, - assemblyName + HotReloadConstants.CompiledAssemblyExtension); + string dllPath = CompiledAssemblyLayout.Resolve(projectRoot).DllPath(assemblyName); return ScriptAssemblies(assemblyName, dllPath); } diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadVirtualPlayerProject.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadVirtualPlayerProject.cs index c2dbbe699..f9f574062 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadVirtualPlayerProject.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadVirtualPlayerProject.cs @@ -11,12 +11,14 @@ internal static class HotReloadVirtualPlayerProject // Why the text and the kinds come from one decision: a reason that names a Virtual Player // has to arrive with VirtualPlayer set, or the next step would tell the player to compile // its own project, which has no assemblies to compile. - internal static HotReloadFailureDescription DescribeMissingCompiledAssembly(string projectRoot, string dllPath) + internal static HotReloadFailureDescription DescribeMissingCompiledAssembly( + CompiledAssemblyLayout layout, + string dllPath) { - Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty."); + Debug.Assert(layout != null, "layout must not be null."); Debug.Assert(!string.IsNullOrEmpty(dllPath), "dllPath must not be null or empty."); - if (!CompiledAssemblyLayout.Resolve(projectRoot).IsVirtualPlayer) + if (!layout.IsVirtualPlayer) { return HotReloadFailureDescription.CompiledAssemblyMissing( "Compiled assembly not found at '" + dllPath + "'. Compile the project first.", From 0825dd4f7967452a4490c2154cfe73b35b1261e7 Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 8 Oct 2026 09:25:45 +0900 Subject: [PATCH 04/11] Tell a Virtual Player to compile the main project when its assembly is missing Hot reload now patches a Virtual Player, so the missing-assembly reason and the recommended next action no longer say it cannot. They name the main Editor's project as the one whose compile is missing. --- .../HotReload/Shared/HotReloadConstants.cs | 2 +- .../HotReload/Shared/HotReloadVirtualPlayerProject.cs | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs index a6c24ce69..3fa77955d 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs @@ -740,7 +740,7 @@ public static bool IsPublicizableProjectAssemblyFileName(string fileNameWithoutE // Why the main Editor's project: a Virtual Player loads the script assemblies of the main // project, so only a compile there reaches it. public const string VirtualPlayerRecommendedNextAction = - "This Editor is a Multiplayer Play Mode Virtual Player, which hot reload cannot patch. Run 'uloop compile' against the main Editor's project; the compiled result reaches this player. A patch applied in the main Editor does not."; + "This Editor is a Multiplayer Play Mode Virtual Player and reads the compiled assemblies of the main Editor's project. Run 'uloop compile' against the main Editor's project; the compiled result reaches this player. Then rerun hot reload with this player's project path. A patch applied to the main Editor does not reach this player."; public const string CompiledAssemblyMissingRecommendedNextAction = "The compiled assembly for the file is missing, so there is nothing to fix in the source: run 'uloop compile'."; diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadVirtualPlayerProject.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadVirtualPlayerProject.cs index f9f574062..44c1520dc 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadVirtualPlayerProject.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadVirtualPlayerProject.cs @@ -25,13 +25,13 @@ internal static HotReloadFailureDescription DescribeMissingCompiledAssembly( isVirtualPlayer: false); } - // Why a different reason: a Virtual Player has no compiled assemblies under its own - // root, so "compile first" gives the same answer however many times it is followed. + // Why a different reason: a Virtual Player cannot compile its own assemblies, so only a + // compile of the main Editor's project puts the missing assembly where it reads from. return HotReloadFailureDescription.CompiledAssemblyMissing( "Compiled assembly not found at '" + dllPath + "'. This Editor is a Multiplayer Play Mode " - + "Virtual Player: it loads the script assemblies of the main Editor's project, so hot reload " - + "cannot patch it yet. The edit reaches this player through a compile; a patch applied to the " - + "main Editor does not reach it.", + + "Virtual Player: it loads the script assemblies of the main Editor's project at '" + + layout.MainProjectRoot + "', and that project has not compiled this assembly yet. Compile the " + + "main Editor's project first; a patch applied to the main Editor does not reach this player.", isVirtualPlayer: true); } } From 6633bd181c2c494663a1de5d8715e7cc1a0e276d Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 8 Oct 2026 09:26:52 +0900 Subject: [PATCH 05/11] Add failing tests for references Unity lists relative to a Virtual Player root A Virtual Player's compilation pipeline lists the main project's script assemblies as ../../ScriptAssemblies/.dll. The worker reference list and the resolver search directories resolve such paths against the current directory and drop them. The project root is threaded through both so the tests can name it; the resolution itself follows. --- ...HotReloadResolverSearchDirectoriesTests.cs | 38 +++++++++++++++++-- .../HotReloadShimReferenceBuilderTests.cs | 38 ++++++++++++++++++- .../HotReloadGroupWorkerInputBuilder.cs | 1 + .../HotReloadIntroducedTypePreparation.cs | 2 +- .../HotReloadResolverSearchDirectories.cs | 3 +- .../Patching/HotReloadShimReferenceBuilder.cs | 4 +- 6 files changed, 78 insertions(+), 8 deletions(-) diff --git a/Assets/Tests/Editor/HotReload/HotReloadResolverSearchDirectoriesTests.cs b/Assets/Tests/Editor/HotReload/HotReloadResolverSearchDirectoriesTests.cs index f5bbaf592..0e81ed18b 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadResolverSearchDirectoriesTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadResolverSearchDirectoriesTests.cs @@ -64,7 +64,7 @@ public void Collect_IncludesDirectoriesOfTransitivelyReferencedPrecompiledAssemb string pluginPath = CreateEmptyFile("plugins", "Plugin.dll"); ReferenceGraph graph = CreateReferenceGraph(pluginPath, Array.Empty()); - IReadOnlyCollection directories = HotReloadResolverSearchDirectories.Collect(graph.Tests); + IReadOnlyCollection directories = HotReloadResolverSearchDirectories.Collect(_tempRoot, graph.Tests); Assert.That(directories, Does.Contain(FullDirectoryOf(graph.Tests.outputPath))); Assert.That(directories, Does.Contain(FullDirectoryOf(pluginPath))); @@ -89,7 +89,7 @@ public void Collect_OrdersOwnReferencesBeforeTransitiveOnes() string ownPath = CreateEmptyFile("own", "Own.dll"); ReferenceGraph graph = CreateReferenceGraph(pluginPath, new[] { ownPath }); - List directories = new List(HotReloadResolverSearchDirectories.Collect(graph.Tests)); + List directories = new List(HotReloadResolverSearchDirectories.Collect(_tempRoot, graph.Tests)); int ownIndex = directories.IndexOf(FullDirectoryOf(ownPath)); int pluginIndex = directories.IndexOf(FullDirectoryOf(pluginPath)); @@ -112,7 +112,7 @@ public void Collect_ForHotReloadTestAssembly_ReachesTheCodeAnalysisPluginDirecto Does.Not.Contain(pluginDirectory), "Unity listed the plugin among the test assembly's own references, so the transitive walk is not what reaches it."); - Assert.That(HotReloadResolverSearchDirectories.Collect(testAssembly), Does.Contain(pluginDirectory)); + Assert.That(HotReloadResolverSearchDirectories.Collect(Directory.GetCurrentDirectory(), testAssembly), Does.Contain(pluginDirectory)); } /// @@ -140,7 +140,7 @@ public void GetOrCreatePublicizedCopy_ResolvesAnAssemblyThatOnlyATransitiveRefer string publicized = ReferencePublicizer.GetOrCreatePublicizedCopy( image.Home, - HotReloadResolverSearchDirectories.Collect(graph.Tests)); + HotReloadResolverSearchDirectories.Collect(_tempRoot, graph.Tests)); Assert.That(File.Exists(publicized), Is.True); } @@ -174,6 +174,36 @@ public void TryBuildShimReferencePaths_PublicizesTheTargetWithTransitiveSearchDi Assert.That(File.Exists(result.References[0]), Is.True); } + /// + /// Verifies a reference Unity lists relative to a Virtual Player's root, as it does for the + /// main project's script assemblies, is resolved against that root and not the current + /// directory, so its directory becomes a search directory. + /// + [Test] + public void Collect_ResolvesARelativeReferenceAgainstTheProjectRoot() + { + const string relativeReference = "../../ScriptAssemblies/Fixture.Other.dll"; + string scriptAssembliesPath = CreateEmptyFile(Path.Combine("Library", "ScriptAssemblies"), "Fixture.Other.dll"); + string playerRoot = Path.Combine(_tempRoot, "Library", "VP", "mppm1"); + Directory.CreateDirectory(playerRoot); + Assert.That( + File.Exists(relativeReference), + Is.False, + "The reference must not exist relative to the current directory, or the test cannot tell the two apart."); + UnityCompilationAssembly root = new UnityCompilationAssembly( + "Tests", + ScriptPath("Tests.dll"), + Array.Empty(), + Array.Empty(), + Array.Empty(), + new[] { relativeReference }, + AssemblyFlags.EditorAssembly); + + IReadOnlyCollection directories = HotReloadResolverSearchDirectories.Collect(playerRoot, root); + + Assert.That(directories, Does.Contain(FullDirectoryOf(scriptAssembliesPath))); + } + // Tests -> Game -> Core -> plugin: two steps, so a walk that adds only the direct // reference's own references still misses the plugin. private ReferenceGraph CreateReferenceGraph(string pluginPath, string[] testsOwnReferences) diff --git a/Assets/Tests/Editor/HotReload/HotReloadShimReferenceBuilderTests.cs b/Assets/Tests/Editor/HotReload/HotReloadShimReferenceBuilderTests.cs index 1489a2716..9865d5d29 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadShimReferenceBuilderTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadShimReferenceBuilderTests.cs @@ -57,11 +57,47 @@ public void BuildWorkerReferencePaths_WhenReferencesAlreadyContainTarget_ListsTa AssemblyFlags.None); HotReloadTypeHome targetHome = HotReloadTypeHome.ScriptAssemblies("Fixture.Target", targetPath); - string[] paths = HotReloadShimReferenceBuilder.BuildWorkerReferencePaths(compilationAssembly, targetHome); + string[] paths = HotReloadShimReferenceBuilder.BuildWorkerReferencePaths(_tempRoot, compilationAssembly, targetHome); Assert.That(paths, Is.EqualTo(new[] { Path.GetFullPath(otherPath), Path.GetFullPath(targetPath) })); } + /// + /// Verifies that a reference Unity lists relative to a Virtual Player's root, as it does for + /// the main project's script assemblies, is resolved against that root and not the current + /// directory. + /// + [Test] + public void BuildWorkerReferencePaths_RelativeReference_IsResolvedAgainstTheProjectRoot() + { + const string relativeReference = "../../ScriptAssemblies/Fixture.Other.dll"; + string scriptAssembliesDirectory = Path.Combine(_tempRoot, "Library", "ScriptAssemblies"); + string otherPath = Path.Combine(scriptAssembliesDirectory, "Fixture.Other.dll"); + string targetPath = Path.Combine(_tempRoot, "Fixture.Target.dll"); + string playerRoot = Path.Combine(_tempRoot, "Library", "VP", "mppm1"); + Directory.CreateDirectory(scriptAssembliesDirectory); + Directory.CreateDirectory(playerRoot); + File.WriteAllBytes(otherPath, new byte[] { 0 }); + File.WriteAllBytes(targetPath, new byte[] { 0 }); + Assert.That( + File.Exists(relativeReference), + Is.False, + "The reference must not exist relative to the current directory, or the test cannot tell the two apart."); + UnityCompilationAssembly compilationAssembly = new UnityCompilationAssembly( + "Fixture.Target", + targetPath, + Array.Empty(), + Array.Empty(), + Array.Empty(), + new[] { relativeReference, targetPath }, + AssemblyFlags.None); + HotReloadTypeHome targetHome = HotReloadTypeHome.ScriptAssemblies("Fixture.Target", targetPath); + + string[] paths = HotReloadShimReferenceBuilder.BuildWorkerReferencePaths(playerRoot, compilationAssembly, targetHome); + + Assert.That(paths, Does.Contain(Path.GetFullPath(otherPath))); + } + /// /// Verifies that a null home and a home whose DLL does not exist add no publicized reference. /// diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadGroupWorkerInputBuilder.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadGroupWorkerInputBuilder.cs index 0b4791625..87b4dc37b 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadGroupWorkerInputBuilder.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadGroupWorkerInputBuilder.cs @@ -33,6 +33,7 @@ internal static TransformWorkerInputDto BuildWorkerInput( sources = sources, defines = firstFile.CompilationAssembly.defines ?? Array.Empty(), referencePaths = HotReloadShimReferenceBuilder.BuildWorkerReferencePaths( + firstFile.ProjectRoot, firstFile.CompilationAssembly, firstFile.Home), targetTypesAssemblyPath = Path.GetFullPath(firstFile.Home.DllPath), diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadIntroducedTypePreparation.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadIntroducedTypePreparation.cs index dbb9ef7a9..44bc875e5 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadIntroducedTypePreparation.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadIntroducedTypePreparation.cs @@ -140,7 +140,7 @@ private static async Task CompileArtifact firstFile.Home, collaborators.Domain, firstFile.ProjectRoot, - HotReloadResolverSearchDirectories.Collect(firstFile.CompilationAssembly), + HotReloadResolverSearchDirectories.Collect(firstFile.ProjectRoot, firstFile.CompilationAssembly), collaborators.InternalAccessGrant.IsAvailable); if (!references.Success) { diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Patching/HotReloadResolverSearchDirectories.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Patching/HotReloadResolverSearchDirectories.cs index ae33b28ba..a8118e156 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Patching/HotReloadResolverSearchDirectories.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Patching/HotReloadResolverSearchDirectories.cs @@ -16,8 +16,9 @@ namespace io.github.hatayama.UnityCliLoop.FirstPartyTools /// internal static class HotReloadResolverSearchDirectories { - internal static IReadOnlyCollection Collect(UnityCompilationAssembly rootAssembly) + internal static IReadOnlyCollection Collect(string projectRoot, UnityCompilationAssembly rootAssembly) { + Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty."); Debug.Assert(rootAssembly != null, "rootAssembly must not be null."); List orderedDirectories = new List(); diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Patching/HotReloadShimReferenceBuilder.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Patching/HotReloadShimReferenceBuilder.cs index 0af6c3b20..d7d0ca5fd 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Patching/HotReloadShimReferenceBuilder.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Patching/HotReloadShimReferenceBuilder.cs @@ -23,9 +23,11 @@ namespace io.github.hatayama.UnityCliLoop.FirstPartyTools internal static class HotReloadShimReferenceBuilder { internal static string[] BuildWorkerReferencePaths( + string projectRoot, UnityCompilationAssembly compilationAssembly, HotReloadTypeHome targetHome) { + Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty."); Debug.Assert(targetHome != null, "targetHome must not be null."); List paths = new List(); @@ -422,7 +424,7 @@ private static List BuildShimReferencePaths( // resolves netstandard/engine modules without hardcoding Editor Contents layouts. // Referenced assemblies count transitively: Cecil may need a DLL only they list. IReadOnlyCollection resolverSearchDirectories = - HotReloadResolverSearchDirectories.Collect(compilationAssembly); + HotReloadResolverSearchDirectories.Collect(projectRoot, compilationAssembly); List references = new List(); string publicizedTarget = ReferencePublicizer.GetOrCreatePublicizedCopy( From eee82d6013c56367fb203d8e27ee940760d0c286 Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 8 Oct 2026 09:27:42 +0900 Subject: [PATCH 06/11] Resolve Unity's relative assembly references against the project root The worker reference list, the shim reference list and the resolver search directories now join each reference with the project root before checking it exists. Absolute references are unchanged, and in an ordinary project the current directory is the root, so only a Virtual Player sees a difference: its ../../ScriptAssemblies references now reach the main project's DLLs. --- .../HotReloadResolverSearchDirectories.cs | 20 ++++++++++++++---- .../Patching/HotReloadShimReferenceBuilder.cs | 21 +++++++++++++++---- 2 files changed, 33 insertions(+), 8 deletions(-) diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Patching/HotReloadResolverSearchDirectories.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Patching/HotReloadResolverSearchDirectories.cs index a8118e156..22560e40a 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Patching/HotReloadResolverSearchDirectories.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Patching/HotReloadResolverSearchDirectories.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.IO; using UnityEngine; @@ -36,7 +37,7 @@ internal static IReadOnlyCollection Collect(string projectRoot, UnityCom UnityCompilationAssembly current = pending.Dequeue(); // allReferences already dereferences assemblyReferences, so a null array would have // thrown here; no null guard after this point. - List unseenReferences = TakeUnseenReferences(current.allReferences, seenReferences); + List unseenReferences = TakeUnseenReferences(projectRoot, current.allReferences, seenReferences); foreach (string directory in ReferencePublicizer.CollectResolverSearchDirectories(unseenReferences)) { if (seenDirectories.Add(directory)) @@ -60,14 +61,25 @@ internal static IReadOnlyCollection Collect(string projectRoot, UnityCom // Why skip a path seen earlier: the engine references, about 230 per assembly, repeat in // every assembly of the closure, and each one would cost a File.Exists. A path seen earlier // already added its directory at the same or a shallower level, or did not exist then either. - private static List TakeUnseenReferences(string[] references, HashSet seenReferences) + // Why against the root: a Virtual Player's script assemblies are listed relative to its root + // (../../ScriptAssemblies), and its process need not run there. + private static List TakeUnseenReferences( + string projectRoot, + string[] references, + HashSet seenReferences) { List unseenReferences = new List(); foreach (string reference in references) { - if (seenReferences.Add(reference)) + if (string.IsNullOrEmpty(reference)) { - unseenReferences.Add(reference); + continue; + } + + string fullReference = Path.GetFullPath(Path.Combine(projectRoot, reference)); + if (seenReferences.Add(fullReference)) + { + unseenReferences.Add(fullReference); } } diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Patching/HotReloadShimReferenceBuilder.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Patching/HotReloadShimReferenceBuilder.cs index d7d0ca5fd..2d9548abc 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Patching/HotReloadShimReferenceBuilder.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Patching/HotReloadShimReferenceBuilder.cs @@ -35,9 +35,17 @@ internal static string[] BuildWorkerReferencePaths( { foreach (string reference in compilationAssembly.allReferences) { - if (!string.IsNullOrEmpty(reference) && File.Exists(reference)) + if (string.IsNullOrEmpty(reference)) { - paths.Add(Path.GetFullPath(reference)); + continue; + } + + // Why against the root: a Virtual Player's script assemblies are listed relative + // to its root (../../ScriptAssemblies), and its process need not run there. + string fullReference = Path.GetFullPath(Path.Combine(projectRoot, reference)); + if (File.Exists(fullReference)) + { + paths.Add(fullReference); } } } @@ -453,12 +461,17 @@ private static List BuildShimReferencePaths( string shimTargetAssemblyName = Path.GetFileNameWithoutExtension(fullTarget); foreach (string reference in compilationAssembly.allReferences) { - if (string.IsNullOrEmpty(reference) || !File.Exists(reference)) + if (string.IsNullOrEmpty(reference)) { continue; } - string fullReference = Path.GetFullPath(reference); + // Resolved against the root for the same reason as in BuildWorkerReferencePaths. + string fullReference = Path.GetFullPath(Path.Combine(projectRoot, reference)); + if (!File.Exists(fullReference)) + { + continue; + } if (string.Equals(fullReference, fullTarget, StringComparison.OrdinalIgnoreCase)) { // Replaced by the publicized copy above. From 04d434ef3212970668d73fb9fe63cc457d04d4e7 Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 8 Oct 2026 09:28:28 +0900 Subject: [PATCH 07/11] Add failing tests for checking the pipeline's output path against hot reload's Hot reload decides where a compiled assembly lives from the project root's shape. These tests pin that a reported output path elsewhere fails instead of letting hot reload read a different DLL than the pipeline wrote. --- ...HotReloadCompiledAssemblyPathCheckTests.cs | 117 ++++++++++++++++++ ...loadCompiledAssemblyPathCheckTests.cs.meta | 11 ++ .../HotReloadCompiledAssemblyPathCheck.cs | 16 +++ ...HotReloadCompiledAssemblyPathCheck.cs.meta | 11 ++ 4 files changed, 155 insertions(+) create mode 100644 Assets/Tests/Editor/HotReload/HotReloadCompiledAssemblyPathCheckTests.cs create mode 100644 Assets/Tests/Editor/HotReload/HotReloadCompiledAssemblyPathCheckTests.cs.meta create mode 100644 Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCompiledAssemblyPathCheck.cs create mode 100644 Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCompiledAssemblyPathCheck.cs.meta diff --git a/Assets/Tests/Editor/HotReload/HotReloadCompiledAssemblyPathCheckTests.cs b/Assets/Tests/Editor/HotReload/HotReloadCompiledAssemblyPathCheckTests.cs new file mode 100644 index 000000000..e01d6a833 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadCompiledAssemblyPathCheckTests.cs @@ -0,0 +1,117 @@ +using System; +using System.IO; + +using NUnit.Framework; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload +{ + /// + /// Covers the check that the output path the compilation pipeline reports for an assembly is + /// the DLL hot reload reads, for an ordinary project and for a Multiplayer Play Mode Virtual Player. + /// + public sealed class HotReloadCompiledAssemblyPathCheckTests + { + private string _mainRoot; + private string _playerRoot; + + [SetUp] + public void SetUp() + { + string workspace = Path.Combine(Path.GetTempPath(), "uloop-test-" + Guid.NewGuid().ToString("N")); + _mainRoot = Path.Combine(workspace, "project"); + _playerRoot = Path.Combine(_mainRoot, "Library", "VP", "mppm1"); + } + + /// + /// What: an ordinary project whose pipeline reports Library/ScriptAssemblies/A.dll passes. + /// + [Test] + public void DescribeOutputPathMismatch_OrdinaryRootReportingItsScriptAssemblies_ReturnsNull() + { + CompiledAssemblyLayout layout = CompiledAssemblyLayout.Resolve(_mainRoot); + + Assert.That( + HotReloadCompiledAssemblyPathCheck.DescribeOutputPathMismatch(layout, "A", "Library/ScriptAssemblies/A.dll"), + Is.Null); + } + + /// + /// What: a Virtual Player whose pipeline reports ../../ScriptAssemblies/A.dll, the main + /// project's DLL, passes. + /// + [Test] + public void DescribeOutputPathMismatch_VirtualPlayerReportingTheMainProjectsDll_ReturnsNull() + { + CompiledAssemblyLayout layout = CompiledAssemblyLayout.Resolve(_playerRoot); + + Assert.That( + HotReloadCompiledAssemblyPathCheck.DescribeOutputPathMismatch(layout, "A", "../../ScriptAssemblies/A.dll"), + Is.Null); + } + + /// + /// What: a Virtual Player whose pipeline reports a DLL under its own root fails as a Virtual + /// Player's missing assembly, naming both the reported path and the directory hot reload reads. + /// + [Test] + public void DescribeOutputPathMismatch_VirtualPlayerReportingItsOwnLibrary_FailsNamingBothPaths() + { + CompiledAssemblyLayout layout = CompiledAssemblyLayout.Resolve(_playerRoot); + + HotReloadFailureDescription failure = + HotReloadCompiledAssemblyPathCheck.DescribeOutputPathMismatch(layout, "A", "Library/ScriptAssemblies/A.dll"); + + Assert.That(failure, Is.Not.Null); + Assert.That( + failure.Kinds, + Is.EqualTo(HotReloadFailureKinds.CompiledAssemblyMissing | HotReloadFailureKinds.VirtualPlayer)); + Assert.That( + failure.Message, + Does.Contain(Path.GetFullPath(Path.Combine(_playerRoot, "Library", "ScriptAssemblies", "A.dll")))); + Assert.That(failure.Message, Does.Contain(layout.CompiledAssembliesDirectory)); + } + + /// + /// What: an absolute output path that names the DLL hot reload reads passes. + /// + [Test] + public void DescribeOutputPathMismatch_AbsoluteMatchingPath_ReturnsNull() + { + CompiledAssemblyLayout layout = CompiledAssemblyLayout.Resolve(_mainRoot); + + Assert.That( + HotReloadCompiledAssemblyPathCheck.DescribeOutputPathMismatch(layout, "A", layout.DllPath("A")), + Is.Null); + } + + /// + /// What: an ordinary project whose pipeline reports another assembly's DLL fails as a missing + /// assembly that is not a Virtual Player's. + /// + [Test] + public void DescribeOutputPathMismatch_OrdinaryRootReportingAnotherAssembly_Fails() + { + CompiledAssemblyLayout layout = CompiledAssemblyLayout.Resolve(_mainRoot); + + HotReloadFailureDescription failure = + HotReloadCompiledAssemblyPathCheck.DescribeOutputPathMismatch(layout, "A", "Library/ScriptAssemblies/B.dll"); + + Assert.That(failure, Is.Not.Null); + Assert.That(failure.Kinds, Is.EqualTo(HotReloadFailureKinds.CompiledAssemblyMissing)); + } + + /// + /// What: when the pipeline reports no output path, nothing is compared. + /// + [TestCase("")] + [TestCase(null)] + public void DescribeOutputPathMismatch_NoOutputPath_ReturnsNull(string outputPath) + { + CompiledAssemblyLayout layout = CompiledAssemblyLayout.Resolve(_mainRoot); + + Assert.That(HotReloadCompiledAssemblyPathCheck.DescribeOutputPathMismatch(layout, "A", outputPath), Is.Null); + } + } +} diff --git a/Assets/Tests/Editor/HotReload/HotReloadCompiledAssemblyPathCheckTests.cs.meta b/Assets/Tests/Editor/HotReload/HotReloadCompiledAssemblyPathCheckTests.cs.meta new file mode 100644 index 000000000..a169ea056 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadCompiledAssemblyPathCheckTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: a21ed7923f0e54fb2859cccf400141ee +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCompiledAssemblyPathCheck.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCompiledAssemblyPathCheck.cs new file mode 100644 index 000000000..08c1386dc --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCompiledAssemblyPathCheck.cs @@ -0,0 +1,16 @@ +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Checks that the compilation pipeline puts an assembly where hot reload reads it from. + /// + internal static class HotReloadCompiledAssemblyPathCheck + { + internal static HotReloadFailureDescription DescribeOutputPathMismatch( + CompiledAssemblyLayout layout, + string assemblyName, + string outputPath) + { + return null; + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCompiledAssemblyPathCheck.cs.meta b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCompiledAssemblyPathCheck.cs.meta new file mode 100644 index 000000000..0cf24334d --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCompiledAssemblyPathCheck.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 733e44a6979c447c3b9f75fcbb42e056 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: From 55f8bc4016bdc0bb02ef0daea70ee4bed9a59153 Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 8 Oct 2026 09:29:05 +0900 Subject: [PATCH 08/11] Fail hot reload when the pipeline's output path is not the DLL it reads ResolvePatchTarget now compares the compilation pipeline's outputPath with the DLL path the layout built, after the DLL is found and before the MVID guard. A mismatch fails as a missing compiled assembly naming both paths, so a layout hot reload does not know never patches from the wrong DLL. --- .../HotReloadCompiledAssemblyPathCheck.cs | 34 ++++++++++++++++++- .../HotReload/HotReloadPatchTargetSupport.cs | 11 ++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCompiledAssemblyPathCheck.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCompiledAssemblyPathCheck.cs index 08c1386dc..6c11e9ba6 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCompiledAssemblyPathCheck.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCompiledAssemblyPathCheck.cs @@ -1,3 +1,8 @@ +using System; +using System.IO; + +using UnityEngine; + namespace io.github.hatayama.UnityCliLoop.FirstPartyTools { /// @@ -5,12 +10,39 @@ namespace io.github.hatayama.UnityCliLoop.FirstPartyTools /// internal static class HotReloadCompiledAssemblyPathCheck { + // Why only a check: hot reload decides where an assembly lives from the root's shape, since + // most places that need the path have only the assembly's name. Comparing here keeps that + // decision from silently reading another DLL if Unity ever writes assemblies elsewhere. internal static HotReloadFailureDescription DescribeOutputPathMismatch( CompiledAssemblyLayout layout, string assemblyName, string outputPath) { - return null; + Debug.Assert(layout != null, "layout must not be null."); + Debug.Assert(!string.IsNullOrEmpty(assemblyName), "assemblyName must not be null or empty."); + + if (string.IsNullOrEmpty(outputPath)) + { + return null; + } + + // Path.Combine returns an absolute outputPath unchanged, so one expression covers both forms. + string reported = Path.GetFullPath(Path.Combine(layout.ProjectRoot, outputPath)); + string expected = Path.GetFullPath(layout.DllPath(assemblyName)); + // Windows paths are case-insensitive, as ReferencePublicizer compares them. + StringComparison comparison = Application.platform == RuntimePlatform.WindowsEditor + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; + if (string.Equals(reported, expected, comparison)) + { + return null; + } + + return HotReloadFailureDescription.CompiledAssemblyMissing( + "The compilation pipeline reports '" + assemblyName + "' at '" + reported + + "', but hot reload reads compiled assemblies from '" + layout.CompiledAssembliesDirectory + + "'. Compile the project and retry; if this persists, the project layout is one hot reload does not know.", + isVirtualPlayer: layout.IsVirtualPlayer); } } } diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadPatchTargetSupport.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadPatchTargetSupport.cs index e04a9a774..1ab778337 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadPatchTargetSupport.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadPatchTargetSupport.cs @@ -112,6 +112,17 @@ internal static HotReloadPatchTargetResolution ResolvePatchTarget( new HotReloadFileProcessResult(outcomes, warnings, 0)); } + HotReloadFailureDescription outputPathFailure = HotReloadCompiledAssemblyPathCheck.DescribeOutputPathMismatch( + layout, + assemblyName, + compilationAssembly.outputPath); + if (outputPathFailure != null) + { + outcomes.Add(HotReloadMethodOutcome.FailedBecause("(file)", outputPathFailure, assemblyResolvePath)); + return HotReloadPatchTargetResolution.EarlyExit( + new HotReloadFileProcessResult(outcomes, warnings, 0)); + } + HotReloadFailureDescription mvidGuardFailure = CheckMvidGuard(home); if (mvidGuardFailure != null) { From 8ac8854715045b82f52aa98f971bfb2dcf0d7617 Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 8 Oct 2026 09:30:09 +0900 Subject: [PATCH 09/11] Pin that a Virtual Player snapshots the main project's DLL under its own root The capture reads the compiled assembly from the main project and writes the snapshot below the player's Library/UloopHotReload, creating no hot reload state under the main project. Forcing the Virtual Player check to false makes this test fail along with the layout's Virtual Player cases. --- ...tReloadSnapshotAssemblyEnumerationTests.cs | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/Assets/Tests/Editor/HotReload/HotReloadSnapshotAssemblyEnumerationTests.cs b/Assets/Tests/Editor/HotReload/HotReloadSnapshotAssemblyEnumerationTests.cs index 3635427ef..f2db96c95 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadSnapshotAssemblyEnumerationTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadSnapshotAssemblyEnumerationTests.cs @@ -157,6 +157,46 @@ public void CollectSnapshotAssemblies_KeepsOnlyAssembliesWithSourcesAndCompiledO Assert.That(result[0].SourceFiles, Is.EqualTo(new[] { SourceRelativePath })); } + /// + /// Verifies a Virtual Player's capture reads the main project's compiled assembly and writes + /// the snapshot under the player's own root, leaving no hot reload state under the main project. + /// + [Test] + public void CaptureAssemblies_ForVirtualPlayerRoot_ReadsTheMainProjectsDllAndSnapshotsUnderThePlayer() + { + string mainRoot = Path.Combine(Path.GetTempPath(), "uloop-test-" + Guid.NewGuid().ToString("N")); + try + { + string scriptAssembliesDirectory = Path.Combine(mainRoot, "Library", "ScriptAssemblies"); + Directory.CreateDirectory(scriptAssembliesDirectory); + string sourceDllPath = typeof(HotReloadSnapshotAssemblyEnumerationTests).Assembly.Location; + string dllPath = Path.Combine(scriptAssembliesDirectory, "Fixture" + HotReloadConstants.CompiledAssemblyExtension); + File.Copy(sourceDllPath, dllPath); + File.Copy(Path.ChangeExtension(sourceDllPath, ".pdb"), Path.ChangeExtension(dllPath, ".pdb")); + string playerRoot = Path.Combine(mainRoot, "Library", "VP", "mppm1"); + Directory.CreateDirectory(Path.Combine(playerRoot, "Assets")); + File.WriteAllText(Path.Combine(playerRoot, "Assets", "Fixture.cs"), "class Fixture {}\n"); + string mvid = HotReloadSourceSnapshotter.ReadAssemblyMvid(dllPath); + + HotReloadSourceSnapshotter.CaptureAssemblies(playerRoot, new[] { CreateAssembly("Fixture") }); + + string playerSnapshotSource = Path.Combine( + playerRoot, + HotReloadConstants.SourceSnapshotRelativeDirectory, + "Fixture-" + mvid, + HotReloadSourceSnapshotter.HashProjectRelativePath(SourceRelativePath) + ".cs"); + Assert.That(File.Exists(playerSnapshotSource), Is.True); + Assert.That(Directory.Exists(Path.Combine(mainRoot, "Library", "UloopHotReload")), Is.False); + } + finally + { + if (Directory.Exists(mainRoot)) + { + Directory.Delete(mainRoot, true); + } + } + } + // Copies this test assembly's own DLL and PDB, so the fixture has a real image for Cecil to read. private string PlantCompiledAssembly(string assemblyName) { From 1878fd919724e96456e4a1d4b5609c6b0a646482 Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 8 Oct 2026 09:30:46 +0900 Subject: [PATCH 10/11] Document that hot reload patches a Multiplayer Play Mode Virtual Player The Multiplayer Play Mode reference now explains how to send hot reload to a player, that the player reads the main project's compiled assemblies but keeps its own patch state, and that patches do not cross between Editors. The scope table row now covers only the case where the main project has not compiled the assembly. --- .../references/multiplayer-play-mode.md | 23 ++++++++++--------- .../references/scope-and-limits.md | 2 +- .../references/multiplayer-play-mode.md | 23 ++++++++++--------- .../references/scope-and-limits.md | 2 +- .../Skill/references/multiplayer-play-mode.md | 23 ++++++++++--------- .../Skill/references/scope-and-limits.md | 2 +- 6 files changed, 39 insertions(+), 36 deletions(-) diff --git a/.agents/skills/uloop-control-play-mode/references/multiplayer-play-mode.md b/.agents/skills/uloop-control-play-mode/references/multiplayer-play-mode.md index b715e2aef..29e02aa42 100644 --- a/.agents/skills/uloop-control-play-mode/references/multiplayer-play-mode.md +++ b/.agents/skills/uloop-control-play-mode/references/multiplayer-play-mode.md @@ -49,17 +49,18 @@ main Editor) do not block each other. ## Hot reload -- A hot-reload patch lives in the Editor process it was applied to. A patch applied to the - main Editor does not reach the Virtual Players, and a player's own - `uloop --project-path /Library/VP/mppm hot-reload --status` reports no - active patch. -- Hot reload cannot patch a Virtual Player yet: a player loads the main project's - `Library/ScriptAssemblies` and has none under its own root. `hot-reload --files ...` sent - to a player reports the file as `Failed`. Whether the CLI then compiles in that player - follows `--compile-on-skip`, as for any unapplied edit: when it compiles, the edit comes in - (`Outcome` is `ReplacedByCompile`); when the compile is held (`CompileFallback` is - `HeldForPlayMode`: `auto`, the default, while that player is in Play Mode), the edit has not - reached the player. +- A hot-reload patch lives in the Editor process it was applied to. To patch a Virtual + Player, send the command to that player: + `uloop --project-path /Library/VP/mppm hot-reload --files ...`. +- A player reads the main project's `Library/ScriptAssemblies` and keeps its own hot-reload + state (the `ActivePatchTotal` that `--status` reports, and the source snapshots) under its own + `Library/UloopHotReload/`. +- A patch applied to the main Editor does not reach the players, and a patch applied to a player + does not reach the main Editor or the other players. A compile of the main Editor's project + reaches every player, and clears the patches a player holds. +- When the main Editor's project has never compiled the edited assembly, a player reports the + file as `Failed` with `Compiled assembly not found ... Compile the main Editor's project + first.` ## Known limitations diff --git a/.agents/skills/uloop-hot-reload/references/scope-and-limits.md b/.agents/skills/uloop-hot-reload/references/scope-and-limits.md index 25e9f5d6b..236af49f5 100644 --- a/.agents/skills/uloop-hot-reload/references/scope-and-limits.md +++ b/.agents/skills/uloop-hot-reload/references/scope-and-limits.md @@ -430,7 +430,7 @@ source on disk. When a run skips a method it had patched before, `Warnings` name | File does not belong to any compiled assembly | Per-file entry with `Method` = `(file)`; only `Assets/` and `Packages/` sources resolve | | Resolved assembly name is missing from CompilationPipeline | Per-file entry with `Method` = `(file)`; Unity may have mapped a not-yet-imported `.asmdef` onto a predefined assembly. Run `uloop compile` first | | Script is not in the last compiled assembly's source list and its assembly membership cannot be confirmed | Per-file entry with `Method` = `(file)`; a new file passed with `--files` is hot-reloadable when its membership in an existing, unchanged compiled assembly is confirmed (`.asmdef` / `.asmref` boundaries are checked when present; a predefined assembly with none also passes), but fails when the Editor is not ready or an `.asmdef` / `.asmref` on its path was added, deleted, or changed since the last import — run `uloop compile` first | -| The Editor is a Multiplayer Play Mode Virtual Player | Per-file entry with `Method` = `(file)`; a Virtual Player has no compiled assemblies under its own project root, so hot reload cannot patch it yet. The edit reaches that player only through a compile: the CLI's compile fallback brings it in when `--compile-on-skip` lets the compile run, and `auto` holds it while that player is in Play Mode (`CompileFallback` is `HeldForPlayMode`). A patch applied to the main Editor does not reach Virtual Players (each is a separate Editor process) | +| The Editor is a Multiplayer Play Mode Virtual Player and the main Editor's project has not compiled the assembly | Per-file entry with `Method` = `(file)`; a Virtual Player reads the main Editor's `Library/ScriptAssemblies`, and the reason says to compile the main Editor's project first. Until then the edit reaches that player only through a compile: the CLI's compile fallback brings it in when `--compile-on-skip` lets the compile run, and `auto` holds it while that player is in Play Mode (`CompileFallback` is `HeldForPlayMode`). A patch applied to the main Editor does not reach Virtual Players (each is a separate Editor process) | | The Editor is compiling or importing when the request arrives, or starts to before the reload is applied | Per-file entry with `Method` = `(file)`; nothing in the source needs a change. The response sets `RetryAfterEditorReady`, and the CLI waits for the Editor to settle (up to 10 minutes) and applies the same request again once; if Unity's own compile already took the edit in, the second apply reports `NothingToApply`. A second refusal falls through to `--compile-on-skip`. | | Another uloop command still holds the Editor when the request arrives (for example a `uloop compile` sent a moment earlier) | The request is not run; the CLI does not retry against the busy Editor and does not bring it to the front. It waits for that command to finish and the Editor to be ready (up to 10 minutes), then sends the same request once. `EditorReadyRetryNote` names the command it waited for and `Timing.EditorReadyWaitMs` is the wait. A busy answer to that one request is reported as `UNITY_SERVER_BUSY` without another wait. While a cancelled `execute-dynamic-code` request still holds the Editor, the wait sends the request again every 5 seconds, because the Editor takes such a request's slot back only when another request arrives. When the command that ran was a compile that already took the edit in, give `--files`: with no files the Editor selects changed files anew, finds none, and fails validation instead of reporting `NothingToApply` | | Loaded assembly differs from the one on disk (pending compile) | Run `uloop compile` first, then retry | diff --git a/.claude/skills/uloop-control-play-mode/references/multiplayer-play-mode.md b/.claude/skills/uloop-control-play-mode/references/multiplayer-play-mode.md index b715e2aef..29e02aa42 100644 --- a/.claude/skills/uloop-control-play-mode/references/multiplayer-play-mode.md +++ b/.claude/skills/uloop-control-play-mode/references/multiplayer-play-mode.md @@ -49,17 +49,18 @@ main Editor) do not block each other. ## Hot reload -- A hot-reload patch lives in the Editor process it was applied to. A patch applied to the - main Editor does not reach the Virtual Players, and a player's own - `uloop --project-path /Library/VP/mppm hot-reload --status` reports no - active patch. -- Hot reload cannot patch a Virtual Player yet: a player loads the main project's - `Library/ScriptAssemblies` and has none under its own root. `hot-reload --files ...` sent - to a player reports the file as `Failed`. Whether the CLI then compiles in that player - follows `--compile-on-skip`, as for any unapplied edit: when it compiles, the edit comes in - (`Outcome` is `ReplacedByCompile`); when the compile is held (`CompileFallback` is - `HeldForPlayMode`: `auto`, the default, while that player is in Play Mode), the edit has not - reached the player. +- A hot-reload patch lives in the Editor process it was applied to. To patch a Virtual + Player, send the command to that player: + `uloop --project-path /Library/VP/mppm hot-reload --files ...`. +- A player reads the main project's `Library/ScriptAssemblies` and keeps its own hot-reload + state (the `ActivePatchTotal` that `--status` reports, and the source snapshots) under its own + `Library/UloopHotReload/`. +- A patch applied to the main Editor does not reach the players, and a patch applied to a player + does not reach the main Editor or the other players. A compile of the main Editor's project + reaches every player, and clears the patches a player holds. +- When the main Editor's project has never compiled the edited assembly, a player reports the + file as `Failed` with `Compiled assembly not found ... Compile the main Editor's project + first.` ## Known limitations diff --git a/.claude/skills/uloop-hot-reload/references/scope-and-limits.md b/.claude/skills/uloop-hot-reload/references/scope-and-limits.md index 25e9f5d6b..236af49f5 100644 --- a/.claude/skills/uloop-hot-reload/references/scope-and-limits.md +++ b/.claude/skills/uloop-hot-reload/references/scope-and-limits.md @@ -430,7 +430,7 @@ source on disk. When a run skips a method it had patched before, `Warnings` name | File does not belong to any compiled assembly | Per-file entry with `Method` = `(file)`; only `Assets/` and `Packages/` sources resolve | | Resolved assembly name is missing from CompilationPipeline | Per-file entry with `Method` = `(file)`; Unity may have mapped a not-yet-imported `.asmdef` onto a predefined assembly. Run `uloop compile` first | | Script is not in the last compiled assembly's source list and its assembly membership cannot be confirmed | Per-file entry with `Method` = `(file)`; a new file passed with `--files` is hot-reloadable when its membership in an existing, unchanged compiled assembly is confirmed (`.asmdef` / `.asmref` boundaries are checked when present; a predefined assembly with none also passes), but fails when the Editor is not ready or an `.asmdef` / `.asmref` on its path was added, deleted, or changed since the last import — run `uloop compile` first | -| The Editor is a Multiplayer Play Mode Virtual Player | Per-file entry with `Method` = `(file)`; a Virtual Player has no compiled assemblies under its own project root, so hot reload cannot patch it yet. The edit reaches that player only through a compile: the CLI's compile fallback brings it in when `--compile-on-skip` lets the compile run, and `auto` holds it while that player is in Play Mode (`CompileFallback` is `HeldForPlayMode`). A patch applied to the main Editor does not reach Virtual Players (each is a separate Editor process) | +| The Editor is a Multiplayer Play Mode Virtual Player and the main Editor's project has not compiled the assembly | Per-file entry with `Method` = `(file)`; a Virtual Player reads the main Editor's `Library/ScriptAssemblies`, and the reason says to compile the main Editor's project first. Until then the edit reaches that player only through a compile: the CLI's compile fallback brings it in when `--compile-on-skip` lets the compile run, and `auto` holds it while that player is in Play Mode (`CompileFallback` is `HeldForPlayMode`). A patch applied to the main Editor does not reach Virtual Players (each is a separate Editor process) | | The Editor is compiling or importing when the request arrives, or starts to before the reload is applied | Per-file entry with `Method` = `(file)`; nothing in the source needs a change. The response sets `RetryAfterEditorReady`, and the CLI waits for the Editor to settle (up to 10 minutes) and applies the same request again once; if Unity's own compile already took the edit in, the second apply reports `NothingToApply`. A second refusal falls through to `--compile-on-skip`. | | Another uloop command still holds the Editor when the request arrives (for example a `uloop compile` sent a moment earlier) | The request is not run; the CLI does not retry against the busy Editor and does not bring it to the front. It waits for that command to finish and the Editor to be ready (up to 10 minutes), then sends the same request once. `EditorReadyRetryNote` names the command it waited for and `Timing.EditorReadyWaitMs` is the wait. A busy answer to that one request is reported as `UNITY_SERVER_BUSY` without another wait. While a cancelled `execute-dynamic-code` request still holds the Editor, the wait sends the request again every 5 seconds, because the Editor takes such a request's slot back only when another request arrives. When the command that ran was a compile that already took the edit in, give `--files`: with no files the Editor selects changed files anew, finds none, and fails validation instead of reporting `NothingToApply` | | Loaded assembly differs from the one on disk (pending compile) | Run `uloop compile` first, then retry | diff --git a/Packages/src/Editor/FirstPartyTools/ControlPlayMode/Skill/references/multiplayer-play-mode.md b/Packages/src/Editor/FirstPartyTools/ControlPlayMode/Skill/references/multiplayer-play-mode.md index b715e2aef..29e02aa42 100644 --- a/Packages/src/Editor/FirstPartyTools/ControlPlayMode/Skill/references/multiplayer-play-mode.md +++ b/Packages/src/Editor/FirstPartyTools/ControlPlayMode/Skill/references/multiplayer-play-mode.md @@ -49,17 +49,18 @@ main Editor) do not block each other. ## Hot reload -- A hot-reload patch lives in the Editor process it was applied to. A patch applied to the - main Editor does not reach the Virtual Players, and a player's own - `uloop --project-path /Library/VP/mppm hot-reload --status` reports no - active patch. -- Hot reload cannot patch a Virtual Player yet: a player loads the main project's - `Library/ScriptAssemblies` and has none under its own root. `hot-reload --files ...` sent - to a player reports the file as `Failed`. Whether the CLI then compiles in that player - follows `--compile-on-skip`, as for any unapplied edit: when it compiles, the edit comes in - (`Outcome` is `ReplacedByCompile`); when the compile is held (`CompileFallback` is - `HeldForPlayMode`: `auto`, the default, while that player is in Play Mode), the edit has not - reached the player. +- A hot-reload patch lives in the Editor process it was applied to. To patch a Virtual + Player, send the command to that player: + `uloop --project-path /Library/VP/mppm hot-reload --files ...`. +- A player reads the main project's `Library/ScriptAssemblies` and keeps its own hot-reload + state (the `ActivePatchTotal` that `--status` reports, and the source snapshots) under its own + `Library/UloopHotReload/`. +- A patch applied to the main Editor does not reach the players, and a patch applied to a player + does not reach the main Editor or the other players. A compile of the main Editor's project + reaches every player, and clears the patches a player holds. +- When the main Editor's project has never compiled the edited assembly, a player reports the + file as `Failed` with `Compiled assembly not found ... Compile the main Editor's project + first.` ## Known limitations diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Skill/references/scope-and-limits.md b/Packages/src/Editor/FirstPartyTools/HotReload/Skill/references/scope-and-limits.md index 25e9f5d6b..236af49f5 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Skill/references/scope-and-limits.md +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Skill/references/scope-and-limits.md @@ -430,7 +430,7 @@ source on disk. When a run skips a method it had patched before, `Warnings` name | File does not belong to any compiled assembly | Per-file entry with `Method` = `(file)`; only `Assets/` and `Packages/` sources resolve | | Resolved assembly name is missing from CompilationPipeline | Per-file entry with `Method` = `(file)`; Unity may have mapped a not-yet-imported `.asmdef` onto a predefined assembly. Run `uloop compile` first | | Script is not in the last compiled assembly's source list and its assembly membership cannot be confirmed | Per-file entry with `Method` = `(file)`; a new file passed with `--files` is hot-reloadable when its membership in an existing, unchanged compiled assembly is confirmed (`.asmdef` / `.asmref` boundaries are checked when present; a predefined assembly with none also passes), but fails when the Editor is not ready or an `.asmdef` / `.asmref` on its path was added, deleted, or changed since the last import — run `uloop compile` first | -| The Editor is a Multiplayer Play Mode Virtual Player | Per-file entry with `Method` = `(file)`; a Virtual Player has no compiled assemblies under its own project root, so hot reload cannot patch it yet. The edit reaches that player only through a compile: the CLI's compile fallback brings it in when `--compile-on-skip` lets the compile run, and `auto` holds it while that player is in Play Mode (`CompileFallback` is `HeldForPlayMode`). A patch applied to the main Editor does not reach Virtual Players (each is a separate Editor process) | +| The Editor is a Multiplayer Play Mode Virtual Player and the main Editor's project has not compiled the assembly | Per-file entry with `Method` = `(file)`; a Virtual Player reads the main Editor's `Library/ScriptAssemblies`, and the reason says to compile the main Editor's project first. Until then the edit reaches that player only through a compile: the CLI's compile fallback brings it in when `--compile-on-skip` lets the compile run, and `auto` holds it while that player is in Play Mode (`CompileFallback` is `HeldForPlayMode`). A patch applied to the main Editor does not reach Virtual Players (each is a separate Editor process) | | The Editor is compiling or importing when the request arrives, or starts to before the reload is applied | Per-file entry with `Method` = `(file)`; nothing in the source needs a change. The response sets `RetryAfterEditorReady`, and the CLI waits for the Editor to settle (up to 10 minutes) and applies the same request again once; if Unity's own compile already took the edit in, the second apply reports `NothingToApply`. A second refusal falls through to `--compile-on-skip`. | | Another uloop command still holds the Editor when the request arrives (for example a `uloop compile` sent a moment earlier) | The request is not run; the CLI does not retry against the busy Editor and does not bring it to the front. It waits for that command to finish and the Editor to be ready (up to 10 minutes), then sends the same request once. `EditorReadyRetryNote` names the command it waited for and `Timing.EditorReadyWaitMs` is the wait. A busy answer to that one request is reported as `UNITY_SERVER_BUSY` without another wait. While a cancelled `execute-dynamic-code` request still holds the Editor, the wait sends the request again every 5 seconds, because the Editor takes such a request's slot back only when another request arrives. When the command that ran was a compile that already took the edit in, give `--files`: with no files the Editor selects changed files anew, finds none, and fails validation instead of reporting `NothingToApply` | | Loaded assembly differs from the one on disk (pending compile) | Run `uloop compile` first, then retry | From 92ebcc56cdc6353dbb07dd7f078a0cea68239733 Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 8 Oct 2026 10:08:17 +0900 Subject: [PATCH 11/11] Keep a Windows drive root intact and pin the Virtual Player type home Trimming separators turned a drive root such as C:\ into the drive-relative C:, so a path root now stays as it is. The script-assemblies home test builds its expected path from literal segments, which the layout also uses, instead of the slash-joined constant that mixes separators on Windows. A new test pins that a Virtual Player's type home points at the main project's DLL, and the resolver test names the project root the same way the others do. --- .../Editor/HotReload/HotReloadDomainTests.cs | 3 ++- .../HotReloadResolverSearchDirectoriesTests.cs | 4 +++- .../Editor/HotReload/HotReloadTypeHomeTests.cs | 18 ++++++++++++++++++ .../ScriptPath/CompiledAssemblyLayout.cs | 10 ++++++---- 4 files changed, 29 insertions(+), 6 deletions(-) diff --git a/Assets/Tests/Editor/HotReload/HotReloadDomainTests.cs b/Assets/Tests/Editor/HotReload/HotReloadDomainTests.cs index d2767321d..8a85bc864 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadDomainTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadDomainTests.cs @@ -617,7 +617,8 @@ public void ResolveTypeHome_NoActiveArtifactForTheName_ReturnsTheScriptAssemblie home.DllPath, Is.EqualTo(Path.Combine( projectRoot, - HotReloadConstants.ScriptAssembliesRelativeDirectory, + "Library", + "ScriptAssemblies", ProjectAssemblyName + HotReloadConstants.CompiledAssemblyExtension))); } diff --git a/Assets/Tests/Editor/HotReload/HotReloadResolverSearchDirectoriesTests.cs b/Assets/Tests/Editor/HotReload/HotReloadResolverSearchDirectoriesTests.cs index 0e81ed18b..028329b38 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadResolverSearchDirectoriesTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadResolverSearchDirectoriesTests.cs @@ -112,7 +112,9 @@ public void Collect_ForHotReloadTestAssembly_ReachesTheCodeAnalysisPluginDirecto Does.Not.Contain(pluginDirectory), "Unity listed the plugin among the test assembly's own references, so the transitive walk is not what reaches it."); - Assert.That(HotReloadResolverSearchDirectories.Collect(Directory.GetCurrentDirectory(), testAssembly), Does.Contain(pluginDirectory)); + Assert.That(HotReloadResolverSearchDirectories.Collect( + Path.GetFullPath(Path.Combine(Application.dataPath, "..")), + testAssembly), Does.Contain(pluginDirectory)); } /// diff --git a/Assets/Tests/Editor/HotReload/HotReloadTypeHomeTests.cs b/Assets/Tests/Editor/HotReload/HotReloadTypeHomeTests.cs index 4312f7727..319b0019f 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadTypeHomeTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadTypeHomeTests.cs @@ -1,3 +1,4 @@ +using System; using System.IO; using System.Reflection; @@ -34,6 +35,23 @@ private static string TestAssemblyDllPath } } + /// + /// What: a Virtual Player root resolves its script assemblies under the main project. + /// + [Test] + public void ScriptAssembliesUnderProject_VirtualPlayerRoot_PointsAtTheMainProjectsCompiledAssembly() + { + string mainRoot = Path.Combine(Path.GetTempPath(), "uloop-test-" + Guid.NewGuid().ToString("N")); + string playerRoot = Path.Combine(mainRoot, "Library", "VP", "mppm1"); + + HotReloadTypeHome home = HotReloadTypeHome.ScriptAssembliesUnderProject(playerRoot, "A"); + + Assert.That(home.Kind, Is.EqualTo(HotReloadTypeHomeKind.ScriptAssemblies)); + Assert.That( + home.DllPath, + Is.EqualTo(Path.GetFullPath(Path.Combine(mainRoot, "Library", "ScriptAssemblies", "A.dll")))); + } + /// /// What: a ScriptAssemblies home whose compiled Mvid matches the live assembly resolves to that assembly. /// diff --git a/Packages/src/Editor/FirstPartyTools/Common/ScriptPath/CompiledAssemblyLayout.cs b/Packages/src/Editor/FirstPartyTools/Common/ScriptPath/CompiledAssemblyLayout.cs index 338eb096f..5074a162f 100644 --- a/Packages/src/Editor/FirstPartyTools/Common/ScriptPath/CompiledAssemblyLayout.cs +++ b/Packages/src/Editor/FirstPartyTools/Common/ScriptPath/CompiledAssemblyLayout.cs @@ -49,10 +49,12 @@ internal static CompiledAssemblyLayout Resolve(string projectRoot) string fullRoot = Path.GetFullPath(projectRoot); // Why trim first: with a trailing separator, Path.GetDirectoryName returns the same - // directory, so every parent lookup below would land one level too low. - string trimmedRoot = fullRoot.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); - // A root made only of separators has nothing left after the trim; it stays as it was. - string root = trimmedRoot.Length == 0 ? fullRoot : trimmedRoot; + // directory, so every parent lookup below would land one level too low. A path root ("/" + // or "C:\") stays as it is, because trimming it would leave "" or the drive-relative "C:". + bool isPathRoot = string.Equals(Path.GetPathRoot(fullRoot), fullRoot, StringComparison.Ordinal); + string root = isPathRoot + ? fullRoot + : fullRoot.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); string virtualPlayersDirectory = Path.GetDirectoryName(root); string libraryDirectory = string.IsNullOrEmpty(virtualPlayersDirectory) ? null