Skip to content

Crash when creating Nullable<T> with an Il2CppSystem.ValueType due to incorrect boxing #240

@dogdie233

Description

@dogdie233

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

  1. 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}");
    }
  2. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions