<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>unity · wleeaf field notes</title><link>https://articles.wleeaf.dev/tags/unity/</link><description>Hands-on guides and build logs from the wleeaf studio: self-hosting, Linux, networking, Docker, the command line and reverse engineering.</description><language>en-us</language><lastBuildDate>Thu, 07 May 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://articles.wleeaf.dev/tags/unity/feed.xml" rel="self" type="application/rss+xml"/><item><title>Live Editing Suzerain: Walking an IL2CPP Heap to Find a Hidden Lua VM</title><link>https://articles.wleeaf.dev/posts/reverse-engineering-suzerain/</link><pubDate>Thu, 07 May 2026 00:00:00 +0000</pubDate><guid>https://articles.wleeaf.dev/posts/reverse-engineering-suzerain/</guid><description>How I built WolknerProtocol, a real-time state editor for Suzerain, by reverse engineering its Unity 6 IL2CPP build to find the in-process Lua variable table without any static knowledge of class layouts.</description><category>reverse-engineering</category><category>il2cpp</category><category>unity</category><category>lua</category><category>cpp</category><category>qt</category><content:encoded><![CDATA[<p>I wanted to replay specific late-game branches in <a href="https://torporgames.com/">Suzerain</a> 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.</p>
<p>So I built a runtime state editor instead. It attaches to a running <code>Suzerain.exe</code>, finds the live in-game Lua variables, and lets me read or rewrite any of them without restarting the game. The result is <a href="https://github.com/ts-solidarity/WolknerProtocol">WolknerProtocol</a>, 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.</p>
<h2 id="what-i-was-working-with">What I was working with<a class="anchor" href="#what-i-was-working-with" aria-label="Link to “What I was working with”">#</a></h2>
<p>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 <code>GameAssembly.dll</code> (the compiled native code) and <code>il2cpp_data/Metadata/global-metadata.dat</code> (the game&rsquo;s own type information).</p>
<p>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 <code>variables</code> field that turned out to be a Lua-table-style serialization of around 800,000 characters. Things like <code>[&quot;BaseGame.GovernmentBudget&quot;]=9, [&quot;BaseGame.PersonalWealth&quot;]=4, ...</code> going on forever.</p>
<h2 id="dead-end-one-classic-memory-scanning">Dead end one: classic memory scanning<a class="anchor" href="#dead-end-one-classic-memory-scanning" aria-label="Link to “Dead end one: classic memory scanning”">#</a></h2>
<p>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.</p>
<p>This works for finding <em>a</em> value of 9 in memory. It does not work for finding <em>the</em> 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.</p>
<p>I confirmed this by overwriting the literal save-file string <code>[&quot;BaseGame.GovernmentBudget&quot;]=9</code> to <code>=5</code> 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.</p>
<h2 id="dead-end-two-save-editing">Dead end two: save editing<a class="anchor" href="#dead-end-two-save-editing" aria-label="Link to “Dead end two: save editing”">#</a></h2>
<p>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.</p>
<p>Two reasons it is not enough. First, you need to restart the game on every change. Second, the variables <code>BaseGame.Sordland_HUDStat_GovernmentBudget_Max = 30</code> 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.</p>
<p>I wanted to edit values mid-play, freely, with no consequences other than the game state itself.</p>
<h2 id="the-dump-that-changed-everything">The dump that changed everything<a class="anchor" href="#the-dump-that-changed-everything" aria-label="Link to “The dump that changed everything”">#</a></h2>
<p>I gave up on blind scanning and dumped the IL2CPP metadata. The right tool for Unity 6 (metadata version 31) is <a href="https://github.com/SamboyCoding/Cpp2IL">Cpp2IL</a>. Run it against the game directory, ask for <code>diffable-cs</code> output, and you get a directory full of decompiled C# pseudocode showing every class, every field, every offset.</p>
<p>Two things jumped out:</p>
<p>First, there is a <code>Language.Lua</code> namespace with classes like <code>LuaTable</code>, <code>LuaString</code>, <code>LuaNumber</code>, <code>LuaBoolean</code>, <code>LuaInterpreter</code>. Suzerain has an embedded Lua VM written in C#.</p>
<p>Second, there is <code>PixelCrushers.DialogueSystem.DialogueLua</code> with methods like <code>GetVariable(string)</code> and <code>SetVariable(string, object)</code>. So the game uses <a href="https://www.pixelcrushers.com/dialogue-system/">PixelCrushers Dialogue System</a>, a popular Unity asset for narrative games, which embeds its own Lua interpreter for variable storage.</p>
<p>The data model became clear. The game has a global Lua table called <code>Variable</code>. Every game variable is stored in it as a key-value pair where the key is a <code>LuaString</code> (or sometimes a plain <code>System.String</code>) and the value is a <code>LuaNumber</code>, <code>LuaBoolean</code>, or <code>LuaString</code>. The save file is just this table serialized.</p>
<p>The dump also gave me the field layouts. <code>LuaNumber</code> has a <code>double Number</code> field at offset 16 in the object. <code>LuaTable</code> has its main hash dictionary at offset 24. Every C# <code>Dictionary&lt;TKey, TValue&gt;</code> has a contiguous <code>entries</code> array with each entry shaped like <code>[hashCode(4), next(4), key(8), value(8)]</code> for reference types.</p>
<h2 id="finding-the-luanumber-class-at-runtime">Finding the LuaNumber class at runtime<a class="anchor" href="#finding-the-luanumber-class-at-runtime" aria-label="Link to “Finding the LuaNumber class at runtime”">#</a></h2>
<p>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 <code>GameAssembly.dll</code> once it is loaded, but the loaded base address varies between launches due to ASLR. I needed runtime detection.</p>
<p>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.</p>
<div class="code is-plain">
<div class="code-head"><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<pre tabindex="0"><code>for (size_t off = 16; off + 8 &lt;= toRead; off += 8) {
    double d;
    std::memcpy(&amp;d, buf.data() + off, 8);
    if (!std::isfinite(d) || d == 0.0) continue;
    if (std::fabs(d) &gt; 1e6) continue;
    if (d != std::trunc(d)) continue;
    uint64_t typePtr;
    std::memcpy(&amp;typePtr, buf.data() + off - 16, 8);
    if (typePtr &lt; 0x100000 || typePtr &gt; 0x0000800000000000ULL) continue;
    counts[typePtr]++;
}</code></pre>
</div>
<p>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.</p>
<h2 id="the-dict-entries">The dict entries<a class="anchor" href="#the-dict-entries" aria-label="Link to “The dict entries”">#</a></h2>
<p>Once you have the LuaNumber class pointer, finding dict entries is straightforward in two passes.</p>
<p>Pass one: scan writable memory for any 8-byte position whose value equals the LuaNumber class pointer. Each hit is a LuaNumber object&rsquo;s start address. Collect all of these into a hash set.</p>
<p>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 <code>System.String</code> or a <code>LuaString</code> (which itself wraps a <code>System.String</code>), and you have a name for the value.</p>
<p>I filter for keys that start with known prefixes like <code>BaseGame.</code>, <code>RiziaDLC.</code>, or <code>GameCondition.</code>. 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.</p>
<p>Total runtime, parallelized across CPUs, around 20 seconds for a 4 GB process. The bottleneck is the kernel serializing reads from <code>/proc/&lt;pid&gt;/mem</code>, not the scanning logic.</p>
<h2 id="the-actual-hard-part">The actual hard part<a class="anchor" href="#the-actual-hard-part" aria-label="Link to “The actual hard part”">#</a></h2>
<p>This is where I almost gave up.</p>
<p>After locating a dict entry pointing to the LuaNumber for <code>BaseGame.GovernmentBudget</code>, I wrote <code>11.0</code> to that LuaNumber&rsquo;s <code>Number</code> 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 <code>2.42092e-322</code>.</p>
<p>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.</p>
<p>Suzerain&rsquo;s Lua treats values as immutable. When <code>Variable[&quot;BaseGame.GovernmentBudget&quot;]</code> changes from 9 to 10, the runtime allocates a new <code>LuaNumber(10)</code> and updates the dict entry&rsquo;s value pointer to point at the new instance. The old <code>LuaNumber(9)</code> becomes garbage, eventually collected, eventually overwritten by something else.</p>
<p>So caching LuaNumber addresses is hopeless. They go stale on every game tick.</p>
<p>What is stable is the dict entry itself. The dict&rsquo;s <code>entries</code> 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.</p>
<p>The fix is one extra dereference. Instead of caching <code>valueLuaNumberAddr</code>, cache <code>valueSlotAddr</code>. Reads become a two-hop operation:</p>
<div class="code is-plain">
<div class="code-head"><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<pre tabindex="0"><code>double readValue(uint64_t valueSlotAddr) {
    uint64_t luaPtr;
    mem.readInto(valueSlotAddr, &amp;luaPtr, 8);
    double v;
    mem.readInto(luaPtr + 16, &amp;v, 8);
    return v;
}</code></pre>
</div>
<p>Writes work the same way: dereference the slot to get the <em>current</em> LuaNumber, then write to its <code>Number</code> field. If the game later allocates a fresh LuaNumber for that variable, our next read picks up the new address automatically through the slot.</p>
<p>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.</p>
<h2 id="the-build">The build<a class="anchor" href="#the-build" aria-label="Link to “The build”">#</a></h2>
<p>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 (<code>&gt;10</code>, <code>=5</code>, etc.), 1 Hz live polling with the editing cell exempted so typed input is not clobbered.</p>
<p>The platform abstraction is small. One <code>ProcessMem</code> class with an <code>#ifdef _WIN32</code> split. On Linux it uses <code>pread</code> and <code>pwrite</code> against <code>/proc/&lt;pid&gt;/mem</code> after parsing <code>/proc/&lt;pid&gt;/maps</code> to find writable regions. On Windows it uses <code>OpenProcess</code>, <code>VirtualQueryEx</code> to enumerate regions, and <code>ReadProcessMemory</code> and <code>WriteProcessMemory</code> for I/O. About fifty lines of platform-specific code total.</p>
<p>For distribution I cross compile from Linux to Windows. <code>mingw-w64</code> for the toolchain, <a href="https://github.com/miurahr/aqtinstall">aqtinstall</a> to fetch Qt6 binaries for both targets, a small CMake toolchain file, and <code>windres</code> 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.</p>
<p>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.</p>
<h2 id="what-i-learned">What I learned<a class="anchor" href="#what-i-learned" aria-label="Link to “What I learned”">#</a></h2>
<p>Three things, in order of usefulness for future projects.</p>
<p><strong>Do not fight the game&rsquo;s data model.</strong> 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.</p>
<p><strong>Cache slots, not values.</strong> 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.</p>
<p><strong>IL2CPP is more legible than its reputation.</strong> 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.</p>
<p>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.</p>
<p>The repo is at <a href="https://github.com/ts-solidarity/WolknerProtocol">github.com/ts-solidarity/WolknerProtocol</a>. Linux and Windows binaries on the releases page, MIT licensed, single-player only, please be kind to the small studio that made the game.</p>
]]></content:encoded></item></channel></rss>