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 Nullable instance 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:
|
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); |
|
} |
Related issues:
#207
#69
Product and Version: Il2CppInterop
1.4.6&1.5.1Description
When attempting to create a
new Il2CppSystem.Nullable<T>(T value)whereTis a struct (and therefore anIl2CppSystem.ValueType), a crash occurs upon accessing theValueproperty.This appears to be caused by the generated constructor for
Nullable<T>incorrectly handling thevalueparameter. The generator seems to treat theIl2CppSystem.ValueTypeas 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.
In a BepInEx plugin using Il2CppInterop, try to create and use a
Nullableinstance of this struct.Analysis of the Generated Code
The issue seems to stem from the constructor generated by
Il2CppInterop.Generator. For a genericNullable<T>, the generated code is as follows:In this case,
TisAwesomeStruct, which is not a primitive C#ValueTypebut anIl2CppSystem.ValueType(a class wrapper). The code path for!typeof(T).IsValueTypeis executed, which results inreferencepointing 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.ValueTypebefore invoking the native constructor works as expected. This demonstrates that unboxing is the necessary step.This workaround correctly passes the unboxed struct pointer, and everything works correctly.
Suggested Area for Investigation
The issue likely resides in the
ILGeneratorExlogic. The generator needs to differentiate between a standard managed object and anIl2CppSystem.ValueType. When the generic parameterTis anIl2CppSystem.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
Related issues:
#207
#69