Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,20 @@ uloop --project-path <PROJECT_ROOT>/Library/VP/mppm<id> 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 <PROJECT_ROOT>/Library/VP/mppm<id> 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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,20 @@ uloop --project-path <PROJECT_ROOT>/Library/VP/mppm<id> 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 <PROJECT_ROOT>/Library/VP/mppm<id> 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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
166 changes: 166 additions & 0 deletions Assets/Tests/Editor/HotReload/HotReloadVirtualPlayerProjectTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
using System.IO;

using NUnit.Framework;

using io.github.hatayama.UnityCliLoop.FirstPartyTools;

namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload
{
/// <summary>
/// EditMode coverage for recognizing a Multiplayer Play Mode Virtual Player by its project root,
/// and for the missing-assembly reason worded for it.
/// </summary>
public class HotReloadVirtualPlayerProjectTests
{
private static readonly string VirtualPlayerRoot =
Path.Combine("workspace", "project", "Library", "VP", "mppm0a1b2c3d");

private static readonly string OrdinaryProjectRoot = Path.Combine("workspace", "project");

/// <summary>
/// What: a player directory directly under Library/VP is recognized as a Virtual Player root.
/// </summary>
[Test]
public void IsVirtualPlayerProjectRoot_PlayerDirectoryUnderLibraryVP_ReturnsTrue()
{
Assert.That(HotReloadVirtualPlayerProject.IsVirtualPlayerProjectRoot(VirtualPlayerRoot), Is.True);
}

/// <summary>
/// What: a trailing separator does not shift the parent lookup up by one directory.
/// </summary>
[Test]
public void IsVirtualPlayerProjectRoot_TrailingSeparator_ReturnsTrue()
{
string rootWithTrailingSeparator = VirtualPlayerRoot + Path.DirectorySeparatorChar;

Assert.That(
HotReloadVirtualPlayerProject.IsVirtualPlayerProjectRoot(rootWithTrailingSeparator),
Is.True);
}

/// <summary>
/// What: an ordinary project root is not taken for a Virtual Player.
/// </summary>
[Test]
public void IsVirtualPlayerProjectRoot_OrdinaryProjectRoot_ReturnsFalse()
{
Assert.That(HotReloadVirtualPlayerProject.IsVirtualPlayerProjectRoot(OrdinaryProjectRoot), Is.False);
}

/// <summary>
/// What: the Library/VP directory itself is not a Virtual Player root.
/// </summary>
[Test]
public void IsVirtualPlayerProjectRoot_TheVPDirectoryItself_ReturnsFalse()
{
string virtualPlayersDirectory = Path.Combine("workspace", "project", "Library", "VP");

Assert.That(
HotReloadVirtualPlayerProject.IsVirtualPlayerProjectRoot(virtualPlayersDirectory),
Is.False);
}

/// <summary>
/// What: a directory under Library whose parent is not named VP is not a Virtual Player root.
/// </summary>
[Test]
public void IsVirtualPlayerProjectRoot_ParentIsNotVP_ReturnsFalse()
{
string projectRoot = Path.Combine("workspace", "project", "Library", "Other", "mppm0a1b2c3d");

Assert.That(HotReloadVirtualPlayerProject.IsVirtualPlayerProjectRoot(projectRoot), Is.False);
}

/// <summary>
/// What: a directory under a VP directory that is not inside Library is not a Virtual Player root.
/// </summary>
[Test]
public void IsVirtualPlayerProjectRoot_GrandparentIsNotLibrary_ReturnsFalse()
{
string projectRoot = Path.Combine("workspace", "project", "Other", "VP", "mppm0a1b2c3d");

Assert.That(HotReloadVirtualPlayerProject.IsVirtualPlayerProjectRoot(projectRoot), Is.False);
}

/// <summary>
/// What: a path without a parent or without a grandparent, including one made only of a
/// separator, is answered false instead of throwing.
/// </summary>
[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.");
}

/// <summary>
/// What: on Windows, a Virtual Player root is recognized with backslashes and with forward slashes.
/// </summary>
[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.");
}

/// <summary>
/// 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.
/// </summary>
[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"));
}

/// <summary>
/// What: for an ordinary project, the reason keeps the compile-first text unchanged.
/// </summary>
[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."));
}
}
}

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,20 @@ uloop --project-path <PROJECT_ROOT>/Library/VP/mppm<id> 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 <PROJECT_ROOT>/Library/VP/mppm<id> 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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
using System;
using System.IO;

using UnityEngine;

namespace io.github.hatayama.UnityCliLoop.FirstPartyTools
{
/// <summary>
/// Recognizes a Multiplayer Play Mode Virtual Player by its project root, and words the
/// missing-assembly reason for it.
/// </summary>
internal static class HotReloadVirtualPlayerProject
{
private const string LibraryDirectoryName = "Library";
private const string VirtualPlayersDirectoryName = "VP";

// A Virtual Player's project root is <main project>/Library/VP/<player directory>.
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.";
}
}
}

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading