Playing through video games as quickly as possible, or “speedrunning”, is one of gaming’s oldest traditions. There’s no better way for one to demonstrate their impressive mastery of a game than by completing it at as fast as possible.

An upcoming game called Neon White, developed by Angel Matrix and published by Annapurna Interactive, makes the challenge of speedrunning explicit by asking players to complete levels as quickly as possible. The demo for Neon White features plenty of interesting movement techniques, like being propelled forwards with grenade and rocket explosions. Mastering these advanced forms of movement can open up shortcuts on each level, allowing players to improve their times and climb the online leaderboards.

Although Neon White’s demo features robust systems for comparing times on individual levels, I was having so much fun with the game that I wanted to be able to speedrun every level in the demo in a so-called “All Demo Levels” run.

Background

Most speedrunners use a timer program like LiveSplit to track their time. LiveSplit comes with an assortment of convenient features for speedrunners, like being able to divide a game into discrete sections, or “splits”, and tracking the times of each split individually.

One caveat to LiveSplit is that users often need to manually press a button to signal the timer that a split has been completed, which is a minor inconvenience. Not only does pressing this button take the speedrunner’s focus away from the game, but it also introduces some variance into the split timings. To alleviate this inconvenience, LiveSplit also provides so-called “auto-splitting” functionality. In short, users can write programs that read the values at a particular location in a game’s memory, and then use those values to inform when LiveSplit should advance to the next split.

For instance, an “All Demo Levels” run of Neon White would use one split for each level of the game. Since Neon White’s demo contains 18 levels, a complete run would contain 18 splits. If we could read the game’s memory to determine the current level, we could use that information to write a custom autosplitter, which would advance the splits in LiveSplit automatically every time the level changes.

Information Gathering

The documentation for LiveSplit autosplitters indicates that we will need to find a pointer to a relevant area of the game’s memory. To locate this pointer, we will eventually need to use Cheat Engine to find a pointer to the current level of Neon White. Instead of immediately jumping to Cheat Engine and low-level memory debugging, let’s try to uncover some of Neon White’s internal logic at a higher level.

Looking in the installation directory for Neon White, we can see that it contains files named UnityPlayer.dll and UnityCrashHandler64.exe. Accordingly, we can guess that this game was made with the Unity game engine. We will try to find the level name or level ID in Unity, then use that information to inform our search for a memory pointer in Cheat Engine.

A tool like UnityExplorer would suit our needs quite nicely, since it provides an in-game UI to examine internal game values. UnityExplorer is distributed as a plugin which needs to be loaded into a Unity executable at runtime. There are several different ways to load UnityExplorer into the Neon White Demo, but I used the MelonLoader framework.

Once the UnityExplorer plugin is successfully loaded, a GUI overlay will appear in-game:

Neon White with the Unity Inspector plugin loaded

UnityExplorer enables some very powerful functionality: we can now view the hierarchy of all the objects in the current Unity scene. We can also search for objects by their class name. Browsing the list of classes, we can notice a class that is simply called Game. Judging from the name, this class may serve as a high-level controller for game logic, and may provide a reference to the data we’re looking for (the level name or level ID).

Searching for objects of class Game yields one object as a result. Examining this object reveals that it does, indeed, have a field named _currentLevel with a type of LevelData. Examining this _currentLevel object, we can see that it does reference a level ID:

The revealed level ID values

Now we know which values to search for in Cheat Engine!

Cheat Engine

After opening Cheat Engine, we’ll select the Neon White Demo process to start reading its memory. We’ll start by searching for the string TUT_MOVEMENT:

Results for searching for the internal level name in Cheat Engine

Normally, we would like to change these values in the game by moving to another level, then perform another search for the new level ID value in Cheat Engine. In this way, we would be able to filter down the list of memory addresses. However, there is a problem with this approach in Unity games: when we move to a new level, a new LevelData object is created at a completely different location in memory, meaning that it would not show up in Cheat Engine’s list of search results after the initial scan. We need to come up with an alternative approach.

Since there are only four results, we’ll perform a pointer scan for each memory address. We’ll use a scattershot approach, adding a lot of pointers to our watchlist, and we’ll hope that some of them correctly update as the level changes in-game.

Performing a pointer scan in Cheat Engine

Note: Since we’re performing multiple pointer scans, we can use the “Generate pointermap” option in Cheat Engine to reduce the amount of times Cheat Engine has to scan the game’s memory.

After performing each pointer scan, we are shown a list of pointer paths that point to the specified address. We will double-click each of these paths to add them to our watchlist in Cheat Engine, prioritising paths that have a base address of UnityPlayer.dll and a low number of offsets. After adding pointer paths to all 4 search results to our watchlist, we can go back to the game and move onto the next level. Hopefully some of these pointer paths will now point to an updated level ID value.

Cheat Engine pointer watchlist after changing levels in Neon White

Although many of our pointers still have the old value of TUT_MOVEMENT, it looks like we were able to successfully find several memory pointers that changed to point at the new level ID! We can now write an autosplitter program that uses one of these memory pointers and advances the splits whenever the value changes.

Writing the Autosplitter

LiveSplit autosplitter programs are written in a special scripting language called the Auto Splitting Language (or “ASL”). To write our autosplitter for the Neon White demo, we’re going to rely very heavily on the ASL documentation.

The first step of writing an ASL program is the state descriptor, which describes the game progress. This area is where we will define the pointer we found earlier. The value of this pointer will become available in the rest of our script.

state("Neon White") {
    string255 levelId : "UnityPlayer.dll", 0x1A058E0, 0x48, 0x10, 0x18;
}

ASL will be continuously polling the game process to check for updates at the memory pointer. ASL will make the levelId variable accessible through both current.levelId and old.levelId, which we can use to determine when the level ID changed.

ASL has a variety of functions we can implement, but the one most relevant to us is split. If we return true on this function, then LiveSplit will advance the split:

split {
    // split whenever the level ID changes
    if (current.levelId != old.levelId) {
        return true;
    }
}

This implementation should work in theory. However, in practice, this implementation advances two splits every time the level changes. Upon further inspection, this is because the level ID is set to a null value for a brief moment between levels. The solution to this problem is to use the vars object supplied by ASL to keep track of the real old level ID (instead of comparing against a null value).

I also implemented functionality that would automatically start or restart the run whenever the first level was loaded. The full code for the autosplitter is below:

state("Neon White") {
    string255 levelId : "UnityPlayer.dll", 0x1A058E0, 0x48, 0x10, 0x18;
}

startup {
    vars.firstLevelId = "id/TUT_MOVEMENT.unity";
    vars.oldLevelId = vars.firstLevelId;
}

reset {
    if (current.levelId == vars.firstLevelId && current.levelId != old.levelId) {
        // reset the run anytime we enter the first level
        return true;
    }
}

start {
    if (current.levelId == vars.firstLevelId && current.levelId != old.levelId) {
        // reset old level ID to the first level ID when restarting the run
        vars.oldLevelId = vars.firstLevelId;
        return true;
    }
}

split {
    // when restarting a level, the levelId is set to null for a brief instant,
    // so only check against the actual old levelId instead of using old.levelId
    if (current.levelId != vars.oldLevelId && current.levelId != null) {
        vars.oldLevelId = current.levelId;
        return true;
    }
}

Release and Feedback

As with many of my projects, I open-sourced the code for this autosplitter on GitHub. As a next step, I released the autosplitter to the Neon White community, who were very thankful. I was able to use their feedback to further improve the autosplitter.

At the time of writing, most runs submitted to Neon White’s speedrun.com leaderboards run the autosplitter to keep track of their splits, including the current world record holder:

One member of the community suggested that I submit my autosplitter to LiveSplit’s list of autosplitters so that users would be able to instantly download the autosplitter directly through LiveSplit without having to navigate to GitHub. The submission process was quite painless. LiveSplit is also an open-source program, and one of LiveSplit’s maintainers was kind enough to make their own improvements to the Neon White autosplitter. This kind of collaboration is invaluable in the open-source community.

Development of the autosplitter is still ongoing, and the autosplitter will be updated when Neon White is fully released. The community is very excited for the full release of Neon White, and so am I - it’s fun to go fast!

Acknowledgements

See Also