Field notes · game modding

Modding Against IL2CPP

Sons of the Forest ships as a compiled native binary with no managed assemblies to reference. This is what it took to build a full admin menu on top of it anyway, and why almost every design decision in the codebase traces back to that one constraint.

01

The wall you hit first

Most Unity games are pleasant to mod. They ship Mono assemblies: real .NET DLLs you can open in a decompiler, reference from your own project, and call as though the game were a library someone published for you.

Sons of the Forest is compiled with IL2CPP. The C# is transpiled to C++ and compiled to a native binary. There is no managed assembly. There is nothing to reference. What survives the transpilation is a single file called global-metadata.dat, a binary blob describing every type, method and field the original C# had, which the runtime uses to bridge native code back to something reflectable.

So you cannot simply call LocalPlayer.Vitals. Something has to reconstruct a managed façade over a native binary first, and every call you make across that boundary reaches into memory the .NET runtime does not own.

Three consequences follow, and between them they shape every file in this project:

That third one is where the interesting work started.

02

Learning a game you have no source for

Before writing a line of the menu, I had to establish what the game actually exposed. The starting point was the metadata file itself.

BepInEx 6's IL2CPP branch reads global-metadata.dat on first launch and generates managed proxy assemblies, one per game assembly, each containing a stub for every type and member the metadata describes. That gives you something to reference. It does not tell you which of those thousands of members are the ones you want, or whether the ones you picked still behave the way their names suggest.

So I parsed the metadata directly and built an index of every type mapped to its methods and fields, then validated that index two ways before trusting it: against a known-good probe that should resolve, and against a fabricated field name that should not. An index that confidently finds a member you invented is an index that will confidently find anything.

With that in hand, every game member the plugin intended to call was resolved against the live build, and not just by name. Names alone miss arity drift: a method that keeps its name across a patch but gains an argument. So the parameter count of every call was compared against the metadata's own parameterCount field.

Result

35 of 35 members matched. 0 mismatched. The plugin was written against a surface that had been proven to exist, member by member, before any of it ran.

03

Making the compiler do the checking

Verifying once is useful. Verifying on every build is better, and it turned out to be free.

Because the project references those generated interop assemblies directly rather than looking members up by string at runtime, a game member that stops existing breaks the build. If Endnight renames a field in a patch, dotnet build fails with an error naming the exact line. It does not compile happily and then throw two hours into someone's play session.

Which is why every Harmony patch target is written with nameof instead of a string:

[HarmonyPatch(typeof(Vitals), nameof(Vitals.TriggerDeath))]

The string "TriggerDeath" would survive a rename and silently patch nothing at all, the worst failure mode available because everything looks fine. nameof(Vitals.TriggerDeath) cannot survive it.

Worth being clear about

A green build still does not prove the mod works. It says nothing about lifecycle ordering, whether Harmony actually bound at runtime, or whether anything draws. Those are runtime-verifiable only, and the evidence lives in the loader's log.

04

The failure problem, and one seam

Here is the daily reality of the second consequence from section one. Every call into the game can throw for reasons that are not bugs: no world loaded yet, a component mid-teardown, a wrapper whose native object was freed a frame ago. You have two honest options: guard every touch, or let the game crash.

My first pass guarded them inline, the obvious way. That produced 117 hand-written try/catch blocks, seventeen of which were completely empty. Seventeen places where a genuine failure disappeared without leaving a trace.

That is not error handling. It is error concealment wearing error handling's clothes, and I had written all of it myself.

The fix was to collapse them into a single guarded seam, one place that decides what gets logged, what gets swallowed, and what is genuinely fatal:

Safe.Get("player.vitals", () => LocalPlayer.Vitals);

Safe.Run("stat.peg", stat, s => s._currentValue = s._max);

Three things about that shape are deliberate.

Every guarded call is named

The site name comes first so each line labels itself. Names are dotted and grouped by subsystem: player.vitals, esp.actor-list, and boot.harmony-patchall. A log line points straight at the code that produced it, and the whole set can be listed with one search.

Noise level is declared, not assumed

A site says whether its failure is transient (a teardown race: report once, then stay quiet), a warning (unexpected but recoverable), or fatal. That middle category matters more than it sounds: the menu ticks every frame while the world simulates underneath, so an unsuppressed warning on a per-frame path emits thousands of lines a minute and buries the one that mattered. Suppression is keyed on the site name, which is precisely why the name is mandatory rather than optional.

Allocation was designed for, not hoped about

A lambda that captures a local variable allocates a closure object every single call. A lambda that captures nothing gets cached once by the compiler into a static field. On a path that runs sixty times a second across five different stats, that difference is real garbage pressure inside a game that is already working hard.

// captures nothing; allocated once, ever
Safe.Run("stat.peg", stat, s => s._currentValue = s._max);

// captures `stat`; a new closure on every frame
Safe.Run("stat.peg", () => stat._currentValue = stat._max);

Both compile. Only the first belongs in a tick. The seam carries state-passing overloads specifically so the hot paths can stay allocation-free without anyone having to remember why.

From a real boot log

[items.database] NullReferenceException at ItemDatabaseManager.get_Items(), followed immediately by "further reports from 'items.database' suppressed". The item database genuinely does not exist on the title screen. The read threw, the seam caught it, named it, logged it once, and went quiet. Under the empty catch block it replaced, that line would not exist at all.

05

Three interventions, and why each intercepts

Only three patches change how the game behaves. Everything else observes. All three landed on the same principle after the naive version failed.

God mode blocks death, it does not out-heal it

The obvious implementation writes health back to maximum every frame. It loses two races: a single hit larger than your entire health bar, and a death triggered before the next tick arrives. Both were reproducible in play; you could still die with god mode on, which rather defeats it.

Blocking the death call outright cannot lose a race, because death never runs. Health pegging stayed, but as a cosmetic nicety rather than the mechanism.

Infinite ammo stops the spend, it does not refill

Same reasoning a layer down. Refilling the magazine every frame means the round is consumed and then replaced, and both the HUD and the weapon's own state machine notice. Suppressing the event the controller raises as the round leaves is the earliest correct point to intervene.

The damage multiplier scales in flight

Rather than letting the hit land and correcting afterwards, the multiplier modifies the value before the original method ever sees it. Every downstream system then observes one consistent number instead of a raw figure followed by a suspicious adjustment.

Thirteen further patches exist, and none of them alter anything. They keep the item-tracking registry synchronised with the world by watching pickup, container and waypoint lifecycles.

06

Drawing the game's GPS map safely

The current menu does not manufacture a substitute island or export the game's artwork. It locates the live 4096×4096 Cartography surface already loaded for the handheld GPS and layers travel controls over that game-owned texture.

The difficult part was ownership. Holding an IL2CPP texture wrapper between frames eventually produced an ObjectCollectedException, even when ordinary managed references still appeared valid. The final path caches only stable value metadata, including Unity's integer instance ID, then reacquires and draws the texture inside the same guarded Repaint callback. A narrow tracker-to-surface lookup remains as a fallback. The plugin never copies, destroys, saves, embeds or redistributes the map asset.

The viewport uses the real map aspect ratio and supports FIT, 1X, cursor-anchored wheel zoom and press-drag panning. Current position, saved spots and GPS locations are projected through the same world bounds. Native marker art is used when it is alive; guarded procedural silhouettes cover missing icons without turning every point into an anonymous square.

Labels are normalized from the game's object names, placed in a second collision-aware pass and clustered when the full-island view is too dense. Hover keeps the complete details, and a click travels through the game's own teleport helper instead of performing a blind transform write.

07

Two more problems worth naming

Seeing through walls, honestly

The lazy ESP draws a box on every entry in the actor list and lets you sort it out. This one reads the classification the game already maintains internally, so the Enemies, Animals and Friendly toggles genuinely filter rather than just recolouring the same wall of boxes.

Health bars are assembled from each creature's individual damage regions. Every region with a break threshold contributes its maximum, and applied damage is summed against the total. That tracks real injury per body part instead of one hit-point number.

Avatars across a thread boundary

Steam profile pictures are the only place the mod touches the network, and they sit on an awkward constraint: Unity textures can only be created on the main thread, but a blocking HTTP call on the main thread stalls the frame.

So downloads run on the thread pool and hand raw bytes to a concurrent queue; the per-frame tick drains that queue and does the texture creation where it is legal. Three caches sit behind it: memory, disk, and a negative cache of IDs already known to have nothing stored. Without the third one, every uncached player triggers a filesystem probe on every single frame.

08

What it does

Thirteen categories, flat. It was originally three levels deep: rail, then a chip strip, then sections. Flattening it to one click was the single best usability change made to the thing.

Player

God mode, endless stamina, max strength, no fall damage. Hunger, thirst, cold, sleep and lung capacity all stop mattering. A status panel applies or clears any condition the game models.

Movement

Run, jump and swim sliders, plus free flight with a sprint modifier. Undo All restores exactly what the save loaded with.

Map

The game's native GPS surface with FIT, zoom, drag-to-pan, click travel, collision-aware labels, saved positions and verified location markers.

Combat

Infinite ammo, rapid fire, a damage multiplier, AI freeze, and radius tools that kill, wound, ignite, shock or dismember whatever is nearby.

Spawn

The entire item catalogue behind a live search. Mutants with bosses marked, every animal, story companions, props and cutscenes.

Gear

Hang glider thrust and pitch, Knight V top speed, flashlight brightness and drain, rebreather air and light.

World

Instant and free building, endless logs and rope, season switching, time of day, weather, and clearing everything you have built.

Game Setup

The custom-game rules you normally pick once at world creation, including enemy behaviour, survival damage, and season length, are changeable at any moment.

Vision

Actor ESP with real classification and per-region health bars, item and container ESP, a corner radar, and the game's own debug overlays.

Online

A live roster with Steam portraits, admin tags, and travel, summon, kill or revive on every row.

Cheats

The engine's own cheat flags, read back rather than fired blind. Achievements, armour state, a frozen ocean.

Console

All 358 debug commands, searchable, each with a plain-English description so the raw table is actually readable.

9,628lines of C#
24source files
83guarded call sites
16Harmony patches
59procedural icons
35/35API members verified

The icons are worth a footnote. All fifty-nine are rasterised in code onto a 48-pixel canvas with coverage sampling, then downsampled. There is nothing to ship or load, and the result is resolution-independent. Colour is baked into each texture rather than tinted at draw time, which is what lets a single 18-pixel icon carry two or three tones and still read.

09

What isn't finished

A write-up that only lists strengths is marketing. These are the real gaps, as of v1.2.0:

10

Download

Windows x64 only. The one-file installer finds the Steam installation, downloads the pinned official BepInEx 6 Unity IL2CPP build, verifies its byte length and SHA-256, and installs the embedded mod as BepInEx/plugins/SonsOfTheDead.dll. BepInEx 5 will not work because it targets Mono.

Press ` in game to open the panel. Rebindable from Settings.

Sons of the Forest Menu v1.2.0 · built and written by cyberfox1337x
Verified against game build 20228174, Unity 2022.2.16f1, IL2CPP
BepInEx 6.0.0-be.785 · Il2CppInterop 1.5.3 · HarmonyX 2.10.2

Console command descriptions, named-location coordinates and the spawnable character list are drawn from the community Sons of the Forest wiki.