Reverse Engineering a Unity IL2CPP Save File

Feb 20, 2026·
David Bösiger
David Bösiger
· 5 min read
blog

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 state
  • MetaData.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:

  1. Il2CppDumper - Extracts C# type definitions from IL2CPP games
  2. Ghidra - For analyzing the native GameAssembly.dll
  3. 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 PatternTypePurpose
Bounty: {GUID}floatCurrent bounty amount
UnforgivableCrimeCommitted: {GUID}boolThe hostile flag!
{GUID}_InfamyintNegative reputation
{GUID}_FameintPositive 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.0
  • UnforgivableCrimeCommitted: {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

  1. 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.

  2. Check IsNotSaved properties - My initial assumption that BountyTracker.BountyData was saved was wrong. Always verify what’s actually serialized.

  3. Binary formats often have patterns - The delimiter-based format (|, {, }) made parsing much easier once I recognized it.

  4. Ghidra + dump.cs = powerful combo - Using RVA addresses from Il2CppDumper to navigate Ghidra’s decompiled code is an effective workflow for IL2CPP games.

  5. 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 zlib module - 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.