# Unity Il2cpp Modding

> Mod Unity IL2CPP games with BepInEx 6 + Harmony: plugin dev, metadata version workarounds, interop generation, runtime patching.

- Skill: `wcpaka-lgtm/unity-il2cpp-modding` (Agent Skill, multi-file: 6 files)
- Install (CLI): `npx skillmds@latest add wcpaka-lgtm/unity-il2cpp-modding`
- Raw SKILL.md: https://api.skillmd.com/api/skills/wcpaka-lgtm/unity-il2cpp-modding/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: wcpaka-lgtm (https://skillmd.com/u/wcpaka-lgtm)
- Updated: 2026-09-21
- Page: https://skillmd.com/skills/wcpaka-lgtm/unity-il2cpp-modding

---


# 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
```bash
# 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.zip` from GitHub releases.
- **Nightly (metadata ≥35):** Download from `https://nightly.link/BepInEx/BepInEx/workflows/build/master/BepInEx_CI_BleedingEdge_<hash>_<num>.zip` — extract the inner `BepInEx-Unity.IL2CPP-win-x64-*.zip`.
- Extract to game root. Key files: `winhttp.dll` (proxy), `doorstop_config.ini`, `BepInEx/core/`, `dotnet/`.
- **Backup** existing `winhttp.dll` as `winhttp.dll.bak` before 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):
1. Download Cpp2IL standalone (≥ `2022.1.0-pre-release.21` for v39 support).
2. Run: `Cpp2IL.exe --game-path "<game_dir>" --output-as dummydll --output-to "<output_dir>"`
3. Copy output DLLs to `BepInEx/interop/`.
4. Alternatively, replace `BepInEx/core/Cpp2IL.Core.dll` and `LibCpp2IL.dll` with 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):
```csharp
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:
1. Dump ALL types (pass no target args) and grep the output.
2. **Binary string search** on the dummy DLL — fastest for discovery when you don't know the class names:
```python
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 transfer
- `T_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 from `BepInEx/core/`: `BepInEx.Unity.IL2CPP.dll`, `BepInEx.Core.dll`, `0Harmony.dll`, `Il2CppInterop.Runtime.dll`.
- Use `BasePlugin` (BepInEx 6), NOT `BaseUnityPlugin`.
- See `templates/BepInEx-IL2CPP-Plugin/` for a starter project.

### 6. Deploy & test
- Copy plugin DLL to `BepInEx/plugins/`.
- Launch game. Check `BepInEx/LogOutput.log` for load errors.
- If game won't start: rename `winhttp.dll` → `winhttp.dll.bak` to 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`:
```csharp
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:
1. Dump types first (step 4).
2. Identify the **exact** class + method from the dump.
3. 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`:
```csharp
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:**
1. **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 in `AddItemsPost`, check the flag.
2. **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.
3. **Separate patch targets:** Instead of patching the shared `T_Bag.AddItems`, patch the mining-specific caller directly (e.g. postfix on `Server_GiveOreToHost` to 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 `_guard` in a `finally` block (already in the pattern).
- Add a small diagnostic: log `__result` of 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_GiveOreToHost` pass `amount * 5` via 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:
```csharp
[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\n**Mode 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:
```csharp
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):**
```csharp
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)
```csharp
// 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)
```csharp
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.log` shows `[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:
1. **Close the game first** (DLLs are locked while running).
2. Remove the mod plugin: `rm BepInEx/plugins/<Mod>.dll` (or the whole `BepInEx/` to nuke everything).
3. Restore the original proxy: `cp winhttp.dll.bak winhttp.dll` (the `.bak` you made at install time).
4. Remove BepInEx's doorstop config: `rm doorstop_config.ini`.
5. Optionally remove the whole `BepInEx/` folder + `dotnet/` to fully revert.
6. **Do NOT touch the save file** if the user wants to keep save edits — restoring `SAVE.GZ.bak` would 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
1. **Run Il2CppDumper** to get method RVAs:
   ```bash
   # 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)
   ```
2. **Find target methods** in `script.json` (use terminal python, NOT execute_code — MSYS path issues):
   ```python
   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]}")
   ```
3. **Convert RVA → file offset** using PE section headers:
   ```python
   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
   ```
4. **Patch bytes**:
   ```python
   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)
   ```
5. **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 -c` in terminal, not execute_code (MSYS /tmp path mismatch).
- **ObscuredInt / CodeStage AntiCheat**: Games using `ObscuredInt` store 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 workaround
- `references/ofs-mining-multiplier.md` — Ore Factory Squad case study: drop-source vs inventory-sink, the rewardMultiplier=0.5 penalty, key class map
- `scripts/DumpTypes.cs` — C# reflection dumper for Cpp2IL dummy assemblies
- `templates/BepInEx-IL2CPP-Plugin/` — starter plugin project (csproj + Plugin.cs)

