Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
87 changes: 71 additions & 16 deletions Documentation/Class-Injection.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,13 +55,13 @@ public class MyClass : SomeIL2CPPClass
{
// Used by IL2CPP when creating new instances of this class
public MyClass(IntPtr ptr) : base(ptr) { }

// Used by managed code when creating new instances of this class
public MyClass() : base(ClassInjector.DerivedConstructorPointer<MyClass>())
{
ClassInjector.DerivedConstructorBody(this);
}

// Any other methods
}

Expand All @@ -79,25 +79,81 @@ 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.#
* Finalizers are supported for injected classes, but note that the managed injector (`~MyClass`) is not invoked predictably.
If you want to implement a custom finalizer, define a `private void Il2CppFinalize()` method on your class (see below).
* Due to the implementation of finalizers, the "resurrection pattern" does not translate directly to injected types.
If you do not call `ClassInjector.ResurrectObject` during an object's finalizer,
the unmanaged object will be garbage collected and future use will throw `ObjectCollectedException`s.
**NB:** You will still also need to call `Il2CppSystem.GC.ReRegisterForFinalize` if applicable.

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

private void Il2CppFinalize()
Copy link
Collaborator

Choose a reason for hiding this comment

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

v2 replaces [HideFromIl2Cpp] with an opt-in system. The user indicator for this would be:

[Il2CppMethod(Name = "Finalize")]

Copy link
Author

Choose a reason for hiding this comment

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

not exactly, i think the attribute does make sense for consistency but it would still need special-casing and the injected class' Finalize method would not directly translate to the one defined here.

Copy link
Collaborator

Choose a reason for hiding this comment

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

The Name property applies in any situation where an Il2Cpp name differs from the generated name in managed code. In the case of finalizers, the v2 generator appends underscores, which is approximately the same as what you're doing.

{
// ...
}
}

ClassInjector.RegisterTypeInIl2Cpp<Foo>();

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

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

1. `Foo` is injected into the il2cpp domain:
* The class injector walks the superclasses of `Foo` and collects all the `Il2CppFinalize` methods.
* These finalizers are organised in a chain from subclass to superclass.
* The injected class is created with an unmanaged finalizer which we can hook into later.
2. 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`.
3. The managed instance of `Foo` goes out of scope, allowing this instance to be garbage collected.
4. Some time later, the managed GC will run its finalizer:
* Eventually, the GC reaches the finalizer of `Il2CppObjectBase`
where the strong handle to the unmanaged instance is released.
* Assuming no other managed or unmanaged references to the unmanaged object exist,
the unmanaged instance of `Foo` can now be garbage collected.
* Here, the managed instance of `Foo` is destroyed by the garbage collector,
but the unmanaged instance still exists temporarily.
5. When the unmanaged GC acknowleges this, the unmanaged object's finalizer is called, firing the hook we installed:
* A fresh managed instance of `Foo` is created.
* This fresh instance is "downgraded" and its strong handle becomes a weak handle,
preventing this instance from blocking the garbage collection of the unmanaged instance.
* We follow the links in the finalizer chain, running the user-specified finalization method (finally!).
6. 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 an `Il2CppFinalize` method are collected without running any hooks.

</details>

## Fields injection

> TODO: Describe how field injection works based on [#24](https://github.com/BepInEx/Il2CppAssemblyUnhollower/pull/24)
Expand All @@ -107,4 +163,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

47 changes: 22 additions & 25 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
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
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()
private void Il2CppFinalize()
{
Marshal.FreeHGlobal(MethodInfo);
MethodInfo = IntPtr.Zero;
ReferencedDelegate = null;
MethodInfo.Set(IntPtr.Zero);
s_storedDelegates.TryRemove(MethodInfo, out _);
}
}
}
Loading
Loading