From e6f5f72eec76ad88ad699a94dab55857634ca949 Mon Sep 17 00:00:00 2001 From: hatayama Date: Sun, 11 Oct 2026 13:03:58 +0900 Subject: [PATCH] Read the sources a compiled assembly was built from out of its PDB document table Snapshot capture asks Unity for every compilation assembly on the main thread, which is the one step it cannot move off the main thread. A PDB already lists every source the compiler was given, so these parts let a later change build the same list from the dll and PDB instead: - HotReloadPdbDocumentTable reads every document row, including sources without a method body, after matching the PDB to the dll's build. - HotReloadCompiledSourceMap maps the urls to Unity asset paths for both url forms (pathmapped "./" and absolute), packages whose folder differs from their name, packages outside the project and Virtual Players. - HotReloadSnapshotPackageRoots reads the package roots with the same immutability rule the capture uses. A cross-check test compares the result with Unity's sourceFiles for every compiled assembly of this project. Nothing calls the parts in production yet. --- Assets/Tests/Editor/HotReload/Fixtures.meta | 8 + .../HotReload/Fixtures/PdbDocumentTable.meta | 8 + .../CommentOnlySourceFixture.cs | 1 + .../CommentOnlySourceFixture.cs.meta | 11 + .../PdbDocumentTable/EmptySourceFixture.cs | 0 .../EmptySourceFixture.cs.meta | 11 + .../InterfaceOnlySourceFixture.cs | 10 + .../InterfaceOnlySourceFixture.cs.meta | 11 + .../HotReloadCompiledSourceCrossCheckTests.cs | 145 ++++++++ ...eloadCompiledSourceCrossCheckTests.cs.meta | 11 + .../HotReloadCompiledSourceMapTests.cs | 310 ++++++++++++++++++ .../HotReloadCompiledSourceMapTests.cs.meta | 11 + .../HotReloadPdbDocumentTableTests.cs | 96 ++++++ .../HotReloadPdbDocumentTableTests.cs.meta | 11 + .../Shared/HotReloadCompiledSourceMap.cs | 304 +++++++++++++++++ .../Shared/HotReloadCompiledSourceMap.cs.meta | 11 + .../Shared/HotReloadPdbDocumentIndex.cs | 4 +- .../Shared/HotReloadPdbDocumentTable.cs | 52 +++ .../Shared/HotReloadPdbDocumentTable.cs.meta | 11 + .../Shared/HotReloadSnapshotPackageRoots.cs | 58 ++++ .../HotReloadSnapshotPackageRoots.cs.meta | 11 + 21 files changed, 1093 insertions(+), 2 deletions(-) create mode 100644 Assets/Tests/Editor/HotReload/Fixtures.meta create mode 100644 Assets/Tests/Editor/HotReload/Fixtures/PdbDocumentTable.meta create mode 100644 Assets/Tests/Editor/HotReload/Fixtures/PdbDocumentTable/CommentOnlySourceFixture.cs create mode 100644 Assets/Tests/Editor/HotReload/Fixtures/PdbDocumentTable/CommentOnlySourceFixture.cs.meta create mode 100644 Assets/Tests/Editor/HotReload/Fixtures/PdbDocumentTable/EmptySourceFixture.cs create mode 100644 Assets/Tests/Editor/HotReload/Fixtures/PdbDocumentTable/EmptySourceFixture.cs.meta create mode 100644 Assets/Tests/Editor/HotReload/Fixtures/PdbDocumentTable/InterfaceOnlySourceFixture.cs create mode 100644 Assets/Tests/Editor/HotReload/Fixtures/PdbDocumentTable/InterfaceOnlySourceFixture.cs.meta create mode 100644 Assets/Tests/Editor/HotReload/HotReloadCompiledSourceCrossCheckTests.cs create mode 100644 Assets/Tests/Editor/HotReload/HotReloadCompiledSourceCrossCheckTests.cs.meta create mode 100644 Assets/Tests/Editor/HotReload/HotReloadCompiledSourceMapTests.cs create mode 100644 Assets/Tests/Editor/HotReload/HotReloadCompiledSourceMapTests.cs.meta create mode 100644 Assets/Tests/Editor/HotReload/HotReloadPdbDocumentTableTests.cs create mode 100644 Assets/Tests/Editor/HotReload/HotReloadPdbDocumentTableTests.cs.meta create mode 100644 Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompiledSourceMap.cs create mode 100644 Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompiledSourceMap.cs.meta create mode 100644 Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPdbDocumentTable.cs create mode 100644 Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPdbDocumentTable.cs.meta create mode 100644 Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSnapshotPackageRoots.cs create mode 100644 Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSnapshotPackageRoots.cs.meta diff --git a/Assets/Tests/Editor/HotReload/Fixtures.meta b/Assets/Tests/Editor/HotReload/Fixtures.meta new file mode 100644 index 0000000000..5d26d7d21e --- /dev/null +++ b/Assets/Tests/Editor/HotReload/Fixtures.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 3298d49965eca4acbba263b69f9df9d7 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/HotReload/Fixtures/PdbDocumentTable.meta b/Assets/Tests/Editor/HotReload/Fixtures/PdbDocumentTable.meta new file mode 100644 index 0000000000..d2be3a9894 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/Fixtures/PdbDocumentTable.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 747fd421be97e470792bafc10aa93f02 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/HotReload/Fixtures/PdbDocumentTable/CommentOnlySourceFixture.cs b/Assets/Tests/Editor/HotReload/Fixtures/PdbDocumentTable/CommentOnlySourceFixture.cs new file mode 100644 index 0000000000..88249b1ebb --- /dev/null +++ b/Assets/Tests/Editor/HotReload/Fixtures/PdbDocumentTable/CommentOnlySourceFixture.cs @@ -0,0 +1 @@ +// A source with only this comment, so the compiled PDB lists it in its document table but no method maps to it. diff --git a/Assets/Tests/Editor/HotReload/Fixtures/PdbDocumentTable/CommentOnlySourceFixture.cs.meta b/Assets/Tests/Editor/HotReload/Fixtures/PdbDocumentTable/CommentOnlySourceFixture.cs.meta new file mode 100644 index 0000000000..b3daab23a0 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/Fixtures/PdbDocumentTable/CommentOnlySourceFixture.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 1e11477b08cdb4a8282b9052296b3694 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/HotReload/Fixtures/PdbDocumentTable/EmptySourceFixture.cs b/Assets/Tests/Editor/HotReload/Fixtures/PdbDocumentTable/EmptySourceFixture.cs new file mode 100644 index 0000000000..e69de29bb2 diff --git a/Assets/Tests/Editor/HotReload/Fixtures/PdbDocumentTable/EmptySourceFixture.cs.meta b/Assets/Tests/Editor/HotReload/Fixtures/PdbDocumentTable/EmptySourceFixture.cs.meta new file mode 100644 index 0000000000..ef2b11b256 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/Fixtures/PdbDocumentTable/EmptySourceFixture.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b28fb164e01984cb699d60514414cc90 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/HotReload/Fixtures/PdbDocumentTable/InterfaceOnlySourceFixture.cs b/Assets/Tests/Editor/HotReload/Fixtures/PdbDocumentTable/InterfaceOnlySourceFixture.cs new file mode 100644 index 0000000000..a76345970f --- /dev/null +++ b/Assets/Tests/Editor/HotReload/Fixtures/PdbDocumentTable/InterfaceOnlySourceFixture.cs @@ -0,0 +1,10 @@ +namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload +{ + /// + /// A source with no method body, so the compiled PDB lists it in its document table but no sequence point refers to it. + /// + internal interface IPdbDocumentTableFixture + { + void Run(); + } +} diff --git a/Assets/Tests/Editor/HotReload/Fixtures/PdbDocumentTable/InterfaceOnlySourceFixture.cs.meta b/Assets/Tests/Editor/HotReload/Fixtures/PdbDocumentTable/InterfaceOnlySourceFixture.cs.meta new file mode 100644 index 0000000000..01cacb640d --- /dev/null +++ b/Assets/Tests/Editor/HotReload/Fixtures/PdbDocumentTable/InterfaceOnlySourceFixture.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 8328444c980024a6b82be1a025db1c44 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/HotReload/HotReloadCompiledSourceCrossCheckTests.cs b/Assets/Tests/Editor/HotReload/HotReloadCompiledSourceCrossCheckTests.cs new file mode 100644 index 0000000000..46fd9aa9c1 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadCompiledSourceCrossCheckTests.cs @@ -0,0 +1,145 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Text; + +using NUnit.Framework; + +using UnityEngine; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; + +using UnityCompilationAssembly = UnityEditor.Compilation.Assembly; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload +{ + /// + /// Cross-checks, on this project's compiled assemblies, the source list built from each PDB's + /// document table against the source list Unity reports for the assembly. Whether the two agree + /// decides if the snapshot capture can stop asking Unity for its assemblies. + /// + public sealed class HotReloadCompiledSourceCrossCheckTests + { + private const string TestAssemblyName = "UnityCLILoop.Tests.Editor.HotReload"; + private const int MaxListedDifferences = 10; + + /// + /// What: for every compiled assembly with sources, a dll and a PDB, the PDB list agrees with + /// Unity's on whether the assembly is an immutable package's, and for the others names the same sources. + /// + [Test] + public void FromDocuments_MatchesTheSourceFilesUnityListsForEveryCompiledAssembly() + { + CompiledAssemblyLayout layout = CompiledAssemblyLayout.Resolve(ProjectRoot()); + IReadOnlyList packageRoots = HotReloadSnapshotPackageRoots.ReadCurrent(); + StringComparer comparer = HotReloadSourcePathNormalizer.ProjectRelativePathComparer(); + List mismatches = new List(); + Stopwatch readTime = new Stopwatch(); + int checkedAssemblies = 0; + + foreach (UnityCompilationAssembly assembly in HotReloadCompilationAssemblies.Current()) + { + string[] sourceFiles = assembly.sourceFiles; + string dllPath = layout.DllPath(assembly.name); + string pdbPath = layout.PdbPath(assembly.name); + if (sourceFiles == null || sourceFiles.Length == 0 || !File.Exists(dllPath) || !File.Exists(pdbPath)) + { + continue; + } + + readTime.Start(); + List documents = HotReloadPdbDocumentTable.Read(dllPath, pdbPath); + readTime.Stop(); + HotReloadCompiledSources sources = HotReloadCompiledSourceMap.FromDocuments( + documents, + layout.MainProjectRoot, + layout.ProjectRoot, + packageRoots, + PathComparison()); + checkedAssemblies++; + + bool expectedImmutable = HotReloadSourceSnapshotter.ShouldSkipImmutablePackageSources(sourceFiles); + if (sources.IsImmutablePackageAssembly != expectedImmutable) + { + mismatches.Add( + assembly.name + ": IsImmutablePackageAssembly is " + sources.IsImmutablePackageAssembly + + " but Unity's first source says " + expectedImmutable + "."); + continue; + } + + if (expectedImmutable) + { + continue; + } + + HashSet fromPdb = new HashSet(sources.AssetPaths, comparer); + HashSet fromUnity = new HashSet( + sourceFiles.Select(HotReloadSourcePathNormalizer.ToForwardSlashes), + comparer); + if (!fromPdb.SetEquals(fromUnity)) + { + mismatches.Add(DescribeDifference(assembly.name, fromPdb, fromUnity)); + } + } + + TestContext.WriteLine( + "Read " + checkedAssemblies + " PDB document tables in " + readTime.ElapsedMilliseconds + " ms."); + Assert.That(checkedAssemblies, Is.GreaterThan(0), "No compiled assembly with a dll and a PDB was found."); + Assert.That(mismatches, Is.Empty, string.Join("\n", mismatches)); + } + + /// + /// What: the test assembly's list holds the fixtures without a method body, so the list is the + /// document table and not only the documents a sequence point refers to. + /// + [Test] + public void FromDocuments_IncludesTheBodylessFixtures() + { + CompiledAssemblyLayout layout = CompiledAssemblyLayout.Resolve(ProjectRoot()); + List documents = HotReloadPdbDocumentTable.Read( + layout.DllPath(TestAssemblyName), + layout.PdbPath(TestAssemblyName)); + + HotReloadCompiledSources sources = HotReloadCompiledSourceMap.FromDocuments( + documents, + layout.MainProjectRoot, + layout.ProjectRoot, + HotReloadSnapshotPackageRoots.ReadCurrent(), + PathComparison()); + + Assert.That(sources.AssetPaths, Is.SupersetOf(HotReloadPdbDocumentTableTests.BodylessFixtureAssetPaths)); + } + + private static string DescribeDifference(string assemblyName, HashSet fromPdb, HashSet fromUnity) + { + StringBuilder text = new StringBuilder(); + text.Append(assemblyName).Append(": the source lists differ."); + AppendOnlyIn(text, "only in the PDB", fromPdb, fromUnity); + AppendOnlyIn(text, "only in Unity's sourceFiles", fromUnity, fromPdb); + return text.ToString(); + } + + private static void AppendOnlyIn(StringBuilder text, string label, HashSet side, HashSet other) + { + List only = side.Where(path => !other.Contains(path)).OrderBy(path => path, StringComparer.Ordinal).ToList(); + text.Append("\n ").Append(label).Append(" (").Append(only.Count).Append("):"); + foreach (string path in only.Take(MaxListedDifferences)) + { + text.Append("\n ").Append(path); + } + } + + // The comparison the snapshot paths use on this platform: case-insensitive on Windows only. + private static StringComparison PathComparison() + { + return Path.DirectorySeparatorChar == '\\' ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + } + + private static string ProjectRoot() + { + return Path.GetFullPath(Path.Combine(Application.dataPath, "..")); + } + } +} diff --git a/Assets/Tests/Editor/HotReload/HotReloadCompiledSourceCrossCheckTests.cs.meta b/Assets/Tests/Editor/HotReload/HotReloadCompiledSourceCrossCheckTests.cs.meta new file mode 100644 index 0000000000..b36ce15804 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadCompiledSourceCrossCheckTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: aa9fbc393cd4c42fb8b852c9c82dfcba +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/HotReload/HotReloadCompiledSourceMapTests.cs b/Assets/Tests/Editor/HotReload/HotReloadCompiledSourceMapTests.cs new file mode 100644 index 0000000000..c74701520f --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadCompiledSourceMapTests.cs @@ -0,0 +1,310 @@ +using System; +using System.Collections.Generic; + +using Mono.Cecil.Cil; + +using NUnit.Framework; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload +{ + /// + /// Covers how the document urls of a PDB map to the asset paths Unity uses for the sources of an + /// assembly, and whether the assembly belongs to an immutable package. Only synthetic urls and + /// package roots are used; nothing is read from disk. + /// + public sealed class HotReloadCompiledSourceMapTests + { + private const string ProjectRoot = "/proj"; + private const string EmbeddedResolvedPath = "/proj/Packages/src"; + private const string EmbeddedAssetPath = "Packages/com.example.embedded"; + private const string LocalResolvedPath = "/elsewhere/LocalPkg"; + private const string LocalAssetPath = "Packages/com.example.local"; + private const string RegistryResolvedPath = "/proj/Library/PackageCache/com.example.reg@1.0.0"; + private const string RegistryAssetPath = "Packages/com.example.reg"; + + private static readonly HotReloadSnapshotPackageRoot[] Packages = + { + new HotReloadSnapshotPackageRoot(EmbeddedResolvedPath, EmbeddedAssetPath, false), + new HotReloadSnapshotPackageRoot(LocalResolvedPath, LocalAssetPath, false), + new HotReloadSnapshotPackageRoot(RegistryResolvedPath, RegistryAssetPath, true), + }; + + /// + /// What: a project-relative url of an Assets source maps to its asset path, and the assembly is not immutable. + /// + [Test] + public void FromDocuments_ProjectRelativeAssetsSource_MapsToTheAssetPath() + { + HotReloadCompiledSources sources = Map("./Assets/A.cs"); + + Assert.That(sources.AssetPaths, Is.EqualTo(new[] { "Assets/A.cs" })); + Assert.That(sources.IsImmutablePackageAssembly, Is.False); + } + + /// + /// What: a url written with backslashes maps to the asset path written with slashes. + /// + [Test] + public void FromDocuments_BackslashUrl_MapsToASlashAssetPath() + { + HotReloadCompiledSources sources = Map(".\\Assets\\B.cs"); + + Assert.That(sources.AssetPaths, Is.EqualTo(new[] { "Assets/B.cs" })); + } + + /// + /// What: an absolute url inside the compiling project, as an assembly without a pathmap records it, maps to the asset path. + /// + [Test] + public void FromDocuments_AbsoluteUrlInsideTheCompilingProject_MapsToTheAssetPath() + { + HotReloadCompiledSources sources = Map("/proj/Assets/C.cs"); + + Assert.That(sources.AssetPaths, Is.EqualTo(new[] { "Assets/C.cs" })); + } + + /// + /// What: a source of an embedded package whose folder differs from its name maps to the asset path under the package name. + /// + [Test] + public void FromDocuments_EmbeddedPackageWhoseFolderDiffersFromItsName_MapsUnderThePackageName() + { + HotReloadCompiledSources sources = Map("./Packages/src/Editor/X.cs"); + + Assert.That(sources.AssetPaths, Is.EqualTo(new[] { "Packages/com.example.embedded/Editor/X.cs" })); + Assert.That(sources.IsImmutablePackageAssembly, Is.False); + } + + /// + /// What: a source of a local package outside the project, recorded as an absolute url, maps to the asset path under the package name. + /// + [Test] + public void FromDocuments_LocalPackageOutsideTheProject_MapsUnderThePackageName() + { + HotReloadCompiledSources sources = Map("/elsewhere/LocalPkg/Runtime/Y.cs"); + + Assert.That(sources.AssetPaths, Is.EqualTo(new[] { "Packages/com.example.local/Runtime/Y.cs" })); + Assert.That(sources.IsImmutablePackageAssembly, Is.False); + } + + /// + /// What: a source of a registry package makes the assembly an immutable package assembly. + /// + [Test] + public void FromDocuments_RegistryPackageSource_IsImmutable() + { + HotReloadCompiledSources sources = Map("./Library/PackageCache/com.example.reg@1.0.0/Z.cs"); + + Assert.That(sources.IsImmutablePackageAssembly, Is.True); + Assert.That(sources.AssetPaths, Is.EqualTo(new[] { "Packages/com.example.reg/Z.cs" })); + } + + /// + /// What: a relative url without a leading "./", which is how a source generator names its output, is dropped. + /// + [Test] + public void FromDocuments_GeneratedDocumentName_IsDropped() + { + HotReloadCompiledSources sources = Map("Example.SourceGen/Gen.g.cs"); + + Assert.That(sources.AssetPaths, Is.Empty); + } + + /// + /// What: a file inside the project but under neither Assets nor a package root is dropped. + /// + [Test] + public void FromDocuments_ProjectFileOutsideAssetsAndPackages_IsDropped() + { + HotReloadCompiledSources sources = Map("./Library/Bee/artifacts/Gen.cs"); + + Assert.That(sources.AssetPaths, Is.Empty); + } + + /// + /// What: a document that is not a C# source is dropped. + /// + [Test] + public void FromDocuments_NonCSharpDocument_IsDropped() + { + HotReloadCompiledSources sources = Map("./Assets/Shader.hlsl"); + + Assert.That(sources.AssetPaths, Is.Empty); + } + + /// + /// What: a url listed twice yields its asset path once. + /// + [Test] + public void FromDocuments_DuplicateDocuments_YieldsTheAssetPathOnce() + { + HotReloadCompiledSources sources = Map("./Assets/A.cs", "./Assets/A.cs"); + + Assert.That(sources.AssetPaths, Is.EqualTo(new[] { "Assets/A.cs" })); + } + + /// + /// What: for a Virtual Player, whose urls are relative to the main project, an embedded package + /// source maps whether Package Manager resolves the package under the player's root or the main project's. + /// + [TestCase("/proj/Library/VP/p1/Packages/src")] + [TestCase("/proj/Packages/src")] + public void FromDocuments_VirtualPlayerEmbeddedPackage_MapsUnderThePackageName(string resolvedPath) + { + HotReloadSnapshotPackageRoot[] packages = + { + new HotReloadSnapshotPackageRoot(resolvedPath, EmbeddedAssetPath, false), + }; + + HotReloadCompiledSources sources = HotReloadCompiledSourceMap.FromDocuments( + Documents("./Packages/src/X.cs"), + ProjectRoot, + "/proj/Library/VP/p1", + packages, + StringComparison.Ordinal); + + Assert.That(sources.AssetPaths, Is.EqualTo(new[] { "Packages/com.example.embedded/X.cs" })); + } + + /// + /// What: a folder beside a package root whose name starts with the root's name is not taken for the package. + /// + [Test] + public void FromDocuments_SiblingFolderOfAPackageRoot_IsDropped() + { + HotReloadCompiledSources sources = Map("/elsewhere/LocalPkg2/A.cs"); + + Assert.That(sources.AssetPaths, Is.Empty); + } + + /// + /// What: the first mapped source decides immutability, so an immutable source before a mutable one makes the assembly immutable. + /// + [Test] + public void FromDocuments_FirstMappedSourceDecidesImmutability_IsImmutable() + { + HotReloadCompiledSources sources = Map( + "./Library/PackageCache/com.example.reg@1.0.0/Z.cs", + "./Assets/A.cs"); + + Assert.That(sources.IsImmutablePackageAssembly, Is.True); + } + + /// + /// What: a dropped document before a mutable source does not decide immutability. + /// + [Test] + public void FromDocuments_FirstDroppedDocumentDoesNotDecide_IsMutable() + { + HotReloadCompiledSources sources = Map("Example.SourceGen/Gen.g.cs", "./Assets/A.cs"); + + Assert.That(sources.IsImmutablePackageAssembly, Is.False); + Assert.That(sources.AssetPaths, Is.EqualTo(new[] { "Assets/A.cs" })); + } + + /// + /// What: under a case-insensitive comparison a url spelled in another case still maps, keeping the url's spelling. + /// + [Test] + public void FromDocuments_IgnoreCaseComparison_MapsKeepingTheUrlSpelling() + { + HotReloadCompiledSources sources = HotReloadCompiledSourceMap.FromDocuments( + Documents("./ASSETS/a.CS"), + ProjectRoot, + ProjectRoot, + Packages, + StringComparison.OrdinalIgnoreCase); + + Assert.That(sources.AssetPaths, Is.EqualTo(new[] { "ASSETS/a.CS" })); + } + + /// + /// What: no documents yield no asset paths and a mutable assembly. + /// + [Test] + public void FromDocuments_NoDocuments_IsEmptyAndMutable() + { + HotReloadCompiledSources sources = Map(); + + Assert.That(sources.AssetPaths, Is.Empty); + Assert.That(sources.IsImmutablePackageAssembly, Is.False); + } + + /// + /// What: a Windows url with a drive letter maps to the asset path on every platform, whatever its + /// separators and the case of its drive letter. + /// + [TestCase("C:/proj/Assets/A.cs")] + [TestCase("C:\\proj\\Assets\\A.cs")] + [TestCase("c:/proj/Assets/A.cs")] + public void FromDocuments_WindowsDriveLetterUrl_MapsToTheAssetPath(string url) + { + HotReloadCompiledSources sources = HotReloadCompiledSourceMap.FromDocuments( + Documents(url), + "C:/proj", + "C:/proj", + Array.Empty(), + StringComparison.OrdinalIgnoreCase); + + Assert.That(sources.AssetPaths, Is.EqualTo(new[] { "Assets/A.cs" })); + } + + /// + /// What: an absolute url of an embedded package inside the project maps to the asset path under the package name. + /// + [Test] + public void FromDocuments_AbsoluteUrlOfAnEmbeddedPackage_MapsUnderThePackageName() + { + HotReloadCompiledSources sources = Map("/proj/Packages/src/Editor/X.cs"); + + Assert.That(sources.AssetPaths, Is.EqualTo(new[] { "Packages/com.example.embedded/Editor/X.cs" })); + Assert.That(sources.IsImmutablePackageAssembly, Is.False); + } + + /// + /// What: an absolute url of a registry package inside the project makes the assembly immutable. + /// + [Test] + public void FromDocuments_AbsoluteUrlOfARegistryPackage_IsImmutable() + { + HotReloadCompiledSources sources = Map("/proj/Library/PackageCache/com.example.reg@1.0.0/Z.cs"); + + Assert.That(sources.IsImmutablePackageAssembly, Is.True); + Assert.That(sources.AssetPaths, Is.EqualTo(new[] { "Packages/com.example.reg/Z.cs" })); + } + + /// + /// What: a folder inside the project beside a package root whose name starts with the root's name is not taken for the package. + /// + [Test] + public void FromDocuments_SiblingFolderOfAnInProjectPackageRoot_IsDropped() + { + HotReloadCompiledSources sources = Map("./Packages/src2/A.cs"); + + Assert.That(sources.AssetPaths, Is.Empty); + } + + private static HotReloadCompiledSources Map(params string[] urls) + { + return HotReloadCompiledSourceMap.FromDocuments( + Documents(urls), + ProjectRoot, + ProjectRoot, + Packages, + StringComparison.Ordinal); + } + + private static List Documents(params string[] urls) + { + List documents = new List(urls.Length); + foreach (string url in urls) + { + documents.Add(new HotReloadPdbDocument(url, DocumentHashAlgorithm.None, Array.Empty())); + } + + return documents; + } + } +} diff --git a/Assets/Tests/Editor/HotReload/HotReloadCompiledSourceMapTests.cs.meta b/Assets/Tests/Editor/HotReload/HotReloadCompiledSourceMapTests.cs.meta new file mode 100644 index 0000000000..8cca48e740 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadCompiledSourceMapTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: def368e6a13934412aef3302f6b104e4 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/HotReload/HotReloadPdbDocumentTableTests.cs b/Assets/Tests/Editor/HotReload/HotReloadPdbDocumentTableTests.cs new file mode 100644 index 0000000000..a1442d3e9a --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadPdbDocumentTableTests.cs @@ -0,0 +1,96 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; + +using NUnit.Framework; + +using UnityEngine; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload +{ + /// + /// Contract of reading every row of a PDB's document table: it lists sources without a method + /// body, which the sequence-point walk of the document index leaves out, and it refuses a PDB + /// of another build than the dll. + /// + public sealed class HotReloadPdbDocumentTableTests + { + private const string TestAssemblyName = "UnityCLILoop.Tests.Editor.HotReload"; + private const string OtherAssemblyName = "UnityCLILoop.PausePoints.Runtime"; + + internal static readonly string[] BodylessFixtureAssetPaths = + { + "Assets/Tests/Editor/HotReload/Fixtures/PdbDocumentTable/EmptySourceFixture.cs", + "Assets/Tests/Editor/HotReload/Fixtures/PdbDocumentTable/CommentOnlySourceFixture.cs", + "Assets/Tests/Editor/HotReload/Fixtures/PdbDocumentTable/InterfaceOnlySourceFixture.cs", + }; + + /// + /// What: the table lists the empty, comment-only and interface-only fixtures, none of which the + /// sequence-point walk returns. The urls are compared by their end because an assembly under + /// Assets has no pathmap, so its urls are absolute. + /// + [Test] + public void Read_ListsSourcesWithoutAMethodBody() + { + string dllPath = DllPath(TestAssemblyName); + string pdbPath = Path.ChangeExtension(dllPath, ".pdb"); + + List table = HotReloadPdbDocumentTable.Read(dllPath, pdbPath); + List withSequencePoints = HotReloadPdbDocumentIndex.ReadDocuments(dllPath, pdbPath); + + foreach (string assetPath in BodylessFixtureAssetPaths) + { + Assert.That( + table.Any(document => EndsWithAssetPath(document, assetPath)), + Is.True, + "The document table must list " + assetPath + "."); + Assert.That( + withSequencePoints.Any(document => EndsWithAssetPath(document, assetPath)), + Is.False, + "No sequence point may refer to " + assetPath + "."); + } + } + + /// + /// What: a PDB of another assembly than the dll makes the read throw instead of returning its documents. + /// + [Test] + public void Read_WhenThePdbBelongsToAnotherBuild_Throws() + { + string directory = Path.Combine( + Path.GetTempPath(), + "uloop-pdb-document-table-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(directory); + try + { + string dllPath = Path.Combine(directory, "X.dll"); + string pdbPath = Path.Combine(directory, "X.pdb"); + File.Copy(DllPath(TestAssemblyName), dllPath); + File.Copy(Path.ChangeExtension(DllPath(OtherAssemblyName), ".pdb"), pdbPath); + + Assert.Throws(() => HotReloadPdbDocumentTable.Read(dllPath, pdbPath)); + } + finally + { + Directory.Delete(directory, recursive: true); + } + } + + private static bool EndsWithAssetPath(HotReloadPdbDocument document, string assetPath) + { + return document.Url.Replace('\\', '/').EndsWith("/" + assetPath, StringComparison.Ordinal); + } + + private static string DllPath(string assemblyName) + { + return Path.Combine( + Path.GetFullPath(Path.Combine(Application.dataPath, "..")), + HotReloadConstants.ScriptAssembliesRelativeDirectory, + assemblyName + HotReloadConstants.CompiledAssemblyExtension); + } + } +} diff --git a/Assets/Tests/Editor/HotReload/HotReloadPdbDocumentTableTests.cs.meta b/Assets/Tests/Editor/HotReload/HotReloadPdbDocumentTableTests.cs.meta new file mode 100644 index 0000000000..e4632109c8 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadPdbDocumentTableTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: ea345d95bc3234bb99c3a9eb447240ce +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompiledSourceMap.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompiledSourceMap.cs new file mode 100644 index 0000000000..19977800f8 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompiledSourceMap.cs @@ -0,0 +1,304 @@ +using System; +using System.Collections.Generic; + +using UnityEngine; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// The sources a compiled assembly was built from, named by the asset paths Unity uses for them, + /// and whether the assembly belongs to a package hot reload treats as read-only. + /// + internal sealed class HotReloadCompiledSources + { + internal HotReloadCompiledSources(IReadOnlyList assetPaths, bool isImmutablePackageAssembly) + { + Debug.Assert(assetPaths != null, "assetPaths must not be null."); + AssetPaths = assetPaths; + IsImmutablePackageAssembly = isImmutablePackageAssembly; + } + + /// Each asset path once, in the order the PDB lists its document. + internal IReadOnlyList AssetPaths { get; } + + /// True when the first source that maps to an asset path is in an immutable package. + internal bool IsImmutablePackageAssembly { get; } + } + + /// + /// Turns the document urls of a PDB into the asset paths Unity lists as the assembly's sources. + /// A url takes one of three forms: "./" and a path relative to the compiling project, which an + /// assembly compiled with a pathmap records; an absolute path, which an assembly without a + /// pathmap records for every source and any assembly records for a package outside the project; + /// or a relative name without "./", which is a source generator's output. Urls of files that are + /// not C# sources, not under Assets and not under a package root are left out. + /// + internal static class HotReloadCompiledSourceMap + { + private const string CSharpExtension = ".cs"; + private const string ProjectRelativePrefix = "./"; + private const string AssetsPrefix = "Assets/"; + + // A package root in every form a url can name its folder by. A relative form is null when + // the folder is not under that root. + private readonly struct NormalizedPackageRoot + { + public readonly string ResolvedPath; + public readonly string RelativeToCompilingRoot; + public readonly string RelativeToOwnRoot; + public readonly string AssetPath; + public readonly bool IsImmutable; + + public NormalizedPackageRoot( + string resolvedPath, + string relativeToCompilingRoot, + string relativeToOwnRoot, + string assetPath, + bool isImmutable) + { + ResolvedPath = resolvedPath; + RelativeToCompilingRoot = relativeToCompilingRoot; + RelativeToOwnRoot = relativeToOwnRoot; + AssetPath = assetPath; + IsImmutable = isImmutable; + } + } + + private readonly struct MappedSource + { + public readonly string AssetPath; + public readonly bool IsImmutable; + + public MappedSource(string assetPath, bool isImmutable) + { + AssetPath = assetPath; + IsImmutable = isImmutable; + } + } + + /// + /// Maps the documents of one assembly's PDB. is the + /// project whose compile wrote the PDB, which is the main project for a Virtual Player, and + /// is this Editor's own project. + /// is Ordinal or OrdinalIgnoreCase. Does no IO. + /// + internal static HotReloadCompiledSources FromDocuments( + IReadOnlyList documents, + string compilingProjectRoot, + string projectRoot, + IReadOnlyList packageRoots, + StringComparison pathComparison) + { + Debug.Assert(documents != null, "documents must not be null."); + Debug.Assert(!string.IsNullOrEmpty(compilingProjectRoot), "compilingProjectRoot must not be null or empty."); + Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty."); + Debug.Assert(packageRoots != null, "packageRoots must not be null."); + Debug.Assert( + pathComparison == StringComparison.Ordinal || pathComparison == StringComparison.OrdinalIgnoreCase, + "pathComparison must be Ordinal or OrdinalIgnoreCase."); + + string compilingRoot = NormalizeRoot(compilingProjectRoot); + string ownRoot = NormalizeRoot(projectRoot); + List packages = NormalizePackages(packageRoots, compilingRoot, ownRoot, pathComparison); + List assetPaths = new List(); + HashSet seen = new HashSet( + pathComparison == StringComparison.OrdinalIgnoreCase ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal); + bool? firstIsImmutable = null; + foreach (HotReloadPdbDocument document in documents) + { + if (!TryMap(document.Url, compilingRoot, packages, pathComparison, out MappedSource mapped)) + { + continue; + } + + if (!seen.Add(mapped.AssetPath)) + { + continue; + } + + // Why the first mapped source: it keeps the rule of ShouldSkipImmutablePackageSources, + // which classifies an assembly by its first source because an asmdef keeps one + // assembly inside one package. + if (firstIsImmutable == null) + { + firstIsImmutable = mapped.IsImmutable; + } + + assetPaths.Add(mapped.AssetPath); + } + + return new HotReloadCompiledSources(assetPaths, firstIsImmutable ?? false); + } + + private static bool TryMap( + string documentUrl, + string compilingRoot, + List packages, + StringComparison comparison, + out MappedSource mapped) + { + mapped = default(MappedSource); + string url = documentUrl.Replace('\\', '/'); + if (!url.EndsWith(CSharpExtension, comparison)) + { + return false; + } + + if (url.StartsWith(ProjectRelativePrefix, StringComparison.Ordinal)) + { + return TryMapProjectRelative(url.Substring(ProjectRelativePrefix.Length), packages, comparison, out mapped); + } + + if (IsRooted(url)) + { + return TryMapAbsolute(url, compilingRoot, packages, comparison, out mapped); + } + + // A relative name without "./" is a generated source, which has no asset path. + return false; + } + + private static bool TryMapProjectRelative( + string relativePath, + List packages, + StringComparison comparison, + out MappedSource mapped) + { + if (relativePath.StartsWith(AssetsPrefix, comparison)) + { + mapped = new MappedSource(relativePath, false); + return true; + } + + // Why both relative forms: a Virtual Player's urls are relative to the main project, + // while the Package Manager may resolve an embedded package under the player's root. + int bestLength = -1; + NormalizedPackageRoot best = default(NormalizedPackageRoot); + foreach (NormalizedPackageRoot package in packages) + { + int length = Math.Max( + PrefixLength(relativePath, package.RelativeToCompilingRoot, comparison), + PrefixLength(relativePath, package.RelativeToOwnRoot, comparison)); + if (length > bestLength) + { + bestLength = length; + best = package; + } + } + + return TryMapUnderPackage(relativePath, best, bestLength, out mapped); + } + + private static bool TryMapAbsolute( + string absolutePath, + string compilingRoot, + List packages, + StringComparison comparison, + out MappedSource mapped) + { + int rootLength = PrefixLength(absolutePath, compilingRoot, comparison); + if (rootLength >= 0) + { + return TryMapProjectRelative(absolutePath.Substring(rootLength + 1), packages, comparison, out mapped); + } + + int bestLength = -1; + NormalizedPackageRoot best = default(NormalizedPackageRoot); + foreach (NormalizedPackageRoot package in packages) + { + int length = PrefixLength(absolutePath, package.ResolvedPath, comparison); + if (length > bestLength) + { + bestLength = length; + best = package; + } + } + + return TryMapUnderPackage(absolutePath, best, bestLength, out mapped); + } + + // path minus its first prefixLength characters and the "/" after them, under the package's asset path. + private static bool TryMapUnderPackage( + string path, + NormalizedPackageRoot package, + int prefixLength, + out MappedSource mapped) + { + if (prefixLength < 0) + { + mapped = default(MappedSource); + return false; + } + + mapped = new MappedSource(package.AssetPath + "/" + path.Substring(prefixLength + 1), package.IsImmutable); + return true; + } + + // The length of folder when path is inside it, so that the character after it is "/"; -1 + // otherwise. Why the "/": "/a/LocalPkg2/x.cs" must not count as inside "/a/LocalPkg". + private static int PrefixLength(string path, string folder, StringComparison comparison) + { + if (folder == null || path.Length <= folder.Length + 1) + { + return -1; + } + + if (path[folder.Length] != '/' || !path.StartsWith(folder, comparison)) + { + return -1; + } + + return folder.Length; + } + + // Why not Path.IsPathRooted: on macOS and Linux it does not take "C:/proj/..." for rooted, + // so a PDB written on Windows would lose its sources there. + private static bool IsRooted(string url) + { + if (url.Length > 0 && url[0] == '/') + { + return true; + } + + return url.Length >= 3 && IsAsciiLetter(url[0]) && url[1] == ':' && url[2] == '/'; + } + + private static bool IsAsciiLetter(char character) + { + return (character >= 'A' && character <= 'Z') || (character >= 'a' && character <= 'z'); + } + + private static List NormalizePackages( + IReadOnlyList packageRoots, + string compilingRoot, + string ownRoot, + StringComparison comparison) + { + List packages = new List(packageRoots.Count); + foreach (HotReloadSnapshotPackageRoot root in packageRoots) + { + string resolvedPath = NormalizeRoot(root.ResolvedPath); + packages.Add(new NormalizedPackageRoot( + resolvedPath, + RelativeTo(resolvedPath, compilingRoot, comparison), + RelativeTo(resolvedPath, ownRoot, comparison), + NormalizeRoot(root.AssetPath), + root.IsImmutable)); + } + + return packages; + } + + // The rest of path after root and "/", or null when path is not inside root. + private static string RelativeTo(string path, string root, StringComparison comparison) + { + int length = PrefixLength(path, root, comparison); + return length < 0 ? null : path.Substring(length + 1); + } + + private static string NormalizeRoot(string path) + { + return path.Replace('\\', '/').TrimEnd('/'); + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompiledSourceMap.cs.meta b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompiledSourceMap.cs.meta new file mode 100644 index 0000000000..7401d0860e --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadCompiledSourceMap.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7cd0f305ace4344be91cb7ea5588de22 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPdbDocumentIndex.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPdbDocumentIndex.cs index f17ab12805..e1499f0d8e 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPdbDocumentIndex.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPdbDocumentIndex.cs @@ -509,7 +509,7 @@ private static List CollectDocumentsWithVisibleSequencePoints(Me } // Reads only the PE headers and the debug directory; the type and method tables stay unread. - private static Guid ReadCodeViewGuid(string dllPath) + internal static Guid ReadCodeViewGuid(string dllPath) { using FileStream dllStream = File.Open(dllPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); using PEReader peReader = new PEReader(dllStream); @@ -528,7 +528,7 @@ private static Guid ReadCodeViewGuid(string dllPath) } // The same mapping Cecil's portable PDB reader applies to a document's hash algorithm GUID. - private static DocumentHashAlgorithm ToHashAlgorithm(Guid algorithm) + internal static DocumentHashAlgorithm ToHashAlgorithm(Guid algorithm) { if (algorithm == Sha1HashAlgorithmGuid) { diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPdbDocumentTable.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPdbDocumentTable.cs new file mode 100644 index 0000000000..c0eed27fab --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPdbDocumentTable.cs @@ -0,0 +1,52 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Reflection.Metadata; + +using UnityEngine; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Reads every row of a portable PDB's document table: each source the compiler was given, + /// including files without a method body, which HotReloadPdbDocumentIndex leaves out because + /// no sequence point refers to them. + /// + internal static class HotReloadPdbDocumentTable + { + /// + /// Returns the documents of the PDB in table order, after checking that the PDB belongs to + /// the build of the dll. Throws InvalidOperationException for a PDB of another build. + /// + internal static List Read(string dllPath, string pdbPath) + { + Debug.Assert(!string.IsNullOrEmpty(dllPath), "dllPath must not be null or empty."); + Debug.Assert(!string.IsNullOrEmpty(pdbPath), "pdbPath must not be null or empty."); + + Guid codeViewGuid = HotReloadPdbDocumentIndex.ReadCodeViewGuid(dllPath); + using FileStream pdbStream = File.Open(pdbPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + using MetadataReaderProvider provider = MetadataReaderProvider.FromPortablePdbStream(pdbStream); + MetadataReader reader = provider.GetMetadataReader(); + + // Why checked here: a PDB of another build would list sources the dll was not compiled from. + BlobContentId pdbId = new BlobContentId(reader.DebugMetadataHeader.Id); + if (pdbId.Guid != codeViewGuid) + { + throw new InvalidOperationException( + "The PDB beside " + Path.GetFileName(dllPath) + " belongs to another build of it."); + } + + List documents = new List(reader.Documents.Count); + foreach (DocumentHandle handle in reader.Documents) + { + Document document = reader.GetDocument(handle); + documents.Add(new HotReloadPdbDocument( + reader.GetString(document.Name), + HotReloadPdbDocumentIndex.ToHashAlgorithm(reader.GetGuid(document.HashAlgorithm)), + reader.GetBlobBytes(document.Hash))); + } + + return documents; + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPdbDocumentTable.cs.meta b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPdbDocumentTable.cs.meta new file mode 100644 index 0000000000..233654e520 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPdbDocumentTable.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b406ae595de0a4bd99e539062bf7eff8 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSnapshotPackageRoots.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSnapshotPackageRoots.cs new file mode 100644 index 0000000000..5bb8eee856 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSnapshotPackageRoots.cs @@ -0,0 +1,58 @@ +using System.Collections.Generic; + +using UnityEditor.PackageManager; + +using PackageManagerPackageInfo = UnityEditor.PackageManager.PackageInfo; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// One registered package as the snapshot capture sees it: the folder its files live in, the + /// virtual path Unity names them by, and whether hot reload treats its sources as read-only. + /// + internal readonly struct HotReloadSnapshotPackageRoot + { + public readonly string ResolvedPath; + public readonly string AssetPath; + public readonly bool IsImmutable; + + public HotReloadSnapshotPackageRoot(string resolvedPath, string assetPath, bool isImmutable) + { + ResolvedPath = resolvedPath; + AssetPath = assetPath; + IsImmutable = isImmutable; + } + } + + /// + /// Asks the Package Manager for the registered packages and whether each is immutable for hot + /// reload. Why not an extension of ScriptPackageRoots.ReadCurrent: that type is shared with + /// pause points, and adding the immutability to it would change their code too. + /// + internal static class HotReloadSnapshotPackageRoots + { + /// + /// Lists every registered package that has both a folder on disk and a virtual path. Reads + /// Package Manager state, so it must be called on the Unity main thread. + /// + internal static IReadOnlyList ReadCurrent() + { + PackageManagerPackageInfo[] packages = PackageManagerPackageInfo.GetAllRegisteredPackages(); + List roots = new List(packages.Length); + foreach (PackageManagerPackageInfo package in packages) + { + if (string.IsNullOrEmpty(package.resolvedPath) || string.IsNullOrEmpty(package.assetPath)) + { + continue; + } + + // The same rule as HotReloadSourceSnapshotter.ShouldSkipImmutablePackageSources: + // only project-owned packages stay editable through hot reload. + bool isImmutable = !(package.source == PackageSource.Embedded || package.source == PackageSource.Local); + roots.Add(new HotReloadSnapshotPackageRoot(package.resolvedPath, package.assetPath, isImmutable)); + } + + return roots; + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSnapshotPackageRoots.cs.meta b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSnapshotPackageRoots.cs.meta new file mode 100644 index 0000000000..cbd3384bea --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadSnapshotPackageRoots.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b006190806cec4bfe96c4947fb9a8831 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: