using System; using System.Collections.Generic; using System.Globalization; using System.IO; using System.Text; using UnityPipeline.Microsoft.CodeAnalysis; using UnityPipeline.Microsoft.CodeAnalysis.CSharp; using UnityPipeline.Microsoft.CodeAnalysis.CSharp.Syntax; using UnityPipeline.Microsoft.CodeAnalysis.Text; using Unity.Pipeline.Compilation; namespace Unity.Pipeline.CodeReload { /// /// Per-method snapshot of a reloadable file's last-compiled source, so a reload can skip /// methods whose bodies still match the code the target already runs compiled. Without this, /// every save re-registers an interpreter override for every [CodeReload] method in the file, /// and methods the user never touched pay the interpreter's per-call cost. /// /// A baseline is captured per (file, preprocessor symbols) pair — the same file diffs /// differently under editor and player defines. For each [CodeReload] method it stores a hash /// of the declaration's token stream (trivia excluded, so whitespace and comment edits don't /// count as changes), plus one context hash over the rest of the file with all executable /// bodies removed. A context change (fields, consts, signatures, usings) conservatively marks /// every method changed: an override compiles against the current file, so same-file /// declaration edits can shift an untouched body's meaning. /// /// Owned by whoever knows when disk matches the compiled state (the editor watch); with no /// baseline captured, classification returns null and reloads behave as before. The store is /// static, so the editor persists/restores it across domain reloads itself. /// static class CodeReloadBaseline { private sealed class Entry { public ulong ContextHash; public readonly Dictionary MethodHashes = new Dictionary(); } // Key: normalized full path + '\u001f' + define key. private static readonly Dictionary s_Entries = new Dictionary(StringComparer.OrdinalIgnoreCase); /// Number of captured (file, defines) baselines. public static int Count => s_Entries.Count; /// /// Snapshot as the compiled reference state for the given /// preprocessor symbols (null = project defaults). Call only when the file on disk is known /// to match what the reload target runs compiled. Returns false (and drops any stale entry) /// when the file can't be read or has no [CodeReload] method. /// public static bool Capture(string filePath, string[] preprocessorSymbols = null) { string source; try { source = File.ReadAllText(filePath); } catch { source = null; } return CaptureFromSource(filePath, source, preprocessorSymbols); } /// See ; source text supplied by the caller (tests). public static bool CaptureFromSource(string filePath, string sourceCode, string[] preprocessorSymbols = null) { var key = KeyFor(filePath, preprocessorSymbols); var entry = sourceCode == null ? null : ComputeEntry(sourceCode, preprocessorSymbols); if (entry == null) { s_Entries.Remove(key); return false; } s_Entries[key] = entry; return true; } /// /// Diff against the captured baseline for /// (, ). Returns the /// [CodeReload] method names whose declarations still match it — safe to leave running /// compiled. Null when no baseline was captured for that pair (treat everything as /// changed); empty when the file's context changed, so nothing is skippable. /// public static HashSet GetUnchangedMethods( string filePath, string sourceCode, string[] preprocessorSymbols = null) { if (!s_Entries.TryGetValue(KeyFor(filePath, preprocessorSymbols), out var baseline)) return null; var unchanged = new HashSet(StringComparer.Ordinal); var current = sourceCode == null ? null : ComputeEntry(sourceCode, preprocessorSymbols); if (current == null || current.ContextHash != baseline.ContextHash) return unchanged; foreach (var kv in current.MethodHashes) if (baseline.MethodHashes.TryGetValue(kv.Key, out var h) && h == kv.Value) unchanged.Add(kv.Key); return unchanged; } /// /// True when every [CodeReload] method in matches the baseline /// and none of them is in (methods that must reload anyway — /// e.g. already overridden on a device that can't unregister). Callers use this to skip a /// whole compile-and-push. False when no baseline was captured. /// public static bool IsFileUpToDate( string filePath, string sourceCode, string[] preprocessorSymbols = null, ICollection mustReload = null) { if (!s_Entries.TryGetValue(KeyFor(filePath, preprocessorSymbols), out var baseline)) return false; var current = sourceCode == null ? null : ComputeEntry(sourceCode, preprocessorSymbols); if (current == null || current.ContextHash != baseline.ContextHash) return false; foreach (var kv in current.MethodHashes) { if (!baseline.MethodHashes.TryGetValue(kv.Key, out var h) || h != kv.Value) return false; if (mustReload != null && mustReload.Contains(kv.Key)) return false; } return true; } /// Drop every captured baseline (watch stopped, or about to recapture). public static void Clear() => s_Entries.Clear(); /// /// Flatten the store to one string so the editor can stash it in SessionState across the /// play-mode domain reload — the reload wipes these statics while disk keeps the user's /// edits, so the baseline can't be recaptured from disk at that point. /// public static string Serialize() { var sb = new StringBuilder(); foreach (var kv in s_Entries) { sb.Append(kv.Key).Append('\u001e').Append(kv.Value.ContextHash.ToString("x", CultureInfo.InvariantCulture)); foreach (var m in kv.Value.MethodHashes) sb.Append('\u001e').Append(m.Key).Append('=').Append(m.Value.ToString("x", CultureInfo.InvariantCulture)); sb.Append('\n'); } return sb.ToString(); } /// Replace the store with a snapshot. Unparseable lines are dropped. public static void Restore(string serialized) { s_Entries.Clear(); if (string.IsNullOrEmpty(serialized)) return; foreach (var line in serialized.Split('\n')) { if (line.Length == 0) continue; var fields = line.Split('\u001e'); if (fields.Length < 2) continue; if (!ulong.TryParse(fields[1], NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var contextHash)) continue; var entry = new Entry { ContextHash = contextHash }; bool ok = true; for (int i = 2; i < fields.Length; i++) { int eq = fields[i].LastIndexOf('='); if (eq <= 0 || !ulong.TryParse(fields[i].Substring(eq + 1), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var h)) { ok = false; break; } entry.MethodHashes[fields[i].Substring(0, eq)] = h; } if (ok) s_Entries[fields[0]] = entry; } } private static string KeyFor(string filePath, string[] preprocessorSymbols) { string path; try { path = Path.GetFullPath(filePath); } catch { path = filePath ?? ""; } var defines = preprocessorSymbols == null ? "" : string.Join(";", preprocessorSymbols); return path + '\u001f' + defines; } /// /// Hash the file: one hash per [CodeReload] method name (overload hashes fold together — /// method ids are name-keyed everywhere else too), and a context hash over every token /// outside an executable body. Null when no class carries a [CodeReload] method. /// private static Entry ComputeEntry(string sourceCode, string[] preprocessorSymbols) { SyntaxNode root; try { root = CSharpSyntaxTree.ParseText(sourceCode, RoslynCompilationService.ProjectParseOptions(preprocessorSymbols)).GetRoot(); } catch { return null; } var entry = new Entry(); foreach (var node in root.DescendantNodes()) { if (node is not MethodDeclarationSyntax method || !InPlaceReloadProcessor.HasCodeReloadAttribute(method)) continue; var name = method.Identifier.ValueText; var declHash = HashTokens(method, null); entry.MethodHashes[name] = entry.MethodHashes.TryGetValue(name, out var prior) ? MixHash(prior, declHash) : declHash; } if (entry.MethodHashes.Count == 0) return null; entry.ContextHash = HashTokens(root, CollectBodySpans(root)); return entry; } /// /// Spans of every executable body in the file: method/ctor/operator bodies, accessor /// bodies, and expression bodies (incl. expression-bodied properties/indexers). Reloadable /// bodies are covered per-method; the others bind to compiled code from an override either /// way, so their edits can't change what an override does and must not poison the context. /// private static List CollectBodySpans(SyntaxNode root) { var spans = new List(); foreach (var node in root.DescendantNodes()) { switch (node) { case BaseMethodDeclarationSyntax m: if (m.Body != null) spans.Add(m.Body.Span); if (m.ExpressionBody != null) spans.Add(m.ExpressionBody.Span); break; case AccessorDeclarationSyntax a: if (a.Body != null) spans.Add(a.Body.Span); if (a.ExpressionBody != null) spans.Add(a.ExpressionBody.Span); break; case PropertyDeclarationSyntax p when p.ExpressionBody != null: spans.Add(p.ExpressionBody.Span); break; case IndexerDeclarationSyntax ix when ix.ExpressionBody != null: spans.Add(ix.ExpressionBody.Span); break; } } spans.Sort((a, b) => a.Start.CompareTo(b.Start)); return spans; } // FNV-1a 64 over the token stream. Trivia never enters the hash, so formatting and // comments can't mark a method changed. A separator byte between tokens keeps ("ab","c") // and ("a","bc") distinct even though C# tokenization already makes that unlikely. private const ulong FnvOffset = 14695981039346656037UL; private const ulong FnvPrime = 1099511628211UL; private static ulong HashTokens(SyntaxNode node, List excludedSpans) { ulong h = FnvOffset; int next = 0; // excludedSpans is sorted; tokens come in document order foreach (var token in node.DescendantTokens()) { if (excludedSpans != null) { while (next < excludedSpans.Count && excludedSpans[next].End <= token.Span.Start) next++; if (next < excludedSpans.Count && excludedSpans[next].Contains(token.Span)) continue; } h = (h ^ 0xFF) * FnvPrime; var text = token.Text; for (int i = 0; i < text.Length; i++) { h = (h ^ (byte)text[i]) * FnvPrime; h = (h ^ (byte)(text[i] >> 8)) * FnvPrime; } } return h; } private static ulong MixHash(ulong a, ulong b) { for (int i = 0; i < 8; i++) a = (a ^ (byte)(b >> (i * 8))) * FnvPrime; return a; } } }