Files

315 lines
17 KiB
C#

using System;
using System.Collections.Generic;
using UnityEngine;
using Unity.Pipeline.CodeReload;
using IlInterpreter.Interpreter;
namespace Unity.Pipeline.Compilation
{
/// <summary>
/// IL2CPP-safe in-place code reload backend: instead of <c>Assembly.Load</c> + reflection, the
/// transformed override assembly bytes run in the IlInterpreter interpreter, and each override is
/// registered as an interpreter dispatch in <see cref="CodeReloadRegistry"/>.
///
/// Override methods are <c>static {ret} {Method}({TargetType} instance, ...)</c>, dispatched as
/// <c>interpreter.Invoke("{Method}", instance, ...args)</c>. The host binding is the shared
/// standard surface plus the target type — public and non-public members (the override is
/// compiled with access checks disabled). Other referenced types bind on demand during Load via
/// the binding's <c>AutoBindResolver</c>.
///
/// Inherits the interpreter's limits: constructs IlInterpreter doesn't lower yet throw at dispatch
/// time, and <see cref="CodeReloadRegistry.TryInvokeCodeReload"/> falls back to the original
/// compiled body for that call. Member refs unbound at Load are reported through the
/// <c>warnings</c> out param so the reload response shows the gap before play-mode execution hits it.
/// </summary>
static class InterpreterCodeReloadExecutor
{
// Editor and any development player (incl. IL2CPP on device): the interpreter runs pushed IL
// bytes without Assembly.Load or Roslyn.
#if UNITY_EDITOR || DEVELOPMENT_BUILD || ENABLE_RUNTIME_PIPELINE
/// <summary>
/// Load <paramref name="assemblyBytes"/> into an interpreter and register an interpreter-backed
/// override for each <paramref name="methodNames"/> entry on <paramref name="typeName"/>.
/// A name with no woven dispatch target (the method — or its whole type — was added since
/// the running build was compiled) cannot be dispatched from compiled code and is reported
/// through <paramref name="skipped"/>; it still loads into the interpreter, so other
/// reloaded bodies in the same file can call it.
/// Returns the method ids that bound; <paramref name="skipped"/> carries
/// reasons for any that didn't; <paramref name="warnings"/> carries non-fatal load-time
/// diagnostics (host members that resolved to throwing stubs — the reload still applies, and
/// a stub only throws if its call site is reached).
/// </summary>
public static List<string> Register(
byte[] assemblyBytes, string typeName, IEnumerable<string> methodNames,
out List<string> skipped, out List<string> warnings)
{
var registered = new List<string>();
skipped = new List<string>();
warnings = new List<string>();
var names = new List<string>(methodNames ?? Array.Empty<string>());
if (assemblyBytes == null || names.Count == 0)
return registered;
// All methods in one in-place file share the target type, but not every NAME resolves:
// a [CodeReload] method added since the last compile has no woven prologue to dispatch
// from, so its id is not in the registry. Resolve the type from the first name that is;
// the rest load but only intra-file calls reach them (reported below). A file whose type
// was never compiled resolves nothing — targetType stays null and the binding runs
// without the target-type overlay.
Type targetType = null;
var resolvable = new List<string>();
var unwovenNames = new List<string>();
foreach (var m in names)
{
if (CodeReloadRegistry.TryGetReloadableDeclaringType($"{typeName}.{m}", out var t))
{
targetType = targetType ?? t;
resolvable.Add(m);
}
else
{
unwovenNames.Add(m);
}
}
IlInterpreter.Interpreter.ScriptInterpreter interpreter;
try
{
// Standard shared surface plus the target type so the rewritten body can touch its
// own members, public and non-public. Every other type the override references is
// bound on demand by the Load pass via the binding's AutoBindResolver.
var binding = IlInterpreterHostBindings.CreateStandard();
if (targetType != null)
binding = binding.AllowType(targetType)
.AllowNonPublicInstanceMembers(targetType);
interpreter = new IlInterpreter.Interpreter.ScriptInterpreter(
binding, msg => Debug.Log(msg));
// Lenient: a method that can't be lowered becomes a skipped stub (reported per method
// in the registration loop below) instead of failing the whole file's reload — so one
// bad method (e.g. SetupUI referencing a member missing on the running build) doesn't
// drop the other reloaded methods (UIUpdate) in the same file.
interpreter.Load(new OverrideScript(assemblyBytes), lenient: true);
}
catch (Exception ex)
{
// Lenient load no longer throws for per-method lowering failures, so this is a
// whole-assembly failure (parse error, cctor, a genuine crash). Anything but a
// ScriptValidationException carries an unactionable message ("Index was outside the
// bounds of the array") — name the type and put the full stack in the log.
var detail = ex is IlInterpreter.ScriptValidationException
? ex.Message
: $"{ex.GetType().Name}: {ex.Message} (full stack in the editor log)";
Debug.LogError($"CodeReload: interpreter load failed for '{typeName}': {ex}");
foreach (var m in names)
skipped.Add($"{m}: interpreter load failed: {detail}");
return registered;
}
// These member refs resolved to throwing stubs. The reload still applies (a stub only
// throws if reached), but report the gap in the reload response rather than as a mid-play
// ScriptRuntimeException on whichever frame first hits it.
if (interpreter.UnboundHostMembers.Count > 0)
warnings.Add(
$"warning: {interpreter.UnboundHostMembers.Count} host member(s) referenced by the " +
$"reloaded code are not in the binding and will throw if reached: " +
$"{string.Join(", ", interpreter.UnboundHostMembers)}. Types outside the standard " +
"surface and demand-time auto-bind are not callable from interpreted code; " +
"BCL types beyond the shims are intentionally not auto-bound.");
foreach (var methodName in resolvable)
{
var targetMethodId = $"{typeName}.{methodName}";
var name = methodName; // capture per iteration
// Skipped under lenient load — its body couldn't be lowered. Report the reason and
// move on rather than register a dispatcher that throws on first call. The most
// common cause across the editor→player boundary is a reference the running build
// lacks, so make that actionable.
if (interpreter.TryGetLoweringSkip(methodName, out var lowerSkip))
{
if (lowerSkip.Contains("did not resolve to a host or script method"))
lowerSkip += " — the referenced member is neither an interpreted " +
"(reloaded/[CodeReload]) method nor a bound host member. If it was added " +
"since the running build was made, rebuild/redeploy the player to sync, " +
"or mark it [CodeReload].";
skipped.Add($"{methodName}: interpreter load skipped this method: {lowerSkip}");
continue;
}
object Dispatch(object instance, object[] parameters)
{
// Override signature: static M(TargetType instance, ...originalArgs). Use typed
// Invoke overloads for the common arities to avoid a per-call object[] (args are
// already boxed by the woven prologue). Returns the interpreter's (boxed) result,
// which the woven prologue of a value-returning method unboxes; void methods
// return null, which that prologue ignores.
int n = parameters?.Length ?? 0;
switch (n)
{
case 0: return interpreter.Invoke<object>(name, instance);
case 1: return interpreter.Invoke<object, object>(name, instance, parameters[0]);
case 2: return interpreter.Invoke<object, object, object>(name, instance, parameters[0], parameters[1]);
default:
var args = new object[1 + n];
args[0] = instance;
Array.Copy(parameters, 0, args, 1, n);
return interpreter.Invoke(name, args);
}
}
if (CodeReloadRegistry.RegisterInterpreterMethodOverride(targetMethodId, Dispatch, out var skipReason))
registered.Add(targetMethodId);
else
skipped.Add($"{methodName} -> {targetMethodId}: {skipReason}");
}
// A method with no woven target has no compiled caller to dispatch from. It is still
// part of the loaded assembly, so other reloaded bodies in this file call it fine —
// report it so the reload response says why it isn't independently dispatchable.
foreach (var methodName in unwovenNames)
{
if (interpreter.TryGetLoweringSkip(methodName, out var lowerSkip))
{
skipped.Add($"{methodName}: interpreter load skipped this method: {lowerSkip}");
continue;
}
// An empty registry means discovery never ran (nothing entered Play Mode / no
// driver booted), not that this one method is new — say so instead of blaming the
// build.
if (!CodeReloadRegistry.HasReloadableMethods)
{
skipped.Add($"{methodName}: no [CodeReload] targets are registered in this session, so " +
"there is nothing to dispatch to. Targets are discovered when Play Mode starts " +
"(or a Player boots); enter Play Mode and save again.");
continue;
}
skipped.Add($"{methodName}: no woven dispatch target — the method (or its type) was " +
"added after the running build was compiled. Other reloaded [CodeReload] bodies in " +
"this file can call it; dispatching it on its own requires a rebuild.");
}
return registered;
}
/// <summary>
/// Push-time host-surface check: load the compiled override bytes into a throwaway
/// interpreter against the same binding a player builds on receipt, and return the member
/// refs that resolved to throwing stubs. Running the device's load pass in the editor turns
/// a mid-play ScriptRuntimeException into a synchronous diagnostic in the
/// reload_file_player_interpreter response (the device ack is async and carries only a warning
/// count).
///
/// The editor's surface is a superset of a device player's (no IL2CPP stripping, and
/// editor-only assemblies resolve), so a clean result here is no guarantee — the player's
/// own load pass stays the backstop. The converse holds strictly: every member reported
/// here is a throwing stub on the player too.
/// </summary>
/// <param name="note">Non-fatal context when the check was degraded: the target type
/// could not be resolved (its non-public members may be reported as unbound), or the
/// interpreter load itself threw (the player's load will likely fail the same way).</param>
public static List<string> ValidateBindingSurface(
byte[] assemblyBytes, string typeName, IReadOnlyList<string> methodNames, out string note)
{
note = null;
var unbound = new List<string>();
if (assemblyBytes == null || assemblyBytes.Length == 0)
return unbound;
try
{
var binding = IlInterpreterHostBindings.CreateStandard();
var targetType = ResolveTargetType(typeName, methodNames);
if (targetType != null)
binding.AllowType(targetType).AllowNonPublicInstanceMembers(targetType);
else
note = $"target type '{typeName}' not resolved in the editor — its own " +
"non-public members may show up as unbound";
using var interpreter = new IlInterpreter.Interpreter.ScriptInterpreter(binding, _ => { });
// Lenient like the player's Register: a method that can't lower is skipped, not
// thrown, so the pre-check still reports the unbound members of the methods that DID
// lower instead of failing the whole validation on the first bad method.
interpreter.Load(new OverrideScript(assemblyBytes), lenient: true);
unbound.AddRange(interpreter.UnboundHostMembers);
}
catch (Exception ex)
{
note = $"interpreter load failed during validation: {ex.Message} — the player's " +
"load pass will likely fail the same way";
}
return unbound;
}
/// <summary>
/// Resolve the override's target type the way <see cref="Register"/> does (the code-reload
/// registry, only populated in play mode), falling back to a scan of the loaded user
/// assemblies for a unique short-name match (override extraction records short names).
/// Returns null when unknown or ambiguous; validation then runs without the target-type overlay.
/// </summary>
static Type ResolveTargetType(string typeName, IReadOnlyList<string> methodNames)
{
if (methodNames != null && methodNames.Count > 0 &&
CodeReloadRegistry.TryGetReloadableDeclaringType($"{typeName}.{methodNames[0]}", out var fromRegistry))
return fromRegistry;
// Skip engine/BCL assemblies ([CodeReload] target types live in user code); mirrors
// IlInterpreterHostBindings.IsAutoBindSkippedAssembly's classification intent.
Type unique = null;
foreach (var asm in PipelineUtils.GetLoadedAssemblies())
{
var name = asm.GetName().Name ?? string.Empty;
if (name.StartsWith("Unity", StringComparison.Ordinal) ||
name.StartsWith("System", StringComparison.Ordinal) ||
name.StartsWith("Microsoft.", StringComparison.Ordinal) ||
name.StartsWith("Mono.", StringComparison.Ordinal) ||
name.StartsWith("mscorlib", StringComparison.Ordinal) ||
name.StartsWith("netstandard", StringComparison.Ordinal) ||
name.StartsWith("nunit.", StringComparison.Ordinal))
continue;
Type[] types;
try { types = asm.GetTypes(); }
catch (System.Reflection.ReflectionTypeLoadException ex) { types = ex.Types; }
catch { continue; }
foreach (var t in types)
{
if (t == null || t.Name != typeName) continue;
if (unique != null && unique != t) return null; // ambiguous — skip the overlay
unique = t;
}
}
return unique;
}
/// <summary>Minimal <see cref="IlInterpreter.IScript"/> over the Roslyn-emitted override PE bytes.</summary>
private sealed class OverrideScript : IlInterpreter.IScript
{
public OverrideScript(byte[] bytes) { Il = bytes; }
public string Name => "PipelineCodeReload";
public ReadOnlyMemory<byte> Il { get; }
}
#else
public static List<string> Register(
byte[] assemblyBytes, string typeName, IEnumerable<string> methodNames,
out List<string> skipped, out List<string> warnings)
{
skipped = new List<string> { "Interpreter code reload only supported on Desktop development builds" };
warnings = new List<string>();
return new List<string>();
}
public static List<string> ValidateBindingSurface(
byte[] assemblyBytes, string typeName, IReadOnlyList<string> methodNames, out string note)
{
note = "Binding validation only supported in the editor and Desktop development builds";
return new List<string>();
}
#endif
}
}