Skip to content

fix: Correctly unbox generic parameters wrapped in Il2CppObjectBase when underlying type is ValueType (e.g. Nullable<T>(T value) constructor)#246

Open
dogdie233 wants to merge 5 commits into
BepInEx:masterfrom
dogdie233:issue-240
Open

fix: Correctly unbox generic parameters wrapped in Il2CppObjectBase when underlying type is ValueType (e.g. Nullable<T>(T value) constructor)#246
dogdie233 wants to merge 5 commits into
BepInEx:masterfrom
dogdie233:issue-240

Conversation

@dogdie233

Copy link
Copy Markdown

Description

This PR addresses a code generation issue regarding how generic parameters (T) are marshaled when passed to native methods.

The Problem

When a method takes a generic parameter T (e.g., Method(T value)), and T is instantiated as an Il2CppObjectBase wrapper representing a native ValueType (Struct), the previous codegen logic treated it purely as a reference type.

It passed the pointer to the boxed object (header + data) to the native function. However, if the native method signature expects the raw value type (common in generic methods like Nullable<T>.ctor or generic collection manipulation), this resulted in the native code reading the object header as data, causing data corruption or crashes.

The Fix

I updated the code generation template to include a runtime check for generic parameters.
The new logic detects this specific scenario:

  1. The object is a string? Handle as string.
  2. The object is an Il2CppObjectBase?
    • Check if the underlying native class is a ValueType (il2cpp_class_is_valuetype).
    • Check if the wrapper type T is compatible with Il2CppSystem.ValueType.
    • If yes, unbox the object (il2cpp_object_unbox) to get the raw data pointer before passing it to the native method.

Related Issue

Fixes #240
(Although the issue specifically reports Nullable<T>, this fix applies generally to any method accepting T where T is a struct wrapper).

Test Plan

I verified the fix using Il2CppSystem.Nullable<T> as a reproduction case, as it relies heavily on correctly receiving the unboxed value of T.

Test Environment:

  • Target: A custom struct (AwesomeStruct) defined in the game assembly.
  • Scenario: Wrapping the struct in Nullable<AwesomeStruct> and verifying data integrity.

Code for Testing

// Struct defined in game assembly
public struct AwesomeStruct
{
    public int intValue;
    public float floatValue;
    public string stringValue;
    
    public AwesomeStruct(int intValue, float floatValue, string stringValue)
    {
        this.intValue = intValue;
        this.floatValue = floatValue;
        this.stringValue = stringValue;
    }

    public override string ToString() => $"Int: {intValue}, Float: {floatValue}, String: {stringValue}";
}

// MelonLoader Test
public void Test2()
{
    var awe = new AwesomeStruct(1, 2.0f, "test");
    // This constructor takes "T value". Previously, it received the boxed pointer.
    var nullableAwe = new Il2CppSystem.Nullable<AwesomeStruct>(awe);
    
    MelonLogger.Msg(nullableAwe.HasValue.ToString()); 
    MelonLogger.Msg(nullableAwe.Value.ToString());    
}

Results

Before Fix (Broken):
The native constructor read the object header as the struct data, resulting in garbage values.

[04:24:16.500] [TestMod] True
[04:24:16.502] [TestMod] Int: -448339040, Float: 7.202674E-43, String:

After Fix (Working):
The native constructor receives the unboxed data correctly.

[04:33:28.170] [TestMod] True
[04:33:28.172] [TestMod] Int: 1, Float: 2, String: test

(Note: I also verified the Dictionary/Int64 case mentioned in PR #69 to ensure no regression, and it works correctly.)

Implementation Note

Regarding the unbox check condition:
I am currently using typeof(Il2CppSystem.ValueType).IsAssignableFrom(typeof(T)) combined with il2cpp_class_is_valuetype on the runtime instance.

There is an alternative approach using il2cpp_class_is_valuetype(Il2CppClassPointerStore<T>.NativeClassPtr). I opted for IsAssignableFrom as it seemed safer for the generated code flow, but I am open to feedback if checking the NativeClassPtr directly is preferred for this generic constraint check.

@dogdie233 dogdie233 changed the title Issue 240 fix: Correctly unbox generic parameters wrapped in Il2CppObjectBase when underlying type is ValueType (e.g. Nullable<T>(T value) constructor) Dec 23, 2025
@dogdie233

Copy link
Copy Markdown
Author

The generated Nullable constructor before this fix:

public unsafe Nullable(T value)
	: this(IL2CPP.il2cpp_object_new(Il2CppClassPointerStore<Nullable<T>>.NativeClassPtr))
{
	//IL_0055->IL0058: Incompatible stack types: I vs Ref
	//IL_0048->IL0058: Incompatible stack types: I vs Ref
	System.IntPtr* ptr = stackalloc System.IntPtr[1];
	ref T reference;
	if (!typeof(T).IsValueType)
	{
		object obj = value;
		reference = ref *(?*)((!(obj is string)) ? IL2CPP.Il2CppObjectBaseToPtr((Il2CppObjectBase)((obj is Il2CppObjectBase) ? obj : null)) : IL2CPP.ManagedStringToIl2Cpp(obj as string));
	}
	else
	{
		reference = ref value;
	}
	*ptr = (nint)Unsafe.AsPointer(ref reference);
	Unsafe.SkipInit(out System.IntPtr intPtr2);
	System.IntPtr intPtr = IL2CPP.il2cpp_runtime_invoke(NativeMethodInfoPtr__ctor_Public_Void_T_0, IL2CPP.il2cpp_object_unbox(IL2CPP.Il2CppObjectBaseToPtrNotNull((Il2CppObjectBase)(object)this)), (void**)ptr, ref intPtr2);
	Il2CppException.RaiseExceptionIfNecessary(intPtr2);
}

after this fix:

public unsafe Nullable(T value)
    : this(IL2CPP.il2cpp_object_new(Il2CppClassPointerStore<Il2CppSystem.Nullable<T>>.NativeClassPtr))
{
    //IL_0085->IL0088: Incompatible stack types: I vs Ref
    //IL_0049->IL0088: Incompatible stack types: I vs Ref
    //IL_0056->IL0088: Incompatible stack types: I vs Ref
    //IL_0071->IL0088: Incompatible stack types: I vs Ref
    //IL_0078->IL0088: Incompatible stack types: I vs Ref
    System.IntPtr* ptr = stackalloc System.IntPtr[1];
    ref T reference;
    if (!typeof(T).IsValueType)
    {
        object obj = value;
        if (obj is string)
        {
            reference = ref *(?*)IL2CPP.ManagedStringToIl2Cpp(obj as string);
        }
        else
        {
            System.IntPtr intPtr = IL2CPP.Il2CppObjectBaseToPtr((Il2CppObjectBase)((obj is Il2CppObjectBase) ? obj : null));
            reference = ref *(?*)intPtr;
            if (intPtr != (System.IntPtr)0)
            {
                reference = ref *(?*)intPtr;
                if (IL2CPP.il2cpp_class_is_valuetype(IL2CPP.il2cpp_object_get_class(intPtr)))
                {
                    reference = ref *(?*)intPtr;
                    if (typeof(Il2CppSystem.ValueType).IsAssignableFrom(typeof(T)))
                    {
                        reference = ref *(?*)IL2CPP.il2cpp_object_unbox(intPtr);
                    }
                }
            }
        }
    }
    else
    {
        reference = ref value;
    }
    *ptr = (nint)Unsafe.AsPointer(ref reference);
    Unsafe.SkipInit(out System.IntPtr intPtr3);
    System.IntPtr intPtr2 = IL2CPP.il2cpp_runtime_invoke(NativeMethodInfoPtr__ctor_Public_Void_T_0, IL2CPP.il2cpp_object_unbox(IL2CPP.Il2CppObjectBaseToPtrNotNull((Il2CppObjectBase)(object)this)), (void**)ptr, ref intPtr3);
    Il2CppException.RaiseExceptionIfNecessary(intPtr3);
}

@dogdie233

Copy link
Copy Markdown
Author

It seems that the generated field getter/setter also has a similar issue, I will fix it later

var awe = new AwesomeStruct(1, 2.0f, "test");
MelonLogger.Msg(awe.ToString());
var nullableAwe = new Il2CppSystem.Nullable<AwesomeStruct>(awe);
MelonLogger.Msg(nullableAwe.HasValue.ToString());
MelonLogger.Msg(nullableAwe.Value.ToString());
MelonLogger.Msg(nullableAwe.value.stringValue);  // access field return invalid data
MelonLogger.Msg(nullableAwe.Value.stringValue);

output

[01:36:28.021] [TestMod] Int: 1, Float: 2, String: test
[01:36:28.027] [TestMod] True
[01:36:28.030] [TestMod] Int: 1, Float: 2, String: test
[01:36:28.032] [TestMod] null
[01:36:28.034] [TestMod] test

@dogdie233

Copy link
Copy Markdown
Author

fix: Incorrect data returned when accessing generic fields containing value types

Description

This PR fixes an issue where accessing a generic field that contains a value type (custom struct) returns incorrect/garbage data or causes a crash, whereas accessing the same data via a property works perfectly.

Steps to Reproduce

Suppose we have the following C# code compiled via IL2CPP in a Unity game:

Unity Game Code (Click to expand)
public struct StructWrapper<T>
{
    public string value;
    public T data;
    
    public string Value => value;
    public T Data => data;
    
    public StructWrapper(string value, T data)
    {
        this.value = value;
        this.data = data;
    }
}

public class ClassWrapper<T>
{
    public string value;
    public T data;

    public string Value => value;
    public T Data => data;
    
    public ClassWrapper(string value, T data)
    {
        this.value = value;
        this.data = data;
    }
}

public struct AwesomeStruct
{
    public int intValue;
    public float floatValue;
    public string stringValue;
    
    public AwesomeStruct(int intValue, float floatValue, string stringValue)
    {
        this.intValue = intValue;
        this.floatValue = floatValue;
        this.stringValue = stringValue;
    }

    public override string ToString()
    {
        return $"Int: {intValue}, Float: {floatValue}, String: {stringValue}";
    }
}

And the following Mod code trying to access it:

var awe = new AwesomeStruct(1, 2.0f, "test");
var sw = new StructWrapper<AwesomeStruct>(awe);

// Getting data via Field fails/returns dirty data
MelonLogger.Msg($"Access from field: {sw.data.intValue} {sw.data.stringValue}"); 

// Getting data via Property works perfectly
MelonLogger.Msg($"Access from property: {sw.Data.intValue} {sw.Data.stringValue}"); 

Root Cause Analysis

Upon inspecting the wrapper code generated by Il2CppInterop.Generator, the getter for the data field looks like this:

public unsafe T data
{
    get
    {
        nint num = (nint)IL2CPP.il2cpp_object_unbox(IL2CPP.Il2CppObjectBaseToPtrNotNull((Il2CppObjectBase)(object)this)) + (int)IL2CPP.il2cpp_field_get_offset(NativeFieldInfoPtr_data);
        return IL2CPP.PointerToValueGeneric<T>((IntPtr)num, true, false);
    }
    set {...}
}

The calculated num points directly to the actual memory address of the data field. If we simply dereferenced num, we could get the correct value. However, the issue occurs inside IL2CPP.PointerToValueGeneric due to the arguments passed (isFieldPointer: true, valueTypeWouldBeBoxed: false).

Let's trace what happens inside PointerToValueGeneric<T>:

public static T? PointerToValueGeneric<T>(IntPtr objectPointer, bool isFieldPointer, bool valueTypeWouldBeBoxed)
{
    if (isFieldPointer)
    {
        if (il2cpp_class_is_valuetype(Il2CppClassPointerStore<T>.NativeClassPtr))
            // 1st Boxing: Since isFieldPointer is true and AwesomeStruct is a ValueType, the pointer is boxed once here.
            objectPointer = il2cpp_value_box(Il2CppClassPointerStore<T>.NativeClassPtr, objectPointer);  
        else
            objectPointer = *(IntPtr*)objectPointer;
    }
    
    if (!valueTypeWouldBeBoxed && il2cpp_class_is_valuetype(Il2CppClassPointerStore<T>.NativeClassPtr))
        // 2nd Boxing: Since valueTypeWouldBeBoxed is false and it's a ValueType, the pointer gets boxed AGAIN!
        objectPointer = il2cpp_value_box(Il2CppClassPointerStore<T>.NativeClassPtr, objectPointer);

    if (typeof(T) == typeof(string))
        return (T)(object)Il2CppStringToManaged(objectPointer);

    if (objectPointer == IntPtr.Zero)
        return default;

    // Type Check Fails: typeof(T).IsValueType is FALSE here. 
    // Because in the interop assembly, 'AwesomeStruct' is generated as a CLASS inheriting from 'Il2CppSystem.ValueType'.
    if (typeof(T).IsValueType)  
        return Il2CppObjectBase.UnboxUnsafe<T>(objectPointer);

    // Finally, it takes the double-boxed pointer and tries to convert it into an Il2CppObject.
    // This results in dirty data or a crash.
    return Il2CppObjectPool.Get<T>(objectPointer);  
}

Because of this logic, generic fields containing value types get double-boxed and misidentified as reference types, causing the method to return invalid data.

Testing

Full Test Script & Output (Click to expand)

Test Code:

public void Test2()
{
    MelonLogger.Msg("======== Test2 ========");
    var awe = new AwesomeStruct(1, 2.0f, "test");
    MelonLogger.Msg(awe.ToString());
    
    var nullableAwe = new Il2CppSystem.Nullable<AwesomeStruct>(awe);
    MelonLogger.Msg(nullableAwe.HasValue.ToString()); // True, prev: crash
    MelonLogger.Msg(nullableAwe.value.ToString());
    MelonLogger.Msg(nullableAwe.Value.ToString());    // AwesomeStruct.ToString() output, prev: crash
    MelonLogger.Msg($"Access from field: {nullableAwe.value.intValue} {nullableAwe.value.stringValue}"); // Accessing string field, prev: dirty data
    MelonLogger.Msg($"Access from property: {nullableAwe.Value.intValue} {nullableAwe.Value.stringValue}");
    
    // Modify
    nullableAwe.value = new AwesomeStruct(123, 123.321f, "Ciallo~");
    MelonLogger.Msg(nullableAwe.Value.ToString());

    var vvv = new StructWrapper<StructWrapper<AwesomeStruct>>("vvv - outer", new StructWrapper<AwesomeStruct>("vvv - inner", new AwesomeStruct(3, 3.3f, "Third Struct")));
    var rrv = new ClassWrapper<ClassWrapper<AwesomeStruct>>("rrv - outer", new ClassWrapper<AwesomeStruct>("rrv - inner", new AwesomeStruct(4, 4.4f, "Fourth Struct")));
    var vrv = new StructWrapper<ClassWrapper<AwesomeStruct>>("vrv - outer", new ClassWrapper<AwesomeStruct>("vrv - inner", new AwesomeStruct(5, 5.5f, "Fifth Struct")));
    var rvv = new ClassWrapper<StructWrapper<AwesomeStruct>>("rvv - outer", new StructWrapper<AwesomeStruct>("rvv - inner", new AwesomeStruct(6, 6.6f, "Sixth Struct")));
    
    MelonLogger.Msg("===== Nested Wrappers Test (access from property) =====");
    MelonLogger.Msg($"vvv: SW({vvv.Value}, SW({vvv.Data.Value}, Invoking method: {vvv.Data.Data.ToString()}, read data: {vvv.Data.Data.intValue} {vvv.Data.Data.stringValue}))");
    MelonLogger.Msg($"rrv: CW({rrv.Value}, CW({rrv.Data.Value}, Invoking method: {rrv.Data.Data.ToString()}, read data: {rrv.Data.Data.intValue} {rrv.Data.Data.stringValue}))");
    MelonLogger.Msg($"vrv: SW({vrv.Value}, CW({vrv.Data.Value}, Invoking method: {vrv.Data.Data.ToString()}, read data: {vrv.Data.Data.intValue} {vrv.Data.Data.stringValue}))");
    MelonLogger.Msg($"rvv: CW({rvv.Value}, SW({rvv.Data.Value}, Invoking method: {rvv.Data.Data.ToString()}, read data: {rvv.Data.Data.intValue} {rvv.Data.Data.stringValue}))");
    
    MelonLogger.Msg("===== Nested Wrappers Test (access from field) =====");
    MelonLogger.Msg($"vvv: SW({vvv.value}, SW({vvv.data.value}, Invoking method: {vvv.data.data.ToString()}, read data: {vvv.data.data.intValue} {vvv.data.data.stringValue}))");
    MelonLogger.Msg($"rrv: CW({rrv.value}, CW({rrv.data.value}, Invoking method: {rrv.data.data.ToString()}, read data: {rrv.data.data.intValue} {rrv.data.data.stringValue}))");
    MelonLogger.Msg($"vrv: SW({vrv.value}, CW({vrv.data.value}, Invoking method: {vrv.data.data.ToString()}, read data: {vrv.data.data.intValue} {vrv.data.data.stringValue}))");
    MelonLogger.Msg($"rvv: CW({rvv.value}, SW({rvv.data.value}, Invoking method: {rvv.data.data.ToString()}, read data: {rvv.data.data.intValue} {rvv.data.data.stringValue}))");
    
    MelonLogger.Msg("===== Test on custom struct =====");
    var myVVV = new StructWrapper<StructWrapper<int>>("vvv - outer", new StructWrapper<int>("vvv - inner", 1116));
    MelonLogger.Msg($"myVVV: SW({myVVV.Value}, SW({myVVV.Data.Value}, Invoking method: {myVVV.Data.Data.ToString()}, read data: {myVVV.Data.Data}))");
    MelonLogger.Msg($"myVVV: SW({myVVV.value}, SW({myVVV.data.value}, Invoking method: {myVVV.data.data.ToString()}, read data: {myVVV.data.data}))");
}

Output after fix:

[04:15:57.708] [TestMod] ======== Test2 ========
[04:15:57.710] [TestMod] Int: 1, Float: 2, String: test
[04:15:57.713] [TestMod] True
[04:15:57.715] [TestMod] Int: 1, Float: 2, String: test
[04:15:57.716] [TestMod] Int: 1, Float: 2, String: test
[04:15:57.718] [TestMod] Access from field: 1 test
[04:15:57.723] [TestMod] Access from property: 1 test
[04:15:57.725] [TestMod] Int: 123, Float: 123.321, String: Ciallo~
[04:15:57.729] [TestMod] ===== Nested Wrappers Test (access from property) =====
[04:15:57.731] [TestMod] vvv: SW(vvv - outer, SW(vvv - inner, Invoking method: Int: 3, Float: 3.3, String: Third Struct, read data: 3 Third Struct))
[04:15:57.733] [TestMod] rrv: CW(rrv - outer, CW(rrv - inner, Invoking method: Int: 4, Float: 4.4, String: Fourth Struct, read data: 4 Fourth Struct))
[04:15:57.737] [TestMod] vrv: SW(vrv - outer, CW(vrv - inner, Invoking method: Int: 5, Float: 5.5, String: Fifth Struct, read data: 5 Fifth Struct))
[04:15:57.739] [TestMod] rvv: CW(rvv - outer, SW(rvv - inner, Invoking method: Int: 6, Float: 6.6, String: Sixth Struct, read data: 6 Sixth Struct))
[04:15:57.741] [TestMod] ===== Nested Wrappers Test (access from field) =====
[04:15:57.743] [TestMod] vvv: SW(vvv - outer, SW(vvv - inner, Invoking method: Int: 3, Float: 3.3, String: Third Struct, read data: 3 Third Struct))
[04:15:57.746] [TestMod] rrv: CW(rrv - outer, CW(rrv - inner, Invoking method: Int: 4, Float: 4.4, String: Fourth Struct, read data: 4 Fourth Struct))
[04:15:57.748] [TestMod] vrv: SW(vrv - outer, CW(vrv - inner, Invoking method: Int: 5, Float: 5.5, String: Fifth Struct, read data: 5 Fifth Struct))
[04:15:57.755] [TestMod] rvv: CW(rvv - outer, SW(rvv - inner, Invoking method: Int: 6, Float: 6.6, String: Sixth Struct, read data: 6 Sixth Struct))
[04:15:57.757] [TestMod] ===== Test on custom struct =====
[04:15:57.762] [TestMod] myVVV: SW(vvv - outer, SW(vvv - inner, Invoking method: 1116, read data: 1116))
[04:15:57.767] [TestMod] myVVV: SW(vvv - outer, SW(vvv - inner, Invoking method: 1116, read data: 1116))

@dogdie233

Copy link
Copy Markdown
Author

I think is ready for review :D

@ds5678 ds5678 mentioned this pull request May 6, 2026
@ds5678 ds5678 added this to the 2.0.0 milestone May 17, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants