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" + ] }