Skip to content

Conversation

@dogdie233
Copy link

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
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);
}

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

1 participant