356 lines
19 KiB
C#
356 lines
19 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Reflection;
|
|
using System.Text;
|
|
using UnityEditor;
|
|
using UnityEditor.Build;
|
|
using UnityEditor.Build.Reporting;
|
|
using UnityEditor.Compilation;
|
|
using UnityEditor.UnityLinker;
|
|
using UnityEngine;
|
|
using Unity.Pipeline.Compilation;
|
|
using Unity.Pipeline.CodeReload;
|
|
using IlInterpreter.Interpreter;
|
|
using Assembly = System.Reflection.Assembly;
|
|
|
|
namespace Unity.Pipeline.Editor.BuildProcessors
|
|
{
|
|
/// <summary>
|
|
/// Contributes an auto-generated <c>link.xml</c> that preserves the host-binding surface the
|
|
/// interpreter exposes to reloaded / eval'd code, so IL2CPP managed stripping cannot remove
|
|
/// members the built game never referenced (symptom: "Host method '…' is not registered in the
|
|
/// binding", on device only).
|
|
///
|
|
/// Unity's own generated <c>link.xml</c> can't cover this: it preserves what the build *references*,
|
|
/// and the whole point of code reload is to call members the build never referenced. So preserve
|
|
/// (a) every type in <see cref="HostBinding.RegisteredTypes"/> with <c>preserve="all"</c>, and
|
|
/// (b) — because demand-time auto-bind (<see cref="HostBinding.AutoBindResolver"/>) lets reloaded
|
|
/// code reach ANY user or UnityEngine type — the build's user assemblies and UnityEngine modules
|
|
/// wholesale.
|
|
///
|
|
/// Development builds only: the interpreter reload/eval sinks compile out of release players.
|
|
/// Also preserves the interpreter engine itself (IlInterpreter + its reflection/metadata
|
|
/// dependencies) — kept here, rather than in a static <c>Runtime/link.xml</c>, precisely so
|
|
/// release players don't pay the size cost for machinery they compile out.
|
|
/// </summary>
|
|
class CodeReloadLinkXmlGenerator : IUnityLinkerProcessor
|
|
{
|
|
public int callbackOrder => 0;
|
|
|
|
public string GenerateAdditionalLinkXmlFile(BuildReport report, UnityLinkerBuildPipelineData data)
|
|
{
|
|
// Only development builds carry the interpreter; release builds need no extra preservation.
|
|
if (report == null || !report.summary.options.HasFlag(BuildOptions.Development))
|
|
return string.Empty;
|
|
|
|
// The managed assemblies staged for this player build — the universe for the wholesale
|
|
// preservation pass. See CollectPlayerAssemblyNames: it unions the linker input directory
|
|
// with the editor's own player-assembly view, because on some build backends the input
|
|
// directory is empty at IUnityLinkerProcessor time and relying on it alone silently
|
|
// collapsed preservation to the explicit per-type surface — stripping everything
|
|
// demand-time auto-bind reaches (e.g. a live-added `new Slider()`, whose UITK ctors the
|
|
// built game never referenced) on device only.
|
|
// inputDirectory is obsolete on the incremental build pipeline (it comes back empty there),
|
|
// which is exactly the gap the union above closes; on the classic pipeline it is still the
|
|
// exact staged set, so keep reading it.
|
|
#pragma warning disable CS0618
|
|
var playerAssemblies = CollectPlayerAssemblyNames(data.inputDirectory);
|
|
#pragma warning restore CS0618
|
|
|
|
var path = Path.GetFullPath(FileUtil.GetUniqueTempPathInProject() + "-hostbindings.link.xml");
|
|
File.WriteAllText(path, GenerateLinkXml(playerAssemblies, out var stats));
|
|
// The stats line is the diagnostic: if a game assembly with [CodeReload] types is missing
|
|
// from the reloadable-target list (e.g. its name matches the engine-assembly skip
|
|
// prefixes), its private state can be stripped and binding fails on device only.
|
|
Debug.Log($"Pipeline: generated code-reload host-binding link.xml at {path} — {stats}");
|
|
return path;
|
|
}
|
|
|
|
/// <summary>
|
|
/// The simple names of the managed assemblies that end up in this player build — the universe
|
|
/// the wholesale-preservation pass draws from. Unions two sources so a gap in either can't
|
|
/// silently collapse preservation:
|
|
/// <list type="bullet">
|
|
/// <item>the linker input directory (the exact staged set, incl. test assemblies for a test
|
|
/// player) — recursively, since some backends nest the managed DLLs in a subfolder;</item>
|
|
/// <item>the editor's own view — <see cref="CompilationPipeline"/> player assemblies (user
|
|
/// code) plus every loaded <c>UnityEngine*</c> module (the precompiled engine modules
|
|
/// demand-time auto-bind can construct from) — which stays populated even when the input
|
|
/// directory is empty at <see cref="IUnityLinkerProcessor"/> time.</item>
|
|
/// </list>
|
|
/// A union only adds; it never drops an assembly the input scan found, so a test player keeps
|
|
/// its test assemblies. Returns null only if every source came up empty (preserve per-type only).
|
|
/// </summary>
|
|
internal static string[] CollectPlayerAssemblyNames(string inputDirectory)
|
|
{
|
|
var names = new HashSet<string>(StringComparer.Ordinal);
|
|
|
|
try
|
|
{
|
|
if (!string.IsNullOrEmpty(inputDirectory) && Directory.Exists(inputDirectory))
|
|
foreach (var dll in Directory.GetFiles(inputDirectory, "*.dll", SearchOption.AllDirectories))
|
|
names.Add(Path.GetFileNameWithoutExtension(dll));
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Debug.LogWarning($"Pipeline: could not enumerate linker input assemblies " +
|
|
$"('{inputDirectory}'): {ex.Message} — falling back to the editor's " +
|
|
"player-assembly view for wholesale preservation.");
|
|
}
|
|
|
|
try
|
|
{
|
|
foreach (var a in CompilationPipeline.GetAssemblies(AssembliesType.Player))
|
|
if (!string.IsNullOrEmpty(a.name))
|
|
names.Add(a.name);
|
|
}
|
|
catch { /* editor API unavailable in this context — the UnityEngine sweep still runs */ }
|
|
|
|
foreach (var a in PipelineUtils.GetLoadedAssemblies())
|
|
{
|
|
var n = a.GetName().Name;
|
|
if (!string.IsNullOrEmpty(n) && n.StartsWith("UnityEngine", StringComparison.Ordinal))
|
|
names.Add(n);
|
|
}
|
|
|
|
return names.Count > 0 ? new List<string>(names).ToArray() : null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Build the interpreter's default host binding and emit a <c>link.xml</c> preserving every
|
|
/// registered type, grouped by owning assembly. Pure and deterministic — directly unit-testable.
|
|
/// Derives from <see cref="IlInterpreterHostBindings.CreateStandard"/> — the builder the code-reload
|
|
/// executor runs — so the preservation set cannot drift from the runtime surface. The per-reload
|
|
/// <c>AllowType(targetType)</c> overlay is excluded: the target is the user's own component,
|
|
/// which the built player references and IL2CPP won't strip.
|
|
/// </summary>
|
|
public static string GenerateLinkXml() => GenerateLinkXml(null, out _);
|
|
|
|
public static string GenerateLinkXml(out string stats) => GenerateLinkXml(null, out stats);
|
|
|
|
/// <summary><paramref name="stats"/>: one-line summary of what was preserved, logged at build
|
|
/// time so a missing game assembly is visible in the build log.
|
|
/// <paramref name="playerAssemblyNames"/>: simple names of the managed assemblies staged for
|
|
/// the player build; when provided, user assemblies and UnityEngine modules from that set are
|
|
/// preserved wholesale (including engine-named assemblies carrying [CodeReload] members — the
|
|
/// attribute marks them as user code), and reload-target entries are limited to assemblies in
|
|
/// the set (editor-only and test assemblies drop out of game builds); when null, only per-type
|
|
/// preservation is emitted, unfiltered.</summary>
|
|
public static string GenerateLinkXml(IEnumerable<string> playerAssemblyNames, out string stats)
|
|
{
|
|
var binding = IlInterpreterHostBindings.CreateStandard();
|
|
|
|
// SortedSet/SortedDictionary give a stable, diffable ordering and dedupe (closed generics
|
|
// collapsing onto their definition can produce repeats).
|
|
var byAssembly = new SortedDictionary<string, SortedSet<string>>(StringComparer.Ordinal);
|
|
foreach (var t in binding.RegisteredTypes)
|
|
{
|
|
// Preserve the open definition for constructed generics (a closed-generic fullname is
|
|
// assembly-qualified and won't match in link.xml).
|
|
var def = t.IsGenericType && !t.IsGenericTypeDefinition ? t.GetGenericTypeDefinition() : t;
|
|
|
|
var asm = def.Assembly.GetName().Name;
|
|
var full = def.FullName;
|
|
if (string.IsNullOrEmpty(asm) || string.IsNullOrEmpty(full))
|
|
continue;
|
|
|
|
full = full.Replace('+', '/'); // link.xml uses '/' as the nested-type separator
|
|
|
|
if (!byAssembly.TryGetValue(asm, out var types))
|
|
byAssembly[asm] = types = new SortedSet<string>(StringComparer.Ordinal);
|
|
types.Add(full);
|
|
}
|
|
|
|
// Also preserve the reloadable target components. The target type IS referenced by the
|
|
// built player, but a PRIVATE field only the interpreter touches can still be stripped,
|
|
// breaking the reflection walk; preserve="all" keeps every member so
|
|
// AllowNonPublicInstanceMembers(targetType) can resolve private state on device.
|
|
int hostTypeCount = 0;
|
|
foreach (var kv in byAssembly)
|
|
hostTypeCount += kv.Value.Count;
|
|
|
|
// The AppDomain scan below also sees editor-only and test assemblies that are not part
|
|
// of the player build; when the staged set is known, reload targets outside it are
|
|
// dropped as noise. A test-player build stages its test assemblies, so fixtures keep
|
|
// their entries exactly where they matter.
|
|
HashSet<string> staged = null;
|
|
if (playerAssemblyNames != null)
|
|
{
|
|
staged = new HashSet<string>(StringComparer.Ordinal);
|
|
foreach (var name in playerAssemblyNames)
|
|
if (!string.IsNullOrEmpty(name))
|
|
staged.Add(name);
|
|
}
|
|
|
|
int targetTypeCount = 0;
|
|
var targetAssemblies = new SortedSet<string>(StringComparer.Ordinal);
|
|
var engineNamedTargetAssemblies = new SortedSet<string>(StringComparer.Ordinal);
|
|
foreach (var t in CollectReloadableTargetTypes())
|
|
{
|
|
var asm = t.Assembly.GetName().Name;
|
|
var full = t.FullName;
|
|
if (string.IsNullOrEmpty(asm) || string.IsNullOrEmpty(full))
|
|
continue;
|
|
if (staged != null && !staged.Contains(asm))
|
|
continue;
|
|
full = full.Replace('+', '/');
|
|
if (!byAssembly.TryGetValue(asm, out var types))
|
|
byAssembly[asm] = types = new SortedSet<string>(StringComparer.Ordinal);
|
|
types.Add(full);
|
|
targetTypeCount++;
|
|
targetAssemblies.Add(asm);
|
|
if (IsNonUserAssembly(asm))
|
|
engineNamedTargetAssemblies.Add(asm);
|
|
}
|
|
|
|
// Dev-build widening: demand-time auto-bind (HostBinding.AutoBindResolver) lets a reloaded
|
|
// or eval'd body call into ANY user or UnityEngine type, so RegisteredTypes is not the full
|
|
// callable set — preserve the build's user assemblies and UnityEngine modules wholesale.
|
|
// Unity.* packages (Burst, Collections, …) stay per-type: AOT-heavy and rarely touched from
|
|
// reload bodies. (The project-extension seam that could promote a specific type was
|
|
// deferred to a future release, so on-device reload bodies can only reach package types
|
|
// the stripper kept.)
|
|
var wholesale = new SortedSet<string>(StringComparer.Ordinal);
|
|
// The interpreter engine itself: it parses pushed override IL via
|
|
// System.Reflection.Metadata and resolves host members by name, so those paths look
|
|
// unused to the stripper. Emitted here (dev builds only) rather than in a static
|
|
// Runtime/link.xml, which would bloat release players that compile the interpreter out.
|
|
foreach (var name in InterpreterEngineAssemblies)
|
|
wholesale.Add(name);
|
|
if (staged != null)
|
|
{
|
|
foreach (var name in staged)
|
|
{
|
|
if (name.StartsWith("UnityEngine", StringComparison.Ordinal)) wholesale.Add(name);
|
|
else if (!IsNonUserAssembly(name)) wholesale.Add(name);
|
|
}
|
|
|
|
// A staged, engine-named assembly carrying [CodeReload] members has identified itself
|
|
// as user code participating in code reload — a package-like name (e.g. "Unity.MyGame")
|
|
// must not demote it. Preserving only its [CodeReload] types would leave the rest of
|
|
// it strippable (demand-time auto-bind would hit stripped members on device only),
|
|
// so promote it into the wholesale set like any other user assembly.
|
|
foreach (var asm in engineNamedTargetAssemblies)
|
|
wholesale.Add(asm);
|
|
}
|
|
|
|
stats = $"{hostTypeCount} host-binding type(s) and {targetTypeCount} reloadable target type(s) " +
|
|
$"across {byAssembly.Count} assembly(ies); target assemblies: " +
|
|
(targetAssemblies.Count > 0 ? string.Join(", ", targetAssemblies) : "none") +
|
|
$"; {wholesale.Count} assembly(ies) preserved wholesale for demand-time auto-bind";
|
|
|
|
var sb = new StringBuilder();
|
|
sb.AppendLine("<linker>");
|
|
sb.AppendLine(" <!--");
|
|
sb.AppendLine(" AUTO-GENERATED by CodeReloadLinkXmlGenerator (development builds only). Do not edit.");
|
|
sb.AppendLine(" Preserves the IlInterpreter interpreter engine and its host-binding surface");
|
|
sb.AppendLine(" (HostBinding.RegisteredTypes), plus — wholesale — the build's user assemblies and");
|
|
sb.AppendLine(" UnityEngine modules, so reloaded/eval'd code can call members the built game");
|
|
sb.AppendLine(" never referenced (including types the interpreter binds on demand), which IL2CPP");
|
|
sb.AppendLine(" managed stripping would otherwise remove.");
|
|
sb.AppendLine(" -->");
|
|
// Wholesale assemblies first (their per-type entries are subsumed), then per-type blocks.
|
|
foreach (var name in wholesale)
|
|
sb.AppendLine($" <assembly fullname=\"{name}\" preserve=\"all\"/>");
|
|
foreach (var kv in byAssembly)
|
|
{
|
|
if (wholesale.Contains(kv.Key)) continue;
|
|
sb.AppendLine($" <assembly fullname=\"{kv.Key}\">");
|
|
foreach (var type in kv.Value)
|
|
sb.AppendLine($" <type fullname=\"{type}\" preserve=\"all\"/>");
|
|
sb.AppendLine(" </assembly>");
|
|
}
|
|
sb.AppendLine("</linker>");
|
|
return sb.ToString();
|
|
}
|
|
|
|
// The IlInterpreter engine and its reflection/metadata dependencies — always preserved
|
|
// wholesale so pushed code reload keeps working on device.
|
|
static readonly string[] InterpreterEngineAssemblies =
|
|
{
|
|
"Unity.Pipeline.IlInterpreter",
|
|
"UnityPipeline.System.Reflection.Metadata",
|
|
"UnityPipeline.System.Collections.Immutable",
|
|
};
|
|
|
|
// Engine/BCL/tooling assembly names — NOT user code. Decides which staged assemblies are
|
|
// preserved wholesale, and which reload-target assemblies get the engine-named warning.
|
|
// Matching is deliberately precise (exact "Unity"/"System" or a dotted prefix, never the
|
|
// bare word) so a game assembly that merely starts with the word — "UnityChanDemo",
|
|
// "SystemsRuntime" — is still preserved wholesale.
|
|
static bool IsNonUserAssembly(string name) =>
|
|
name == "Unity" ||
|
|
name.StartsWith("Unity.", StringComparison.Ordinal) ||
|
|
name.StartsWith("UnityEngine", StringComparison.Ordinal) ||
|
|
name.StartsWith("UnityEditor", StringComparison.Ordinal) ||
|
|
name == "System" ||
|
|
name.StartsWith("System.", StringComparison.Ordinal) ||
|
|
name.StartsWith("Microsoft.", StringComparison.Ordinal) ||
|
|
name.StartsWith("Mono.", StringComparison.Ordinal) ||
|
|
name == "mscorlib" ||
|
|
name == "netstandard" ||
|
|
name.StartsWith("nunit.", StringComparison.Ordinal);
|
|
|
|
/// <summary>
|
|
/// User-code types that carry a class-level <c>[CodeReload]</c> or any <c>[CodeReload]</c>
|
|
/// method — i.e. every type whose members can be reloaded through
|
|
/// the interpreter and therefore needs its (possibly private) fields kept under stripping.
|
|
/// The attributes live in <c>Unity.Pipeline</c>, so only assemblies referencing it can carry
|
|
/// them — a cheap, metadata-only check that gates the expensive type scan. Assembly *names*
|
|
/// play no part here, so game assemblies named like packages ("Unity.MyGame") are covered.
|
|
/// </summary>
|
|
static IEnumerable<Type> CollectReloadableTargetTypes()
|
|
{
|
|
var results = new List<Type>();
|
|
foreach (var asm in PipelineUtils.GetLoadedAssemblies())
|
|
{
|
|
if (!ReferencesPipelineRuntime(asm))
|
|
continue;
|
|
|
|
Type[] types;
|
|
try { types = asm.GetTypes(); }
|
|
catch (ReflectionTypeLoadException ex) { types = ex.Types; }
|
|
catch { continue; }
|
|
|
|
foreach (var t in types)
|
|
{
|
|
if (t == null) continue;
|
|
try
|
|
{
|
|
if (t.IsDefined(typeof(CodeReloadAttribute), inherit: false) || HasReloadableMethod(t))
|
|
results.Add(t);
|
|
}
|
|
catch { /* type that can't be inspected — skip */ }
|
|
}
|
|
}
|
|
return results;
|
|
}
|
|
|
|
static bool ReferencesPipelineRuntime(Assembly asm)
|
|
{
|
|
var pipeline = typeof(CodeReloadAttribute).Assembly.GetName().Name;
|
|
try
|
|
{
|
|
foreach (var reference in asm.GetReferencedAssemblies())
|
|
if (string.Equals(reference.Name, pipeline, StringComparison.Ordinal))
|
|
return true;
|
|
}
|
|
catch { /* dynamic/unloadable assembly — treat as not referencing */ }
|
|
return false;
|
|
}
|
|
|
|
static bool HasReloadableMethod(Type t)
|
|
{
|
|
const BindingFlags flags = BindingFlags.Public | BindingFlags.NonPublic
|
|
| BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly;
|
|
foreach (var m in t.GetMethods(flags))
|
|
{
|
|
if (m.IsDefined(typeof(CodeReloadAttribute), inherit: false))
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
}
|
|
}
|