Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,9 @@ _pkginfo.txt
# but keep track of directories ending in .cache
!?*.[Cc]ache/

# Helix Editor local configuration files
/.helix/

# Others
ClientBin/
~$*
Expand Down
72 changes: 58 additions & 14 deletions Documentation/Class-Injection.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,25 +79,70 @@ var someInstance = new MyClass(pointer);

* `[HideFromIl2Cpp]` can be used to prevent a method from being exposed to il2cpp

## Caveats

* Injected class instances are handled by IL2CPP garbage collection. This means that an object may be collected even if
it's referenced from managed domain. Attempting to use that object afterwards will result
in `ObjectCollectedException`. Conversely, managed representation of injected object will not be garbage collected as
long as it's referenced from IL2CPP domain.
* It might be possible to create a cross-domain reference loop that will prevent objects from being garbage collected.
Avoid doing anything that will result in injected class instances (indirectly) storing references to itself. The
simplest example of how to leak memory is this:
## Garbage Collection

* Managed instances of injected classes hold strong handles to their unmanaged counterparts.
This means it is unlikely for an unmanaged object to be collected while it is referenced from the managed domain.
If this rare case does occur, an attempt to use to injected instance will throw an `ObjectCollectedException`.
* If there are no extant references to the managed instance, it will be garbage collected,
releasing the strong handle it holds and allowing the underlying unmanaged object to eventually be collected in turn.
When this occurs, however, the finalizer for the managed instance will not be run,
delaying execution until the unmanaged object is also ready to be garbage collected.
* The unmanaged-to-managed mapping is a one-to-many relationship. While during typical execution there will be exactly zero,
or exactly one extant managed object, in certain situations, such as when the object pool is disabled, there may be any number.
* Due to the implementation of finalizers, calling `System.GC.SuppressFinalize` or `System.GC.ReRegisterForFinalize` is invalid
for all injected types with finalizers, leading to `ObjectCollectedExeption`s at best, and memory safety issues at worst.
The methods on `Il2CppSystem.GC` will always work as intended, however.
As a corrolary, once an managed instance is finalized, it is no longer valid,
so the "resurrection pattern" is not functional for injected types.

<details>
<summary>A detailed example of finalizer behavior for injected classes</summary>
<br>

Consider the following example:

```c#
class Injected: Il2CppSystem.Object {
Il2CppSystem.Collections.Generic.List<Il2CppSystem.Object> list = new ...;
public Injected() {
list.Add(this); // reference to itself through an IL2CPP list. This will prevent both this and list from being garbage collected, ever.
class Foo : Il2CppSystem.Object
{
public Foo(IntPtr ptr) : base(ptr) { }
public Foo() : this(ClassInjector.DerivedConstructorPointer<Foo>())
{
ClassInjector.DerivedConstructorBody(this);
}
~Foo()
{
// ...
}
}

Foo foobar = new();
// ... function ends ...
```

In the above example, the events occur in this order:

1. The parameterless `Foo` constructor is called:
* An unmanaged instance of the injected class is created.
* A strong handle to the unmanaged instance is put into the managed instance of `Foo`.
* The finalizer of the managed instance itself is suppressed,
but a hook is installed to watch for its collection.
2. The managed instance of `Foo` goes out of scope,
allowing the managed instance to be garbage collected (without running its finalizer).
3. Some time later, the managed instance is collected and the hook is triggered:
* The strong handle to the unmanaged instance is released
* Assuming no other managed or unmanaged references to the object exist,
the unmanaged instance of `Foo` can now be garbage collected.
4. Before this happens, the unmanaged object's finalizer is called:
* A fresh managed instance of `Foo` is created and _its_ finalizer is called directly.
* This new managed instance is immediately invalidated and forgotten.
5. The unmanaged object, which has no extant references, is garbage collected with its managed finalizer having run exactly once.

Note that this complexity is present only for injected classes with finalizers.
Injected classes without finalizers are collected following a more standard procedure.

</details>

## Fields injection

> TODO: Describe how field injection works based on [#24](https://github.com/BepInEx/Il2CppAssemblyUnhollower/pull/24)
Expand All @@ -107,4 +152,3 @@ class Injected: Il2CppSystem.Object {
* Not all members are exposed to Il2Cpp side - no properties, events or static methods will be visible to
Il2Cpp reflection. Fields are exported, but the feature is fairly limited.
* Only a limited set of types is supported for method signatures

45 changes: 21 additions & 24 deletions Il2CppInterop.Runtime/DelegateSupport.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,10 @@
using System.Reflection.Emit;
using System.Runtime.InteropServices;
using System.Text;
using Il2CppInterop.Common;
using Il2CppInterop.Runtime.Injection;
using Il2CppInterop.Runtime.InteropTypes;
using Il2CppInterop.Runtime.InteropTypes.Fields;
using Il2CppInterop.Runtime.Runtime;
using Microsoft.Extensions.Logging;
using Object = Il2CppSystem.Object;
using ValueType = Il2CppSystem.ValueType;

Expand Down Expand Up @@ -133,10 +132,14 @@

bodyBuilder.Emit(OpCodes.Ldarg_0);
bodyBuilder.Emit(OpCodes.Call,
typeof(ClassInjectorBase).GetMethod(nameof(ClassInjectorBase.GetMonoObjectFromIl2CppPointer))!);
bodyBuilder.Emit(OpCodes.Castclass, typeof(Il2CppToMonoDelegateReference));
typeof(Il2CppObjectPool).GetMethod(nameof(Il2CppObjectPool.Get))!
.MakeGenericMethod(typeof(Il2CppToMonoDelegateReference)));
bodyBuilder.Emit(OpCodes.Ldfld,
typeof(Il2CppToMonoDelegateReference).GetField(nameof(Il2CppToMonoDelegateReference.ReferencedDelegate)));
typeof(Il2CppToMonoDelegateReference).GetField(nameof(Il2CppToMonoDelegateReference.MethodInfo)));
bodyBuilder.Emit(OpCodes.Call,
typeof(Il2CppValueField<IntPtr>).GetMethod(nameof(Il2CppValueField<IntPtr>.Get)));
bodyBuilder.Emit(OpCodes.Call,
typeof(DelegateSupport).GetMethod(nameof(DelegateSupport.GetStoredDelegate), BindingFlags.NonPublic | BindingFlags.Static));

for (var i = 0; i < managedParameters.Length; i++)
{
Expand Down Expand Up @@ -190,15 +193,10 @@
bodyBuilder.Emit(OpCodes.Stloc, returnLocal);
}

var exceptionLocal = bodyBuilder.DeclareLocal(typeof(Exception));
bodyBuilder.BeginCatchBlock(typeof(Exception));
bodyBuilder.Emit(OpCodes.Stloc, exceptionLocal);
bodyBuilder.Emit(OpCodes.Ldstr, "Exception in IL2CPP-to-Managed trampoline, not passing it to il2cpp: ");
bodyBuilder.Emit(OpCodes.Ldloc, exceptionLocal);
bodyBuilder.Emit(OpCodes.Callvirt, typeof(object).GetMethod(nameof(ToString))!);
bodyBuilder.Emit(OpCodes.Call,
typeof(string).GetMethod(nameof(string.Concat), new[] { typeof(string), typeof(string) })!);
bodyBuilder.Emit(OpCodes.Call, typeof(DelegateSupport).GetMethod(nameof(LogError), BindingFlags.Static | BindingFlags.NonPublic)!);
bodyBuilder.Emit(OpCodes.Ldstr, "IL2CPP-to-Managed delegate trampoline");
bodyBuilder.Emit(OpCodes.Call, typeof(ClassInjector).GetMethod(nameof(ClassInjector.LogException),
BindingFlags.Static | BindingFlags.NonPublic)!);

bodyBuilder.EndExceptionBlock();

Expand All @@ -209,11 +207,6 @@
return trampoline.CreateDelegate(GetOrCreateDelegateType(signature, managedMethod));
}

private static void LogError(string message)
{
Logger.Instance.LogError("{Message}", message);
}

public static TIl2Cpp? ConvertDelegate<TIl2Cpp>(Delegate @delegate) where TIl2Cpp : Il2CppObjectBase
{
if (@delegate == null)
Expand Down Expand Up @@ -289,6 +282,8 @@
methodInfo.IsMarshalledFromNative = true;

var delegateReference = new Il2CppToMonoDelegateReference(@delegate, methodInfo.Pointer);
// Leak the object so we never have to do this again.
GCHandle.Alloc(delegateReference);

Il2CppSystem.Delegate converted;
if (UnityVersionHandler.MustUseDelegateConstructor)
Expand Down Expand Up @@ -317,6 +312,9 @@
return converted.Cast<TIl2Cpp>();
}

private static readonly ConcurrentDictionary<IntPtr, Delegate> s_storedDelegates = new();
private static Delegate GetStoredDelegate(IntPtr methodInfoPtr) => s_storedDelegates[methodInfoPtr];

internal class MethodSignature : IEquatable<MethodSignature>
{
public readonly bool ConstructedFromNative;
Expand Down Expand Up @@ -362,14 +360,14 @@
return _hashCode;
}

public bool Equals(MethodSignature other)

Check warning on line 363 in Il2CppInterop.Runtime/DelegateSupport.cs

View workflow job for this annotation

GitHub Actions / build

Nullability of reference types in type of parameter 'other' of 'bool MethodSignature.Equals(MethodSignature other)' doesn't match implicitly implemented member 'bool IEquatable<MethodSignature>.Equals(MethodSignature? other)' (possibly because of nullability attributes).
{
if (ReferenceEquals(null, other)) return false;
if (ReferenceEquals(this, other)) return true;
return _hashCode.GetHashCode() == other.GetHashCode();
}

public override bool Equals(object obj)

Check warning on line 370 in Il2CppInterop.Runtime/DelegateSupport.cs

View workflow job for this annotation

GitHub Actions / build

Nullability of type of parameter 'obj' doesn't match overridden member (possibly because of nullability attributes).
{
if (ReferenceEquals(null, obj)) return false;
if (ReferenceEquals(this, obj)) return true;
Expand All @@ -390,8 +388,7 @@

private class Il2CppToMonoDelegateReference : Object
{
public IntPtr MethodInfo;
public Delegate ReferencedDelegate;
Comment on lines -393 to -394

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm very happy to see this. One of the things in v2 that was bothering me is that I wasn't sure what to do with these fields. I'm introducing a breaking change that injected classes can't have managed state.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah i think breaking change is the only way to fix the semantics. i have a branch somewhere for bepinex which fixes it there.

public Il2CppValueField<IntPtr> MethodInfo;

public Il2CppToMonoDelegateReference(IntPtr obj0) : base(obj0)
{
Expand All @@ -402,15 +399,15 @@
{
ClassInjector.DerivedConstructorBody(this);

ReferencedDelegate = referencedDelegate;
MethodInfo = methodInfo;
MethodInfo!.Set(methodInfo);
s_storedDelegates[methodInfo] = referencedDelegate;
}

~Il2CppToMonoDelegateReference()
{
Marshal.FreeHGlobal(MethodInfo);
MethodInfo = IntPtr.Zero;
ReferencedDelegate = null;
MethodInfo.Set(IntPtr.Zero);
s_storedDelegates.TryRemove(MethodInfo, out _);
}
}
}
Loading
Loading