diff --git a/Assets/Tests/Editor/HotReload/HotReloadPdbCompilationInfoTests.cs b/Assets/Tests/Editor/HotReload/HotReloadPdbCompilationInfoTests.cs
new file mode 100644
index 000000000..a70a0deef
--- /dev/null
+++ b/Assets/Tests/Editor/HotReload/HotReloadPdbCompilationInfoTests.cs
@@ -0,0 +1,225 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Text;
+
+using Mono.Cecil;
+using Mono.Cecil.Cil;
+
+using NUnit.Framework;
+
+using UnityEngine;
+
+using io.github.hatayama.UnityCliLoop.FirstPartyTools;
+
+using UnityCompilationAssembly = UnityEditor.Compilation.Assembly;
+
+namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload
+{
+ ///
+ /// Contract of reading the defines and metadata references a portable PDB records, and a
+ /// cross-check of both against what Unity reports for every compiled assembly of this project.
+ /// Whether they agree decides if the compilation list can be rebuilt from PDBs at startup.
+ ///
+ public sealed class HotReloadPdbCompilationInfoTests
+ {
+ private const string TestAssemblyName = "UnityCLILoop.Tests.Editor.HotReload";
+ private const string ReferenceAssemblySuffix = ".ref.dll";
+ private const string AssemblySuffix = ".dll";
+ private const int MaxListedDifferences = 10;
+
+ ///
+ /// What: the test assembly's own PDB yields the editor and test defines it was compiled with,
+ /// and references whose file names and MVIDs are filled in.
+ ///
+ [Test]
+ public void Read_ReturnsTheDefinesAndReferencesOfTheTestAssembly()
+ {
+ CompiledAssemblyLayout layout = CompiledAssemblyLayout.Resolve(ProjectRoot());
+
+ HotReloadPdbCompilationInfo info = HotReloadPdbCompilationInfo.Read(layout.PdbPath(TestAssemblyName));
+
+ CollectionAssert.Contains(info.Defines, "UNITY_EDITOR");
+ CollectionAssert.Contains(info.Defines, "UNITY_INCLUDE_TESTS");
+ Assert.That(info.References, Is.Not.Empty);
+ Assert.That(info.References.Select(reference => reference.FileName), Has.Some.EqualTo("nunit.framework.dll"));
+ Assert.That(info.References.Select(reference => reference.FileName), Has.All.EndsWith(AssemblySuffix));
+ Assert.That(info.References.Select(reference => reference.Mvid), Has.None.EqualTo(Guid.Empty));
+ }
+
+ ///
+ /// What: for every compiled assembly with a PDB, the PDB's defines equal Unity's defines in
+ /// order, and its reference file names equal the file names of Unity's references, counting a
+ /// project assembly's X.ref.dll as X.dll. Each precompiled reference also carries the MVID of
+ /// the dll Unity lists for it, which is what the PDB's MVID is later compared with.
+ ///
+ [Test]
+ public void PdbCompilationInfo_MatchesGetAssembliesForEveryCompiledAssembly()
+ {
+ CompiledAssemblyLayout layout = CompiledAssemblyLayout.Resolve(ProjectRoot());
+ List mismatches = new List();
+ Dictionary mvidByReferencePath = new Dictionary(StringComparer.Ordinal);
+ int checkedAssemblies = 0;
+
+ foreach (UnityCompilationAssembly assembly in HotReloadCompilationAssemblies.Current())
+ {
+ string pdbPath = layout.PdbPath(assembly.name);
+ if (!File.Exists(layout.DllPath(assembly.name)) || !File.Exists(pdbPath))
+ {
+ continue;
+ }
+
+ HotReloadPdbCompilationInfo info = HotReloadPdbCompilationInfo.Read(pdbPath);
+ checkedAssemblies++;
+ AddDefineMismatch(assembly, info, mismatches);
+ AddReferenceMismatch(assembly, info, mismatches);
+ AddMvidMismatches(assembly, info, mvidByReferencePath, mismatches);
+ }
+
+ Assert.That(checkedAssemblies, Is.GreaterThan(0), "No compiled assembly with a PDB was found.");
+ Assert.That(mismatches, Is.Empty, string.Join("\n", mismatches));
+ }
+
+ ///
+ /// What: a portable PDB written without Roslyn's compilation records (here by Cecil) is
+ /// refused, instead of reading as an assembly with no defines and no references.
+ ///
+ [Test]
+ public void Read_WhenThePdbHasNoCompilationRecords_Throws()
+ {
+ string directory = Path.Combine(
+ Path.GetTempPath(),
+ "uloop-pdb-compilation-info-" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(directory);
+ try
+ {
+ string dllPath = Path.Combine(directory, "NoRecords.dll");
+ using (ModuleDefinition module = ModuleDefinition.CreateModule("NoRecords", ModuleKind.Dll))
+ {
+ module.Write(dllPath, new WriterParameters
+ {
+ WriteSymbols = true,
+ SymbolWriterProvider = new PortablePdbWriterProvider(),
+ });
+ }
+
+ string pdbPath = Path.ChangeExtension(dllPath, ".pdb");
+ Assert.That(File.Exists(pdbPath), Is.True, "Cecil did not write the portable PDB.");
+
+ Assert.Throws(() => HotReloadPdbCompilationInfo.Read(pdbPath));
+ }
+ finally
+ {
+ Directory.Delete(directory, true);
+ }
+ }
+
+ private static void AddDefineMismatch(
+ UnityCompilationAssembly assembly,
+ HotReloadPdbCompilationInfo info,
+ List mismatches)
+ {
+ string[] fromUnity = assembly.defines ?? Array.Empty();
+ if (info.Defines.SequenceEqual(fromUnity, StringComparer.Ordinal))
+ {
+ return;
+ }
+
+ // The order question is reported separately from a content difference.
+ bool sameSet = new HashSet(info.Defines, StringComparer.Ordinal).SetEquals(fromUnity);
+ mismatches.Add(
+ assembly.name + ": defines differ " + (sameSet ? "in order only" : "in content")
+ + DescribeDifference(info.Defines, fromUnity));
+ }
+
+ private static void AddReferenceMismatch(
+ UnityCompilationAssembly assembly,
+ HotReloadPdbCompilationInfo info,
+ List mismatches)
+ {
+ HashSet fromPdb = new HashSet(
+ info.References.Select(reference => ToImplementationFileName(reference.FileName)),
+ StringComparer.Ordinal);
+ HashSet fromUnity = new HashSet(
+ (assembly.allReferences ?? Array.Empty()).Select(Path.GetFileName),
+ StringComparer.Ordinal);
+ if (fromPdb.SetEquals(fromUnity))
+ {
+ return;
+ }
+
+ mismatches.Add(assembly.name + ": reference file names differ" + DescribeDifference(fromPdb, fromUnity));
+ }
+
+ private static void AddMvidMismatches(
+ UnityCompilationAssembly assembly,
+ HotReloadPdbCompilationInfo info,
+ Dictionary mvidByReferencePath,
+ List mismatches)
+ {
+ Dictionary pathByFileName = new Dictionary(StringComparer.Ordinal);
+ foreach (string referencePath in assembly.compiledAssemblyReferences ?? Array.Empty())
+ {
+ pathByFileName[Path.GetFileName(referencePath)] = referencePath;
+ }
+
+ List wrong = new List();
+ foreach (HotReloadPdbReferencedAssembly reference in info.References)
+ {
+ if (!pathByFileName.TryGetValue(reference.FileName, out string referencePath))
+ {
+ continue;
+ }
+
+ if (!mvidByReferencePath.TryGetValue(referencePath, out string expected))
+ {
+ expected = HotReloadAssemblyMvid.Read(Path.GetFullPath(Path.Combine(ProjectRoot(), referencePath)));
+ mvidByReferencePath[referencePath] = expected;
+ }
+
+ if (reference.Mvid.ToString("N") != expected)
+ {
+ wrong.Add(reference.FileName);
+ }
+ }
+
+ if (wrong.Count == 0)
+ {
+ return;
+ }
+
+ mismatches.Add(
+ assembly.name + ": " + wrong.Count + " reference MVIDs differ from the dll ("
+ + string.Join(", ", wrong.Take(MaxListedDifferences)) + ")");
+ }
+
+ private static string DescribeDifference(IEnumerable fromPdb, IEnumerable fromUnity)
+ {
+ List onlyInPdb = fromPdb.Except(fromUnity, StringComparer.Ordinal).ToList();
+ List onlyInUnity = fromUnity.Except(fromPdb, StringComparer.Ordinal).ToList();
+ StringBuilder message = new StringBuilder();
+ message.Append(" (only in PDB: ").Append(onlyInPdb.Count).Append(' ');
+ message.Append(string.Join(", ", onlyInPdb.Take(MaxListedDifferences)));
+ message.Append("; only in Unity: ").Append(onlyInUnity.Count).Append(' ');
+ message.Append(string.Join(", ", onlyInUnity.Take(MaxListedDifferences)));
+ message.Append(')');
+ return message.ToString();
+ }
+
+ private static string ToImplementationFileName(string fileName)
+ {
+ if (!fileName.EndsWith(ReferenceAssemblySuffix, StringComparison.Ordinal))
+ {
+ return fileName;
+ }
+
+ return fileName.Substring(0, fileName.Length - ReferenceAssemblySuffix.Length) + AssemblySuffix;
+ }
+
+ private static string ProjectRoot()
+ {
+ return Path.GetFullPath(Path.Combine(Application.dataPath, ".."));
+ }
+ }
+}
diff --git a/Assets/Tests/Editor/HotReload/HotReloadPdbCompilationInfoTests.cs.meta b/Assets/Tests/Editor/HotReload/HotReloadPdbCompilationInfoTests.cs.meta
new file mode 100644
index 000000000..2910bab90
--- /dev/null
+++ b/Assets/Tests/Editor/HotReload/HotReloadPdbCompilationInfoTests.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: eaa911ed77928402a93d83a195b80e97
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPdbCompilationInfo.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPdbCompilationInfo.cs
new file mode 100644
index 000000000..2fd9e4111
--- /dev/null
+++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPdbCompilationInfo.cs
@@ -0,0 +1,158 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Reflection.Metadata;
+using System.Text;
+
+using UnityEngine;
+
+namespace io.github.hatayama.UnityCliLoop.FirstPartyTools
+{
+ ///
+ /// One metadata reference the compiler was given, as its PDB records it. For an assembly of the
+ /// project the file is its reference assembly (X.ref.dll), so the MVID is that of the reference
+ /// assembly, not of the dll under Library/ScriptAssemblies.
+ ///
+ internal readonly struct HotReloadPdbReferencedAssembly
+ {
+ public readonly string FileName;
+ public readonly Guid Mvid;
+
+ public HotReloadPdbReferencedAssembly(string fileName, Guid mvid)
+ {
+ FileName = fileName;
+ Mvid = mvid;
+ }
+ }
+
+ ///
+ /// The compiler inputs a portable PDB records besides its sources: the preprocessor defines and
+ /// the metadata references. Safe to call from a pool thread; the only Unity API it uses is
+ /// Debug.Assert.
+ ///
+ internal sealed class HotReloadPdbCompilationInfo
+ {
+ // Roslyn's custom debug information kinds (Portable PDB spec, "Compilation Options" and
+ // "Compilation Metadata References").
+ private static readonly Guid CompilationOptionsKind = new Guid("B5FEEC05-8CD0-4A83-96DA-466284BB4BD8");
+ private static readonly Guid MetadataReferencesKind = new Guid("7E4D4708-096E-4C5C-AEDA-CB10BA6A740D");
+ private const string DefineKey = "define";
+ private const int MvidLength = 16;
+ // Kind/flags byte, timestamp and image size that sit between the alias and the MVID.
+ private const int BytesBeforeMvid = 1 + 4 + 4;
+
+ public readonly IReadOnlyList Defines;
+ public readonly IReadOnlyList References;
+
+ private HotReloadPdbCompilationInfo(
+ IReadOnlyList defines,
+ IReadOnlyList references)
+ {
+ Defines = defines;
+ References = references;
+ }
+
+ ///
+ /// Reads the defines, in the order the compiler was given them, and the metadata references
+ /// of the PDB. Throws InvalidOperationException when the PDB lacks either record or a record
+ /// is malformed.
+ ///
+ internal static HotReloadPdbCompilationInfo Read(string pdbPath)
+ {
+ Debug.Assert(!string.IsNullOrEmpty(pdbPath), "pdbPath must not be null or empty.");
+
+ using FileStream pdbStream = File.Open(pdbPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
+ using MetadataReaderProvider provider = MetadataReaderProvider.FromPortablePdbStream(pdbStream);
+ MetadataReader reader = provider.GetMetadataReader();
+
+ byte[] options = ReadModuleRecord(reader, CompilationOptionsKind, pdbPath);
+ byte[] references = ReadModuleRecord(reader, MetadataReferencesKind, pdbPath);
+ return new HotReloadPdbCompilationInfo(ParseDefines(options, pdbPath), ParseReferences(references, pdbPath));
+ }
+
+ private static byte[] ReadModuleRecord(MetadataReader reader, Guid kind, string pdbPath)
+ {
+ // Why only the module's rows: both records describe the whole compilation, so Roslyn
+ // attaches them to the module.
+ foreach (CustomDebugInformationHandle handle in reader.GetCustomDebugInformation(EntityHandle.ModuleDefinition))
+ {
+ CustomDebugInformation information = reader.GetCustomDebugInformation(handle);
+ if (reader.GetGuid(information.Kind) != kind)
+ {
+ continue;
+ }
+
+ return reader.GetBlobBytes(information.Value);
+ }
+
+ throw new InvalidOperationException(
+ "The PDB " + Path.GetFileName(pdbPath) + " has no compilation record of kind " + kind + ".");
+ }
+
+ // The options blob is a sequence of null-terminated UTF-8 key and value pairs.
+ private static IReadOnlyList ParseDefines(byte[] blob, string pdbPath)
+ {
+ int position = 0;
+ while (position < blob.Length)
+ {
+ string key = ReadNullTerminated(blob, ref position, pdbPath);
+ string value = ReadNullTerminated(blob, ref position, pdbPath);
+ if (key == DefineKey)
+ {
+ return value.Length == 0 ? Array.Empty() : value.Split(',');
+ }
+ }
+
+ return Array.Empty();
+ }
+
+ // Each reference is: file name and extern aliases (both null-terminated), a kind/flags
+ // byte, a uint32 timestamp, a uint32 image size and the 16-byte MVID.
+ private static IReadOnlyList ParseReferences(byte[] blob, string pdbPath)
+ {
+ List references = new List();
+ int position = 0;
+ while (position < blob.Length)
+ {
+ string fileName = ReadNullTerminated(blob, ref position, pdbPath);
+ ReadNullTerminated(blob, ref position, pdbPath);
+ position += BytesBeforeMvid;
+ if (position + MvidLength > blob.Length)
+ {
+ throw MalformedRecord(pdbPath);
+ }
+
+ references.Add(new HotReloadPdbReferencedAssembly(fileName, new Guid(Slice(blob, position, MvidLength))));
+ position += MvidLength;
+ }
+
+ return references;
+ }
+
+ private static string ReadNullTerminated(byte[] blob, ref int position, string pdbPath)
+ {
+ int terminator = Array.IndexOf(blob, (byte)0, position);
+ if (terminator < 0)
+ {
+ throw MalformedRecord(pdbPath);
+ }
+
+ string text = Encoding.UTF8.GetString(blob, position, terminator - position);
+ position = terminator + 1;
+ return text;
+ }
+
+ private static byte[] Slice(byte[] blob, int start, int length)
+ {
+ byte[] slice = new byte[length];
+ Buffer.BlockCopy(blob, start, slice, 0, length);
+ return slice;
+ }
+
+ private static InvalidOperationException MalformedRecord(string pdbPath)
+ {
+ return new InvalidOperationException(
+ "A compilation record in the PDB " + Path.GetFileName(pdbPath) + " is malformed.");
+ }
+ }
+}
diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPdbCompilationInfo.cs.meta b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPdbCompilationInfo.cs.meta
new file mode 100644
index 000000000..0bc3942fd
--- /dev/null
+++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadPdbCompilationInfo.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 097901e3ad2b94819a43bd25397d6038
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant: