-
Notifications
You must be signed in to change notification settings - Fork 106
Description
Product and Version: Il2CppInterop 1.4.6 & 1.5.1
Description
When attempting to create a new Il2CppSystem.Nullable<T>(T value) where T is a struct (and therefore an Il2CppSystem.ValueType), a crash occurs upon accessing the Value property.
This appears to be caused by the generated constructor for Nullable<T> incorrectly handling the value parameter. The generator seems to treat the Il2CppSystem.ValueType as a boxed object (Il2CppObjectBase) and passes a pointer to this managed object instead of unboxing it to a pointer to the actual struct data. This leads to a memory access violation when the native code attempts to dereference it.
Steps to Reproduce
-
Define a struct in a Unity project and compile it with IL2CPP.
// Defined in Unity public struct AwesomeStruct { public int v1, v2; public int V1 => v1; public int V2 => v2; public AwesomeStruct(int v1, int v2) { this.v1 = v1; this.v2 = v2; } } // A method to test with, if needed public static class Program { public static void Print(Nullable<AwesomeStruct> s) => Console.WriteLine($"{s.Value.v1}, {s.Value.v2}"); }
-
In a BepInEx plugin using Il2CppInterop, try to create and use a
Nullableinstance of this struct.// Executed in a BepInEx plugin // 1. Create the struct instance var awesomeStruct = new AwesomeStruct(1, 2); // 2. Wrap it in an Il2CppSystem.Nullable<T> var nullable = new Il2CppSystem.Nullable<AwesomeStruct>(awesomeStruct); // 3. Attempt to access the Value property. This results in a crash. Console.WriteLine(nullable.Value.V1.ToString()); // CRASH
Analysis of the Generated Code
The issue seems to stem from the constructor generated by Il2CppInterop.Generator. For a generic Nullable<T>, the generated code is as follows:
public unsafe Nullable(T value)
: this(IL2CPP.il2cpp_object_new(Il2CppClassPointerStore<Nullable<T>>.NativeClassPtr))
{
System.IntPtr* ptr = stackalloc System.IntPtr[1];
ref T reference;
// This branch is taken for Il2CppSystem.ValueType
if (!typeof(T).IsValueType)
{
object obj = value;
// 'reference' points to the managed object, not the raw struct data
reference = ref *(_003F*)((!(obj is string)) ? IL2CPP.Il2CppObjectBaseToPtr(obj as Il2CppObjectBase) : IL2CPP.ManagedStringToIl2Cpp(obj as string));
}
else
{
reference = ref value;
}
*ptr = (nint)Unsafe.AsPointer(ref reference);
Unsafe.SkipInit(out System.IntPtr exc);
// The invoke call receives a pointer to a boxed object instead of an unboxed struct pointer.
System.IntPtr intPtr = IL2CPP.il2cpp_runtime_invoke(NativeMethodInfoPtr__ctor_Public_Void_T_0, IL2CPP.il2cpp_object_unbox(IL2CPP.Il2CppObjectBaseToPtrNotNull(this)), (void**)ptr, ref exc);
Il2CppInterop.Runtime.Il2CppException.RaiseExceptionIfNecessary(exc);
}In this case, T is AwesomeStruct, which is not a primitive C# ValueType but an Il2CppSystem.ValueType (a class wrapper). The code path for !typeof(T).IsValueType is executed, which results in reference pointing to the managed "box" object. The native constructor, however, expects a pointer to the raw, unboxed struct data.
Proposed Solution / Workaround
A manual implementation that correctly unboxes the Il2CppSystem.ValueType before invoking the native constructor works as expected. This demonstrates that unboxing is the necessary step.
static Il2CppSystem.Nullable<AwesomeStruct> CreateNullableAwesomeStruct(AwesomeStruct value)
{
// Allocate space for arguments
IntPtr* args = stackalloc IntPtr[1];
// Unbox the Il2CppSystem.ValueType to get a pointer to the raw struct data
IntPtr unboxedStructPtr = IL2CPP.il2cpp_object_unbox(value.Pointer);
args[0] = unboxedStructPtr;
// Create a new Nullable<T> object in the IL2CPP domain
var objPtr = IL2CPP.il2cpp_object_new(Il2CppClassPointerStore<Nullable<AwesomeStruct>>.NativeClassPtr);
// Get the native method pointer for the constructor
var ctorPtr = (IntPtr)typeof(Il2CppSystem.Nullable<AwesomeStruct>)
.GetField("NativeMethodInfoPtr__ctor_Public_Void_T_0", BindingFlags.Static | BindingFlags.NonPublic)!
.GetValue(null)!;
// Invoke the native constructor
IntPtr exc = IntPtr.Zero;
IL2CPP.il2cpp_runtime_invoke(
ctorPtr,
IL2CPP.il2cpp_object_unbox(objPtr), // 'this' pointer
(void**)args, // Arguments
ref exc
);
Il2CppException.RaiseExceptionIfNecessary(exc);
return new Il2CppSystem.Nullable<AwesomeStruct>(objPtr);
}This workaround correctly passes the unboxed struct pointer, and everything works correctly.
Suggested Area for Investigation
The issue likely resides in the ILGeneratorEx logic. The generator needs to differentiate between a standard managed object and an Il2CppSystem.ValueType. When the generic parameter T is an Il2CppSystem.ValueType, it should emit IL to unbox the object before passing it as a parameter to the native method.
This seems to be the relevant section in the generator:
Il2CppInterop/Il2CppInterop.Generator/Extensions/ILGeneratorEx.cs
Lines 257 to 266 in be52ffc
| if (unboxNonBlittableType) | |
| { | |
| body.Add(OpCodes.Dup); | |
| body.Add(OpCodes.Brfalse_S, finalNop); // return null immediately | |
| body.Add(OpCodes.Dup); | |
| body.Add(OpCodes.Call, imports.IL2CPP_il2cpp_object_get_class.Value); | |
| body.Add(OpCodes.Call, imports.IL2CPP_il2cpp_class_is_valuetype.Value); | |
| body.Add(OpCodes.Brfalse_S, finalNop); // return reference types immediately | |
| body.Add(OpCodes.Call, imports.IL2CPP_il2cpp_object_unbox.Value); | |
| } |