[GameHacking] Subnautica Below Zero - Unity3D Modding - dnSpy Tips & Tricks

jamarirjamarir
18 min read

Just another dnSpy Write-up.

This article is a pragmatic introduction into the Unity3D Modding world. Our focus will be to edit the game’s logic using dnSpy, based upon the Steam version of the Subnautica Below Zero (SEP-2022 49371) game. To follow along with the steps below, you may buy the game in Steam (it’s worth it, I promess :D).

The above image cover is taken from here.

dnSpy (now maintained by dnSpyEx) is a debugger and .NET assembly editor (downloadable here). Most importantly, it can be used to easily patch mono games based on the Unity3D Engine.

Environment Setup

Assembly-CSharp

Fortunately for us, all the game’s logic can be found within the Assembly-CSharp.dll assembly file. This is one of the assembly files / building blocks of .NET applications used to run the game.

Therefore, if we wan’t to patch the game’s logic, we’ll can alter the game’s Assembly-CSharp.dll assembly file. In Unity3D games, this file can be found into <GAME>/<GAME_Data>/Managed/Assembly-CSharp.dll. So let’s first make a copy of the original file before altering it:

Game’s Architecture

Next, we need to find out the game’s architecture (x64 or x86). An easy way to discover that is simply to run file against our game and check if it’s a PE32, or PE32+ executable:

PS D:\Program Files (x86)\Steam\steamapps\common\SubnauticaZero> file .\SubnauticaZero.exe
.\SubnauticaZero.exe: PE32+ executable (GUI) x86-64, for MS Windows, 7 sections

Or even launch it, and look at the Task Manager’s details:

In both cases, the game’s architecture is x64. Thus, we’ll use the 64 bit version of dnSpy.

Local Visual Studio Project

Trying to edit the code within dnSpy directly works, but can be challenging. Indeed, whenever we edit the Assembly-CSharp's code, we must compile it. However, the dnSpy compilation can screw up our code’s structure through code optimization, which makes it harder for us to implement new updates with our new scrumbled patch.

An easy alternative is to make our editions into a local Visual Studio C# project, that we’ll later copy into dnSpy. To do so, we may first open the Assembly-CSharp.dll file into dnSpy, and add a new class with the following template:

using System;
using UnityEngine;
namespace ModMenuSpace
{
    public class ModMenu : MonoBehaviour
    {
        public static void ModMenuManager()
        {
            DrawMenu();
        }

        public static void DrawMenu()
        {
            if (GUI.Button(new Rect(0f, 0f, 100f, 30f), menuLabel))
            {
                menuVisible = !menuVisible;
            }
            if (menuVisible)
            {
                GUI.Box(new Rect(100f, 0f, 500f, 400f), menuLabel);
                int n = 0;
                cheat1Active = (GUI.Button(new Rect(100f, 30f * n++, 180f, 30f), "Cheat1 [" + cheat1Active.ToString() + "]") ? (!cheat1Active) : cheat1Active);
            }
        }
        public static string menuLabel = "MOD MENU";
        public static bool menuVisible = false;
        public static bool cheat1Active = false;
    }
}

This class template solely prints a dummy Mod Menu that we’ll need later on. Then, we export our new assembly code into a Visual Studio project with the default options:

When we open the exported *.sln file with Visual Studio, we have the following error message, but our choice isn’t important. Indeed, our only purpose is to be able to reference game’s objects within our ModMenu class for easier programming. Let’s then stick with the recommanded solution:

We now have a local Visual Studio project with the game's references loaded.

dnSpy Modding

OnGUI() ModMenu

Before enjoying and developing new cheats, our first purpose is to find a method that is called every frame in-game. This can be done hooking any OnGUI() method the game is calling, as this method is called to render stuff onto the screen.

Other global MonoBehavior methods could be used, such as Update(), or OnParticleCollision() for example; as long as they’re called every frame. OnGUI(), rendering stuff every frame on the screen, is definitely a good candidate for any game.

Our purpose, though, is to find the OnGUI() called as early and as globally as possible within the game. The trick we can use to find such OnGUI() is to inject the following GUI rectangle in many OnGUI() methods, and check which one is permanent in-game:

GUI.Button(new Rect(0f, <OFFSET>f, 400f, 50f), "<CLASS>.OnGUI()");

Where we'll increase <OFFSET> by 50 for each function (starting at 0), and set <CLASS> to the current classname for debugging purposes. In dnSpy, if we look for OnGUI() methods within the game’s code (into the { } - namespace, aka. the global namespace, holding almost all the code):

We find numerous OnGUI() calls. Thus, we can add our rectangle in each of them. For example, the first two calls are patched as follows:

As a side note, notice how the dnSpy compilation screwed up my first OnGUI() code, with the initialization of new variables:

Even if this patch is technically functionning and doesn’t change the code’s behavior, having a local Visual Studio project allows us to maintain our code’s structure in our future patches.

Also, if for some reasons, we aren’t able to compile the OnGUI() methods, and a bunch of errors are shown, we could either choose Edit Class (C#)… instead, or Edit IL Instructions… with the following CIL opcodes at the beginning of the function:

Also, make sure the class is loading the UnityEngine namespace at the top:

using UnityEngine;

Once we altered our OnGUI functions, we can compile our patched Assembly-CSharp.dll file pressing CTRL + ALT + S (File > Save All…), and let the default options:

Now, we want to list, among our candidates, the methods that are called every frame within the game. In my case, I ended up with valid 4 hookings:

Therefore, we may arbitrary choose one of these, let’s say WaterBiomeManager.OnGUI(). So we may rollback all of our OnGUI() patches, and patch WaterBiomeManager.OnGUI() alone to call our custom mod menu:

Note that to “refresh” the available namespaces, we must compile the assembly file with File > Save All…. For instance, if we do change the ModMenu’s ModMenuManager function to MyNewModMenuManager(), that new function name won’t be callable, until we compile the assembly file.

When we fire-up the game, we can now see our new mod menu !

Now that we know our ModMenuManager() function is called every frame, we may use it to build our first cheat.

Basic Infinite Oxygen Hack

In-game, we have basically 3 attributes conditionning our survival: Health, Temperature and Oxygen. Obviously, staying too long underwater decreases our remaining oxygen, and gets us killed when empty:

Reversing The Game’s Logic

For us to get infinite oxygen, we’ll have to reverse the game code and find out where our player’s oxygen attribute is processed. One very common and easy trick is to look for any class containing Player in its name. Naturally, the class Player exists:

Going into that class, we can look for globally and frequently used Unity3D Mono methods, such as:

Here, we know our player’s oxygen must be processed every frame. Thus, we’ll be looking at its Update()-like functions. Interestingly enough, in the Player.Update() function, we see a code logic checking whether our player can breathe:

In particular, the RemoveOxygen() is called against the this.oxygenMgr attribute. Therefore, this attribute most likely contains all the player oxygen’s logic. Then we may click it:

And click on the OxygenManager class to check its methods. Again, in the OxygenManager.Update() class, there’s an AddOxygenAtSurface() function, which ultimately calls the AddOxygen() function if some conditions are met:

Then, all we need to do is to call OxygenManager.AddOxygen() method on the oxygenMgr attribute tied to our player instance to get inifinite oxygen.

Grabbing Our Player’s Instance

Now, we must find a way to reference our player’s instance within our ModMenu class. Again, this requires to reverse the game’s logic, until we find a function that is doing what we want: grab an instance of our player.

To do so, we may analyze the Player’s class, and look where this class is used in the code:

Obviously, numerous methods are somehow using our Player’s class (e.g. an enermy wanna locate where we are to chase us, a fish wanna escape from us, etc.). Navigating a bit into the code, we see that Player.main is used by many classes to get an instance of our player, for example:

That’s indeed what we want, as Player.main is a public (readable by any class) and static (readable without instantiation) attribute:

Coding Th4’ Ch34t !!

Finally, we can go into our local Visual Studio project, and adapt our ModManager() class to add oxygen to our player’s instance every frame:

using System;
using UnityEngine;
namespace ModMenuSpace
{
    public class ModMenu : MonoBehaviour
    {
        public static void ModMenuManager()
        {
            DrawMenu();
            RunSilently(() => CheatInfiniteOxygen());
        }
        public static void CheatInfiniteOxygen()
        {
            if (cheatActiveInfiniteOxygen)
            {
                Player.main.oxygenMgr.AddOxygen(1f);
            }
        }
        public static void RunSilently(Action action)
        {
            try { action(); } catch { }
        }
        public static void DrawMenu()
        {
            if (GUI.Button(new Rect(0f, 0f, 100f, 30f), menuLabel))
            {
                menuVisible = !menuVisible;
            }
            if (menuVisible)
            {
                GUI.Box(new Rect(100f, 0f, 500f, 400f), menuLabel);
                int n = 1;
                cheatActiveInfiniteOxygen = (GUI.Button(new Rect(100f, 30f * n++, 180f, 30f), "InfiniteOxygen [" + cheatActiveInfiniteOxygen.ToString() + "]") ? (!cheatActiveInfiniteOxygen) : cheatActiveInfiniteOxygen);

            }
        }
        public static string menuLabel = "MOD MENU";
        public static bool menuVisible = false;
        public static bool cheatActiveInfiniteOxygen = false;
    }
}

Then, we copy-paste it into dnSpy’s ModMenuSpace, and finally compile the assembly file.

Notice that the RunSilently() function has been added. That’s because in the main menu of the game, our player’s instance isn’t initialized yet (thus Player.main doesn’t exist yet). Therefore, to prevent our code to crash at certain frames, we catch any exception on functions (e.g. CheatInfiniteOxygen()) that might not be able to run.

Also, In Visual Studio, pressing CTRL+A automatically scrolls down to the end of our code. If we want to rapidly get back to our previous location in the code, we may configure, in Tools > Options > Environment > Keyboard, the View.NavigateBackward and View.NavigateForward shortcuts to CTRL + - and CTRL + + respectively:

Now, we can toggle the cheat in our mod menu in the main game screen, and enjoy our new oxygen hack !

Note that multiple solutions may exist to get an Infinite Oxygen cheat. For instance, we could have:

  • Tell the game that our player is NOT underwater every frame.

  • Patch the RemoveOxygen() method to do nothing every frame.

  • Call the AddOxygen() every frame (which we did).

The only thing that matters is to implement at least one cheat that is doing the job.

Now we may extend our mod menu to contain any cheat we want. Our mod features and complexity solely depends on the effort invested to reverse the game’s logic.

Cursor Locked Fix

Subnautica Below Zero is an FPS game. As such, when we’re playing, our cursor is locked in the middle, and invisible. Therefore, we can’t toggle ON and OFF our cheat while playing. An alternative that doesn’t work is to unlock our cursor pausing the game first, and try to toggle the cheat. However, the game is interpreting that click outside of the pause menu as resuming the game:

Pressing F8 to open the feeback menu would solve that problem for that particular game. But let’s pretend such solution doesn’t exist to have a global solution that is game-independant.

As we can see, clicking onto our ModMenu button doesn’t toggle the cheat ON. One alternative would be to simply use predefined hotkey shortcuts (with Input.GetKey() for instance) instead of the mouse. But sticking with our mouse solution, we can use the following function to solve that cursor locking issue:

[...]
        public static void ModMenuManager()
        {
            DrawMenu();
            CursorToggle(KeyCode.F10);
            RunSilently(() => CheatInfiniteOxygen());
        }
[...]
        public static void CursorToggle(KeyCode k)
        {
            if (!cursorFixToggle && Input.GetKey(k))
            {
                cursorLockStateBackup = Cursor.lockState;
                cursorVisibleBackup = Cursor.visible;
                cursorFixToggle = true;
            } else if (cursorFixToggle && Input.GetKey(k))
            {
                Cursor.lockState = cursorLockStateBackup;
                Cursor.visible = cursorVisibleBackup;
                cursorFixToggle = false;
            }

            if (cursorFixToggle)
            {
                Cursor.lockState = CursorLockMode.None;
                Cursor.visible = true;
            } else
            {
                 Cursor.lockState = cursorLockStateBackup;
                 Cursor.visible = cursorVisibleBackup;
            }
        }
[...]
        public static bool cursorFixToggle = false;
        public static CursorLockMode cursorLockStateBackup;
        public static bool cursorVisibleBackup;
[...]
  • First, we define a function named CursorToggle() which backs up the current mouse state in-game.

  • Then, whenever the F10 key is pressed, it toggles the cursor’s state between Locked/Invisible to Unlocked/Visible.

  • This new function being called every frame in our ModMenuManager() function, we can use it in-game whenever we want.

Now, pressing F10 while in-game, we can unlock our mouse, and toggle our cheat ON:

Note that this mouse trick isn’t 100% accurate, as multiple F10 press might be needed to toggle the mouse’s state.

Dirty But Handy Debugger

In the game, our player’s temperature ranges from 0 to 100. If that temperature is 0, it puts us into a hypothermic state, and then gets us killed.

Let’s say that we wanna set our temperature to 99 every frame, to avoid dying. One way we could implement that cheat is to reverse the game’s logic, with the appropriates calls, as we did with our oxygen hack. The issue with this method, though, is that we need to reverse the game without having the Player’s in-game attributes. But if we find a way to get all of our player’s attributes at any frame, we could perform the reversing more lazily :D

Dumping Player Instance’s State

Let’s consider the following situation:

Here, we know our player’s temperature is around 35°C. If we could use that information to instantly narrow down our search, it would be quite handy. Then, what we could do is to locally save the state of our player’s instance into a file when we press a button in our Mod Menu (say DumpState(Player)):

[...]
using System.IO;
[...]
        public static void DrawMenu()
[...]
            if (menuVisible)
            {
                GUI.Box(new Rect(100f, 0f, 500f, 400f), menuLabel);
                if (GUI.Button(new Rect(100f, 0f, 180f, 30f), "DumpState(Player)")) ;
                {
                    DumpState(Player.main.gameObject);
                }
                int n = 1;
                cheatActiveInfiniteOxygen = (GUI.Button(new Rect(100f, 30f * n++, 180f, 30f), "InfiniteOxygen [" + cheatActiveInfiniteOxygen.ToString() + "]") ? (!cheatActiveInfiniteOxygen) : cheatActiveInfiniteOxygen);
            }
[...]
        public static void DumpState(GameObject go)
        {
            RunSilently(() => GameObjectToJsonDump(go));
            // Sanity check that the file isn't larger than 10Mo
            if (dumpJson.Length < 10 * 1024 * 1024)
            {
                File.WriteAllText($"{dumpDir}/{go.name}.json", dumpJson);
            }
        }
[...]
        public static string dumpJson;
        public static string dumpDir = "D:/Modding/";
[...]

Using the following ChatGPT’ed function:

[...]
        public static void GameObjectToJsonDump(GameObject go)
        {
            string Safe(Func<object> g) { try { return (g()?.ToString() ?? "").Replace("\"", "\\\""); } catch { return "err"; } }
            void Trim(StringBuilder _sb) { if (_sb.Length > 0 && _sb[_sb.Length - 1] == ',') _sb.Length--; }
            var sb = new StringBuilder();

            // GameObject
            sb.Append("{\"name\":\"").Append(go.name).Append("\",\"components\":[");
            var comps = go.GetComponents<Component>();

            for (int i = 0; i < comps.Length; i++)
            {
                var c = comps[i];
                var t = c.GetType();

                // Fields
                sb.Append("{\"type\":\"").Append(t.Name).Append("\",\"fields\":{");
                foreach (var f in t.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic))
                {
                    var val = Safe(() => f.GetValue(c)).Replace("\n", " ").Replace("\r", " ").Replace("\t", " ").Trim();
                    sb.Append("\"").Append(f.Name).Append("\":\"").Append(val).Append("\",");
                }

                // Properties
                Trim(sb); sb.Append("},\"properties\":{");
                var seen = new HashSet<string>();
                foreach (var p in t.GetProperties(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic))
                    if (p.CanRead && p.GetIndexParameters().Length == 0 && seen.Add(p.Name))
                        sb.Append("\"").Append(p.Name).Append("\":\"").Append(Safe(() => p.GetValue(c))).Append("\",");

                // Methods (use full signatures as keys to prevent duplication)
                Trim(sb); sb.Append("},\"methods\":{");
                foreach (var m in t.GetMethods(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic))
                {
                    string sig = m.ToString().Replace("\n", " ").Replace("\r", " ").Replace("\t", " ").Trim();
                    sb.Append("\"").Append(Safe(() => sig)).Append("\":\"method\",");
                }

                Trim(sb); sb.Append("}}");
                if (i < comps.Length - 1) sb.Append(",");
            }

            sb.Append("]}");
            dumpJson = Regex.Replace(sb.ToString(), "[\r\n\t]", "");
        }
[...]

In my case, I’ve put the folder to be "D:/Modding/". So once our new assembly is compiled within dnSpy, we can dump our player’s state in-game with our new button:

Which produces the following local D:/Modding/Player.json file (beautified in Notepad++ with JsonTools):

Now, we can see all of our player’s attributes when we dumped the instance, making the searching a lot easier !

Temperature Hacked !

Dump Analysis

In our above video, we know the player’s temperature was around 74°C. After some inspections of our fresh dump, we find out that the game is actually storing the temperature minus 10 in the player’s instance:

But most importantly, we know our current temperature is stored into the currentBodyHeatValue of the BodyTemperature class. On top of that, analyzing where that BodyTemperature class is used in dnSpy redirects us into the Player’s class:

So if we want to get an instance of that temperature into our ModMenu class, we may use Player.main.GetComponent<BodyTemperature>().

Also, notice that in our dump, any of the "type" keys are components tied to our player’s instance. For example, if we want to edit the following soundsEnabled attribute to False:

We could simply have scripted the following mod:

Player.main.GetComponent<FootstepSounds>().soundsEnabled = false;

Dealing With Read-Only Attributes…

Then, we may add a temperature hack in our ModMenu, similarly as how we implemented our oxygen hack:

[...]
        public static void ModMenuManager()
        {
            DrawMenu();
            CursorToggle(KeyCode.F10);
            RunSilently(() => CheatInfiniteOxygen());
            RunSilently(() => CheatInfiniteTemperature());
        }
[...]
        public static void CheatInfiniteTemperature()
        {
            if (cheatActiveInfiniteTemperature)
            {
                Player.main.GetComponent<BodyTemperature>().currentBodyHeatValue = 99f;
            }
        }
[...]
        public static void DrawMenu()
[...]
            if (menuVisible)
            {
                GUI.Box(new Rect(100f, 0f, 500f, 400f), menuLabel);
                if (GUI.Button(new Rect(100f, 0f, 180f, 30f), "DumpState(Player)")) ;
                {
                    RunSilently(() => DumpState(Player.main.gameObject));
                }
                int n = 1;
                cheatActiveInfiniteOxygen = (GUI.Button(new Rect(100f, 30f * n++, 180f, 30f), "InfiniteOxygen [" + cheatActiveInfiniteOxygen.ToString() + "]") ? (!cheatActiveInfiniteOxygen) : cheatActiveInfiniteOxygen);
                cheatActiveInfiniteTemperature = (GUI.Button(new Rect(100f, 30f * n++, 180f, 30f), "InfiniteTemperature [" + cheatActiveInfiniteTemperature.ToString() + "]") ? (!cheatActiveInfiniteTemperature) : cheatActiveInfiniteTemperature);
            }
[...]
        public static bool cheatActiveInfiniteOxygen = false;
        public static bool cheatActiveInfiniteTemperature = false;
[...]

Unfortunately though, this isn’t working, as Visual Studio is warning us that the currentBodyHeatValue attribute is marked as read-only:

Indeed, looking at that attribute in the BodyTemperature class shows it has been assigned a getter function, but no setter. Therefore, we cannot edit that attribute, but only read/get it:

One thing to be noted here is that currentBodyHeatValue doesn’t actually exists. Instead, this attribute is constructed based upon the following calculation:

return this.coldMeterMaxValue - this.currentColdMeterValue;

Then, if we define a setter as follows (where value is our ModMenu affectation input, e.g. 99f above):

this.currentColdMeterValue = this.coldMeterMaxValue - value;

The resulting getter calculation, when the setter is called, becomes:

    return this.coldMeterMaxValue - this.currentColdMeterValue;
<=> return this.coldMeterMaxValue - (this.coldMeterMaxValue - value);
<=> return value;

Where our value input is set to our attribute, which allows us to edit that attribute to any value we want in our ModMenu class. Then, we may edit the BodyTemperature class in dnSpy, and patch the currentBodyHeatValue attribute into:

Finally, we can compile our previous temperature cheat to set our temperature to 99°C every frame !

Notice that another mathematically working setter calculation could have been:

this.coldMeterMaxValue = value + this.currentColdMeterValue;

Resulting in the following getter calculation, when the setter is called:

    return this.coldMeterMaxValue - this.currentColdMeterValue;
<=> return (value + this.currentColdMeterValue) - this.currentColdMeterValue;
<=> return value;

However, this would produce unwanted edge cases in the game, as we’re updating the CONSTANT max temperature threashold, intially set to 100. Instead, the best solution is to edit a dynamic / non-constant attribute within our setter, i.e. this.currentColdMeterValue here, as this attribute is updated every frame anyway.

Movement Speed Hacked !

Finally, let’s have a glance at another trick while building our mods. Let’s say we wanna implement a mod that makes the player move really fast. The first thing we could do is to look for any occurence of speed into our player instance dump, using the ".?speed.?"|"type" ReGEX in Notepad++:

This ReGEX will search for any "speed" text in double quotes, as well as all the "type" for us to check in which class the speed attribute belongs to. For example, in the above image, we see that:

  • The Player class has 6 attributes containing the speed text, e.g. "movementSpeed".

  • The GroundMotor class has 9 attributes containing the speed text, e.g. "forwardMaxSpeed".

  • The PlayerController class has 11 attributes containing the speed text, e.g. "walkRunForwardMaxSpeed".

  • Etc.

Spoiler Alert: the attribute that allows to implement our speed cheat is the GroundMotor.forwardMaxSpeed attribute. But let’s pretend we didn’t know that.

Then, in order for us to know which attribute is affecting our player’s speed, we might test guessable good candidates from our above attributes (or even all of them). For example, let’s say I suspect that some of the PlayerController, GroundMotor and UnderwaterMotor's attributes allows to cheat our in-game running speed. So I can use the following DebugFloatBox() functions for each attribute in the CheatMovementSpeed() function:

[...]
        public static void ModMenuManager()
        {
            DrawMenu();
            CursorToggle(KeyCode.F10);
            RunSilently(() => CheatInfiniteOxygen());
            RunSilently(() => CheatInfiniteTemperature());
            RunSilently(() => CheatMovementSpeed());
        }
        public static void DrawMenu()
        {
[...]
            if (menuVisible)
            {
[...]
                cheatActiveMovementSpeed = (GUI.Button(new Rect(100f, 30f * n++, 180f, 30f), "MovementSpeed [" + cheatActiveMovementSpeed.ToString() + "]") ? (!cheatActiveMovementSpeed) : cheatActiveMovementSpeed);
[...]
        public static void CheatMovementSpeed()
        {
            if (cheatActiveMovementSpeed)
            {
                int numBox = 0;
                DebugFloatBox(ref Player.main.GetComponent<PlayerController>().walkRunForwardMaxSpeed, ref numBox);
                DebugFloatBox(ref Player.main.GetComponent<PlayerController>().walkRunBackwardMaxSpeed, ref numBox);
                DebugFloatBox(ref Player.main.GetComponent<PlayerController>().walkRunStrafeMaxSpeed, ref numBox);
                DebugFloatBox(ref Player.main.GetComponent<GroundMotor>().forwardMaxSpeed, ref numBox);
                DebugFloatBox(ref Player.main.GetComponent<PlayerController>().swimVerticalMaxSpeed, ref numBox);
                DebugFloatBox(ref Player.main.GetComponent<PlayerController>().swimForwardMaxSpeed, ref numBox);
                DebugFloatBox(ref Player.main.GetComponent<PlayerController>().swimBackwardMaxSpeed, ref numBox);
                DebugFloatBox(ref Player.main.GetComponent<PlayerController>().swimStrafeMaxSpeed, ref numBox);
                DebugFloatBox(ref Player.main.GetComponent<UnderwaterMotor>().forwardMaxSpeed, ref numBox);
            }
        }
        public static void DebugFloatBox(ref float f, ref int numBox)
        {
            f = Convert.ToSingle(GUI.TextField(new Rect(1000f, 30f * numBox++, 300f, 30f), Convert.ToString(f)));
        }
[...]
        public static bool cheatActiveMovementSpeed = false;
[...]

Which shows up as follows in the game:

As we can see, when our MovementSpeed cheat is toggled ON, new boxes in the top-right of the screen appeared. Each of these boxes corresponds to a DebugFloatBox() call. For example, the second box, holding the value 5, is our player’s PlayerController.walkRunBackwardMaxSpeed value.

In order to easily spot which variable limits our movement speed, we may patch each debug box while we’re playing. Here, we see that the fourth box, i.e. our player’s GroundMotor.forwardMaxSpeed attribute, was limiting our speed:

Then, we may update our CheatMovementSpeed() function to set that attribute when the cheat is enabled, and restore it to 4.4f whenever disabled:

public static void CheatMovementSpeed()
{
    if (cheatActiveMovementSpeed)
    {
        /*int numBox = 0;
        DebugFloatBox(ref Player.main.GetComponent<PlayerController>().walkRunForwardMaxSpeed, ref numBox);
        DebugFloatBox(ref Player.main.GetComponent<PlayerController>().walkRunBackwardMaxSpeed, ref numBox);
        DebugFloatBox(ref Player.main.GetComponent<PlayerController>().walkRunStrafeMaxSpeed, ref numBox);
        DebugFloatBox(ref Player.main.GetComponent<GroundMotor>().forwardMaxSpeed, ref numBox);
        DebugFloatBox(ref Player.main.GetComponent<PlayerController>().swimVerticalMaxSpeed, ref numBox);
        DebugFloatBox(ref Player.main.GetComponent<PlayerController>().swimForwardMaxSpeed, ref numBox);
        DebugFloatBox(ref Player.main.GetComponent<PlayerController>().swimBackwardMaxSpeed, ref numBox);
        DebugFloatBox(ref Player.main.GetComponent<PlayerController>().swimStrafeMaxSpeed, ref numBox);
        DebugFloatBox(ref Player.main.GetComponent<UnderwaterMotor>().forwardMaxSpeed, ref numBox);*/
        Player.main.GetComponent<GroundMotor>().forwardMaxSpeed = 99f;
    }
    else
    {
        Player.main.GetComponent<GroundMotor>().forwardMaxSpeed = 4.4f;
    }
}

For reference, the whole Mod Menu code is available in this GitHub repository.

0
Subscribe to my newsletter

Read articles from jamarir directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

jamarir
jamarir

Jamaledine AMARIR. Pentester, CTF Player, Game Modding enthusiast | CRTO