diff --git a/Assets/Tests/Editor/HotReload/Fixtures.meta b/Assets/Tests/Editor/HotReload/Fixtures.meta
new file mode 100644
index 000000000..5d26d7d21
--- /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 000000000..d2be3a989
--- /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 000000000..88249b1eb
--- /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 000000000..b3daab23a
--- /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 000000000..e69de29bb
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 000000000..ef2b11b25
--- /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 000000000..a76345970
--- /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 000000000..01cacb640
--- /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 000000000..46fd9aa9c
--- /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 000000000..b36ce1580
--- /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 000000000..c74701520
--- /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 000000000..8cca48e74
--- /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 000000000..a1442d3e9
--- /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 000000000..e4632109c
--- /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 000000000..19977800f
--- /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 000000000..7401d0860
--- /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 f17ab1280..e1499f0d8 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 000000000..c0eed27fa
--- /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 000000000..233654e52
--- /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 000000000..5bb8eee85
--- /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 000000000..cbd3384be
--- /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: