Load Save Displacement

Posted: 18 Jun 2023

Exploring the mechanisms and code behind a speedrunning trick that lets runners skip half of the game.

There’s a “trick” (read: bug) in Psychonauts which speedrunners have dubbed “Load Save Displacement” (acronym intentional). This trick not only lets them skip getting the cobweb duster, but also allows them to completely bypass Fred, Edgar and Gloria’s levels. That’s… pretty major.

Even worse (or better, depending on your perspective), the latter version of the trick is really easy to do (the duster skip, not so much, but we’ll get to that) and it is absolutely bizarre to see in action.

Here is a video of the trick being performed at the Asylum - this is the exact method runners use to skip straight to Loboto’s lab. Small epilepsy warning due to a hall of mirrors effect.

Really bizarre, huh? And shockingly easy to do when you know how. What exactly is going on here? Well, let’s break down exactly what you do to achieve this.

It’s very simple. After completing Milkman we just head past Gloria and into the courtyard level transition, then after the screen starts to fade we just make a save game and load it. Then we can simply hop into the lift and it takes us straight to Loboto’s lab.

Now, that is what the lift is there for after all. The intention is that you can use it as a shortcut straight from the grounds to the lab but it’s meant to be locked if we’ve not actually gotten there already. So why does it suddenly unlock??

On top of that we’ve got some funky visual effects going on. The skybox is now a hall of mirrors effect and the water is a horrible solid block of red. It’s clear that something has gone very wrong behind the scenes and it’s not clear how all these things link together.

It doesn’t end there either - there’s a second variant of this trick that is used to skip the cobweb duster. This version is much harder to perform. Let’s see that one in action.

Potential epilepsy warning for the Hall of Mirrors effect here.

From this point you would use the flying trick to head straight to the boss chamber (same as in a normal run without this trick). We once again see some common threads - save at a specific point, load the save, and things are all messed up.

Though the key effect in this instance is that Raz ends up in the main area of the level, past the normally unskippable cobwebs in the caravan. Peculiar, huh?

This powerful trick is used in a couple of other places in a run, though it’s the easy variant being used for small timesaves in places like the campgrounds, where Raz’s positional displacement can be advantageous.

For the sake of clarity, from this point on I’ll be referring to these tricks as “LSD” for the easy version and “salts LSD” for the hard version.

From seeing this trick in action, if you’re technically minded you might draw your own conclusions based on the sequence. However, I’m fairly confident that most common first guesses would be wrong as this trick is very particular to the way the game is built. Let’s explore.

While both versions of the trick have slightly different execution, they both share the exact same root cause. To understand that, we need to understand how the game’s save/load system works because this very much stems from some… questionable usage of that system in the code.

There’s a special “Global” script megaclass that handles… a lot of stuff. Too much to cover in-depth. But the important thing here is that it can handle saving and loading of global variables.

Because scripts are confined to the specific level/map they are in and the game needs a way to remember different information no matter what the player does or where they go, the game needs a way to remember a value or flag. So, the global class can handle that.

And of course another advantage is that when you save the game, the current values of all those globals are written to your save. Handy!

So, the game has a global variable system and those global variables get written out to your save file. That’s piece 1 of the puzzle. What’s piece 2? Well, now we need to find out exactly how the player transitions between levels in cases where LSD works.

Starting with the Asylum example, this transition is handled by a class called Global.Props.Teleporter. Let’s peek at the relevant code. Specifically, we care about the code relevant to switching levels.

If the teleport destination is a different level, then the game saves a global variable (teleport) to remember the destination - this is a variable set on the teleport object which tells the game what object to place Raz at when he loads into the next level.

After saving this variable, it goes into the ‘LoadingNewLevel’ state which is responsible for fading out the screen and then, when the fade is completed, the game loads the new level.

When a level loads, the level script (a type of script present in every level responsible for all kinds of things) will go through a specific process to decide where to position Raz. The very first order of business? Checking the ‘teleport’ variable.

If that variable is set, then the game will find the object with a name matching the value of that variable and try to put Raz at the position at rotation of that object.

So if we hit a teleport trigger in Level A where the destination is “TPPoint” in Level B then when the next level loads the game will find the object named “TPPoint” and place Raz there. You get some points if you’re already putting together what the problem might be here.

This code is run by the base LevelScript every time we load into a level, no matter how you entered that level. Including after loading a save. Now, there’s a problem. The next level doesn’t load until after the fadeout completes. But the teleport global is set well beforehand.

So, there is a point in time where we can save the game AFTER teleport is set, but before Level B loads. And, as with all globals, the teleport global is written to our save game. This creates a very special save file.

Because now, we have a save file where we are still in Level A, and that’s the level that loads, but teleport names an entity that only exists in Level B. And that Raz placement code will run when we load that save. And it will see that. And there is no validity checking.

The code DOES NOT ensure that the entity referred to by teleport actually exists. It blindly marches on, trying to operate on an entity that is, in all likelihood, nil. This causes many problems.

This function, startPlayer, is run from a function called onBeginLevel in the base level script. All levels in the game have their own custom level script generally with their own custom onBeginLevel function, but they always call down into the base script.

And when they do, well, the game runs into a script error when it tries to operate on a non-existent entity. That prevents the entire rest of the function from executing. No matter the level, NOTHING after the call down into the base script will run.

This has… ramifications, depending on the level. Let’s look at our example case in the Asylum.

Here’s a section of ASGR’s onBeginLevel function. As you can see, it calls into the base VERY early in the function. Nothing after the line %Ob.Parent.onBeginLevel(self) will run.

This means it doesn’t set up the water settings. That explains the visual bug we got with the water. It doesn’t even get a chance to set the skybox, so we get a hall of mirror instead. What about the lift?

Well the lift entity always exists in the level but it’s locked until you’re meant to be able to use it. Turns out though that it’s enabled by default. The code to disable it when it needs to be is right at the bottom of this function. Needless to say, it doesn’t run.

There’s an all manner of other stuff that gets messed up here but those are the ones we witnessed when doing the trick. Needless to say, the game is generally in a bit of a sorry state after doing it, depending on what exactly the level script does.

By the way, normally when doing this trick Raz just gets dumped at a default position (hence why he ends up in the actual level when doing this in Meat Circus). How come he seems to end up in such a convenient spot when doing it in Asylum? Well, he doesn’t always.

In this instance, Raz is saved from being punted out of bounds by the existence of a small hack in the ASGR level script, in a function that is run in postBeginLevel instead of onBeginLevel

This code is intended to make Raz spawn outside the gate the first time you load into ASGR after beating Milkman. It checks if the last level you were in was Milkman, then checks if Milkman has been completed, and if so it sets Raz to be outside the gate.

Said code is only meant to run once - the first time you beat Milkman. But due to an oversight (the code checks for a global called bPlacedRazAfterMM and runs if it is not set, but then never actually sets it after, so the code always runs) it runs every time.

If the last level WASN’T Milkman, then it means Raz will spawn out of bounds and this is inconvenient for running because it means needing to fly back into the level. Though, the only case that’d happen is if you missed the window to save when going into the courtyard.

So now we understand the mechanism behind the first variation of this trick. But how does it work when it comes to Meat Circus? How is it possible that we can trigger it using Smelling Salts and just why exactly is it so damn hard to pull off?

Well, the trick working in that case is something of a perfect storm. There’s a lot that happens to line up to make it possible.

See, the Smelling Salts change after you reach Meat Circus. Before that point, they do check to see if you entered by using the Psycho-Portal. If so, then the game will dump you at an appropriate place.

Otherwise, you must’ve entered from the collective unconscious, so it puts you there. In this case, it just loads CASA (Sasha’s Lab and also the CU) and the process of figuring out where to put Raz there is handled elsewhere.

HOWEVER. After we reach Meat Circus, there’s no going back to the real world. Using the Smelling Salts will always take you to either the CU or back to the Meat Circus.

But when it takes you to the CU in this case, it handles it via a function called returnToCU in the base level script. This is a very simple and small function. It does 2 things. It sets the player to spawn at the right place and then loads CASA.

How does it set where the player spawns? Well…

Yep. teleport is back. But it loads the next level right after setting it. Now, loadNewLevel does some processing of its own and even runs a fade, however, that fade not only lasts a mere 0.25 seconds but the Smelling Salts already fade the screen out.

The effective result? After the Smelling Salts fade you have give or take a vague and indeterminate 0.25 second window in which to save the game. Too early and you just load into Meat Circus normally. It’s an incredibly tough trick to time.

But, ultimately, the mechanism is the exact same as with the other version of the trick. teleport is set, the entity it’s looking for does not exist in the level the save file is set to, and the level script’s onBeginLevel function fails early.

Though because it relies on that function, salts LSD doesn’t work any time before Meat Circus (some strange stuff still happens, but not this trick)

So, that’s the trick. A very specific set of circumstances in the way the game is coded lead to a trick that lets you skip pretty much the entire second half of the game. This doesn’t work any time before Meat Circus (some strange stuff still happens, but not this trick)

It’s a bit of a shame that this trick wasn’t discovered until shortly after Double Fine’s video of them watching SMK run the game was released. It’d have been wonderful to see their reaction to this trick. Wonder if any of them kept up with the scene enough to know this exists.

A lot of this was rephrased, shortened and cleaned up from a longer writeup I did after first debugging this, which you can find here: (A lot of this was rephrased, shortened and cleaned up from a longer writeup I did after first debugging this, which you can find here: https://www.speedrun.com/psychonauts/thread/vu9sc)[https://www.speedrun.com/psychonauts/thread/vu9sc]

This is such a fascinating bug and honestly knowing just how tight that timing window is for the Smelling Salts version makes me respect it all the more every time I see someone pull it off.

Anyway, I’ve covered everything I wanted to in regards to this. There’s a bit more detail and some extra tidbits in the aforementioned link if you are interested but that covers the essential stuff.