Reverse Engineering einer Unity IL2CPP Save-Datei

Feb. 20, 2026·
David Bösiger
David Bösiger
· 5 Min Lesezeit
blog

Ich spielte Tainted Grail: The Fall of Avalon, als ich versehentlich ein “unverzeihliches Verbrechen” beging. Jeder NPC wurde feindlich, und es gab keine Möglichkeit im Spiel, das Kopfgeld zu löschen. Anstatt Dutzende Stunden Spielfortschritt zu verlieren, entschied ich mich, es selbst zu beheben - indem ich das Save-Datei-Format reverse engineerte.

Was als schnelle Lösung begann, wurde zu einem faszinierenden Deep Dive in Unity IL2CPP-Interna, binäre Serialisierungsformate und Native-Code-Analyse. So habe ich es gemacht.

Das Problem

Das Spiel markiert bestimmte Verbrechen als “unverzeihlich”, was NPCs permanent auf feindlich setzt. Das Kopfgeldsystem ist absichtlich so designed, aber ich wollte es zurücksetzen. Die Save-Dateien sind binär - nicht JSON oder Klartext - also konnte ich sie nicht einfach in einem Texteditor bearbeiten.

Aufklärung: Womit haben wir es zu tun?

Zuerst musste ich die Struktur des Spiels verstehen. Ein kurzer Blick ins Spielverzeichnis zeigte, dass es ein Unity IL2CPP-Build ist. Das bedeutet:

  • Der originale C#-Code wurde zu C++ und dann zu Native Code kompiliert
  • Typinformationen sind in global-metadata.dat gespeichert
  • Die Hauptspiellogik ist in GameAssembly.dll

Die Save-Dateien befinden sich in:

AppData/LocalLow/Questline/Fall of Avalon/[SteamID]/Saved/

Jeder Speicherslot enthält mehrere .data-Dateien:

  • Gameplay.data - Hauptspielzustand (~320KB)
  • CampaignMap_HOS.data - Weltkartenzustand
  • MetaData.data - Save-Metadaten

Ein Blick auf den Hex-Dump einer Save-Datei zeigte, dass es kein Klartext war:

b4 bd 4d ac 65 00 78 9c ...

Nicht erkennbar als gzip oder zlib (falsche Magic Bytes). Zeit, tiefer zu graben.

Tool-Setup

Für dieses Projekt verwendete ich:

  1. Il2CppDumper - Extrahiert C#-Typdefinitionen aus IL2CPP-Spielen
  2. Ghidra - Für die Analyse der nativen GameAssembly.dll
  3. Python - Für den Bau des Save-Editors

Il2CppDumper ausführen

cd /path/to/Il2CppDumper
DOTNET_ROLL_FORWARD=LatestMajor dotnet Il2CppDumper.dll \
  GameAssembly.dll \
  global-metadata.dat \
  output/

Dies produzierte eine 38MB grosse dump.cs-Datei mit allen Klassendefinitionen des Spiels - komplett mit Feld-Offsets und Methodenadressen. Eine Goldgrube.

Das Kopfgeldsystem finden

Durchsuchen von dump.cs nach kopfgeldbezogenen Klassen:

// 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; } // Gibt TRUE zurück!
}

Wichtige Entdeckung: BountyTracker hat IsNotSaved = true. Das Kopfgeld wird nicht direkt gespeichert - es wird zur Laufzeit berechnet aus anderen Daten. Ich musste herausfinden, aus welchen Daten es berechnet wird.

Das Save-Format entdecken

Durch Trial and Error mit Pythons zlib:

import zlib

with open("Gameplay.data", "rb") as f:
    compressed = f.read()

# Raw Deflate - keine zlib/gzip-Header!
decompressed = zlib.decompress(compressed, -zlib.MAX_WBITS)

Die Save-Dateien verwenden rohe Deflate-Kompression ohne den Standard-zlib-Header. Nach der Dekompression konnte ich die Struktur sehen:

[Version String]     "1.00.039\x00"
[Model Definitions]  ~550KB Typ-IDs und Namen
[Model Data]         ~2.5MB tatsächlich gespeicherte Daten

Der Datenabschnitt verwendet ein Delimiter-basiertes Format:

  • | (0x7c) - Feldtrenner
  • { (0x7b) - Verschachteltes Objekt Start
  • } (0x7d) - Verschachteltes Objekt Ende

Ghidra-Analyse

Um das genaue Serialisierungsformat zu verstehen, lud ich GameAssembly.dll in Ghidra und fand die relevanten Funktionen mittels RVA-Adressen aus dump.cs.

Zum Beispiel zeigte mir ContextualFacts.Serialize an Adresse 0x1859CC8A0 genau, wie das Spiel Daten schreibt:

// Schreibt Typ-Marker 0x2a4, dann Dictionary-Inhalt, dann Delimiter
auStackX_10[0] = 0x2a4;
FUN_1815022a0(param_2 + 0x10, auStackX_10, 2);  // Schreibe 2-Byte-Typ
FUN_1814f9570(param_2, ...);                     // Schreibe Dictionary
FUN_185a063e0(param_2 + 0x10, 0x7c, 0);         // Schreibe Delimiter

Die tatsächliche Speicherung

Nach dem Durchverfolgen des Codes fand ich, wo Verbrechensdaten tatsächlich gespeichert werden. Es ist in ContextualFacts - eine Dictionary-ähnliche Struktur mit String-Keys:

Key-MusterTypZweck
Bounty: {GUID}floatAktueller Kopfgeldbetrag
UnforgivableCrimeCommitted: {GUID}boolDas Feindlich-Flag!
{GUID}_InfamyintNegative Reputation
{GUID}_FameintPositive Reputation

Die Strings sind UTF-16 kodiert, gefolgt von ihren Werten und einem |-Delimiter.

Den Save-Editor bauen

Mit dem verstandenen Format schrieb ich ein Python-Tool, um diese Werte zu finden und zurückzusetzen:

def find_unforgivable_entries(data: bytes) -> list:
    """Finde UnforgivableCrimeCommitted-Einträge."""
    entries = []
    prefix = "UnforgivableCrimeCommitted: ".encode('utf-16-le')

    i = 0
    while True:
        pos = data.find(prefix, i)
        if pos == -1:
            break

        # Finde String-Ende (Null-Terminator)
        str_end = pos + len(prefix)
        while data[str_end:str_end+2] != b'\x00\x00':
            str_end += 2

        # Wert ist 1 Byte nach 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):
    # Dekomprimieren
    with open(filepath, 'rb') as f:
        data = bytearray(zlib.decompress(f.read(), -zlib.MAX_WBITS))

    # Einträge finden und zurücksetzen
    for entry in find_unforgivable_entries(data):
        if entry['value'] == 1:
            data[entry['offset']] = 0  # Auf false setzen

    # Rekomprimieren und speichern
    compressor = zlib.compressobj(level=9, wbits=-zlib.MAX_WBITS)
    with open(filepath, 'wb') as f:
        f.write(compressor.compress(bytes(data)) + compressor.flush())

Der vollständige Editor behandelt:

  • Bounty: {GUID} - Float auf 0.0 zurücksetzen
  • UnforgivableCrimeCommitted: {GUID} - Bool auf false zurücksetzen
  • {GUID}_Infamy - Int auf 0 zurücksetzen

Das Ergebnis

Nach dem Ausführen des Editors:

=== 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! ===

Spielstand geladen - NPCs sind wieder freundlich!

Gelernte Lektionen

  1. IL2CPP versteckt nicht viel - Mit Il2CppDumper bekommt man volle Typinformationen inklusive Feld-Offsets und Methodenadressen. Die “Kompilierung zu Native” ist mehr Optimierung als Verschleierung.

  2. IsNotSaved-Properties prüfen - Meine anfängliche Annahme, dass BountyTracker.BountyData gespeichert wird, war falsch. Immer verifizieren, was tatsächlich serialisiert wird.

  3. Binärformate haben oft Muster - Das Delimiter-basierte Format (|, {, }) machte das Parsen viel einfacher, sobald ich es erkannte.

  4. Ghidra + dump.cs = mächtige Kombo - RVA-Adressen aus Il2CppDumper zu nutzen, um in Ghidras dekompiliertem Code zu navigieren, ist ein effektiver Workflow für IL2CPP-Spiele.

  5. UTF-16-Strings sind häufig in Unity - Die meisten String-Daten im Save waren UTF-16 LE kodiert.

Tools und Ressourcen

  • Il2CppDumper - Typinfos aus IL2CPP-Spielen extrahieren
  • Ghidra - NSAs Reverse-Engineering-Framework
  • Pythons zlib-Modul - Für Deflate-Kompression/Dekompression

Der Save-Editor, den ich gebaut habe, ist spezifisch für dieses Spiel, aber die Techniken gelten für jeden Unity-IL2CPP-Titel. Die Kombination aus Typextraktion und Native-Code-Analyse macht diese Spiele recht zugänglich für Reverse Engineering.


Manchmal ist der beste Weg, Reverse Engineering zu lernen, ein Problem zu haben, das man wirklich lösen will.