mono-rt
Dynamic bindings to the Mono runtime, designed for process injection into Unity games and other Mono-hosted applications on Windows.
Rather than starting a new JIT domain, this crate attaches to a Mono runtime that is already
running in the target process. All exports are resolved at runtime via GetModuleHandleW and
GetProcAddress, so no import library or compile-time link to mono.dll is needed.
Platform support
| Platform | Status |
|---|---|
| Windows | Supported |
| Linux | Planned but contributions welcome! |
The core binding layer is platform-agnostic; only init() uses a Windows-specific API to locate
the loaded module. A Linux port would replace that with dlopen/dlsym and is a self-contained
change.
Getting started
Add the dependency:
[]
= "0.2.0"
# or, for the latest commit:
= { = "https://github.com/theo-abel/mono-rt" }
Then in your injected code:
use *;
// 1. Resolve exports from the already-loaded Mono DLL.
// Common names: "mono.dll" (Unity <= 2017), "mono-2.0-bdwgc.dll" (Unity 2018+)
init?;
// 2. Attach the current thread. Keep the guard alive for the duration of all Mono work.
let _guard = unsafe ;
// 3. Navigate the assembly graph.
let image = find?.expect;
let class = image.class_from_name?.expect;
// 4. Look up a method and invoke it.
let method = class.method?.expect;
let domain = root?.expect;
let obj = class.new_object?.expect;
let result = unsafe ;
// 5. Read a field value directly from a live instance.
let hp_field = class.field?.expect;
let offset = hp_field.offset?;
// let hp: f32 = unsafe { mono_rt::read_field(obj_ptr, offset) };
Threading model
Mono requires every thread that calls into the runtime to be registered with the garbage
collector. MonoThreadGuard::attach() handles this registration, and the guard automatically
deregisters the thread when dropped.
All handle types (MonoClass, MonoObject, MonoMethod, ...) are !Send + !Sync, they are
bound to the thread on which they were obtained. The compiler prevents them from crossing thread
boundaries silently. If you do need to transfer a handle to another thread where you can
guarantee both threads are attached, you can opt in with an explicit unsafe impl Send.
Runtime API coverage
The table below reflects the current state. The goal is to cover the APIs most relevant to game modding and runtime inspection; lower-level or rarely-needed functions can be added as needed.
| Area | Covered | Not yet covered |
|---|---|---|
| Initialization | init, thread attach/detach, root domain |
domain unload, domain creation |
| Assembly | open by path, enumerate loaded, get image | get by name, get list from domain |
| Image | find by name, class lookup | enumerate all classes |
| Class | field/method by name, field/method enumeration, type descriptor, vtable, object allocation | parent class, interfaces, properties, events, nested types |
| Field | offset, name, type, static read | instance write, static write |
| Method | invoke (raw + typed Value), name |
full signature, parameter types, return type, flags |
| Object | unbox | get class, get type, clone |
| String | create from &str, convert to String |
|
| Array | length, element address | create, set element |
| Type | kind (TypeKind enum), boxing |
is_valuetype, get_class |
| GC | — | pinned handles (gc_handle_new/free) |
| Exceptions | surface as MonoError::ManagedException |
inspect message, stack trace |
Safety
unsafe appears in two places in the public API:
MonoThreadGuard::attach(): you assert that the returned guard will be dropped on the same thread that calledattach.MonoMethod::invoke/invoke_with: you assert that the object and argument types match the method's actual signature, which Mono does not validate at the call site.read_field/write_field: you assert that the offset and typeTare correct for the target field, and that the object pointer is live.
Everything else - null checks, CString conversion, error propagation - is handled by the library.
Integration tests
The crate ships a standalone test binary (mono-rt-integration) that exercises every public
API layer against a real, live Mono runtime. Unlike the unit tests, this binary must run
against an actual Mono DLL, it is not part of cargo nextest run.
Prerequisites
You need a mono-2.0-bdwgc.dll (Unity 2018+) or mono-2.0-sgen.dll (standard Mono
install) already on disk. The binary never modifies the DLL or the process it targets; it
only loads it in-process to call the inspection API.
Running
Use the test-integration recipe. The first argument is the path to the Mono DLL; the
second (optional) argument is the directory that contains mscorlib.dll. The second
argument is only needed when the DLL comes from a game whose managed assemblies are not
stored under the standard lib/mono/4.5/ layout next to the runtime.
Standard Mono installation (choco install mono or the official installer):
just test-integration "C:\Program Files\Mono\bin\mono-2.0-sgen.dll"
Unity game DLL (assemblies live in <Game>_Data\Managed\):
just test-integration `
"C:\path\to\game\MonoBleedingEdge\EmbedRuntime\mono-2.0-bdwgc.dll" `
"C:\path\to\game\GameName_Data\Managed"
A passing run prints one [PASS] line per test and exits with code 0:
[PASS] init_succeeds
[PASS] root_domain_is_some
...
[PASS] thread_guard_attach_drop
Credits
Some inspiration was drawn from the mono-rs project, particularly the public API design. Shoot out to Bartosz for paving the way!
License
GPL-3.0-only. See LICENSE.