Initial commit: AIGame Unity client with URP, HybridCLR, YooAsset and tooling.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
+11
@@ -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;
|
||||
}
|
||||
}
|
||||
+11
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -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.<member></c></item>
|
||||
/// <item>static members inherited or declared on the type hierarchy (e.g. <c>FindObjectsByType</c> on
|
||||
/// Object) -> <c><ContainingType>.<member></c></item>
|
||||
/// <item>nested types (enum/class) of the type hierarchy (e.g. a private <c>enum State</c>) -> <c><ContainingType>.<NestedType></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 <ret> Method(Type instance, <params>) { ...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:
|
||||
Reference in New Issue
Block a user