Initial commit: AIGame Unity client with URP, HybridCLR, YooAsset and tooling.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+279
@@ -0,0 +1,279 @@
|
||||
using System.Xml;
|
||||
using NUnit.Framework;
|
||||
using Unity.Pipeline.Editor.BuildProcessors;
|
||||
using Unity.Pipeline.CodeReload;
|
||||
|
||||
namespace Unity.Pipeline.Tests.Editor
|
||||
{
|
||||
/// <summary>
|
||||
/// Tests for the generated link.xml that preserves the interpreter's host-binding surface under
|
||||
/// IL2CPP managed stripping. The build-time contribution runs only inside a real player build, but
|
||||
/// the XML generator is pure and editor-callable, so its output is verified directly here.
|
||||
/// </summary>
|
||||
class CodeReloadLinkXmlGeneratorTests
|
||||
{
|
||||
[Test]
|
||||
public void GenerateLinkXml_IsWellFormedLinkerDocument()
|
||||
{
|
||||
var xml = CodeReloadLinkXmlGenerator.GenerateLinkXml();
|
||||
|
||||
var doc = new XmlDocument();
|
||||
Assert.DoesNotThrow(() => doc.LoadXml(xml), "Generated link.xml must be well-formed XML.");
|
||||
Assert.AreEqual("linker", doc.DocumentElement.Name, "Root element must be <linker>.");
|
||||
Assert.IsNotEmpty(doc.SelectNodes("/linker/assembly"), "Expected at least one <assembly> group.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GenerateLinkXml_PreservesUnityEngineColor_TheGreenYellowCase()
|
||||
{
|
||||
// Color.greenYellow exists in the Unity API but is stripped from an IL2CPP player when the
|
||||
// built game never references it. AllowType<Color>() registers the whole type, so preserving
|
||||
// Color with preserve="all" is exactly what keeps greenYellow (and every other member)
|
||||
// callable from reloaded code.
|
||||
var xml = CodeReloadLinkXmlGenerator.GenerateLinkXml();
|
||||
|
||||
var doc = new XmlDocument();
|
||||
doc.LoadXml(xml);
|
||||
|
||||
var colorNode = doc.SelectSingleNode(
|
||||
"/linker/assembly/type[@fullname='UnityEngine.Color']");
|
||||
Assert.IsNotNull(colorNode, "UnityEngine.Color must be preserved so its members survive stripping.");
|
||||
Assert.AreEqual("all", colorNode.Attributes["preserve"]?.Value,
|
||||
"Color must be preserved with preserve=\"all\" to keep members like greenYellow.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GenerateLinkXml_GroupsColorUnderCoreModule()
|
||||
{
|
||||
var xml = CodeReloadLinkXmlGenerator.GenerateLinkXml();
|
||||
|
||||
var doc = new XmlDocument();
|
||||
doc.LoadXml(xml);
|
||||
|
||||
var node = doc.SelectSingleNode(
|
||||
"/linker/assembly[@fullname='UnityEngine.CoreModule']/type[@fullname='UnityEngine.Color']");
|
||||
Assert.IsNotNull(node, "Color should be grouped under its owning assembly, UnityEngine.CoreModule.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GenerateLinkXml_WithPlayerAssemblies_PreservesUserAndEngineModulesWholesale()
|
||||
{
|
||||
// Demand-time auto-bind lets reloaded code call into any user or UnityEngine type, so the
|
||||
// dev build must preserve those assemblies wholesale; BCL and Unity.* package assemblies
|
||||
// stay per-type (the auto-bind policy declines the BCL, and Unity.* packages are AOT-heavy).
|
||||
var xml = CodeReloadLinkXmlGenerator.GenerateLinkXml(
|
||||
new[] { "Assembly-CSharp", "MyGame.Gameplay", "UnityEngine.CoreModule", "UnityEngine.AIModule",
|
||||
"System.Core", "mscorlib", "Unity.Burst", "Unity.Pipeline" },
|
||||
out _);
|
||||
|
||||
var doc = new XmlDocument();
|
||||
doc.LoadXml(xml);
|
||||
|
||||
foreach (var expected in new[]
|
||||
{ "Assembly-CSharp", "MyGame.Gameplay", "UnityEngine.CoreModule", "UnityEngine.AIModule" })
|
||||
{
|
||||
var node = doc.SelectSingleNode($"/linker/assembly[@fullname='{expected}']");
|
||||
Assert.IsNotNull(node, $"{expected} must be preserved wholesale.");
|
||||
Assert.AreEqual("all", node.Attributes["preserve"]?.Value,
|
||||
$"{expected} must carry assembly-level preserve=\"all\".");
|
||||
Assert.AreEqual(0, node.ChildNodes.Count,
|
||||
$"{expected} is wholesale — per-type entries are subsumed.");
|
||||
}
|
||||
|
||||
foreach (var excluded in new[] { "System.Core", "mscorlib", "Unity.Burst", "Unity.Pipeline" })
|
||||
{
|
||||
var node = doc.SelectSingleNode($"/linker/assembly[@fullname='{excluded}']");
|
||||
Assert.IsTrue(node == null || node.Attributes["preserve"] == null,
|
||||
$"{excluded} must not be preserved wholesale.");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GenerateLinkXml_WithoutPlayerAssemblies_OnlyInterpreterEngineIsWholesale()
|
||||
{
|
||||
// Null player-assembly set (unreadable linker input, or the parameterless test entry
|
||||
// point) degrades to per-type host-surface preservation plus the always-on interpreter
|
||||
// engine assemblies — nothing else may be preserved wholesale.
|
||||
var xml = CodeReloadLinkXmlGenerator.GenerateLinkXml();
|
||||
|
||||
var doc = new XmlDocument();
|
||||
doc.LoadXml(xml);
|
||||
|
||||
foreach (XmlNode asm in doc.SelectNodes("/linker/assembly"))
|
||||
{
|
||||
var name = asm.Attributes["fullname"]?.Value;
|
||||
if (name == "Unity.Pipeline.IlInterpreter" ||
|
||||
name == "UnityPipeline.System.Reflection.Metadata" ||
|
||||
name == "UnityPipeline.System.Collections.Immutable")
|
||||
continue;
|
||||
Assert.IsNull(asm.Attributes["preserve"],
|
||||
$"without a player assembly set '{name}' may not be preserved wholesale");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GenerateLinkXml_AlwaysPreservesInterpreterEngineAssembliesWholesale()
|
||||
{
|
||||
// The interpreter parses pushed override IL via System.Reflection.Metadata and resolves
|
||||
// host members by name; under IL2CPP stripping those paths look unused. This preservation
|
||||
// used to live in a static Runtime/link.xml, but that file applied to release builds too
|
||||
// (real size cost for machinery that is compiled out of release players). The generator
|
||||
// only contributes to development builds, so it now owns this set — with or without a
|
||||
// player-assembly list.
|
||||
foreach (var xml in new[]
|
||||
{
|
||||
CodeReloadLinkXmlGenerator.GenerateLinkXml(),
|
||||
CodeReloadLinkXmlGenerator.GenerateLinkXml(new[] { "Assembly-CSharp" }, out _),
|
||||
})
|
||||
{
|
||||
var doc = new XmlDocument();
|
||||
doc.LoadXml(xml);
|
||||
|
||||
foreach (var expected in new[]
|
||||
{ "Unity.Pipeline.IlInterpreter", "UnityPipeline.System.Reflection.Metadata", "UnityPipeline.System.Collections.Immutable" })
|
||||
{
|
||||
var node = doc.SelectSingleNode($"/linker/assembly[@fullname='{expected}']");
|
||||
Assert.IsNotNull(node, $"{expected} must be preserved so the interpreter survives stripping.");
|
||||
Assert.AreEqual("all", node.Attributes["preserve"]?.Value,
|
||||
$"{expected} must carry assembly-level preserve=\"all\".");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GenerateLinkXml_UnityPrefixedGameAssemblies_AreTreatedAsUserCode()
|
||||
{
|
||||
// A bare "Unity"/"System" prefix would misclassify game assemblies that merely start
|
||||
// with the word — "UnityChanDemo", "SystemsRuntime" — as engine/BCL code, silently
|
||||
// dropping them from wholesale preservation and from the [CodeReload] target scan
|
||||
// (both share IsNonUserAssembly). Only real engine/BCL names are non-user.
|
||||
var xml = CodeReloadLinkXmlGenerator.GenerateLinkXml(
|
||||
new[] { "UnityChanDemo", "SystemsRuntime", "Unity", "Unity.Burst", "System.Core" },
|
||||
out _);
|
||||
|
||||
var doc = new XmlDocument();
|
||||
doc.LoadXml(xml);
|
||||
|
||||
foreach (var user in new[] { "UnityChanDemo", "SystemsRuntime" })
|
||||
{
|
||||
var node = doc.SelectSingleNode($"/linker/assembly[@fullname='{user}']");
|
||||
Assert.IsNotNull(node, $"{user} is user code and must be preserved wholesale.");
|
||||
Assert.AreEqual("all", node.Attributes["preserve"]?.Value,
|
||||
$"{user} must carry assembly-level preserve=\"all\".");
|
||||
}
|
||||
|
||||
foreach (var engine in new[] { "Unity", "Unity.Burst", "System.Core" })
|
||||
{
|
||||
var node = doc.SelectSingleNode($"/linker/assembly[@fullname='{engine}']");
|
||||
Assert.IsTrue(node == null || node.Attributes["preserve"] == null,
|
||||
$"{engine} is engine/BCL code and must not be preserved wholesale.");
|
||||
}
|
||||
}
|
||||
|
||||
// Rescue probe: this test assembly is Unity.Pipeline.Tests.Editor — it matches the
|
||||
// engine-assembly name pattern, yet carries this [CodeReload] member, standing in for a
|
||||
// game assembly named e.g. "Unity.MyGame".
|
||||
class EngineNamedAssemblyProbe { [CodeReload] void Tick() { } }
|
||||
|
||||
[Test]
|
||||
public void GenerateLinkXml_FindsCodeReloadTargets_InEngineNamedAssemblies()
|
||||
{
|
||||
// The [CodeReload] attribute must be authoritative for the target scan; the engine-name
|
||||
// check is only a fast path. [CodeReload] lives in Unity.Pipeline, so an engine-named
|
||||
// assembly that references Unity.Pipeline can carry targets and must still be scanned —
|
||||
// otherwise its private state is stripped and reload fails on device only.
|
||||
var xml = CodeReloadLinkXmlGenerator.GenerateLinkXml();
|
||||
|
||||
var expected = typeof(EngineNamedAssemblyProbe).FullName.Replace('+', '/');
|
||||
StringAssert.Contains($"<type fullname=\"{expected}\" preserve=\"all\"/>", xml,
|
||||
"[CodeReload] targets in an engine-named assembly must still be preserved.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GenerateLinkXml_WithPlayerAssemblies_DropsReloadTargetsNotStagedForTheBuild()
|
||||
{
|
||||
// The AppDomain scan sees editor-only and test assemblies that are not part of the
|
||||
// player build; their [CodeReload] entries are noise in a game build's link.xml. When
|
||||
// the staged set is known, only reload targets actually in the build are emitted —
|
||||
// which also covers user test asmdefs. A test-player build stages its test
|
||||
// assemblies, so fixtures keep their entries exactly where they matter.
|
||||
var probe = typeof(EngineNamedAssemblyProbe).FullName.Replace('+', '/');
|
||||
|
||||
var xml = CodeReloadLinkXmlGenerator.GenerateLinkXml(new[] { "Assembly-CSharp" }, out _);
|
||||
StringAssert.DoesNotContain(probe, xml,
|
||||
"an unstaged assembly must not contribute reload-target entries");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GenerateLinkXml_PromotesStagedEngineNamedAssembliesCarryingCodeReloadTargets()
|
||||
{
|
||||
// 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 the
|
||||
// assembly strippable, so demand-time auto-bind could hit stripped members on device
|
||||
// only; instead it is preserved wholesale like any other user assembly. This test
|
||||
// assembly (Unity.Pipeline.Tests.Editor, carrying the probe above) plays that role.
|
||||
var xml = CodeReloadLinkXmlGenerator.GenerateLinkXml(
|
||||
new[] { "Assembly-CSharp", "Unity.Pipeline.Tests.Editor" }, out _);
|
||||
|
||||
var doc = new XmlDocument();
|
||||
doc.LoadXml(xml);
|
||||
|
||||
var node = doc.SelectSingleNode("/linker/assembly[@fullname='Unity.Pipeline.Tests.Editor']");
|
||||
Assert.IsNotNull(node, "the promoted assembly must appear in the link.xml");
|
||||
Assert.AreEqual("all", node.Attributes["preserve"]?.Value,
|
||||
"the promoted assembly must carry assembly-level preserve=\"all\"");
|
||||
Assert.AreEqual(0, node.ChildNodes.Count,
|
||||
"wholesale preservation subsumes the per-type [CodeReload] entries");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CollectPlayerAssemblyNames_WithEmptyInputDir_RecoversUnityEngineModulesFromTheEditor()
|
||||
{
|
||||
// The linker input directory is empty at IUnityLinkerProcessor time on some build backends
|
||||
// (the symptom that stripped UITK ctors on device: a live-added `new Slider()` couldn't
|
||||
// resolve because UnityEngine.UIElementsModule wasn't preserved wholesale). The collector
|
||||
// must fall back to the editor's own assembly view so wholesale preservation still fires.
|
||||
var names = CodeReloadLinkXmlGenerator.CollectPlayerAssemblyNames("/no/such/linker/input/dir");
|
||||
|
||||
Assert.IsNotNull(names, "an empty input dir must still yield the editor's player-assembly view");
|
||||
CollectionAssert.Contains(names, "UnityEngine.UIElementsModule",
|
||||
"the UnityEngine module carrying Slider must be recovered so its ctors survive stripping");
|
||||
CollectionAssert.Contains(names, "UnityEngine.CoreModule",
|
||||
"core UnityEngine modules must be recovered from the loaded editor assemblies");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CollectPlayerAssemblyNames_FeedsWholesalePreservationOfUIElements()
|
||||
{
|
||||
// End-to-end for the Slider fix: the recovered assembly set, fed through the generator,
|
||||
// must preserve UnityEngine.UIElementsModule wholesale (assembly-level preserve="all"),
|
||||
// which is what keeps Slider's constructors callable from a reloaded body on device.
|
||||
var names = CodeReloadLinkXmlGenerator.CollectPlayerAssemblyNames(null);
|
||||
var xml = CodeReloadLinkXmlGenerator.GenerateLinkXml(names, out _);
|
||||
|
||||
var doc = new XmlDocument();
|
||||
doc.LoadXml(xml);
|
||||
|
||||
var node = doc.SelectSingleNode("/linker/assembly[@fullname='UnityEngine.UIElementsModule']");
|
||||
Assert.IsNotNull(node, "UnityEngine.UIElementsModule must be preserved so Slider's ctors survive.");
|
||||
Assert.AreEqual("all", node.Attributes["preserve"]?.Value,
|
||||
"UnityEngine.UIElementsModule must carry assembly-level preserve=\"all\".");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GenerateLinkXml_EveryTypeIsPreservedAll()
|
||||
{
|
||||
var xml = CodeReloadLinkXmlGenerator.GenerateLinkXml();
|
||||
|
||||
var doc = new XmlDocument();
|
||||
doc.LoadXml(xml);
|
||||
|
||||
foreach (XmlNode type in doc.SelectNodes("/linker/assembly/type"))
|
||||
{
|
||||
Assert.AreEqual("all", type.Attributes["preserve"]?.Value,
|
||||
$"Type {type.Attributes["fullname"]?.Value} must use preserve=\"all\".");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user