Il2CppInspector Tutorial: Working with code in IL2CPP DLL injection projects
Once you have created a C++ scaffolding project for DLL injection with Il2CppInspector, you will likely be wondering how to interact with the injected IL2CPP application. In this article, we’ll look at how to perform common tasks such as accessing static and instance fields, creating new objects, working with strings, calling vtable functions and more.
The first part of this article is reference material covering how types, methods, properties and so on are represented, named and addressed in C++. The second part shows a number of concrete working examples, so if you want to skip the theory, feel free to jump forwards. Let’s dive in!
Note: C++ scaffolding is experimental and currently not for the faint-hearted. This document will be updated as the codebase matures and improves to reflect the current state of scaffolding projects.
PART 1: How .NET applications are represented in IL2CPP
Types
In IL2CPP, types are represented by an Il2CppClass
object – one for each type in the application. This object contains many members allowing you to get RTTI (perform reflection) for the corresponding type. The most important ones for runtime use are as follows:
typedef struct Il2CppClass
{
// ...
void* static_fields;
// ...
VirtualInvokeData vtable[32];
} Il2CppClass;
These two members allow you to access a type’s static fields and vtable methods respectively. We’ll explore this more in a moment.
Object instances (.NET reference types) are represented by an Il2CppObject
object. Each object starts with the following definition:
typedef struct Il2CppObject
{
Il2CppClass *klass;
MonitorData *monitor;
} Il2CppObject;
The klass
member is the important one: it allows you to access the object’s corresponding class definition. monitor
is for debugging purposes only. These fields are followed by additional .NET type-dependent fields representing the object instance members.
Info: Field definitions can occur in two flavours depending on which compiler was used to build the application. For apps compiled with MSVC (generally the case for Windows platforms), a single fields
item containing a pointer to a struct containing all the fields will be present. For apps compiled with GCC or Clang (generally the case for Linux, Android and iOS), the fields will be included in the Il2CppObject
class directly after klass
and monitor
. As we’re concerned with Windows DLL injection in this article, we’ll generally assume that MSVC is the source compiler.
While IL2CPP always uses Il2CppClass
and Il2CppObject
internally for every type definition and object instance, Il2CppInspector produces strongly-typed code with unique struct
types for each .NET type. For example, for System.IO.FileStream
, the corresponding Il2CppObject
type generated for MSVC-compiled applications is:
struct FileStream {
struct FileStream__Class *klass;
MonitorData *monitor;
struct FileStream__Fields fields;
};
struct FileStream__Fields {
struct Stream__Fields _;
struct Byte__Array *buf;
struct String *name;
struct SafeFileHandle *safeHandle;
bool isExposed;
// ...
}
FileStream
here may be cast to Il2CppObject
if required.
Notice that the first field “_
” of FileStream__Fields
encapsulates the fields from the base class System.IO.Stream
:
struct Stream__Fields {
struct MarshalByRefObject__Fields _;
struct Stream_ReadWriteTask *_activeReadWriteTask;
struct SemaphoreSlim *_asyncActiveSemaphore;
};
Similarly, the first field “_
” of Stream__Fields
encapsulates the fields from its abstract base class, System.MarshalByRefObject
. This chain continues all the way back to System.Object
which is the base type for all .NET reference types.
The corresponding Il2CppClass
type generated is:
struct FileStream__Class {
Il2CppClass_0 _0;
Il2CppRuntimeInterfaceOffsetPair *interfaceOffsets;
struct FileStream__StaticFields *static_fields;
const Il2CppRGCTXData *rgctx_data;
Il2CppClass_1 _1;
struct FileStream__VTable vtable;
};
This struct
is equivalent to Il2CppClass
except that we have abstracted away fields that don’t require .NET type-specific strong typing into two surrogate members _0
and _1
, and interposed strongly typed static_fields
and vtable
members. The idea behind this is to reduce the need for many ugly casts in your code. You can still cast the object to Il2CppClass
if required.
Info: The fields _0
and _1
will be replaced with macro expansion in a later version of Il2CppInspector so that Visual Studio IntelliSense produces more meaningful suggestions when working with Il2CppClass
types.
Value types
.NET value types are represented by struct
s containing their fields. Each value type has a corresponding boxed type (generated by Il2CppInspector with the “__Boxed
” suffix) which is an Il2CppObject
with an extra member for the field struct
. For example, for System.Decimal
:
struct Decimal {
int32_t flags;
int32_t hi;
int32_t lo;
int32_t mid;
};
struct Decimal__Boxed {
struct Decimal__Class *klass;
MonitorData *monitor;
struct Decimal fields;
};
Decimal__Boxed
here may be cast to Il2CppObject
.
If the boxed struct only contains one field, the field is included by Il2CppInspector directly. The memory layout remains compatible since a struct
contains no hidden header data:
struct Int32 {
int32_t m_value;
};
struct Int32__Boxed {
struct Int32__Class *klass;
MonitorData *monitor;
int32_t fields;
};
Enumerations
Enumerations are value types so they always have a boxed equivalent. Il2CppInspector defines C# enum
s as C++ enum class
es ending in “__Enum
“, with a value
member for the boxed type:
enum class DateTimeFormatFlags__Enum : int32_t {
None = 0x00000000,
UseGenitiveMonth = 0x00000001,
UseLeapYearMonth = 0x00000002,
UseSpacesInMonthNames = 0x00000004,
UseHebrewRule = 0x00000008,
UseSpacesInDayNames = 0x00000010,
UseDigitPrefixInTokens = 0x00000020,
NotInitialized = -1,
};
struct DateTimeFormatFlags__Enum__Boxed {
struct DateTimeFormatFlags__Enum__Class *klass;
MonitorData *monitor;
DateTimeFormatFlags__Enum value;
};
Arrays
.NET arrays are represented by the Il2CppArray
class:
typedef struct Il2CppArray
{
Il2CppObject obj;
Il2CppArrayBounds *bounds;
il2cpp_array_size_t max_length;
__declspec(align(8)) void* vector[32];
} Il2CppArray;
The vector
member contains the array elements themselves. Note that the value of 32 is just an artifact of how IL2CPP declares arrays and can be safely ignored. Il2CppInspector defines strongly-typed arrays as types with the “__Arrray
” suffix, for example here is int[]
(System.Int32[]
):
struct Int32 {
int32_t m_value;
};
struct Int32__Boxed {
struct Int32__Class *klass;
MonitorData *monitor;
int32_t fields;
};
struct Int32__Array {
struct Int32__Array__Class *klass;
MonitorData *monitor;
Il2CppArrayBounds *bounds;
il2cpp_array_size_t max_length;
int32_t vector[32];
};
Strings
IL2CPP does not use a translated equivalent of System.String
for string handling – in fact, if you try to call a constructor on the C++ compilation of System.String
it will deliberately throw an exception. Instead, IL2CPP uses the Il2CppString
struct to represent .NET strings:
typedef struct Il2CppString
{
Il2CppObject object;
int32_t length;
Il2CppChar chars[32];
} Il2CppString;
The chars
member contains the string data and once again the “32” can be ignored. Il2CppChar
is typedef’d as uint16_t
and strings are stored with UTF-16 encoding.
However, let us take a look at the generated type definition for the real System.String
type:
struct __declspec(align(8)) String__Fields {
int32_t m_stringLength;
uint16_t m_firstChar;
};
struct String {
struct String__Class *klass;
MonitorData *monitor;
struct String__Fields fields;
};
Astute readers familiar with C++ struct memory layouts may notice that Il2CppString::length
and String::m_stringLength
are of the same type and have the same offset from the start of the type. Similarly, Il2CppString::chars
and String::m_firstChar
have the same type and the same offset.
This is not a coincidence. Il2CppString
and the C++ equivalent of System.String
can be cast to each other, enabling Il2CppString
to be passed to C++-equivalent .NET methods, and System.String
s to be passed to IL2CPP API functions. This is a key piece of knowledge that will make your life much easier when dealing with strings!
Tip: Sometimes you may just want a C++ std::string
for logging purposes or manipulation with native C++ functions. Il2CppInspector provides a helper function in scaffolding projects called il2cppi_to_string
which accepts an Il2CppString*
or String*
and returns a std::string
. Strings converted this way are re-encoded as UTF-8.
Naming conventions in scaffolding projects
All generated types and function pointers are placed in the app
namespace to avoid naming conflicts with items in the global namespace. Invalid C++ identifier characters such as .
(dot), <
and >
(chevrons enclosing generic type parameters) are replaced with underscores.
Class and object struct
s are given the unqualified name of the C# type. Naming conflicts are resolved by adding an underscore and a numeric suffix, eg. if there are two types in different namespaces called Foo
, they will be defined as Foo
and Foo_1
.
Generic types produced by the C# compiler use a backtick suffix with the generic type’s arity to distinguish between them, eg. List<T>
is given the name List`1
and Dictionary<TKey, TValue>
is named Dictionary`2
. The names are followed by the fully-qualified generic type arguments in square brackets. This means that a List<int>
in C++ will take the IL name List`1[System.Int32]
and become List_1_System_Int32_
, and Dictionary<double, string>
will take the IL name Dictionary`2[System.Double,System.String]
and become Dictionary_2_System_Double_System_String_
. This is fiddly at first but you will quickly grow accustomed to it.
Note: Nested types inside generic types do not inherit the arity suffix from the declaring type. The name of the nested type Foo<T1,T2>.Bar
is Bar
, not Bar`2
, even though the C# compiler will generate Bar
as the generic type Bar<T1,T2>
. If a nested type adds new generic type parameters, it will once again get an arity suffix. The nested type Baz
in Foo<T1,T2>.Baz<T3>
will be compiled with three generic type parameters, but will be named Baz`1
.
Instance field objects have the type’s struct name folowed by __Fields
.
Static field objects have the type’s struct name followed by __StaticFields
.
Vtable objects have the type’s struct name followed by __VTable
.
Here is a complete example for System.IO.FileStream
(some members are elided for brevity):
struct FileStream__Fields {
struct Stream__Fields _;
struct Byte__Array *buf;
struct String *name;
struct SafeFileHandle *safeHandle;
// ...
};
struct FileStream {
struct FileStream__Class *klass;
MonitorData *monitor;
struct FileStream__Fields fields;
};
struct FileStream__VTable {
VirtualInvokeData Equals;
VirtualInvokeData Finalize;
VirtualInvokeData GetHashCode;
VirtualInvokeData ToString;
// ...
};
struct FileStream__StaticFields {
struct Byte__Array *buf_recycle;
struct Object *buf_recycle_lock;
};
struct FileStream__Class {
Il2CppClass_0 _0;
Il2CppRuntimeInterfaceOffsetPair *interfaceOffsets;
struct FileStream__StaticFields *static_fields;
const Il2CppRGCTXData *rgctx_data;
Il2CppClass_1 _1;
struct FileStream__VTable vtable;
};
Methods are named with the unqualified type name followed by an underscore and the method name. Naming conflicts are resolved by adding an underscore and a numeric suffix. For example, System.IO.Path
contains several overloads of the Combine
method; these are named Path_Combine
, Path_Combine_1
, Path_Combine_2
etc.
Methods and Properties
Compiled C++ code has no concept of classes so all methods are global. The first argument to a non-static class method will always be the instance of the type on which the method is being called – this is normal for C++ compiled code. In addition, IL2CPP adds a MethodInfo*
parameter as the final argument to each method (including static methods) – we’ll come back to this in a moment.
For example, here is how System.IO.FileStream.WriteByte
is defined by Il2CppInspector:
void Stream_WriteByte(Stream *__this, uint8_t value, MethodInfo *method);
Instance constructors in .NET are compiled with the special name .ctor
, and static constructors are named .cctor
. The System.IO.StreamReader
constructors are defined as follows:
// equivalent of StreamReader()
void StreamReader__ctor(StreamReader *__this, MethodInfo *method);
// equivalent of StreamReader(Stream)
void StreamReader__ctor_1(StreamReader *__this, Stream *stream, MethodInfo *method);
// ...
Properties are accessed via getter and setter methods. These are named as follows, using System.IO.FileStream.Position
as an example:
int64_t FileStream_get_Position(FileStream *__this, MethodInfo *method);
void FileStream_set_Position(FileStream *__this, int64_t value, MethodInfo *method);
Some methods share the same compiled code; these are called shared methods. If the method you are calling is a shared method, you must provide a MethodInfo*
– a reference to the original method definition – so that the compiled code knows which implementation you want. We demonstrate how to do this in part 2.
Vtable methods
.NET supports polymorphism, which allows methods declared as virtual
or abstract
in a base class – or methods declared in an interface
– to be overridden and reimplemented in derived classes. When a method with overrides is called, the correct implementation for the object’s type is executed regardless of the type reference we are using. Consider the following classical example:
class Actor
{
public virtual string Name() => "An in-game actor";
}
class Player : Actor
{
public override string Name() => "The player";
}
class Enemy : Actor
{
public override string Name() => "An enemy";
}
class DoSomething
{
public void DoWork() {
Actor player = new Player();
Actor enemy = new Enemy();
Console.WriteLine(player.Name()); // Outputs "The player"
Console.WriteLine(enemy.Name()); // Outputs "An enemy"
}
}
Despite the fact we have declared player
and enemy
as Actor
objects, when we call Name()
, the true underlying type’s Name
method is called. This is the essence of polymorphism, but how does this magic work? The answer lies in vtables.
When Name()
is invoked, Actor.Name
is not called directly. Under the hood, each type has a table – the vtable – of all of the virtual methods defined, inherited or overridden by the type, with pointers to the correct implementations for the type. When a virtual method is invoked, the vtable is consulted to find the correct implementation to call.
Prior to Unity 5.3.6, the vtable for a type was a simple list of function addresses. From Unity 5.3.6 onwards, this was changed to a VirtualInvokeData
for each vtable entry:
typedef void(*Il2CppMethodPointer)();
typedef struct VirtualInvokeData
{
Il2CppMethodPointer methodPtr;
const MethodInfo* method;
} VirtualInvokeData;
methodPtr
contains the function address of the vtable method, and method
contains its corresponding MethodInfo*
. This allows vtable methods to also be shared methods.
The vtable for Actor
in the example above looks like this when compiled with IL2CPP:
struct Actor__VTable {
VirtualInvokeData Equals;
VirtualInvokeData Finalize;
VirtualInvokeData GetHashCode;
VirtualInvokeData ToString;
VirtualInvokeData Name;
};
The first four vtable entries refer to method overrides inherited by every class from System.Object
. The final vtable entry refers to our Name
method. The vtables for Player
and Enemy
look exactly the same, as you would expect. A type’s vtable can be accessed via the Il2CppClass::vtable
field. This is normally an array of VirtualInvokeData
, but Il2CppInspector replaces it with individual named entries as shown above so that you don’t need to know the vtable index for a method. These vtable container types are given the suffix __VTable
.
About icalls
IL2CPP does not re-compile everything in the .NET base class library. Some types and methods are substituted with native implementations known as icalls. You can find these in the libil2cpp/icalls
folder of any IL2CPP installation. For example, IL2CPP provides a native implementation of System.Net.Dns.GetHostByName
:

Generally, the process of making icalls is seamless – you merely call a .NET method implementation as normal, and the .NET methods are compiled as thunks which make the icall for you. However, it’s useful to know about this behaviour for two reasons: first, calling a static method on a type which is substituted with an icall will not result in the type’s static constructor (if any) being executed if it hasn’t already. For example, the Mono method System.Environment.GetOSVersionString
is implemented in libil2cpp/icalls/mscorlib/System/Environment.cpp
as:
Il2CppString * Environment::GetOSVersionString(void)
{
return il2cpp::vm::String::New(il2cpp::os::Environment::GetOsVersionString().c_str());
}
Calling Environment_GetOSVersionString()
will redirect to this call, bypassing any static constructors in Environment
.
Secondly, some methods are purposely disabled by IL2CPP, such as string constructors mentioned earlier. For example, IL2CPP prevents you from creating a new AppDomain
:
Il2CppAppDomain* AppDomain::createDomain(Il2CppString*, mscorlib_System_AppDomainSetup*)
{
NOT_SUPPORTED_IL2CPP(AppDomain::createDomain, "IL2CPP only supports one app domain, others cannot be created.");
return NULL;
}
This will throw an unhandled exception in your DLL and cause the application to crash, so it can be very perplexing if you’re not aware of it! If you get unexpected crashes when calling specific methods, check the IL2CPP source code to see if there is a native implementation.
Native types
Similarly to Il2CppString
, IL2CPP implements a considerable number of other native C++ structs to represent certain key .NET types. As in the examples of Environment.GetOSVersionString
and AppDomain.CreateDomain
above where Il2CppString*
is substituted in place of System.String
in the method arguments, these native structs replace various other .NET types in compiled methods.
Some key objects are:
Il2CppThread
substitutesSystem.Threading.Thread
Il2CppException
andIl2CppSystemException
substituteSystem.Exception
Il2CppDelegate
substitutes delegates (which in compiled IL code always derive fromSystem.MulticastDelegate
)Il2CppStringBuilder
substitutesSystem.Text.StringBuilder
There are numerous others, they all begin with Il2Cpp
and can be found at the top of your generated appdata/il2cpp-types.h
file before any application-specific types are defined.
Generic types
When a .NET application is compiled, the IL metadata contains all the generic type definitions defined in the source code. This enables you to use .NET Reflection’s MakeGenericType() method to create new concrete instantiations of a generic type with specified generic type arguments.
Compiled C++ code does not work like this. When C++ templates (the equivalent of .NET generics) are processed by the compiler, only the actual concrete types used by the source code are compiled. IL2CPP handles .NET generics in a similar way. For example, if an application uses two instantiations of List<T>
– say List<int>
and List<float>
– IL2CPP will generic concrete non-generic types for each of these, and they will be the only instantiations of List<T>
available in the application by default.
The IL2CPP metadata does contain all of the generic type definitions from the source code, and IL2CPP substitutes MakeGenericType
with an icall providing support to generate concrete types not in the original application, however you will not be able to use Il2CppInspector’s auto-generated type definitions and method prototypes to access these types.
Visibility (access modifiers)
Compiled code has no concept of access modifiers like private
or protected
, so every field, property and method is considered public and available to use.
Bytecode stripping
By default, applications compiled with Unity are run through a so-called bytecode stripper which removes all of the types not used by the application. This means that you may find some types present in the .NET base class library are unavailable. Generally this is of limited impact, but it is something to be aware of if you discover a type you wish to use is not present. There is not much you can do about this besides implementing replacement code yourself.
Metadata usages
One final concept is key to understanding how an IL2CPP application works: metadata usages.
IL2CPP maintains a table of various kinds of items that have been initialized: type definitions (TypeInfo
), type references (Il2CppType
), method definitions and references (MethodInfo
), field definitions (FieldInfo
) and string literals (StringLiteral
). Each entry in the table is represented by a metadata token. When the application starts, nothing is initialized. Each time a .NET method equivalent is called for the first time, IL2CPP consults the list of items that the method requires to be initialized before it can execute (the method’s metadata usage range), initializes them, and stores a pointer to each initialized item. This is called lazy initialization and improves the startup time of the application at the expense of slightly slower first-time execution of each method.
The two most important items are the type definitions and method references. When you access a type’s static members, you look up the TypeInfo
entry of the type in the metadata usages table to get its Il2CppClass
object. You may also recall that the final parameter of each method is a MethodInfo*
. To find this object, you look up the MethodInfo
entry of the corresponding method.
The upshot of this is you cannot access static members of a class, or methods which require a MethodInfo*
argument, until the corresponding metadata usages have been initialized. If you attempt to do this, the application will crash. This is not normally a problem, but depending on how early you inject the DLL, some items may not be initialized yet, so it is something to bear in mind as it can catch you out.
Tip: Il2CppInspector generates named pointer variables to all of these items so you don’t actually have to perform the lookup yourself – for example if you want the Il2CppClass*
for a type Foo
you can just use *Foo__TypeInfo
and so on. If you need the MethodInfo*
for BigInteger.ToString
, use *BigInteger_ToString__MethodInfo
.
Tip: Prior to Unity 2020.2 (metadata v27), the metadata usage tokens were stored in global-metadata.dat
and the initialized pointers were created in memory at runtime. Starting with Unity 2020.2, metadata usages are stored in a table in the binary, and the initialization step replaces the token in-place with a pointer to the relevant struct; IL2CPP sets the bottom bit of every token so it can tell which table entries are initialized pointers and which are tokens. Il2CppInspector provides a helper method bool il2cppi_is_initialized(T* metadataItem)
which will accept a pointer to a metadata usage (token, pointer or uninitialized memory) and determine whether it is initialized, regardless of Unity version. You can use this to check for uninitialized items before attempting to use them.
PART 2: IL2CPP scaffolding code examples
Now we’ve got the theory under our belt, let’s crack on with the good stuff – some working code!
Before you begin
Many IL2CPP methods require some back-end thread structures to be set correctly or they will throw an access violation exception. Place the following at the start of your code to make sure everything is set up:
il2cpp_thread_attach(il2cpp_domain_get());
Finding the starting point
Usually when we want to access something in the target application, we have to find it via a pointer chain which starts with a static field or property. We don’t know where the object instances have been allocated in memory yet, but we can use the TypeInfo
metadata usages – which have a fixed address relative to the image base – to get the static fields of any initialized type.
Let’s suppose we have decided we want infinite health in the beautiful yet grueling single-player open-world survival game The Long Dark. We’ve identified a type containing the player’s health:
public class Condition : MonoBehaviour
{
public float m_CurrentHP;
public float m_MaxHP;
public float m_CriticalHP;
public float m_TimeToDisplayConditionWhenChanged;
public float m_TimeToDisplayConditionDelta;
// ...
}
We don’t know where this object is in memory, but we can skim the C# or DLL output of Il2CppInspector to find every type which uses Condition
. A quick search for static Condition
gives us a fast answer:
public class GameManager : MonoBehaviour
{
// ...
private static BrokenRib m_BrokenRib;
private static CabinFever m_CabinFever;
private static Condition m_Condition;
private static ConditionTableManager m_ConditionTableManager;
private static DownsampleAurora m_DownsampleAurora;
// ...
}
Excellent! Likely all we need to do is set m_CurrentHP
to m_MaxHP
periodically. How?
Static fields
This is quite straightforward:
- Use the
TypeInfo
metadata usage entry to fetchGameManager
(anIl2CppClass*
typed asGameManager__Class
*) - Use the
static_fields
field to fetchGameManager
‘s static fields (typed asGameManager__StaticFields
*) - Fetch the player’s condition object (an
Il2CppObject*
typed asCondition*
) from the static fieldGameManager.m_Condition
The final code is a one-liner:
auto condition = (*GameManager__TypeInfo)->static_fields->m_Condition;
Instance fields
Set one instance field to the value of another instance field to replenish the player’s health, by using the fields
field (typed as Condition__Fields
– note, not a pointer) of Il2CppObject
:
condition->fields.m_CurrentHP = condition->fields.m_MaxHP;
The complete example looks like this:
while (true) {
if (il2cppi_is_initialized(GameManager__TypeInfo)) {
auto condition = (*GameManager__TypeInfo)->static_fields->m_Condition;
condition->fields.m_CurrentHP = condition->fields.MaxHP;
// Let's give ourselves infinite sprint while we're at it!
if (il2cppi_is_initialized(PlayerMovement__TypeInfo)) {
(*PlayerMovement_TypeInfo)->static_fields->m_UnlimitedSprint = true;
}
}
Sleep(100);
}
This resets the player’s health to the maximum possible amount every 100ms. Note that when the application is updated, you can regenerate the scaffolding project in place – main.cpp
where this code goes will not be replaced – and recompile without any code changes. You don’t need to konw anything about the memory layout of the application. This is the beauty of the scaffolding project.
Calling methods
Non-shared methods do not require an instance of MethodInfo*
so you can just pass nullptr
:
auto osVersionString = Environment_GetOSVersionString(nullptr);
For instance methods, the first argument should be the object we are calling the method on, for example if we have a Vector3__Boxed
called myVector3
:
auto vectorAsString = Vector3_ToString(myVector3, nullptr);
Shared methods require a MethodInfo*
argument as outlined in part 1. You can use the global variables defined with the suffix __MethodInfo
to retrieve these structs. For example, to create an empty array of double
(System.Double
):
auto emptyDoubleArray = Array_Empty_12(*Array_Empty__MethodInfo);
(in this specific example project, Array_Empty_12
returns a Double__Array*
. Different projects will have different numerical suffixes for their overloads – unfortunately)
If we look at il2cpp-functions.h
we can see that many methods share this same code:
// ...
DO_APP_FUNC(0x00D20990, KeyValuePair_2_System_Guid_System_Object___Array *, Array_Empty_10, (MethodInfo * method));
DO_APP_FUNC(0x00D20990, KeyValuePair_2_System_Object_System_Object___Array *, Array_Empty_11, (MethodInfo * method));
DO_APP_FUNC(0x00D20990, Double__Array *, Array_Empty_12, (MethodInfo * method));
DO_APP_FUNC(0x00D20990, Int32__Array *, Array_Empty_13, (MethodInfo * method));
DO_APP_FUNC(0x00D20990, UInt32__Array *, Array_Empty_14, (MethodInfo * method));
DO_APP_FUNC(0x00D20990, SequenceNode_SequenceConstructPosContext__Array *, Array_Empty_15, (MethodInfo * method));
DO_APP_FUNC(0x00D20990, EventDispatcher_DispatchContext__Array *, Array_Empty_16, (MethodInfo * method));
// ...
Notice how all of these methods have the same function pointer. Hence, we must supply the MethodInfo*
to differentiate them. If you call a method which does not share its function with any other methods, the method is not shared and MethodInfo*
can be set to nullptr
.
Creating an object
Creating a .NET-equivalent object in IL2CPP is a two-step process. First you must allocate memory for the object and initialize it using the API call il2cpp_object_new
, then call its constructor:
auto myVector3 = (Vector3__Boxed*) il2cpp_object_new((Il2CppClass *) *Vector3__TypeInfo);
Here we create a new Vector3
object. il2cpp_object_new
takes an Il2CppClass*
and returns an Il2CppObject*
, so we must cast the type definition and the returned struct. Since Vector3
is a value type, we will receive the boxed object instance, therefore we must cast to Vector3__Boxed*
.
Now we call whichever constructor on the object we want to use:
Vector3__ctor(myVector3, 1.0f, 2.0f, 3.0f, nullptr);
Notice that .NET method arguments with primitive types such as float
get translated directly to their C++ equivalents. The constructor does not need a MethodInfo*
so we once again pass nullptr
.
We can now prove the object has been created correctly by accessing its instance fields:
std::cout << "x: " << std::to_string(myVector3->fields.x)
<< ", y: " << std::to_string(myVector3->fields.y)
<< ", z: " << std::to_string(myVector3->fields.z)
<< std::endl;
Working with strings
As mentioned in part 1, String*
and Il2CppString*
are tacitly equivalent, so you can cast between them with reinterpret_cast
:
// Get an example string that will definitely be present in every IL2CPP app
auto systemString = Environment_GetOSVersionString(nullptr); // returns app::String*
// The two are somewhat interchangeable
auto il2cppString = reinterpret_cast<Il2CppString*>(systemString);
The following statements are therefore semantically equivalent and access the same memory location to retrieve the string’s length:
// Both of these will give the correct length regardless of the true underlying type
std::cout << "System.String " << systemString->fields.m_stringLength << std::endl;
std::cout << "IL2CPPString " << il2cppString->length << std::endl;
The il2cppi_to_string
helper function provided by Il2CppInspector can be called with either type:
// Conversion functions are provided for both Il2CppString* and System.String* (app::String*)
std::cout << il2cppi_to_string(systemString) << std::endl;
std::cout << il2cppi_to_string(il2cppString) << std::endl;
To create a string, you cannot use System.String
‘s constructors – these are redirected to icalls that throw exceptions. Instead, you should use the internal Mono function String.CreateString
. This function has many overloads accepting various types of pointer and array; an easy one to use accepts a uint16_t*
to a Unicode string and can be called as follows:
// Creating a System.String*
auto new_system_str = String_CreateString_2(nullptr, (uint16_t *) u"a new System.String", nullptr);
(the source code linked above states that the this
pointer should not be used, so we set it to nullptr
)
However, if you don’t need any special functionality from the overloads, there is an easier way. Just call the IL2CPP API il2cpp_string_new
:
// Creating an Il2CppString*
auto new_il2cpp_str = il2cpp_string_new("a new Il2CppString");
Since String
and Il2CppString
are equivalent, you can use the return value of il2cpp_string_new
in .NET-equivalent String
methods. Both of the following lines are equivalent to calling myString.Replace('S', 'X')
:
// You can use either type as a System.String argument (or write to a System.String field in a class)
new_il2cpp_str = reinterpret_cast<Il2CppString*>(String_Replace(reinterpret_cast<String*>(new_il2cpp_str), u'S', u'X', nullptr));
new_system_str = String_Replace(new_system_str, u'S', u'X', nullptr);
// Output is the same
std::cout << il2cppi_to_string(new_system_str) << std::endl;
std::cout << il2cppi_to_string(new_il2cpp_str) << std::endl;
Working with generic types
Generic types behave like regular types in IL2CPP. Let’s start by creating an IEnumerable<int>
with the static method Enumerable.Range
:
// Returns IEnumerable_1_System_Int32_ *
// Equivalent to IEnumerable<int> enumerable = Enumerable.Range(1, 10);
auto enumerable = Enumerable_Range(1, 10, nullptr);
Let’s now pass this IEnumerable<int>
to the constructor of List<int>
to copy it into a new list. First, initialize the object:
// Create a List<int>
auto myList = (List_1_System_Int32_*) il2cpp_object_new((Il2CppClass*) *List_1_System_Int32___TypeInfo);
Now we need to call the constructor. This is a shared method, so this time we’ll need the corresponding MethodInfo*
as the last argument:
// This is equivalent to var myList = new List<int>(enumerable);
List_1_System_Int32___ctor_1(myList, enumerable, *List_1_System_Int32___ctor_1__MethodInfo);
If all went well, our list should now contain 10 items:
std::cout << myList->fields._size << std::endl; // should be 10
We use a normally private field _size
here for speed, but if you prefer you can instead fetch the Count
property as you normally would in C#:
std::cout << List_1_System_Int32__get_Count(myList, nullptr) << std::endl;
Iterating over collections
The standard foreach
loop we use in C# for collection iteration does quite a lot of magic behind the scenes, but in a nutshell, a loop like:
foreach (var item in myCollection)
Console.WriteLine(item);
is approximately (when ignoring IDisposable
and exceptions) semantically equivalent to:
using (var enumerator = myCollection.GetEnumerator()) {
while (enumerator.MoveNext()) {
item = enumerator.Current;
Console.WriteLine(item);
}
}
This is therefore the mechanism we need to use in C++ to iterate collections that implement IEnumerable
. Unfortunately, MoveNext
is frequently inlined during compilation so there is not always an easy way for us to call it. Although this can’t be worked around for every possible collection type, for commonly used collections such as List<T>
which are backed by an array, there is – luckily – an easier way. We can simply fetch the backing array an iterate over it with a simple for
loop:
auto items = myList->fields._items->vector;
for (int i = 0; i < myList->fields._size; i++)
std::cout << items[i] << std::endl;
For the above list example, this will print the numbers 1-10.
Calling vtable methods
Let’s go back to our Vector3
example. Naturally, if you know exactly which implementation of a virtual method you want, and you know the underlying type, you can just call the method directly. For example, Vector3.ToString()
:
String* pszVector3 = Vector3_ToString(myVector3, nullptr);
std::cout << il2cppi_to_string(pszVector3) << std::endl;
Let’s pretend we don’t know we are handling a Vector3
, but we want to call ToString()
on the object. Here is how we call the ToString
vtable method:
// Pretend we are dealing with an unknown type derived from System.Object
auto someObject = (Object*) myVector3;
// Call object.ToString() (which will actually invoke Vector3.ToString())
auto toString = (String*(*)(Object*, MethodInfo*)) someObject->klass->vtable.ToString.methodPtr;
auto toString_MethodInfo = (MethodInfo *) someObject->klass->vtable.ToString.method;
auto pszSomeObject = toString(someObject, toString_MethodInfo);
First, we fetch the pointer to the object’s ToString
implementation and cast it to the correct method signature, storing it in toString
.
Secondly, we fetch the MethodInfo*
for the call into toString_MethodInfo
.
Finally, we call the function, passing in our object and the MethodInfo*
, which will ultimately invoke Vector3.ToString()
.
Info: We know this is rather unruly! A future version of Il2CppInspector will generate strongly-typed vtable function pointers and helpers to clean this up.
Managed exception handling
You can enclose C++ code in try...catch
blocks as you would in C#. If an exception is thrown, IL2CPP will package it up into an Il2CppExceptionWrapper
which you can catch as follows in this example which attempts to call File.OpenRead
on a file that doesn’t exist, throwing a System.IO.FileNotFoundException
:
try {
auto someFile = (String*) il2cpp_string_new("some file that doesn't exist");
File_OpenRead(someFile, nullptr);
}
catch (Il2CppExceptionWrapper& e) {
// do something with the exception
}
Il2CppExceptionWrapper
contains a single field ex
which is a pointer to Il2CppException
. The message
field contains the message normally found in System.Exception.Message
:
catch (Il2CppExceptionWrapper& e) {
auto ex = e.ex;
std::cout << il2cppi_to_string(ex->message) << std::endl;
}
If you need fields from the underlying .NET exception, you have to inspect the exception name and perform a cast to the corresponding managed exception class. The following example outputs the name of the missing file (System.IO.FileNotFoundException._fileName
):
auto exName = ex->object.Il2CppClass.klass->name;
if (!strcmp(exName, "FileNotFoundException")) {
auto exFNF = (FileNotFoundException*) (&ex->object);
auto missingFile = exFNF->fields._fileName;
std::cout << il2cppi_to_string(missingFile) << std::endl;
}
Il2CppException.object
contains the managed exception object. Note that we use strcmp
to compare for equality because we are comparing a const char *
to a std::string
and a pointer comparison with ==
will always return false.
Inspecting class definitions
We have already looked at a few fields of Il2CppClass
such as static_fields
, vtable
and name
. The fields of Il2CppClass
are somewhat reminiscent of those in the managed type System.Type
, but with extra C++ and IL2CPP-specific information.
You can always retrieve the class definition of an object (Il2CppObject
) via its klass
field.
The definition of Il2CppClass
is too long to reproduce here so I recommend you check out the definition near the top of the generated appdata/il2cpp-types.h
file if you’re interested. Among other things, you can access the type’s name, namespace, source assembly, underlying element type (for arrays, pointers and references), declaring type (for nested types), parent type (for derived types), generic type definition (for concrete generics), and inspect the list of fields, properties, methods, events, nested types, implemented interfaces and so on. This can be very useful for walking the type hierarchy at runtime.
Using the IL2CPP APIs
IL2CPP provides a suite of APIs that can be used to simplify certain tasks. All of the API names begin with il2cpp_
and we’ve already seen a few of them such as il2cpp_object_new
and il2cpp_string_new
. The available APIs differ between Unity versions and individual applications; the APIs available in your project will be defined in the generated file appdata/il2cpp-api-functions.h
.
The scope of what can be performed with these APIs is too large to cover in one article, but we can kickstart with a couple of examples.
Here is how to iterate over all of the assemblies in the application:
Il2CppDomain* domain = il2cpp_domain_get();
size_t size = 0;
const Il2CppAssembly** assemblies = il2cpp_domain_get_assemblies(domain, &size);
for (auto i = 0; i < size; i++) {
std::cout << assemblies[i]->aname.name << '\n';
}
See https://github.com/djkaty/Il2CppInspector/issues/39#issuecomment-661808065 for a more complete example of this.
This code from beatsaber-hook is a more complex example which shows how to create generic types with IL2CPP APIs (the APIs have been abstracted into the il2cpp_functions
namespace and stripped of the leading il2cpp_
prefix in this example).
Note: Some applications strip most or all API exports besides il2cpp_init
which is required by UnityPlayer.dll. Others will encrypt the API export names to defeat casual hackers. Different Unity versions have also occasionally changed the API arguments. Il2CppInspector will make a best effort to decrypt API exports and remove unavailable exports from the header files to avoid compilation failures. It will also try to determine the Unity version from the file contents and generate the correct method signatures for you. You can guarantee the correct API headers are generated by telling Il2CppInspector exactly which Unity version was used to compile the application.
Some applications also replace API exports with dummy functions that do nothing, so be aware of this. Note that almost none of the APIs are required and you can almost always accomplish the same tasks using the generated type and metadata usage headers.
The final word
Scaffolding is a powerful tool but requires some finesse to get right. Much more can be said about the techniques and possibilities available, but I hope this whirlwind tour provided a handy crash course on your path to interacting with IL2CPP applications from C++. Happy hacking!
Hi katy!
I used Riru-Il2CppDumper(https://github.com/Perfare/Riru-Il2CppDumper) to create a dump.cs while game is runnng, one of the classes is defined as follows:
public class DialogTeamStadiumRaceResultList : DialogTeamStadiumRaceInfo // TypeDefIndex: 12843
{
// Fields
private RectTransform _itemParent; // 0x48
private RaceResultContainer _item; // 0x50
private ButtonCommon _closeButton; // 0x58
private ButtonCommon _replayButton; // 0x60
private readonly List`1 _itemList; // 0x68
private Int32 _roundNum; // 0x70
private static DialogCommon _dialog; // 0x0
}
But what I expected was a C ++ scaffolding code. my question is have you ever written any tools that can convert such C# source code -only contains declaration of class and methods- into CPP source code with c++ scaffolding format ? Or what part of the IL2CppInspector repo should I refer to if I want to implement such tool?
Riru-Il2CppDumper is a separate project by a different author and does not generate C++ code. You have to input the metadata and binary files into Il2CppInspector to do that, and it will generate the scaffolding code for you automatically.
See: https://katyscode.wordpress.com/2020/11/27/il2cppinspector-tutorial-how-to-create-use-and-debug-il2cpp-dll-injection-projects/ for details
Wow! Browsing this has really blown my mind. Something I don’t yet understand from the docs is how to handle base types. Consider this (C#):
class Sequence : IBufferWriter { … }
class MessagePackWriter
{
MessagePackWriter(IBufferWriter writer) { … }
…
}
var sequence = new Sequence<System.Byte>();
var mpw = new MessagePackWriter(sequence);
In C# this just works.
The equivalent in a C++ DLL injection project looks like this:
auto sequence = (Sequence_1_System_Byte_*) il2cpp_object_new(…
Sequence_1_System_Byte___ctor_1(sequence,…
auto mpw = (MessagePackWriter__Boxed*) il2cpp_object_new(…
MessagePackWriter__ctor(mpw, sequence, nullptr);
That last line doesn’t compile, as it expects a IBufferWriter_1_System_Byte_ as the second argument. If I cast sequence to the expected type I get a memory access violation.
Given the sequence object, how do I get the correct memory address for the base type which I can pass to MessagePack’s ctor?
So, after looking at the generated source code, it seems casting the IL2CPP representation of the derived type to its IL2CPP representation of the derived type should work.
Not sure where the access violation is coming from. Will have to dig deeper.
Okay, so I don’t understand what is going on. Even this throws a memory access violation:
void Run()
{
Byte__Array* emptyByteArray =
app::Array_Empty_7(*app::Array_Empty_7__MethodInfo);
}
Any insight as to what might be going wrong is very much appreciated. Next step would be to get out a disassembler which I don’t have.
Wish I could edit. Should read:
So, after looking at the generated source code, it seems casting the IL2CPP representation of the derived type to the IL2CPP representation of its BASE type should work.
Not sure where the access violation is coming from. Will have to dig deeper.
Did you manage to do it?
Nevermind I couldn’t cast because I didn’t cast to a pointer
Hello, I’m confused. I have a BaseEntity class and in this class there is a variable array: public static IDictionary <long, BaseEntity> Entitys; I tried to restore the class and it kind of worked out for me.
static IDictionary_2_System_Int64_BaseEntity_ * GetEntitys ()
{
if (il2cppi_is_initialized (BaseEntity__TypeInfo))
{
return (* BaseEntity__TypeInfo) -> static_fields-> Entitys;
}
}
IDictionary_2_System_Int64_BaseEntity_ * entitysMassive = BaseEntity_SOTA :: GetEntitys ();
but if I access the pointer to my entitysMassive->, then there are no fields, there is only monitor and class, but where can I get the fields I don’t understand
I also tried this:
for (int i = 0; i <25; i ++)
{
Transform * tr = BaseEntityClass [i] .fields._Eya_k__BackingField;
}
but how can I refer to Transform :: Position I don’t understand, I need from the resulting Transform Eya; get position
Hey, thanks this was very helpful.
Is it possible to monkey patch a method of a class?
Thanks
I noticed some Unity functions are also implemented as icalls:
translates to
I can’t seem to find where the icall is registered in the Il2Cpp output project. Is it called from UnityPlayer.dll (since il2cpp_add_internal_call is an export)?
Also what does UnityPlayer.dll usually contain, and is it also compiled by Il2Cpp? If so, where is the Il2Cpp output for UnityPlayer.dll placed when I build the project in Unity?
BTW very awesome work w/ Il2cppinspector and great explanation on the blog.
Hi,Katy. I want to know if I want to use equivalent code like
typeof(GameObject)
in il2cpp scaffold, how can I get the type?Then is it possible to replace a method in class like hook or something?