diff --git a/plug-ins/Apple.BackgroundAssets/Apple.BackgroundAssets_Unity/Assets/Apple.BackgroundAssets/Runtime/Error.cs b/plug-ins/Apple.BackgroundAssets/Apple.BackgroundAssets_Unity/Assets/Apple.BackgroundAssets/Runtime/Error.cs
index e2c0a171..5540908a 100644
--- a/plug-ins/Apple.BackgroundAssets/Apple.BackgroundAssets_Unity/Assets/Apple.BackgroundAssets/Runtime/Error.cs
+++ b/plug-ins/Apple.BackgroundAssets/Apple.BackgroundAssets_Unity/Assets/Apple.BackgroundAssets/Runtime/Error.cs
@@ -10,8 +10,13 @@ public class Error : Exception {
internal struct baw_err {
internal IntPtr description;
-
- bool _static;
+
+ // C '_Bool', held as a byte rather than a bool so that baw_err stays blittable. A bool
+ // would make every struct embedding this one non-blittable, and IL2CPP would then marshal
+ // those field by field — which silently corrupts the unions that overlay baw_err with a
+ // success value. [MarshalAs] does not help: it sets the width of the conversion, it does
+ // not remove it. See Tests/TestInteropBlittability.cs.
+ byte _static;
}
diff --git a/plug-ins/Apple.BackgroundAssets/Apple.BackgroundAssets_Unity/Assets/Apple.BackgroundAssets/Tests.meta b/plug-ins/Apple.BackgroundAssets/Apple.BackgroundAssets_Unity/Assets/Apple.BackgroundAssets/Tests.meta
new file mode 100644
index 00000000..2f4898ba
--- /dev/null
+++ b/plug-ins/Apple.BackgroundAssets/Apple.BackgroundAssets_Unity/Assets/Apple.BackgroundAssets/Tests.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: d49242b181e34b2db4d73f857c57623d
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/plug-ins/Apple.BackgroundAssets/Apple.BackgroundAssets_Unity/Assets/Apple.BackgroundAssets/Tests/Apple.BackgroundAssets.Tests.asmdef b/plug-ins/Apple.BackgroundAssets/Apple.BackgroundAssets_Unity/Assets/Apple.BackgroundAssets/Tests/Apple.BackgroundAssets.Tests.asmdef
new file mode 100644
index 00000000..d3ccf43b
--- /dev/null
+++ b/plug-ins/Apple.BackgroundAssets/Apple.BackgroundAssets_Unity/Assets/Apple.BackgroundAssets/Tests/Apple.BackgroundAssets.Tests.asmdef
@@ -0,0 +1,24 @@
+{
+ "name": "Apple.BackgroundAssets.Tests",
+ "rootNamespace": "",
+ "references": [
+ "UnityEngine.TestRunner",
+ "UnityEditor.TestRunner",
+ "Apple.BackgroundAssets",
+ "Apple.Core",
+ "Apple.Core.Tests"
+ ],
+ "includePlatforms": [],
+ "excludePlatforms": [],
+ "allowUnsafeCode": false,
+ "overrideReferences": true,
+ "precompiledReferences": [
+ "nunit.framework.dll"
+ ],
+ "autoReferenced": false,
+ "defineConstraints": [
+ "UNITY_INCLUDE_TESTS"
+ ],
+ "versionDefines": [],
+ "noEngineReferences": false
+}
diff --git a/plug-ins/Apple.BackgroundAssets/Apple.BackgroundAssets_Unity/Assets/Apple.BackgroundAssets/Tests/Apple.BackgroundAssets.Tests.asmdef.meta b/plug-ins/Apple.BackgroundAssets/Apple.BackgroundAssets_Unity/Assets/Apple.BackgroundAssets/Tests/Apple.BackgroundAssets.Tests.asmdef.meta
new file mode 100644
index 00000000..b5a41bf2
--- /dev/null
+++ b/plug-ins/Apple.BackgroundAssets/Apple.BackgroundAssets_Unity/Assets/Apple.BackgroundAssets/Tests/Apple.BackgroundAssets.Tests.asmdef.meta
@@ -0,0 +1,7 @@
+fileFormatVersion: 2
+guid: 9edb8439e0e84443b0a88082e81d6d28
+AssemblyDefinitionImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/plug-ins/Apple.BackgroundAssets/Apple.BackgroundAssets_Unity/Assets/Apple.BackgroundAssets/Tests/TestInteropBlittability.cs b/plug-ins/Apple.BackgroundAssets/Apple.BackgroundAssets_Unity/Assets/Apple.BackgroundAssets/Tests/TestInteropBlittability.cs
new file mode 100644
index 00000000..451b5278
--- /dev/null
+++ b/plug-ins/Apple.BackgroundAssets/Apple.BackgroundAssets_Unity/Assets/Apple.BackgroundAssets/Tests/TestInteropBlittability.cs
@@ -0,0 +1,266 @@
+using System;
+using System.Collections.Generic;
+using System.Reflection;
+using System.Runtime.CompilerServices;
+using System.Runtime.InteropServices;
+
+using NUnit.Framework;
+
+namespace Apple.BackgroundAssets.Tests {
+
+ ///
+ /// Layout guards for the structs and signatures that cross the managed/native boundary.
+ ///
+ /// These bindings are written so that IL2CPP hands every struct to the native wrapper exactly as
+ /// it is laid out in managed memory. That only holds while the structs stay blittable. The moment
+ /// one field isn't, IL2CPP quietly switches the whole containing struct onto the marshalled path:
+ /// it emits a second "_marshaled_pinvoke" version of the type plus functions that convert between
+ /// the two a field at a time. Nothing warns about it, and for the LayoutKind.Explicit unions here
+ /// that conversion is actively wrong — see TestExplicitLayoutStructsAreBlittable.
+ ///
+ /// Blittability is invisible in source, so these tests read it back off the compiled assembly.
+ ///
+ public class TestInteropBlittability {
+
+ /// The assembly whose interop surface is under test.
+ static Assembly BackgroundAssets => typeof(Error).Assembly;
+
+ #region Tests
+
+ ///
+ /// Every struct that appears in a native signature must be blittable.
+ ///
+ /// "Native signature" means a [DllImport] declaration (a call into the wrapper) or the Invoke
+ /// of a delegate the wrapper calls back (a reverse P/Invoke). Those are the only places the
+ /// marshaller inspects a struct, and it inspects the whole field graph, so this walks the graph
+ /// too rather than just the top-level type.
+ ///
+ [Test]
+ public void TestStructsInNativeSignaturesAreBlittable() {
+ Dictionary structs = StructsInNativeSignatures();
+
+ // If discovery silently found nothing, the test below would pass while checking nothing.
+ Assert.IsNotEmpty(structs, "Found no structs in any native signature. Discovery is broken, not the bindings.");
+
+ List failures = new List();
+ foreach (KeyValuePair entry in structs) {
+ if (!IsBlittable(entry.Key, out string reason)) {
+ failures.Add($"{NameOf(entry.Key)} (reached via {entry.Value}): {reason}");
+ }
+ }
+
+ Assert.IsEmpty(failures, Report("These interop structs are not blittable, so IL2CPP will marshal them field by field", failures));
+ }
+
+ ///
+ /// Every union must be blittable.
+ ///
+ /// Marshalling converts a struct one field at a time, which is meaningless for a union: the
+ /// marshaller has no notion of which member is live, so it writes each member in turn to the
+ /// same bytes and the last one wins. baw_assetpackmanifest_res overlays the manifest with an
+ /// error; when a non-blittable bool put it on the marshalled path, the error was copied over
+ /// the manifest and destroyed its asset-pack array pointer, crashing
+ /// AssetPackManifest.GetAssetPack() on iOS 26.
+ ///
+ /// There is no way to marshal a union correctly, so the fix is to never be marshalled. This
+ /// test is deliberately broader than the one above: it covers every explicit-layout struct in
+ /// the assembly, including any not yet wired up to a native call.
+ ///
+ [Test]
+ public void TestExplicitLayoutStructsAreBlittable() {
+ List unions = new List();
+ foreach (Type type in DeclaredTypes()) {
+ if (type.IsValueType && !type.IsEnum && type.StructLayoutAttribute?.Value == LayoutKind.Explicit) {
+ unions.Add(type);
+ }
+ }
+
+ Assert.IsNotEmpty(unions, "Found no explicit-layout structs. Discovery is broken, not the bindings.");
+
+ List failures = new List();
+ foreach (Type union in unions) {
+ if (!IsBlittable(union, out string reason)) {
+ failures.Add($"{NameOf(union)}: {reason}");
+ }
+ }
+
+ Assert.IsEmpty(failures, Report("These overlapping structs are not blittable, so marshalling them will corrupt whichever member is live", failures));
+ }
+
+ #endregion
+
+ #region Blittability
+
+ /// The types a P/Invoke can pass straight through, with no conversion step.
+ static readonly HashSet BlittableTypes = new HashSet {
+ typeof(sbyte), typeof(byte),
+ typeof(short), typeof(ushort),
+ typeof(int), typeof(uint),
+ typeof(long), typeof(ulong),
+ typeof(float), typeof(double),
+ typeof(IntPtr), typeof(UIntPtr)
+ };
+
+ ///
+ /// Reports whether the marshaller can leave alone, and if not, why not.
+ ///
+ static bool IsBlittable(Type type, out string reason) {
+ reason = null;
+
+ // A pointer is an address; the marshaller never looks through it. An enum is always backed
+ // by an integer type.
+ if (type.IsPointer || type.IsEnum) {
+ return true;
+ }
+
+ if (BlittableTypes.Contains(type)) {
+ return true;
+ }
+
+ if (type == typeof(bool)) {
+ reason = "bool is never blittable, and no [MarshalAs] annotation changes that. "
+ + "Use byte for a field holding a C '_Bool'";
+ return false;
+ }
+
+ if (!type.IsValueType || type == typeof(char) || type == typeof(decimal)) {
+ reason = $"{NameOf(type)} is not a blittable type";
+ return false;
+ }
+
+ // A struct is blittable only if its layout is pinned down and every field is blittable too.
+ StructLayoutAttribute layout = type.StructLayoutAttribute;
+ if (layout == null || layout.Value == LayoutKind.Auto) {
+ reason = $"{NameOf(type)} has automatic layout; it needs [StructLayout(LayoutKind.Sequential)] or [StructLayout(LayoutKind.Explicit)]";
+ return false;
+ }
+
+ foreach (FieldInfo field in type.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)) {
+ // [MarshalAs] on a field is a request to convert, which is exactly what must not happen.
+ if (field.GetCustomAttribute() != null) {
+ reason = $"field '{NameOf(type)}.{field.Name}' carries [MarshalAs], which puts the whole struct on the marshalled path";
+ return false;
+ }
+
+ if (!IsBlittable(field.FieldType, out string fieldReason)) {
+ reason = $"field '{NameOf(type)}.{field.Name}': {fieldReason}";
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ #endregion
+
+ #region Discovery
+
+ ///
+ /// Every method the marshaller sees: the [DllImport] declarations that call into the native
+ /// wrapper, and the Invoke of every delegate the wrapper calls back out through.
+ ///
+ static IEnumerable NativeSignatures() {
+ foreach (Type type in DeclaredTypes()) {
+ if (typeof(Delegate).IsAssignableFrom(type)) {
+ MethodInfo invoke = type.GetMethod("Invoke");
+ if (invoke != null) {
+ yield return invoke;
+ }
+ continue;
+ }
+
+ const BindingFlags All = BindingFlags.Static | BindingFlags.Instance
+ | BindingFlags.Public | BindingFlags.NonPublic
+ | BindingFlags.DeclaredOnly;
+
+ foreach (MethodInfo method in type.GetMethods(All)) {
+ // [DllImport] is a pseudo-custom attribute: it lands in the method's metadata flags
+ // rather than the custom attribute table, so test the flag.
+ if ((method.Attributes & MethodAttributes.PinvokeImpl) != 0) {
+ yield return method;
+ }
+ }
+ }
+ }
+
+ ///
+ /// Every struct reachable from a native signature, mapped to the signature that reaches it.
+ ///
+ static Dictionary StructsInNativeSignatures() {
+ Dictionary found = new Dictionary();
+
+ foreach (MethodInfo method in NativeSignatures()) {
+ string site = DescribeSite(method);
+
+ CollectStruct(method.ReturnType, site, found);
+ foreach (ParameterInfo parameter in method.GetParameters()) {
+ CollectStruct(parameter.ParameterType, site, found);
+ }
+ }
+
+ return found;
+ }
+
+ ///
+ /// Records if it is a struct, looking through by-ref, array and pointer
+ /// wrappers first. Strings and other reference types are skipped: a reference type is free to be
+ /// marshalled as a parameter, and unlike a struct field it drags nothing else onto that path.
+ ///
+ static void CollectStruct(Type type, string site, Dictionary found) {
+ while (type.IsByRef || type.IsArray || type.IsPointer) {
+ type = type.GetElementType();
+ }
+
+ if (type == typeof(void) || !type.IsValueType || type.IsEnum || type.IsPrimitive) {
+ return;
+ }
+
+ if (!found.ContainsKey(type)) {
+ found.Add(type, site);
+ }
+ }
+
+ /// The assembly's own types, minus anything the compiler generated for us.
+ static IEnumerable DeclaredTypes() {
+ foreach (Type type in BackgroundAssets.GetTypes()) {
+ // Async methods and iterators compile down to structs holding managed references. They
+ // never reach native code, and they would otherwise dominate the failure list.
+ if (type.IsDefined(typeof(CompilerGeneratedAttribute), inherit: false)) {
+ continue;
+ }
+
+ yield return type;
+ }
+ }
+
+ #endregion
+
+ #region Reporting
+
+ /// A name that includes declaring types, e.g. "AssetPackManifest.baw_assetpackmanifest_res".
+ static string NameOf(Type type) {
+ string name = type.Name;
+ for (Type declaring = type.DeclaringType; declaring != null; declaring = declaring.DeclaringType) {
+ name = declaring.Name + "." + name;
+ }
+
+ return name;
+ }
+
+ static string DescribeSite(MethodInfo method) {
+ Type declaring = method.DeclaringType;
+
+ return typeof(Delegate).IsAssignableFrom(declaring)
+ ? $"callback {NameOf(declaring)}"
+ : $"{NameOf(declaring)}.{method.Name}";
+ }
+
+ static string Report(string headline, List failures) {
+ return $"{headline}:{Environment.NewLine} " + string.Join($"{Environment.NewLine} ", failures);
+ }
+
+ #endregion
+
+ }
+
+}
diff --git a/plug-ins/Apple.BackgroundAssets/Apple.BackgroundAssets_Unity/Assets/Apple.BackgroundAssets/Tests/TestInteropBlittability.cs.meta b/plug-ins/Apple.BackgroundAssets/Apple.BackgroundAssets_Unity/Assets/Apple.BackgroundAssets/Tests/TestInteropBlittability.cs.meta
new file mode 100644
index 00000000..b76c3755
--- /dev/null
+++ b/plug-ins/Apple.BackgroundAssets/Apple.BackgroundAssets_Unity/Assets/Apple.BackgroundAssets/Tests/TestInteropBlittability.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 0ad05d704f504e0dbe11dce6e450bf7d
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/plug-ins/Apple.BackgroundAssets/Apple.BackgroundAssets_Unity/Packages/manifest.json b/plug-ins/Apple.BackgroundAssets/Apple.BackgroundAssets_Unity/Packages/manifest.json
index 7d5a2b9a..6eeb06bb 100644
--- a/plug-ins/Apple.BackgroundAssets/Apple.BackgroundAssets_Unity/Packages/manifest.json
+++ b/plug-ins/Apple.BackgroundAssets/Apple.BackgroundAssets_Unity/Packages/manifest.json
@@ -46,5 +46,8 @@
"com.unity.modules.video": "1.0.0",
"com.unity.modules.wind": "1.0.0",
"com.unity.modules.xr": "1.0.0"
- }
+ },
+ "testables": [
+ "com.apple.unityplugin.core"
+ ]
}