Tinkering with de4dot, ILSpy and Harmony to mod the Steam game NERTS! Online
Throughout the pandemic, I've had fun playing NERTS! Online, a competitive solitaire game by Zachtronics. A friend and I got curious to look at how it works, and in doing so, I got the idea to see what it would take to mod. As far as I know, nobody has written a NERTS! mod before - so I had to figure out the process from the beginning.
This was very much a labor of love for the game and an opportunity to learn more about how to build on top of existing code. I was careful to be as respectful to the game's authors as possible - my mod focuses on a few features that make the game more fun, avoids anything that could be called a cheat, and only works in private lobbies!
Background
NERTS! Online is a game written in C# using the .NET Framework. For cross-compatibility, it uses Mono, an open source implementation of .NET. This allows the .exe file containing the game to be run on macOS without modification.
To start my experiment, I was curious to look at the source code and understand how some of the networking works. In particular, I was curious to find out more about how lobby discovery is implemented, because I had initially set out with the intent of building an open source game server. I quickly came across the decompiler ILSpy and opened the game's executable to see what I could read. Some of the logic was present here but many of the strings and symbol names had been obfuscated. I initially tried to read what I could but later found de4dot, an (unfortunately discontinued) tool which did a great job of recovering strings and renaming symbols. To be clear, there was still a lot of work left at this point - the original symbol names were well and truly gone and I had to spend a good while figuring out the purpose of methods like "method_8". However, this did make following the logic easier, and allowed me to begin reasoning about how the game functions.
For multiplayer, NERTS! relies heavily on Steam's matchmaking system. Steam's SDK provides functions for everything from creating a lobby to establishing a peer-to-peer connection. For things like the lobby name and settings, Steam allows "lobby data" to be set. I wanted to list this data, so I built a small application using the Rust library steamworks-rs. After implementing a few missing bindings (like GetLobbyDataByIndex
to list keys without knowing their names) the output looked like this:
Found 1 lobbies. LobbyId(ID_HERE) LobbyData { key: "BALANCE_MODE", value: "0" } LobbyData { key: "GAME_LENGTH", value: "100" } LobbyData { key: "LOBBY_NAME", value: "Oliver" } ...
As you can see, there are a number of keys each with a string value for the corresponding setting.
When a game (or in this case, something pretending to be one) is launched externally to Steam, the Steam SDK doesn't know which game is running. I learned that in this case, Steam looks for a steam_appid.txt file in the current working directory. Putting the id of your game of choice here forces the SDK to run in the context of that particular game.
This was a fun experiment but ultimately convinced me not to go down the route of building a server as I had originally planned! The lobby discovery and even the communication channels are heavily based on Steam's protocol, and therefore connecting to a custom server would at the very least require modifying the game to use a new communication system. I didn't want to significantly grow the scope of this project and reinventing something provided by Steam didn't particularly appeal to me. As a result, I decided that a client side mod would be a better goal.
Modding basics
Knowing I wanted to write a mod, and having begun to understand how NERTS! is implemented, I needed to figure out how to patch a C# program. I quickly discovered Harmony, a tool initially created for the game RimWorld that handles patching methods at runtime. This seemed perfect and I set about proving it would work by printing a "Hello World!" message at the startup of NERTS!.
Reading over the documentation, I learned that Harmony doesn't handle loading itself in to an executable and that other tools exist for this. Many of these were specific to particular frameworks like Unity but I found BepInEx which (at least in the bleeding edge builds) has a .NET loader. For the curious, there's some more information about how I set this up in this commit on GitHub.
With Harmony loaded and the decompiled NERTS! source open, I was ready to write the first few lines of my mod. Harmony provides an easy to use annotation syntax that makes it easy to run code before or after any method is invoked. For example, printing "Hello World!" each time a frame is drawn for the title screen is as simple as follows:
[HarmonyPatch(typeof(TitleScreen), "Update")] [HarmonyPostfix] static void TitleScreenUpdate() { Console.WriteLine("Hello World!"); }
This is a "postfix" patch meaning it runs after each invocation of the Update
method in TitleScreen
. Other variants include "prefix" (which runs before, and can optionally cancel execution of the original method) and "transpiler" (which allows the individual instructions of the original implementation to be modified).
If you look at the source for my mod today, you'll find that I no longer mention BepInEx. The steps to install this were fairly complicated, both because it isn't stable yet and also because it's a more generic loader with support for many features I wasn't using. Realising as I got more familiar with BepInEx that it was starting to look feasible, I set out to (and succeeded in) implementing my own loader. This patches the executable on disk to invoke a LoadMod
method at startup. This method then finds the DLL of my mod (which itself manages loading Harmony) and uses reflection to invoke the entry method. I won't include all of the code here but the patching itself is incredibly simple:
AssemblyDefinition original = AssemblyDefinition.ReadAssembly("original.exe"); MethodInfo loadMethod = typeof(NertsPlusPatcher).GetMethod("LoadMod", BindingFlags.Static | BindingFlags.Public); Mono.Cecil.Cil.MethodBody body = original.EntryPoint.Body; Instruction callInstruction = Instruction.Create(OpCodes.Call, original.MainModule.ImportReference(loadMethod)); body.GetILProcessor().InsertBefore(body.Instructions[0], callInstruction); original.Write("new.exe");
Implementation
With all of the foundations complete, it was time to implement some features! The process for this was fairly straightforward. For any given feature (for example, changing the intro music) I would find the relevant methods (playing sounds, sending sounds to other players) and use Harmony to alter the implementation. Based on the current state, as well as any parameters, I could decide if I needed to change anything about the call being made or override the implementation entirely.
In general, the NERTS! code was extremely easy to mod. It's heavily class based, and has concepts like a screen stack (for showing a menu screen on top of a game screen, for example) and game server (which handles tracking state and sending updates between players). There were a few cases where things weren't as easy to extend as I would have liked, but I was able to work around these. For example, the game usually shuffles all decks on a timer, but I wanted to trigger this manually. I tried to find a "shuffle the decks" method but realised that the shuffling code I was looking for was inlined together with the same code that handles the main game loop. I could have copied this code - or even used Harmony to access the underlying instructions - but I decided instead to advance the timer, and then revert any unintentional changes this caused to other parts of the state.
I finished things off by improving the experience of installing the mod. During development, this was a slow process of installing tools like de4dot and making sure to place the correct DLL files in your steamapps folder. I wanted to make the mod (which I decided to call NertsPlus) accessible to everyone and so I created a patch.py
script which performs all of the required steps. Python felt like a nice choice here as I wanted something similar to a bash script but with support for multiple platforms.
Closing thoughts/What's next
If I get the time, I'd love to try implementing some new gameplay mechanics. At the very least, I'd like to keep NertsPlus up to date with new NERTS! versions, so it continues working for the medium to long term future.
If you're reading this Zach - I hope the good intentions of this endeavour are clear! I'm a big fan of NERTS! Online and this project has taught me a lot. I hope the mod can be a fun place to tinker with new features and gameplay mechanics that may even be incorporated in to the real game in the future!
Want to see the code, or install the mod to use it for yourself? NertsPlus is available here.