diff --git a/Assets/Tests/Editor/HotReload/HotReloadIntroducedTypeArtifactReferenceBuilderTests.cs b/Assets/Tests/Editor/HotReload/HotReloadIntroducedTypeArtifactReferenceBuilderTests.cs
index c3395e5131..b6e3d6ae33 100644
--- a/Assets/Tests/Editor/HotReload/HotReloadIntroducedTypeArtifactReferenceBuilderTests.cs
+++ b/Assets/Tests/Editor/HotReload/HotReloadIntroducedTypeArtifactReferenceBuilderTests.cs
@@ -238,6 +238,46 @@ public void Build_ReferenceResolutionFailure_ReturnsFailed(string failingImage)
Assert.That(ExposedCopiesOf(unresolvable), Is.Empty);
}
+ ///
+ /// Verifies the build writes the exposed copies with the search directories it is given, not
+ /// with ones derived from the worker references, which do not reach an assembly the target's
+ /// metadata needs.
+ ///
+ [Test]
+ public void Build_UsesTheResolverSearchDirectoriesItIsGiven()
+ {
+ string externalName = "GivenSearchEnumFixture_" + Guid.NewGuid().ToString("N");
+ string externalDirectory = Path.Combine(Path.GetTempPath(), "uloop-test-" + Guid.NewGuid().ToString("N"));
+ try
+ {
+ InternalsExposureTestImage target =
+ InternalsExposureTestImage.CreateWithConstantOfEnumIn(externalDirectory, externalName, HideCandidate);
+ _images.Add(target);
+ TransformWorkerInputDto input = CreateInput(target);
+ IReadOnlyCollection workerDirectories =
+ ReferencePublicizer.CollectResolverSearchDirectories(input.referencePaths);
+ List givenDirectories = new List(workerDirectories) { externalDirectory };
+
+ // Why the failing build goes first: a failed write caches nothing, while a successful
+ // one would satisfy the second build from the cache before Cecil resolves anything.
+ HotReloadArtifactCompileReferences fromWorkerDirectories =
+ BuildSearching(input, target, workerDirectories, true);
+ HotReloadArtifactCompileReferences fromGivenDirectories =
+ BuildSearching(input, target, givenDirectories, true);
+
+ Assert.That(fromWorkerDirectories.Success, Is.False);
+ Assert.That(fromWorkerDirectories.ErrorMessage, Does.StartWith(ExposureFailurePrefix));
+ Assert.That(fromGivenDirectories.Success, Is.True);
+ }
+ finally
+ {
+ if (Directory.Exists(externalDirectory))
+ {
+ Directory.Delete(externalDirectory, true);
+ }
+ }
+ }
+
///
/// Verifies building internals-only references leaves the shared worker input untouched, so
/// the worker runs and the shim compilation keep binding against the raw references.
@@ -407,16 +447,31 @@ public void Failed_EmptyReason_IsRejected(string reason)
Assert.That(failed.ErrorMessage, Is.EqualTo("Resolution failed."));
}
+ // Searches the worker references' directories, as Build did before the caller supplied them.
private HotReloadArtifactCompileReferences Build(
TransformWorkerInputDto input,
InternalsExposureTestImage target,
bool exposeInternals)
+ {
+ return BuildSearching(
+ input,
+ target,
+ ReferencePublicizer.CollectResolverSearchDirectories(input.referencePaths),
+ exposeInternals);
+ }
+
+ private HotReloadArtifactCompileReferences BuildSearching(
+ TransformWorkerInputDto input,
+ InternalsExposureTestImage target,
+ IReadOnlyCollection resolverSearchDirectories,
+ bool exposeInternals)
{
return HotReloadIntroducedTypeArtifactReferenceBuilder.Build(
input,
target.Home,
HotReloadCompositionRoot.Services.Domain,
_projectRoot,
+ resolverSearchDirectories,
exposeInternals);
}
diff --git a/Assets/Tests/Editor/HotReload/HotReloadResolverSearchDirectoriesTests.cs b/Assets/Tests/Editor/HotReload/HotReloadResolverSearchDirectoriesTests.cs
new file mode 100644
index 0000000000..f5bbaf592d
--- /dev/null
+++ b/Assets/Tests/Editor/HotReload/HotReloadResolverSearchDirectoriesTests.cs
@@ -0,0 +1,251 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+
+using Mono.Cecil;
+using NUnit.Framework;
+using UnityEditor.Compilation;
+using UnityEngine;
+
+using io.github.hatayama.UnityCliLoop.FirstPartyTools;
+
+using UnityCompilationAssembly = UnityEditor.Compilation.Assembly;
+
+namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload
+{
+ ///
+ /// Verifies the Cecil search directories for publicizing a compilation assembly's references
+ /// reach the precompiled DLLs that only a transitively referenced assembly lists, and keep the
+ /// directories of the assembly's own references first.
+ ///
+ public sealed class HotReloadResolverSearchDirectoriesTests
+ {
+ private const string CodeAnalysisPluginPath =
+ "Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/Plugins/CodeAnalysis/"
+ + "UnityCliLoop.System.Collections.Immutable.dll";
+
+ private readonly List _images = new List();
+ private string _tempRoot;
+
+ [SetUp]
+ public void SetUp()
+ {
+ _tempRoot = Path.Combine(Path.GetTempPath(), "uloop-test-" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(_tempRoot);
+ }
+
+ [TearDown]
+ public void TearDown()
+ {
+ foreach (InternalsExposureTestImage image in _images)
+ {
+ foreach (string copyPath in PublicizedCopiesOf(image))
+ {
+ File.Delete(copyPath);
+ }
+
+ image.Dispose();
+ }
+
+ _images.Clear();
+ if (Directory.Exists(_tempRoot))
+ {
+ Directory.Delete(_tempRoot, true);
+ }
+ }
+
+ ///
+ /// Verifies the directories reach a plugin two assembly references away, which neither the
+ /// root's own references nor those of its direct reference list.
+ ///
+ [Test]
+ public void Collect_IncludesDirectoriesOfTransitivelyReferencedPrecompiledAssemblies()
+ {
+ string pluginPath = CreateEmptyFile("plugins", "Plugin.dll");
+ ReferenceGraph graph = CreateReferenceGraph(pluginPath, Array.Empty());
+
+ IReadOnlyCollection directories = HotReloadResolverSearchDirectories.Collect(graph.Tests);
+
+ Assert.That(directories, Does.Contain(FullDirectoryOf(graph.Tests.outputPath)));
+ Assert.That(directories, Does.Contain(FullDirectoryOf(pluginPath)));
+ Assert.That(
+ ReferencePublicizer.CollectResolverSearchDirectories(graph.Tests.allReferences),
+ Does.Not.Contain(FullDirectoryOf(pluginPath)),
+ "The root's own references must not list the plugin, or the graph does not test the transitive walk.");
+ Assert.That(
+ ReferencePublicizer.CollectResolverSearchDirectories(graph.Game.allReferences),
+ Does.Not.Contain(FullDirectoryOf(pluginPath)),
+ "The direct reference must not list the plugin, or one step of the walk would be enough.");
+ }
+
+ ///
+ /// Verifies the directories of the root's own references come before those that only a
+ /// transitive reference adds, so a same-named DLL resolves to the root's own copy first.
+ ///
+ [Test]
+ public void Collect_OrdersOwnReferencesBeforeTransitiveOnes()
+ {
+ string pluginPath = CreateEmptyFile("plugins", "Plugin.dll");
+ string ownPath = CreateEmptyFile("own", "Own.dll");
+ ReferenceGraph graph = CreateReferenceGraph(pluginPath, new[] { ownPath });
+
+ List directories = new List(HotReloadResolverSearchDirectories.Collect(graph.Tests));
+
+ int ownIndex = directories.IndexOf(FullDirectoryOf(ownPath));
+ int pluginIndex = directories.IndexOf(FullDirectoryOf(pluginPath));
+ Assert.That(ownIndex, Is.GreaterThanOrEqualTo(0));
+ Assert.That(pluginIndex, Is.GreaterThan(ownIndex));
+ }
+
+ ///
+ /// Verifies that for this project's hot-reload test assembly, whose asmdef overrides its
+ /// references, the directories reach the code analysis plugins that only a referenced tool
+ /// assembly lists.
+ ///
+ [Test]
+ public void Collect_ForHotReloadTestAssembly_ReachesTheCodeAnalysisPluginDirectoryOnlyTransitively()
+ {
+ UnityCompilationAssembly testAssembly = PublicizerTestSearchDirectories.HotReloadTestAssembly();
+ string pluginDirectory = Path.GetFullPath(Path.GetDirectoryName(CodeAnalysisPluginPath));
+ Assert.That(
+ ReferencePublicizer.CollectResolverSearchDirectories(testAssembly.allReferences),
+ 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));
+ }
+
+ ///
+ /// Verifies a publicized copy whose metadata needs an assembly that only a transitive
+ /// reference lists cannot be written with the root's own reference directories, and can be
+ /// written with the transitive ones.
+ ///
+ [Test]
+ public void GetOrCreatePublicizedCopy_ResolvesAnAssemblyThatOnlyATransitiveReferenceLists()
+ {
+ string externalName = "TransitiveEnumFixture_" + Guid.NewGuid().ToString("N");
+ string externalDirectory = Path.Combine(_tempRoot, "plugins");
+ InternalsExposureTestImage image =
+ InternalsExposureTestImage.CreateWithConstantOfEnumIn(externalDirectory, externalName);
+ _images.Add(image);
+ ReferenceGraph graph = CreateReferenceGraph(
+ Path.Combine(externalDirectory, externalName + ".dll"),
+ Array.Empty());
+
+ // Why the failing call goes first: a failed write caches nothing, while a successful one
+ // would satisfy the second call from the cache before Cecil resolves anything.
+ Assert.Throws(() => ReferencePublicizer.GetOrCreatePublicizedCopy(
+ image.Home,
+ ReferencePublicizer.CollectResolverSearchDirectories(graph.Tests.allReferences)));
+
+ string publicized = ReferencePublicizer.GetOrCreatePublicizedCopy(
+ image.Home,
+ HotReloadResolverSearchDirectories.Collect(graph.Tests));
+
+ Assert.That(File.Exists(publicized), Is.True);
+ }
+
+ ///
+ /// Verifies the shim reference build publicizes its target with the transitive search
+ /// directories, so a target whose metadata needs an assembly that only a transitive reference
+ /// lists yields references instead of a publicize failure.
+ ///
+ [Test]
+ public void TryBuildShimReferencePaths_PublicizesTheTargetWithTransitiveSearchDirectories()
+ {
+ string externalName = "TransitiveShimEnumFixture_" + Guid.NewGuid().ToString("N");
+ string externalDirectory = Path.Combine(_tempRoot, "plugins");
+ InternalsExposureTestImage image =
+ InternalsExposureTestImage.CreateWithConstantOfEnumIn(externalDirectory, externalName);
+ _images.Add(image);
+ ReferenceGraph graph = CreateReferenceGraph(
+ Path.Combine(externalDirectory, externalName + ".dll"),
+ Array.Empty());
+
+ HotReloadShimReferenceBuilder.ShimReferencePathsResult result =
+ HotReloadShimReferenceBuilder.TryBuildShimReferencePaths(
+ graph.Tests,
+ image.Home,
+ false,
+ false,
+ Array.Empty());
+
+ Assert.That(result.ErrorMessage, Is.Null);
+ Assert.That(File.Exists(result.References[0]), Is.True);
+ }
+
+ // 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)
+ {
+ UnityCompilationAssembly core = new UnityCompilationAssembly(
+ "Core",
+ ScriptPath("Core.dll"),
+ Array.Empty(),
+ Array.Empty(),
+ Array.Empty(),
+ new[] { pluginPath },
+ AssemblyFlags.None);
+ UnityCompilationAssembly game = new UnityCompilationAssembly(
+ "Game",
+ ScriptPath("Game.dll"),
+ Array.Empty(),
+ Array.Empty(),
+ new[] { core },
+ Array.Empty(),
+ AssemblyFlags.None);
+ UnityCompilationAssembly tests = new UnityCompilationAssembly(
+ "Tests",
+ ScriptPath("Tests.dll"),
+ Array.Empty(),
+ Array.Empty(),
+ new[] { game },
+ testsOwnReferences,
+ AssemblyFlags.EditorAssembly);
+ return new ReferenceGraph(game, tests);
+ }
+
+ // The script assemblies exist as files so their directory counts as a search directory.
+ private string ScriptPath(string fileName)
+ {
+ return CreateEmptyFile("scripts", fileName);
+ }
+
+ private string CreateEmptyFile(string directoryName, string fileName)
+ {
+ string directory = Path.Combine(_tempRoot, directoryName);
+ Directory.CreateDirectory(directory);
+ string path = Path.Combine(directory, fileName);
+ File.WriteAllBytes(path, Array.Empty());
+ return path;
+ }
+
+ private static string FullDirectoryOf(string path)
+ {
+ return Path.GetDirectoryName(Path.GetFullPath(path));
+ }
+
+ private static string[] PublicizedCopiesOf(InternalsExposureTestImage image)
+ {
+ string directory = Path.Combine(
+ Path.GetFullPath(Path.Combine(Application.dataPath, "..")),
+ HotReloadConstants.PublicizedRefsRelativeDirectory);
+ return Directory.Exists(directory)
+ ? Directory.GetFiles(directory, image.Definition.Name.Name + "-*")
+ : Array.Empty();
+ }
+
+ private sealed class ReferenceGraph
+ {
+ internal ReferenceGraph(UnityCompilationAssembly game, UnityCompilationAssembly tests)
+ {
+ Game = game;
+ Tests = tests;
+ }
+
+ internal UnityCompilationAssembly Game { get; }
+
+ internal UnityCompilationAssembly Tests { get; }
+ }
+ }
+}
diff --git a/Assets/Tests/Editor/HotReload/HotReloadResolverSearchDirectoriesTests.cs.meta b/Assets/Tests/Editor/HotReload/HotReloadResolverSearchDirectoriesTests.cs.meta
new file mode 100644
index 0000000000..003c472b5d
--- /dev/null
+++ b/Assets/Tests/Editor/HotReload/HotReloadResolverSearchDirectoriesTests.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: af19673c8e56e4647acb50c3c3a5fd53
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Tests/Editor/HotReload/InternalsExposureTestImage.cs b/Assets/Tests/Editor/HotReload/InternalsExposureTestImage.cs
index 0d0ff23074..fd94d9c0cd 100644
--- a/Assets/Tests/Editor/HotReload/InternalsExposureTestImage.cs
+++ b/Assets/Tests/Editor/HotReload/InternalsExposureTestImage.cs
@@ -60,33 +60,9 @@ internal static InternalsExposureTestImage CreateWithConstantOfMissingEnum(
Action configure = null)
{
string externalDirectory = Path.Combine(Application.temporaryCachePath, externalName);
- Directory.CreateDirectory(externalDirectory);
try
{
- using DefaultAssemblyResolver writeResolver = new DefaultAssemblyResolver();
- writeResolver.AddSearchDirectory(externalDirectory);
- using AssemblyDefinition external = AssemblyDefinition.CreateAssembly(
- new AssemblyNameDefinition(externalName, new Version(1, 0, 0, 0)), externalName, ModuleKind.Dll);
- TypeDefinition externalKind = new TypeDefinition(
- "", "ExternalKind", TypeAttributes.Public | TypeAttributes.Sealed,
- external.MainModule.ImportReference(typeof(Enum)));
- externalKind.Fields.Add(new FieldDefinition(
- "value__",
- FieldAttributes.Public | FieldAttributes.SpecialName | FieldAttributes.RTSpecialName,
- external.MainModule.TypeSystem.Int32));
- external.MainModule.Types.Add(externalKind);
- external.Write(Path.Combine(externalDirectory, externalName + ".dll"));
- return new InternalsExposureTestImage(
- candidate =>
- {
- configure?.Invoke(candidate);
- candidate.Fields.Add(new FieldDefinition(
- "Default",
- FieldAttributes.Assembly | FieldAttributes.Static | FieldAttributes.Literal
- | FieldAttributes.HasDefault,
- candidate.Module.ImportReference(externalKind)) { Constant = 1 });
- },
- writeResolver);
+ return CreateWithConstantOfEnumIn(externalDirectory, externalName, configure);
}
finally
{
@@ -94,6 +70,41 @@ internal static InternalsExposureTestImage CreateWithConstantOfMissingEnum(
}
}
+ // Why the enum's assembly is left in place: a copy of this image can then be written only by
+ // a resolver that searches externalDirectory, so a test can tell which search directories
+ // reach it. The caller owns externalDirectory and deletes it.
+ internal static InternalsExposureTestImage CreateWithConstantOfEnumIn(
+ string externalDirectory,
+ string externalName,
+ Action configure = null)
+ {
+ Directory.CreateDirectory(externalDirectory);
+ using DefaultAssemblyResolver writeResolver = new DefaultAssemblyResolver();
+ writeResolver.AddSearchDirectory(externalDirectory);
+ using AssemblyDefinition external = AssemblyDefinition.CreateAssembly(
+ new AssemblyNameDefinition(externalName, new Version(1, 0, 0, 0)), externalName, ModuleKind.Dll);
+ TypeDefinition externalKind = new TypeDefinition(
+ "", "ExternalKind", TypeAttributes.Public | TypeAttributes.Sealed,
+ external.MainModule.ImportReference(typeof(Enum)));
+ externalKind.Fields.Add(new FieldDefinition(
+ "value__",
+ FieldAttributes.Public | FieldAttributes.SpecialName | FieldAttributes.RTSpecialName,
+ external.MainModule.TypeSystem.Int32));
+ external.MainModule.Types.Add(externalKind);
+ external.Write(Path.Combine(externalDirectory, externalName + ".dll"));
+ return new InternalsExposureTestImage(
+ candidate =>
+ {
+ configure?.Invoke(candidate);
+ candidate.Fields.Add(new FieldDefinition(
+ "Default",
+ FieldAttributes.Assembly | FieldAttributes.Static | FieldAttributes.Literal
+ | FieldAttributes.HasDefault,
+ candidate.Module.ImportReference(externalKind)) { Constant = 1 });
+ },
+ writeResolver);
+ }
+
internal static MethodDefinition AddReadMethod(TypeDefinition type, string name, MethodAttributes access)
{
MethodDefinition method = new MethodDefinition(name,
diff --git a/Assets/Tests/Editor/HotReload/PublicizerTestSearchDirectories.cs b/Assets/Tests/Editor/HotReload/PublicizerTestSearchDirectories.cs
index d66085d7af..c9a8126715 100644
--- a/Assets/Tests/Editor/HotReload/PublicizerTestSearchDirectories.cs
+++ b/Assets/Tests/Editor/HotReload/PublicizerTestSearchDirectories.cs
@@ -19,6 +19,15 @@ internal static class PublicizerTestSearchDirectories
private const string HotReloadTestAssemblyName = "UnityCLILoop.Tests.Editor.HotReload";
public static IReadOnlyCollection ForHotReloadTestAssembly()
+ {
+ return ReferencePublicizer.CollectResolverSearchDirectories(HotReloadTestAssembly().allReferences);
+ }
+
+ ///
+ /// Returns Unity's compilation assembly for this test assembly, failing the test when the
+ /// pipeline does not list it.
+ ///
+ public static UnityCompilationAssembly HotReloadTestAssembly()
{
UnityCompilationAssembly compilationAssembly = FindHotReloadTestAssembly();
Assert.That(
@@ -26,7 +35,7 @@ public static IReadOnlyCollection ForHotReloadTestAssembly()
Is.Not.Null,
"CompilationPipeline assembly not found: " + HotReloadTestAssemblyName);
- return ReferencePublicizer.CollectResolverSearchDirectories(compilationAssembly.allReferences);
+ return compilationAssembly;
}
private static UnityCompilationAssembly FindHotReloadTestAssembly()
diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadIntroducedTypePreparation.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadIntroducedTypePreparation.cs
index eb6db8ff41..dbb9ef7a9c 100644
--- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadIntroducedTypePreparation.cs
+++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadIntroducedTypePreparation.cs
@@ -140,6 +140,7 @@ private static async Task CompileArtifact
firstFile.Home,
collaborators.Domain,
firstFile.ProjectRoot,
+ HotReloadResolverSearchDirectories.Collect(firstFile.CompilationAssembly),
collaborators.InternalAccessGrant.IsAvailable);
if (!references.Success)
{
diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Patching/HotReloadIntroducedTypeArtifactReferenceBuilder.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Patching/HotReloadIntroducedTypeArtifactReferenceBuilder.cs
index ea3b64b705..8762094caf 100644
--- a/Packages/src/Editor/FirstPartyTools/HotReload/Patching/HotReloadIntroducedTypeArtifactReferenceBuilder.cs
+++ b/Packages/src/Editor/FirstPartyTools/HotReload/Patching/HotReloadIntroducedTypeArtifactReferenceBuilder.cs
@@ -15,6 +15,8 @@ internal static class HotReloadIntroducedTypeArtifactReferenceBuilder
/// Builds the references one artifact compiles against: the worker's raw references, or,
/// when is set, the same list with the target assembly
/// and its retained artifacts replaced by copies whose internal members are public.
+ /// are where Cecil looks for the assemblies
+ /// those copies refer to while it writes them.
///
///
/// Why only the target and its retained artifacts: the declarations belong to the target
@@ -28,9 +30,10 @@ internal static HotReloadArtifactCompileReferences Build(
HotReloadTypeHome targetHome,
HotReloadDomain domain,
string projectRoot,
+ IReadOnlyCollection resolverSearchDirectories,
bool exposeInternals)
{
- ValidateBuildArguments(transformInput, targetHome, domain, projectRoot);
+ ValidateBuildArguments(transformInput, targetHome, domain, projectRoot, resolverSearchDirectories);
if (!exposeInternals)
{
return HotReloadArtifactCompileReferences.Raw(BuildRawReferencePaths(transformInput));
@@ -51,7 +54,7 @@ internal static HotReloadArtifactCompileReferences Build(
// and the other artifacts of the domain, and Cecil resolves them while writing a copy.
IReadOnlyCollection searchDirectories = HotReloadShimReferenceBuilder.CollectArtifactSearchDirectories(
retainedHomes,
- ReferencePublicizer.CollectResolverSearchDirectories(references));
+ resolverSearchDirectories);
// Why only a resolution failure becomes a result: Cecil could not find an assembly a copy
// needs, which fails this preparation like any other unusable reference. A missing file or
@@ -99,7 +102,8 @@ private static void ValidateBuildArguments(
TransformWorkerInputDto transformInput,
HotReloadTypeHome targetHome,
HotReloadDomain domain,
- string projectRoot)
+ string projectRoot,
+ IReadOnlyCollection resolverSearchDirectories)
{
if (transformInput == null)
{
@@ -120,6 +124,11 @@ private static void ValidateBuildArguments(
{
throw new ArgumentException("projectRoot must not be null or empty.", nameof(projectRoot));
}
+
+ if (resolverSearchDirectories == null)
+ {
+ throw new ArgumentNullException(nameof(resolverSearchDirectories));
+ }
}
// Why the rule that deduplicates references: the worker list holds the target as Unity
diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Patching/HotReloadResolverSearchDirectories.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Patching/HotReloadResolverSearchDirectories.cs
new file mode 100644
index 0000000000..ae33b28ba1
--- /dev/null
+++ b/Packages/src/Editor/FirstPartyTools/HotReload/Patching/HotReloadResolverSearchDirectories.cs
@@ -0,0 +1,76 @@
+using System;
+using System.Collections.Generic;
+
+using UnityEngine;
+
+using UnityCompilationAssembly = UnityEditor.Compilation.Assembly;
+
+namespace io.github.hatayama.UnityCliLoop.FirstPartyTools
+{
+ ///
+ /// Cecil search directories for publicizing a compilation assembly's references: the
+ /// directories of the assembly's own references first, then those of every assembly it
+ /// references transitively. Cecil resolves a referenced assembly while writing a publicized
+ /// copy, and a precompiled DLL that only a referenced assembly lists would otherwise be
+ /// unreachable (a test assembly with overrideReferences does not list the game's plugins).
+ ///
+ internal static class HotReloadResolverSearchDirectories
+ {
+ internal static IReadOnlyCollection Collect(UnityCompilationAssembly rootAssembly)
+ {
+ Debug.Assert(rootAssembly != null, "rootAssembly must not be null.");
+
+ List orderedDirectories = new List();
+ HashSet seenDirectories = new HashSet(StringComparer.Ordinal);
+ HashSet seenReferences = new HashSet(StringComparer.Ordinal);
+ HashSet visitedAssemblies = new HashSet(StringComparer.Ordinal);
+ Queue pending = new Queue();
+ pending.Enqueue(rootAssembly);
+ visitedAssemblies.Add(rootAssembly.name);
+
+ // Why breadth first: the directories of the root's own references come before anything
+ // a transitive reference adds, so a same-named DLL keeps resolving to the root's own copy.
+ while (pending.Count > 0)
+ {
+ 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);
+ foreach (string directory in ReferencePublicizer.CollectResolverSearchDirectories(unseenReferences))
+ {
+ if (seenDirectories.Add(directory))
+ {
+ orderedDirectories.Add(directory);
+ }
+ }
+
+ foreach (UnityCompilationAssembly referenced in current.assemblyReferences)
+ {
+ if (visitedAssemblies.Add(referenced.name))
+ {
+ pending.Enqueue(referenced);
+ }
+ }
+ }
+
+ return orderedDirectories;
+ }
+
+ // 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)
+ {
+ List unseenReferences = new List();
+ foreach (string reference in references)
+ {
+ if (seenReferences.Add(reference))
+ {
+ unseenReferences.Add(reference);
+ }
+ }
+
+ return unseenReferences;
+ }
+ }
+}
diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Patching/HotReloadResolverSearchDirectories.cs.meta b/Packages/src/Editor/FirstPartyTools/HotReload/Patching/HotReloadResolverSearchDirectories.cs.meta
new file mode 100644
index 0000000000..9a66168ca7
--- /dev/null
+++ b/Packages/src/Editor/FirstPartyTools/HotReload/Patching/HotReloadResolverSearchDirectories.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 0ff1a2b4b2c0e4d648dd8829a6a30440
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Patching/HotReloadShimReferenceBuilder.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Patching/HotReloadShimReferenceBuilder.cs
index af12b18649..19f0b7eefc 100644
--- a/Packages/src/Editor/FirstPartyTools/HotReload/Patching/HotReloadShimReferenceBuilder.cs
+++ b/Packages/src/Editor/FirstPartyTools/HotReload/Patching/HotReloadShimReferenceBuilder.cs
@@ -421,8 +421,9 @@ private static List BuildShimReferencePaths(
// 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 =
- ReferencePublicizer.CollectResolverSearchDirectories(compilationAssembly.allReferences);
+ HotReloadResolverSearchDirectories.Collect(compilationAssembly);
List references = new List();
string publicizedTarget = ReferencePublicizer.GetOrCreatePublicizedCopy(
diff --git a/docs/hot-reload.md b/docs/hot-reload.md
index b47be33ff8..56bcf84e73 100644
--- a/docs/hot-reload.md
+++ b/docs/hot-reload.md
@@ -58,6 +58,8 @@ Harmony ID: `io.github.hatayama.uloop.hot-reload` (distinct from the pause point
Caches: `Library/UloopHotReload/PublicizedRefs/fmt2/-.dll`,
`Library/UloopHotReload/Worker//`, and
`Library/UloopHotReload/SourceSnapshot/-/`.
+Cecil looks up the assemblies a publicized copy refers to in the directories of the group
+assembly's compile references first, then in those of every assembly it references transitively.
When a verified snapshot marks a currently patched method as unchanged, the
orchestrator reverts that patch to the compiled IL instead of re-emitting a shim.