A couple weeks ago my gaming group wanted to play the game R.E.P.O, a hilarious co-op horror game. We’d played it many times before and had become accustomed to a mod that allowed us to share upgrades. These upgrades are crucial to surviving further into the game, but they require paying your very limited resources to buy and only apply to a single player. This made the upgrades a point of hostility that I didn’t enjoy much. It was enough of an annoyance that I was willing to help three other “non-technical” friends install a mod that allowed us to share the upgrades.
We’ve continuously had to deal with breaking updates. However, on this particular day two weeks ago, the mod no longer worked. Even after updating everyone to the latest version, I noticed the logs complaining about a missing class and realized the mod must not have been patched to work with the latest game version. We continued playing without the mod, but afterwards, I got nerd sniped…hard. If you want a job done right, you have to do it yourself.
R.E.P.O is a Unity game so it uses C#/.NET, something I’m familiar with from my days working at HP. I had never written a Unity mod, but with help from AI I had a working example within a few hours. Just a single Python script and C# file. Running the Python script would use the C# compiler that comes pre-installed with Windows to compile the mod into a .NET “dll”. It also downloaded a common Unity mod “framework” called “BepInEx”. Installing the BepInEx files on top of your game files would cause it to automatically load any dlls you copy into a “plugins” directory. It worked, but the dev cycle was unbearably slow. The C# mod code the AI came up with was riddled with problems, and each time I wanted to test something, I had to exit and re-launch the game and get into a multiplayer round. The simple task of getting my and my friends’ Steam IDs took too many attempts; I ended up just hardcoding them because I couldn’t bring myself to relaunch the game for a 10th time to test the latest AI slop.
I wondered why BepInEx didn’t support hot reloading. I looked it up and realized that even though .NET lets you dynamically load dlls at any time, it doesn’t seem to let you unload them. I tried finding support for a scripting language, but I didn’t find much. I thought a scripting language would be perfect for this use case, so I guess I was no longer writing a mod, I was now writing a scripting language so I could write a mod.
The first thing to do was make sure I could inject my own DLL into the game without BepInEx. I’d recently done some DLL injection when I worked on “App Veil” for Tuple on Windows, so I had this working quickly. Next, I needed to learn how to get my injected native DLL to communicate with the .NET runtime. I learned that Unity games aren’t actually .NET executables; they are native executables that run .NET code using the Mono runtime (I think they might be moving away from this though?). It turns out Mono also has very good support for interfacing with native code through their “Embedded API”. So, once my dll is injected, all it does is call LoadLibrary on this Mono embedded API dll, and now I’ve got full access to .NET. Now we’re ready.
Over the next two weeks, outside of working hours, I created a scripting language/VM that could read a script and forward calls to the Mono Embedded API. It continuously reloads the scripts to see if they’ve changed and re-executes them when they do. Here’s the code. Last night we gave it a trial run. With ILSpy at the ready to look at the .NET code, I threw together a small script that gets my “steam id” and uses that to upgrade my sprint speed.
var Steamworks = @Assembly("Facepunch.Steamworks.Win64")
var SteamClient = @Class(Steamworks.Steamworks.SteamClient)
@Log("waiting for steam id...")
var attempt = 1
loop
if (SteamClient.get_IsValid()) { break }
@Log(" no steam id yet, attempt ", attempt)
attempt = attempt + 1
yield 2000
continue
var steam_id = @ToString(SteamClient.get_SteamId().Value)
@Log("steam id is '", steam_id, "'")
var game = @Assembly("Assembly-CSharp")
var punManager = @Class(game.PunManager).instance
var value = 0
var diff = 0
value = punManager.UpgradePlayerSprintSpeed(steam_id, 0)
@Log("Sprint current=", value)
diff = 7 - value
value = punManager.UpgradePlayerSprintSpeed(steam_id, diff)
@Log("Sprint new =", value)
After launching the game and attaching my DLL I ran into a syntax error on this line diff = 7 - value. I hadn’t implemented the “subtraction” binary operator yet. A couple minutes and around 10 lines of code later, we were on the next attempt. We ran into another unimplemented feature, but once again within a couple mintues we were on attempt 3. With that…to my disbelief it actually worked! My sprint speed in the game was actually updated. I updated the script to set the sprint speed to a new value, saved the file, and it applied immediately in the game. No recompilation, no restart, no multiplayer setup, just immediate results.
We got everyone into the game and started actually playing…well, they started playing and I started coding. While in game I was able to update my script to make us all Gods. We were all flying around the levels with our absurdly upgraded sprint speed/stamina and effectively infinite “extra air jumps”. We could grab the once horrifying monsters and fling them to and frow like they were blow-up toys. There was also an issue where adding health only worked for my player, but after messing around with various APIs, I even figured out how to heal the others as well. And I did all this, without having to restart the game.
The language is still very basic at the moment with alot of missing features. I don’t think functions even really work yet, but, I’m excited to keep iterating on it and improve it as I go. I’ll leave you with the very hacky script I created during the game:
// save this script to C:\mutiny\mods\REPO\godmode
var Steamworks = @Assembly("Facepunch.Steamworks.Win64")
var SteamClient = @Class(Steamworks.Steamworks.SteamClient)
@Log("waiting for steam id...")
var attempt = 1
loop
if (SteamClient.get_IsValid()) { break }
@Log(" no steam id yet, attempt ", attempt)
attempt = attempt + 1
yield 2000
continue
// comment/uncomment the following lines to re-run the script for Danny/Zach, their
// upgrades won't apply until the next level.
var steam_id = @ToString(SteamClient.get_SteamId().Value)
//var steam_id = @ToString(76561197963995344) // danny
//var steam_id = @ToString(76561199195454462) // Zach
@Log("steam id is '", steam_id, "'")
var game = @Assembly("Assembly-CSharp")
var PunManager = @Class(game.PunManager)
var punManagerInstance = PunManager.instance
var value = 0
var diff = 0
value = punManagerInstance.UpgradePlayerSprintSpeed(steam_id, 0)
@Log("Sprint current=", value)
diff = 7 - value
value = punManagerInstance.UpgradePlayerSprintSpeed(steam_id, diff)
@Log("Sprint new =", value)
value = punManagerInstance.UpgradePlayerEnergy(steam_id, 0)
@Log("Stamina current=", value)
diff = 1000 - value
value = punManagerInstance.UpgradePlayerEnergy(steam_id, diff)
@Log("Stamina new =", value)
value = punManagerInstance.UpgradePlayerHealth(steam_id, 0)
@Log("Health current=", value)
diff = 1000 - value
value = punManagerInstance.UpgradePlayerHealth(steam_id, diff)
@Log("Health new =", value)
value = punManagerInstance.UpgradePlayerExtraJump(steam_id, 0)
@Log("Jump current=", value)
diff = 1000 - value
value = punManagerInstance.UpgradePlayerExtraJump(steam_id, diff)
@Log("Jump new =", value)
value = punManagerInstance.UpgradePlayerThrowStrength(steam_id, 0)
@Log("Throw current=", value)
diff = 1 - value
value = punManagerInstance.UpgradePlayerThrowStrength(steam_id, diff)
@Log("Throw new =", value)
value = punManagerInstance.UpgradePlayerGrabRange(steam_id, 0)
@Log("Range current=", value)
diff = 3 - value
value = punManagerInstance.UpgradePlayerGrabRange(steam_id, diff)
@Log("Range new =", value)
value = punManagerInstance.UpgradePlayerGrabStrength(steam_id, 0)
@Log("Strength current=", value)
diff = 50 - value
value = punManagerInstance.UpgradePlayerGrabStrength(steam_id, diff)
@Log("Strength new =", value)
var SemiFunc = @Class(game.SemiFunc)
var player = SemiFunc.PlayerAvatarGetFromSteamID(steam_id)
//@LogClass(@ClassOf(player.playerHealth))
player.playerHealth.Heal(99999999, 0)
// It's annoying to have to keep giving health to Danny/Zach so here you go guys!
var danny = SemiFunc.PlayerAvatarGetFromSteamID("76561197963995344")
danny.playerHealth.HealOther(99999999, 0)
var zach = SemiFunc.PlayerAvatarGetFromSteamID("76561199195454462")
zach.playerHealth.HealOther(99999999, 0)