From 667040ec6b488441133349dbff2aabede46823ae Mon Sep 17 00:00:00 2001 From: hatayama Date: Tue, 6 Oct 2026 15:02:43 +0900 Subject: [PATCH 1/4] Publicize shim references with the search directories of every transitively referenced assembly Cecil resolves the assemblies a publicized copy refers to while it writes the copy, and it only searched the directories of the group assembly's own compile references. Unity's allReferences is not transitive, so a test assembly whose asmdef overrides its references could not resolve a precompiled DLL that only a referenced game assembly lists, and the file failed with "Publicizing referenced assemblies failed". The search directories now come from a breadth-first walk of the referenced assemblies, with the assembly's own references first so a same-named DLL keeps resolving to the same copy. Reference paths seen earlier in the walk are skipped before File.Exists, because the engine references repeat in every assembly. The shim's compile references are unchanged. --- ...HotReloadResolverSearchDirectoriesTests.cs | 222 ++++++++++++++++++ ...loadResolverSearchDirectoriesTests.cs.meta | 11 + .../HotReload/InternalsExposureTestImage.cs | 61 +++-- .../PublicizerTestSearchDirectories.cs | 11 +- .../HotReloadResolverSearchDirectories.cs | 76 ++++++ ...HotReloadResolverSearchDirectories.cs.meta | 11 + .../Patching/HotReloadShimReferenceBuilder.cs | 3 +- 7 files changed, 368 insertions(+), 27 deletions(-) create mode 100644 Assets/Tests/Editor/HotReload/HotReloadResolverSearchDirectoriesTests.cs create mode 100644 Assets/Tests/Editor/HotReload/HotReloadResolverSearchDirectoriesTests.cs.meta create mode 100644 Packages/src/Editor/FirstPartyTools/HotReload/Patching/HotReloadResolverSearchDirectories.cs create mode 100644 Packages/src/Editor/FirstPartyTools/HotReload/Patching/HotReloadResolverSearchDirectories.cs.meta diff --git a/Assets/Tests/Editor/HotReload/HotReloadResolverSearchDirectoriesTests.cs b/Assets/Tests/Editor/HotReload/HotReloadResolverSearchDirectoriesTests.cs new file mode 100644 index 0000000000..10d9adfcbd --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadResolverSearchDirectoriesTests.cs @@ -0,0 +1,222 @@ +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); + } + + // 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/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( From 94ea0bda4d8494db84ef783a466794c53f9d3a14 Mon Sep 17 00:00:00 2001 From: hatayama Date: Tue, 6 Oct 2026 15:06:39 +0900 Subject: [PATCH 2/4] Expose introduced-type artifacts with the same transitive search directories The internals-exposed copies for an introduced-type artifact are written by Cecil too, and their search directories came from the worker's reference list, which holds only the group assembly's own compile references. The artifact compile now receives the directories of every transitively referenced assembly, collected from the group's compilation assembly the same way as for the shim references, so both compiles resolve the same precompiled DLLs. --- ...IntroducedTypeArtifactReferenceBuilderTests.cs | 1 + .../HotReloadIntroducedTypePreparation.cs | 1 + ...eloadIntroducedTypeArtifactReferenceBuilder.cs | 15 ++++++++++++--- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/Assets/Tests/Editor/HotReload/HotReloadIntroducedTypeArtifactReferenceBuilderTests.cs b/Assets/Tests/Editor/HotReload/HotReloadIntroducedTypeArtifactReferenceBuilderTests.cs index c3395e5131..cfb9619f56 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadIntroducedTypeArtifactReferenceBuilderTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadIntroducedTypeArtifactReferenceBuilderTests.cs @@ -417,6 +417,7 @@ private HotReloadArtifactCompileReferences Build( target.Home, HotReloadCompositionRoot.Services.Domain, _projectRoot, + ReferencePublicizer.CollectResolverSearchDirectories(input.referencePaths), exposeInternals); } 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 From 3f5cdea25110aff854abf559091e8f0c68b70560 Mon Sep 17 00:00:00 2001 From: hatayama Date: Tue, 6 Oct 2026 15:08:05 +0900 Subject: [PATCH 3/4] Document the transitive search directories for publicized reference copies --- docs/hot-reload.md | 2 ++ 1 file changed, 2 insertions(+) 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. From 882a5a2dac057be5a40a0c8878e5484f7f86d0a1 Mon Sep 17 00:00:00 2001 From: hatayama Date: Tue, 6 Oct 2026 15:34:48 +0900 Subject: [PATCH 4/4] Pin that both reference builders use the transitive search directories The new tests called Collect and the publicizer directly, so putting the old search directories back at either production call site left every test green. One test now builds shim references for a target whose metadata needs an assembly only a transitive reference lists, and another checks that the artifact reference build writes its exposed copies with the directories it is given rather than ones derived from the worker references. --- ...oducedTypeArtifactReferenceBuilderTests.cs | 56 ++++++++++++++++++- ...HotReloadResolverSearchDirectoriesTests.cs | 29 ++++++++++ 2 files changed, 84 insertions(+), 1 deletion(-) diff --git a/Assets/Tests/Editor/HotReload/HotReloadIntroducedTypeArtifactReferenceBuilderTests.cs b/Assets/Tests/Editor/HotReload/HotReloadIntroducedTypeArtifactReferenceBuilderTests.cs index cfb9619f56..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,17 +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, - ReferencePublicizer.CollectResolverSearchDirectories(input.referencePaths), + resolverSearchDirectories, exposeInternals); } diff --git a/Assets/Tests/Editor/HotReload/HotReloadResolverSearchDirectoriesTests.cs b/Assets/Tests/Editor/HotReload/HotReloadResolverSearchDirectoriesTests.cs index 10d9adfcbd..f5bbaf592d 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadResolverSearchDirectoriesTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadResolverSearchDirectoriesTests.cs @@ -145,6 +145,35 @@ public void GetOrCreatePublicizedCopy_ResolvesAnAssemblyThatOnlyATransitiveRefer 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)