fix: Correctly unbox generic parameters wrapped in Il2CppObjectBase when underlying type is ValueType (e.g. Nullable<T>(T value) constructor)#246
Conversation
…ethods with generated value types. Close issue: BepInEx#240
|
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);
} |
|
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);
|
fix: Incorrect data returned when accessing generic fields containing value typesDescriptionThis 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 ReproduceSuppose 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 AnalysisUpon inspecting the wrapper code generated by 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 Let's trace what happens inside 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. TestingFull 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: |
|
I think is ready for review :D |
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)), andTis instantiated as anIl2CppObjectBasewrapper 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>.ctoror 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:
Il2CppObjectBase?il2cpp_class_is_valuetype).Tis compatible withIl2CppSystem.ValueType.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 acceptingTwhereTis 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 ofT.Test Environment:
AwesomeStruct) defined in the game assembly.Nullable<AwesomeStruct>and verifying data integrity.Code for Testing
Results
Before Fix (Broken):
The native constructor read the object header as the struct data, resulting in garbage values.
After Fix (Working):
The native constructor receives the unboxed data correctly.
(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 withil2cpp_class_is_valuetypeon the runtime instance.There is an alternative approach using
il2cpp_class_is_valuetype(Il2CppClassPointerStore<T>.NativeClassPtr). I opted forIsAssignableFromas it seemed safer for the generated code flow, but I am open to feedback if checking theNativeClassPtrdirectly is preferred for this generic constraint check.