Initial commit: AIGame Unity client with URP, HybridCLR, YooAsset and tooling.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
udx
2026-09-21 14:21:07 +08:00
co-authored by Cursor
commit f3f66ab063
931 changed files with 112366 additions and 0 deletions
@@ -0,0 +1,211 @@
using System;
using System.Collections.Generic;
using System.Linq;
using UnityPipeline.Microsoft.CodeAnalysis;
using UnityPipeline.Microsoft.CodeAnalysis.CSharp;
using UnityPipeline.Microsoft.CodeAnalysis.CSharp.Syntax;
using UnityEngine;
namespace Unity.Pipeline.CodeReload
{
/// <summary>
/// Accessibility gate for [CodeReload] method bodies on the compiled (Assembly.Load) backend.
///
/// The compiled backend cannot use non-public members of the target type: even if the override
/// compiled with Roslyn's access checks relaxed, Unity's Mono enforces accessibility when
/// JIT-ing the loaded override IL and does not honor <c>[assembly: IgnoresAccessChecksTo]</c>
/// (verified on Mono 6.13) — the access would throw (e.g. <c>FieldAccessException</c>) on first
/// dispatch and silently fall back to the original body. Compiled-backend overrides therefore
/// compile with standard access checks (see
/// <c>RoslynCompilationService.AllowNonPublicMemberAccess</c>, interpreter-only), and this
/// validator runs up front — only when the compiled backend will execute the override — to turn
/// the eventual raw CS0122 into a clear reload error. The interpreter backend reaches
/// non-public members via reflection and must not be gated.
/// </summary>
static class AccessibilityValidator
{
/// <summary>
/// Check that every [CodeReload] method body only accesses public instance members of its
/// declaring type. Runs over an already-built semantic model + class declaration, so the
/// in-place reload path shares one compilation between validation and transformation
/// instead of building two.
/// </summary>
/// <param name="model">The semantic model built over the source containing the declaring type.</param>
/// <param name="classDecl">The declaring type's class declaration within <paramref name="model"/>.</param>
/// <param name="methodBodies">Map of method name to body source, for the methods being validated.</param>
/// <param name="originalTypeName">Name of the declaring type to validate against.</param>
/// <returns>The validation result, including any violations found.</returns>
public static AccessibilityValidationResult ValidatePublicAccess(
SemanticModel model,
ClassDeclarationSyntax classDecl,
Dictionary<string, string> methodBodies,
string originalTypeName)
{
var result = new AccessibilityValidationResult
{
IsValid = true,
Violations = new List<AccessibilityViolation>()
};
try
{
var classSymbol = model.GetDeclaredSymbol(classDecl);
foreach (var method in classDecl.Members.OfType<MethodDeclarationSyntax>())
{
var methodName = method.Identifier.ValueText;
if (!methodBodies.ContainsKey(methodName) || method.Body == null)
continue;
var seen = new HashSet<string>();
foreach (var name in method.Body.DescendantNodes().OfType<SimpleNameSyntax>())
{
if (IsMemberName(name))
continue;
var symbol = model.GetSymbolInfo(name).Symbol;
if (!IsInstanceMemberOf(symbol, classSymbol))
continue;
if (symbol.DeclaredAccessibility != Accessibility.Public && seen.Add(symbol.Name))
{
result.Violations.Add(new AccessibilityViolation
{
MemberName = symbol.Name,
MethodName = methodName,
AccessLevel = symbol.DeclaredAccessibility,
ViolationType = AccessibilityViolationType.PrivateAccess,
ErrorMessage = $"Cannot access non-public member '{symbol.Name}' " +
$"({symbol.DeclaredAccessibility}) in [CodeReload] method '{methodName}'",
Suggestion = $"Make '{symbol.Name}' public in {originalTypeName}, or reload " +
"through the interpreter backend (reload_file_editor_interpreter), which " +
"reaches non-public members. The compiled backend loads the override " +
"as a separate assembly and Mono enforces accessibility at dispatch."
});
}
}
}
result.IsValid = result.Violations.Count == 0;
return result;
}
catch (Exception ex)
{
Debug.LogError($"CodeReload: Accessibility validation error: {ex.Message}");
return new AccessibilityValidationResult
{
IsValid = false,
ValidationError = ex.Message,
Violations = new List<AccessibilityViolation>()
};
}
}
/// <summary>True if the name is the right-hand member name of an access (foo.Bar -> Bar).</summary>
private static bool IsMemberName(SimpleNameSyntax node)
{
if (node.Parent is MemberAccessExpressionSyntax ma && ma.Name == node)
return true;
if (node.Parent is QualifiedNameSyntax)
return true;
if (node.Parent is MemberBindingExpressionSyntax)
return true;
return false;
}
/// <summary>True if the symbol is an instance field/property/method/event of the type or a base.</summary>
private static bool IsInstanceMemberOf(ISymbol symbol, INamedTypeSymbol type)
{
if (symbol == null || symbol.IsStatic)
return false;
switch (symbol.Kind)
{
case SymbolKind.Field:
case SymbolKind.Property:
case SymbolKind.Method:
case SymbolKind.Event:
break;
default:
return false;
}
for (var t = type; t != null; t = t.BaseType)
{
if (SymbolEqualityComparer.Default.Equals(t, symbol.ContainingType))
return true;
}
return false;
}
}
/// <summary>
/// Result of accessibility validation for code reload methods.
/// </summary>
class AccessibilityValidationResult
{
/// <summary>True when no accessibility violations were found.</summary>
public bool IsValid { get; set; }
/// <summary>Violations found, if any.</summary>
public List<AccessibilityViolation> Violations { get; set; } = new List<AccessibilityViolation>();
/// <summary>Set when validation itself could not run (e.g. a parse failure), rather than a violation being found.</summary>
public string ValidationError { get; set; }
/// <summary>Human-readable summary of <see cref="ValidationError"/> or all <see cref="Violations"/>.</summary>
public string GetFormattedErrorMessage()
{
if (!string.IsNullOrEmpty(ValidationError))
return $"CodeReload Validation Error: {ValidationError}";
if (Violations.Count == 0)
return "All member access is valid for code reload.";
var errorMessage = $"CodeReload Validation Failed: {Violations.Count} accessibility violation(s) found\n\n";
for (int i = 0; i < Violations.Count; i++)
{
var violation = Violations[i];
errorMessage += $"{i + 1}. Method '{violation.MethodName}': {violation.ErrorMessage}\n";
errorMessage += $" → Suggestion: {violation.Suggestion}\n";
if (i < Violations.Count - 1)
errorMessage += "\n";
}
errorMessage += "\nFix these accessibility issues and run reload_file again.";
return errorMessage;
}
}
/// <summary>
/// Information about a specific accessibility violation in code reload code.
/// </summary>
class AccessibilityViolation
{
/// <summary>Name of the non-public member that was accessed.</summary>
public string MemberName { get; set; }
/// <summary>Name of the [CodeReload] method containing the access.</summary>
public string MethodName { get; set; }
/// <summary>The member's actual accessibility.</summary>
public Accessibility AccessLevel { get; set; }
/// <summary>Category of violation.</summary>
public AccessibilityViolationType ViolationType { get; set; }
/// <summary>Human-readable description of the violation.</summary>
public string ErrorMessage { get; set; }
/// <summary>Suggested fix.</summary>
public string Suggestion { get; set; }
}
/// <summary>
/// Types of accessibility violations that can occur in code reload methods.
/// </summary>
enum AccessibilityViolationType
{
/// <summary>A private member was accessed.</summary>
PrivateAccess,
/// <summary>An internal member was accessed.</summary>
InternalAccess,
/// <summary>A protected member was accessed.</summary>
ProtectedAccess,
/// <summary>The source could not be parsed to determine accessibility.</summary>
ParseError
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 1bdaafa6e8d172648a57310104db179a
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,67 @@
using System;
namespace Unity.Pipeline.CodeReload
{
/// <summary>
/// Thread-safe snapshot of the most recent code-reload activity, for on-screen display by
/// <see cref="CodeReloadStatusOverlay"/>. Fed from two sides: the PlayerConnection receiver
/// (compile-started / compile-failed notices arrive on a background thread) and the apply path
/// in RuntimePipelineDriver (main thread). Timestamps use <see cref="Environment.TickCount"/>
/// because Unity's Time API is main-thread-only and reporters run off it.
/// </summary>
static class CodeReloadActivity
{
public enum Phase
{
/// <summary>No reload in flight; nothing to show beyond the override count.</summary>
Idle = 0,
/// <summary>The editor announced a compile for this player (ReloadPendingMsg).</summary>
Compiling = 1,
/// <summary>A pushed override was applied; message is the apply summary.</summary>
Applied = 2,
/// <summary>Editor compile failed or on-device apply failed; message says why.</summary>
Failed = 3,
}
private static readonly object s_Lock = new object();
private static Phase s_Phase = Phase.Idle;
private static string s_Message = "";
private static int s_PhaseTick = Environment.TickCount;
public static void ReportCompileStarted(string name) => Set(Phase.Compiling, name);
public static void ReportApplied(string summary) => Set(Phase.Applied, summary);
public static void ReportFailed(string summary) => Set(Phase.Failed, summary);
private static void Set(Phase phase, string message)
{
lock (s_Lock)
{
s_Phase = phase;
s_Message = message ?? "";
s_PhaseTick = Environment.TickCount;
}
}
/// <summary>Current phase, its message, and milliseconds since the phase changed.</summary>
public static Phase Snapshot(out string message, out int ageMs)
{
lock (s_Lock)
{
message = s_Message;
ageMs = Environment.TickCount - s_PhaseTick;
return s_Phase;
}
}
private static long s_OverrideCalls;
/// <summary>Called by the dispatch path once per overridden-method invocation. The running
/// total lets the overlay show a call rate — "N overrides · 0 calls/s" exposes the
/// bound-but-inert case (override registered but the woven prologue never dispatching),
/// which no log line catches.</summary>
public static void CountOverrideCall() => System.Threading.Interlocked.Increment(ref s_OverrideCalls);
/// <summary>Total overridden-method invocations this session.</summary>
public static long OverrideCallCount => System.Threading.Interlocked.Read(ref s_OverrideCalls);
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 689f041b295d86a4b8ffd6249abf5a7a
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,295 @@
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
{
/// <summary>
/// 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.
/// </summary>
static class CodeReloadBaseline
{
private sealed class Entry
{
public ulong ContextHash;
public readonly Dictionary<string, ulong> MethodHashes = new Dictionary<string, ulong>();
}
// Key: normalized full path + '\u001f' + define key.
private static readonly Dictionary<string, Entry> s_Entries =
new Dictionary<string, Entry>(StringComparer.OrdinalIgnoreCase);
/// <summary>Number of captured (file, defines) baselines.</summary>
public static int Count => s_Entries.Count;
/// <summary>
/// Snapshot <paramref name="filePath"/> 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.
/// </summary>
public static bool Capture(string filePath, string[] preprocessorSymbols = null)
{
string source;
try { source = File.ReadAllText(filePath); }
catch { source = null; }
return CaptureFromSource(filePath, source, preprocessorSymbols);
}
/// <summary>See <see cref="Capture"/>; source text supplied by the caller (tests).</summary>
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;
}
/// <summary>
/// Diff <paramref name="sourceCode"/> against the captured baseline for
/// (<paramref name="filePath"/>, <paramref name="preprocessorSymbols"/>). 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.
/// </summary>
public static HashSet<string> GetUnchangedMethods(
string filePath, string sourceCode, string[] preprocessorSymbols = null)
{
if (!s_Entries.TryGetValue(KeyFor(filePath, preprocessorSymbols), out var baseline))
return null;
var unchanged = new HashSet<string>(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;
}
/// <summary>
/// True when every [CodeReload] method in <paramref name="sourceCode"/> matches the baseline
/// and none of them is in <paramref name="mustReload"/> (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.
/// </summary>
public static bool IsFileUpToDate(
string filePath, string sourceCode, string[] preprocessorSymbols = null,
ICollection<string> 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;
}
/// <summary>Drop every captured baseline (watch stopped, or about to recapture).</summary>
public static void Clear() => s_Entries.Clear();
/// <summary>
/// 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.
/// </summary>
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();
}
/// <summary>Replace the store with a <see cref="Serialize"/> snapshot. Unparseable lines are dropped.</summary>
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 ? "<project>" : string.Join(";", preprocessorSymbols);
return path + '\u001f' + defines;
}
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>
/// 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.
/// </summary>
private static List<TextSpan> CollectBodySpans(SyntaxNode root)
{
var spans = new List<TextSpan>();
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<TextSpan> 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;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 9844e60b4d0144ffa632f3433a159737
@@ -0,0 +1,673 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Runtime.ExceptionServices;
using Unity.Pipeline.Threading;
using UnityEngine;
namespace Unity.Pipeline.CodeReload
{
/// <summary>
/// Central registry for managing code reload method and component overrides.
/// Handles runtime method resolution, override registration, and fallback logic.
/// Thread-safe for runtime code reload operations.
/// </summary>
/// <remarks>
/// Deliberately NOT [NoAutoStaticsCleanup]: RegisterReloadableMethod appends without deduplicating,
/// and RuntimePipelineDriver.Awake re-registers on every Play Mode entry, so opting out would
/// accumulate duplicates across FEPM sessions. Let AutoStaticsCleanup reset these statics instead.
/// </remarks>
public static class CodeReloadRegistry
{
/// <summary>
/// Dispatcher used to marshal main-thread-required code-reload overrides to the main thread.
/// Injected by RuntimePipelineDriver (= its server's dispatcher). Null = run on the
/// current thread (no marshaling).
/// </summary>
public static Dispatcher Dispatcher { get; set; }
/// <summary>
/// Absolute roots a running Player is allowed to code reload source files from (Assets folder
/// + loaded package locations). Baked into RuntimePipelineBuildInfo at build time (a running
/// Player cannot resolve the project layout) and published here when the runtime server
/// starts, so reload commands can validate incoming file paths. Null until published.
/// </summary>
public static IReadOnlyList<string> AllowedReloadRoots { get; set; }
// Facts established at domain-reload / Play Mode entry that only matter once the user
// actually reloads (target count, receiver state, connected players). Collected here and
// printed as ONE console line on the first override registration ATTEMPT of the session —
// attempt, not success, so a reload against an empty registry still gets the summary next
// to its skip reasons. Statics reset with the domain, so each session collects afresh.
private static readonly List<string> s_FirstReloadNotes = new();
private static bool s_FirstReloadNotesFlushed;
/// <summary>
/// Record a startup fact for the "first reload this session" summary line. Once that line
/// has been printed, a late note (e.g. a player connecting mid-session) is logged directly.
/// </summary>
/// <param name="note">Short clause, no trailing period, e.g. "3 reloadable method(s) registered".</param>
public static void NoteForFirstReload(string note)
{
if (string.IsNullOrEmpty(note)) return;
lock (s_FirstReloadNotes)
{
if (s_FirstReloadNotesFlushed)
{
Debug.Log($"CodeReload: {note}.");
return;
}
s_FirstReloadNotes.Add(note);
}
}
private static void FlushFirstReloadNotes()
{
string line = null;
lock (s_FirstReloadNotes)
{
if (s_FirstReloadNotesFlushed) return;
s_FirstReloadNotesFlushed = true;
if (s_FirstReloadNotes.Count > 0)
line = "CodeReload: first reload this session — " + string.Join("; ", s_FirstReloadNotes) + ".";
s_FirstReloadNotes.Clear();
}
if (line != null)
Debug.Log(line);
}
// Thread-safe collections for runtime code reload switching
private static readonly ConcurrentDictionary<string, MethodOverride> m_MethodOverrides = new();
private static readonly ConcurrentDictionary<string, List<MethodInfo>> m_ReloadableMethods = new();
private static readonly ConcurrentDictionary<string, Type> m_LoadedCodeReloadTypes = new();
/// <summary>
/// Raised after a method override is registered or removed (once per method — a
/// multi-method reload fires it several times back to back, so debounce reactions).
/// Lets UI that reflects reloadable code (e.g. a debug panel overlay) rebuild when a
/// compiled method's body is swapped.
///
/// INTERNAL for now — deliberately held back from the first public API surface; make it
/// public when the introspection surface ships (external consumers poll
/// GetStats().ActiveOverrideCount meanwhile). The raise sites stay: register, unregister
/// (revert), and ClearAllOverrides — the transitions a subscriber needs.
/// </summary>
internal static event Action OverridesChanged;
private static void RaiseOverridesChanged()
{
try
{
OverridesChanged?.Invoke();
}
catch (Exception ex)
{
Debug.LogError($"CodeReload: OverridesChanged handler threw: {ex}");
}
}
/// <summary>
/// Register a method as reloadable. Called during discovery phase.
/// </summary>
/// <param name="method">The method marked with <see cref="CodeReloadAttribute"/>.</param>
/// <param name="attribute">The attribute instance (supplies the override id and main-thread requirement).</param>
public static void RegisterReloadableMethod(MethodInfo method, CodeReloadAttribute attribute)
{
var methodId = GetMethodId(method, attribute.Id);
if (!m_ReloadableMethods.ContainsKey(methodId))
{
m_ReloadableMethods[methodId] = new List<MethodInfo>();
}
m_ReloadableMethods[methodId].Add(method);
}
/// <summary>
/// Register a code reload method override from compiled code reload assembly.
/// Returns true if the override was registered, false if it was skipped.
/// </summary>
/// <param name="overrideMethod">The override method marked with <see cref="CodeReloadOverrideMethodAttribute"/>.</param>
/// <param name="attribute">The attribute instance (identifies the target method).</param>
/// <param name="sourceType">The type the override method was compiled into.</param>
/// <returns>True if the override was registered.</returns>
public static bool RegisterMethodOverride(MethodInfo overrideMethod, CodeReloadOverrideMethodAttribute attribute, Type sourceType)
{
return RegisterMethodOverride(overrideMethod, attribute, sourceType, out _);
}
/// <summary>
/// Register a code reload method override from compiled code reload assembly.
/// Returns true if the override was registered, false if it was skipped (target not
/// reloadable or signature mismatch). The out parameter carries a user-facing reason
/// when registration is skipped.
/// </summary>
/// <param name="overrideMethod">The override method marked with <see cref="CodeReloadOverrideMethodAttribute"/>.</param>
/// <param name="attribute">The attribute instance (identifies the target method).</param>
/// <param name="sourceType">The type the override method was compiled into.</param>
/// <param name="skipReason">A user-facing reason when registration is skipped, or null on success.</param>
/// <returns>True if the override was registered.</returns>
public static bool RegisterMethodOverride(MethodInfo overrideMethod, CodeReloadOverrideMethodAttribute attribute, Type sourceType, out string skipReason)
{
FlushFirstReloadNotes();
skipReason = null;
var targetMethodId = attribute.TargetMethodId;
// Validate that target method exists and is reloadable
if (!m_ReloadableMethods.ContainsKey(targetMethodId))
{
Debug.LogWarning($"CodeReload: Target method '{targetMethodId}' not found or not marked [CodeReload]");
skipReason = $"target '{targetMethodId}' is not registered as a reloadable [CodeReload] method. " +
"Ensure the component is in the scene and in play mode (RuntimePipelineDriver auto-discovers it).";
return false;
}
var originalMethods = m_ReloadableMethods[targetMethodId];
if (!originalMethods.Any())
{
Debug.LogWarning($"CodeReload: No original methods registered for '{targetMethodId}'");
skipReason = $"no original methods registered for '{targetMethodId}'.";
return false;
}
// Validate signature compatibility (basic check - instance parameter + matching return type)
var originalMethod = originalMethods.First();
if (!ValidateSignatureCompatibility(originalMethod, overrideMethod))
{
Debug.LogError($"CodeReload: Signature mismatch for '{targetMethodId}'. Override method must have instance parameter as first argument.");
skipReason = $"signature mismatch for '{targetMethodId}'. The override must be " +
$"'public static {originalMethod.ReturnType.Name} {overrideMethod.Name}" +
$"({originalMethod.DeclaringType?.Name} instance, ...)'.";
return false;
}
var methodOverride = new MethodOverride
{
TargetMethodId = targetMethodId,
OverrideMethod = overrideMethod,
SourceType = sourceType,
RequireMainThread = GetMainThreadRequirement(originalMethod),
Description = attribute.Description
};
m_MethodOverrides[targetMethodId] = methodOverride;
RaiseOverridesChanged();
return true;
}
/// <summary>
/// Register an IL2CPP-safe override that dispatches through <paramref name="interpreterInvoke"/>
/// (a IlInterpreter interpreter invoke) instead of a loaded <see cref="MethodInfo"/>. The target
/// must already be registered reloadable (same precondition as <see cref="RegisterMethodOverride"/>).
/// </summary>
internal static bool RegisterInterpreterMethodOverride(
string targetMethodId, Func<object, object[], object> interpreterInvoke, out string skipReason)
{
FlushFirstReloadNotes();
skipReason = null;
if (!m_ReloadableMethods.TryGetValue(targetMethodId, out var originalMethods) || originalMethods.Count == 0)
{
skipReason = $"target '{targetMethodId}' is not registered as a reloadable [CodeReload] method. " +
"Ensure the component is in the scene and in play mode (RuntimePipelineDriver auto-discovers it).";
return false;
}
m_MethodOverrides[targetMethodId] = new MethodOverride
{
TargetMethodId = targetMethodId,
InterpreterInvoke = interpreterInvoke,
RequireMainThread = GetMainThreadRequirement(originalMethods.First()),
};
Debug.Log($"CodeReload: Registered interpreter override for '{targetMethodId}'");
RaiseOverridesChanged();
return true;
}
/// <summary>
/// Remove one active override so the woven prologue falls through to the original compiled
/// body again. Used when a reloaded file's method matches the compiled baseline again (the
/// edit was reverted) — keeping the override would leave a correct but slower interpreter
/// dispatch in place. Returns false when no override was registered for the id.
/// </summary>
internal static bool UnregisterMethodOverride(string targetMethodId)
{
if (!m_MethodOverrides.TryRemove(targetMethodId, out _))
return false;
Debug.Log($"CodeReload: Removed override for '{targetMethodId}' — original compiled body is active again");
RaiseOverridesChanged();
return true;
}
/// <summary>
/// After a reload binds, invoke every <c>[OnCodeReload]</c> method on all live instances of the
/// types that own the given reloaded methods. Lets a component re-init state when its code is
/// swapped. Parameterless instance methods only; exceptions are logged, not propagated.
/// </summary>
internal static void InvokeReloadCallbacks(IEnumerable<string> reloadedTargetMethodIds)
{
if (reloadedTargetMethodIds == null) return;
var types = new HashSet<Type>();
foreach (var id in reloadedTargetMethodIds)
{
if (m_ReloadableMethods.TryGetValue(id, out var methods) && methods.Count > 0
&& methods[0].DeclaringType != null)
{
types.Add(methods[0].DeclaringType);
}
}
foreach (var type in types)
InvokeReloadCallbacksOnType(type);
}
private static void InvokeReloadCallbacksOnType(Type type)
{
var callbacks = type
.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance)
.Where(m => m.GetParameters().Length == 0 && m.GetCustomAttribute<OnCodeReloadAttribute>() != null)
.ToList();
if (callbacks.Count == 0) return;
if (!typeof(UnityEngine.Object).IsAssignableFrom(type))
{
Debug.LogWarning($"CodeReload: [OnCodeReload] on '{type.Name}' ignored — only " +
"UnityEngine.Object-derived types are supported (instances are found via FindObjectsByType).");
return;
}
#if UNITY_6000_5_OR_NEWER
var instances = UnityEngine.Object.FindObjectsByType(type);
#else
var instances = UnityEngine.Object.FindObjectsByType(type, FindObjectsSortMode.None);
#endif
foreach (var instance in instances)
{
foreach (var cb in callbacks)
{
try
{
cb.Invoke(instance, null);
}
catch (Exception ex)
{
Debug.LogError($"CodeReload: [OnCodeReload] {type.Name}.{cb.Name} threw: {ex.Message}");
}
}
}
}
/// <summary>True when at least one [CodeReload] target has been registered this session.</summary>
internal static bool HasReloadableMethods => m_ReloadableMethods.Count > 0;
/// <summary>
/// Resolve the runtime <see cref="Type"/> that declares a reloadable target method, so an
/// interpreter host binding can be built for it. Returns false if not registered.
/// </summary>
internal static bool TryGetReloadableDeclaringType(string targetMethodId, out Type declaringType)
{
declaringType = null;
if (m_ReloadableMethods.TryGetValue(targetMethodId, out var methods) && methods.Count > 0)
{
declaringType = methods[0].DeclaringType;
return declaringType != null;
}
return false;
}
/// <summary>
/// Attempt to invoke code reload override if available, otherwise invoke original method.
/// Returns true if code reload override was invoked, false if original method should be called.
/// </summary>
/// <typeparam name="T">The instance's type.</typeparam>
/// <param name="methodId">Method identifier, "TypeName.MethodName".</param>
/// <param name="instance">The instance to invoke the override on.</param>
/// <param name="parameters">The original call's arguments, boxed.</param>
/// <returns>True if a code reload override was invoked.</returns>
public static bool TryInvokeCodeReload<T>(string methodId, T instance, object[] parameters = null)
{
return TryInvokeCodeReload(methodId, (object)instance, parameters);
}
/// <summary>
/// Cheap, allocation-free check for an active override. The woven prologue calls this
/// before building the boxed argument array, so a woven method with no active override
/// allocates nothing per call.
/// </summary>
/// <param name="methodId">Method identifier, "TypeName.MethodName".</param>
/// <returns>True if an override is currently registered for <paramref name="methodId"/>.</returns>
public static bool HasOverride(string methodId) => m_MethodOverrides.ContainsKey(methodId);
/// <summary>
/// Non-generic dispatch entry point. This is the method woven into [CodeReload]
/// methods at compile time (a non-generic signature keeps the injected IL simple).
/// Returns true if a code reload override was invoked, false if the original body should run.
/// </summary>
/// <param name="methodId">Method identifier, "TypeName.MethodName".</param>
/// <param name="instance">The instance to invoke the override on.</param>
/// <param name="parameters">The original call's arguments, boxed.</param>
/// <returns>True if a code reload override was invoked.</returns>
public static bool TryInvokeCodeReload(string methodId, object instance, object[] parameters = null)
=> TryInvokeCodeReloadResult(methodId, instance, parameters, out _);
/// <summary>
/// Called by the woven prologue when an override returned null for a method whose return
/// type is a value type — unboxing null would throw an opaque NullReferenceException at
/// the call site. Public only for woven code; not meant to be called by hand.
/// </summary>
/// <param name="methodId">Method identifier, "TypeName.MethodName", named in the thrown message.</param>
public static void ThrowOverrideReturnedNull(string methodId)
{
throw new InvalidOperationException(
$"CodeReload: the override for '{methodId}' returned null, but the method returns " +
"a value type, so there is no value to unbox. Fix the override to return a value, " +
"or revert the reload.");
}
/// <summary>
/// Value-returning dispatch: woven into value-returning [CodeReload] methods. Like
/// <see cref="TryInvokeCodeReload(string, object, object[])"/> but also hands back the
/// override's (boxed) return value in <paramref name="result"/>. Returns true if an override
/// ran (and the woven prologue should return <paramref name="result"/>), false if the
/// original body should run.
/// </summary>
/// <param name="methodId">Method identifier, "TypeName.MethodName".</param>
/// <param name="instance">The instance to invoke the override on.</param>
/// <param name="parameters">The original call's arguments, boxed.</param>
/// <param name="result">The override's (boxed) return value, if one ran.</param>
/// <returns>True if a code reload override was invoked.</returns>
public static bool TryInvokeCodeReloadResult(string methodId, object instance, object[] parameters, out object result)
{
result = null;
if (!m_MethodOverrides.TryGetValue(methodId, out var methodOverride))
{
return false; // No code reload override available
}
CodeReloadActivity.CountOverrideCall(); // feeds the overlay's calls/s rate
try
{
result = InvokeOverrideMarshalled(methodOverride, instance, parameters);
return true;
}
catch (TargetInvocationException ex) when (ex.InnerException != null)
{
// Exception from mono backend - rethrow for cases like imgui ExitGui
ExceptionDispatchInfo.Capture(ex.InnerException).Throw();
throw; // unreachable
}
catch (IlInterpreter.ScriptRuntimeException ex) when (ex.InnerException != null)
{
// Exception from IlInterpreter backend - rethrow for cases like imgui ExitGui.
// The inner exception alone has no script location; log the interpreter's
// message (which carries "at line N (file)") first so the failure is traceable.
if (!(ex.InnerException is ExitGUIException))
Debug.LogError($"CodeReload: override '{methodId}' threw {ex.Message}");
ExceptionDispatchInfo.Capture(ex.InnerException).Throw();
throw; // unreachable
}
catch (Exception ex)
{
// Invocation infrastructure failed (interpreter-detected error, reflection arg
// marshaling): log and fall back to the original body.
Debug.LogError($"CodeReload: Error invoking override for '{methodId}': " +
$"{ex.GetType().Name}: {ex.Message}");
Debug.LogError($"CodeReload: Stack trace: {ex.StackTrace}");
result = null;
return false; // Fall back to original method
}
}
/// <summary>
/// Run the override, marshalling to the main thread when required, and return its (boxed)
/// result. The common case is already on the main thread: invoke directly with no per-call
/// closure allocation; only the rare cross-thread marshal pays for a closure.
/// </summary>
private static object InvokeOverrideMarshalled(MethodOverride methodOverride, object instance, object[] parameters)
{
if (methodOverride.RequireMainThread && Dispatcher != null && !Dispatcher.IsMainThread())
{
object marshalled = null;
var mo = methodOverride;
Dispatcher.Invoke(() => marshalled = InvokeOverride(mo, instance, parameters));
return marshalled;
}
return InvokeOverride(methodOverride, instance, parameters);
}
/// <summary>
/// Invoke the override: through the IlInterpreter interpreter (IL2CPP-safe; the delegate marshals
/// args itself, so no array is built here) or via reflection on the loaded MethodInfo (which
/// needs an instance-prepended argument array). Returns the (boxed) result, null for void.
/// </summary>
private static object InvokeOverride(MethodOverride methodOverride, object instance, object[] parameters)
{
if (methodOverride.InterpreterInvoke != null)
return methodOverride.InterpreterInvoke(instance, parameters);
var invokeParams = new object[(parameters?.Length ?? 0) + 1];
invokeParams[0] = instance;
if (parameters != null && parameters.Length > 0)
Array.Copy(parameters, 0, invokeParams, 1, parameters.Length);
return methodOverride.OverrideMethod.Invoke(null, invokeParams);
}
/// <summary>
/// Register all methods in a type that have the [CodeReload] attribute.
/// Uses reflection to discover and register them as reload targets.
/// </summary>
/// <param name="type">The type to scan for [CodeReload] methods.</param>
public static void RegisterReloadableType(System.Type type)
{
if (type == null)
{
Debug.LogWarning("CodeReload: Cannot register null type");
return;
}
int registeredCount = 0;
// Get all instance methods (public and non-public)
var instanceMethods = type.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
foreach (var method in instanceMethods)
{
var codeReloadAttr = method.GetCustomAttribute<CodeReloadAttribute>();
if (codeReloadAttr != null)
{
RegisterReloadableMethod(method, codeReloadAttr);
registeredCount++;
}
}
// Get all static methods (public and non-public)
var staticMethods = type.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static);
foreach (var method in staticMethods)
{
var codeReloadAttr = method.GetCustomAttribute<CodeReloadAttribute>();
if (codeReloadAttr != null)
{
RegisterReloadableMethod(method, codeReloadAttr);
registeredCount++;
}
}
if (registeredCount > 0)
{
Debug.Log($"CodeReload: Registered {registeredCount} reloadable methods from type {type.Name}");
}
else
{
Debug.Log($"CodeReload: No methods with [CodeReload] attribute found in type {type.Name}");
}
}
/// <summary>
/// Register a type from loaded code reload assembly for discovery scanning.
/// </summary>
/// <param name="type">The loaded code-reload assembly's type.</param>
/// <param name="assemblyId">Identifier of the compiled code-reload assembly the type came from.</param>
public static void RegisterCodeReloadType(Type type, string assemblyId)
{
var typeKey = $"{assemblyId}:{type.FullName}";
m_LoadedCodeReloadTypes[typeKey] = type;
}
/// <summary>
/// Clear all code reload overrides. Used by cleanup_codereload command.
/// </summary>
public static void ClearAllOverrides()
{
var overrideCount = m_MethodOverrides.Count;
var typeCount = m_LoadedCodeReloadTypes.Count;
m_MethodOverrides.Clear();
m_LoadedCodeReloadTypes.Clear();
if (overrideCount > 0)
RaiseOverridesChanged();
}
/// <summary>
/// Clear all registry state including reloadable methods.
/// FOR TESTING ONLY - this should not be called in production code.
/// </summary>
public static void ClearAllForTesting()
{
var overrideCount = m_MethodOverrides.Count;
var typeCount = m_LoadedCodeReloadTypes.Count;
var reloadableCount = m_ReloadableMethods.Sum(kvp => kvp.Value.Count);
m_MethodOverrides.Clear();
m_LoadedCodeReloadTypes.Clear();
m_ReloadableMethods.Clear();
}
/// <summary>
/// Get statistics about current code reload state.
/// </summary>
/// <returns>Current reloadable/override/loaded-type counts.</returns>
public static CodeReloadStats GetStats()
{
return new CodeReloadStats
{
ReloadableMethodCount = m_ReloadableMethods.Sum(kvp => kvp.Value.Count),
ActiveOverrideCount = m_MethodOverrides.Count,
LoadedTypeCount = m_LoadedCodeReloadTypes.Count,
ReloadableMethodIds = m_ReloadableMethods.Keys.ToList(),
ActiveOverrideIds = m_MethodOverrides.Keys.ToList()
};
}
/// <summary>
/// Generate method ID from MethodInfo and optional custom ID.
/// Format: TypeName.MethodName or custom ID if provided.
/// </summary>
private static string GetMethodId(MethodInfo method, string customId = null)
{
if (!string.IsNullOrEmpty(customId))
{
return customId;
}
return $"{method.DeclaringType?.Name}.{method.Name}";
}
/// <summary>
/// Validate that code reload method signature is compatible with original method.
/// Code reload method must have instance parameter as first argument.
/// </summary>
private static bool ValidateSignatureCompatibility(MethodInfo originalMethod, MethodInfo overrideMethod)
{
var originalParams = originalMethod.GetParameters();
var overrideParams = overrideMethod.GetParameters();
// Code reload method must have at least one parameter (the instance)
if (overrideParams.Length == 0)
{
return false;
}
// First parameter must be compatible with declaring type of original method
var firstParam = overrideParams[0];
if (!originalMethod.DeclaringType.IsAssignableFrom(firstParam.ParameterType))
{
return false;
}
// Remaining parameters must match original method parameters
if (overrideParams.Length - 1 != originalParams.Length)
{
return false;
}
for (int i = 0; i < originalParams.Length; i++)
{
if (overrideParams[i + 1].ParameterType != originalParams[i].ParameterType)
{
return false;
}
}
// Return types must match
return originalMethod.ReturnType == overrideMethod.ReturnType;
}
/// <summary>
/// Determine if original method requires main thread execution.
/// </summary>
private static bool GetMainThreadRequirement(MethodInfo originalMethod)
{
// Honour the [CodeReload] attribute's main thread requirement
var codeReloadAttr = originalMethod.GetCustomAttribute<CodeReloadAttribute>();
if (codeReloadAttr != null)
{
return codeReloadAttr.RequireMainThread;
}
// Default to main thread requirement for safety
return true;
}
/// <summary>
/// Information about a registered method override.
/// </summary>
private class MethodOverride
{
public string TargetMethodId { get; set; }
public MethodInfo OverrideMethod { get; set; }
/// <summary>
/// IL2CPP-safe dispatch: when set, the override runs through this delegate (a IlInterpreter
/// interpreter invoke) instead of <see cref="OverrideMethod"/> reflection. Receives the
/// target instance and the original call arguments; returns the method's (boxed) result,
/// or null for a void method.
/// </summary>
public Func<object, object[], object> InterpreterInvoke { get; set; }
public Type SourceType { get; set; }
public bool RequireMainThread { get; set; }
public string Description { get; set; }
}
}
/// <summary>
/// Statistics about current code reload registry state.
/// </summary>
public class CodeReloadStats
{
/// <summary>Number of methods registered as reloadable.</summary>
public int ReloadableMethodCount { get; set; }
/// <summary>Number of active method overrides.</summary>
public int ActiveOverrideCount { get; set; }
/// <summary>Number of code-reload assembly types currently loaded.</summary>
public int LoadedTypeCount { get; set; }
/// <summary>Method ids registered as reloadable.</summary>
public List<string> ReloadableMethodIds { get; set; } = new();
/// <summary>Method ids with an active override.</summary>
public List<string> ActiveOverrideIds { get; set; } = new();
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 23cd8d0c782f1974e8a3b4095a178092
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,703 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using UnityPipeline.Microsoft.CodeAnalysis;
using UnityPipeline.Microsoft.CodeAnalysis.CSharp;
using UnityPipeline.Microsoft.CodeAnalysis.CSharp.Syntax;
using Unity.Pipeline.Compilation;
using UnityEngine;
namespace Unity.Pipeline.CodeReload
{
/// <summary>
/// Main orchestrator for in-place code reload processing.
/// Handles the complete pipeline: source parsing -> transformation -> compilation.
/// </summary>
static class InPlaceReloadProcessor
{
/// <summary>
/// Process a source file for in-place code reload.
/// Extracts [CodeReload] methods, transforms to static overrides, and compiles.
/// DEADLOCK FIX: Detects main thread and uses synchronous processing when needed.
/// </summary>
/// <param name="sourceFilePath">Path to source file containing [CodeReload] methods</param>
/// <param name="assemblyDir">Optional directory to save compiled assembly</param>
/// <param name="pdb">Emit debug symbols mapped to the original source so breakpoints bind.</param>
/// <returns>Compilation result with success/failure and diagnostic information</returns>
public static Task<InPlaceReloadResult> ProcessSourceFileAsync(string sourceFilePath, string assemblyDir = null, bool pdb = false, bool useInterpreter = false)
{
// Runs synchronously on the calling (main) thread; returns a completed Task.
return Task.FromResult(ProcessSourceFileOnMainThread(sourceFilePath, assemblyDir, pdb, useInterpreter));
}
/// <summary>
/// Process source file on main thread synchronously to avoid deadlocks.
/// </summary>
/// <param name="sourceFilePath">Path to source file containing [CodeReload] methods.</param>
/// <param name="assemblyDir">Optional directory to save compiled assembly.</param>
/// <param name="pdb">Emit debug symbols mapped to the original source so breakpoints bind.</param>
/// <param name="useInterpreter">Route the override through the interpreter backend instead of Assembly.Load.</param>
/// <returns>Compilation result with success/failure and diagnostic information.</returns>
public static InPlaceReloadResult ProcessSourceFileOnMainThread(string sourceFilePath, string assemblyDir = null, bool pdb = false, bool useInterpreter = false)
{
var result = new InPlaceReloadResult
{
SourceFilePath = sourceFilePath,
Success = false
};
try
{
Debug.Log($"CodeReload: Processing source file synchronously on main thread: {sourceFilePath}");
// The interpreter backend needs #line directives when source-line errors are
// enabled: its runtime errors resolve locations from the embedded PDB, which are
// only meaningful mapped to the user's file.
var lineInfo = useInterpreter && CodeReloadCompiler.EmitSourceLineInfo;
var prep = PrepareOverride(sourceFilePath, pdb || lineInfo, enforcePublicAccess: !useInterpreter);
if (!prep.Success)
{
result.ErrorMessage = prep.Error;
result.CompilationDiagnostics = prep.Diagnostics;
return result;
}
result.OriginalTypeName = prep.TypeName;
result.ExtractedMethods = prep.MethodNames;
result.UpToDateMethods = prep.UpToDateMethods;
result.TransformedCode = prep.TransformedCode;
// A method that matches the baseline again (the edit was undone) may still carry an
// override from an earlier reload — drop it so the compiled body runs at full speed.
foreach (var name in prep.UpToDateMethods)
{
var id = $"{prep.TypeName}.{name}";
if (CodeReloadRegistry.UnregisterMethodOverride(id))
result.RevertedMethods.Add(id);
}
if (prep.MethodNames.Count == 0)
{
result.Success = true;
result.CompilationDiagnostics = prep.Diagnostics;
Debug.Log($"CodeReload: {sourceFilePath} is up to date — all " +
$"{prep.UpToDateMethods.Count} [CodeReload] method(s) match the compiled baseline" +
(result.RevertedMethods.Count > 0
? $"; removed {result.RevertedMethods.Count} stale override(s)" : "") + ".");
return result;
}
var compilationResult = CompileTransformedCode(prep, assemblyDir, pdb, sourceFilePath, useInterpreter);
result.Success = compilationResult.IsSuccess;
result.AssemblyName = compilationResult.AssemblyName;
result.RegisteredMethods = compilationResult.RegisteredMethods;
result.CompilationDiagnostics = prep.Diagnostics.Concat(compilationResult.Diagnostics).ToList();
if (result.Success)
{
Debug.Log($"CodeReload: In-place reload successful for {sourceFilePath} - {result.RegisteredMethods.Count} methods registered" +
(result.UpToDateMethods.Count > 0 ? $" ({result.UpToDateMethods.Count} up to date, left compiled)" : ""));
}
else
{
result.ErrorMessage = compilationResult.ErrorDetails ?? "Compilation failed";
Debug.LogError($"CodeReload: In-place reload compilation failed: {result.ErrorMessage}");
}
return result;
}
catch (Exception ex)
{
Debug.LogError($"CodeReload: Error processing source file {sourceFilePath}: {ex.Message}\n{ex.StackTrace}");
result.ErrorMessage = $"Processing error: {ex.Message}";
return result;
}
}
/// <summary>
/// What <see cref="PrepareOverride"/> hands back: the extracted type and surviving methods,
/// the transformed source (null when everything was up to date), and the diagnostics
/// gathered on the way. <see cref="Success"/> false means a hard failure with
/// <see cref="Error"/> set.
/// </summary>
private sealed class OverridePreparation
{
public bool Success;
public string Error;
public string TypeName;
public List<string> MethodNames;
public List<string> UpToDateMethods = new List<string>();
public string TransformedCode;
public List<string> Diagnostics = new List<string>();
}
/// <summary>
/// Shared prefix for in-place reload and push-to-player: read the file, extract [CodeReload]
/// methods, drop the ones still matching the captured <see cref="CodeReloadBaseline"/> (they
/// run correct AND faster compiled — <see cref="OverridePreparation.UpToDateMethods"/>
/// reports them, and <paramref name="forceInclude"/> keeps named ones in anyway), reject
/// non-public member access when the compiled backend will run the override
/// (<paramref name="enforcePublicAccess"/>), and transform bodies into a static override
/// class. When every method is up to date, succeeds with an empty
/// <see cref="OverridePreparation.MethodNames"/> and a null
/// <see cref="OverridePreparation.TransformedCode"/> — nothing to compile.
/// </summary>
private static OverridePreparation PrepareOverride(
string sourceFilePath, bool pdb, bool enforcePublicAccess,
string[] preprocessorSymbols = null, ICollection<string> forceInclude = null,
bool useBaseline = true)
{
var prep = new OverridePreparation();
var sourceCode = ReadSourceFile(sourceFilePath);
if (string.IsNullOrEmpty(sourceCode))
{
prep.Error = $"Could not read source file: {sourceFilePath}";
return prep;
}
var extraction = ExtractCodeReloadableMethods(sourceCode, preprocessorSymbols);
// Each skip entry carries its own reason (return-type gate, nested type, …).
foreach (var skipped in extraction.SkippedMethods)
prep.Diagnostics.Add($"[CodeReload] {skipped} was skipped.");
if (!extraction.HasMethods)
{
prep.Error = extraction.SkippedMethods.Count > 0
? $"No reloadable [CodeReload] methods in {sourceFilePath}. Skipped: " +
$"{string.Join(", ", extraction.SkippedMethods)}."
: $"No [CodeReload] methods found in {sourceFilePath}";
return prep;
}
// Skip methods whose declarations still match the captured baseline: their compiled
// bodies are already what the source says, and the compiled body is strictly faster
// than any override (the interpreter backend pays per call). No baseline captured
// (returns null) = no filtering, the pre-baseline behavior.
var unchanged = useBaseline
? CodeReloadBaseline.GetUnchangedMethods(sourceFilePath, sourceCode, preprocessorSymbols)
: null;
if (unchanged != null && unchanged.Count > 0)
{
foreach (var name in extraction.Methods.Keys.ToList())
{
if (!unchanged.Contains(name)) continue;
if (forceInclude != null && forceInclude.Contains(name)) continue;
extraction.Methods.Remove(name);
extraction.MethodSignatures.Remove(name);
prep.UpToDateMethods.Add(name);
}
if (extraction.Methods.Count == 0)
{
prep.TypeName = extraction.TypeName;
prep.MethodNames = new List<string>();
prep.Success = true;
return prep;
}
}
// Build one compilation + semantic model for the transform, so Roslyn's metadata bind
// is paid once per reload (shared with the string TransformMethodBodies overload).
var model = SourceCodeTransformer.BuildSemanticModel(
sourceCode, extraction.TypeName, out var classDecl, preprocessorSymbols);
if (classDecl == null)
{
prep.Error = $"Could not find class '{extraction.TypeName}' in source.";
return prep;
}
// Only the interpreter backend compiles with relaxed accessibility (see
// RoslynCompilationService.AllowNonPublicMemberAccess) and reaches non-public members at
// runtime, via reflection. The Assembly.Load backend keeps standard access checks — a
// non-public access would only surface later as a raw CS0122 — so reject it up front
// with an actionable message.
if (enforcePublicAccess)
{
var validation = AccessibilityValidator.ValidatePublicAccess(
model, classDecl, extraction.Methods, extraction.TypeName);
if (!validation.IsValid)
{
prep.Error = validation.GetFormattedErrorMessage();
return prep;
}
}
// enforcePublicAccess doubles as the backend discriminator (true == Assembly.Load, see
// the callers): only the interpreter needs interpolation lowered to string.Format —
// the compiled backend keeps interpolations native.
prep.TransformedCode = SourceCodeTransformer.TransformMethodBodies(
model, classDecl, extraction.Methods, extraction.TypeName, extraction.MethodSignatures,
emitLineDirectives: pdb, originalFilePath: sourceFilePath,
rewriteInterpolations: !enforcePublicAccess);
prep.TypeName = extraction.TypeName;
prep.MethodNames = extraction.Methods.Keys.ToList();
prep.Success = true;
return prep;
}
/// <summary>
/// Compile a source file's [CodeReload] override(s) to raw assembly IL bytes without
/// registering them locally — for pushing to a player that runs them through the
/// interpreter. Editor-side only (needs Roslyn). <paramref name="useBaseline"/> false
/// bypasses the <see cref="CodeReloadBaseline"/> filter — the caller can't vouch that the
/// target player was built from the baselined sources.
/// </summary>
internal static PushCompileResult CompileOverrideForPush(string sourceFilePath, string[] preprocessorSymbols = null,
ICollection<string> forceInclude = null, bool useBaseline = true)
{
var r = new PushCompileResult();
try
{
// When source-line errors are enabled, #line directives map each body to the
// original file so the embedded PDB's sequence points — used by the player-side
// interpreter for runtime error messages — carry the user's real line numbers.
// enforcePublicAccess: false — pushed overrides run through the on-device
// interpreter, which reaches non-public members by reflection.
var prep = PrepareOverride(sourceFilePath, pdb: CodeReloadCompiler.EmitSourceLineInfo,
enforcePublicAccess: false, preprocessorSymbols, forceInclude, useBaseline);
if (!prep.Success)
{
r.Error = prep.Error;
r.Diagnostics = prep.Diagnostics;
return r;
}
r.UpToDateMethods = prep.UpToDateMethods;
if (prep.MethodNames.Count == 0)
{
// Everything matches the baseline — a push would only replace compiled bodies
// with slower interpreted ones on the device.
r.Success = true;
r.TypeName = prep.TypeName;
r.Diagnostics = prep.Diagnostics;
return r;
}
var tempName = $"PushReload_{prep.TypeName}_{DateTime.Now:yyyyMMdd_HHmmss}";
if (!CodeReloadCompiler.TryCompileOverrideBytes(prep.TransformedCode, tempName, out var il, out var compileDiagnostics, documentPath: sourceFilePath, preprocessorSymbols: preprocessorSymbols))
{
r.Error = "Compilation failed";
r.Diagnostics = prep.Diagnostics.Concat(compileDiagnostics).ToList();
return r;
}
r.Success = true;
r.TypeName = prep.TypeName;
r.MethodNames = prep.MethodNames;
r.IlBytes = il;
r.Diagnostics = prep.Diagnostics.Concat(compileDiagnostics).ToList();
return r;
}
catch (Exception ex)
{
r.Error = $"Push compile error: {ex.Message}";
return r;
}
}
/// <summary>
/// Check if a source file contains [CodeReload] methods.
/// Simple synchronous check to avoid async deadlocks in tests.
/// </summary>
/// <param name="sourceFilePath">Path to source file to check</param>
/// <returns>True if file contains [CodeReload] methods</returns>
public static Task<bool> ContainsCodeReloadableMethodsAsync(string sourceFilePath)
{
try
{
if (!File.Exists(sourceFilePath))
{
return Task.FromResult(false);
}
// Use synchronous read for simple attribute check to avoid deadlocks
var sourceCode = File.ReadAllText(sourceFilePath);
if (string.IsNullOrEmpty(sourceCode))
{
return Task.FromResult(false);
}
// Quick check for the [CodeReload] attribute. Prefix match (no closing bracket) so
// combined attribute lists like [CodeReload, DebugPanel("x")] still pass.
var result = sourceCode.Contains("[CodeReload");
return Task.FromResult(result);
}
catch (Exception ex)
{
Debug.LogError($"CodeReload: Error checking for [CodeReload] methods in {sourceFilePath}: {ex.Message}");
return Task.FromResult(false);
}
}
/// <summary>
/// Read source file content synchronously (for main thread).
/// </summary>
private static string ReadSourceFile(string filePath)
{
try
{
if (!File.Exists(filePath))
{
Debug.LogError($"CodeReload: Source file not found: {filePath}");
return null;
}
return File.ReadAllText(filePath);
}
catch (Exception ex)
{
Debug.LogError($"CodeReload: Error reading source file {filePath}: {ex.Message}");
return null;
}
}
/// <summary>
/// Extract [CodeReload] methods from source code.
/// </summary>
private static CodeReloadableExtractionResult ExtractCodeReloadableMethods(
string sourceCode, string[] preprocessorSymbols = null)
{
var result = new CodeReloadableExtractionResult();
try
{
var syntaxTree = CSharpSyntaxTree.ParseText(sourceCode,
RoslynCompilationService.ProjectParseOptions(preprocessorSymbols));
var root = syntaxTree.GetRoot();
// Find the class containing [CodeReload] methods.
var classDeclaration = root.DescendantNodes()
.OfType<ClassDeclarationSyntax>()
.FirstOrDefault(c => c.DescendantNodes().OfType<MethodDeclarationSyntax>().Any(HasCodeReloadAttribute));
if (classDeclaration == null)
{
return result;
}
result.TypeName = classDeclaration.Identifier.ValueText;
// The transform lifts bodies into ONE override class for the top-level type chosen
// above. Tagged methods anywhere else in the file — in a NESTED type (which the
// weaver still weaves: Cecil's GetTypes() is recursive) or in a sibling top-level
// type — would otherwise be dropped without a word, leaving a woven prologue that
// never finds an override. Surface each one loudly; when nothing else is
// reloadable, these become the reload's error.
foreach (var method in root.DescendantNodes()
.OfType<MethodDeclarationSyntax>().Where(HasCodeReloadAttribute))
{
var owner = method.Ancestors().OfType<TypeDeclarationSyntax>().FirstOrDefault();
if (owner == null || owner == classDeclaration)
continue;
var ownerChain = string.Join(".", method.Ancestors()
.OfType<TypeDeclarationSyntax>().Reverse().Select(t => t.Identifier.ValueText));
result.SkippedMethods.Add(owner.Parent is TypeDeclarationSyntax
? $"'{method.Identifier.ValueText}' (declared in nested type '{ownerChain}' — " +
"code reload only supports methods declared directly in a top-level class)"
: $"'{method.Identifier.ValueText}' (declared in '{ownerChain}' — only the first " +
$"type with [CodeReload] methods, '{result.TypeName}', is reloaded per file; split the file)");
}
var codeReloadableMethods = classDeclaration.Members
.OfType<MethodDeclarationSyntax>()
.Where(HasCodeReloadAttribute);
foreach (var method in codeReloadableMethods)
{
var methodName = method.Identifier.ValueText;
// Tagged methods must pass the same gate the weaver applies (void or
// IEnumerator returns; instance or static). The weaver never weaves a
// dispatch prologue into the others, so their override would register and
// then never run — reload would report success while silently no-opping.
if (!IsMethodLevelSupported(method, out var skipReason))
{
result.SkippedMethods.Add($"'{methodName}' ({skipReason})");
continue;
}
var methodBody = ExtractMethodBody(method);
var signature = ExtractMethodSignature(method);
if (methodBody != null)
{
result.Methods[methodName] = methodBody;
result.MethodSignatures[methodName] = signature;
}
}
Debug.Log($"CodeReload: Extracted {result.Methods.Count} [CodeReload] methods from class {result.TypeName}");
return result;
}
catch (Exception ex)
{
Debug.LogError($"CodeReload: Error extracting [CodeReload] methods: {ex.Message}");
return result;
}
}
/// <summary>
/// Check if method has [CodeReload] attribute. The unqualified name must match exactly: a
/// suffix match also catches [OnCodeReload] — the post-reload callback, not a reload target —
/// and pushing that name makes the player-side registry lookup fail for the whole file.
/// Internal so <see cref="CodeReloadBaseline"/> hashes exactly the methods extraction picks.
/// </summary>
internal static bool HasCodeReloadAttribute(MethodDeclarationSyntax method)
{
return method.AttributeLists
.SelectMany(al => al.Attributes)
.Any(a => UnqualifiedAttributeName(a) == "CodeReload");
}
/// <summary>
/// Attribute name as the source wrote it, minus any namespace qualifier and the optional
/// "Attribute" suffix — [Foo.BarAttribute] and [Bar] both yield "Bar".
/// </summary>
private static string UnqualifiedAttributeName(AttributeSyntax attr)
{
var name = attr.Name.ToString();
var lastDot = name.LastIndexOf('.');
if (lastDot >= 0)
name = name.Substring(lastDot + 1);
if (name.EndsWith("Attribute"))
name = name.Substring(0, name.Length - "Attribute".Length);
return name;
}
/// <summary>
/// Gate for explicitly-tagged [CodeReload] methods, mirroring the skips the weaver applies
/// (CodeReloadInPlaceILPostProcessor.Process): unsupported return types get no dispatch
/// prologue, so extracting them would produce an override that never runs. Instance and
/// STATIC methods both weave (a static dispatches with a null instance). Coroutines
/// (System.Collections.IEnumerator) are supported: the weaver routes them through the
/// result dispatch, and the override's iterator — compiled or interpreted — is what
/// StartCoroutine drives.
/// </summary>
private static bool IsMethodLevelSupported(MethodDeclarationSyntax method, out string reason)
{
var returnType = method.ReturnType.ToString();
if (returnType != "void" &&
returnType is not ("IEnumerator" or "System.Collections.IEnumerator"))
{
reason = $"returns {returnType} — void methods and IEnumerator coroutines only";
return false;
}
reason = null;
return true;
}
/// <summary>
/// Extract method body content (excluding braces).
/// null when the method has no block body at all (expression-bodied, abstract).
/// </summary>
private static string ExtractMethodBody(MethodDeclarationSyntax method)
{
if (method.Body == null)
return null;
var bodyText = method.Body.ToString().Trim();
// Remove outer braces
if (bodyText.StartsWith("{") && bodyText.EndsWith("}"))
{
bodyText = bodyText.Substring(1, bodyText.Length - 2).Trim();
}
return bodyText;
}
/// <summary>
/// Extract method signature information.
/// </summary>
private static MethodSignatureInfo ExtractMethodSignature(MethodDeclarationSyntax method)
{
var signature = new MethodSignatureInfo
{
ReturnType = method.ReturnType.ToString()
};
foreach (var parameter in method.ParameterList.Parameters)
{
var paramInfo = new ParameterInfo
{
Type = parameter.Type?.ToString() ?? "object",
Name = parameter.Identifier.ValueText,
HasDefaultValue = parameter.Default != null,
DefaultValue = parameter.Default?.Value?.ToString()
};
signature.Parameters.Add(paramInfo);
}
return signature;
}
/// <summary>
/// Compile transformed code synchronously (for main thread).
/// </summary>
private static CodeReloadCompileResult CompileTransformedCode(
OverridePreparation prep,
string assemblyDir,
bool emitPdb,
string documentPath,
bool useInterpreter)
{
try
{
// Generate a temporary file name for the transformed code
var tempFileName = $"InPlace_{prep.TypeName}_{DateTime.Now:yyyyMMdd_HHmmss}";
return CodeReloadCompiler.CompileSourceCodeOnMainThread(
prep.TransformedCode,
tempFileName,
assemblyDir,
emitPdb,
documentPath,
useInterpreter
? new InterpreterOverrideSet
{
TypeName = prep.TypeName,
MethodNames = prep.MethodNames
}
: null);
}
catch (Exception ex)
{
Debug.LogError($"CodeReload: Error compiling transformed code for {prep.TypeName}: {ex.Message}");
return CodeReloadCompileResult.Failure(
"Compilation Error",
ex.Message,
0,
new List<string> { ex.ToString() });
}
}
/// <summary>
/// Result of extracting [CodeReload] methods from source code.
/// </summary>
private class CodeReloadableExtractionResult
{
/// <summary>
/// Name of the class containing [CodeReload] methods.
/// </summary>
public string TypeName { get; set; }
/// <summary>
/// Dictionary of method names to their extracted body code.
/// </summary>
public Dictionary<string, string> Methods { get; set; } = new Dictionary<string, string>();
/// <summary>
/// Dictionary of method names to their signature information.
/// </summary>
public Dictionary<string, MethodSignatureInfo> MethodSignatures { get; set; } = new Dictionary<string, MethodSignatureInfo>();
/// <summary>
/// Whether any [CodeReload] methods were found.
/// </summary>
public bool HasMethods => Methods.Count > 0;
/// <summary>
/// Explicitly-tagged methods that were rejected with the reason ("'Total' (returns
/// int — void methods and IEnumerator coroutines only)"), so the caller can report
/// them instead of failing opaquely.
/// </summary>
public List<string> SkippedMethods { get; } = new List<string>();
}
}
/// <summary>
/// Result of in-place code reload processing.
/// </summary>
class InPlaceReloadResult
{
/// <summary>
/// Path to the source file that was processed.
/// </summary>
public string SourceFilePath { get; set; }
/// <summary>
/// Whether the processing was successful.
/// </summary>
public bool Success { get; set; }
/// <summary>
/// Name of the original type containing [CodeReload] methods.
/// </summary>
public string OriginalTypeName { get; set; }
/// <summary>
/// List of method names that were extracted and processed.
/// </summary>
public List<string> ExtractedMethods { get; set; } = new List<string>();
/// <summary>
/// Generated transformed code for code reload assembly.
/// </summary>
public string TransformedCode { get; set; }
/// <summary>
/// Name of the compiled assembly (if successful).
/// </summary>
public string AssemblyName { get; set; }
/// <summary>
/// List of registered method IDs (if successful).
/// </summary>
public List<string> RegisteredMethods { get; set; } = new List<string>();
/// <summary>
/// [CodeReload] method names skipped because they still match the compiled baseline —
/// their compiled bodies keep running (no interpreter cost).
/// </summary>
internal List<string> UpToDateMethods { get; set; } = new List<string>();
/// <summary>
/// Method ids whose stale override was removed because the source matches the compiled
/// baseline again (the edit was reverted).
/// </summary>
internal List<string> RevertedMethods { get; set; } = new List<string>();
/// <summary>Every extracted method matched the baseline; nothing was compiled or registered.</summary>
internal bool AllUpToDate => Success && ExtractedMethods.Count == 0 && UpToDateMethods.Count > 0;
/// <summary>
/// Error message if processing failed.
/// </summary>
public string ErrorMessage { get; set; }
/// <summary>
/// Compilation diagnostics (warnings, errors).
/// </summary>
public List<string> CompilationDiagnostics { get; set; } = new List<string>();
/// <summary>
/// Execution time in milliseconds.
/// </summary>
public long ExecutionTimeMs { get; set; }
}
/// <summary>
/// Result of compiling an override for pushing to a player (no local registration).
/// </summary>
internal class PushCompileResult
{
public bool Success { get; set; }
public string TypeName { get; set; }
public List<string> MethodNames { get; set; } = new List<string>();
public byte[] IlBytes { get; set; }
public string Error { get; set; }
public List<string> Diagnostics { get; set; } = new List<string>();
/// <summary>[CodeReload] method names skipped because they still match the compiled baseline.</summary>
public List<string> UpToDateMethods { get; set; } = new List<string>();
/// <summary>Every extracted method matched the baseline; there is nothing to push.</summary>
public bool AllUpToDate => Success && MethodNames.Count == 0 && UpToDateMethods.Count > 0;
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 411fb44c3329710449bcbfc2af627524
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,195 @@
using System;
using System.Collections.Generic;
using System.Text;
namespace Unity.Pipeline.CodeReload
{
/// <summary>
/// Shared contract for pushing a compiled in-place code-reload override from the editor to a
/// running player over Unity's PlayerConnection — the same editor↔player message bus the Profiler
/// and Console use (tunnels over USB for mobile, no IP or open port required). Lives in Runtime so
/// both the player (<c>PlayerConnection.Register</c>) and the editor push command
/// (<c>EditorConnection.Send</c>) reference the same GUIDs and wire format.
///
/// The editor compiles the edited method(s) to override-assembly IL bytes (Roslyn, editor-only)
/// and ships {typeName, methodNames, il}; the player feeds them to
/// <c>InterpreterCodeReloadExecutor.Register</c> and runs them through the IlInterpreter interpreter —
/// no <c>Assembly.Load</c>, so it works under IL2CPP.
///
/// This type touches no Unity API, so it compiles unconditionally on both editor and player.
/// </summary>
static class PipelineCodeReloadConnect
{
/// <summary>editor → player: apply a compiled override (typeName + method names + IL).</summary>
public static readonly Guid ApplyReloadMsg = new Guid("b6f1e0d24c7a4e13a9f2c8d5017e3b44");
/// <summary>player → editor: an ack (ok + human-readable summary) for a received push.</summary>
public static readonly Guid ResultMsg = new Guid("c2a7d4e916b84f0db3e5a1c6072f8d99");
/// <summary>editor → player: a save is being compiled for a push (payload = utf8 file name).
/// Compilation happens editor-side, so this notice is the only way an on-device overlay can
/// show "compiling…" progress.</summary>
public static readonly Guid ReloadPendingMsg = new Guid("d4c9f2a1385e46b7a0d1e8c4529b6f77");
/// <summary>editor → player: the compile for a pending reload failed (payload = utf8 summary).
/// Without it the on-device overlay would sit on "compiling…" forever after a syntax error.</summary>
public static readonly Guid ReloadFailedMsg = new Guid("e7b3a8d05c214f9e8b6d2f0a1c435588");
/// <summary>player → editor: session announcement for push signing — protocol version, the
/// key fingerprint the player's baked public key expects, and the random session nonce every
/// signed push must carry. Sent on connect and in answer to <see cref="RequestHandshakeMsg"/>.</summary>
public static readonly Guid HandshakeMsg = new Guid("f19c5b8a2d7e40c6b8a3f0d612c94e55");
/// <summary>editor → player: re-announce your handshake (empty payload). Sent when the editor's
/// nonce table is empty for a target — typically right after an editor domain reload.</summary>
public static readonly Guid RequestHandshakeMsg = new Guid("a8d2c7f4691e4b0f9c5d3a80e17b6c22");
/// <summary>
/// Wire format: [typeNameLen:int32-LE][utf8 typeName][methodCount:int32-LE]
/// (per method: [len:int32-LE][utf8 name]) [il bytes…].
/// </summary>
public static byte[] Encode(string typeName, IReadOnlyList<string> methodNames, byte[] il)
{
var methods = methodNames ?? Array.Empty<string>();
var nameBytes = Encoding.UTF8.GetBytes(typeName ?? "");
var methodBytes = new byte[methods.Count][];
int methodsSize = 4; // count
for (int i = 0; i < methods.Count; i++)
{
methodBytes[i] = Encoding.UTF8.GetBytes(methods[i] ?? "");
methodsSize += 4 + methodBytes[i].Length;
}
int ilLen = il?.Length ?? 0;
var buf = new byte[4 + nameBytes.Length + methodsSize + ilLen];
int o = 0;
o = WriteInt32(buf, o, nameBytes.Length);
o = WriteBytes(buf, o, nameBytes);
o = WriteInt32(buf, o, methods.Count);
for (int i = 0; i < methods.Count; i++)
{
o = WriteInt32(buf, o, methodBytes[i].Length);
o = WriteBytes(buf, o, methodBytes[i]);
}
if (ilLen > 0) Buffer.BlockCopy(il, 0, buf, o, ilLen);
return buf;
}
public static bool TryDecode(byte[] data, out string typeName, out string[] methodNames, out byte[] il)
{
typeName = null; methodNames = null; il = null;
if (data == null || data.Length < 4) return false;
try
{
int o = 0;
int nameLen = ReadInt32(data, ref o);
if (nameLen < 0 || o + nameLen > data.Length) return false;
typeName = Encoding.UTF8.GetString(data, o, nameLen); o += nameLen;
if (o + 4 > data.Length) return false;
int methodCount = ReadInt32(data, ref o);
if (methodCount < 0) return false;
methodNames = new string[methodCount];
for (int i = 0; i < methodCount; i++)
{
if (o + 4 > data.Length) return false;
int len = ReadInt32(data, ref o);
if (len < 0 || o + len > data.Length) return false;
methodNames[i] = Encoding.UTF8.GetString(data, o, len); o += len;
}
int ilLen = data.Length - o;
il = new byte[ilLen];
if (ilLen > 0) Buffer.BlockCopy(data, o, il, 0, ilLen);
return true;
}
catch
{
return false;
}
}
/// <summary>Handshake wire format: [protocolVersion:int32-LE][fpLen:int32-LE][utf8 fingerprint][nonce…].</summary>
public static byte[] EncodeHandshake(int protocolVersion, string keyFingerprint, byte[] sessionNonce)
{
var fp = Encoding.UTF8.GetBytes(keyFingerprint ?? "");
var nonce = sessionNonce ?? Array.Empty<byte>();
var buf = new byte[4 + 4 + fp.Length + nonce.Length];
int o = 0;
o = WriteInt32(buf, o, protocolVersion);
o = WriteInt32(buf, o, fp.Length);
o = WriteBytes(buf, o, fp);
if (nonce.Length > 0) Buffer.BlockCopy(nonce, 0, buf, o, nonce.Length);
return buf;
}
public static bool TryDecodeHandshake(byte[] data, out int protocolVersion, out string keyFingerprint, out byte[] sessionNonce)
{
protocolVersion = 0; keyFingerprint = null; sessionNonce = null;
if (data == null || data.Length < 8) return false;
try
{
int o = 0;
protocolVersion = ReadInt32(data, ref o);
int fpLen = ReadInt32(data, ref o);
if (fpLen < 0 || fpLen > 256 || o + fpLen > data.Length) return false;
keyFingerprint = Encoding.UTF8.GetString(data, o, fpLen); o += fpLen;
sessionNonce = new byte[data.Length - o];
if (sessionNonce.Length > 0) Buffer.BlockCopy(data, o, sessionNonce, 0, sessionNonce.Length);
return true;
}
catch
{
return false;
}
}
/// <summary>Payload for the plain-text notices (<see cref="ReloadPendingMsg"/>, <see cref="ReloadFailedMsg"/>).</summary>
public static byte[] EncodeText(string text) => Encoding.UTF8.GetBytes(text ?? "");
public static string DecodeText(byte[] data) =>
data == null || data.Length == 0 ? "" : Encoding.UTF8.GetString(data);
/// <summary>Result wire format: [ok:1 byte][utf8 message…].</summary>
public static byte[] EncodeResult(bool ok, string message)
{
var msgBytes = Encoding.UTF8.GetBytes(message ?? "");
var buf = new byte[1 + msgBytes.Length];
buf[0] = (byte)(ok ? 1 : 0);
Buffer.BlockCopy(msgBytes, 0, buf, 1, msgBytes.Length);
return buf;
}
public static bool TryDecodeResult(byte[] data, out bool ok, out string message)
{
ok = false; message = null;
if (data == null || data.Length < 1) return false;
ok = data[0] != 0;
message = data.Length > 1 ? Encoding.UTF8.GetString(data, 1, data.Length - 1) : "";
return true;
}
private static int WriteInt32(byte[] buf, int off, int v)
{
buf[off] = (byte)v;
buf[off + 1] = (byte)(v >> 8);
buf[off + 2] = (byte)(v >> 16);
buf[off + 3] = (byte)(v >> 24);
return off + 4;
}
private static int WriteBytes(byte[] buf, int off, byte[] src)
{
if (src.Length > 0) Buffer.BlockCopy(src, 0, buf, off, src.Length);
return off + src.Length;
}
private static int ReadInt32(byte[] buf, ref int off)
{
int v = buf[off] | (buf[off + 1] << 8) | (buf[off + 2] << 16) | (buf[off + 3] << 24);
off += 4;
return v;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 0ec832e9c0db7b84db213582f2ebc203
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,336 @@
using System;
using System.Security.Cryptography;
using System.Text;
namespace Unity.Pipeline.CodeReload
{
/// <summary>Outcome of opening a signed push envelope. Everything except <see cref="Ok"/> is a
/// rejection; the receiver reports them generically on the wire and specifically in its own log.</summary>
enum PushVerifyStatus
{
Ok,
/// <summary>The receiver has no baked public key — every push is rejected (strict policy).</summary>
NoKey,
/// <summary>Structurally not a v1 envelope (bad magic, truncated, absurd lengths).</summary>
Malformed,
/// <summary>Envelope parses but the RSA signature does not verify under the baked key.</summary>
BadSignature,
/// <summary>Signature is valid but was made for a different session nonce (replay across
/// sessions/devices, or the editor's nonce table is stale).</summary>
NonceMismatch,
/// <summary>Signature and nonce are valid but the counter did not increase (replay within
/// the session, or out-of-order delivery).</summary>
CounterNotIncreasing,
/// <summary>The platform's RSA implementation threw — crypto is unusable here. Surfaced by
/// the boot self-test; should never be the steady state.</summary>
CryptoUnavailable,
}
/// <summary>
/// Authenticated envelope for editor → player code-reload pushes, plus the project signing-key
/// primitives. Pure BCL (System.Security.Cryptography, no Unity API) so it compiles on both
/// sides and is unit-testable without a connection.
///
/// Wire format (little-endian):
/// [magic "UPP1" 4B][session nonce 16B][counter int64][payloadLen int32][payload][RSA signature]
/// The signature is RSA-2048 / SHA-256 / PKCS#1 v1.5 over UTF8(<see cref="DomainTag"/>) followed
/// by every envelope byte before the signature, so a signature can never be repurposed by a
/// future feature signing other content with the same project key.
///
/// Keys are serialized in a deliberately trivial private format (base64 RSAParameters fields)
/// rather than XML/PKCS#8: RSA.To/FromXmlString and ImportPkcs8PrivateKey both have per-platform
/// availability gaps across Unity scripting backends; base64ing the raw parameters has none.
/// </summary>
static class PushEnvelope
{
public const string DomainTag = "unity-pipeline/push-reload/v1";
public const int ProtocolVersion = 1;
public const int NonceSize = 16;
public const int RsaKeySizeBits = 2048;
private const string PrivatePrefix = "upp1-rsa:";
private const string PublicPrefix = "upp1-rsa-pub:";
private const int HeaderSize = 4 + NonceSize + 8 + 4; // magic + nonce + counter + payloadLen
private const int MaxPayload = 64 * 1024 * 1024; // sanity bound, far above any real push
private static readonly byte[] Magic = { (byte)'U', (byte)'P', (byte)'P', (byte)'1' };
// ---- keys ----
/// <summary>Generate a fresh RSA-2048 private key (contains the public half).</summary>
public static string CreatePrivateKey()
{
using (var rsa = RSA.Create())
{
rsa.KeySize = RsaKeySizeBits;
var p = rsa.ExportParameters(true);
return PrivatePrefix + string.Join(".",
B64(p.Modulus), B64(p.Exponent), B64(p.D), B64(p.P), B64(p.Q),
B64(p.DP), B64(p.DQ), B64(p.InverseQ));
}
}
/// <summary>Extract the shippable public half of a private key.</summary>
public static string DerivePublicKey(string privateKey)
{
var p = ParsePrivate(privateKey);
return PublicPrefix + B64(p.Modulus) + "." + B64(p.Exponent);
}
public static bool IsPublicKey(string key) =>
!string.IsNullOrEmpty(key) && key.StartsWith(PublicPrefix, StringComparison.Ordinal);
public static bool IsPrivateKey(string key) =>
!string.IsNullOrEmpty(key) && key.StartsWith(PrivatePrefix, StringComparison.Ordinal);
/// <summary>Short stable id of a keypair (SHA-256 over modulus+exponent, first 8 bytes hex).
/// Accepts the public or the private serialization and gives the same answer.</summary>
public static string Fingerprint(string key)
{
RSAParameters p = IsPublicKey(key) ? ParsePublic(key) : ParsePrivate(key);
using (var sha = SHA256.Create())
{
var data = new byte[p.Modulus.Length + p.Exponent.Length];
Buffer.BlockCopy(p.Modulus, 0, data, 0, p.Modulus.Length);
Buffer.BlockCopy(p.Exponent, 0, data, p.Modulus.Length, p.Exponent.Length);
var hash = sha.ComputeHash(data);
var sb = new StringBuilder(16);
for (int i = 0; i < 8; i++) sb.Append(hash[i].ToString("x2"));
return sb.ToString();
}
}
/// <summary>Cryptographically random session nonce.</summary>
public static byte[] CreateNonce()
{
var nonce = new byte[NonceSize];
using (var rng = RandomNumberGenerator.Create())
rng.GetBytes(nonce);
return nonce;
}
// ---- signing (editor side) ----
public static byte[] Sign(string privateKey, byte[] sessionNonce, long counter, byte[] payload) =>
Sign(privateKey, sessionNonce, counter, payload, DomainTag);
/// <summary>Domain-tag overload: exists so tests can prove a signature made under a different
/// tag is rejected. Production code always signs with <see cref="DomainTag"/>.</summary>
public static byte[] Sign(string privateKey, byte[] sessionNonce, long counter, byte[] payload, string domainTag)
{
if (sessionNonce == null || sessionNonce.Length != NonceSize)
throw new ArgumentException($"session nonce must be {NonceSize} bytes", nameof(sessionNonce));
payload = payload ?? Array.Empty<byte>();
var preamble = new byte[HeaderSize + payload.Length];
Buffer.BlockCopy(Magic, 0, preamble, 0, 4);
Buffer.BlockCopy(sessionNonce, 0, preamble, 4, NonceSize);
WriteInt64(preamble, 4 + NonceSize, counter);
WriteInt32(preamble, 4 + NonceSize + 8, payload.Length);
Buffer.BlockCopy(payload, 0, preamble, HeaderSize, payload.Length);
byte[] sig;
using (var rsa = RSA.Create())
{
rsa.ImportParameters(ParsePrivate(privateKey));
sig = rsa.SignData(Tagged(domainTag, preamble), HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
}
var envelope = new byte[preamble.Length + sig.Length];
Buffer.BlockCopy(preamble, 0, envelope, 0, preamble.Length);
Buffer.BlockCopy(sig, 0, envelope, preamble.Length, sig.Length);
return envelope;
}
// ---- opening (player side) ----
/// <summary>Cheap structural check: does this look like a v1 envelope (as opposed to a
/// legacy raw payload from a pre-signing editor)?</summary>
public static bool LooksLikeEnvelope(byte[] data) =>
data != null && data.Length >= HeaderSize &&
data[0] == Magic[0] && data[1] == Magic[1] && data[2] == Magic[2] && data[3] == Magic[3];
/// <summary>Parse without any crypto. Editor-play-mode strip only — never a security gate.</summary>
public static bool TryParse(byte[] envelope, out byte[] nonce, out long counter, out byte[] payload)
{
nonce = null; counter = 0; payload = null;
if (!LooksLikeEnvelope(envelope)) return false;
nonce = new byte[NonceSize];
Buffer.BlockCopy(envelope, 4, nonce, 0, NonceSize);
counter = ReadInt64(envelope, 4 + NonceSize);
int payloadLen = ReadInt32(envelope, 4 + NonceSize + 8);
if (payloadLen < 0 || payloadLen > MaxPayload || HeaderSize + payloadLen > envelope.Length)
return false;
payload = new byte[payloadLen];
Buffer.BlockCopy(envelope, HeaderSize, payload, 0, payloadLen);
return true;
}
/// <summary>
/// Full verification: structure, signature under <paramref name="publicKey"/>, session nonce
/// equality, strictly increasing counter. On <see cref="PushVerifyStatus.Ok"/>,
/// <paramref name="payload"/> and <paramref name="counter"/> are set. Stateless — callers own
/// the last-counter bookkeeping (see <see cref="PushVerifier"/>).
/// </summary>
public static PushVerifyStatus TryOpen(string publicKey, byte[] envelope, byte[] expectedNonce,
long lastCounter, out long counter, out byte[] payload)
{
counter = 0; payload = null;
if (string.IsNullOrEmpty(publicKey)) return PushVerifyStatus.NoKey;
if (!LooksLikeEnvelope(envelope)) return PushVerifyStatus.Malformed;
int payloadLen = ReadInt32(envelope, 4 + NonceSize + 8);
if (payloadLen < 0 || payloadLen > MaxPayload) return PushVerifyStatus.Malformed;
int preambleLen = HeaderSize + payloadLen;
if (preambleLen >= envelope.Length) return PushVerifyStatus.Malformed; // signature must be non-empty
// Signature first: nothing later is meaningful on unauthenticated bytes.
try
{
var preamble = new byte[preambleLen];
Buffer.BlockCopy(envelope, 0, preamble, 0, preambleLen);
var sig = new byte[envelope.Length - preambleLen];
Buffer.BlockCopy(envelope, preambleLen, sig, 0, sig.Length);
using (var rsa = RSA.Create())
{
rsa.ImportParameters(ParsePublic(publicKey));
if (!rsa.VerifyData(Tagged(DomainTag, preamble), sig, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1))
return PushVerifyStatus.BadSignature;
}
}
catch (FormatException) { return PushVerifyStatus.Malformed; } // corrupt key string
catch (Exception) { return PushVerifyStatus.CryptoUnavailable; }
if (expectedNonce == null || expectedNonce.Length != NonceSize)
return PushVerifyStatus.NonceMismatch;
// Constant-time not required (the nonce is not secret; it is a liveness binding), but
// the full compare is trivial anyway.
int diff = 0;
for (int i = 0; i < NonceSize; i++) diff |= envelope[4 + i] ^ expectedNonce[i];
if (diff != 0) return PushVerifyStatus.NonceMismatch;
long c = ReadInt64(envelope, 4 + NonceSize);
if (c <= lastCounter) return PushVerifyStatus.CounterNotIncreasing;
counter = c;
payload = new byte[payloadLen];
Buffer.BlockCopy(envelope, HeaderSize, payload, 0, payloadLen);
return PushVerifyStatus.Ok;
}
/// <summary>
/// Boot-time probe that the platform can run the verify path at all (RSA import + VerifyData
/// on a garbage signature must return false, not throw). Catches an IL2CPP/platform crypto
/// gap on day one instead of as mysterious per-push rejects.
/// </summary>
public static bool VerifySelfTest(string publicKey, out string error)
{
error = null;
try
{
using (var rsa = RSA.Create())
{
rsa.ImportParameters(ParsePublic(publicKey));
var garbageSig = new byte[RsaKeySizeBits / 8];
if (rsa.VerifyData(new byte[] { 1, 2, 3 }, garbageSig, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1))
{
error = "garbage signature verified — RSA implementation is broken here";
return false;
}
}
return true;
}
catch (Exception ex)
{
error = ex.GetType().Name + ": " + ex.Message;
return false;
}
}
// ---- internals ----
private static byte[] Tagged(string domainTag, byte[] preamble)
{
var tag = Encoding.UTF8.GetBytes(domainTag ?? "");
var signed = new byte[tag.Length + preamble.Length];
Buffer.BlockCopy(tag, 0, signed, 0, tag.Length);
Buffer.BlockCopy(preamble, 0, signed, tag.Length, preamble.Length);
return signed;
}
private static RSAParameters ParsePrivate(string key)
{
if (!IsPrivateKey(key)) throw new FormatException("not a " + PrivatePrefix + " private key");
var f = key.Substring(PrivatePrefix.Length).Split('.');
if (f.Length != 8) throw new FormatException("private key must have 8 fields");
return new RSAParameters
{
Modulus = D64(f[0]), Exponent = D64(f[1]), D = D64(f[2]), P = D64(f[3]),
Q = D64(f[4]), DP = D64(f[5]), DQ = D64(f[6]), InverseQ = D64(f[7]),
};
}
private static RSAParameters ParsePublic(string key)
{
if (!IsPublicKey(key)) throw new FormatException("not a " + PublicPrefix + " public key");
var f = key.Substring(PublicPrefix.Length).Split('.');
if (f.Length != 2) throw new FormatException("public key must have 2 fields");
return new RSAParameters { Modulus = D64(f[0]), Exponent = D64(f[1]) };
}
private static string B64(byte[] b) => Convert.ToBase64String(b ?? Array.Empty<byte>());
private static byte[] D64(string s) => Convert.FromBase64String(s);
private static void WriteInt32(byte[] buf, int off, int v)
{
buf[off] = (byte)v; buf[off + 1] = (byte)(v >> 8);
buf[off + 2] = (byte)(v >> 16); buf[off + 3] = (byte)(v >> 24);
}
private static void WriteInt64(byte[] buf, int off, long v)
{
for (int i = 0; i < 8; i++) buf[off + i] = (byte)(v >> (8 * i));
}
private static int ReadInt32(byte[] buf, int off) =>
buf[off] | (buf[off + 1] << 8) | (buf[off + 2] << 16) | (buf[off + 3] << 24);
private static long ReadInt64(byte[] buf, int off)
{
long v = 0;
for (int i = 0; i < 8; i++) v |= (long)buf[off + i] << (8 * i);
return v;
}
}
/// <summary>
/// The player-side gate: one per receiver session. Owns the session nonce and the last-accepted
/// counter, so <see cref="Verify"/> is the single call the receiver makes per push.
/// </summary>
sealed class PushVerifier
{
private readonly string m_PublicKey;
private readonly byte[] m_SessionNonce;
private long m_LastCounter;
public PushVerifier(string publicKey, byte[] sessionNonce)
{
m_PublicKey = publicKey;
m_SessionNonce = sessionNonce;
}
public byte[] SessionNonce => m_SessionNonce;
public PushVerifyStatus Verify(byte[] envelope, out byte[] payload)
{
var status = PushEnvelope.TryOpen(m_PublicKey, envelope, m_SessionNonce, m_LastCounter,
out var counter, out payload);
if (status == PushVerifyStatus.Ok)
m_LastCounter = counter;
return status;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 3f7a1c92be504d8fa2617e0c4b9d5a21
@@ -0,0 +1,590 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
// The package bundles Roslyn renamed with a "UnityPipeline." prefix (Runtime/Plugins/CodeAnalysis);
// the dotnet test projects reference the same DLLs (IlInterpreter/BundledRoslyn.props).
using UnityPipeline.Microsoft.CodeAnalysis;
using UnityPipeline.Microsoft.CodeAnalysis.CSharp;
using UnityPipeline.Microsoft.CodeAnalysis.CSharp.Syntax;
using Unity.Pipeline.Compilation;
using UnityEngine;
namespace Unity.Pipeline.CodeReload
{
/// <summary>
/// Transforms [CodeReload] method bodies (edited in place on a MonoBehaviour) into static
/// code reload override methods that the registry can dispatch to.
///
/// The transform is driven by a Roslyn <see cref="SemanticModel"/>. Because the body is moved into
/// a standalone static class (no <c>this</c>, no base type, not nested in the original type), every
/// reference that relied on the declaring type's scope is re-qualified so it still binds:
/// <list type="bullet">
/// <item>instance members of the type hierarchy (e.g. <c>transform</c> on Component) -> <c>instance.&lt;member&gt;</c></item>
/// <item>static members inherited or declared on the type hierarchy (e.g. <c>FindObjectsByType</c> on
/// Object) -> <c>&lt;ContainingType&gt;.&lt;member&gt;</c></item>
/// <item>nested types (enum/class) of the type hierarchy (e.g. a private <c>enum State</c>) -> <c>&lt;ContainingType&gt;.&lt;NestedType&gt;</c></item>
/// </list>
/// Locals, parameters, and types/members reachable via the file's <c>using</c> directives are left
/// untouched. The output is a static override class the registry can bind:
///
/// [CodeReloadOverrideMethod("Type.Method")]
/// public static &lt;ret&gt; Method(Type instance, &lt;params&gt;) { ...rewritten body... }
/// </summary>
static class SourceCodeTransformer
{
/// <summary>
/// Transform the [CodeReload] methods named in <paramref name="methodBodies"/> into a
/// static override class. <paramref name="originalTypeDefinition"/> (the full source of the
/// file) is required so the semantic model can resolve member bindings in context.
/// </summary>
/// <param name="emitLineDirectives">
/// When true, each rewritten body is bracketed by <c>#line</c> directives that map it back to
/// the original source file so an attached debugger binds breakpoints in the file the user
/// edited. Requires <paramref name="originalFilePath"/>. The body is emitted with its original
/// whitespace (no <c>NormalizeWhitespace</c>) so line positions survive the transform.
/// </param>
/// <param name="originalFilePath">Absolute path of the source file, recorded in the #line directives.</param>
/// <param name="methodBodies">Map of method name to edited body source.</param>
/// <param name="originalTypeName">Name of the declaring type.</param>
/// <param name="originalMethodSignatures">Map of method name to its original signature.</param>
/// <param name="originalTypeDefinition">The full original source file, needed to build the semantic model.</param>
/// <param name="rewriteInterpolations">
/// Rewrite string interpolation to <c>string.Format(...)</c>. Required by the interpreter
/// backend, which cannot run the compiler's <c>DefaultInterpolatedStringHandler</c>
/// lowering; pass false on the Assembly.Load backend so interpolations compile natively
/// (e.g. a <c>FormattableString</c>-targeted interpolation survives).
/// </param>
/// <returns>Source for a static override class matching the shape the registry dispatches to.</returns>
internal static string TransformMethodBodies(
Dictionary<string, string> methodBodies,
string originalTypeName,
Dictionary<string, MethodSignatureInfo> originalMethodSignatures,
string originalTypeDefinition = null,
bool emitLineDirectives = false,
string originalFilePath = null,
bool rewriteInterpolations = true)
{
if (string.IsNullOrEmpty(originalTypeDefinition))
{
throw new InvalidOperationException(
"In-place transformation requires the full original source to build a semantic model.");
}
var model = BuildSemanticModel(originalTypeDefinition, originalTypeName, out var classDecl);
if (classDecl == null)
throw new InvalidOperationException($"Could not find class '{originalTypeName}' in source.");
return TransformMethodBodies(model, classDecl, methodBodies, originalTypeName,
originalMethodSignatures, emitLineDirectives, originalFilePath, rewriteInterpolations);
}
/// <summary>
/// Parse <paramref name="source"/> and build the one compilation + semantic model the
/// transform (and its callers' validation) runs against, plus the declaration of
/// <paramref name="typeName"/> — null when the source has no such class. Imports ALL
/// metadata members, not the Public default: the transform's new-method check diffs the
/// source against the compiled type, and with public-only import every PRIVATE compiled
/// method would look new and get wrongly co-emitted. <paramref name="preprocessorSymbols"/>
/// (the project's, or a push target's) makes #if regions match the assembly being patched.
/// </summary>
internal static SemanticModel BuildSemanticModel(
string source, string typeName, out ClassDeclarationSyntax classDecl,
string[] preprocessorSymbols = null)
{
var tree = CSharpSyntaxTree.ParseText(source,
RoslynCompilationService.ProjectParseOptions(preprocessorSymbols));
var compilation = CSharpCompilation.Create(
"CodeReloadInPlaceTransform",
new[] { tree },
RoslynCompilationService.GetMetadataReferences(),
new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)
.WithMetadataImportOptions(MetadataImportOptions.All));
var model = compilation.GetSemanticModel(tree);
classDecl = tree.GetRoot().DescendantNodes()
.OfType<ClassDeclarationSyntax>()
.FirstOrDefault(c => c.Identifier.ValueText == typeName);
return model;
}
/// <summary>
/// Core transform over an already-built semantic model + class declaration, so the in-place
/// reload path can share one compilation between validation and transformation instead of
/// building two.
/// </summary>
internal static string TransformMethodBodies(
SemanticModel model,
ClassDeclarationSyntax classDecl,
Dictionary<string, string> methodBodies,
string originalTypeName,
Dictionary<string, MethodSignatureInfo> originalMethodSignatures,
bool emitLineDirectives = false,
string originalFilePath = null,
bool rewriteInterpolations = true)
{
try
{
var root = classDecl.SyntaxTree.GetRoot();
var classSymbol = model.GetDeclaredSymbol(classDecl);
var compiledTwin = FindCompiledType(model.Compilation, classSymbol);
// A top-level type with no compiled twin is entirely NEW (the file was added since
// the build): every method is co-emittable, and the receiver parameter can't name
// the type — it doesn't exist in any reference — so it degrades to object (the
// dispatch passes null for statics anyway). Instance-member access still can't
// compile in that state; new-type reloads are for self-contained static methods.
var wholeTypeIsNew = compiledTwin == null && classSymbol?.ContainingType == null;
var newMethods = ComputeNewMethodNames(classDecl, compiledTwin, wholeTypeIsNew);
// A static type can't appear as a parameter (CS0721), so its receiver slot — always
// dispatched null anyway — degrades to object. Check the compiled twin too: the
// source may have dropped the static keyword while the running build still has it.
var typeIsStatic = classDecl.Modifiers.Any(SyntaxKind.StaticKeyword)
|| (compiledTwin?.IsStatic ?? false);
var instanceTypeName = wholeTypeIsNew || typeIsStatic ? "object" : originalTypeName;
var rewriter = new ImplicitScopeQualifier(model, classSymbol, newMethods,
new HashSet<string>(methodBodies.Keys));
var sb = new StringBuilder();
var emittedUsings = new HashSet<string>();
foreach (var u in root.DescendantNodes().OfType<UsingDirectiveSyntax>())
{
if (emittedUsings.Add(u.Name?.ToString() ?? u.ToString()))
sb.AppendLine(u.ToString());
}
if (emittedUsings.Add("Unity.Pipeline.CodeReload"))
sb.AppendLine("using Unity.Pipeline.CodeReload;");
var namespaceName = GetNamespace(classDecl);
if (!string.IsNullOrEmpty(namespaceName) && emittedUsings.Add(namespaceName))
sb.AppendLine($"using {namespaceName};");
sb.AppendLine();
sb.AppendLine($"public static class {originalTypeName}CodeReloadOverrides");
sb.AppendLine("{");
foreach (var method in classDecl.Members.OfType<MethodDeclarationSyntax>())
{
var methodName = method.Identifier.ValueText;
var isOverride = methodBodies.ContainsKey(methodName) && method.Body != null;
// A method the compiled type doesn't declare is NEW — nothing compiled can
// dispatch to it, but a reloaded body can: co-emit it into the override class
// so it becomes an interpreted method the rewritten call sites reach directly.
var isNewMethod = !isOverride && newMethods.Contains(methodName)
&& (method.Body != null || method.ExpressionBody != null);
if (!isOverride && !isNewMethod)
continue;
// Qualify instance members first: the semantic model is bound to the original
// syntax tree, so this must run before any rewrite that produces new nodes.
// Interpolation-to-string.Format is purely syntactic — no semantic model — so
// it can safely run on the qualified (synthesized) tree. It runs only for the
// interpreter backend (see the rewriteInterpolations doc).
// New methods may be expression-bodied; emitted text is `=> expr;` then.
SyntaxNode rewrittenBody = method.Body != null
? rewriter.Visit(method.Body)
: rewriter.Visit(method.ExpressionBody);
if (rewriteInterpolations)
rewrittenBody = new InterpolationToFormatRewriter().Visit(rewrittenBody);
var bodySuffix = method.Body != null ? "" : ";";
var returnType = method.ReturnType.ToString();
// Overrides always take the receiver first (the dispatch passes null for
// statics); a new STATIC method has no receiver at all — call sites stay bare.
var parameters = new List<string>();
if (isOverride || !method.Modifiers.Any(SyntaxKind.StaticKeyword))
parameters.Add($"{instanceTypeName} instance");
parameters.AddRange(isOverride
? method.ParameterList.Parameters.Select(p => $"{p.Type} {p.Identifier.ValueText}")
// New methods keep their parameter list verbatim (modifiers, defaults).
: method.ParameterList.Parameters.Select(p => p.ToString()));
if (isOverride)
sb.AppendLine($" [CodeReloadOverrideMethod(\"{originalTypeName}.{methodName}\")]");
sb.AppendLine($" public static {returnType} {methodName}({string.Join(", ", parameters)})");
if (emitLineDirectives && !string.IsNullOrEmpty(originalFilePath))
{
// Map the body back to the original file. The rewriter only makes intra-line
// edits, so a single #line at the body's opening brace maps every line of the
// block; emit the body with original trivia (not NormalizeWhitespace) to keep
// line counts intact. #line hidden masks the generated scaffolding that follows.
var bodyNode = (SyntaxNode)method.Body ?? method.ExpressionBody;
var bodyLine = bodyNode.GetLocation().GetLineSpan().StartLinePosition.Line + 1;
var escapedPath = originalFilePath.Replace("\\", "\\\\");
sb.AppendLine($"#line {bodyLine} \"{escapedPath}\"");
sb.AppendLine(rewrittenBody.ToString() + bodySuffix);
sb.AppendLine("#line hidden");
}
else
{
sb.AppendLine(" " + rewrittenBody.NormalizeWhitespace().ToString() + bodySuffix);
}
sb.AppendLine();
}
sb.AppendLine("}");
var result = sb.ToString();
Debug.Log($"CodeReload: Transformed {methodBodies.Count} in-place method(s) for {originalTypeName}");
return result;
}
catch (Exception ex)
{
Debug.LogError($"CodeReload: Error transforming in-place methods for {originalTypeName}: {ex.Message}");
throw new InvalidOperationException(
$"Failed to transform in-place methods for {originalTypeName}: {ex.Message}", ex);
}
}
private static string GetNamespace(SyntaxNode node)
{
for (var current = node.Parent; current != null; current = current.Parent)
{
if (current is NamespaceDeclarationSyntax ns)
return ns.Name.ToString();
}
return null;
}
/// <summary>
/// Names of methods the edited source declares but the COMPILED type doesn't — methods the
/// user added since the last compile. Their call sites can't bind to the host object, so
/// they are co-emitted into the override class and called directly. Generic methods are
/// excluded (outside the interpreter subset). When the whole type is new
/// (<paramref name="wholeTypeIsNew"/>) every method qualifies; when only the compiled twin
/// lookup failed (a nested type) the set is empty — every call site then binds to the
/// host, the pre-existing behavior.
/// </summary>
private static HashSet<string> ComputeNewMethodNames(
ClassDeclarationSyntax classDecl, INamedTypeSymbol compiledType, bool wholeTypeIsNew)
{
var result = new HashSet<string>();
if (compiledType == null && !wholeTypeIsNew)
return result;
foreach (var method in classDecl.Members.OfType<MethodDeclarationSyntax>())
{
if (method.Body == null && method.ExpressionBody == null)
continue;
if (method.TypeParameterList != null)
continue;
var name = method.Identifier.ValueText;
// Declared-members check only: a name that exists compiled (any overload, or on a
// base type but redeclared here) stays host-bound. A source method hiding a base
// member counts as new — the co-emitted copy preserves the source's binding.
if (wholeTypeIsNew || compiledType.GetMembers(name).Length == 0)
result.Add(name);
}
return result;
}
/// <summary>
/// The compiled (metadata) symbol for <paramref name="classSymbol"/>, resolved from the
/// compilation's references — the source declaration shadows it inside the compilation, so
/// the reference assemblies are probed directly. Null when no compiled twin exists.
/// </summary>
private static INamedTypeSymbol FindCompiledType(
UnityPipeline.Microsoft.CodeAnalysis.Compilation compilation, INamedTypeSymbol classSymbol)
{
if (classSymbol == null || classSymbol.ContainingType != null)
return null;
var metadataName = classSymbol.ContainingNamespace is { IsGlobalNamespace: false } ns
? $"{ns.ToDisplayString()}.{classSymbol.MetadataName}"
: classSymbol.MetadataName;
foreach (var reference in compilation.References)
{
if (compilation.GetAssemblyOrModuleSymbol(reference) is IAssemblySymbol asm
&& asm.GetTypeByMetadataName(metadataName) is { } compiled)
return compiled;
}
return null;
}
/// <summary>
/// Rewrites bare references that depended on the original type's scope so they still bind after
/// the body is moved into a standalone static class, using the semantic model:
/// instance members -> <c>instance.x</c>, static members of the type hierarchy ->
/// <c>ContainingType.x</c>, and nested types of the type hierarchy -> <c>ContainingType.Nested</c>.
/// Locals, parameters, and anything reachable via <c>using</c> are left untouched.
/// </summary>
private class ImplicitScopeQualifier : CSharpSyntaxRewriter
{
private readonly SemanticModel m_Model;
private readonly INamedTypeSymbol m_Type;
private readonly HashSet<string> m_NewMethods;
private readonly HashSet<string> m_ReloadedMethods;
public ImplicitScopeQualifier(SemanticModel model, INamedTypeSymbol type,
HashSet<string> newMethods = null, HashSet<string> reloadedMethods = null)
{
m_Model = model;
m_Type = type;
m_NewMethods = newMethods ?? new HashSet<string>();
m_ReloadedMethods = reloadedMethods ?? new HashSet<string>();
}
/// <summary>
/// Calls to NEW methods (declared in the edited source, absent from the compiled type)
/// can't bind to the host object — the callee only exists as a co-emitted static in the
/// override class. Rebuild the call as a direct static invocation, threading the
/// receiver (the implicit <c>instance</c>, or an explicit one like <c>other.Helper()</c>)
/// as the first argument to match the co-emitted signature.
/// </summary>
public override SyntaxNode VisitInvocationExpression(InvocationExpressionSyntax node)
{
// Resolve on the original node — the model is bound to the original tree.
var target = m_NewMethods.Count > 0
? m_Model.GetSymbolInfo(node.Expression).Symbol as IMethodSymbol
: null;
var visited = (InvocationExpressionSyntax)base.VisitInvocationExpression(node);
if (target == null || !m_NewMethods.Contains(target.Name)
|| !SymbolEqualityComparer.Default.Equals(target.ContainingType, m_Type))
return visited;
// Conditional calls (`x?.NewHelper()`) have no receiver expression to thread as an
// argument; leave them — the compile fails with a clear CS1061 instead of silently
// running the helper on the wrong instance.
if (visited.Expression is MemberBindingExpressionSyntax)
return visited;
var arguments = visited.ArgumentList;
// A new method that is itself reloaded is emitted in the override shape — leading
// instance parameter even when static (the dispatch it gets after the next compile
// passes null there) — so its call sites must pass a receiver either way.
if (!target.IsStatic || m_ReloadedMethods.Contains(target.Name))
{
// The visited expression is either `receiver.Helper` (this -> instance already
// applied by the base visit) or a bare/qualified name; a member access carries
// the receiver to thread through, anything else means the implicit `instance`.
// A STATIC call has no receiver — its qualifier is a type name — so the
// caller's own `instance` is what the override-shaped callee gets.
var receiver = !target.IsStatic && visited.Expression is MemberAccessExpressionSyntax ma
? ma.Expression
: (ExpressionSyntax)SyntaxFactory.IdentifierName("instance");
arguments = arguments.WithArguments(
arguments.Arguments.Insert(0, SyntaxFactory.Argument(receiver)));
}
return SyntaxFactory.InvocationExpression(
SyntaxFactory.IdentifierName(target.Name), arguments)
.WithTriviaFrom(visited);
}
public override SyntaxNode VisitThisExpression(ThisExpressionSyntax node)
{
return SyntaxFactory.IdentifierName("instance").WithTriviaFrom(node);
}
public override SyntaxNode VisitIdentifierName(IdentifierNameSyntax node)
{
if (ShouldSkip(node))
return base.VisitIdentifierName(node);
// Symbol is resolved from the original node (the semantic model is bound to the original
// tree); the same node is what gets wrapped since an identifier has no children to rewrite.
return TryQualify(node, node) ?? base.VisitIdentifierName(node);
}
public override SyntaxNode VisitGenericName(GenericNameSyntax node)
{
// e.g. GetComponent<Renderer>() or FindObjectsByType<T>() — visit type arguments first
// (a type arg may itself be a nested type needing qualification), then wrap the result.
var visited = (GenericNameSyntax)base.VisitGenericName(node);
if (ShouldSkip(node))
return visited;
return TryQualify(node, visited) ?? visited;
}
private static bool ShouldSkip(SimpleNameSyntax node)
{
// Right-hand side of a member access (foo.Bar -> Bar), or part of a qualified name.
if (node.Parent is MemberAccessExpressionSyntax ma && ma.Name == node)
return true;
if (node.Parent is QualifiedNameSyntax)
return true;
if (node.Parent is MemberBindingExpressionSyntax)
return true;
return false;
}
/// <summary>
/// Returns the qualified replacement for <paramref name="toWrap"/> if <paramref name="original"/>
/// binds to something that needs the original type's scope, otherwise null (leave as-is).
/// </summary>
private SyntaxNode TryQualify(SimpleNameSyntax original, SimpleNameSyntax toWrap)
{
var symbol = m_Model.GetSymbolInfo(original).Symbol;
if (symbol == null)
return null;
// `var` (and a `using Alias = Outer.Nested;` alias) binds to a type spelled
// differently from the identifier. Qualifying the identifier would emit
// `Outer.var` / `Outer.Alias`, which does not exist. Roslyn re-infers `var` on
// the rewritten initializer, so leave the spelling alone.
if (symbol is ITypeSymbol && symbol.Name != original.Identifier.ValueText)
return null;
// Nested type of the type hierarchy (e.g. a private `enum State`) -> ContainingType.Nested.
if (symbol is INamedTypeSymbol nested && nested.ContainingType != null
&& InTypeHierarchy(nested.ContainingType))
return QualifyWithType(toWrap, nested.ContainingType, original);
if (!IsMemberSymbol(symbol))
return null;
if (!InTypeHierarchy(symbol.ContainingType))
return null;
// A method GROUP over a new method (delegate argument, event `+=`): the member
// doesn't exist on the compiled type, so `instance.M` can't bind. Wrap it as a
// lambda over the co-emitted static instead.
if (symbol is IMethodSymbol group && m_NewMethods.Contains(group.Name)
&& !(original.Parent is InvocationExpressionSyntax inv && inv.Expression == original)
&& MethodGroupLambda(group, original, receiver: null) is { } lambda)
return lambda.WithTriviaFrom(toWrap);
return symbol.IsStatic
? QualifyWithType(toWrap, symbol.ContainingType, original)
: QualifyWith(toWrap, SyntaxFactory.IdentifierName("instance"));
}
/// <summary>
/// `x.Click` where Click is NEW and not invoked — the member-access form of a method
/// group over a new method. Same lambda rewrite as the bare-identifier form, but the
/// (visited) receiver expression is threaded as the co-emitted static's first argument.
/// </summary>
public override SyntaxNode VisitMemberAccessExpression(MemberAccessExpressionSyntax node)
{
if (m_NewMethods.Count > 0
&& !(node.Parent is InvocationExpressionSyntax inv && inv.Expression == node)
&& m_Model.GetSymbolInfo(node).Symbol is IMethodSymbol group
&& m_NewMethods.Contains(group.Name)
&& SymbolEqualityComparer.Default.Equals(group.ContainingType, m_Type))
{
var visited = (MemberAccessExpressionSyntax)base.VisitMemberAccessExpression(node);
if (MethodGroupLambda(group, node, visited.Expression) is { } lambda)
return lambda.WithTriviaFrom(node);
return visited;
}
return base.VisitMemberAccessExpression(node);
}
/// <summary>
/// Builds <c>((a0, a1) => M(receiver, a0, a1))</c> for a method group over a new
/// method, shaped by the delegate type the group converts to (the semantic model's
/// ConvertedType). Null when the conversion target isn't a delegate — the caller
/// falls through, and the compile reports the site. One semantic difference from a
/// real method-group conversion: the receiver expression is evaluated per invocation,
/// not captured at conversion time.
/// </summary>
private ExpressionSyntax MethodGroupLambda(
IMethodSymbol group, ExpressionSyntax original, ExpressionSyntax receiver)
{
if (m_Model.GetTypeInfo(original).ConvertedType is not INamedTypeSymbol delegateType
|| delegateType.DelegateInvokeMethod is not { } invoke)
return null;
var argList = new List<ArgumentSyntax>();
// Matches the co-emitted signature: instance methods and reloaded (override-shaped)
// methods take the receiver first; new plain statics don't.
if (!group.IsStatic || m_ReloadedMethods.Contains(group.Name))
argList.Add(SyntaxFactory.Argument(receiver ?? SyntaxFactory.IdentifierName("instance")));
var parameters = new List<ParameterSyntax>();
for (int i = 0; i < invoke.Parameters.Length; i++)
{
var name = "__mg" + i;
parameters.Add(SyntaxFactory.Parameter(SyntaxFactory.Identifier(name)));
argList.Add(SyntaxFactory.Argument(SyntaxFactory.IdentifierName(name)));
}
var call = SyntaxFactory.InvocationExpression(
SyntaxFactory.IdentifierName(group.Name),
SyntaxFactory.ArgumentList(SyntaxFactory.SeparatedList(argList)));
return SyntaxFactory.ParenthesizedExpression(
SyntaxFactory.ParenthesizedLambdaExpression(
SyntaxFactory.ParameterList(SyntaxFactory.SeparatedList(parameters)),
call));
}
private static bool IsMemberSymbol(ISymbol symbol)
{
switch (symbol.Kind)
{
case SymbolKind.Field:
case SymbolKind.Property:
case SymbolKind.Method:
case SymbolKind.Event:
return true;
default:
return false;
}
}
private bool InTypeHierarchy(INamedTypeSymbol containingType)
{
if (containingType == null)
return false;
for (var t = m_Type; t != null; t = t.BaseType)
{
if (SymbolEqualityComparer.Default.Equals(t, containingType))
return true;
}
return false;
}
/// <summary>Qualify with a fully-qualified type name (e.g. global::UnityEngine.Object.X).</summary>
private static SyntaxNode QualifyWithType(SimpleNameSyntax node, INamedTypeSymbol type, SimpleNameSyntax original)
{
var typeName = type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
// In a type position (local/foreach declaration, cast, array type, generic argument,
// typeof) the parent's slot holds a TypeSyntax, so the replacement must be a
// QualifiedName — a MemberAccessExpression there makes the rewriter's typed rebuild
// throw InvalidCastException. Expression positions keep MemberAccess; both print the
// same text.
if (SyntaxFacts.IsInNamespaceOrTypeContext(original))
return SyntaxFactory.QualifiedName(
SyntaxFactory.ParseName(typeName),
node.WithoutTrivia())
.WithTriviaFrom(node);
return QualifyWith(node, SyntaxFactory.ParseExpression(typeName));
}
private static SyntaxNode QualifyWith(SimpleNameSyntax node, ExpressionSyntax qualifier)
{
return SyntaxFactory.MemberAccessExpression(
SyntaxKind.SimpleMemberAccessExpression,
qualifier,
node.WithoutTrivia())
.WithTriviaFrom(node);
}
}
}
/// <summary>
/// Information about a method's signature for transformation purposes.
/// </summary>
class MethodSignatureInfo
{
/// <summary>The method's return type name, or "void".</summary>
public string ReturnType { get; set; }
/// <summary>The method's parameters.</summary>
public List<ParameterInfo> Parameters { get; set; } = new List<ParameterInfo>();
/// <summary>True unless <see cref="ReturnType"/> is "void".</summary>
public bool ReturnsValue => !string.IsNullOrEmpty(ReturnType) && ReturnType != "void";
}
/// <summary>
/// Information about a method parameter.
/// </summary>
class ParameterInfo
{
/// <summary>The parameter's type name.</summary>
public string Type { get; set; }
/// <summary>The parameter's name.</summary>
public string Name { get; set; }
/// <summary>Whether the parameter has a default value.</summary>
public bool HasDefaultValue { get; set; }
/// <summary>The parameter's default value expression, if any.</summary>
public string DefaultValue { get; set; }
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 425cdc175cccce547a6a3bf6f73c1f4f
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant: