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

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions