Live Editing Suzerain: Walking an IL2CPP Heap to Find a Hidden Lua VM
I wanted to replay specific late-game branches in Suzerain without grinding for the right resources every time. The game has a save editor scene built in, but it requires a restart, and high values trigger debuffs that gate the cheat itself.
So I built a runtime state editor instead. It attaches to a running Suzerain.exe, finds the live in-game Lua variables, and lets me read or rewrite any of them without restarting the game. The result is WolknerProtocol, and the path to it ran through three dead ends and one good idea. Worth writing up, because the good idea is more general than a single game.
What I was working with
Suzerain is a Unity 6 game shipped with IL2CPP. The build I had was version 3.1.0.1.153, running through Proton on Linux. IL2CPP compiles C# to C++ at build time, so there is no Mono runtime metadata to query and no easy way to enumerate types from outside the process. The only obvious entry points were GameAssembly.dll (the compiled native code) and il2cpp_data/Metadata/global-metadata.dat (the game’s own type information).
The game state lives in C# heap memory once the game is running. The save file confirmed it: a single JSON document with a giant variables field that turned out to be a Lua-table-style serialization of around 800,000 characters. Things like ["BaseGame.GovernmentBudget"]=9, ["BaseGame.PersonalWealth"]=4, ... going on forever.
Dead end one: classic memory scanning
The first instinct is always the same. Find the value 9 in the running process, change it in-game, scan again, narrow. Standard scanmem workflow, no metadata required.
This works for finding a value of 9 in memory. It does not work for finding the value of 9 that the game uses, because Suzerain has hundreds of int-valued things. Even after several rounds of narrowing you end up with thousands of candidates, and even when you find one and write to it, the game ignores you. The address you found is a buffer, a render cache, an undo snapshot, anything but the live state.
I confirmed this by overwriting the literal save-file string ["BaseGame.GovernmentBudget"]=9 to =5 in memory. The change held, but the game UI kept showing 9, and at the next autosave my edit was gone. Whatever I was editing, it was not what the game read.
Dead end two: save editing
The save file is JSON. In principle you can edit it in a text editor, set budget to whatever you want, and reload the save. The community has guides for this.
Two reasons it is not enough. First, you need to restart the game on every change. Second, the variables BaseGame.Sordland_HUDStat_GovernmentBudget_Max = 30 and similar set hard caps. Editing budget to 999 triggers a debuff cascade because the value is out of bounds. You can edit the cap variables too, but now you are doing two restarts to make one change.
I wanted to edit values mid-play, freely, with no consequences other than the game state itself.
The dump that changed everything
I gave up on blind scanning and dumped the IL2CPP metadata. The right tool for Unity 6 (metadata version 31) is Cpp2IL. Run it against the game directory, ask for diffable-cs output, and you get a directory full of decompiled C# pseudocode showing every class, every field, every offset.
Two things jumped out:
First, there is a Language.Lua namespace with classes like LuaTable, LuaString, LuaNumber, LuaBoolean, LuaInterpreter. Suzerain has an embedded Lua VM written in C#.
Second, there is PixelCrushers.DialogueSystem.DialogueLua with methods like GetVariable(string) and SetVariable(string, object). So the game uses PixelCrushers Dialogue System, a popular Unity asset for narrative games, which embeds its own Lua interpreter for variable storage.
The data model became clear. The game has a global Lua table called Variable. Every game variable is stored in it as a key-value pair where the key is a LuaString (or sometimes a plain System.String) and the value is a LuaNumber, LuaBoolean, or LuaString. The save file is just this table serialized.
The dump also gave me the field layouts. LuaNumber has a double Number field at offset 16 in the object. LuaTable has its main hash dictionary at offset 24. Every C# Dictionary<TKey, TValue> has a contiguous entries array with each entry shaped like [hashCode(4), next(4), key(8), value(8)] for reference types.
Finding the LuaNumber class at runtime
The metadata told me what to look for. The next problem was actually finding it without hardcoding addresses. Class pointers in IL2CPP are at fixed offsets within GameAssembly.dll once it is loaded, but the loaded base address varies between launches due to ASLR. I needed runtime detection.
My approach was a heuristic that worked the first time and has been reliable since. I sample 8-byte aligned positions in writable memory. For each position whose 8 bytes interpret as a finite, non-zero, integer-valued double in a sane range, I look at the 16 preceding bytes (the type pointer for IL2CPP objects always sits at offset 0). I cluster all the type pointers I see and pick the most common one. That is the LuaNumber class.
for (size_t off = 16; off + 8 <= toRead; off += 8) {
double d;
std::memcpy(&d, buf.data() + off, 8);
if (!std::isfinite(d) || d == 0.0) continue;
if (std::fabs(d) > 1e6) continue;
if (d != std::trunc(d)) continue;
uint64_t typePtr;
std::memcpy(&typePtr, buf.data() + off - 16, 8);
if (typePtr < 0x100000 || typePtr > 0x0000800000000000ULL) continue;
counts[typePtr]++;
}
Suzerain has hundreds of LuaNumber instances representing live game variables, each one preceded by the same type pointer. Other types of objects with double values are much rarer. The cluster wins clearly every time.
The dict entries
Once you have the LuaNumber class pointer, finding dict entries is straightforward in two passes.
Pass one: scan writable memory for any 8-byte position whose value equals the LuaNumber class pointer. Each hit is a LuaNumber object’s start address. Collect all of these into a hash set.
Pass two: scan writable memory again for 8-byte positions whose value is in that set. Each hit is a place that points to a LuaNumber. The 8 bytes immediately before the hit are usually the key pointer for that dict entry. Dereference the key pointer as either a System.String or a LuaString (which itself wraps a System.String), and you have a name for the value.
I filter for keys that start with known prefixes like BaseGame., RiziaDLC., or GameCondition.. After the second pass I have a complete catalog of every numeric in-game variable, keyed by its real name. About 428 of them in a typical save.
Total runtime, parallelized across CPUs, around 20 seconds for a 4 GB process. The bottleneck is the kernel serializing reads from /proc/<pid>/mem, not the scanning logic.
The actual hard part
This is where I almost gave up.
After locating a dict entry pointing to the LuaNumber for BaseGame.GovernmentBudget, I wrote 11.0 to that LuaNumber’s Number field. The game UI showed 11. I high-fived myself. Then I played for a minute, the value changed naturally to 10, and the next time I read from that same address I got 2.42092e-322.
A denormalized double is the smell of pointer-sized garbage being interpreted as a float. The address still existed, the read succeeded, but whatever was at that address was no longer the budget LuaNumber.
Suzerain’s Lua treats values as immutable. When Variable["BaseGame.GovernmentBudget"] changes from 9 to 10, the runtime allocates a new LuaNumber(10) and updates the dict entry’s value pointer to point at the new instance. The old LuaNumber(9) becomes garbage, eventually collected, eventually overwritten by something else.
So caching LuaNumber addresses is hopeless. They go stale on every game tick.
What is stable is the dict entry itself. The dict’s entries array is allocated once and lives at a fixed address for the process lifetime. IL2CPP uses Boehm GC, which is non-moving for non-managed types, so allocations do not relocate. The 8 bytes inside the entry that hold the LuaNumber pointer (the value slot) sit at a stable address. The pointer they hold changes; the slot does not.
The fix is one extra dereference. Instead of caching valueLuaNumberAddr, cache valueSlotAddr. Reads become a two-hop operation:
double readValue(uint64_t valueSlotAddr) {
uint64_t luaPtr;
mem.readInto(valueSlotAddr, &luaPtr, 8);
double v;
mem.readInto(luaPtr + 16, &v, 8);
return v;
}
Writes work the same way: dereference the slot to get the current LuaNumber, then write to its Number field. If the game later allocates a fresh LuaNumber for that variable, our next read picks up the new address automatically through the slot.
This single insight made the rest of the project work. Live polling at 1 Hz, manual edits that stick across game events, autosaves that do not break tracking, all of it falls out of the value-slot indirection.
The build
Once the data model was working I wrapped it in a Qt6 GUI. C++20, frameless window, dark slate theme, sliding mode toggle for the Sordland and Rizia campaigns, sortable table of all 428 variables, search box that supports name substrings and value comparisons (>10, =5, etc.), 1 Hz live polling with the editing cell exempted so typed input is not clobbered.
The platform abstraction is small. One ProcessMem class with an #ifdef _WIN32 split. On Linux it uses pread and pwrite against /proc/<pid>/mem after parsing /proc/<pid>/maps to find writable regions. On Windows it uses OpenProcess, VirtualQueryEx to enumerate regions, and ReadProcessMemory and WriteProcessMemory for I/O. About fifty lines of platform-specific code total.
For distribution I cross compile from Linux to Windows. mingw-w64 for the toolchain, aqtinstall to fetch Qt6 binaries for both targets, a small CMake toolchain file, and windres to embed the icon and version info into the PE. The Windows build is a self-contained 28 MB folder with the executable and the necessary Qt DLLs. Defender flags it as a HackTool on first run, which is standard for memory-editing binaries; whitelist the folder and move on.
The repo has the toolchain file and full instructions if you want to learn how to ship a Qt6 desktop app for Windows from a Linux machine. That path was less documented than I expected.
What I learned
Three things, in order of usefulness for future projects.
Do not fight the game’s data model. Classic memory scanning works against simple games where values are stored as primitives in stable C structs. Modern Unity games are not those games. Once you accept that there is a managed runtime with its own object lifecycle, the right move is to reverse engineer the runtime, not to scan around it.
Cache slots, not values. Anywhere a runtime might replace objects (Lua tables, immutable strings, GC-managed values), pointers to those objects are inherently fragile. Pointers to the slots that hold those pointers are stable. One extra dereference per access is a tiny cost for full correctness across every game state mutation.
IL2CPP is more legible than its reputation. Cpp2IL produces readable pseudocode. The runtime layout is consistent and predictable. Boehm GC is non-moving. Compared to JIT-based Mono, where addresses can move on you, IL2CPP is actually friendly to outside-process introspection once you accept that you are working at the heap level.
If you have ever wanted to mess with the runtime state of a single-player Unity game without touching save files, the playbook above generalizes. Find the scripting layer (Lua, custom DSL, or just C# itself), dump the metadata, locate live values through one stable indirection, and stop fighting the GC.
The repo is at github.com/ts-solidarity/WolknerProtocol. Linux and Windows binaries on the releases page, MIT licensed, single-player only, please be kind to the small studio that made the game.