Skip to content

Instantly share code, notes, and snippets.

@luckytyphlosion
Last active November 23, 2023 04:58
Show Gist options
  • Save luckytyphlosion/e1c1fd8b445e7df406ad4bc480f9dcdd to your computer and use it in GitHub Desktop.
Save luckytyphlosion/e1c1fd8b445e7df406ad4bc480f9dcdd to your computer and use it in GitHub Desktop.
Slay the Spire unknown room re-rolling glitches.

Known conditions where an unknown room can be rerolled due to a save and quit

1. Unknown room directly after a shop room

Note that this applies regardless of whether the shop was from an unknown room or a shop room. This is because the game does not allow generating two shops in a row. The game checks this by checking if the current room is a shop, setting the shop chance to 0 if so, and then assigning the current room to the room generated by the ? room.

However, the game does not save the current room (instead setting it to an empty room upon loading), so if the player generates the room via loading the save, it will not set the shop chance to 0, altering the overall probabilities and potentially generating a different room.

2. Any shrine after a re-usable shrine

Below are the two types of shrine categories: One-time shrines (name from source code) and reusable shrines (unofficial name, source code calls it shrineList). One-time shrines can only be seen once in the entire run, and are kept track of in the save file. Reusable shrines can be seen only once per act, with the list resetting after each act. The list of seen reusable shrines are not stored in the save file, which is where issues arise from.

One-time shrines:

  • Accursed Blacksmith
  • Bonfire Elementals
  • Designer
  • Duplicator
  • FaceTrader
  • Fountain of Cleansing
  • Knowing Skull
  • Lab
  • N'loth
  • NoteForYourself (pre-A15)
  • SecretPortal
  • The Joust
  • WeMeetAgain
  • The Woman in Blue

Reusable shrines:

  • Match and Keep!
  • Wheel of Change
  • Golden Shrine
  • Transmorgrifier
  • Purifier
  • Upgrade Shrine

The exact conditions that can cause a shrine to reroll are below (restricted to each act):

  • You must have encountered a reusable shrine, and you must not have saved and quit after encountering the reusable shrine.
  • You must then encounter either another reusable shrine or a one-time shrine.

If both conditions are true, then saving and quitting can cause the ? node to reroll. This is because the reusable shrine list isn't saved to the save file, so the probabilities for choosing each shrine are altered as there are more events in the pool after loading the save than before.

3. Events with scripted combats

If you save and quit on the rewards screen of an event with a scripted combat, upon reloading the save file it is possible for the event to be rerolled. The tl;dr from ForgottenArbiter is that "the game will incorrectly save your event rng counter as one higher than it should". Detailed explanation is below:

Firstly, a basic explanation of Slay the Spire RNG: Different parts of the game have different RNGs. For example, there's a cardRng, a potionRng, and an eventRng. Every seeded RNG in the game uses the run seed at the top right of the game. In addition, each RNG has a counter associated with it, incremented every time the RNG is rolled. The way that the game saves the state of some of these RNGs is by storing the RNG counter, so that the RNG state can be restored simply by calling the RNG with the base seed "RNG counter" times.

Below are descriptions of the relevant code of two functions relating to this glitch.

AbstractDungeon.nextRoomTransition is a function that runs a lot of code relating to entering a new room. This can either be called when entering from one room to another, or to load a room from a save file. The code performs certain checks depending on whether the room is loaded from a save file or not. The relevant parts are as follows:

  1. If we are not loading a save, increment the floor and save the game.
  2. If the room we're entering (also referred to as the next room) is an unknown room, do the following: 3. Make a copy of the event RNG (referred to as eventRng) and use that copy to roll what the ? room resolves to. 4. If we are loading a save on a combat reward screen and the resolved ? room is an event, do nothing. Otherwise, set eventRng to the duplicated eventRng, effectively advancing eventRng by 1, and set the entered room to the resolved room. - Note: This is the only place in the entire code where eventRng actually advances. All other uses of eventRng will make a copy without actually advancing eventRng's state. 5. If the resolved room is a monster room, set a variable combatEvent to true.

There is another part of the code in AbstractRoom.update which handles saving the game after winning a combat with a rewards screen. The relevant parts are below: 6. If we are not loading a save and not loading from a "post-combat" state: 7. Create the save data, but don't save it yet. 8. If combatEvent is true, then decrement the event RNG counter in the save file (referred to as event_seed_count). This does not decrement the event RNG counter itself. 9. Save the game.

The entire purpose of this code is to ensure the event RNG stays consistent from a save and quit on the rewards screen of an unknown -> monster room. Below describes an example:

  • eventRng.counter is at 3. We enter an unknown room.
  • [Line 1] Since we are not loading a save, the game will increment the floor and save.
  • [Line 2] We're entering an unknown room, so execute the next three lines.
  • [Line 3] The game makes a copy of eventRng and rolls what the unknown room will be. In our example, it'll be a monster room.
  • [Line 4] Since we are not loading a save on a combat reward screen, advance eventRng by 1 and set the next room to a monster room. eventRng.counter is now 4.
  • [Line 5] Since the resolved room is a monster room, set combatEvent to true.

After we win the fight, the following occurs.

  • [Line 6] Since we are not loading a save and not loading from a "post-combat" state, execute the next three lines.
  • [Line 7] We create the save data without saving. Since eventRng.counter is 4, the save data contains event_seed_count as 4.
  • [Line 8] Since combatEvent is true from earlier, we decrement event_seed_count, so it is now 3.
  • [Line 9] The game is saved.

Despite event_seed_count being 3 and eventRng.counter being 4, the game will stay in a correct RNG state regardless of whether you save and quit or not when the combat is over (assuming the previous room was not a shop). Observe with the following two scenarios.

Scenario 1: Don't save and quit.

  • eventRng.counter is 4. We enter a new room.
  • [Line 1] Since we are not loading a save, the game will increment the floor and save. event_seed_count is now 4.

Scenario 2: Save and quit.

  • Upon loading the save, eventRng.counter is 3. We enter the same unknown room.
  • [Line 1] We are loading a save, so don't do anything here.
  • [Line 2] We are entering an unknown room, so execute the next three lines.
  • [Line 3] The game makes a copy of eventRng and rolls what the unknown room will be. eventRng.counter was 3 when the unknown room was originally rolled, so we will get the exact same result, which is a monster room.
  • [Line 4] While we are loading a save on a combat reward screen, the resolved room is not an event room. Therefore, advance eventRng by 1 and set the next room to a monster room. eventRng.counter is now 4.
  • [Line 5] Since the resolved room is a monster room, set combatEvent to true.
  • [Line 6] Since we are loading from a save, do not execute the next three lines.
  • Entering a new room will now cause event_seed_count to be saved as 4, which is the same as if we did not save and quit.

So these weird shenanigans with the event RNG counter work fine with unknown -> monster rooms. However, the logic does not work with an event that leads into a scripted combat. Observe the following code flow below.

  • eventRng.counter is at 3. We enter an unknown room.
  • [Line 1] Since we are not loading a save, the game will increment the floor and save.
  • [Line 2] We're entering an unknown room, so execute the next three lines.
  • [Line 3] The game makes a copy of eventRng and rolls what the unknown room will be. In our example, it'll be the Hyponotizing Mushrooms event.
  • [Line 4] Since we are not loading a save on a combat reward screen, advance eventRng by 1 and set the next room to a monster room. eventRng.counter is now 4.
  • [Line 5] Since the resolved room is not a monster room (it's still an event), don't set combatEvent to true (by default, it is false).

After choosing to anger the mushrooms and winning the fight, the following code runs.

  • [Line 6] Since we are not loading a save and not loading from a "post-combat" state, execute the next three lines.
  • [Line 7] We create the save data without saving. Since eventRng.counter is 4, the save data contains event_seed_count as 4.
  • [Line 8] combatEvent is NOT true from earlier, so event_seed_count stays as 4.
  • [Line 9] The game is saved.

Now, observe what happens in the following two scenarios, one with and without a save and quit on the rewards screen.

Scenario 1: Don't save and quit.

  • eventRng.counter is 4. We enter a new room.
  • [Line 1] Since we are not loading a save, the game will increment the floor and save. event_seed_count is now 4.

Scenario 2: Save and quit

  • Upon loading the save, eventRng.counter is 4. We enter the same unknown room.
  • [Line 1] We are loading a save, so don't do anything here.
  • [Line 2] We are entering an unknown room, so execute the next three lines.
  • [Line 3] The game makes a copy of eventRng and rolls what the unknown room will be. This is where the desync occurs. eventRng.counter is 4 instead of 3, so there is a possibility that the unknown room will resolve to something different. Observe with the following two sub-scenarios:

Scenario 2A: Save and quit, event still rolled

  • [Line 3] We manage to roll an event room as the unknown room even with a different RNG state.
  • [Line 4] We are loading a save on a combat reward screen and the resolved room is an event room. Therefore, don't advance eventRng by 1 and don't set the next room to an event room (however, some earlier code specific to loading from a save has already set the next room to an event room anyway). As a reminder, eventRng.counter is still 4.
  • [Line 5] Since the resolved room is not a monster room (it's still an event), don't set combatEvent to true (by default, it is false).
  • [Line 6] Since we are loading from a save, do not execute the next three lines.
  • Entering a new room will now cause event_seed_count to be saved as 4, which is the same as if we did not save and quit. So even though eventRng.counter was not saved properly, we still end up with the same event_seed_count.

Scenario 2B: Save and quit, event not rolled

  • [Line 3] We did not roll an event, instead rolling a shop, monster, or treasure room.
  • [Line 4] While we are loading a save on a combat reward screen, the resolved room is not an event room. Therefore, advance eventRng by 1 and set the next room to whatever it was rolled as. eventRng.counter is now 5.
  • The remaining lines are irrelevant, because at this point the room has been rerolled with a different type.

List of events that lead into scripted combats:

  • Hypnotizing Mushrooms
  • Dead Adventurer
  • Masked Bandits
  • The Colosseum
  • Mysterious Sphere
  • Mind Bloom
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment