Initial commit: AIGame Unity client with URP, HybridCLR, YooAsset and tooling.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+355
@@ -0,0 +1,355 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b802ce727e209d74bbbda4467462840a
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+375
@@ -0,0 +1,375 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Unity.Pipeline.Config;
|
||||
using UnityEngine;
|
||||
using UnityEditor;
|
||||
using UnityEditor.Build;
|
||||
using UnityEditor.Build.Reporting;
|
||||
|
||||
namespace Unity.Pipeline.Editor.BuildProcessors
|
||||
{
|
||||
/// <summary>
|
||||
/// Build processor for runtime Pipeline support. Three responsibilities:
|
||||
/// - Validates the RuntimePipelineConfig settings (JSON-authored, read via
|
||||
/// RuntimePipelineConfig.Load()) before allowing builds with runtime Pipeline enabled.
|
||||
/// - Bakes the current settings into a transient RuntimePipelineConfig Resources asset so a
|
||||
/// Player build can find them (the authored copy stays in ProjectSettings/, never here).
|
||||
/// - Bakes the project's code reload scope (Assets + loaded package locations) into a
|
||||
/// generated RuntimePipelineBuildInfo asset. A running Player cannot resolve the project
|
||||
/// layout, so the absolute roots it is allowed to code reload from must be captured at build
|
||||
/// time.
|
||||
/// Both generated assets are purged at the very start of every build (before either is
|
||||
/// possibly rewritten) and deleted again after a successful build, so machine-specific/
|
||||
/// duplicated data never lingers in the project — including a stale, possibly-enabled asset
|
||||
/// left behind by a build that was interrupted before OnPostprocessBuild could run (Unity
|
||||
/// never invokes it for a failed or cancelled build).
|
||||
/// </summary>
|
||||
#if UNITY_6000_3_OR_NEWER
|
||||
class PipelineRuntimeBuildProcessor : IPreprocessBuildWithContext, IPostprocessBuildWithReport
|
||||
#else
|
||||
class PipelineRuntimeBuildProcessor : IPreprocessBuildWithReport, IPostprocessBuildWithReport
|
||||
#endif
|
||||
{
|
||||
/// <summary>Callback ordering relative to other build processors (0 = default).</summary>
|
||||
public int callbackOrder => 0;
|
||||
|
||||
private const string ConfigAssetPath = "Assets/Settings/Pipeline/Resources/RuntimePipelineConfig.asset";
|
||||
private const string BuildInfoAssetPath = "Assets/Settings/Pipeline/Resources/RuntimePipelineBuildInfo.asset";
|
||||
|
||||
// Opt-in symbol that puts the Pipeline into a build that is not a Development Build. Kept in
|
||||
// step with the defineConstraints on Unity.Pipeline / Unity.Pipeline.IlInterpreter and on the
|
||||
// bundled DLLs under Runtime/Plugins/CodeAnalysis: if this file and those disagree, a player
|
||||
// gets settings assets no code can read, or Pipeline code with no settings to start from.
|
||||
private const string EnableSymbol = "ENABLE_RUNTIME_PIPELINE";
|
||||
|
||||
/// <summary>Verify bundled DLL integrity and validate runtime pipeline configuration before a build.</summary>
|
||||
#if UNITY_6000_3_OR_NEWER
|
||||
/// <param name="ctx">The build callback context.</param>
|
||||
public void OnPreprocessBuild(BuildCallbackContext ctx)
|
||||
#else
|
||||
/// <param name="report">The build report.</param>
|
||||
public void OnPreprocessBuild(BuildReport report)
|
||||
#endif
|
||||
{
|
||||
// Purge any leftovers from a previous, interrupted build before anything else in this
|
||||
// method — every exit path below (no config, disabled, not a development build,
|
||||
// validation failure) must start from a clean slate. OnPostprocessBuild is not invoked
|
||||
// for a failed/cancelled build, so it can never be relied on for this;
|
||||
// this purge is the only mechanism that actually guarantees it. Without it, a stale
|
||||
// *enabled* asset left by an earlier interrupted build would still get packaged into a
|
||||
// build that is supposed to have Pipeline disabled.
|
||||
DeleteGeneratedAssetsIfPresent();
|
||||
|
||||
// Integrity gate: fail the build if a bundled Roslyn DLL was swapped or modified.
|
||||
VerifyBundledChecksums();
|
||||
|
||||
var config = RuntimePipelineConfig.Load();
|
||||
if (config == null)
|
||||
{
|
||||
Debug.LogWarning("Pipeline: No RuntimePipelineConfig asset found (Project Settings > Pipeline > Runtime). Pipeline will be disabled in Player builds.");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (!config.enableInBuilds)
|
||||
{
|
||||
Debug.LogWarning("Pipeline: RuntimePipelineConfig found, but enableInBuilds = false. Pipeline will be disabled in Player builds.");
|
||||
return;
|
||||
}
|
||||
|
||||
// A build that will not contain the Pipeline assemblies cannot read the generated
|
||||
// Resources assets, so baking them would ship dead, machine-specific data.
|
||||
if (!PipelineIsInBuild())
|
||||
{
|
||||
Debug.Log("Pipeline: enableInBuilds is on, but this build is neither a Development " +
|
||||
$"Build nor defines {EnableSymbol}, so the Pipeline assemblies and settings are " +
|
||||
"excluded from it.");
|
||||
return;
|
||||
}
|
||||
|
||||
var validationResult = config.Validate();
|
||||
if (!validationResult.IsValid)
|
||||
{
|
||||
throw new BuildFailedException($"Pipeline: Runtime configuration validation failed: {validationResult.Message}");
|
||||
}
|
||||
|
||||
if (validationResult.Level == "warning")
|
||||
{
|
||||
Debug.LogWarning($"Pipeline: Runtime configuration warning: {validationResult.Message}");
|
||||
}
|
||||
|
||||
// Push signing: ensure the project key exists and bake its public half into the
|
||||
// build info, so the player rejects any code-reload push not signed by the matching
|
||||
// private key. Log the fingerprint so a later key mismatch is diagnosable from the
|
||||
// build log alone.
|
||||
var privateKey = PushSigningKey.LoadOrCreate(PushSigningKey.DefaultDirectory, out var keyCreated);
|
||||
var pushPublicKey = Unity.Pipeline.CodeReload.PushEnvelope.DerivePublicKey(privateKey);
|
||||
Debug.Log($"Pipeline: push-signing key {(keyCreated ? "GENERATED" : "loaded")} " +
|
||||
$"(fingerprint {Unity.Pipeline.CodeReload.PushEnvelope.Fingerprint(pushPublicKey)}, " +
|
||||
$"Library/Pipeline/{PushSigningKey.KeyFileName}). This build only accepts pushes signed with it.");
|
||||
|
||||
WriteConfigAsset(config);
|
||||
WriteBuildInfoAsset(pushPublicKey);
|
||||
|
||||
Debug.Log("Pipeline: Runtime server ENABLED in build.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
UnityEngine.Object.DestroyImmediate(config);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether this build will contain the Pipeline runtime assemblies, i.e. whether it satisfies
|
||||
/// their <c>UNITY_EDITOR || DEVELOPMENT_BUILD || ENABLE_RUNTIME_PIPELINE</c> define
|
||||
/// constraint. A scripted build (the <c>build</c> command, which calls BuildPlayer with
|
||||
/// <see cref="BuildOptions.Development"/>) is a development build even when the Build
|
||||
/// Settings toggle is off, so the command's active options count too.
|
||||
/// </summary>
|
||||
private static bool PipelineIsInBuild()
|
||||
{
|
||||
if (EditorUserBuildSettings.development)
|
||||
return true;
|
||||
|
||||
if (Commands.Build.BuildCommand.ActiveScriptedBuildOptions is { } scriptedOptions
|
||||
&& (scriptedOptions & BuildOptions.Development) != 0)
|
||||
return true;
|
||||
|
||||
var group = BuildPipeline.GetBuildTargetGroup(EditorUserBuildSettings.activeBuildTarget);
|
||||
var defines = PlayerSettings.GetScriptingDefineSymbols(NamedBuildTarget.FromBuildTargetGroup(group));
|
||||
return defines != null && defines.Split(';').Any(d => d.Trim() == EnableSymbol);
|
||||
}
|
||||
|
||||
public void OnPostprocessBuild(BuildReport report)
|
||||
{
|
||||
DeleteGeneratedAssetsIfPresent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Absolute roots considered in-scope for runtime code reload: the project's Assets folder
|
||||
/// plus the resolved location of every package loaded into the project (a local package may
|
||||
/// live anywhere on disk, not only under Packages).
|
||||
/// </summary>
|
||||
/// <returns>Absolute paths of the project's Assets folder and every loaded package.</returns>
|
||||
public static List<string> CollectProjectRoots()
|
||||
{
|
||||
var roots = new List<string> { Path.GetFullPath(Application.dataPath) };
|
||||
|
||||
foreach (var package in UnityEditor.PackageManager.PackageInfo.GetAllRegisteredPackages())
|
||||
{
|
||||
if (!string.IsNullOrEmpty(package.resolvedPath))
|
||||
{
|
||||
roots.Add(Path.GetFullPath(package.resolvedPath));
|
||||
}
|
||||
}
|
||||
|
||||
return roots;
|
||||
}
|
||||
|
||||
private static void WriteConfigAsset(RuntimePipelineConfig sourceConfig)
|
||||
{
|
||||
var folder = Path.GetDirectoryName(ConfigAssetPath).Replace('\\', '/');
|
||||
CreateFolderRecursive(folder);
|
||||
|
||||
// No DeleteGeneratedAssetIfPresent() call here: OnPreprocessBuild already purged both
|
||||
// paths before any of this method's caller ran, so this path is guaranteed clear.
|
||||
|
||||
var baked = ScriptableObject.CreateInstance<RuntimePipelineConfig>();
|
||||
baked.enableInBuilds = sourceConfig.enableInBuilds;
|
||||
baked.port = sourceConfig.port;
|
||||
baked.requestTimeoutMs = sourceConfig.requestTimeoutMs;
|
||||
baked.enableAuditLogging = sourceConfig.enableAuditLogging;
|
||||
baked.autoStart = sourceConfig.autoStart;
|
||||
baked.maxWorkItemsPerFrame = sourceConfig.maxWorkItemsPerFrame;
|
||||
AssetDatabase.CreateAsset(baked, ConfigAssetPath);
|
||||
AssetDatabase.SaveAssets();
|
||||
}
|
||||
|
||||
private static void WriteBuildInfoAsset(string pushPublicKey)
|
||||
{
|
||||
var folder = Path.GetDirectoryName(BuildInfoAssetPath).Replace('\\', '/');
|
||||
CreateFolderRecursive(folder);
|
||||
|
||||
// No DeleteGeneratedAssetIfPresent() call here: OnPreprocessBuild already purged both
|
||||
// paths before any of this method's caller ran, so this path is guaranteed clear.
|
||||
|
||||
var buildInfo = ScriptableObject.CreateInstance<RuntimePipelineBuildInfo>();
|
||||
buildInfo.allowedReloadRoots = CollectProjectRoots();
|
||||
buildInfo.pushPublicKey = pushPublicKey ?? "";
|
||||
AssetDatabase.CreateAsset(buildInfo, BuildInfoAssetPath);
|
||||
AssetDatabase.SaveAssets();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Delete both transient build-only assets if either is present, however they got there —
|
||||
/// baked by a previous build (normal cleanup) or left behind by one that never reached
|
||||
/// OnPostprocessBuild (crash, force-quit, cancelled build).
|
||||
/// </summary>
|
||||
private static void DeleteGeneratedAssetsIfPresent()
|
||||
{
|
||||
DeleteGeneratedAssetIfPresent(ConfigAssetPath);
|
||||
DeleteGeneratedAssetIfPresent(BuildInfoAssetPath);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Untyped load + a raw file-existence fallback, deliberately not a typed
|
||||
/// AssetDatabase.LoadAssetAtPath<T>: a typed load returns null — leaving a stale file
|
||||
/// untouched on disk, inside Resources, where a Player build would still package it — for
|
||||
/// any asset whose script reference broke after a package upgrade, or that was only
|
||||
/// partially written by the very crash that left it behind. Both are exactly the kind of
|
||||
/// leftover this cleanup exists to catch.
|
||||
/// </summary>
|
||||
private static void DeleteGeneratedAssetIfPresent(string assetPath)
|
||||
{
|
||||
if (AssetDatabase.LoadAssetAtPath<UnityEngine.Object>(assetPath) != null || File.Exists(assetPath))
|
||||
AssetDatabase.DeleteAsset(assetPath);
|
||||
}
|
||||
|
||||
private static void CreateFolderRecursive(string folder)
|
||||
{
|
||||
var parts = folder.Split('/');
|
||||
var current = parts[0];
|
||||
for (var i = 1; i < parts.Length; i++)
|
||||
{
|
||||
var next = current + "/" + parts[i];
|
||||
if (!AssetDatabase.IsValidFolder(next))
|
||||
AssetDatabase.CreateFolder(current, parts[i]);
|
||||
current = next;
|
||||
}
|
||||
}
|
||||
|
||||
// Relative location of the bundled Roslyn DLLs + their integrity manifest within the package.
|
||||
private const string CodeAnalysisRelDir = "Runtime/Plugins/CodeAnalysis";
|
||||
private const string ChecksumsFileName = "CHECKSUMS";
|
||||
|
||||
/// <summary>
|
||||
/// Verify the bundled Roslyn DLLs against the committed CHECKSUMS manifest, locating them
|
||||
/// relative to this package on disk. Throws <see cref="BuildFailedException"/> on any
|
||||
/// mismatch so a tampered/swapped DLL cannot be built into a player.
|
||||
/// </summary>
|
||||
public static void VerifyBundledChecksums()
|
||||
{
|
||||
if (!TryGetBundledCodeAnalysisPaths(out var codeAnalysisDir, out var checksumsPath))
|
||||
{
|
||||
throw new BuildFailedException(
|
||||
"Pipeline: could not locate the com.unity.pipeline package on disk to verify " +
|
||||
"bundled Roslyn DLL integrity. Aborting build.");
|
||||
}
|
||||
|
||||
var error = VerifyChecksums(codeAnalysisDir, checksumsPath);
|
||||
if (error != null)
|
||||
{
|
||||
throw new BuildFailedException($"Pipeline: bundled Roslyn DLL integrity check failed. {error}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolve the bundled Roslyn DLL folder and its CHECKSUMS manifest from this package's
|
||||
/// location on disk. Returns false when the package cannot be located, in which case both
|
||||
/// out parameters are null.
|
||||
/// </summary>
|
||||
/// <param name="codeAnalysisDir">Directory holding the bundled Roslyn DLLs.</param>
|
||||
/// <param name="checksumsPath">Path to the CHECKSUMS manifest inside that directory.</param>
|
||||
/// <returns>True when the package was located and both paths were resolved.</returns>
|
||||
public static bool TryGetBundledCodeAnalysisPaths(out string codeAnalysisDir, out string checksumsPath)
|
||||
{
|
||||
codeAnalysisDir = null;
|
||||
checksumsPath = null;
|
||||
|
||||
var packageInfo = UnityEditor.PackageManager.PackageInfo.FindForAssembly(
|
||||
typeof(PipelineRuntimeBuildProcessor).Assembly);
|
||||
|
||||
if (packageInfo == null || string.IsNullOrEmpty(packageInfo.resolvedPath))
|
||||
return false;
|
||||
|
||||
codeAnalysisDir = Path.Combine(packageInfo.resolvedPath, CodeAnalysisRelDir);
|
||||
checksumsPath = Path.Combine(codeAnalysisDir, ChecksumsFileName);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Core, side-effect-free integrity check (so it is directly unit-testable). Returns null
|
||||
/// when every DLL listed in <paramref name="checksumsPath"/> exists under
|
||||
/// <paramref name="codeAnalysisDir"/> with a matching SHA-256 and no unlisted DLL is present;
|
||||
/// otherwise returns a human-readable error describing the first problem found.
|
||||
/// </summary>
|
||||
/// <param name="codeAnalysisDir">Directory containing the bundled Roslyn DLLs.</param>
|
||||
/// <param name="checksumsPath">Path to the CHECKSUMS manifest.</param>
|
||||
/// <returns>Null if every DLL matches; otherwise a human-readable description of the first problem found.</returns>
|
||||
public static string VerifyChecksums(string codeAnalysisDir, string checksumsPath)
|
||||
{
|
||||
if (!Directory.Exists(codeAnalysisDir))
|
||||
return $"DLL directory not found: {codeAnalysisDir}";
|
||||
if (!File.Exists(checksumsPath))
|
||||
return $"CHECKSUMS manifest not found: {checksumsPath}";
|
||||
|
||||
var expected = ParseChecksums(checksumsPath);
|
||||
if (expected.Count == 0)
|
||||
return $"CHECKSUMS manifest has no entries: {checksumsPath}";
|
||||
|
||||
// Every listed DLL must exist and match.
|
||||
foreach (var entry in expected)
|
||||
{
|
||||
var dllPath = Path.Combine(codeAnalysisDir, entry.Key);
|
||||
if (!File.Exists(dllPath))
|
||||
return $"listed DLL is missing: {entry.Key}";
|
||||
|
||||
var actual = ComputeSha256(dllPath);
|
||||
if (!string.Equals(actual, entry.Value, StringComparison.OrdinalIgnoreCase))
|
||||
return $"hash mismatch for {entry.Key} (expected {entry.Value}, got {actual})";
|
||||
}
|
||||
|
||||
// No unlisted DLL may sit alongside them (guards against an injected extra assembly).
|
||||
foreach (var dllPath in Directory.GetFiles(codeAnalysisDir, "*.dll"))
|
||||
{
|
||||
var name = Path.GetFileName(dllPath);
|
||||
if (!expected.ContainsKey(name))
|
||||
return $"unexpected DLL not listed in CHECKSUMS: {name}";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>SHA-256 of a file as a lowercase hex string.</summary>
|
||||
/// <param name="filePath">The file to hash.</param>
|
||||
/// <returns>The lowercase hex-encoded SHA-256 hash.</returns>
|
||||
public static string ComputeSha256(string filePath)
|
||||
{
|
||||
using (var sha = SHA256.Create())
|
||||
using (var stream = File.OpenRead(filePath))
|
||||
{
|
||||
var hash = sha.ComputeHash(stream);
|
||||
var sb = new StringBuilder(hash.Length * 2);
|
||||
foreach (var b in hash)
|
||||
sb.Append(b.ToString("x2"));
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
// Parse "<sha256> <filename> # comment" lines, skipping blanks and '#' comment lines.
|
||||
private static Dictionary<string, string> ParseChecksums(string checksumsPath)
|
||||
{
|
||||
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var raw in File.ReadAllLines(checksumsPath))
|
||||
{
|
||||
var line = raw.Trim();
|
||||
if (line.Length == 0 || line.StartsWith("#"))
|
||||
continue;
|
||||
|
||||
var tokens = line.Split(new[] { ' ', '\t' }, StringSplitOptions.RemoveEmptyEntries);
|
||||
if (tokens.Length < 2)
|
||||
continue;
|
||||
|
||||
result[tokens[1]] = tokens[0];
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 50df0b5c45692fb4a81122ca6eec4709
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
Reference in New Issue
Block a user