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 df26774fe1..b715e2aefe 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 @@ -47,6 +47,20 @@ uloop --project-path /Library/VP/mppm simulate-keyboard --acti uloop's one-command-at-a-time rule is per Editor, so commands to different players (or to the 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. + ## Known limitations - `Stop` sent while Virtual Players are still starting (the main Editor is not yet in Play Mode) 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 19f9702124..eda3049cbc 100644 --- a/.agents/skills/uloop-hot-reload/references/scope-and-limits.md +++ b/.agents/skills/uloop-hot-reload/references/scope-and-limits.md @@ -427,6 +427,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) | | Loaded assembly differs from the one on disk (pending compile) | Run `uloop compile` first, then retry | | Source file fails to parse | Per-file `Failed` entry with `Method` = `(file)` carrying the parse errors; nothing from that file is applied, its earlier patches stay active, and `Success` is false | | Method signature not found in the loaded assembly | Usually a stale assembly; run `uloop compile`. In-file renames and signature changes are classified as added members before reaching this point | 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 df26774fe1..b715e2aefe 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 @@ -47,6 +47,20 @@ uloop --project-path /Library/VP/mppm simulate-keyboard --acti uloop's one-command-at-a-time rule is per Editor, so commands to different players (or to the 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. + ## Known limitations - `Stop` sent while Virtual Players are still starting (the main Editor is not yet in Play Mode) 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 19f9702124..eda3049cbc 100644 --- a/.claude/skills/uloop-hot-reload/references/scope-and-limits.md +++ b/.claude/skills/uloop-hot-reload/references/scope-and-limits.md @@ -427,6 +427,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) | | Loaded assembly differs from the one on disk (pending compile) | Run `uloop compile` first, then retry | | Source file fails to parse | Per-file `Failed` entry with `Method` = `(file)` carrying the parse errors; nothing from that file is applied, its earlier patches stay active, and `Success` is false | | Method signature not found in the loaded assembly | Usually a stale assembly; run `uloop compile`. In-file renames and signature changes are classified as added members before reaching this point | diff --git a/Assets/Tests/Editor/HotReload/HotReloadVirtualPlayerProjectTests.cs b/Assets/Tests/Editor/HotReload/HotReloadVirtualPlayerProjectTests.cs new file mode 100644 index 0000000000..cbc657a8f9 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadVirtualPlayerProjectTests.cs @@ -0,0 +1,166 @@ +using System.IO; + +using NUnit.Framework; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; + +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. + /// + public class HotReloadVirtualPlayerProjectTests + { + private static readonly string VirtualPlayerRoot = + Path.Combine("workspace", "project", "Library", "VP", "mppm0a1b2c3d"); + + 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. + /// + [Test] + public void DescribeMissingCompiledAssembly_VirtualPlayerRoot_SaysHotReloadCannotPatchAVirtualPlayer() + { + string dllPath = Path.Combine(VirtualPlayerRoot, "Library", "ScriptAssemblies", "Sample.dll"); + + string reason = HotReloadVirtualPlayerProject.DescribeMissingCompiledAssembly(VirtualPlayerRoot, dllPath); + + Assert.That(reason, Does.Contain(dllPath)); + Assert.That(reason, Does.Contain("Virtual Player")); + Assert.That(reason, Does.Contain("main Editor")); + Assert.That(reason, Does.Contain("compile")); + Assert.That(reason, Does.Not.Contain("Compile the project first")); + } + + /// + /// What: for an ordinary project, the reason keeps the compile-first text unchanged. + /// + [Test] + public void DescribeMissingCompiledAssembly_OrdinaryRoot_KeepsTheCompileFirstText() + { + string dllPath = Path.Combine(OrdinaryProjectRoot, "Library", "ScriptAssemblies", "Sample.dll"); + + string reason = HotReloadVirtualPlayerProject.DescribeMissingCompiledAssembly(OrdinaryProjectRoot, dllPath); + + Assert.That( + reason, + Is.EqualTo("Compiled assembly not found at '" + dllPath + "'. Compile the project first.")); + } + } +} diff --git a/Assets/Tests/Editor/HotReload/HotReloadVirtualPlayerProjectTests.cs.meta b/Assets/Tests/Editor/HotReload/HotReloadVirtualPlayerProjectTests.cs.meta new file mode 100644 index 0000000000..62a0d7e1b6 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadVirtualPlayerProjectTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: d5931f630b52945939be6449c828de76 +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 df26774fe1..b715e2aefe 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 @@ -47,6 +47,20 @@ uloop --project-path /Library/VP/mppm simulate-keyboard --acti uloop's one-command-at-a-time rule is per Editor, so commands to different players (or to the 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. + ## Known limitations - `Stop` sent while Virtual Players are still starting (the main Editor is not yet in Play Mode) diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadPatchTargetSupport.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadPatchTargetSupport.cs index 23f6cd73a6..8d03315cb3 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadPatchTargetSupport.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadPatchTargetSupport.cs @@ -105,7 +105,7 @@ internal static HotReloadPatchTargetResolution ResolvePatchTarget( outcomes.Add( HotReloadMethodOutcome.Failed( "(file)", - "Compiled assembly not found at '" + home.DllPath + "'. Compile the project first.", + HotReloadVirtualPlayerProject.DescribeMissingCompiledAssembly(projectRoot, home.DllPath), assemblyResolvePath)); return HotReloadPatchTargetResolution.EarlyExit( new HotReloadFileProcessResult(outcomes, warnings, 0)); diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadVirtualPlayerProject.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadVirtualPlayerProject.cs new file mode 100644 index 0000000000..6b9655ead5 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadVirtualPlayerProject.cs @@ -0,0 +1,72 @@ +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. + /// + 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); + } + + internal static string DescribeMissingCompiledAssembly(string projectRoot, string dllPath) + { + 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)) + { + return "Compiled assembly not found at '" + dllPath + "'. Compile the project first."; + } + + // 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. + return "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."; + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadVirtualPlayerProject.cs.meta b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadVirtualPlayerProject.cs.meta new file mode 100644 index 0000000000..b3a1270a40 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadVirtualPlayerProject.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b89d5225b34994bd3bc23f66877431a7 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: 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 19f9702124..eda3049cbc 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 @@ -427,6 +427,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) | | Loaded assembly differs from the one on disk (pending compile) | Run `uloop compile` first, then retry | | Source file fails to parse | Per-file `Failed` entry with `Method` = `(file)` carrying the parse errors; nothing from that file is applied, its earlier patches stay active, and `Success` is false | | Method signature not found in the loaded assembly | Usually a stale assembly; run `uloop compile`. In-file renames and signature changes are classified as added members before reaching this point |