A runtime instrumentation bridge for IL2CPP-hosted processes -- typically
Unity applications compiled for Linux, whose managed side is delivered as
generated C++ inside GameAssembly.so. The bridge is a preloadable shared
library that exposes a stable C ABI for enumerating managed assemblies,
resolving classes and methods, invoking managed code, and reading or
writing fields on live managed objects.
This is a research write-up, not a general-purpose library. It exists to
document a specific set of techniques (signature-based method resolution,
lazy class init recovery, main-thread dispatch under the constraint of a
LD_PRELOADed native library) and to show what a working solution to each
looks like.
IL2CPP is Unity's ahead-of-time compilation pipeline. It converts the
managed C# side of a Unity project into generated C++, then compiles the
result to native code and packages it as GameAssembly.so alongside a
small runtime that exposes a stable il2cpp_* C API. That API is what
this bridge uses to walk metadata and invoke managed methods.
The public IL2CPP runtime is not distributed as a link-time library.
There is no libil2cpp.so you can build against. Every consumer either
generates headers from the target build (which ties them to a specific
target) or resolves the runtime dynamically through dlsym. This bridge
takes the second path, so it works against any IL2CPP build whose
il2cpp_* exports are present.
38 C-linkage functions, grouped roughly:
- Bootstrap and discovery:
BridgeInit,BridgePing,BridgeGetTargetModuleBase - Metadata walking:
BridgeFindImage,BridgeFindClass,BridgeFindClassByName,BridgeIterateImageClasses,BridgeEnumClassMethods,BridgeIterateClassFields - Method resolution:
BridgeFindMethod,BridgeFindMethodBySignature,BridgeFindMethodBySignatureNamed,BridgeFindStaticMethodReturning - Invocation:
BridgeInvokeDirect,BridgeInvokeStatic,BridgeInvokeInstance,BridgeInvokeStaticOnClass,BridgeGetStaticProperty - Field access:
BridgeReadStaticFieldValue,BridgeWriteStaticFieldValue,BridgeReadInstanceFieldValue,BridgeWriteInstanceFieldValue - Values, strings, arrays:
BridgeStringNew,BridgeBoxValue,BridgeObjectUnbox,BridgeFindObjectsOfType,BridgeArrayLength,BridgeArrayGetObject - Main-thread dispatch:
BridgeSubmitMainThread,BridgeRunOnMainThread,BridgeDrainMainThreadQueue,BridgeIsMainThread,BridgeNoteMainThread - Lazy class init:
BridgeEnsureClassInit - Logging:
RuntimeBridgeLog,RuntimeBridgeVLog,BridgeLogException
Every public function calls BridgeInit() first, and safely degrades to
a zero / null / false result if bootstrap has not completed or failed.
If you have 15 minutes: docs/DESIGN.md. It walks through the four
problems that took the most work to solve, and how the code addresses each:
- Bootstrap timing. Target module is loaded before its own runtime is initialized. Naive dlsym races and reads null domain pointers.
- Signature-based method resolution. Obfuscated targets rename classes and methods on every rebuild. Resolution by name breaks on every patch; resolution by shape (return type, parameter types, arity) does not.
- Lazy class init.
il2cpp_class_get_methodssilently returns an empty iterator until the class is initialized.il2cpp_runtime_class_initruns managed.cctorcode and must be dispatched to the main thread. - Main-thread dispatch with race-safe timeout cleanup. Blocking calls
through
BridgeRunOnMainThreadallocate their sync primitives on the caller's stack. If the caller times out before the drain thread picks up the job, we have to remove the job under the queue mutex before the sync primitives get destroyed.
$ make
g++ -std=c++17 -O2 -Wall -Wextra -fPIC -fvisibility=hidden -Iinclude \
src/runtime_bridge.cpp -o runtime_bridge.so -shared -ldl -lpthread
You get runtime_bridge.so, ~48KB, exporting 38 Bridge* and Runtime*
symbols under default visibility.
The bridge is designed to be LD_PRELOADed into an IL2CPP process
alongside a domain-specific consumer -- a second preloaded library that
knows the shape of the managed API in the target and drives the bridge
to do useful things.
$ LD_PRELOAD=./runtime_bridge.so:./my_domain_layer.so ./target_binary
Inside the domain layer, resolve bridge exports via dlsym(RTLD_DEFAULT, ...)
because the bridge is loaded with RTLD_GLOBAL:
auto find_class = (BridgeFindClassFn)dlsym(RTLD_DEFAULT, "BridgeFindClass");
Il2CppClass* env = find_class("mscorlib.dll", "System", "Environment");See examples/inspect_target.cpp for a self-contained walkthrough that
resolves the bridge, waits for it to come up, and enumerates the methods
of System.Environment.
BRIDGE_LOG-- path to the bridge log file (default/tmp/runtime_bridge.log).BRIDGE_TARGET_MODULE-- override the default target module name (GameAssembly.so) if the host uses a different filename.
- Linux only.
/proc/self/maps,dlopen(..., RTLD_NOLOAD), andLD_PRELOADare all POSIX-y, but the module-discovery logic is Linux-shaped. A macOS port would needDYLD_INSERT_LIBRARIESand a different maps reader. - No hot-reload. The bridge does not attempt to survive a runtime reload of the target module.
- No generated proxies. External consumers pass class names, method names, and argument arrays raw. A future iteration could generate typed stubs from a metadata dump, but that would defeat the point of signature-based resolution.
MIT. See LICENSE.