Fixing My Corrupted FEZ Savefile
Software is more fragile than you think
Published on 16 January 2023
Last updated on 16 January 2022

Background

FEZ is a puzzle indie game from 2012 featuring unique gameplay mechanics. In short, the player interacts with a 3D world through four 2D views. Because said views erase the depth dimension, the player character (Gomez) can jump between objects distant along the Z-axis.

A few days ago, I was happily going through the puzzles even when they got irredeemably hard; until something very unfortunate happened.

exit
A view of the exit door

The above is an image of a puzzle room in FEZ where Gomez is standing next to the exit door. While solving the puzzle, I was expecting a secret door to be revealed since the world map suggested this. Right after stumbling upon the solution, a transitional animation started playing; I heard the sound of a stone door gliding into the ground.

secret
A view of the revealed secret door

In my rush, I mistakenly went through the exit door before the animation finished playing. Going back in, the secret door was visible, yet unusable. It was as if my leaving the room prematurely caused the game to enter an invalid 'limbo' state. Like most people, I immediately restarted the game hoping it would correct itself — perhaps through built-in soundness checking of the savefile.

Alas, nothing happened. What's more, I would later learn that the game kept backups of savefiles before overwriting them. But it was too late by then, as I had triggered, though unintentionally, multiple save points. This meant the original valid savefile was lost, presumably forever.

First steps

Scouring the internet for similar incidents proved pointless. This was clearly a very rare thing to happen, thanks to my unexpected user input. Note that this happened when my game progress totaled 190%1; I had to recover my savefile because starting all over again was not an option.

First, I ran the Unix file command on a copy of my savefile, hoping it would be JSON or some other well-known format.

$ file SaveSlot0

SaveSlot0: data

Well, that's not very helpful. Running hexdump on the savefile showed some recognizable strings but no further leads. It was a custom binary format — the worst possible scenario2.

$ hexdump -C Savefile0

00000000  cf 3a f0 47 61 28 d9 01  06 00 00 00 00 00 00 00  |.:.Ga(..........|
00000010  26 da fe 49 bd 23 d9 01  01 00 01 00 00 01 12 00  |&..I.#..........|
00000020  00 00 01 11 44 4f 54 5f  4c 4f 43 4b 45 44 5f 44  |....DOT_LOCKED_D|
00000030  4f 4f 52 5f 41 00 01 10  44 4f 54 5f 4e 55 54 5f  |OOR_A...DOT_NUT_|
00000040  4e 5f 42 4f 4c 54 5f 41  01 01 0b 44 4f 54 5f 50  |N_BOLT_A...DOT_P|
00000050  49 56 4f 54 5f 41 01 01  11 44 4f 54 5f 54 49 4d  |IVOT_A...DOT_TIM|
00000060  45 5f 53 57 49 54 43 48  5f 41 01 01 0f 44 4f 54  |E_SWITCH_A...DOT|
00000070  5f 54 4f 4d 42 53 54 4f  4e 45 5f 41 00 01 0c 44  |_TOMBSTONE_A...D|
00000080  4f 54 5f 54 52 45 41 53  55 52 45 00 01 0b 44 4f  |OT_TREASURE...DO|
00000090  54 5f 56 41 4c 56 45 5f  41 01 01 13 44 4f 54 5f  |T_VALVE_A...DOT_|
...

Decompilers are cool

To rectify the savefile, I would need to either reverse-engineer its format — which could be arbitrarily complex or decompile the source code of the game. The latter proved to be the more sensible option since I knew the game ran on .NET and was most probably written in C#.

I downloaded the notorious NSA reverse engineering tool Ghidra hoping to decompile portions of the code responsible for savefile operations.

ghidra
The Ghidra logo

Despite having a very cool logo, I couldn't make the Ghidra decompiler work on macOS. Though it's supposed to work even without Rosetta 2 emulation. A few unsuccessful troubleshooting attempts later, I gave up on it because there were other options.

Enter dotPeek, a 'Free .NET Decompiler and Assembly Browser' by JetBrains which is exactly what I needed.

I started by searching the code for keywords such as 'Savefile' and, lo and behold, there was a class called SaveFileOperations with methods to read/write objects of a SaveData class. At this point, I needed to extract the closure of SaveFileOperations's dependencies.

Initially, I copied the class into a local C# console application project, then resolved any unknown references by copying the needed classes. Some references pointed outside the proper FEZ engine, such as Microsoft XNA's Vector3 class; for such cases, I sought to work with a mocked class with minimal methods/properties, otherwise I'd end up pulling in an obscene amount of code.

Later, I found myself with a total of 15 classes — mostly relating to game objects, Cyclic Redundancy Checking of save data, and the code for reading and writing said data to disk.

I hear you asking: Can I see the source code?

The Law requires that I answer "No"

Identity transform

First, I had to make sure I can replicate a savefile byte-to-byte:

var reader = new BinaryReader(File.OpenRead("SaveSlot0"));
// When writing save data to disk, the game prepends the current time
var magic = reader.ReadInt64();
var saveData = SaveFileOperations.Read(new CrcReader(reader));

var writer = new BinaryWriter(File.OpenWrite("SaveSlot0Prime"));
SaveFileOperations.Write(new CrcWriter(writer), saveData);

This did not work. The game wouldn't even load SaveSlot0Prime. The issue seemed to stem from the fact that my generated savefile was only 20K in size while the proper savefiles from the game were always 40K. Using M-x ediff-buffers, I saw that the extra 20K was just null bytes. What the heck?

Digging a bit more into the code I found the PCSaveDevice class responsible for saving and loading files — note that SaveFileOperations only interfaces with Crc{Reader,Writer}. The PCSaveDevice class seems to originate from the EasyStorage package for XNA.

For some reason I cannot possibly fathom, the PCSaveDevice class always writes save data into a byte buffer of size 40960 and raises an exception if the save data is larger. Oddly enough, the EasyStorage version I found does not seem to do this3. The revised script now looked like this4:

var reader = new BinaryReader(File.OpenRead("SaveSlot0"));
var magic = reader.ReadInt64();
var saveData = SaveFileOperations.Read(new CrcReader(reader));

var buffer = new byte[40960];
var output = new BinaryWriter(new MemoryStream(new byte[40960]));
output.Write(magic);
SaveFileOperations.Write(new CrcWriter(output), saveData);

var writer = new BinaryWriter(File.OpenWrite("SaveSlot0Prime"));
writer.Write(buffer);

At this point, SaveSlot0 and SaveSlot0Prime were finally identical.

Restoring the level

Next, I had to address the elephant in the room: restoring my savefile to a consistent state. An interesting starting point was the World property of SaveData; it's of type Dictionary<string, LevelSaveData> and contains all of FEZ's levels, including the one I wish to fix.

Looking around in the LevelSaveData class, there was a public bool FirstVisit property, which I hoped would cause the game to reset the level if I set it to true.

// The level name was found using the `SaveData.Level` property, 
// which records the current level
saveData.World["ZU_4_SIDE"].FirstVisit = true;

Sadly, this did not have the desired effect.

My next idea was to make a fresh savefile where the ZU_4_SIDE level was untouched and use that to replace my corrupted one. To my surprise, there was no ZU_4_SIDE level in the World dictionary of a fresh savefile. I realized that the game doesn't include save data for unsolved levels.

Now the solution was clear: simply remove the ZU_4_SIDE level data from the savefile.

saveData.World.Remove("ZU_4_SIDE");

Et voilà, the world was saved.

ending
Gomez acquires the anti-cube of ZU_4_SIDE

1

I deliberately avoid elaborating on why the progress percentage is over 100%. If your interest is peaked, I encourage you to play FEZ for yourself.

2

But hey, at least it wasn't encrypted!

3

It's important to note that the git history of the repository I linked only stretches back two commits.

4

The script I supplied mimics the code I found in EasyStorage. Perhaps there is a more efficient way of setting file sizes in .NET?