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.