Developers
Building a Cairn mod?
Ask questions and share what you're making with other modders.
Cairn is a Unity IL2CPP game: the C# is compiled to native code, so there's no C# assembly to decompile directly. But IL2CPP keeps full metadata — every type name, method name, field offset, and function address survives. Reverse engineering Cairn means recovering that metadata, reading the native code with names applied, and hooking it at runtime with MelonLoader + Harmony.
You end up with two views of every method:
- C# view — signatures, fields, enums, class layout. Fast to read, no logic.
- Native view — the actual method bodies, decompiled from
GameAssembly.dllin Ghidra.
The C# tells you what exists; the native tells you what it does.
Tools
| Il2CppDumper | Recover names, offsets, and addresses from the game's metadata |
| Ghidra | Decompile native method bodies |
ILSpy / ilspycmd | Read the C# view |
| MelonLoader | Mod loader; also generates the C# proxy assemblies you build against |
| Il2CppInterop | Bridges your .NET code to the IL2CPP runtime (ships with MelonLoader) |
Harmony (0Harmony) | Runtime method patching (ships with MelonLoader) |
| UnityExplorer (Il2CppInterop fork) | Live scene/object inspector in-game |
| CairnAPI | Named APIs over the game's internals — the interop already done (a mod you reference) |
| CairnDevTools | HTTP console that evals C# against the live game |
1. Install MelonLoader
Run the MelonLoader installer against Cairn.exe, then launch the game once. First
launch generates:
-
<game>\MelonLoader\Il2CppAssemblies\— proxy assemblies for every game and Unity assembly. These are what you reference in your mod and what you open in ILSpy. -
<game>\MelonLoader\net6\—MelonLoader.dll,0Harmony.dll,Il2CppInterop.Runtime.dll. <game>\Mods\— drop mod DLLs here.
2. Dump the metadata
Il2CppDumper.exe "<game>\GameAssembly.dll" "<game>\Cairn_Data\il2cpp_data\Metadata\global-metadata.dat" dumpIt ends on a "press any key" prompt that reports a nonzero exit — the output is fine.
You get:
-
dump.cs— every type with field offsets and method RVAs. This file is the index for everything else; you grep this. script.json— function names by address, for import into Ghidra.-
il2cpp.h— struct definitions (may not parse cleanly into Ghidra; the offsets indump.csare the reliable source).
To find a method's address: grep dump.cs for the type, read the
// RVA: 0x... comment above the method. Its address in Ghidra is
0x180000000 + RVA.
3. The C# view
Open any Il2CppAssemblies\*.dll in ILSpy. The game's own code is in the
Il2CppTheGameBakers.Cairn.* assemblies; global-namespace game types appear under
the Il2Cpp namespace.
- You get: class shape, signatures, enums, field layout.
-
You don't get: logic — every method body is an
il2cpp_runtime_invokemarshaling stub. Treat this as the header file.
ilspycmd can choke on a whole assembly — decompile per-type:
ilspycmd -t Full.Type.Name <dll>.
4. The native view (Ghidra)
GameAssembly.dll is large (~90 MB). Full auto-analysis takes hours and you
don't need it — import once, apply names, decompile only the functions you care about.
-
Create a project, import
GameAssembly.dllwith analysis off (-noanalysisheadless, or uncheck analyzers in the GUI). -
Apply names: Il2CppDumper ships
ghidra_with_struct.py— run it and point it atscript.json. It labels every function and creates the metadata structs. Note: Ghidra 12 removed Jython, so the stock script needs Ghidra ≤11.x; on 12+ run it under PyGhidra or port the apply loop (it's a small loop overscript.jsonentries — name + create function at each address). -
Decompile targets on demand: navigate to
0x180000000 + RVA(fromdump.cs) and read the decompiler output, or run a headless post-script that decompiles a list of addresses to.cfiles. Targeted decompiles of a handful of methods take a couple of minutes; that's the loop.
Reading tips:
-
__thisis the instance; field accesses match the offsets indump.cs. When Ghidra shows*(int *)(param_1 + 0xa8), grepdump.csfor the class and find the field at0xa8. -
Unresolved
FUN_18xxxxxxxcalls: subtract0x180000000, grepdump.csforRVA: 0x<hex>. - Decompile the whole call chain, not just the entry point — gates, callees, and especially overrides. A subclass can stub a base method to a no-op; verify which body actually runs.
5. Write a mod
A mod is a net6.0 class library referencing MelonLoader and the proxy assemblies,
with Private=false on everything (the game already has them):
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<GameDir Condition="'$(GameDir)' == ''">C:\Program Files (x86)\Steam\steamapps\common\Cairn</GameDir>
</PropertyGroup>
<ItemGroup>
<Reference Include="MelonLoader"><HintPath>$(GameDir)\MelonLoader\net6\MelonLoader.dll</HintPath><Private>false</Private></Reference>
<Reference Include="0Harmony"><HintPath>$(GameDir)\MelonLoader\net6\0Harmony.dll</HintPath><Private>false</Private></Reference>
<Reference Include="Il2CppInterop.Runtime"><HintPath>$(GameDir)\MelonLoader\net6\Il2CppInterop.Runtime.dll</HintPath><Private>false</Private></Reference>
<Reference Include="Il2Cppmscorlib"><HintPath>$(GameDir)\MelonLoader\Il2CppAssemblies\Il2Cppmscorlib.dll</HintPath><Private>false</Private></Reference>
<Reference Include="Il2CppTheGameBakers.Cairn.Global"><HintPath>$(GameDir)\MelonLoader\Il2CppAssemblies\Il2CppTheGameBakers.Cairn.Global.dll</HintPath><Private>false</Private></Reference>
<Reference Include="UnityEngine.CoreModule"><HintPath>$(GameDir)\MelonLoader\Il2CppAssemblies\UnityEngine.CoreModule.dll</HintPath><Private>false</Private></Reference>
<!-- add Il2CppAssemblies references as the compiler asks for them -->
</ItemGroup>
<Target Name="InstallToGame" AfterTargets="Build">
<Copy SourceFiles="$(TargetPath)" DestinationFolder="$(GameDir)\Mods" />
</Target>
</Project>Entry point:
using MelonLoader;
[assembly: MelonInfo(typeof(MyMod.Core), "MyMod", "0.1.0", "you")]
[assembly: MelonGame("The Game Bakers", "Cairn")]
namespace MyMod;
public class Core : MelonMod
{
public override void OnInitializeMelon() => LoggerInstance.Msg("hello");
public override void OnUpdate() { } // per-frame
}
Build, launch, check <game>\MelonLoader\Latest.log. Note MelonLoader holds
mod DLLs open while the game runs — kill the game before rebuilding.
Before writing raw interop against a game system, check
CairnAPI — menus and mod screens, prompts, inventory,
teleport, game-state events, already reverse-engineered and wrapped. Reference
Mods\CairnAPI.dll like the assemblies above (Private=false).
CairnModOptions gives your mod a page in the
game's Settings menu — declare typed options, write no UI.
6. Harmony patching
Patch the game's methods through the proxy types:
using HarmonyLib;
using Il2Cpp; // global-namespace game types land here
[HarmonyPatch(typeof(SomeGameType), nameof(SomeGameType.SomeMethod))]
static class SomeMethod_Patch
{
static bool Prefix(SomeGameType __instance) => true; // false skips the original
static void Postfix(SomeGameType __instance, ref int __result) { }
} MelonMod patches everything in the assembly automatically; or call
HarmonyInstance.PatchAll() / HarmonyInstance.Patch(...) yourself.
IL2CPP-specific gotchas:
- Verify the patch fires. The C++ compiler inlines small methods — the symbol exists, your patch installs, and callers never hit it. Log from the patch before trusting it.
- Some methods crash when patched (native access violations, typically hot inner-loop or struct-heavy methods). Add patches one at a time; if the game dies on a patched path, hook a caller further up instead.
- You're patching the interop trampoline, not the native code — patches see marshaled proxy objects, which is what you want.
7. Il2CppInterop gotchas
The proxy layer is mostly transparent, until it isn't:
- Two type systems. Game/engine objects use
Il2CppSystem.*types (Il2CppSystem.Collections.Generic.List<T>,Il2CppSystem.Action), notSystem.*. Strings convert implicitly; collections don't — iterate and copy. - Casting. C# casts between proxy types can lie. Use
obj.TryCast<T>()(null on failure) orobj.Cast<T>(). - Delegates. Pass a managed lambda where an Il2Cpp delegate is expected via
the implicit conversion:
button.onClick.AddListener((UnityEngine.Events.UnityAction)MyHandler); - Custom components. Register before first use:
ClassInjector.RegisterTypeInIl2Cpp<MyBehaviour>();thengo.AddComponent<MyBehaviour>(). Give it the(IntPtr ptr) : base(ptr)constructor. Plain C# fields on it work at runtime but are invisible to the engine (no serialization). - Stripped code. Engine methods no game code calls are removed from the
binary —
MissingMethodExceptionat runtime for an API that compiles fine. Find another route to the same effect. - Coroutines.
MelonCoroutines.Start(MyRoutine()), notStartCoroutine. - Unity lifetime. Destroyed objects still hold a proxy; check
obj == null(Unity's overloaded operator works through interop) before use.
8. Live inspection
Install the Il2CppInterop/CoreCLR fork of UnityExplorer as a mod — in-game scene graph browser, component inspector, C# REPL. For "what object is this / what's this field right now" questions it's faster than any amount of decompiling. Pair it with the decompiles: the scene dump shows you the objects, the native code shows you who writes them.
CairnDevTools goes further: an HTTP console (ports 14200+) that compiles and evals C# against the live game. POST a script, get the result — probe fields, call methods, drive the game to a state, all without a rebuild. It's the fastest hypothesis-tester in the kit.
The loop
Everything above compresses into one working rhythm. Say you want to change how some system behaves:
- Find it. Grep
dump.csfor the game's own vocabulary — climbing, stamina, rope, whatever you're after. That names the types and hands you every field offset and method address involved. - Get the shape. Open those types in ILSpy: signatures, enums, which class holds which state.
- Get the truth. Decompile the methods in Ghidra — and their callees, gates, and overrides. A mechanism is never one method; stop when you can narrate the whole path from input to effect.
- Check it live. UnityExplorer or a CairnDevTools eval: does that field really hold what you think, on the object that's actually in the scene?
- Hook it. A Harmony patch at the narrowest point that changes the behavior — and drive the game through its own methods rather than reimplementing them.
- Prove it. Log from the patch, launch, watch it fire. Untested patches silently miss (inlining, wrong override) more often than they fail loudly.
Each pass through the loop is cheap — minutes, not hours. When a change misbehaves, the answer is almost always another lap: decompile one more caller, eval one more field.