mono-rt 0.2.0

Dynamic bindings to the Mono runtime for process injection into Unity games and Mono-hosted applications on Windows
Documentation

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:

[dependencies]

mono-rt = "0.2.0"

# or, for the latest commit:

mono-rt = { git = "https://github.com/theo-abel/mono-rt" }

Then in your injected code:

use mono_rt::prelude::*;

// 1. Resolve exports from the already-loaded Mono DLL.
//    Common names: "mono.dll" (Unity <= 2017), "mono-2.0-bdwgc.dll" (Unity 2018+)
mono_rt::init("mono-2.0-bdwgc.dll")?;

// 2. Attach the current thread. Keep the guard alive for the duration of all Mono work.
let _guard = unsafe { MonoThreadGuard::attach()? };

// 3. Navigate the assembly graph.
let image = MonoImage::find("Assembly-CSharp")?.expect("assembly not loaded");
let class = image.class_from_name("", "PlayerController")?.expect("class not found");

// 4. Look up a method and invoke it.
let method = class.method("Respawn", None)?.expect("method not found");
let domain = MonoDomain::root()?.expect("no root domain");
let obj = class.new_object(domain)?.expect("allocation failed");
let result = unsafe { method.invoke_with(obj.as_ptr(), &[Value::Bool(true)])? };

// 5. Read a field value directly from a live instance.
let hp_field = class.field("m_health")?.expect("field not found");
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 called attach.
  • 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 type T are 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
--- 16 passed, 0 failed ---

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.