Preface
Hey, this is the first official post in my new blog!
I’ve been itching for a while now to share the tech adventures I do in my free time. The itch came after I realized how much I enjoy telling friends about these endeavors, and they seem happy listening to them (or at least they pretend well :)). So while the idea of writing publicly is a bit nerve-wracking, it’s time to take the plunge and give it a shot. So, without further ado, let’s embark on this journey!
Intro
To get this started, this relatively short post takes a dive into the realm of reverse engineering, but with a unique twist – it’s all about having fun with friends!
This year marks the 10th anniversary of our annual LAN party. What began as a casual suggestion a decade ago - “Hey, let’s spend the weekend at my place and play some games together” - has evolved into a gathering of around 20 people. I spend the entire year preparing for it: collecting game options, selecting the final list of games, ensuring they run smoothly on the latest Windows version, and distributing them – all securely locked with a password, of course, to preserve the surprises!
Typically, we avoid replaying games, always seeking new experiences. Admittedly, it’s getting challenging to find fresh ones. However, in honor of our party’s tenth anniversary, we’ve decided to revisit the classics – the games we enjoyed most in previous years.
The process of eliminating games was tough, there were just so many good ones! In the end, only nine remained, and among them was the infamous, highly controversial, and incredibly addictive pirate-themed game Windward, which had taken our third-year party by storm.
Chapter 1 - The Challenge
The last time we embarked on a Windward adventure, it was a wild journey, particularly when someone stumbled upon the option to rename towns for a fee, leading us to discover the city of “Bitches and Hoes.”
But not all was smooth sailing,
- It was impossible to play on a map that allowed us to sail the seas freely without being completely destroyed by high-level pirates.
- The early game was burdened with extensive, solo-trading and questing expeditions across distant locations.
So, this time around, I was determined to enhance our experience by tackling these problems head-on.
Upon launching the latest version of Windward (v.2017-06-17.0
), and generating a new world, I immediately noticed a significant change in the map generation system. It now allowed the ability to create amazing maps, such as this one designed for a co-op mission against low-level pirates,
Or another, where two teams face off with no pirates to intrude,
This resolved our first challenge. To surmount the second hurdle, however, I had to resort to the unthinkable: manipulating the game’s savefile to gain a head start!
My goal was modest - provide everyone with a few initial levels and some extra resources, accelerating the game’s pace. Thus, we venture into the realm of savefile editing.
Chapter 2 - All the Paths Lead To Reverse Engineering
In life, one of the most valuable lessons is this: why work hard if you can just Google it? Indeed, back in 2014, a kind soul posed the question “Is it possible to cheat and give yourself money” on the Windward forums.
Fortunately, a helpful contributor in the comments revealed a straightforward method for giving yourself money: adjust the value the comes after the resource name which is simply plaintext. Also, he hinted the value is in little-endian.
I wasted no time and located the savefile, which the game automatically stores at Documents\Windward\Players\{playerName}.player
on my Windows system. With eager anticipation, I opened it in a hex editor.
Alas, it was not plaintext.
As it turned out, in 2015, the game’s developer made a change, rendering the savefile no longer easily accessible in plaintext. While we could revert to an older game version, that would mean sacrificing the new world editor, bringing back problem #1.
So it appears that I needed to get my hands dirty. I scheduled a meeting with a trusted friend, whose C# skills are matched only by his prowess in reverse engineering, and we embarked on this challenging endeavor.
Chapter 3 - Gazing Into the Abyss
Much like ships sailing the vast seas, the Windwards game sails using the programming language C…sharp. Programming languages such as C# and Java get translated to an “intermediate language” (IL) rather than being directly assembled and compiled into executables, a practice common in low-level languages.
The IL “words” are special opcodes, some even describing quite complicated operations. During runtime, these IL commands are executed by a virtual machine that interprets this unique language.
This architectural choice makes IL easier to reverse engineer: the conversion from IL back to code is notably straightforward. Furthermore, unless intentionally stripped during compilation, IL retains meaningful names and debugging information, invaluable for the process of reverse engineering.
Numerous programs do a good job reflecting (the jargon for “reversing” an IL-based language) C#. We opted for dnSpy, a great tool for reflecting the IL used by C#.
To use dnSpy, our immediate quest was to load some file and preferably one containing the relevant code. An online search quickly revealed the treasure trove for Unity games- the Assembly-CSharp.dll
file. Indeed, upon loading this file and inspecting classes without a namespace (distinguished by the -
marker in dnSpy), we discovered the GamePlayer
class. Within its cargo hold, we discovered some very interesting functions like GiveItem
, AwardXP
, and Save
.
public static void Save(bool encoded)
{
if (string.IsNullOrEmpty(GamePlayer.mPlayerFile))
{
return;
}
// ...
DataNode dataNode = TNManager.playerData as DataNode;
This Save
function is called from another class’s function, bearing a similarly descriptive name: MyPlayer.Save
. Taking a deeper look we discovered MyPlayer.Save
is invoked on various occasions, including SavePeriodically
, OnStart
, and during FastTravel
. Makes sense.
The dataNode
variable we see above is eventually written to the destination where I had initially found the .player
file. This variable contains player data in the form of a DateNode
object, alongside some additional nodes that are added during Save
. The DataNode
class appears to implement a hierarchical structure of nodes, each marked by a key and a value.
While constructing the object destined to be written to a file, the Save
function checks a boolean flag, GameConfig.saveInBinary
. This flag determines whether the file is stored using binaryWriter
or streamWriter
. According to dnSpy, this flag is always set to true
. Inspecting the data that is being stored in TNManager.playerData
, this makes sense, given that the values are often primitive data types like integers and floats.
if (GameConfig.saveInBinary)
{
MemoryStream memoryStream = new MemoryStream();
BinaryWriter binaryWriter = new BinaryWriter(memoryStream);
// ...
dataNode.Write(binaryWriter, encoded && GameConfig.saveCompressed);
array2 = memoryStream.ToArray();
// ...
}
else
{
MemoryStream memoryStream2 = a StreamWriter(memoryStream2);
StreamWriter streamWriter = new StreamWriter(memoryStream2);
// ...
dataNode.Write(streamWriter, 0);
// ...
}
As depicted in the code above, the dataNote
variable implements a Write
function, tasked with writing the DataNode
object to the writer it’s given.
At this juncture, we faced a difficult decision. We could buckle down and dive deeper into the code to fully understand the savefile’s format. However, the developer’s comment hinted at a big obstacle: a custom binaryWriter
implementation, which greatly complicates static reverse engineering efforts.
To circumvent that, we could instead simply execute the code. This strategy allows pausing just before Save
enabling manipulation of objects like the dataNote
variable during runtime.
While my friend opted to try to go for the latter approach, I charted a different course, delving further into the code.
Further down in the Save
function, I noticed it concludes with a short piece of code that backs up the .player
file in the Documents\Windward\Backup
directory if the dataNote
object was successfully processed.
if (array2 != null)
{
// ...
string text = DateTime.Now.ToString("M_d_yyyy_HH");
string text2 = Tools.GetDocumentsPath("Backup/" + text + "/" + GamePlayer.mPlayerFile);
if (!File.Exists(Tools.FindFile(text2, false)))
{
Tools.WriteFile(text2, array2, false, false);
}
}
I navigated to my local Backup
directory, retrieved one of the early .player
files, and to my astonishment, I had a plaintext .player
file in all its glory!
So what happened here? Why is the file in (sort of) plaintext? Well, it seems that when a player is first created, the game calls Save
with the encoded
parameter being false
. Only afterward is the Save
function called again with encoded
set to true
. While the standard .player
file is overwritten, the Backup file remains unchanged (thanks to the File.Exists
check), preserving its plaintext form.
But isn’t there a custom binaryWriter
implementation? Well, inspecting DataNode.Write
, we found this code:
public void Write(BinaryWriter writer, bool compressed = false)
{
if (compressed)
{
LZMA lzma = new LZMA();
lzma.BeginWriting().WriteObject(this);
byte[] array = lzma.Compress();
if (array != null)
{
for (int i = 0; i < 4; i++)
{
writer.Write(DataNode.mLZMA[i]);
}
writer.Write(array);
return;
}
}
writer.WriteObject(this);
// ...
}
The code doesn’t do anything special. It simply writes 4 bytes (CD01
) using the static mLZMA
class member and then the DataNode
object follows, compressed using the LZMA algorithm. The outcome is then simply written to the built-in BinaryWriter
.
So by taking any non-plaintext .player
savefile, removing the first 4 bytes, and then uncompressing (I used $ lzma -d {playerName}.player
), we get an output file identical to the file found in that backup folder.
Now we know what the developer changed: they added compression on top of the savefile, making it harder to analyze or edit without reverse engineering.
Finally, as you can notice, the so-called “plaintext” is, in fact, a DataNode
object. This object comprises nodes identified by string keys (which is what we see and can easily read), each carrying a value of varying types.
Chapter 4 - It’s (Almost) Patchin’ Time!
As we now know, the file is simply a DataNode
object saved using a BinaryWriter
. After figuring this out, we dove into the intricacies of how such an object is saved to a file.
It seems to follow a straightforward pattern: key names starting with their length, followed by the string key itself. Then, there’s a type indicator for the value which comes right after. Nodes are separated using a null byte. We also found that list-type values have their elements separated by a null byte as well.
Below are several examples illustrating this format:
04 || 74 6F 77 6E || 07 || 09 || 43 61 73 65 6E 76 69 65 77
key length || t o w n || type (str) || str length || C a s e n v i e w
04 || 77 6F 6F 64 || 14 || 22 00 00 02
key length || w o o d || type (int) || 4-byte int
07 || 55 6E 6C 6F 63 6B 73 || 00 || 05 || 10 || 53 6C 6F 6F 70 ... || 00 || ...
key length || U n l o c k s || type (list) || list length || element length || S l o o p ... || element seperator || ...
This format allows us to quite easily manipulate the file’s content! We had an immediate question: will Windward accept a plaintext .player
file? If it does, we can make patching an easy process.
Carefully taking the wood
key’s value (22 00 00 02
), copying it to the value of the stone
key, placing the edited .player
file in the right place, and opening a new game loading the edited profile in the process…
The experiment was a success! We now also have an increased amount of 50 stone :) To ensure Windward consistently loaded the new edit file every time, we removed all instances of the original .player
file in various save locations. I would later discover that simply changing the name of the file works. At the time I was worried that the name was verified in some way using the file’s content.
So now our goal is in sight: let’s figure out how to give us heaps of gold…Let’s say, a million?
Recalling the previous test, something struck us as odd. Why the hell did the value 22 00 00 02
in the Wood and Stone keys correspond to 50
? And why does 20 22 00 00
results in 100
Gold? Furthermore, we observed other integer values in the file were saved with type 04
, while all resources used type 14
. Very odd indeed.
Despite making multiple tests, changing resource values, and seeing in-game results, we couldn’t discern a pattern.
Another weirdness, we noticed that the player’s XP was not present in the save file although the GamePlayer
class had an attribute for it.
Our journey into understanding Windward’s savefile format raised more questions than answers, leaving us eager to uncover the mystery.
Chapter 5 - Unmasking the Obfuscation
Back into the depths of dnSpy, we set our sights on code segments that deal with resources. Eventually, we landed at the MyPlayer.SetResource
function:
public static void SetResource(string name, int val)
{
DataNode playerDataNode = TNManager.playerDataNode;
DataNode dataNode;
if (GlobalManager.assemblyMods.size != 0)
// ...
else
{
dataNode = playerDataNode.GetChild("Resources", true);
}
dataNode.SetChild(name, new ObsInt(val));
MyPlayer.syncNeeded = true;
MyPlayer.saveNeeded = true;
}
As you can see, this function takes the Resources
node and assigns it the child matching the name
parameter with the new value determined by val
. But here’s the twist: instead of using the primitive int
type of val
as we expected, it used ObsInt
.
Jumping over to the ObsInt
class, we immediately unveiled the secret - it employs two functions named Obfuscate
and Restore
to transform ordinary integers into the ObsInt
type:
private static int Obfuscate(int x)
{
int num = (x ^ (x >> 7)) & 5570645;
int num2 = x ^ num ^ (num << 7);
num = (num2 ^ (num2 >> 14)) & 52428;
return num2 ^ num ^ (num << 14);
}
private static int Restore(int y)
{
int num = (y ^ (y >> 14)) & 52428;
int num2 = y ^ num ^ (num << 14);
num = (num2 ^ (num2 >> 7)) & 5570645;
return num2 ^ num ^ (num << 7);
}
To confirm our suspicion, we translated this code to Python and applied it to Restore(0x02000022)
(converting 22 00 00 02
to little-endian). Lo and behold, it returned 50
!
Concluding with a bang, we executed Obfuscate(1000000)
and obtained the result (08 28 89 10
after converting to little-endian). With this knowledge, we patched our save file, and…
During the second dnSpy expedition, we discovered that the player’s experience points also resided within the Resources
list. Our initial file lacked it as our player was a noob with 0 XP. So, we patched the Resources
list to contain 5 elements instead of 4, and introduced one at the beginning named xp
, and gave ourselves a staggering 1 million XP - catapulting our player to level 83!
As evident in the patched file above, we adjusted the Resources
list’s length to 5
, added a key length of 2
, and the key name xp
, accompanied by the obfuscated 1 million value. You can also see here the edited gold and the rest of the resource values marked.
Satisfied and elated, we crafted the ultimate savefile, granting everyone ample coins for ship upgrades and some good equipment, a substantial XP boost to start at level 5, and onwards we sailed towards the LAN party!