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 b715e2aefe..29e02aa427 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 25e9f5d6b0..236af49f53 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 b715e2aefe..29e02aa427 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 25e9f5d6b0..236af49f53 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/Assets/Tests/Editor/HotReload/CompiledAssemblyLayoutTests.cs b/Assets/Tests/Editor/HotReload/CompiledAssemblyLayoutTests.cs new file mode 100644 index 0000000000..7fafd9ee3e --- /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 0000000000..d01d9b92ec --- /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/Assets/Tests/Editor/HotReload/HotReloadCompiledAssemblyPathCheckTests.cs b/Assets/Tests/Editor/HotReload/HotReloadCompiledAssemblyPathCheckTests.cs new file mode 100644 index 0000000000..e01d6a833c --- /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 0000000000..a169ea0562 --- /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/Assets/Tests/Editor/HotReload/HotReloadDomainTests.cs b/Assets/Tests/Editor/HotReload/HotReloadDomainTests.cs index d2767321dd..8a85bc8644 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 f5bbaf592d..028329b38c 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,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(testAssembly), Does.Contain(pluginDirectory)); + Assert.That(HotReloadResolverSearchDirectories.Collect( + Path.GetFullPath(Path.Combine(Application.dataPath, "..")), + testAssembly), Does.Contain(pluginDirectory)); } /// @@ -140,7 +142,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 +176,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 1489a2716f..9865d5d291 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/Assets/Tests/Editor/HotReload/HotReloadSnapshotAssemblyEnumerationTests.cs b/Assets/Tests/Editor/HotReload/HotReloadSnapshotAssemblyEnumerationTests.cs index 3635427ef5..f2db96c95f 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) { diff --git a/Assets/Tests/Editor/HotReload/HotReloadTypeHomeTests.cs b/Assets/Tests/Editor/HotReload/HotReloadTypeHomeTests.cs index 4312f77273..319b0019fd 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/Assets/Tests/Editor/HotReload/HotReloadVirtualPlayerProjectTests.cs b/Assets/Tests/Editor/HotReload/HotReloadVirtualPlayerProjectTests.cs index 268d3b5946..52f1a7c94b 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 { @@ -18,130 +18,18 @@ 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. + /// 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 IsVirtualPlayerProjectRoot_PlayerDirectoryUnderLibraryVP_ReturnsTrue() + public void DescribeMissingCompiledAssembly_VirtualPlayerRoot_SaysTheMainProjectHasNotCompiledTheAssembly() { - 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 - /// Virtual Player's so the next step names the main Editor's project. - /// - [Test] - public void DescribeMissingCompiledAssembly_VirtualPlayerRoot_SaysHotReloadCannotPatchAVirtualPlayer() - { - 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( @@ -152,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")); } /// @@ -164,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 8f57186ca2..ee1a792cc6 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/Common/ScriptPath/CompiledAssemblyLayout.cs b/Packages/src/Editor/FirstPartyTools/Common/ScriptPath/CompiledAssemblyLayout.cs new file mode 100644 index 0000000000..5074a162f4 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Common/ScriptPath/CompiledAssemblyLayout.cs @@ -0,0 +1,87 @@ +using System; +using System.IO; + +using UnityEngine; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// 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 + { + // 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) + { + ProjectRoot = projectRoot; + IsVirtualPlayer = isVirtualPlayer; + MainProjectRoot = mainProjectRoot; + CompiledAssembliesDirectory = compiledAssembliesDirectory; + } + + /// 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; } + + /// The main project's root for a Virtual Player; ProjectRoot otherwise. + internal string MainProjectRoot { get; } + + /// The absolute directory the project's compiled assemblies are read from. + internal string CompiledAssembliesDirectory { get; } + + 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. 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 + : 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) + { + Debug.Assert(!string.IsNullOrEmpty(assemblyName), "assemblyName must not be null or empty."); + return Path.Combine(CompiledAssembliesDirectory, assemblyName + ".dll"); + } + + internal string PdbPath(string assemblyName) + { + 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/Common/ScriptPath/CompiledAssemblyLayout.cs.meta b/Packages/src/Editor/FirstPartyTools/Common/ScriptPath/CompiledAssemblyLayout.cs.meta new file mode 100644 index 0000000000..5b7e60449f --- /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: 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 b715e2aefe..29e02aa427 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/HotReloadCallSiteScanner.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCallSiteScanner.cs index 4d66c3d967..030713d350 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 a7da610dba..e3bb7d24af 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/HotReloadCompiledAssemblyPathCheck.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCompiledAssemblyPathCheck.cs new file mode 100644 index 0000000000..6c11e9ba61 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCompiledAssemblyPathCheck.cs @@ -0,0 +1,48 @@ +using System; +using System.IO; + +using UnityEngine; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Checks that the compilation pipeline puts an assembly where hot reload reads it from. + /// + 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) + { + 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/HotReloadCompiledAssemblyPathCheck.cs.meta b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadCompiledAssemblyPathCheck.cs.meta new file mode 100644 index 0000000000..0cf24334d2 --- /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: diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadGroupWorkerInputBuilder.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadGroupWorkerInputBuilder.cs index 0b47916256..87b4dc37b9 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 dbb9ef7a9c..44bc875e55 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/HotReloadNewSourceMembershipValidator.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadNewSourceMembershipValidator.cs index 14a3bb9987..72fe13b7e8 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 ddb491c099..1ab7783375 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadPatchTargetSupport.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadPatchTargetSupport.cs @@ -97,20 +97,32 @@ 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)); } + 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) { @@ -124,7 +136,7 @@ internal static HotReloadPatchTargetResolution ResolvePatchTarget( { HotReloadFailureDescription membershipFailure = HotReloadNewSourceMembershipValidator.TryCapture( editorStateSnapshotCapture, - projectRoot, + layout.ProjectRoot, projectRelativePath, assemblyName, compilationAssembly, @@ -174,7 +186,7 @@ internal static HotReloadPatchTargetResolution ResolvePatchTarget( assemblyName, compilationAssembly, home, - projectRoot, + layout.ProjectRoot, unchangedDecision, newSourceMembershipEvidence); } diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Patching/HotReloadResolverSearchDirectories.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Patching/HotReloadResolverSearchDirectories.cs index ae33b28ba1..22560e40a4 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; @@ -16,8 +17,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(); @@ -35,7 +37,7 @@ internal static IReadOnlyCollection Collect(UnityCompilationAssembly roo 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)) @@ -59,14 +61,25 @@ internal static IReadOnlyCollection Collect(UnityCompilationAssembly roo // 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 2fa9235e00..2d9548abc4 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(); @@ -33,9 +35,17 @@ internal static string[] BuildWorkerReferencePaths( { foreach (string reference in compilationAssembly.allReferences) { - if (!string.IsNullOrEmpty(reference) && File.Exists(reference)) + if (string.IsNullOrEmpty(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(Path.GetFullPath(reference)); + paths.Add(fullReference); } } } @@ -416,14 +426,13 @@ 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. // 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( @@ -452,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. diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Patching/ReferencePublicizer.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Patching/ReferencePublicizer.cs index 5a373a3d17..dd3c2fd5d1 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 1991b71e7c..f2f1bb9923 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/HotReloadConstants.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs index a6c24ce693..3fa77955dc 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/HotReloadSourceSnapshotter.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSourceSnapshotter.cs index 782b7e4f36..6f0e49fa03 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 006595bb32..a65e43a04c 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 53fd63a77e..44c1520dc2 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadVirtualPlayerProject.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadVirtualPlayerProject.cs @@ -1,78 +1,37 @@ -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. - 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 (!IsVirtualPlayerProjectRoot(projectRoot)) + if (!layout.IsVirtualPlayer) { return HotReloadFailureDescription.CompiledAssemblyMissing( "Compiled assembly not found at '" + dllPath + "'. Compile the project first.", 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); } } 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 25e9f5d6b0..236af49f53 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 |