Reverse Engineering a Unity IL2CPP Save File

I was playing Tainted Grail: The Fall of Avalon when I accidentally committed an “unforgivable crime.” Every NPC became hostile, and there was no in-game way to clear the bounty. Rather than lose dozens of hours of progress, I decided to fix it myself - by reverse engineering the save file format.
What started as a quick fix turned into a fascinating deep dive into Unity IL2CPP internals, binary serialization formats, and native code analysis. Here’s how I did it.
The Problem
The game marks certain crimes as “unforgivable,” which permanently sets NPCs to hostile. The bounty system is designed this way intentionally, but I wanted to reset it. The save files are binary - not JSON or plaintext - so I couldn’t just edit them in a text editor.
Reconnaissance: What Are We Dealing With?
First, I needed to understand the game’s structure. A quick look at the game directory revealed it’s a Unity IL2CPP build. This means:
- The original C# code was compiled to C++ and then to native code
- Type information is stored in
global-metadata.dat - The main game logic is in
GameAssembly.dll
The save files are located in:
AppData/LocalLow/Questline/Fall of Avalon/[SteamID]/Saved/
Each save slot contains several .data files:
Gameplay.data- Main game state (~320KB)CampaignMap_HOS.data- World map stateMetaData.data- Save metadata
Looking at the hex dump of a save file, I could see it wasn’t plaintext:
b4 bd 4d ac 65 00 78 9c ...
Not recognizable as gzip or zlib (wrong magic bytes). Time to dig deeper.
Tool Setup
For this project, I used:
- Il2CppDumper - Extracts C# type definitions from IL2CPP games
- Ghidra - For analyzing the native
GameAssembly.dll - Python - For building the save editor
Running Il2CppDumper
cd /path/to/Il2CppDumper
DOTNET_ROLL_FORWARD=LatestMajor dotnet Il2CppDumper.dll \
GameAssembly.dll \
global-metadata.dat \
output/
This produced a 38MB dump.cs file containing all the game’s class definitions - complete with field offsets and method addresses. Gold mine.
Finding the Bounty System
Searching through dump.cs for bounty-related classes:
// Namespace: Awaken.TG.Main.Heroes.Thievery
public struct BountyTracker.BountyData {
public float bounty; // 0x0
public bool unforgivableCrime; // 0x4
}
public class BountyTracker : Element<Hero> {
public sealed override bool IsNotSaved { get; } // Returns TRUE!
}
Key discovery: BountyTracker has IsNotSaved = true. The bounty isn’t stored directly - it’s calculated at runtime from other data. I needed to find what data it’s calculated from.
Discovering the Save Format
Through trial and error with Python’s zlib:
import zlib
with open("Gameplay.data", "rb") as f:
compressed = f.read()
# Raw Deflate - no zlib/gzip headers!
decompressed = zlib.decompress(compressed, -zlib.MAX_WBITS)
The save files use raw Deflate compression without the standard zlib header. Once decompressed, I could see the structure:
[Version String] "1.00.039\x00"
[Model Definitions] ~550KB of type IDs and names
[Model Data] ~2.5MB of actual saved data
The data section uses a delimiter-based format:
|(0x7c) - Field separator{(0x7b) - Nested object start}(0x7d) - Nested object end
Ghidra Analysis
To understand the exact serialization format, I loaded GameAssembly.dll into Ghidra and found the relevant functions using RVA addresses from dump.cs.
For example, ContextualFacts.Serialize at address 0x1859CC8A0 showed me exactly how the game writes data:
// Writes type marker 0x2a4, then the dictionary contents, then delimiter
auStackX_10[0] = 0x2a4;
FUN_1815022a0(param_2 + 0x10, auStackX_10, 2); // Write 2-byte type
FUN_1814f9570(param_2, ...); // Write dictionary
FUN_185a063e0(param_2 + 0x10, 0x7c, 0); // Write delimiter
The Actual Storage
After tracing through the code, I found where crime data is actually stored. It’s in ContextualFacts - a dictionary-like structure with string keys:
| Key Pattern | Type | Purpose |
|---|---|---|
Bounty: {GUID} | float | Current bounty amount |
UnforgivableCrimeCommitted: {GUID} | bool | The hostile flag! |
{GUID}_Infamy | int | Negative reputation |
{GUID}_Fame | int | Positive reputation |
The strings are UTF-16 encoded, followed by their values and a | delimiter.
Building the Save Editor
With the format understood, I wrote a Python tool to find and reset these values:
def find_unforgivable_entries(data: bytes) -> list:
"""Find UnforgivableCrimeCommitted entries."""
entries = []
prefix = "UnforgivableCrimeCommitted: ".encode('utf-16-le')
i = 0
while True:
pos = data.find(prefix, i)
if pos == -1:
break
# Find string end (null terminator)
str_end = pos + len(prefix)
while data[str_end:str_end+2] != b'\x00\x00':
str_end += 2
# Value is 1 byte after null terminator
value_pos = str_end + 2
bool_val = data[value_pos] # 1 = true, 0 = false
entries.append({
'offset': value_pos,
'key': data[pos:str_end].decode('utf-16-le'),
'value': bool_val
})
i = pos + 1
return entries
def reset_save(filepath):
# Decompress
with open(filepath, 'rb') as f:
data = bytearray(zlib.decompress(f.read(), -zlib.MAX_WBITS))
# Find and reset entries
for entry in find_unforgivable_entries(data):
if entry['value'] == 1:
data[entry['offset']] = 0 # Set to false
# Recompress and save
compressor = zlib.compressobj(level=9, wbits=-zlib.MAX_WBITS)
with open(filepath, 'wb') as f:
f.write(compressor.compress(bytes(data)) + compressor.flush())
The full editor handles:
Bounty: {GUID}- Reset float to 0.0UnforgivableCrimeCommitted: {GUID}- Reset bool to false{GUID}_Infamy- Reset int to 0
The Result
After running the editor:
=== Resetting Crime/Bounty/Infamy Data ===
--- Resetting 1 Bounty Entries ---
Reset Bounty: dac76fde53334594d95eb1b1e0e86519: 5800.00 -> 0.0
--- Resetting 1 UnforgivableCrime Entries ---
Reset UnforgivableCrimeCommitted: dac76fde...: TRUE -> false
--- Resetting 1 Infamy Entries ---
Reset 559996820e73bfb43b32f319ee885880_Infamy: 2 -> 0
=== Save file updated successfully! ===
Loaded the save - NPCs are friendly again!
Lessons Learned
IL2CPP doesn’t hide much - With Il2CppDumper, you get full type information including field offsets and method addresses. The “compilation to native” is more of an optimization than an obfuscation.
Check IsNotSaved properties - My initial assumption that
BountyTracker.BountyDatawas saved was wrong. Always verify what’s actually serialized.Binary formats often have patterns - The delimiter-based format (
|,{,}) made parsing much easier once I recognized it.Ghidra + dump.cs = powerful combo - Using RVA addresses from Il2CppDumper to navigate Ghidra’s decompiled code is an effective workflow for IL2CPP games.
UTF-16 strings are common in Unity - Most string data in the save was UTF-16 LE encoded.
Tools and Resources
- Il2CppDumper - Extract type info from IL2CPP games
- Ghidra - NSA’s reverse engineering framework
- Python’s
zlibmodule - For Deflate compression/decompression
The save editor I built is specific to this game, but the techniques apply to any Unity IL2CPP title. The combination of type extraction and native code analysis makes these games quite approachable for reverse engineering.
Sometimes the best way to learn reverse engineering is to have a problem you actually want to solve.
