Unity IL2CPP Game Modding (BepInEx 6 + Harmony)
When to use
User wants to modify a Unity game that uses IL2CPP scripting backend (check for GameAssembly.dll + il2cpp_data/Metadata/global-metadata.dat in game folder).
Workflow
1. Identify IL2CPP version
# Check metadata version (first 4 bytes of global-metadata.dat, little-endian)
python -c "import struct; print(struct.unpack('<I', open('global-metadata.dat','rb').read(4))[0])"
- Versions 23–29: BepInEx 6 stable (pre.2) works out of the box.
- Versions 35, 38, 39, 104–106 (Unity 6+): BepInEx stable fails. Use nightly build.
2. Install BepInEx
- Stable (metadata ≤29): Download
BepInEx-Unity.IL2CPP-win-x64-6.0.0-pre.2.zipfrom GitHub releases. - Nightly (metadata ≥35): Download from
https://nightly.link/BepInEx/BepInEx/workflows/build/master/BepInEx_CI_BleedingEdge_<hash>_<num>.zip— extract the innerBepInEx-Unity.IL2CPP-win-x64-*.zip. - Extract to game root. Key files:
winhttp.dll(proxy),doorstop_config.ini,BepInEx/core/,dotnet/. - Backup existing
winhttp.dllaswinhttp.dll.bakbefore first run so you can disable BepInEx if game breaks.
3. Generate interop assemblies
BepInEx auto-generates on first launch via bundled Cpp2IL. If it fails (metadata version mismatch):
- Download Cpp2IL standalone (≥
2022.1.0-pre-release.21for v39 support). - Run:
Cpp2IL.exe --game-path "<game_dir>" --output-as dummydll --output-to "<output_dir>" - Copy output DLLs to
BepInEx/interop/. - Alternatively, replace
BepInEx/core/Cpp2IL.Core.dllandLibCpp2IL.dllwith versions from the nightly BepInEx build.
4. Inspect game types
Use a small C# reflection dumper on the generated dummy DLLs (dnfile often fails on Cpp2IL output):
var asm = Assembly.LoadFrom(dllPath);
foreach (var t in asm.GetTypes())
foreach (var m in t.GetMethods(BindingFlags.Public|BindingFlags.NonPublic|BindingFlags.Instance|BindingFlags.Static|BindingFlags.DeclaredOnly))
Console.WriteLine($"{t.Name}.{m.Name}({string.Join(", ", m.GetParameters().Select(p => $"{p.ParameterType.Name} {p.Name}"))})");
See scripts/DumpTypes.cs for a ready-to-use version.
⚠️ DumpTypes matches type names EXACTLY (targets.Contains(t.Name)), so searching "Time", "Day", "Clock" returns nothing if the real class is DayNightManager. Two workarounds:
- Dump ALL types (pass no target args) and grep the output.
- Binary string search on the dummy DLL — fastest for discovery when you don't know the class names:
import re
data = open(r'cpp2il_output/Assembly-CSharp.dll','rb').read()
for kw in [b'Time', b'Day', b'Night', b'Clock', b'Sun', b'Hour']:
for m in re.finditer(kw, data):
s = data[max(0,m.start()-30):m.end()+30]
print(''.join(chr(b) if 32<=b<127 else '.' for b in s).strip('.'))
This surfaces real class/method names (DayNightManager, SetDayLength, HoursUntilDayEnd, CurrentTimeOfDay) that you then feed back into DumpTypes as exact names.
4b. Trace the reward pipeline — PATCH THE SOURCE, NOT THE SINK (CRITICAL)
Dumping types is not enough — you must map the full pipeline from drop source to player inventory, then patch the earliest stage that carries the quantity.
The pipeline (drop → collect → inventory):
[STAGE 1 — DROP SOURCE] OreNode.GetDropCount(hits) → Int32 ← how many items the node SPAWNS
↓ rewardMultiplier (float field, e.g. 0.5) multiplies here
[STAGE 2 — WORLD ITEMS] CollectItemFlyer / world pickups ← items flying around in the world
↓ player walks over / picks up
[STAGE 3 — INVENTORY SINK] T_Bag.AddItems(item, count) → Int32 ← what actually enters the bag
↓ maxCapacity / CanAddItem clamps here
⚠️ RULE: Patch the DROP SOURCE (Stage 1) when it actually fires. This is the ideal — patching GetDropCount means more items physically spawn in the world; capacity becomes a non-issue. However, verify the source method is actually called during the gameplay action you're targeting. In Ore Factory Squad, GetDropCount only fires for dynamite/blast paths — normal pickaxe mining goes directly to T_Bag.AddItems (Stage 3). If the source method never fires (zero log lines despite [OK] patch), you must patch the sink instead — but use the postfix + re-invoke pattern (not prefix __args mutation, which silently fails in IL2CPP — see Pitfalls).
Real correction from the field: after 9 failed plugin versions patching T_Bag.AddItems + capacity unlocks, the user said "아 광석 드랍량으로 배율조정을 했어야지" ("I should've adjusted the DROP amount"). Patching OreNode.GetDropCount postfix (__result *= 5) + neutralizing rewardMultiplier (0.5→1.0) worked where sink-patching never did.
Also check for hidden penalty multipliers. A rewardMultiplier / dropMultiplier float field set to < 1.0 (e.g. 0.5) silently HALVES all drops. Find it in the drop-system class (NodeBreakSystem) and reset it to 1.0 in an Awake postfix. This is often the "the game is tuned to give less than it shows" knob the user is complaining about.
Do NOT assume the first "Add"-like method you find is the player inventory. Games often have multiple item containers:
ItemStack.AddCount→ world items, sacks, conveyor belts (NOT player inventory)T_Sack.ServerAddToInventory→ sack→bag transferT_Bag.AddItems/T_Bag.AddItem→ actual player inventory (the SINK — only patch if you specifically need inventory-side control, e.g. unlimited carry)
For networked games (Mirror/Netcode), patch both the server-side method and the RPC path — the client may receive the amount via RPC independently.
5. Write the BepInEx plugin
- Target
net6.0. Reference fromBepInEx/core/:BepInEx.Unity.IL2CPP.dll,BepInEx.Core.dll,0Harmony.dll,Il2CppInterop.Runtime.dll. - Use
BasePlugin(BepInEx 6), NOTBaseUnityPlugin. - See
templates/BepInEx-IL2CPP-Plugin/for a starter project.
6. Deploy & test
- Copy plugin DLL to
BepInEx/plugins/. - Launch game. Check
BepInEx/LogOutput.logfor load errors. - If game won't start: rename
winhttp.dll→winhttp.dll.bakto disable BepInEx and restore normal launch.
Pitfalls
IL2CPP field accessors can't be Harmony-patched
get_someField() methods are inlined by IL2CPP. Harmony error: "Method is a field accessor, it can't be patched."
Fix: Patch a lifecycle method (Awake, Start, OnEnable) as postfix, then modify the field via reflection on __instance:
static void AwakePostfix(object __instance) {
var f = __instance.GetType().GetField("rewardMultiplier", BindingFlags.NonPublic|BindingFlags.Instance);
f.SetValue(__instance, (float)f.GetValue(__instance) * 10f);
}
IL2CPP interop types have null parameter names
ParameterInfo.Name is often null in IL2CPP interop assemblies. Any code doing p.Name.ToLower() throws NullReferenceException.
Fix: Always null-check: p.Name?.ToLower() ?? "". Better: match by parameter type and position, not name.
Don't broad-patch by keyword matching
Scanning all methods for verbs like "spawn/drop/collect" and patching them all is fragile and slow. Instead:
- Dump types first (step 4).
- Identify the exact class + method from the dump.
- Patch only those specific methods.
BepInEx version vs metadata version
| Metadata | Unity | BepInEx needed |
|---|---|---|
| 23–29 | 2019–2022 | 6.0.0-pre.2 (stable) |
| 35+ | Unity 6+ | Nightly (be.700+) |
dnfile can't parse Cpp2IL dummy DLLs
Use Assembly.LoadFrom + .NET reflection instead (see scripts/DumpTypes.cs).
IL2CPP interop exposes fields as properties
GetField("rewardMultiplier") returns null even though the dump shows it as a field. IL2CPP interop wraps backing fields as properties. Always try GetProperty first, fall back to GetField:
var prop = t.GetProperty("rewardMultiplier", BindingFlags.NonPublic|BindingFlags.Instance);
if (prop != null && prop.PropertyType == typeof(float))
prop.SetValue(__instance, (float)prop.GetValue(__instance) * 10f);
else {
var f = t.GetField("rewardMultiplier", BindingFlags.NonPublic|BindingFlags.Instance);
// ...
}
StackTrace-based call filtering does NOT work in IL2CPP
If you need to distinguish "mining AddItems" from "pickup AddItems" (e.g. apply x5 only to ore mining, not palette/factory pickups), do NOT use new StackTrace() to walk caller frames. IL2CPP stack frames return null or empty DeclaringType.Name for native methods, so your filter will misclassify everything. Symptom: log shows [스킵] Copper (비채굴) — mining was incorrectly skipped.
Working alternatives:
- Flag-based: Patch the caller methods (e.g.
OreNode.InflictHit,Server_GiveOreToHost) with a prefix that sets[ThreadStatic] static bool _isMining = true, and a postfix/finally that resets it. Then inAddItemsPost, check the flag. - Item-type filtering: If mining always produces specific item types (Copper, Iron, Sandstone, Clay, Gold) and pickups produce different types (products, components), filter by
ItemName(__args[0])against a whitelist. - Separate patch targets: Instead of patching the shared
T_Bag.AddItems, patch the mining-specific caller directly (e.g. postfix onServer_GiveOreToHostto call AddItems extra times).
Postfix re-invoke can be intermittent
The postfix + re-invoke pattern (call AddItems N−1 extra times) may work on first test then stop working on subsequent attempts. Possible causes: IL2CPP method handle invalidation after GC, _guard flag stuck due to exception in a prior call, or the game's internal state machine rejecting rapid duplicate adds.
Mitigation:
- Always reset
_guardin afinallyblock (already in the pattern). - Add a small diagnostic: log
__resultof each re-invoked call. If they return 0, the game is rejecting duplicates (anti-dupe check?). - If intermittent, consider patching the caller instead (e.g. make
Server_GiveOreToHostpassamount * 5via postfix on its args before it calls AddItems).
Plugin DLL locked while game is running
cp to BepInEx/plugins/ fails with "Device or resource busy" if the game process is still alive. Always close the game before deploying a new plugin DLL.
Nightly BepInEx zip structure
The nightly.link download is a zip of zips — one inner archive per platform (IL2CPP-win-x64, IL2CPP-linux-x64, Mono-win-x64, etc.). Extract the outer zip first, then extract the correct inner BepInEx-Unity.IL2CPP-win-x64-*.zip into the game root.
Spawn-time patches don't affect existing saves
Methods like T_NodePiece.Initialize(T_Item, Int32, Int32) run when nodes are spawned. Nodes already in the save file were spawned in a previous session — their Initialize won't fire again. Patch the collection/reward path instead (see step 4b).
⚠️ __args mutation in prefix does NOT propagate to IL2CPP native methods
This is the single most deceptive IL2CPP pitfall. In a Harmony prefix, modifying __args[1] = count * 5 appears to work — your log prints 1->5 — but the IL2CPP native C++ method receives the original unmodified value (1). The interop layer copies arguments to the native call before (or independently of) your prefix modification. This is different from Mono, where __args mutation reliably propagates.
Symptom: Log shows [x5] 1->5 [Copper] but the player only receives 1 item. No error, no capacity warning — the method simply runs with the original args.
Fix — postfix + re-invoke pattern: Don't modify args. Let the original method run normally (adds 1), then in the postfix, call the same method N−1 more times via reflection:
[ThreadStatic] static bool _guard;
static void AddItemsPost(object[] __args, int __result, object __instance) {
if (_guard || __result <= 0 || Multiplier <= 1) return;
var item = __args[0];
var addItems = __instance.GetType()
.GetMethods(BindingFlags.Public|BindingFlags.NonPublic|BindingFlags.Instance)
.FirstOrDefault(x => x.Name == "AddItems" && x.GetParameters().Length == 3);
if (addItems == null) return;
_guard = true; // prevent infinite recursion
try {
for (int i = 1; i < Multiplier; i++)
addItems.Invoke(__instance, new object[] { item, __result, __args[2] });
} finally { _guard = false; }
}
The _guard flag is mandatory — without it, each re-invoked call triggers the postfix again → infinite recursion → stack overflow.
When to suspect this: If your prefix log shows the multiplication happened but the user reports no change, AND capacity/stack-limit is confirmed working (user says "가방 용량은 제대로 작동하고있어"), then __args propagation is the culprit — not capacity, not wrong target.
Patch fires in logs but user sees no effect
If LogOutput.log shows your multiplier firing (e.g. [x10] AddCount: 13 -> 130) but the user reports no visible change, there are three distinct failure modes:
Mode A: Wrong subsystem. The method belongs to world objects (sacks, conveyors, belts) not the player inventory, or the result is overwritten by a later network sync. Fix: Go back to the type dump. Find the class the player's UI reads from (bag/inventory panel), then trace what feeds it. Patch that feed method.
Mode B: Internal validation silently caps the result. The patched method IS the right one (player inventory), and the prefix successfully multiplies the argument — but the method body has an internal capacity/stack-limit check that clamps the actual amount added. Example log:
\n[BAG] AddItems: 2->100 (x50) [Sandstone]\n[BAG] ⚠ 용량부족: 요청=100 실제=2 [Sandstone]\n\nThe prefix changed 2→100, but the method returned 2 because the bag was full.\n\nMode C: __args mutation doesn't propagate to IL2CPP native code. The prefix log shows 1->5 but the native method received 1. No capacity warning fires because the method never saw the multiplied value. The user confirms capacity is fine ("가방 용량은 제대로 작동하고있어") yet items don't multiply. Fix: Use the postfix + re-invoke pattern (see the __args pitfall above).
Diagnosis: Add a postfix that compares the requested amount vs the return value:
static void AddItemsPost(object[] __args, int __result) {
if (__args[1] is int req && req > 0 && __result < req)
Log($"⚠ Capacity capped: requested={req} actual={__result}");
}
If this fires, the patch target is correct but you must also unlock the capacity.
Fix — unlock capacity in the prefix (before the method body runs):
static void AddItemsPre(object[] __args, object __instance) {
// Unlock capacity FIRST, then multiply
var t = __instance.GetType();
var fMax = t.GetField("maxCapacity", BindingFlags.NonPublic|BindingFlags.Instance);
var fBase = t.GetField("baseMaxCapacity", BindingFlags.NonPublic|BindingFlags.Instance);
fMax?.SetValue(__instance, 99999);
fBase?.SetValue(__instance, 99999); // prevent restore from base
// ... then multiply __args[1]
}
⚠️ Patching get_MaxCapacity postfix alone is NOT sufficient. IL2CPP may inline the property getter, so the method body reads the backing field directly, bypassing your postfix. You must set the field value in the prefix. Also patch CanAddItem postfix → true as a safety net.
⚠️ Watch for capacity-restoring methods. Games often have ApplyBackpackCapacityFromUpgrade(), SetMaxCapacity(), OnBuffsChanged() etc. that reset capacity from upgrade levels. If capacity keeps resetting, also postfix-patch those methods to re-apply 99999, or patch ApplyMaxCapacityFromIndex to no-op.
General principle: When a prefix patch modifies an argument but the user sees no change, ALWAYS add a postfix to log the return value vs the modified argument. If they differ, internal validation is the bottleneck — not a wrong patch target.
Runtime adjustment (WeMod-style, no game restart)
For mods where the user wants to change values on the fly (multipliers, toggles), embed a control surface in the plugin:
Option A: File watcher (simplest)
// In plugin Load():
var configFile = Path.Combine(Paths.ConfigPath, "my_mod.txt");
File.WriteAllText(configFile, "10"); // default
var watchThread = new Thread(() => {
string last = "";
while (running) {
Thread.Sleep(2000);
var text = File.ReadAllText(configFile).Trim();
if (text != last) { last = text; Multiplier = int.Parse(text); }
}
}) { IsBackground = true };
watchThread.Start();
User edits the .txt file → value updates within 2 seconds, no restart.
Option B: Embedded HTTP server + web UI (best UX)
var http = new HttpListener();
http.Prefixes.Add("http://localhost:8765/");
http.Start();
// GET / → HTML page with slider + preset buttons
// GET /api → {"multiplier":10}
// POST /set → body "v=50" → Multiplier = 50, save to file
User opens http://localhost:8765 in browser → slider/buttons → instant apply.
Key: All Harmony patches read from a static volatile int Multiplier field, so changing it takes effect on the very next call — no re-patching needed.
Option C: In-game hotkeys
Use UnityEngine.Input.GetKeyDown in a MonoBehaviour.Update() loop. Works but requires IL2CPP interop for Input class.
Recommendation: Option B (HTTP) for the best WeMod-like experience. Option A (file) as fallback if HttpListener is blocked.
Verification
BepInEx/LogOutput.logshows[Message: BepInEx] Chainloader startup complete= BepInEx loaded.- Plugin log lines confirm patches applied.
- If
Error loading [PluginName]appears, read the stack trace — usually a null-ref or missing type.
Clean rollback / uninstall (revert to vanilla)
Users frequently ask to undo a mod while keeping other changes (e.g. "keep my money edit, remove the mod"). Do it surgically:
- Close the game first (DLLs are locked while running).
- Remove the mod plugin:
rm BepInEx/plugins/<Mod>.dll(or the wholeBepInEx/to nuke everything). - Restore the original proxy:
cp winhttp.dll.bak winhttp.dll(the.bakyou made at install time). - Remove BepInEx's doorstop config:
rm doorstop_config.ini. - Optionally remove the whole
BepInEx/folder +dotnet/to fully revert. - Do NOT touch the save file if the user wants to keep save edits — restoring
SAVE.GZ.bakwould wipe their money/progress. Only restore the save backup if they explicitly want the save reverted too.
Verify vanilla state: ls winhttp.dll doorstop_config.ini BepInEx should show only the original winhttp.dll.
Direct binary patching (no BepInEx needed)
For simple patches (NOP a method, force return true, disable a function), you can patch GameAssembly.dll bytes directly — no BepInEx, no Harmony, no plugin compilation. Much faster for one-off cheats.
Workflow
- Run Il2CppDumper to get method RVAs:
# Download from https://github.com/Perfare/Il2CppDumper/releases (v6.7.46) # Set config.json: RequireAnyKey=false, ForceIl2CppVersion=true, ForceVersion=29 ./Il2CppDumper.exe GameAssembly.dll "il2cpp_data/Metadata/global-metadata.dat" # Produces: dump.cs (types), script.json (method addresses) - Find target methods in
script.json(use terminal python, NOT execute_code — MSYS path issues):import json data = json.load(open('script.json')) for m in data['ScriptMethod']: if 'soul' in m['Name'].lower(): print(f"0x{m['Address']:X} {m['Name']} {m['Signature'][:100]}") - Convert RVA → file offset using PE section headers:
import struct f = open('GameAssembly.dll', 'rb') f.seek(0x3C); pe_off = struct.unpack('<I', f.read(4))[0] f.seek(pe_off + 4 + 16); opt_size = struct.unpack('<H', f.read(2))[0] f.seek(pe_off + 4 + 2); num_sections = struct.unpack('<H', f.read(2))[0] sec_start = pe_off + 4 + 20 + opt_size for i in range(num_sections): f.seek(sec_start + i*40) name = f.read(8).rstrip(b'\x00').decode() vsize, vaddr, rawsize, rawaddr = struct.unpack('<IIII', f.read(16)) print(f'{name:8s} VA=0x{vaddr:08X} Raw=0x{rawaddr:08X}') # file_offset = RVA - section_VA + section_RawAddr - Patch bytes:
data = bytearray(open('GameAssembly.dll', 'rb').read()) # Disable a method: write C3 (ret) at first byte data[file_offset] = 0xC3 # Force return true: B8 01 00 00 00 C3 (mov eax,1; ret) data[file_offset:file_offset+6] = b'\xB8\x01\x00\x00\x00\xC3' open('GameAssembly.dll', 'wb').write(data) - ALWAYS backup first:
cp GameAssembly.dll GameAssembly.dll.bak
Common patch patterns (x86-64)
| Goal | Bytes | Mnemonic |
|---|---|---|
| Disable method (void) | C3 |
ret |
| Force return true | B8 01 00 00 00 C3 |
mov eax,1; ret |
| Force return false/0 | 31 C0 C3 |
xor eax,eax; ret |
| NOP a 5-byte call | 90 90 90 90 90 |
nop×5 |
When to use binary patch vs BepInEx
- Binary patch: simple disable/force-return, one or two methods, no conditional logic. Fastest path — done in 2 minutes.
- BepInEx plugin: conditional logic (multiply only gains not losses), runtime config, multiple interacting patches, or the method body is too complex to safely NOP.
Pitfalls
- Il2CppDumper ForceVersion: Set to 29 for Unity 2022.x (metadata v31 auto-detected as 29.1). For Unity 6 (metadata v39), Il2CppDumper v6.7.46 may fail — use Cpp2IL instead.
- script.json is huge (~60MB). Use
python -cin terminal, not execute_code (MSYS /tmp path mismatch). - ObscuredInt / CodeStage AntiCheat: Games using
ObscuredIntstore values XOR-encrypted. Binary-patching setter/getter methods still works — you intercept at code level, not the obscured value. - Game updates invalidate offsets: Re-run Il2CppDumper and re-patch after updates.
- Steam file verification: "Verify integrity of game files" restores the original DLL. Warn the user.
Case study: Revolution Idle (infinite souls)
- Unity 2022.3.62f3, IL2CPP, metadata v31
- Save: Windows Registry (
HKCU\Software\Oni Gaming\Revolution Idle) — encrypted, not editable - Key methods:
InventoryData.SpendSouls(RVA 0x4558D0),DisplayShop.CheckSouls(RVA 0x5E33E0) - il2cpp section: VA=0x2B2000, Raw=0x2B0C00 → file_offset = RVA - 0x1400
- Patch: SpendSouls →
C3(never decrease), CheckSouls →B8 01 00 00 00 C3(always can buy) - Result: infinite souls, all shop items purchasable, no save modification needed
Support files
references/metadata-v39-fix.md— detailed error transcript and nightly build workaroundreferences/ofs-mining-multiplier.md— Ore Factory Squad case study: drop-source vs inventory-sink, the rewardMultiplier=0.5 penalty, key class mapscripts/DumpTypes.cs— C# reflection dumper for Cpp2IL dummy assembliestemplates/BepInEx-IL2CPP-Plugin/— starter plugin project (csproj + Plugin.cs)