using System; using System.Collections.Generic; using UnityEngine; using Unity.Pipeline.CodeReload; using IlInterpreter.Interpreter; namespace Unity.Pipeline.Compilation { /// /// IL2CPP-safe in-place code reload backend: instead of Assembly.Load + reflection, the /// transformed override assembly bytes run in the IlInterpreter interpreter, and each override is /// registered as an interpreter dispatch in . /// /// Override methods are static {ret} {Method}({TargetType} instance, ...), dispatched as /// interpreter.Invoke("{Method}", instance, ...args). 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 AutoBindResolver. /// /// Inherits the interpreter's limits: constructs IlInterpreter doesn't lower yet throw at dispatch /// time, and falls back to the original /// compiled body for that call. Member refs unbound at Load are reported through the /// warnings out param so the reload response shows the gap before play-mode execution hits it. /// 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 /// /// Load into an interpreter and register an interpreter-backed /// override for each entry on . /// 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 ; it still loads into the interpreter, so other /// reloaded bodies in the same file can call it. /// Returns the method ids that bound; carries /// reasons for any that didn't; 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). /// public static List Register( byte[] assemblyBytes, string typeName, IEnumerable methodNames, out List skipped, out List warnings) { var registered = new List(); skipped = new List(); warnings = new List(); var names = new List(methodNames ?? Array.Empty()); 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(); var unwovenNames = new List(); 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(name, instance); case 1: return interpreter.Invoke(name, instance, parameters[0]); case 2: return interpreter.Invoke(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; } /// /// 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. /// /// 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). public static List ValidateBindingSurface( byte[] assemblyBytes, string typeName, IReadOnlyList methodNames, out string note) { note = null; var unbound = new List(); 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; } /// /// Resolve the override's target type the way 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. /// static Type ResolveTargetType(string typeName, IReadOnlyList 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; } /// Minimal over the Roslyn-emitted override PE bytes. private sealed class OverrideScript : IlInterpreter.IScript { public OverrideScript(byte[] bytes) { Il = bytes; } public string Name => "PipelineCodeReload"; public ReadOnlyMemory Il { get; } } #else public static List Register( byte[] assemblyBytes, string typeName, IEnumerable methodNames, out List skipped, out List warnings) { skipped = new List { "Interpreter code reload only supported on Desktop development builds" }; warnings = new List(); return new List(); } public static List ValidateBindingSurface( byte[] assemblyBytes, string typeName, IReadOnlyList methodNames, out string note) { note = "Binding validation only supported in the editor and Desktop development builds"; return new List(); } #endif } }