From ea491e57a058660edf3b79deb65de14e5bf07bc0 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Fri, 15 Mar 2024 17:16:44 -0400
Subject: [PATCH 01/50] Switch "Skip Cutscene" and "Restart Cutscene" (Dave's
 request)

---
 source/funkin/play/PauseSubState.hx | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/source/funkin/play/PauseSubState.hx b/source/funkin/play/PauseSubState.hx
index 03681ce13..10df25e90 100644
--- a/source/funkin/play/PauseSubState.hx
+++ b/source/funkin/play/PauseSubState.hx
@@ -72,8 +72,8 @@ class PauseSubState extends MusicBeatSubState
    */
   static final PAUSE_MENU_ENTRIES_VIDEO_CUTSCENE:Array<PauseMenuEntry> = [
     {text: 'Resume', callback: resume},
-    {text: 'Restart Cutscene', callback: restartVideoCutscene},
     {text: 'Skip Cutscene', callback: skipVideoCutscene},
+    {text: 'Restart Cutscene', callback: restartVideoCutscene},
     {text: 'Exit to Menu', callback: quitToMenu},
   ];
 

From e4eb543fa7511180319ccb4b90f9d10a20d1dc67 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Fri, 15 Mar 2024 21:53:07 -0400
Subject: [PATCH 02/50] Fix a bug where title music starts blaringly loud.

---
 source/funkin/audio/FunkinSound.hx   | 17 ++++++++++++-----
 source/funkin/ui/title/TitleState.hx |  4 ++--
 2 files changed, 14 insertions(+), 7 deletions(-)

diff --git a/source/funkin/audio/FunkinSound.hx b/source/funkin/audio/FunkinSound.hx
index 9efa6ed50..a0bf8c58c 100644
--- a/source/funkin/audio/FunkinSound.hx
+++ b/source/funkin/audio/FunkinSound.hx
@@ -241,10 +241,16 @@ class FunkinSound extends FlxSound implements ICloneable<FunkinSound>
   @:allow(flixel.sound.FlxSoundGroup)
   override function updateTransform():Void
   {
-    _transform.volume = #if FLX_SOUND_SYSTEM ((FlxG.sound.muted || this.muted) ? 0 : 1) * FlxG.sound.volume * #end
-      (group != null ? group.volume : 1) * _volume * _volumeAdjust;
+    if (_transform != null)
+    {
+      _transform.volume = #if FLX_SOUND_SYSTEM ((FlxG.sound.muted || this.muted) ? 0 : 1) * FlxG.sound.volume * #end
+        (group != null ? group.volume : 1) * _volume * _volumeAdjust;
+    }
 
-    if (_channel != null) _channel.soundTransform = _transform;
+    if (_channel != null)
+    {
+      _channel.soundTransform = _transform;
+    }
   }
 
   public function clone():FunkinSound
@@ -270,11 +276,12 @@ class FunkinSound extends FlxSound implements ICloneable<FunkinSound>
    * Creates a new `FunkinSound` object and loads it as the current music track.
    *
    * @param key The key of the music you want to play. Music should be at `music/<key>/<key>.ogg`.
+   * @param startingVolume The volume you want the music to start at.
    * @param overrideExisting Whether to override music if it is already playing.
    * @param mapTimeChanges Whether to check for `SongMusicData` to update the Conductor with.
    *   Data should be at `music/<key>/<key>-metadata.json`.
    */
-  public static function playMusic(key:String, overrideExisting:Bool = false, mapTimeChanges:Bool = true):Void
+  public static function playMusic(key:String, startingVolume:Float = 1.0, overrideExisting:Bool = false, mapTimeChanges:Bool = true):Void
   {
     if (!overrideExisting && FlxG.sound.music?.playing) return;
 
@@ -292,7 +299,7 @@ class FunkinSound extends FlxSound implements ICloneable<FunkinSound>
       }
     }
 
-    FlxG.sound.music = FunkinSound.load(Paths.music('$key/$key'));
+    FlxG.sound.music = FunkinSound.load(Paths.music('$key/$key'), startingVolume);
 
     // Prevent repeat update() and onFocus() calls.
     FlxG.sound.list.remove(FlxG.sound.music);
diff --git a/source/funkin/ui/title/TitleState.hx b/source/funkin/ui/title/TitleState.hx
index 1c194d80d..26f6612be 100644
--- a/source/funkin/ui/title/TitleState.hx
+++ b/source/funkin/ui/title/TitleState.hx
@@ -222,9 +222,9 @@ class TitleState extends MusicBeatState
   {
     var shouldFadeIn = (FlxG.sound.music == null);
     // Load music. Includes logic to handle BPM changes.
-    FunkinSound.playMusic('freakyMenu', false, true);
+    FunkinSound.playMusic('freakyMenu', 0.0, false, true);
     // Fade from 0.0 to 0.7 over 4 seconds
-    if (shouldFadeIn) FlxG.sound.music.fadeIn(4, 0, 0.7);
+    if (shouldFadeIn) FlxG.sound.music.fadeIn(4.0, 0.0, 1.0);
   }
 
   function getIntroTextShit():Array<Array<String>>

From 5733386519a705e3f119e2e48465cbb93db61331 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Sat, 16 Mar 2024 00:55:05 -0400
Subject: [PATCH 03/50] Update Polymod to improve error handling in scripts.

---
 hmm.json | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/hmm.json b/hmm.json
index 42d17743f..6a8575eb0 100644
--- a/hmm.json
+++ b/hmm.json
@@ -146,7 +146,7 @@
       "name": "polymod",
       "type": "git",
       "dir": null,
-      "ref": "be712450e5d3ba446008884921bb56873b299a64",
+      "ref": "682548319a272f6e4c9efa97aca081866426d5c7",
       "url": "https://github.com/larsiusprime/polymod"
     },
     {

From d56c33cd172eecbed8c7788437580588c7e6526e Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Sat, 16 Mar 2024 00:55:57 -0400
Subject: [PATCH 04/50] Fix a dozen tiny issues with 2hot's audio and visuals
 (and some script crashes!).

---
 assets                                        |   2 +-
 source/funkin/Paths.hx                        |   2 +-
 source/funkin/audio/FunkinSound.hx            |  63 ++++++-
 source/funkin/audio/SoundGroup.hx             |  25 ++-
 .../modding/events/ScriptEventDispatcher.hx   |   7 +-
 source/funkin/play/GameOverSubState.hx        | 165 +++++++++++-------
 source/funkin/play/PlayState.hx               |  14 +-
 source/funkin/play/PlayStatePlaylist.hx       |   8 +-
 source/funkin/play/stage/Stage.hx             |   3 +-
 .../ui/debug/charting/ChartEditorState.hx     |  50 +++---
 source/funkin/ui/freeplay/FreeplayState.hx    |  20 ++-
 source/funkin/ui/mainmenu/MainMenuState.hx    |   7 +-
 source/funkin/ui/story/StoryMenuState.hx      |   7 +-
 source/funkin/ui/title/TitleState.hx          |   9 +-
 source/funkin/ui/transition/LoadingState.hx   |  94 +++++++---
 source/funkin/util/Constants.hx               |   6 +
 16 files changed, 332 insertions(+), 150 deletions(-)

diff --git a/assets b/assets
index 0e2c5bf21..82ee26e1f 160000
--- a/assets
+++ b/assets
@@ -1 +1 @@
-Subproject commit 0e2c5bf2134c7e517b70cf74afd58abe5c7b5e50
+Subproject commit 82ee26e1f733d1b2f30015ae69925e6a39d6526b
diff --git a/source/funkin/Paths.hx b/source/funkin/Paths.hx
index 6006939be..b00d13def 100644
--- a/source/funkin/Paths.hx
+++ b/source/funkin/Paths.hx
@@ -9,7 +9,7 @@ import openfl.utils.Assets as OpenFlAssets;
  */
 class Paths
 {
-  static var currentLevel:String;
+  static var currentLevel:Null<String> = null;
 
   static public function setCurrentLevel(name:String)
   {
diff --git a/source/funkin/audio/FunkinSound.hx b/source/funkin/audio/FunkinSound.hx
index a0bf8c58c..3e521ed0d 100644
--- a/source/funkin/audio/FunkinSound.hx
+++ b/source/funkin/audio/FunkinSound.hx
@@ -276,16 +276,27 @@ class FunkinSound extends FlxSound implements ICloneable<FunkinSound>
    * Creates a new `FunkinSound` object and loads it as the current music track.
    *
    * @param key The key of the music you want to play. Music should be at `music/<key>/<key>.ogg`.
-   * @param startingVolume The volume you want the music to start at.
-   * @param overrideExisting Whether to override music if it is already playing.
-   * @param mapTimeChanges Whether to check for `SongMusicData` to update the Conductor with.
+   * @param params A set of additional optional parameters.
    *   Data should be at `music/<key>/<key>-metadata.json`.
    */
-  public static function playMusic(key:String, startingVolume:Float = 1.0, overrideExisting:Bool = false, mapTimeChanges:Bool = true):Void
+  public static function playMusic(key:String, params:FunkinSoundPlayMusicParams):Void
   {
-    if (!overrideExisting && FlxG.sound.music?.playing) return;
+    if (!(params.overrideExisting ?? false) && FlxG.sound.music?.playing) return;
 
-    if (mapTimeChanges)
+    if (!(params.restartTrack ?? false) && FlxG.sound.music?.playing)
+    {
+      if (FlxG.sound.music != null && Std.isOfType(FlxG.sound.music, FunkinSound))
+      {
+        var existingSound:FunkinSound = cast FlxG.sound.music;
+        // Stop here if we would play a matching music track.
+        if (existingSound._label == Paths.music('$key/$key'))
+        {
+          return;
+        }
+      }
+    }
+
+    if (params?.mapTimeChanges ?? true)
     {
       var songMusicData:Null<SongMusicData> = SongRegistry.instance.parseMusicData(key);
       // Will fall back and return null if the metadata doesn't exist or can't be parsed.
@@ -299,7 +310,13 @@ class FunkinSound extends FlxSound implements ICloneable<FunkinSound>
       }
     }
 
-    FlxG.sound.music = FunkinSound.load(Paths.music('$key/$key'), startingVolume);
+    if (FlxG.sound.music != null)
+    {
+      FlxG.sound.music.stop();
+      FlxG.sound.music.kill();
+    }
+
+    FlxG.sound.music = FunkinSound.load(Paths.music('$key/$key'), params?.startingVolume ?? 1.0, true, false, true);
 
     // Prevent repeat update() and onFocus() calls.
     FlxG.sound.list.remove(FlxG.sound.music);
@@ -333,10 +350,10 @@ class FunkinSound extends FlxSound implements ICloneable<FunkinSound>
       sound._label = embeddedSound;
     }
 
+    if (autoPlay) sound.play();
     sound.volume = volume;
     sound.group = FlxG.sound.defaultSoundGroup;
     sound.persist = true;
-    if (autoPlay) sound.play();
 
     // Call onLoad() because the sound already loaded
     if (onLoad != null && sound._sound != null) onLoad();
@@ -356,3 +373,33 @@ class FunkinSound extends FlxSound implements ICloneable<FunkinSound>
     return sound;
   }
 }
+
+/**
+ * Additional parameters for `FunkinSound.playMusic()`
+ */
+typedef FunkinSoundPlayMusicParams =
+{
+  /**
+   * The volume you want the music to start at.
+   * @default `1.0`
+   */
+  var ?startingVolume:Float;
+
+  /**
+   * Whether to override music if a different track is already playing.
+   * @default `false`
+   */
+  var ?overrideExisting:Bool;
+
+  /**
+   * Whether to override music if the same track is already playing.
+   * @default `false`
+   */
+  var ?restartTrack:Bool;
+
+  /**
+   * Whether to check for `SongMusicData` to update the Conductor with.
+   * @default `true`
+   */
+  var ?mapTimeChanges:Bool;
+}
diff --git a/source/funkin/audio/SoundGroup.hx b/source/funkin/audio/SoundGroup.hx
index a26537c2a..2c14099bd 100644
--- a/source/funkin/audio/SoundGroup.hx
+++ b/source/funkin/audio/SoundGroup.hx
@@ -151,14 +151,14 @@ class SoundGroup extends FlxTypedGroup<FunkinSound>
   /**
    * Stop all the sounds in the group.
    */
-  public function stop()
+  public function stop():Void
   {
     forEachAlive(function(sound:FunkinSound) {
       sound.stop();
     });
   }
 
-  public override function destroy()
+  public override function destroy():Void
   {
     stop();
     super.destroy();
@@ -176,9 +176,14 @@ class SoundGroup extends FlxTypedGroup<FunkinSound>
 
   function get_time():Float
   {
-    if (getFirstAlive() != null) return getFirstAlive().time;
+    if (getFirstAlive() != null)
+    {
+      return getFirstAlive().time;
+    }
     else
+    {
       return 0;
+    }
   }
 
   function set_time(time:Float):Float
@@ -193,16 +198,26 @@ class SoundGroup extends FlxTypedGroup<FunkinSound>
 
   function get_playing():Bool
   {
-    if (getFirstAlive() != null) return getFirstAlive().playing;
+    if (getFirstAlive() != null)
+    {
+      return getFirstAlive().playing;
+    }
     else
+    {
       return false;
+    }
   }
 
   function get_volume():Float
   {
-    if (getFirstAlive() != null) return getFirstAlive().volume;
+    if (getFirstAlive() != null)
+    {
+      return getFirstAlive().volume;
+    }
     else
+    {
       return 1;
+    }
   }
 
   // in PlayState, adjust the code so that it only mutes the player1 vocal tracks?
diff --git a/source/funkin/modding/events/ScriptEventDispatcher.hx b/source/funkin/modding/events/ScriptEventDispatcher.hx
index fd58d0fad..c262c311d 100644
--- a/source/funkin/modding/events/ScriptEventDispatcher.hx
+++ b/source/funkin/modding/events/ScriptEventDispatcher.hx
@@ -8,7 +8,12 @@ import funkin.modding.IScriptedClass;
  */
 class ScriptEventDispatcher
 {
-  public static function callEvent(target:IScriptedClass, event:ScriptEvent):Void
+  /**
+   * Invoke the given event hook on the given scripted class.
+   * @param target The target class to call script hooks on.
+   * @param event The event, which determines the script hook to call and provides parameters for it.
+   */
+  public static function callEvent(target:Null<IScriptedClass>, event:ScriptEvent):Void
   {
     if (target == null || event == null) return;
 
diff --git a/source/funkin/play/GameOverSubState.hx b/source/funkin/play/GameOverSubState.hx
index 95304d762..f84bc8d7f 100644
--- a/source/funkin/play/GameOverSubState.hx
+++ b/source/funkin/play/GameOverSubState.hx
@@ -2,20 +2,18 @@ package funkin.play;
 
 import flixel.FlxG;
 import flixel.FlxObject;
-import flixel.FlxSprite;
-import flixel.sound.FlxSound;
-import funkin.audio.FunkinSound;
+import flixel.input.touch.FlxTouch;
 import flixel.util.FlxColor;
 import flixel.util.FlxTimer;
+import funkin.audio.FunkinSound;
 import funkin.graphics.FunkinSprite;
 import funkin.modding.events.ScriptEvent;
 import funkin.modding.events.ScriptEventDispatcher;
 import funkin.play.character.BaseCharacter;
-import funkin.play.PlayState;
-import funkin.util.MathUtil;
 import funkin.ui.freeplay.FreeplayState;
 import funkin.ui.MusicBeatSubState;
 import funkin.ui.story.StoryMenuState;
+import funkin.util.MathUtil;
 import openfl.utils.Assets;
 
 /**
@@ -24,13 +22,14 @@ import openfl.utils.Assets;
  *
  * The newest implementation uses a substate, which prevents having to reload the song and stage each reset.
  */
+@:nullSafety
 class GameOverSubState extends MusicBeatSubState
 {
   /**
    * The currently active GameOverSubState.
    * There should be only one GameOverSubState in existance at a time, we can use a singleton.
    */
-  public static var instance:GameOverSubState = null;
+  public static var instance:Null<GameOverSubState> = null;
 
   /**
    * Which alternate animation on the character to use.
@@ -38,7 +37,7 @@ class GameOverSubState extends MusicBeatSubState
    * For example, playing a different animation when BF dies in Week 4
    * or Pico dies in Weekend 1.
    */
-  public static var animationSuffix:String = "";
+  public static var animationSuffix:String = '';
 
   /**
    * Which alternate game over music to use.
@@ -46,17 +45,19 @@ class GameOverSubState extends MusicBeatSubState
    * For example, the bf-pixel script sets this to `-pixel`
    * and the pico-playable script sets this to `Pico`.
    */
-  public static var musicSuffix:String = "";
+  public static var musicSuffix:String = '';
 
   /**
    * Which alternate "blue ball" sound effect to use.
    */
-  public static var blueBallSuffix:String = "";
+  public static var blueBallSuffix:String = '';
+
+  static var blueballed:Bool = false;
 
   /**
    * The boyfriend character.
    */
-  var boyfriend:BaseCharacter;
+  var boyfriend:Null<BaseCharacter> = null;
 
   /**
    * The invisible object in the scene which the camera focuses on.
@@ -83,7 +84,8 @@ class GameOverSubState extends MusicBeatSubState
 
   var transparent:Bool;
 
-  final CAMERA_ZOOM_DURATION:Float = 0.5;
+  static final CAMERA_ZOOM_DURATION:Float = 0.5;
+
   var targetCameraZoom:Float = 1.0;
 
   public function new(params:GameOverParams)
@@ -92,24 +94,27 @@ class GameOverSubState extends MusicBeatSubState
 
     this.isChartingMode = params?.isChartingMode ?? false;
     transparent = params.transparent;
+
+    cameraFollowPoint = new FlxObject(PlayState.instance.cameraFollowPoint.x, PlayState.instance.cameraFollowPoint.y, 1, 1);
   }
 
   /**
    * Reset the game over configuration to the default.
    */
-  public static function reset()
+  public static function reset():Void
   {
-    animationSuffix = "";
-    musicSuffix = "";
-    blueBallSuffix = "";
+    animationSuffix = '';
+    musicSuffix = '';
+    blueBallSuffix = '';
+    blueballed = false;
   }
 
-  override public function create()
+  public override function create():Void
   {
     if (instance != null)
     {
       // TODO: Do something in this case? IDK.
-      trace('WARNING: GameOverSubState instance already exists. This should not happen.');
+      FlxG.log.warn('WARNING: GameOverSubState instance already exists. This should not happen.');
     }
     instance = this;
 
@@ -120,7 +125,7 @@ class GameOverSubState extends MusicBeatSubState
     //
 
     // Add a black background to the screen.
-    var bg = new FunkinSprite().makeSolidColor(FlxG.width * 2, FlxG.height * 2, FlxColor.BLACK);
+    var bg:FunkinSprite = new FunkinSprite().makeSolidColor(FlxG.width * 2, FlxG.height * 2, FlxColor.BLACK);
     // We make this transparent so that we can see the stage underneath during debugging,
     // but it's normally opaque.
     bg.alpha = transparent ? 0.25 : 1.0;
@@ -135,18 +140,7 @@ class GameOverSubState extends MusicBeatSubState
     add(boyfriend);
     boyfriend.resetCharacter();
 
-    // Assign a camera follow point to the boyfriend's position.
-    cameraFollowPoint = new FlxObject(PlayState.instance.cameraFollowPoint.x, PlayState.instance.cameraFollowPoint.y, 1, 1);
-    cameraFollowPoint.x = boyfriend.getGraphicMidpoint().x;
-    cameraFollowPoint.y = boyfriend.getGraphicMidpoint().y;
-    var offsets:Array<Float> = boyfriend.getDeathCameraOffsets();
-    cameraFollowPoint.x += offsets[0];
-    cameraFollowPoint.y += offsets[1];
-    add(cameraFollowPoint);
-
-    FlxG.camera.target = null;
-    FlxG.camera.follow(cameraFollowPoint, LOCKON, 0.01);
-    targetCameraZoom = PlayState?.instance?.currentStage?.camZoom * boyfriend.getDeathCameraZoom();
+    setCameraTarget();
 
     //
     // Set up the audio
@@ -156,6 +150,26 @@ class GameOverSubState extends MusicBeatSubState
     Conductor.instance.update(0);
   }
 
+  @:nullSafety(Off)
+  function setCameraTarget():Void
+  {
+    // Assign a camera follow point to the boyfriend's position.
+    cameraFollowPoint.x = boyfriend.getGraphicMidpoint().x;
+    cameraFollowPoint.y = boyfriend.getGraphicMidpoint().y;
+    var offsets:Array<Float> = boyfriend.getDeathCameraOffsets();
+    cameraFollowPoint.x += offsets[0];
+    cameraFollowPoint.y += offsets[1];
+    add(cameraFollowPoint);
+
+    FlxG.camera.target = null;
+    FlxG.camera.follow(cameraFollowPoint, LOCKON, Constants.DEFAULT_CAMERA_FOLLOW_RATE / 2);
+    targetCameraZoom = (PlayState?.instance?.currentStage?.camZoom ?? 1.0) * boyfriend.getDeathCameraZoom();
+  }
+
+  /**
+   * Forcibly reset the camera zoom level to that of the current stage.
+   * This prevents camera zoom events from adversely affecting the game over state.
+   */
   public function resetCameraZoom():Void
   {
     // Apply camera zoom level from stage data.
@@ -164,21 +178,24 @@ class GameOverSubState extends MusicBeatSubState
 
   var hasStartedAnimation:Bool = false;
 
-  override function update(elapsed:Float)
+  override function update(elapsed:Float):Void
   {
     if (!hasStartedAnimation)
     {
       hasStartedAnimation = true;
 
-      if (boyfriend.hasAnimation('fakeoutDeath') && FlxG.random.bool((1 / 4096) * 100))
+      if (boyfriend != null)
       {
-        boyfriend.playAnimation('fakeoutDeath', true, false);
-      }
-      else
-      {
-        boyfriend.playAnimation('firstDeath', true, false); // ignoreOther is set to FALSE since you WANT to be able to mash and confirm game over!
-        // Play the "blue balled" sound. May play a variant if one has been assigned.
-        playBlueBalledSFX();
+        if (boyfriend.hasAnimation('fakeoutDeath') && FlxG.random.bool((1 / 4096) * 100))
+        {
+          boyfriend.playAnimation('fakeoutDeath', true, false);
+        }
+        else
+        {
+          boyfriend.playAnimation('firstDeath', true, false); // ignoreOther is set to FALSE since you WANT to be able to mash and confirm game over!
+          // Play the "blue balled" sound. May play a variant if one has been assigned.
+          playBlueBalledSFX();
+        }
       }
     }
 
@@ -192,10 +209,10 @@ class GameOverSubState extends MusicBeatSubState
     // MOBILE ONLY: Restart the level when tapping Boyfriend.
     if (FlxG.onMobile)
     {
-      var touch = FlxG.touches.getFirst();
+      var touch:FlxTouch = FlxG.touches.getFirst();
       if (touch != null)
       {
-        if (touch.overlaps(boyfriend))
+        if (boyfriend == null || touch.overlaps(boyfriend))
         {
           confirmDeath();
         }
@@ -215,7 +232,7 @@ class GameOverSubState extends MusicBeatSubState
       blueballed = false;
       PlayState.instance.deathCounter = 0;
       // PlayState.seenCutscene = false; // old thing...
-      gameOverMusic.stop();
+      if (gameOverMusic != null) gameOverMusic.stop();
 
       if (isChartingMode)
       {
@@ -239,14 +256,14 @@ class GameOverSubState extends MusicBeatSubState
       // This enables the stepHit and beatHit events.
       Conductor.instance.update(gameOverMusic.time);
     }
-    else
+    else if (boyfriend != null)
     {
       // Music hasn't started yet.
       switch (PlayStatePlaylist.campaignId)
       {
         // TODO: Make the behavior for playing Jeff's voicelines generic or un-hardcoded.
-        // This will simplify the class and make it easier for mods to add death quotes.
-        case "week7":
+        // This will simplify the class and make it easier for mods or future weeks to add death quotes.
+        case 'week7':
           if (boyfriend.getCurrentAnimation().startsWith('firstDeath') && boyfriend.isAnimationFinished() && !playingJeffQuote)
           {
             playingJeffQuote = true;
@@ -279,7 +296,7 @@ class GameOverSubState extends MusicBeatSubState
       isEnding = true;
       startDeathMusic(1.0, true); // isEnding changes this function's behavior.
 
-      boyfriend.playAnimation('deathConfirm' + animationSuffix, true);
+      if (boyfriend != null) boyfriend.playAnimation('deathConfirm' + animationSuffix, true);
 
       // After the animation finishes...
       new FlxTimer().start(0.7, function(tmr:FlxTimer) {
@@ -290,9 +307,12 @@ class GameOverSubState extends MusicBeatSubState
           PlayState.instance.needsReset = true;
 
           // Readd Boyfriend to the stage.
-          boyfriend.isDead = false;
-          remove(boyfriend);
-          PlayState.instance.currentStage.addCharacter(boyfriend, BF);
+          if (boyfriend != null)
+          {
+            boyfriend.isDead = false;
+            remove(boyfriend);
+            PlayState.instance.currentStage.addCharacter(boyfriend, BF);
+          }
 
           // Snap reset the camera which may have changed because of the player character data.
           resetCameraZoom();
@@ -304,7 +324,7 @@ class GameOverSubState extends MusicBeatSubState
     }
   }
 
-  public override function dispatchEvent(event:ScriptEvent)
+  public override function dispatchEvent(event:ScriptEvent):Void
   {
     super.dispatchEvent(event);
 
@@ -317,11 +337,11 @@ class GameOverSubState extends MusicBeatSubState
    */
   function resolveMusicPath(suffix:String, starting:Bool = false, ending:Bool = false):Null<String>
   {
-    var basePath = 'gameplay/gameover/gameOver';
-    if (starting) basePath += 'Start';
-    else if (ending) basePath += 'End';
+    var basePath:String = 'gameplay/gameover/gameOver';
+    if (ending) basePath += 'End';
+    else if (starting) basePath += 'Start';
 
-    var musicPath = Paths.music(basePath + suffix);
+    var musicPath:String = Paths.music(basePath + suffix);
     while (!Assets.exists(musicPath) && suffix.length > 0)
     {
       suffix = suffix.split('-').slice(0, -1).join('-');
@@ -334,23 +354,26 @@ class GameOverSubState extends MusicBeatSubState
 
   /**
    * Starts the death music at the appropriate volume.
-   * @param startingVolume
+   * @param startingVolume The initial volume for the music.
+   * @param force Whether or not to force the music to restart.
    */
   public function startDeathMusic(startingVolume:Float = 1, force:Bool = false):Void
   {
-    var musicPath = resolveMusicPath(musicSuffix, isStarting, isEnding);
-    var onComplete = null;
+    var musicPath:Null<String> = resolveMusicPath(musicSuffix, isStarting, isEnding);
+    var onComplete:() -> Void = () -> {};
+
     if (isStarting)
     {
       if (musicPath == null)
       {
+        // Looked for starting music and didn't find it. Use middle music instead.
         isStarting = false;
         musicPath = resolveMusicPath(musicSuffix, isStarting, isEnding);
       }
       else
       {
         onComplete = function() {
-          isStarting = false;
+          isStarting = true;
           // We need to force to ensure that the non-starting music plays.
           startDeathMusic(1.0, true);
         };
@@ -359,13 +382,16 @@ class GameOverSubState extends MusicBeatSubState
 
     if (musicPath == null)
     {
-      trace('Could not find game over music!');
+      FlxG.log.warn('[GAMEOVER] Could not find game over music at path ($musicPath)!');
       return;
     }
     else if (gameOverMusic == null || !gameOverMusic.playing || force)
     {
       if (gameOverMusic != null) gameOverMusic.stop();
+
       gameOverMusic = FunkinSound.load(musicPath);
+      if (gameOverMusic == null) return;
+
       gameOverMusic.volume = startingVolume;
       gameOverMusic.looped = !(isEnding || isStarting);
       gameOverMusic.onComplete = onComplete;
@@ -378,13 +404,11 @@ class GameOverSubState extends MusicBeatSubState
     }
   }
 
-  static var blueballed:Bool = false;
-
   /**
    * Play the sound effect that occurs when
    * boyfriend's testicles get utterly annihilated.
    */
-  public static function playBlueBalledSFX()
+  public static function playBlueBalledSFX():Void
   {
     blueballed = true;
     if (Assets.exists(Paths.sound('gameplay/gameover/fnf_loss_sfx' + blueBallSuffix)))
@@ -403,7 +427,7 @@ class GameOverSubState extends MusicBeatSubState
    * Week 7-specific hardcoded behavior, to play a custom death quote.
    * TODO: Make this a module somehow.
    */
-  function playJeffQuote()
+  function playJeffQuote():Void
   {
     var randomCensor:Array<Int> = [];
 
@@ -418,20 +442,27 @@ class GameOverSubState extends MusicBeatSubState
     });
   }
 
-  public override function destroy()
+  public override function destroy():Void
   {
     super.destroy();
-    if (gameOverMusic != null) gameOverMusic.stop();
-    gameOverMusic = null;
+    if (gameOverMusic != null)
+    {
+      gameOverMusic.stop();
+      gameOverMusic = null;
+    }
+    blueballed = false;
     instance = null;
   }
 
   public override function toString():String
   {
-    return "GameOverSubState";
+    return 'GameOverSubState';
   }
 }
 
+/**
+ * Parameters used to instantiate a GameOverSubState.
+ */
 typedef GameOverParams =
 {
   var isChartingMode:Bool;
diff --git a/source/funkin/play/PlayState.hx b/source/funkin/play/PlayState.hx
index 984f27c26..43a7d1615 100644
--- a/source/funkin/play/PlayState.hx
+++ b/source/funkin/play/PlayState.hx
@@ -1499,7 +1499,7 @@ class PlayState extends MusicBeatSubState
   public function resetCameraZoom():Void
   {
     // Apply camera zoom level from stage data.
-    defaultCameraZoom = currentStage.camZoom;
+    defaultCameraZoom = currentStage?.camZoom ?? 1.0;
   }
 
   /**
@@ -2712,7 +2712,12 @@ class PlayState extends MusicBeatSubState
 
       if (targetSongId == null)
       {
-        FunkinSound.playMusic('freakyMenu');
+        FunkinSound.playMusic('freakyMenu',
+          {
+            startingVolume: 0.0,
+            overrideExisting: true,
+            restartTrack: false
+          });
 
         // transIn = FlxTransitionableState.defaultTransIn;
         // transOut = FlxTransitionableState.defaultTransOut;
@@ -2993,7 +2998,10 @@ class PlayState extends MusicBeatSubState
    */
   public function resetCamera():Void
   {
-    FlxG.camera.follow(cameraFollowPoint, LOCKON, 0.04);
+    // Apply camera zoom level from stage data.
+    defaultCameraZoom = currentStage?.camZoom ?? 1.0;
+
+    FlxG.camera.follow(cameraFollowPoint, LOCKON, Constants.DEFAULT_CAMERA_FOLLOW_RATE);
     FlxG.camera.targetOffset.set();
     FlxG.camera.zoom = defaultCameraZoom;
     // Snap the camera to the follow point immediately.
diff --git a/source/funkin/play/PlayStatePlaylist.hx b/source/funkin/play/PlayStatePlaylist.hx
index 3b0fb01f6..e47a6288a 100644
--- a/source/funkin/play/PlayStatePlaylist.hx
+++ b/source/funkin/play/PlayStatePlaylist.hx
@@ -5,12 +5,13 @@ package funkin.play;
  *
  * TODO: Add getters/setters for all these properties to validate them.
  */
+@:nullSafety
 class PlayStatePlaylist
 {
   /**
    * Whether the game is currently in Story Mode. If false, we are in Free Play Mode.
    */
-  public static var isStoryMode(default, default):Bool = false;
+  public static var isStoryMode:Bool = false;
 
   /**
    * The loist of upcoming songs to be played.
@@ -31,8 +32,9 @@ class PlayStatePlaylist
 
   /**
    * The internal ID of the current playlist, for example `week4` or `weekend-1`.
+   * @default `null`, used when no playlist is loaded
    */
-  public static var campaignId:String = 'unknown';
+  public static var campaignId:Null<String> = null;
 
   public static var campaignDifficulty:String = Constants.DEFAULT_DIFFICULTY;
 
@@ -45,7 +47,7 @@ class PlayStatePlaylist
     playlistSongIds = [];
     campaignScore = 0;
     campaignTitle = 'UNKNOWN';
-    campaignId = 'unknown';
+    campaignId = null;
     campaignDifficulty = Constants.DEFAULT_DIFFICULTY;
   }
 }
diff --git a/source/funkin/play/stage/Stage.hx b/source/funkin/play/stage/Stage.hx
index 56026469a..e80cbe0ae 100644
--- a/source/funkin/play/stage/Stage.hx
+++ b/source/funkin/play/stage/Stage.hx
@@ -220,7 +220,8 @@ class Stage extends FlxSpriteGroup implements IPlayStateScriptedClass implements
 
       if (propSprite.frames == null || propSprite.frames.numFrames == 0)
       {
-        trace('    ERROR: Could not build texture for prop.');
+        @:privateAccess
+        trace('    ERROR: Could not build texture for prop. Check the asset path (${Paths.currentLevel ?? 'default'}, ${dataProp.assetPath}).');
         continue;
       }
 
diff --git a/source/funkin/ui/debug/charting/ChartEditorState.hx b/source/funkin/ui/debug/charting/ChartEditorState.hx
index c59a5abdb..64ce14d9d 100644
--- a/source/funkin/ui/debug/charting/ChartEditorState.hx
+++ b/source/funkin/ui/debug/charting/ChartEditorState.hx
@@ -6,18 +6,15 @@ import flixel.addons.transition.FlxTransitionableState;
 import flixel.FlxCamera;
 import flixel.FlxSprite;
 import flixel.FlxSubState;
-import flixel.graphics.FlxGraphic;
 import flixel.group.FlxGroup.FlxTypedGroup;
-import funkin.graphics.FunkinCamera;
 import flixel.group.FlxSpriteGroup;
 import flixel.input.keyboard.FlxKey;
+import funkin.play.PlayStatePlaylist;
 import flixel.input.mouse.FlxMouseEvent;
 import flixel.math.FlxMath;
 import flixel.math.FlxPoint;
 import flixel.math.FlxRect;
 import flixel.sound.FlxSound;
-import flixel.system.debug.log.LogStyle;
-import flixel.system.FlxAssets.FlxSoundAsset;
 import flixel.text.FlxText;
 import flixel.tweens.FlxEase;
 import flixel.tweens.FlxTween;
@@ -27,26 +24,19 @@ import flixel.util.FlxSort;
 import flixel.util.FlxTimer;
 import funkin.audio.FunkinSound;
 import funkin.audio.visualize.PolygonSpectogram;
-import funkin.audio.visualize.PolygonSpectogram;
 import funkin.audio.VoicesGroup;
 import funkin.audio.waveform.WaveformSprite;
 import funkin.data.notestyle.NoteStyleRegistry;
 import funkin.data.song.SongData.SongCharacterData;
-import funkin.data.song.SongData.SongCharacterData;
-import funkin.data.song.SongData.SongChartData;
 import funkin.data.song.SongData.SongChartData;
 import funkin.data.song.SongData.SongEventData;
-import funkin.data.song.SongData.SongEventData;
 import funkin.data.song.SongData.SongMetadata;
-import funkin.data.song.SongData.SongMetadata;
-import funkin.data.song.SongData.SongNoteData;
 import funkin.data.song.SongData.SongNoteData;
 import funkin.data.song.SongData.SongOffsets;
 import funkin.data.song.SongDataUtils;
-import funkin.data.song.SongDataUtils;
-import funkin.data.song.SongRegistry;
 import funkin.data.song.SongRegistry;
 import funkin.data.stage.StageData;
+import funkin.graphics.FunkinCamera;
 import funkin.graphics.FunkinSprite;
 import funkin.input.Cursor;
 import funkin.input.TurboKeyHandler;
@@ -62,8 +52,6 @@ import funkin.save.Save;
 import funkin.ui.debug.charting.commands.AddEventsCommand;
 import funkin.ui.debug.charting.commands.AddNotesCommand;
 import funkin.ui.debug.charting.commands.ChartEditorCommand;
-import funkin.ui.debug.charting.commands.ChartEditorCommand;
-import funkin.ui.debug.charting.commands.ChartEditorCommand;
 import funkin.ui.debug.charting.commands.CopyItemsCommand;
 import funkin.ui.debug.charting.commands.CutItemsCommand;
 import funkin.ui.debug.charting.commands.DeselectAllItemsCommand;
@@ -96,6 +84,7 @@ import funkin.ui.debug.charting.toolboxes.ChartEditorOffsetsToolbox;
 import funkin.ui.haxeui.components.CharacterPlayer;
 import funkin.ui.haxeui.HaxeUIState;
 import funkin.ui.mainmenu.MainMenuState;
+import funkin.ui.transition.LoadingState;
 import funkin.util.Constants;
 import funkin.util.FileUtil;
 import funkin.util.logging.CrashHandler;
@@ -120,7 +109,6 @@ import haxe.ui.containers.Grid;
 import haxe.ui.containers.HBox;
 import haxe.ui.containers.menus.Menu;
 import haxe.ui.containers.menus.MenuBar;
-import haxe.ui.containers.menus.MenuBar;
 import haxe.ui.containers.menus.MenuCheckBox;
 import haxe.ui.containers.menus.MenuItem;
 import haxe.ui.containers.ScrollView;
@@ -131,7 +119,6 @@ import haxe.ui.core.Screen;
 import haxe.ui.events.DragEvent;
 import haxe.ui.events.MouseEvent;
 import haxe.ui.events.UIEvent;
-import haxe.ui.events.UIEvent;
 import haxe.ui.focus.FocusManager;
 import haxe.ui.Toolkit;
 import openfl.display.BitmapData;
@@ -5330,30 +5317,31 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
     }
     catch (e)
     {
-      this.error("Could Not Playtest", 'Got an error trying to playtest the song.\n${e}');
+      this.error('Could Not Playtest', 'Got an error trying to playtest the song.\n${e}');
       return;
     }
 
-    // TODO: Rework asset system so we can remove this.
+    // TODO: Rework asset system so we can remove this jank.
     switch (currentSongStage)
     {
       case 'mainStage':
-        Paths.setCurrentLevel('week1');
+        PlayStatePlaylist.campaignId = 'week1';
       case 'spookyMansion':
-        Paths.setCurrentLevel('week2');
+        PlayStatePlaylist.campaignId = 'week2';
       case 'phillyTrain':
-        Paths.setCurrentLevel('week3');
+        PlayStatePlaylist.campaignId = 'week3';
       case 'limoRide':
-        Paths.setCurrentLevel('week4');
+        PlayStatePlaylist.campaignId = 'week4';
       case 'mallXmas' | 'mallEvil':
-        Paths.setCurrentLevel('week5');
+        PlayStatePlaylist.campaignId = 'week5';
       case 'school' | 'schoolEvil':
-        Paths.setCurrentLevel('week6');
+        PlayStatePlaylist.campaignId = 'week6';
       case 'tankmanBattlefield':
-        Paths.setCurrentLevel('week7');
+        PlayStatePlaylist.campaignId = 'week7';
       case 'phillyStreets' | 'phillyBlazin' | 'phillyBlazin2':
-        Paths.setCurrentLevel('weekend1');
+        PlayStatePlaylist.campaignId = 'weekend1';
     }
+    Paths.setCurrentLevel(PlayStatePlaylist.campaignId);
 
     subStateClosed.add(reviveUICamera);
     subStateClosed.add(resetConductorAfterTest);
@@ -5361,7 +5349,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
     FlxTransitionableState.skipNextTransIn = false;
     FlxTransitionableState.skipNextTransOut = false;
 
-    var targetState = new PlayState(
+    var targetStateParams =
       {
         targetSong: targetSong,
         targetDifficulty: selectedDifficulty,
@@ -5372,14 +5360,13 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
         startTimestamp: startTimestamp,
         playbackRate: playbackRate,
         overrideMusic: true,
-      });
+      };
 
     // Override music.
     if (audioInstTrack != null)
     {
       FlxG.sound.music = audioInstTrack;
     }
-    targetState.vocals = audioVocalTrackGroup;
 
     // Kill and replace the UI camera so it doesn't get destroyed during the state transition.
     uiCamera.kill();
@@ -5389,7 +5376,10 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
     this.persistentUpdate = false;
     this.persistentDraw = false;
     stopWelcomeMusic();
-    openSubState(targetState);
+
+    LoadingState.loadPlayState(targetStateParams, false, true, function(targetState) {
+      targetState.vocals = audioVocalTrackGroup;
+    });
   }
 
   /**
diff --git a/source/funkin/ui/freeplay/FreeplayState.hx b/source/funkin/ui/freeplay/FreeplayState.hx
index 7ade5a2a6..068b57a9c 100644
--- a/source/funkin/ui/freeplay/FreeplayState.hx
+++ b/source/funkin/ui/freeplay/FreeplayState.hx
@@ -179,7 +179,7 @@ class FreeplayState extends MusicBeatSubState
 
     #if discord_rpc
     // Updating Discord Rich Presence
-    DiscordClient.changePresence("In the Menus", null);
+    DiscordClient.changePresence('In the Menus', null);
     #end
 
     var isDebug:Bool = false;
@@ -188,14 +188,19 @@ class FreeplayState extends MusicBeatSubState
     isDebug = true;
     #end
 
-    FunkinSound.playMusic('freakyMenu');
+    FunkinSound.playMusic('freakyMenu',
+      {
+        startingVolume: 0.0,
+        overrideExisting: true,
+        restartTrack: false
+      });
 
     // Add a null entry that represents the RANDOM option
     songs.push(null);
 
     // TODO: This makes custom variations disappear from Freeplay. Figure out a better solution later.
     // Default character (BF) shows default and Erect variations. Pico shows only Pico variations.
-    displayedVariations = (currentCharacter == "bf") ? [Constants.DEFAULT_VARIATION, "erect"] : [currentCharacter];
+    displayedVariations = (currentCharacter == 'bf') ? [Constants.DEFAULT_VARIATION, 'erect'] : [currentCharacter];
 
     // programmatically adds the songs via LevelRegistry and SongRegistry
     for (levelId in LevelRegistry.instance.listBaseGameLevelIds())
@@ -205,7 +210,7 @@ class FreeplayState extends MusicBeatSubState
         var song:Song = SongRegistry.instance.fetchEntry(songId);
 
         // Only display songs which actually have available charts for the current character.
-        var availableDifficultiesForSong = song.listDifficulties(displayedVariations);
+        var availableDifficultiesForSong:Array<String> = song.listDifficulties(displayedVariations);
         if (availableDifficultiesForSong.length == 0) continue;
 
         songs.push(new FreeplaySongData(levelId, songId, song, displayedVariations));
@@ -1226,7 +1231,12 @@ class FreeplayState extends MusicBeatSubState
         // TODO: Stream the instrumental of the selected song?
         if (prevSelected == 0)
         {
-          FunkinSound.playMusic('freakyMenu');
+          FunkinSound.playMusic('freakyMenu',
+            {
+              startingVolume: 0.0,
+              overrideExisting: true,
+              restartTrack: false
+            });
           FlxG.sound.music.fadeIn(2, 0, 0.8);
         }
       }
diff --git a/source/funkin/ui/mainmenu/MainMenuState.hx b/source/funkin/ui/mainmenu/MainMenuState.hx
index 1892bdec1..38654dcb8 100644
--- a/source/funkin/ui/mainmenu/MainMenuState.hx
+++ b/source/funkin/ui/mainmenu/MainMenuState.hx
@@ -155,7 +155,12 @@ class MainMenuState extends MusicBeatState
 
   function playMenuMusic():Void
   {
-    FunkinSound.playMusic('freakyMenu');
+    FunkinSound.playMusic('freakyMenu',
+      {
+        startingVolume: 0.0,
+        overrideExisting: true,
+        restartTrack: false
+      });
   }
 
   function resetCamStuff()
diff --git a/source/funkin/ui/story/StoryMenuState.hx b/source/funkin/ui/story/StoryMenuState.hx
index 1f78eb375..82b419373 100644
--- a/source/funkin/ui/story/StoryMenuState.hx
+++ b/source/funkin/ui/story/StoryMenuState.hx
@@ -235,7 +235,12 @@ class StoryMenuState extends MusicBeatState
 
   function playMenuMusic():Void
   {
-    FunkinSound.playMusic('freakyMenu');
+    FunkinSound.playMusic('freakyMenu',
+      {
+        startingVolume: 0.0,
+        overrideExisting: true,
+        restartTrack: false
+      });
   }
 
   function updateData():Void
diff --git a/source/funkin/ui/title/TitleState.hx b/source/funkin/ui/title/TitleState.hx
index 26f6612be..eb4404c78 100644
--- a/source/funkin/ui/title/TitleState.hx
+++ b/source/funkin/ui/title/TitleState.hx
@@ -220,9 +220,14 @@ class TitleState extends MusicBeatState
 
   function playMenuMusic():Void
   {
-    var shouldFadeIn = (FlxG.sound.music == null);
+    var shouldFadeIn:Bool = (FlxG.sound.music == null);
     // Load music. Includes logic to handle BPM changes.
-    FunkinSound.playMusic('freakyMenu', 0.0, false, true);
+    FunkinSound.playMusic('freakyMenu',
+      {
+        startingVolume: 0.0,
+        overrideExisting: true,
+        restartTrack: true
+      });
     // Fade from 0.0 to 0.7 over 4 seconds
     if (shouldFadeIn) FlxG.sound.music.fadeIn(4.0, 0.0, 1.0);
   }
diff --git a/source/funkin/ui/transition/LoadingState.hx b/source/funkin/ui/transition/LoadingState.hx
index 23b3db6a9..304922988 100644
--- a/source/funkin/ui/transition/LoadingState.hx
+++ b/source/funkin/ui/transition/LoadingState.hx
@@ -22,10 +22,12 @@ import openfl.filters.ShaderFilter;
 import openfl.utils.Assets;
 import flixel.util.typeLimit.NextState;
 
-class LoadingState extends MusicBeatState
+class LoadingState extends MusicBeatSubState
 {
   inline static var MIN_TIME = 1.0;
 
+  var asSubState:Bool = false;
+
   var target:NextState;
   var playParams:Null<PlayStateParams>;
   var stopMusic:Bool = false;
@@ -173,7 +175,16 @@ class LoadingState extends MusicBeatState
   {
     if (stopMusic && FlxG.sound.music != null) FlxG.sound.music.stop();
 
-    FlxG.switchState(target);
+    if (asSubState)
+    {
+      this.close();
+      // We will assume the target is a valid substate.
+      FlxG.state.openSubState(cast target);
+    }
+    else
+    {
+      FlxG.switchState(target);
+    }
   }
 
   static function getSongPath():String
@@ -185,17 +196,41 @@ class LoadingState extends MusicBeatState
    * Starts the transition to a new `PlayState` to start a new song.
    * First switches to the `LoadingState` if assets need to be loaded.
    * @param params The parameters for the next `PlayState`.
+   * @param asSubState Whether to open as a substate rather than switching to the `PlayState`.
    * @param shouldStopMusic Whether to stop the current music while loading.
    */
-  public static function loadPlayState(params:PlayStateParams, shouldStopMusic = false):Void
+  public static function loadPlayState(params:PlayStateParams, shouldStopMusic = false, asSubState = false, ?onConstruct:PlayState->Void):Void
   {
     Paths.setCurrentLevel(PlayStatePlaylist.campaignId);
-    var playStateCtor:NextState = () -> new PlayState(params);
+    var playStateCtor:() -> PlayState = function() {
+      return new PlayState(params);
+    };
+
+    if (onConstruct != null)
+    {
+      playStateCtor = function() {
+        var result = new PlayState(params);
+        onConstruct(result);
+        return result;
+      };
+    }
 
     #if NO_PRELOAD_ALL
     // Switch to loading state while we load assets (default on HTML5 target).
-    var loadStateCtor:NextState = () -> new LoadingState(playStateCtor, shouldStopMusic, params);
-    FlxG.switchState(loadStateCtor);
+    var loadStateCtor:NextState = function() {
+      var result = new LoadingState(playStateCtor, shouldStopMusic, params);
+      @:privateAccess
+      result.asSubState = asSubState;
+      return result;
+    }
+    if (asSubState)
+    {
+      FlxG.state.openSubState(loadStateCtor);
+    }
+    else
+    {
+      FlxG.switchState(loadStateCtor);
+    }
     #else
     // All assets preloaded, switch directly to play state (defualt on other targets).
     if (shouldStopMusic && FlxG.sound.music != null)
@@ -209,6 +244,34 @@ class LoadingState extends MusicBeatState
       params.targetSong.cacheCharts(true);
     }
 
+    var shouldPreloadLevelAssets:Bool = !(params?.minimalMode ?? false);
+
+    if (shouldPreloadLevelAssets) preloadLevelAssets();
+
+    if (asSubState)
+    {
+      FlxG.state.openSubState(cast playStateCtor());
+    }
+    else
+    {
+      FlxG.switchState(playStateCtor);
+    }
+    #end
+  }
+
+  #if NO_PRELOAD_ALL
+  static function isSoundLoaded(path:String):Bool
+  {
+    return Assets.cache.hasSound(path);
+  }
+
+  static function isLibraryLoaded(library:String):Bool
+  {
+    return Assets.getLibrary(library) != null;
+  }
+  #else
+  static function preloadLevelAssets():Void
+  {
     // TODO: This section is a hack! Redo this later when we have a proper asset caching system.
     FunkinSprite.preparePurgeCache();
     FunkinSprite.cacheTexture(Paths.image('combo'));
@@ -241,7 +304,10 @@ class LoadingState extends MusicBeatState
     // List all image assets in the level's library.
     // This is crude and I want to remove it when we have a proper asset caching system.
     // TODO: Get rid of this junk!
-    var library = openfl.utils.Assets.getLibrary(PlayStatePlaylist.campaignId);
+    var library = PlayStatePlaylist.campaignId != null ? openfl.utils.Assets.getLibrary(PlayStatePlaylist.campaignId) : null;
+
+    if (library == null) return; // We don't need to do anymore precaching.
+
     var assets = library.list(lime.utils.AssetType.IMAGE);
     trace('Got ${assets.length} assets: ${assets}');
 
@@ -272,20 +338,6 @@ class LoadingState extends MusicBeatState
     // FunkinSprite.cacheAllSongTextures(stage)
 
     FunkinSprite.purgeCache();
-
-    FlxG.switchState(playStateCtor);
-    #end
-  }
-
-  #if NO_PRELOAD_ALL
-  static function isSoundLoaded(path:String):Bool
-  {
-    return Assets.cache.hasSound(path);
-  }
-
-  static function isLibraryLoaded(library:String):Bool
-  {
-    return Assets.getLibrary(library) != null;
   }
   #end
 
diff --git a/source/funkin/util/Constants.hx b/source/funkin/util/Constants.hx
index 1005b312e..8d7800c00 100644
--- a/source/funkin/util/Constants.hx
+++ b/source/funkin/util/Constants.hx
@@ -442,4 +442,10 @@ class Constants
    * The vertical offset of the strumline from the top edge of the screen.
    */
   public static final STRUMLINE_Y_OFFSET:Float = 24;
+
+  /**
+   * The rate at which the camera lerps to its target.
+   * 0.04 = 4% of distance per frame.
+   */
+  public static final DEFAULT_CAMERA_FOLLOW_RATE:Float = 0.04;
 }

From efd0bef89f25467d2d762afada9071435f971617 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Sat, 16 Mar 2024 01:08:20 -0400
Subject: [PATCH 05/50] Remove checkbox that should have been removed earlier.

---
 assets | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/assets b/assets
index 82ee26e1f..fbfaad0c4 160000
--- a/assets
+++ b/assets
@@ -1 +1 @@
-Subproject commit 82ee26e1f733d1b2f30015ae69925e6a39d6526b
+Subproject commit fbfaad0c4e35fbf48937fb7e28e3888587cf16b1

From 1e888658f75eb859f51b698ddde8a5d3af188e8b Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Tue, 26 Mar 2024 12:33:54 -0400
Subject: [PATCH 06/50] First implementation of credits state.

---
 Project.xml                                   |   1 +
 assets                                        |   2 +-
 source/funkin/data/BaseRegistry.hx            |   9 -
 source/funkin/data/JsonFile.hx                |  10 +
 source/funkin/data/song/SongRegistry.hx       |   6 +-
 source/funkin/ui/credits/CreditsData.hx       |  26 +++
 .../funkin/ui/credits/CreditsDataHandler.hx   | 130 ++++++++++++
 source/funkin/ui/credits/CreditsDataMacro.hx  |  67 ++++++
 source/funkin/ui/credits/CreditsState.hx      | 200 ++++++++++++++++++
 source/funkin/ui/mainmenu/MainMenuState.hx    |   7 +
 10 files changed, 445 insertions(+), 13 deletions(-)
 create mode 100644 source/funkin/data/JsonFile.hx
 create mode 100644 source/funkin/ui/credits/CreditsData.hx
 create mode 100644 source/funkin/ui/credits/CreditsDataHandler.hx
 create mode 100644 source/funkin/ui/credits/CreditsDataMacro.hx
 create mode 100644 source/funkin/ui/credits/CreditsState.hx

diff --git a/Project.xml b/Project.xml
index ffc8382a4..5962b9dd8 100644
--- a/Project.xml
+++ b/Project.xml
@@ -175,6 +175,7 @@
 	<haxedef name="haxeui_focus_out_on_click" />
 	<!-- Required to use haxe.ui.backend.flixel.UIState with build macros. -->
 	<haxedef name="haxeui_dont_impose_base_class" />
+	<haxedef name="HARDCODED_CREDITS" />
 
 	<!-- Skip the Intro -->
 	<section if="debug">
diff --git a/assets b/assets
index 5f1726f1b..edccf0421 160000
--- a/assets
+++ b/assets
@@ -1 +1 @@
-Subproject commit 5f1726f1b0c11fc747b7473708cf4e5f28be05f1
+Subproject commit edccf04217c49c730b11c80736e2b2d98a25ee95
diff --git a/source/funkin/data/BaseRegistry.hx b/source/funkin/data/BaseRegistry.hx
index 7419d9425..118516bec 100644
--- a/source/funkin/data/BaseRegistry.hx
+++ b/source/funkin/data/BaseRegistry.hx
@@ -325,12 +325,3 @@ abstract class BaseRegistry<T:(IRegistryEntry<J> & Constructible<EntryConstructo
     }
   }
 }
-
-/**
- * A pair of a file name and its contents.
- */
-typedef JsonFile =
-{
-  fileName:String,
-  contents:String
-};
diff --git a/source/funkin/data/JsonFile.hx b/source/funkin/data/JsonFile.hx
new file mode 100644
index 000000000..421ffc22f
--- /dev/null
+++ b/source/funkin/data/JsonFile.hx
@@ -0,0 +1,10 @@
+package funkin.data;
+
+/**
+ * A pair of a file name and its contents.
+ */
+typedef JsonFile =
+{
+  fileName:String,
+  contents:String
+};
diff --git a/source/funkin/data/song/SongRegistry.hx b/source/funkin/data/song/SongRegistry.hx
index d82e184a5..779a8e618 100644
--- a/source/funkin/data/song/SongRegistry.hx
+++ b/source/funkin/data/song/SongRegistry.hx
@@ -427,7 +427,7 @@ class SongRegistry extends BaseRegistry<Song, SongMetadata>
     return ScriptedSong.listScriptClasses();
   }
 
-  function loadEntryMetadataFile(id:String, ?variation:String):Null<BaseRegistry.JsonFile>
+  function loadEntryMetadataFile(id:String, ?variation:String):Null<JsonFile>
   {
     variation = variation == null ? Constants.DEFAULT_VARIATION : variation;
     var entryFilePath:String = Paths.json('$dataFilePath/$id/$id-metadata${variation == Constants.DEFAULT_VARIATION ? '' : '-$variation'}');
@@ -438,7 +438,7 @@ class SongRegistry extends BaseRegistry<Song, SongMetadata>
     return {fileName: entryFilePath, contents: rawJson};
   }
 
-  function loadMusicDataFile(id:String, ?variation:String):Null<BaseRegistry.JsonFile>
+  function loadMusicDataFile(id:String, ?variation:String):Null<JsonFile>
   {
     variation = variation == null ? Constants.DEFAULT_VARIATION : variation;
     var entryFilePath:String = Paths.file('music/$id/$id-metadata${variation == Constants.DEFAULT_VARIATION ? '' : '-$variation'}.json');
@@ -456,7 +456,7 @@ class SongRegistry extends BaseRegistry<Song, SongMetadata>
     return openfl.Assets.exists(entryFilePath);
   }
 
-  function loadEntryChartFile(id:String, ?variation:String):Null<BaseRegistry.JsonFile>
+  function loadEntryChartFile(id:String, ?variation:String):Null<JsonFile>
   {
     variation = variation == null ? Constants.DEFAULT_VARIATION : variation;
     var entryFilePath:String = Paths.json('$dataFilePath/$id/$id-chart${variation == Constants.DEFAULT_VARIATION ? '' : '-$variation'}');
diff --git a/source/funkin/ui/credits/CreditsData.hx b/source/funkin/ui/credits/CreditsData.hx
new file mode 100644
index 000000000..0f6ea6bcd
--- /dev/null
+++ b/source/funkin/ui/credits/CreditsData.hx
@@ -0,0 +1,26 @@
+package funkin.ui.credits;
+
+/**
+ * The members of the Funkin' Crew, organized by their roles.
+ */
+typedef CreditsData =
+{
+  var roles:Array<CreditsDataRole>;
+}
+
+/**
+ * The members of a specific role on the Funkin' Crew.
+ */
+typedef CreditsDataRole =
+{
+  var roleName:String;
+  var members:Array<CreditsDataMember>;
+}
+
+/**
+ * A member of a specific person on the Funkin' Crew.
+ */
+typedef CreditsDataMember =
+{
+  var fullName:String;
+}
diff --git a/source/funkin/ui/credits/CreditsDataHandler.hx b/source/funkin/ui/credits/CreditsDataHandler.hx
new file mode 100644
index 000000000..6317dd55d
--- /dev/null
+++ b/source/funkin/ui/credits/CreditsDataHandler.hx
@@ -0,0 +1,130 @@
+package funkin.ui.credits;
+
+import funkin.data.JsonFile;
+
+using StringTools;
+
+@:nullSafety
+class CreditsDataHandler
+{
+  public static final BACKER_PUBLIC_URL:String = 'https://funkin.me/backers';
+
+  #if HARDCODED_CREDITS
+  static final CREDITS_DATA_PATH:String = "assets/exclude/data/credits.json";
+  #else
+  static final CREDITS_DATA_PATH:String = "assets/data/credits.json";
+  #end
+
+  public static function debugPrint(data:Null<CreditsData>):Void
+  {
+    if (data == null)
+    {
+      trace('CreditsData(NULL)');
+
+      return;
+    }
+
+    var roleCount = data.roles.length;
+    var memberCount = 0;
+    for (role in data.roles)
+    {
+      memberCount += role.members.length;
+    }
+
+    trace('CreditsData($roleCount roles with $memberCount members)');
+  }
+
+  /**
+   * If for some reason the full credits won't load,
+   * use this hardcoded data for the original Funkin' Crew.
+   *
+   * @return `CreditsData`
+   */
+  public static inline function getFallback():CreditsData
+  {
+    return {
+      roles: [
+        {
+          roleName: 'Founders',
+          members: [
+            {fullName: 'ninjamuffin99'},
+            {fullName: 'PhantomArcade'},
+            {fullName: 'KawaiSprite'},
+            {fullName: 'evilsk8r'},
+          ]
+        }
+      ]
+    };
+  }
+
+  public static function fetchBackerEntries():Array<String>
+  {
+    // TODO: Replace this with a web request.
+    // We can't just grab the current Kickstarter data and include it in builds,
+    // because we don't want to deadname people who haven't logged into the portal yet.
+    // It can be async and paginated for performance!
+    return ['See the list of backers at $BACKER_PUBLIC_URL.'];
+  }
+
+  #if HARDCODED_CREDITS
+  /**
+   * The data for the credits.
+   * Hardcoded into game via a macro at compile time.
+   */
+  public static final CREDITS_DATA:Null<CreditsData> = #if macro null #else CreditsDataMacro.loadCreditsData() #end;
+  #else
+
+  /**
+   * The data for the credits.
+   * Loaded dynamically from the game folder when needed.
+   * Nullable because data may fail to parse.
+   */
+  public static var CREDITS_DATA(get, default):Null<CreditsData> = null;
+
+  static function get_CREDITS_DATA():Null<CreditsData>
+  {
+    if (CREDITS_DATA == null) CREDITS_DATA = parseCreditsData(fetchCreditsData());
+
+    return CREDITS_DATA;
+  }
+
+  static function fetchCreditsData():funkin.data.JsonFile
+  {
+    var rawJson:String = openfl.Assets.getText(CREDITS_DATA_PATH).trim();
+
+    return {
+      fileName: CREDITS_DATA_PATH,
+      contents: rawJson
+    };
+  }
+
+  static function parseCreditsData(file:JsonFile):Null<CreditsData>
+  {
+    #if !macro
+    if (file.contents == null) return null;
+
+    var parser = new json2object.JsonParser<CreditsData>();
+    parser.ignoreUnknownVariables = false;
+    trace('[CREDITS] Parsing credits data from ${CREDITS_DATA_PATH}');
+    parser.fromJson(file.contents, file.fileName);
+
+    if (parser.errors.length > 0)
+    {
+      printErrors(parser.errors, file.fileName);
+      return null;
+    }
+    return parser.value;
+    #else
+    return null;
+    #end
+  }
+
+  static function printErrors(errors:Array<json2object.Error>, id:String = ''):Void
+  {
+    trace('[CREDITS] Failed to parse credits data: ${id}');
+
+    for (error in errors)
+      funkin.data.DataError.printError(error);
+  }
+  #end
+}
diff --git a/source/funkin/ui/credits/CreditsDataMacro.hx b/source/funkin/ui/credits/CreditsDataMacro.hx
new file mode 100644
index 000000000..c97770eef
--- /dev/null
+++ b/source/funkin/ui/credits/CreditsDataMacro.hx
@@ -0,0 +1,67 @@
+package funkin.ui.credits;
+
+#if macro
+import haxe.macro.Context;
+#end
+
+@:access(funkin.ui.credits.CreditsDataHandler)
+class CreditsDataMacro
+{
+  public static macro function loadCreditsData():haxe.macro.Expr.ExprOf<CreditsData>
+  {
+    #if !display
+    trace('Hardcoding credits data...');
+    var json = CreditsDataMacro.fetchJSON();
+
+    if (json == null)
+    {
+      Context.info('[WARN] Could not fetch JSON data for credits.', Context.currentPos());
+      return macro $v{CreditsDataHandler.getFallback()};
+    }
+
+    var creditsData = CreditsDataMacro.parseJSON(json);
+
+    if (creditsData == null)
+    {
+      Context.info('[WARN] Could not parse JSON data for credits.', Context.currentPos());
+      return macro $v{CreditsDataHandler.getFallback()};
+    }
+
+    CreditsDataHandler.debugPrint(creditsData);
+    return macro $v{creditsData};
+    // return macro $v{null};
+    #else
+    // `#if display` is used for code completion. In this case we return
+    // a minimal value to keep code completion fast.
+    return macro $v{CreditsDataHandler.getFallback()};
+    #end
+  }
+
+  #if macro
+  static function fetchJSON():Null<String>
+  {
+    return sys.io.File.getContent(CreditsDataHandler.CREDITS_DATA_PATH);
+  }
+
+  /**
+   * Parse the JSON data for the credits.
+   *
+   * @param json The string data to parse.
+   * @return The parsed data.
+   */
+  static function parseJSON(json:String):Null<CreditsData>
+  {
+    try
+    {
+      // TODO: Use something with better validation but that still works at macro time.
+      return haxe.Json.parse(json);
+    }
+    catch (e)
+    {
+      trace('[ERROR] Failed to parse JSON data for credits.');
+      trace(e);
+      return null;
+    }
+  }
+  #end
+}
diff --git a/source/funkin/ui/credits/CreditsState.hx b/source/funkin/ui/credits/CreditsState.hx
new file mode 100644
index 000000000..1e5965695
--- /dev/null
+++ b/source/funkin/ui/credits/CreditsState.hx
@@ -0,0 +1,200 @@
+package funkin.ui.credits;
+
+import flixel.text.FlxText;
+import flixel.util.FlxColor;
+import funkin.audio.FunkinSound;
+import flixel.FlxSprite;
+import flixel.group.FlxSpriteGroup;
+
+/**
+ * The state used to display the credits scroll.
+ * AAA studios often fail to credit properly, and we're better than them!
+ */
+class CreditsState extends MusicBeatState
+{
+  /**
+   * The height the credits should start at.
+   * Make this an instanced variable so it gets set by the constructor.
+   */
+  final STARTING_HEIGHT = FlxG.height;
+
+  /**
+   * The padding on each side of the screen.
+   */
+  static final SCREEN_PAD = 24;
+
+  /**
+   * The width of the screen the credits should maximally fill up.
+   * Make this an instanced variable so it gets set by the constructor.
+   */
+  final FULL_WIDTH = FlxG.width - (SCREEN_PAD * 2);
+
+  /**
+   * The font to use to display the text.
+   * To use a font from the `assets` folder, use `Paths.font(...)`.
+   * Choose something that will render Unicode properly.
+   */
+  static final CREDITS_FONT = 'Arial';
+
+  /**
+   * The size of the font.
+   */
+  static final CREDITS_FONT_SIZE = 48;
+
+  static final CREDITS_HEADER_FONT_SIZE = 72;
+
+  /**
+   * The color of the text itself.
+   */
+  static final CREDITS_FONT_COLOR = FlxColor.WHITE;
+
+  /**
+   * The color of the text's outline.
+   */
+  static final CREDITS_FONT_STROKE_COLOR = FlxColor.BLACK;
+
+  /**
+   * The speed the credits scroll at, in pixels per second.
+   */
+  static final CREDITS_SCROLL_BASE_SPEED = 25.0;
+
+  /**
+   * The speed the credits scroll at while the button is held, in pixels per second.
+   */
+  static final CREDITS_SCROLL_FAST_SPEED = CREDITS_SCROLL_BASE_SPEED * 4.0;
+
+  /**
+   * The actual sprites and text used to display the credits.
+   */
+  var creditsGroup:FlxSpriteGroup;
+
+  var scrollPaused:Bool = false;
+
+  public function new()
+  {
+    super();
+  }
+
+  public override function create():Void
+  {
+    super.create();
+
+    // Background
+    var bg = new FlxSprite(Paths.image('menuDesat'));
+    bg.scrollFactor.x = 0;
+    bg.scrollFactor.y = 0;
+    bg.setGraphicSize(Std.int(FlxG.width));
+    bg.updateHitbox();
+    bg.x = 0;
+    bg.y = 0;
+    bg.visible = true;
+    bg.color = 0xFFB57EDC; // Lavender
+    add(bg);
+
+    // TODO: Once we need to display Kickstarter backers,
+    // make this use a recycled pool so we don't kill peformance.
+    creditsGroup = new FlxSpriteGroup();
+    creditsGroup.x = SCREEN_PAD;
+    creditsGroup.y = STARTING_HEIGHT;
+
+    buildCreditsGroup();
+
+    add(creditsGroup);
+
+    // Music
+    FunkinSound.playMusic('freeplayRandom',
+      {
+        startingVolume: 0.0,
+        overrideExisting: true,
+        restartTrack: true,
+        loop: true
+      });
+    FlxG.sound.music.fadeIn(2, 0, 0.8);
+  }
+
+  function buildCreditsGroup():Void
+  {
+    var y = 0;
+
+    for (role in CreditsDataHandler.CREDITS_DATA.roles)
+    {
+      creditsGroup.add(buildCreditsLine(role.roleName, y, true, CreditsSide.Center));
+      y += CREDITS_HEADER_FONT_SIZE;
+
+      for (member in role.members)
+      {
+        creditsGroup.add(buildCreditsLine(member.fullName, y, false, CreditsSide.Center));
+        y += CREDITS_FONT_SIZE;
+      }
+
+      // Padding between each role.
+      y += CREDITS_FONT_SIZE * 2;
+    }
+  }
+
+  function buildCreditsLine(text:String, yPos:Float, header:Bool, side:CreditsSide = CreditsSide.Center):FlxText
+  {
+    // CreditsSide.Center: Full screen width
+    // CreditsSide.Left: Left half of screen
+    // CreditsSide.Right: Right half of screen
+    var xPos = (side == CreditsSide.Right) ? (FULL_WIDTH / 2) : 0;
+    var width = (side == CreditsSide.Center) ? FULL_WIDTH : (FULL_WIDTH / 2);
+    var size = header ? CREDITS_HEADER_FONT_SIZE : CREDITS_FONT_SIZE;
+
+    var creditsLine:FlxText = new FlxText(xPos, yPos, width, text);
+    creditsLine.setFormat(CREDITS_FONT, size, CREDITS_FONT_COLOR, FlxTextAlign.CENTER, FlxTextBorderStyle.OUTLINE, CREDITS_FONT_STROKE_COLOR, true);
+
+    return creditsLine;
+  }
+
+  public override function update(elapsed:Float):Void
+  {
+    super.update(elapsed);
+
+    if (!scrollPaused)
+    {
+      // TODO: Replace with whatever the special note button is.
+      if (controls.ACCEPT || FlxG.keys.pressed.SPACE)
+      {
+        // Move the whole group.
+        creditsGroup.y -= CREDITS_SCROLL_FAST_SPEED * elapsed;
+      }
+      else
+      {
+        // Move the whole group.
+        creditsGroup.y -= CREDITS_SCROLL_BASE_SPEED * elapsed;
+      }
+    }
+
+    if (controls.BACK || hasEnded())
+    {
+      exit();
+    }
+    else if (controls.PAUSE)
+    {
+      scrollPaused = !scrollPaused;
+    }
+  }
+
+  function hasEnded():Bool
+  {
+    return creditsGroup.y < -creditsGroup.height;
+  }
+
+  function exit():Void
+  {
+    FlxG.switchState(new funkin.ui.mainmenu.MainMenuState());
+  }
+
+  public override function destroy():Void
+  {
+    super.destroy();
+  }
+}
+
+enum CreditsSide
+{
+  Left;
+  Center;
+  Right;
+}
diff --git a/source/funkin/ui/mainmenu/MainMenuState.hx b/source/funkin/ui/mainmenu/MainMenuState.hx
index a8c2039ab..e536554d0 100644
--- a/source/funkin/ui/mainmenu/MainMenuState.hx
+++ b/source/funkin/ui/mainmenu/MainMenuState.hx
@@ -117,6 +117,10 @@ class MainMenuState extends MusicBeatState
       startExitState(() -> new funkin.ui.options.OptionsState());
     });
 
+    createMenuItem('options', 'mainmenu/options', function() {
+      startExitState(() -> new funkin.ui.credits.CreditsState());
+    });
+
     // Reset position of menu items.
     var spacing = 160;
     var top = (FlxG.height - (spacing * (menuItems.length - 1))) / 2;
@@ -125,6 +129,9 @@ class MainMenuState extends MusicBeatState
       var menuItem = menuItems.members[i];
       menuItem.x = FlxG.width / 2;
       menuItem.y = top + spacing * i;
+      menuItem.scrollFactor.x = 0.0;
+      // This one affects how much the menu items move when you scroll between them.
+      menuItem.scrollFactor.y = 0.4;
     }
 
     resetCamStuff();

From f1811a8594c79f22447af3a62a81fdfb755a9217 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Wed, 27 Mar 2024 17:42:29 -0400
Subject: [PATCH 07/50] Implement Pico pause music

---
 assets                              | 2 +-
 source/funkin/play/PauseSubState.hx | 2 +-
 2 files changed, 2 insertions(+), 2 deletions(-)

diff --git a/assets b/assets
index 8013845e3..8ee8c3bd9 160000
--- a/assets
+++ b/assets
@@ -1 +1 @@
-Subproject commit 8013845e331015b40c4cc35230f6d02bd2148d52
+Subproject commit 8ee8c3bd9a828ced8e89c7c924842e859fea9a8c
diff --git a/source/funkin/play/PauseSubState.hx b/source/funkin/play/PauseSubState.hx
index f16aa00d8..ed847402a 100644
--- a/source/funkin/play/PauseSubState.hx
+++ b/source/funkin/play/PauseSubState.hx
@@ -230,7 +230,7 @@ class PauseSubState extends MusicBeatSubState
    */
   function startPauseMusic():Void
   {
-    var pauseMusicPath:String = Paths.music('breakfast$musicSuffix');
+    var pauseMusicPath:String = Paths.music('breakfast$musicSuffix/breakfast$musicSuffix');
     pauseMusic = FunkinSound.load(pauseMusicPath, true, true);
 
     if (pauseMusic == null)

From 195f366b6571b5a2dc99d6b2058a6b87b9c65a64 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Wed, 27 Mar 2024 17:43:15 -0400
Subject: [PATCH 08/50] Fix an issue where the Random button would crash
 Freeplay.

---
 source/funkin/audio/FunkinSound.hx | 6 ++++++
 1 file changed, 6 insertions(+)

diff --git a/source/funkin/audio/FunkinSound.hx b/source/funkin/audio/FunkinSound.hx
index 6520ff27f..56b36d5df 100644
--- a/source/funkin/audio/FunkinSound.hx
+++ b/source/funkin/audio/FunkinSound.hx
@@ -399,10 +399,16 @@ class FunkinSound extends FlxSound implements ICloneable<FunkinSound>
     return sound;
   }
 
+  @:nullSafety(Off)
   public override function destroy():Void
   {
     // trace('[FunkinSound] Destroying sound "${this._label}"');
     super.destroy();
+    if (fadeTween != null)
+    {
+      fadeTween.cancel();
+      fadeTween = null;
+    }
     FlxTween.cancelTweensOf(this);
     this._label = 'unknown';
   }

From f4dd11e2a976951355a047d97695d00be9f42b29 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Wed, 27 Mar 2024 18:44:59 -0400
Subject: [PATCH 09/50] Update more game audio.

---
 assets | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/assets b/assets
index 8ee8c3bd9..46cbc7524 160000
--- a/assets
+++ b/assets
@@ -1 +1 @@
-Subproject commit 8ee8c3bd9a828ced8e89c7c924842e859fea9a8c
+Subproject commit 46cbc752477fd973960efc658a3c62fd57007afe

From 8fc7e82c61cbbcc3aa0b3957b53e449cf7e49e4d Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Wed, 27 Mar 2024 22:50:53 -0400
Subject: [PATCH 10/50] Update assets submodule

---
 assets | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/assets b/assets
index 46cbc7524..be892870e 160000
--- a/assets
+++ b/assets
@@ -1 +1 @@
-Subproject commit 46cbc752477fd973960efc658a3c62fd57007afe
+Subproject commit be892870e4d4397240d0b47275c4a8259583a942

From b771b46f1cb0fca132355daa09a685022028ac76 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Thu, 28 Mar 2024 00:01:18 -0400
Subject: [PATCH 11/50] Fix a build issue.

---
 source/funkin/audio/FunkinSound.hx | 6 +++++-
 1 file changed, 5 insertions(+), 1 deletion(-)

diff --git a/source/funkin/audio/FunkinSound.hx b/source/funkin/audio/FunkinSound.hx
index 92f910335..51533f371 100644
--- a/source/funkin/audio/FunkinSound.hx
+++ b/source/funkin/audio/FunkinSound.hx
@@ -337,7 +337,11 @@ class FunkinSound extends FlxSound implements ICloneable<FunkinSound>
       FlxG.sound.music.kill();
     }
 
-    FlxG.sound.music = FunkinSound.load(Paths.music('$key/$key'), params?.startingVolume ?? 1.0, true, false, true);
+    // Apparently HaxeFlixel isn't null safe.
+    @:nullSafety(Off)
+    {
+      FlxG.sound.music = FunkinSound.load(Paths.music('$key/$key'), params?.startingVolume ?? 1.0, true, false, true);
+    }
 
     var music = FunkinSound.load(Paths.music('$key/$key'), params?.startingVolume ?? 1.0, params.loop ?? true, false, true);
     if (music != null)

From 893822532e8897926cf7283043e4131a0066995f Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Thu, 28 Mar 2024 00:01:42 -0400
Subject: [PATCH 12/50] Fix an issue causing the Kickstarter video to not play

---
 source/funkin/ui/title/AttractState.hx | 4 +++-
 1 file changed, 3 insertions(+), 1 deletion(-)

diff --git a/source/funkin/ui/title/AttractState.hx b/source/funkin/ui/title/AttractState.hx
index 0af97afd9..a42a6c3d9 100644
--- a/source/funkin/ui/title/AttractState.hx
+++ b/source/funkin/ui/title/AttractState.hx
@@ -17,7 +17,7 @@ import funkin.ui.MusicBeatState;
  */
 class AttractState extends MusicBeatState
 {
-  static final ATTRACT_VIDEO_PATH:String = Paths.videos('kickstarterTrailer');
+  static final ATTRACT_VIDEO_PATH:String = Paths.stripLibrary(Paths.videos('kickstarterTrailer', 'shared'));
 
   public override function create():Void
   {
@@ -29,10 +29,12 @@ class AttractState extends MusicBeatState
     }
 
     #if html5
+    trace('Playing web video ${ATTRACT_VIDEO_PATH}');
     playVideoHTML5(ATTRACT_VIDEO_PATH);
     #end
 
     #if hxCodec
+    trace('Playing native video ${ATTRACT_VIDEO_PATH}');
     playVideoNative(ATTRACT_VIDEO_PATH);
     #end
   }

From b13c6563dff894a969a3129faaae8212e5a29f19 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Thu, 28 Mar 2024 01:46:50 -0400
Subject: [PATCH 13/50] Fix issues with Freeplay OST text and add animations.

---
 assets                                     |  2 +-
 source/funkin/data/freeplay/AlbumData.hx   |  9 +++++
 source/funkin/ui/freeplay/Album.hx         | 11 ++++++
 source/funkin/ui/freeplay/AlbumRoll.hx     | 39 +++++++++++++++++++---
 source/funkin/ui/freeplay/FreeplayState.hx | 19 ++++++++---
 5 files changed, 71 insertions(+), 9 deletions(-)

diff --git a/assets b/assets
index 8013845e3..92bd680af 160000
--- a/assets
+++ b/assets
@@ -1 +1 @@
-Subproject commit 8013845e331015b40c4cc35230f6d02bd2148d52
+Subproject commit 92bd680af3c627211656205dac85f9de5ae5abee
diff --git a/source/funkin/data/freeplay/AlbumData.hx b/source/funkin/data/freeplay/AlbumData.hx
index 265a01fce..ca851376d 100644
--- a/source/funkin/data/freeplay/AlbumData.hx
+++ b/source/funkin/data/freeplay/AlbumData.hx
@@ -1,5 +1,7 @@
 package funkin.data.freeplay;
 
+import funkin.data.animation.AnimationData;
+
 /**
  * A type definition for the data for an album of songs.
  * It includes things like what graphics to display in Freeplay.
@@ -33,4 +35,11 @@ typedef AlbumData =
    * The album title will be displayed below the album art in Freeplay.
    */
   public var albumTitleAsset:String;
+
+  /**
+   * An optional array of animations for the album title.
+   */
+  @:optional
+  @:default([])
+  public var albumTitleAnimations:Array<AnimationData>;
 }
diff --git a/source/funkin/ui/freeplay/Album.hx b/source/funkin/ui/freeplay/Album.hx
index 7291c7357..3060d3eb8 100644
--- a/source/funkin/ui/freeplay/Album.hx
+++ b/source/funkin/ui/freeplay/Album.hx
@@ -1,6 +1,7 @@
 package funkin.ui.freeplay;
 
 import funkin.data.freeplay.AlbumData;
+import funkin.data.animation.AnimationData;
 import funkin.data.freeplay.AlbumRegistry;
 import funkin.data.IRegistryEntry;
 import flixel.graphics.FlxGraphic;
@@ -75,6 +76,16 @@ class Album implements IRegistryEntry<AlbumData>
     return _data.albumTitleAsset;
   }
 
+  public function hasAlbumTitleAnimations()
+  {
+    return _data.albumTitleAnimations.length > 0;
+  }
+
+  public function getAlbumTitleAnimations():Array<AnimationData>
+  {
+    return _data.albumTitleAnimations;
+  }
+
   public function toString():String
   {
     return 'Album($id)';
diff --git a/source/funkin/ui/freeplay/AlbumRoll.hx b/source/funkin/ui/freeplay/AlbumRoll.hx
index a1e63c9a1..bde946e79 100644
--- a/source/funkin/ui/freeplay/AlbumRoll.hx
+++ b/source/funkin/ui/freeplay/AlbumRoll.hx
@@ -7,6 +7,7 @@ import flixel.tweens.FlxTween;
 import flixel.util.FlxTimer;
 import flixel.tweens.FlxEase;
 import funkin.data.freeplay.AlbumRegistry;
+import funkin.util.assets.FlxAnimationUtil;
 import funkin.graphics.FunkinSprite;
 import funkin.util.SortUtil;
 import openfl.utils.Assets;
@@ -21,9 +22,9 @@ class AlbumRoll extends FlxSpriteGroup
    * The ID of the album to display.
    * Modify this value to automatically update the album art and title.
    */
-  public var albumId(default, set):String;
+  public var albumId(default, set):Null<String>;
 
-  function set_albumId(value:String):String
+  function set_albumId(value:Null<String>):Null<String>
   {
     if (this.albumId != value)
     {
@@ -65,6 +66,17 @@ class AlbumRoll extends FlxSpriteGroup
    */
   function updateAlbum():Void
   {
+    if (albumId == null)
+    {
+      albumArt.visible = false;
+      albumTitle.visible = false;
+      if (titleTimer != null)
+      {
+        titleTimer.cancel();
+        titleTimer = null;
+      }
+    }
+
     albumData = AlbumRegistry.instance.fetchEntry(albumId);
 
     if (albumData == null)
@@ -94,7 +106,15 @@ class AlbumRoll extends FlxSpriteGroup
 
     if (Assets.exists(Paths.image(albumData.getAlbumTitleAssetKey())))
     {
-      albumTitle.loadGraphic(Paths.image(albumData.getAlbumTitleAssetKey()));
+      if (albumData.hasAlbumTitleAnimations())
+      {
+        albumTitle.loadSparrow(albumData.getAlbumTitleAssetKey());
+        FlxAnimationUtil.addAtlasAnimations(albumTitle, albumData.getAlbumTitleAnimations());
+      }
+      else
+      {
+        albumTitle.loadGraphic(Paths.image(albumData.getAlbumTitleAssetKey()));
+      }
     }
     else
     {
@@ -155,6 +175,8 @@ class AlbumRoll extends FlxSpriteGroup
       });
   }
 
+  var titleTimer:Null<FlxTimer> = null;
+
   /**
    * Play the intro animation on the album art.
    */
@@ -164,7 +186,14 @@ class AlbumRoll extends FlxSpriteGroup
     FlxTween.tween(albumArt, {x: 950, y: 320, angle: -340}, 0.5, {ease: FlxEase.elasticOut});
 
     albumTitle.visible = false;
-    new FlxTimer().start(0.75, function(_) {
+
+    if (titleTimer != null)
+    {
+      titleTimer.cancel();
+      titleTimer = null;
+    }
+
+    titleTimer = new FlxTimer().start(0.75, function(_) {
       showTitle();
     });
   }
@@ -179,6 +208,8 @@ class AlbumRoll extends FlxSpriteGroup
   public function showTitle():Void
   {
     albumTitle.visible = true;
+    albumTitle.animation.play('active');
+    albumTitle.animation.finishCallback = (_) -> albumTitle.animation.play('idle');
   }
 
   /**
diff --git a/source/funkin/ui/freeplay/FreeplayState.hx b/source/funkin/ui/freeplay/FreeplayState.hx
index f7554197f..6dd96b36d 100644
--- a/source/funkin/ui/freeplay/FreeplayState.hx
+++ b/source/funkin/ui/freeplay/FreeplayState.hx
@@ -380,7 +380,7 @@ class FreeplayState extends MusicBeatSubState
     }
 
     albumRoll = new AlbumRoll();
-    albumRoll.albumId = 'volume1';
+    albumRoll.albumId = null;
     add(albumRoll);
 
     albumRoll.applyExitMovers(exitMovers);
@@ -881,6 +881,8 @@ class FreeplayState extends MusicBeatSubState
 
         for (spr in grpSpr)
         {
+          if (spr == null) continue;
+
           var funnyMoveShit:MoveData = moveData;
 
           if (moveData.x == null) funnyMoveShit.x = spr.x;
@@ -1019,7 +1021,7 @@ class FreeplayState extends MusicBeatSubState
     albumRoll.setDifficultyStars(daSong?.songRating);
 
     // Set the album graphic and play the animation if relevant.
-    var newAlbumId:String = daSong?.albumId ?? Constants.DEFAULT_ALBUM_ID;
+    var newAlbumId:String = daSong?.albumId;
     if (albumRoll.albumId != newAlbumId)
     {
       albumRoll.albumId = newAlbumId;
@@ -1162,6 +1164,7 @@ class FreeplayState extends MusicBeatSubState
       intendedCompletion = 0.0;
       rememberedSongId = null;
       rememberedDifficulty = null;
+      albumRoll.albumId = null;
     }
 
     for (index => capsule in grpCapsules.members)
@@ -1311,7 +1314,7 @@ class FreeplaySongData
   public var songName(default, null):String = '';
   public var songCharacter(default, null):String = '';
   public var songRating(default, null):Int = 0;
-  public var albumId(default, null):String = '';
+  public var albumId(default, null):Null<String> = null;
 
   public var currentDifficulty(default, set):String = Constants.DEFAULT_DIFFICULTY;
   public var displayedVariations(default, null):Array<String> = [Constants.DEFAULT_VARIATION];
@@ -1345,7 +1348,15 @@ class FreeplaySongData
     this.songName = songDifficulty.songName;
     this.songCharacter = songDifficulty.characters.opponent;
     this.songRating = songDifficulty.difficultyRating;
-    this.albumId = songDifficulty.album;
+    if (songDifficulty.album == null)
+    {
+      FlxG.log.warn('No album for: ${songDifficulty.songName}');
+      this.albumId = Constants.DEFAULT_ALBUM_ID;
+    }
+    else
+    {
+      this.albumId = songDifficulty.album;
+    }
   }
 }
 

From 5311b043ac81b2b38acbc5e57a24590f537b0a4a Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Thu, 28 Mar 2024 02:57:22 -0400
Subject: [PATCH 14/50] Rework credits data structure.

---
 source/funkin/ui/credits/CreditsData.hx       | 16 ++++++++---
 .../funkin/ui/credits/CreditsDataHandler.hx   | 28 +++++++++++--------
 source/funkin/ui/credits/CreditsState.hx      | 25 +++++++++++++----
 3 files changed, 47 insertions(+), 22 deletions(-)

diff --git a/source/funkin/ui/credits/CreditsData.hx b/source/funkin/ui/credits/CreditsData.hx
index 0f6ea6bcd..bf7f13ad5 100644
--- a/source/funkin/ui/credits/CreditsData.hx
+++ b/source/funkin/ui/credits/CreditsData.hx
@@ -5,7 +5,7 @@ package funkin.ui.credits;
  */
 typedef CreditsData =
 {
-  var roles:Array<CreditsDataRole>;
+  var entries:Array<CreditsDataRole>;
 }
 
 /**
@@ -13,8 +13,16 @@ typedef CreditsData =
  */
 typedef CreditsDataRole =
 {
-  var roleName:String;
-  var members:Array<CreditsDataMember>;
+  @:optional
+  var header:String;
+
+  @:optional
+  @:default([])
+  var body:Array<CreditsDataMember>;
+
+  @:optional
+  @:default(false)
+  var appendBackers:Bool;
 }
 
 /**
@@ -22,5 +30,5 @@ typedef CreditsDataRole =
  */
 typedef CreditsDataMember =
 {
-  var fullName:String;
+  var line:String;
 }
diff --git a/source/funkin/ui/credits/CreditsDataHandler.hx b/source/funkin/ui/credits/CreditsDataHandler.hx
index 6317dd55d..f2722ffbf 100644
--- a/source/funkin/ui/credits/CreditsDataHandler.hx
+++ b/source/funkin/ui/credits/CreditsDataHandler.hx
@@ -24,14 +24,14 @@ class CreditsDataHandler
       return;
     }
 
-    var roleCount = data.roles.length;
-    var memberCount = 0;
-    for (role in data.roles)
+    var entryCount = data.entries.length;
+    var lineCount = 0;
+    for (entry in data.entries)
     {
-      memberCount += role.members.length;
+      lineCount += entry?.body?.length ?? 0;
     }
 
-    trace('CreditsData($roleCount roles with $memberCount members)');
+    trace('CreditsData($entryCount entries containing $lineCount lines)');
   }
 
   /**
@@ -43,15 +43,19 @@ class CreditsDataHandler
   public static inline function getFallback():CreditsData
   {
     return {
-      roles: [
+      entries: [
         {
-          roleName: 'Founders',
-          members: [
-            {fullName: 'ninjamuffin99'},
-            {fullName: 'PhantomArcade'},
-            {fullName: 'KawaiSprite'},
-            {fullName: 'evilsk8r'},
+          header: 'Founders',
+          body: [
+            {line: 'ninjamuffin99'},
+            {line: 'PhantomArcade'},
+            {line: 'KawaiSprite'},
+            {line: 'evilsk8r'},
           ]
+        },
+        {
+          header: 'Kickstarter Backers',
+          appendBackers: true
         }
       ]
     };
diff --git a/source/funkin/ui/credits/CreditsState.hx b/source/funkin/ui/credits/CreditsState.hx
index 1e5965695..d43e25114 100644
--- a/source/funkin/ui/credits/CreditsState.hx
+++ b/source/funkin/ui/credits/CreditsState.hx
@@ -116,17 +116,30 @@ class CreditsState extends MusicBeatState
   {
     var y = 0;
 
-    for (role in CreditsDataHandler.CREDITS_DATA.roles)
+    for (entry in CreditsDataHandler.CREDITS_DATA.entries)
     {
-      creditsGroup.add(buildCreditsLine(role.roleName, y, true, CreditsSide.Center));
-      y += CREDITS_HEADER_FONT_SIZE;
-
-      for (member in role.members)
+      if (entry.header != null)
       {
-        creditsGroup.add(buildCreditsLine(member.fullName, y, false, CreditsSide.Center));
+        creditsGroup.add(buildCreditsLine(entry.header, y, true, CreditsSide.Center));
+        y += CREDITS_HEADER_FONT_SIZE;
+      }
+
+      for (line in entry?.body ?? [])
+      {
+        creditsGroup.add(buildCreditsLine(line.line, y, false, CreditsSide.Center));
         y += CREDITS_FONT_SIZE;
       }
 
+      if (entry.appendBackers)
+      {
+        var backers = CreditsDataHandler.fetchBackerEntries();
+        for (backer in backers)
+        {
+          creditsGroup.add(buildCreditsLine(backer, y, false, CreditsSide.Center));
+          y += CREDITS_FONT_SIZE;
+        }
+      }
+
       // Padding between each role.
       y += CREDITS_FONT_SIZE * 2;
     }

From 3a86b47292b88674dae074f22d8b5d37b9671348 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Thu, 28 Mar 2024 02:57:51 -0400
Subject: [PATCH 15/50] Fix issue where main menu music wouldn't play after
 credits.

---
 source/funkin/ui/mainmenu/MainMenuState.hx | 17 +++++++++++------
 1 file changed, 11 insertions(+), 6 deletions(-)

diff --git a/source/funkin/ui/mainmenu/MainMenuState.hx b/source/funkin/ui/mainmenu/MainMenuState.hx
index e536554d0..02632628f 100644
--- a/source/funkin/ui/mainmenu/MainMenuState.hx
+++ b/source/funkin/ui/mainmenu/MainMenuState.hx
@@ -51,10 +51,7 @@ class MainMenuState extends MusicBeatState
     transIn = FlxTransitionableState.defaultTransIn;
     transOut = FlxTransitionableState.defaultTransOut;
 
-    if (!(FlxG?.sound?.music?.playing ?? false))
-    {
-      playMenuMusic();
-    }
+    playMenuMusic();
 
     persistentUpdate = persistentDraw = true;
 
@@ -109,15 +106,18 @@ class MainMenuState extends MusicBeatState
     });
 
     #if CAN_OPEN_LINKS
+    // In order to prevent popup blockers from triggering,
+    // we need to open the link as an immediate result of a keypress event,
+    // so we can't wait for the flicker animation to complete.
     var hasPopupBlocker = #if web true #else false #end;
-    createMenuItem('donate', 'mainmenu/donate', selectDonate, hasPopupBlocker);
+    createMenuItem('merch', 'mainmenu/merch', selectMerch, hasPopupBlocker);
     #end
 
     createMenuItem('options', 'mainmenu/options', function() {
       startExitState(() -> new funkin.ui.options.OptionsState());
     });
 
-    createMenuItem('options', 'mainmenu/options', function() {
+    createMenuItem('credits', 'mainmenu/credits', function() {
       startExitState(() -> new funkin.ui.credits.CreditsState());
     });
 
@@ -219,6 +219,11 @@ class MainMenuState extends MusicBeatState
   {
     WindowUtil.openURL(Constants.URL_ITCH);
   }
+
+  function selectMerch()
+  {
+    WindowUtil.openURL(Constants.URL_MERCH);
+  }
   #end
 
   #if newgrounds

From 3d14024fd8747d704910570f66a3592a8042e520 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Thu, 28 Mar 2024 02:58:08 -0400
Subject: [PATCH 16/50] Implement merch link.

---
 source/funkin/util/Constants.hx | 5 +++++
 1 file changed, 5 insertions(+)

diff --git a/source/funkin/util/Constants.hx b/source/funkin/util/Constants.hx
index c7bc03139..7ea537935 100644
--- a/source/funkin/util/Constants.hx
+++ b/source/funkin/util/Constants.hx
@@ -59,6 +59,11 @@ class Constants
    */
   // ==============================
 
+  /**
+   * Link to buy merch for the game.
+   */
+  public static final URL_MERCH:String = 'https://needlejuicerecords.com/pages/friday-night-funkin';
+
   /**
    * Link to download the game on Itch.io.
    */

From e5eca37dc067e82d3a75f849fd62a8c24fe67dca Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Thu, 28 Mar 2024 02:58:46 -0400
Subject: [PATCH 17/50] Update assets submodule.

---
 assets | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/assets b/assets
index edccf0421..289810289 160000
--- a/assets
+++ b/assets
@@ -1 +1 @@
-Subproject commit edccf04217c49c730b11c80736e2b2d98a25ee95
+Subproject commit 289810289d66cbaf5d55494e396e71bdf9085b1e

From c7d67b46e0089a1a1ddfb81bbb470f3100a1b88a Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Thu, 28 Mar 2024 20:29:32 -0400
Subject: [PATCH 18/50] Update art submodule

---
 art | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/art b/art
index 00463685f..03e7c2a23 160000
--- a/art
+++ b/art
@@ -1 +1 @@
-Subproject commit 00463685fa570f0c853d08e250b46ef80f30bc48
+Subproject commit 03e7c2a2353b184e45955c96d763b7cdf1acbc34

From 8020624366a48b1563945722744cca20702e078b Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Thu, 28 Mar 2024 21:40:16 -0400
Subject: [PATCH 19/50] Fix a crash I found.

---
 source/funkin/audio/VoicesGroup.hx | 16 ++++++++++++----
 1 file changed, 12 insertions(+), 4 deletions(-)

diff --git a/source/funkin/audio/VoicesGroup.hx b/source/funkin/audio/VoicesGroup.hx
index 91054cfb0..5037ee1d0 100644
--- a/source/funkin/audio/VoicesGroup.hx
+++ b/source/funkin/audio/VoicesGroup.hx
@@ -159,10 +159,18 @@ class VoicesGroup extends SoundGroup
 
   public override function destroy():Void
   {
-    playerVoices.destroy();
-    playerVoices = null;
-    opponentVoices.destroy();
-    opponentVoices = null;
+    if (playerVoices != null)
+    {
+      playerVoices.destroy();
+      playerVoices = null;
+    }
+
+    if (opponentVoices != null)
+    {
+      opponentVoices.destroy();
+      opponentVoices = null;
+    }
+
     super.destroy();
   }
 }

From aa7ff6fbc45a0820dc2da581ea5b84e44d5df7c7 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Thu, 28 Mar 2024 21:40:47 -0400
Subject: [PATCH 20/50] Update assets submodule

---
 assets | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/assets b/assets
index 485243fdd..15c3f16c7 160000
--- a/assets
+++ b/assets
@@ -1 +1 @@
-Subproject commit 485243fdd44acbc4db6a97ec7bf10a8b18350be9
+Subproject commit 15c3f16c7ec162b7c8d86421b624d74501b9616f

From 342782c3d379d67127d1ef8acc5662fdfc370ae6 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Thu, 28 Mar 2024 22:33:50 -0400
Subject: [PATCH 21/50] Fix issue with pink screen when moving from
 PlayState->Freeplay->Main Menu

---
 source/funkin/play/GameOverSubState.hx     |  4 ++--
 source/funkin/play/PauseSubState.hx        |  3 ++-
 source/funkin/play/PlayState.hx            | 17 ++++-------------
 source/funkin/play/ResultState.hx          |  2 +-
 source/funkin/ui/freeplay/FreeplayState.hx | 19 +++++++++++++++++--
 source/funkin/ui/mainmenu/MainMenuState.hx |  5 ++---
 source/funkin/ui/title/TitleState.hx       | 12 ------------
 7 files changed, 28 insertions(+), 34 deletions(-)

diff --git a/source/funkin/play/GameOverSubState.hx b/source/funkin/play/GameOverSubState.hx
index a1796e912..652ba1484 100644
--- a/source/funkin/play/GameOverSubState.hx
+++ b/source/funkin/play/GameOverSubState.hx
@@ -238,11 +238,11 @@ class GameOverSubState extends MusicBeatSubState
       }
       else if (PlayStatePlaylist.isStoryMode)
       {
-        FlxG.switchState(() -> new StoryMenuState());
+        openSubState(new funkin.ui.transition.StickerSubState(null, (sticker) -> new StoryMenuState(sticker)));
       }
       else
       {
-        FlxG.switchState(() -> new FreeplayState());
+        openSubState(new funkin.ui.transition.StickerSubState(null, (sticker) -> FreeplayState.build(sticker)));
       }
     }
 
diff --git a/source/funkin/play/PauseSubState.hx b/source/funkin/play/PauseSubState.hx
index f16aa00d8..471f8cf02 100644
--- a/source/funkin/play/PauseSubState.hx
+++ b/source/funkin/play/PauseSubState.hx
@@ -12,6 +12,7 @@ import flixel.tweens.FlxTween;
 import flixel.util.FlxColor;
 import funkin.audio.FunkinSound;
 import funkin.data.song.SongRegistry;
+import funkin.ui.freeplay.FreeplayState;
 import funkin.graphics.FunkinSprite;
 import funkin.play.cutscene.VideoCutscene;
 import funkin.play.PlayState;
@@ -658,7 +659,7 @@ class PauseSubState extends MusicBeatSubState
     }
     else
     {
-      state.openSubState(new funkin.ui.transition.StickerSubState(null, (sticker) -> new funkin.ui.freeplay.FreeplayState(null, sticker)));
+      state.openSubState(new funkin.ui.transition.StickerSubState(null, (sticker) -> FreeplayState.build(null, sticker)));
     }
   }
 
diff --git a/source/funkin/play/PlayState.hx b/source/funkin/play/PlayState.hx
index fd6463bb1..678f2430e 100644
--- a/source/funkin/play/PlayState.hx
+++ b/source/funkin/play/PlayState.hx
@@ -2785,18 +2785,6 @@ class PlayState extends MusicBeatSubState
 
       if (targetSongId == null)
       {
-        FunkinSound.playMusic('freakyMenu',
-          {
-            overrideExisting: true,
-            restartTrack: false
-          });
-
-        // transIn = FlxTransitionableState.defaultTransIn;
-        // transOut = FlxTransitionableState.defaultTransOut;
-
-        // TODO: Rework week unlock logic.
-        // StoryMenuState.weekUnlocked[Std.int(Math.min(storyWeek + 1, StoryMenuState.weekUnlocked.length - 1))] = true;
-
         if (currentSong.validScore)
         {
           NGio.unlockMedal(60961);
@@ -3205,7 +3193,10 @@ class PlayState extends MusicBeatSubState
     // Don't go back in time to before the song started.
     targetTimeMs = Math.max(0, targetTimeMs);
 
-    FlxG.sound.music.time = targetTimeMs;
+    if (FlxG.sound.music != null)
+    {
+      FlxG.sound.music.time = targetTimeMs;
+    }
 
     handleSkippedNotes();
     SongEventRegistry.handleSkippedEvents(songEvents, Conductor.instance.songPosition);
diff --git a/source/funkin/play/ResultState.hx b/source/funkin/play/ResultState.hx
index 821f4ba3c..12f395d0f 100644
--- a/source/funkin/play/ResultState.hx
+++ b/source/funkin/play/ResultState.hx
@@ -365,7 +365,7 @@ class ResultState extends MusicBeatSubState
       }
       else
       {
-        openSubState(new funkin.ui.transition.StickerSubState(null, (sticker) -> new FreeplayState(null, sticker)));
+        openSubState(new funkin.ui.transition.StickerSubState(null, (sticker) -> FreeplayState.build(null, sticker)));
       }
     }
 
diff --git a/source/funkin/ui/freeplay/FreeplayState.hx b/source/funkin/ui/freeplay/FreeplayState.hx
index 6cb0d1d9a..40081b2ec 100644
--- a/source/funkin/ui/freeplay/FreeplayState.hx
+++ b/source/funkin/ui/freeplay/FreeplayState.hx
@@ -145,7 +145,7 @@ class FreeplayState extends MusicBeatSubState
       stickerSubState = stickers;
     }
 
-    super();
+    super(FlxColor.TRANSPARENT);
   }
 
   override function create():Void
@@ -899,7 +899,7 @@ class FreeplayState extends MusicBeatSubState
 
       if (Type.getClass(FlxG.state) == MainMenuState)
       {
-        FlxG.state.persistentUpdate = true;
+        FlxG.state.persistentUpdate = false;
         FlxG.state.persistentDraw = true;
       }
 
@@ -1201,6 +1201,21 @@ class FreeplayState extends MusicBeatSubState
       grpCapsules.members[curSelected].selected = true;
     }
   }
+
+  /**
+   * Build an instance of `FreeplayState` that is above the `MainMenuState`.
+   * @return The MainMenuState with the FreeplayState as a substate.
+   */
+  public static function build(?params:FreeplayStateParams, ?stickers:StickerSubState):MusicBeatState
+  {
+    var result = new MainMenuState();
+    result.persistentUpdate = false;
+    result.persistentDraw = true;
+
+    result.openSubState(new FreeplayState(params, stickers));
+
+    return result;
+  }
 }
 
 /**
diff --git a/source/funkin/ui/mainmenu/MainMenuState.hx b/source/funkin/ui/mainmenu/MainMenuState.hx
index a8c2039ab..df81cf6f2 100644
--- a/source/funkin/ui/mainmenu/MainMenuState.hx
+++ b/source/funkin/ui/mainmenu/MainMenuState.hx
@@ -56,7 +56,8 @@ class MainMenuState extends MusicBeatState
       playMenuMusic();
     }
 
-    persistentUpdate = persistentDraw = true;
+    persistentUpdate = false;
+    persistentDraw = true;
 
     var bg:FlxSprite = new FlxSprite(Paths.image('menuBG'));
     bg.scrollFactor.x = 0;
@@ -311,8 +312,6 @@ class MainMenuState extends MusicBeatState
     // Open the debug menu, defaults to ` / ~
     if (controls.DEBUG_MENU)
     {
-      this.persistentUpdate = false;
-      this.persistentDraw = false;
       FlxG.state.openSubState(new DebugMenuSubState());
     }
 
diff --git a/source/funkin/ui/title/TitleState.hx b/source/funkin/ui/title/TitleState.hx
index 1a4e13ab1..7bd4a84af 100644
--- a/source/funkin/ui/title/TitleState.hx
+++ b/source/funkin/ui/title/TitleState.hx
@@ -290,18 +290,6 @@ class TitleState extends MusicBeatState
     // do controls.PAUSE | controls.ACCEPT instead?
     var pressedEnter:Bool = FlxG.keys.justPressed.ENTER;
 
-    if (FlxG.onMobile)
-    {
-      for (touch in FlxG.touches.list)
-      {
-        if (touch.justPressed)
-        {
-          FlxG.switchState(() -> new FreeplayState());
-          pressedEnter = true;
-        }
-      }
-    }
-
     var gamepad:FlxGamepad = FlxG.gamepads.lastActive;
 
     if (gamepad != null)

From ddb41c9ef7d8c08e5100253216979dfdc6c14ef7 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Thu, 28 Mar 2024 23:13:47 -0400
Subject: [PATCH 22/50] Increase AFK timer, and add SFX over it

---
 assets                                   |  2 +-
 source/funkin/ui/freeplay/DJBoyfriend.hx | 29 ++++++++++++++----------
 2 files changed, 18 insertions(+), 13 deletions(-)

diff --git a/assets b/assets
index 15c3f16c7..04605b7e3 160000
--- a/assets
+++ b/assets
@@ -1 +1 @@
-Subproject commit 15c3f16c7ec162b7c8d86421b624d74501b9616f
+Subproject commit 04605b7e3ab7556b395476aa31b8853ff5243c6f
diff --git a/source/funkin/ui/freeplay/DJBoyfriend.hx b/source/funkin/ui/freeplay/DJBoyfriend.hx
index 33f264301..5f1144fab 100644
--- a/source/funkin/ui/freeplay/DJBoyfriend.hx
+++ b/source/funkin/ui/freeplay/DJBoyfriend.hx
@@ -27,8 +27,8 @@ class DJBoyfriend extends FlxAtlasSprite
 
   var gotSpooked:Bool = false;
 
-  static final SPOOK_PERIOD:Float = 10.0;
-  static final TV_PERIOD:Float = 10.0;
+  static final SPOOK_PERIOD:Float = 120.0;
+  static final TV_PERIOD:Float = 180.0;
 
   // Time since dad last SPOOKED you.
   var timeSinceSpook:Float = 0;
@@ -43,7 +43,14 @@ class DJBoyfriend extends FlxAtlasSprite
       switch (name)
       {
         case "Boyfriend DJ watchin tv OG":
-          if (number == 85) runTvLogic();
+          if (number == 80)
+          {
+            FunkinSound.playOnce(Paths.sound('remote_click'));
+          }
+          if (number == 85)
+          {
+            runTvLogic();
+          }
         default:
       }
     };
@@ -219,19 +226,17 @@ class DJBoyfriend extends FlxAtlasSprite
     if (cartoonSnd == null)
     {
       // tv is OFF, but getting turned on
-      // Eric got FUCKING TROLLED there is no `tv_on` or `channel_switch` sound!
-      // FunkinSound.playOnce(Paths.sound('tv_on'), 1.0, function() {
-      // });
-      loadCartoon();
+      FunkinSound.playOnce(Paths.sound('tv_on'), 1.0, function() {
+        loadCartoon();
+      });
     }
     else
     {
       // plays it smidge after the click
-      // new FlxTimer().start(0.1, function(_) {
-      //   // FunkinSound.playOnce(Paths.sound('channel_switch'));
-      // });
-      cartoonSnd.destroy();
-      loadCartoon();
+      FunkinSound.playOnce(Paths.sound('channel_switch'), 1.0, function() {
+        cartoonSnd.destroy();
+        loadCartoon();
+      });
     }
 
     // loadCartoon();

From 7c04630bb775161e9878e6554146a313aaab5c2a Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Fri, 29 Mar 2024 00:49:02 -0400
Subject: [PATCH 23/50] Fix a bug where duplicate notes would get placed at the
 beginning

---
 source/funkin/play/notes/Strumline.hx       | 5 +++++
 source/funkin/ui/transition/LoadingState.hx | 3 ++-
 2 files changed, 7 insertions(+), 1 deletion(-)

diff --git a/source/funkin/play/notes/Strumline.hx b/source/funkin/play/notes/Strumline.hx
index 9a6699c43..a3b5dafc5 100644
--- a/source/funkin/play/notes/Strumline.hx
+++ b/source/funkin/play/notes/Strumline.hx
@@ -295,6 +295,11 @@ class Strumline extends FlxSpriteGroup
   {
     if (noteData.length == 0) return;
 
+    // Ensure note data gets reset if the song happens to loop.
+    // NOTE: I had to remove this line because it was causing notes visible during the countdown to be placed multiple times.
+    // I don't remember what bug I was trying to fix by adding this.
+    // if (conductorInUse.currentStep == 0) nextNoteIndex = 0;
+
     var songStart:Float = PlayState.instance?.startTimestamp ?? 0.0;
     var hitWindowStart:Float = Conductor.instance.songPosition - Constants.HIT_WINDOW_MS;
     var renderWindowStart:Float = Conductor.instance.songPosition + RENDER_DISTANCE_MS;
diff --git a/source/funkin/ui/transition/LoadingState.hx b/source/funkin/ui/transition/LoadingState.hx
index 980c264e3..d913b8099 100644
--- a/source/funkin/ui/transition/LoadingState.hx
+++ b/source/funkin/ui/transition/LoadingState.hx
@@ -210,7 +210,8 @@ class LoadingState extends MusicBeatState
     }
 
     // Load and cache the song's charts.
-    if (params?.targetSong != null)
+    // Don't do this if we already provided the music and charts.
+    if (params?.targetSong != null && !params.overrideMusic)
     {
       params.targetSong.cacheCharts(true);
     }

From 28462681b2af4a55c82fb60aa55b51ac4ab29966 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Fri, 29 Mar 2024 00:52:20 -0400
Subject: [PATCH 24/50] Fix bug where 100ms sustains wouldn't update their
 rendering.

---
 source/funkin/play/notes/Strumline.hx | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/source/funkin/play/notes/Strumline.hx b/source/funkin/play/notes/Strumline.hx
index a3b5dafc5..2b10c05ee 100644
--- a/source/funkin/play/notes/Strumline.hx
+++ b/source/funkin/play/notes/Strumline.hx
@@ -827,7 +827,7 @@ class Strumline extends FlxSpriteGroup
     {
       // The note sprite pool is full and all note splashes are active.
       // We have to create a new note.
-      result = new SustainTrail(0, 100, noteStyle);
+      result = new SustainTrail(0, 0, noteStyle);
       this.holdNotes.add(result);
     }
 

From 9b7bfff817cdd9d50468a263053d9837fa9c3f6d Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Fri, 29 Mar 2024 01:23:36 -0400
Subject: [PATCH 25/50] Update assets submodule

---
 assets | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/assets b/assets
index 485243fdd..44d74c089 160000
--- a/assets
+++ b/assets
@@ -1 +1 @@
-Subproject commit 485243fdd44acbc4db6a97ec7bf10a8b18350be9
+Subproject commit 44d74c0898b630a98344ef22987be73140e932c3

From f50c1ce86fb38d5f5b95a864ed7e0de684414b70 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Fri, 29 Mar 2024 10:17:16 -0400
Subject: [PATCH 26/50] Update assets submodule

---
 assets | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/assets b/assets
index 65e6ff18c..0965a86e2 160000
--- a/assets
+++ b/assets
@@ -1 +1 @@
-Subproject commit 65e6ff18c7fcbd646ac7a3676ca5c2baa95b5fea
+Subproject commit 0965a86e2248bd9b8b2387f8b4f6b16385499db0

From 5be47e6619aa13ca6251c1c580d9dbc4ef330818 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Fri, 29 Mar 2024 10:34:59 -0400
Subject: [PATCH 27/50] Fix limo ride too

---
 assets | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/assets b/assets
index 0965a86e2..5d78b0705 160000
--- a/assets
+++ b/assets
@@ -1 +1 @@
-Subproject commit 0965a86e2248bd9b8b2387f8b4f6b16385499db0
+Subproject commit 5d78b070535d6a1d88c5b450fc092eb6cd331f78

From f19507bd957a45c784883f4beef37017aa57e1ca Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Fri, 29 Mar 2024 23:00:42 -0400
Subject: [PATCH 28/50] Add a bunch of erect songs

---
 assets | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/assets b/assets
index 65e6ff18c..208d1ab80 160000
--- a/assets
+++ b/assets
@@ -1 +1 @@
-Subproject commit 65e6ff18c7fcbd646ac7a3676ca5c2baa95b5fea
+Subproject commit 208d1ab80739cb728de3a64535ef3e3bee0843b0

From 846df8de6fa3f207cc4a12cfa175215679c833ea Mon Sep 17 00:00:00 2001
From: Cameron Taylor <cameron.taylor.ninja@gmail.com>
Date: Sat, 30 Mar 2024 03:06:39 -0400
Subject: [PATCH 29/50] assets submod

---
 assets | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/assets b/assets
index 5d78b0705..3b168f7ca 160000
--- a/assets
+++ b/assets
@@ -1 +1 @@
-Subproject commit 5d78b070535d6a1d88c5b450fc092eb6cd331f78
+Subproject commit 3b168f7cac41e1843de9a223453d0ff4c04b0283

From 3637e3594ba85e1e0344ab33053b8960edd86b3b Mon Sep 17 00:00:00 2001
From: Cameron Taylor <cameron.taylor.ninja@gmail.com>
Date: Sat, 30 Mar 2024 03:24:30 -0400
Subject: [PATCH 30/50] assets submod

---
 assets | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/assets b/assets
index 15c3f16c7..200658724 160000
--- a/assets
+++ b/assets
@@ -1 +1 @@
-Subproject commit 15c3f16c7ec162b7c8d86421b624d74501b9616f
+Subproject commit 200658724592b298f49b13016f2c706c54ad538f

From fe498b38e51babb076a1a94003be002dc17ac2fe Mon Sep 17 00:00:00 2001
From: Cameron Taylor <cameron.taylor.ninja@gmail.com>
Date: Sat, 30 Mar 2024 03:32:17 -0400
Subject: [PATCH 31/50] assets submod

---
 assets | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/assets b/assets
index 2a0afcd76..763c833cb 160000
--- a/assets
+++ b/assets
@@ -1 +1 @@
-Subproject commit 2a0afcd76a26251dbbebb6901df4651f25a92c23
+Subproject commit 763c833cbcde724d50ff31f5bac9f2ac3d5e61a7

From 1df4a354cb3ac7663f2c43da96e555edec7c6724 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Mon, 1 Apr 2024 18:34:26 -0400
Subject: [PATCH 32/50] Freeplay menu now filters to supported songs when you
 change difficulty

---
 source/funkin/ui/freeplay/FreeplayState.hx | 70 +++++++++++++++++-----
 source/funkin/util/Constants.hx            |  6 ++
 2 files changed, 60 insertions(+), 16 deletions(-)

diff --git a/source/funkin/ui/freeplay/FreeplayState.hx b/source/funkin/ui/freeplay/FreeplayState.hx
index 6cb0d1d9a..471fe7e28 100644
--- a/source/funkin/ui/freeplay/FreeplayState.hx
+++ b/source/funkin/ui/freeplay/FreeplayState.hx
@@ -536,21 +536,18 @@ class FreeplayState extends MusicBeatSubState
     });
   }
 
+  var currentFilter:SongFilter = null;
+  var currentFilteredSongs:Array<FreeplaySongData> = [];
+
   /**
    * Given the current filter, rebuild the current song list.
    *
    * @param filterStuff A filter to apply to the song list (regex, startswith, all, favorite)
    * @param force
+   * @param onlyIfChanged Only apply the filter if the song list has changed
    */
-  public function generateSongList(?filterStuff:SongFilter, force:Bool = false):Void
+  public function generateSongList(filterStuff:Null<SongFilter>, force:Bool = false, onlyIfChanged:Bool = true):Void
   {
-    curSelected = 1;
-
-    for (cap in grpCapsules.members)
-    {
-      cap.kill();
-    }
-
     var tempSongs:Array<FreeplaySongData> = songs;
 
     if (filterStuff != null)
@@ -582,6 +579,35 @@ class FreeplayState extends MusicBeatSubState
       }
     }
 
+    // Filter further by current selected difficulty.
+    if (currentDifficulty != null)
+    {
+      tempSongs = tempSongs.filter(song -> {
+        if (song == null) return true; // Random
+        return song.songDifficulties.contains(currentDifficulty);
+      });
+    }
+
+    if (onlyIfChanged)
+    {
+      // == performs equality by reference
+      if (tempSongs.isEqualUnordered(currentFilteredSongs)) return;
+    }
+
+    // Only now do we know that the filter is actually changing.
+
+    rememberedSongId = grpCapsules.members[curSelected]?.songData?.songId;
+
+    for (cap in grpCapsules.members)
+    {
+      cap.kill();
+    }
+
+    currentFilter = filterStuff;
+
+    currentFilteredSongs = tempSongs;
+    curSelected = 0;
+
     var hsvShader:HSVShader = new HSVShader();
 
     var randomCapsule:SongMenuItem = grpCapsules.recycle(SongMenuItem);
@@ -658,11 +684,12 @@ class FreeplayState extends MusicBeatSubState
 
     if (FlxG.keys.justPressed.F)
     {
-      if (songs[curSelected] != null)
+      var targetSong = grpCapsules.members[curSelected]?.songData;
+      if (targetSong != null)
       {
         var realShit:Int = curSelected;
-        songs[curSelected].isFav = !songs[curSelected].isFav;
-        if (songs[curSelected].isFav)
+        targetSong.isFav = !targetSong.isFav;
+        if (targetSong.isFav)
         {
           FlxTween.tween(grpCapsules.members[realShit], {angle: 360}, 0.4,
             {
@@ -854,11 +881,13 @@ class FreeplayState extends MusicBeatSubState
     {
       dj.resetAFKTimer();
       changeDiff(-1);
+      generateSongList(currentFilter, true);
     }
     if (controls.UI_RIGHT_P && !FlxG.keys.pressed.CONTROL)
     {
       dj.resetAFKTimer();
       changeDiff(1);
+      generateSongList(currentFilter, true);
     }
 
     if (controls.BACK && !typing.hasFocus)
@@ -926,7 +955,7 @@ class FreeplayState extends MusicBeatSubState
   public override function destroy():Void
   {
     super.destroy();
-    var daSong:Null<FreeplaySongData> = songs[curSelected];
+    var daSong:Null<FreeplaySongData> = grpCapsules.members[curSelected]?.songData;
     if (daSong != null)
     {
       clearDaCache(daSong.songName);
@@ -948,10 +977,10 @@ class FreeplayState extends MusicBeatSubState
 
     currentDifficulty = diffIdsCurrent[currentDifficultyIndex];
 
-    var daSong:Null<FreeplaySongData> = songs[curSelected];
+    var daSong:Null<FreeplaySongData> = grpCapsules.members[curSelected].songData;
     if (daSong != null)
     {
-      var songScore:SaveScoreData = Save.instance.getSongScore(songs[curSelected].songId, currentDifficulty);
+      var songScore:SaveScoreData = Save.instance.getSongScore(grpCapsules.members[curSelected].songData.songId, currentDifficulty);
       intendedScore = songScore?.score ?? 0;
       intendedCompletion = songScore?.accuracy ?? 0.0;
       rememberedDifficulty = currentDifficulty;
@@ -1103,6 +1132,12 @@ class FreeplayState extends MusicBeatSubState
           targetVariation: targetVariation,
           practiceMode: false,
           minimalMode: false,
+
+          #if (debug || FORCE_DEBUG_VERSION)
+          botPlayMode: FlxG.keys.pressed.SHIFT,
+          #else
+          botPlayMode: false,
+          #end
           // TODO: Make these an option! It's currently only accessible via chart editor.
           // startTimestamp: 0.0,
           // playbackRate: 0.5,
@@ -1115,10 +1150,12 @@ class FreeplayState extends MusicBeatSubState
   {
     if (rememberedSongId != null)
     {
-      curSelected = songs.findIndex(function(song) {
+      curSelected = currentFilteredSongs.findIndex(function(song) {
         if (song == null) return false;
         return song.songId == rememberedSongId;
       });
+
+      if (curSelected == -1) curSelected = 0;
     }
 
     if (rememberedDifficulty != null)
@@ -1127,7 +1164,7 @@ class FreeplayState extends MusicBeatSubState
     }
 
     // Set the difficulty star count on the right.
-    var daSong:Null<FreeplaySongData> = songs[curSelected];
+    var daSong:Null<FreeplaySongData> = grpCapsules.members[curSelected]?.songData;
     albumRoll.setDifficultyStars(daSong?.songRating ?? 0);
   }
 
@@ -1156,6 +1193,7 @@ class FreeplayState extends MusicBeatSubState
     {
       intendedScore = 0;
       intendedCompletion = 0.0;
+      diffIdsCurrent = diffIdsTotal;
       rememberedSongId = null;
       rememberedDifficulty = null;
     }
diff --git a/source/funkin/util/Constants.hx b/source/funkin/util/Constants.hx
index 5d355f2da..13a6f65b5 100644
--- a/source/funkin/util/Constants.hx
+++ b/source/funkin/util/Constants.hx
@@ -181,6 +181,12 @@ class Constants
    */
   public static final DEFAULT_DIFFICULTY_LIST:Array<String> = ['easy', 'normal', 'hard'];
 
+  /**
+   * List of all difficulties used by the base game.
+   * Includes Erect and Nightmare.
+   */
+  public static final DEFAULT_DIFFICULTY_LIST_FULL:Array<String> = ['easy', 'normal', 'hard', 'erect', 'nightmare'];
+
   /**
    * Default player character for charts.
    */

From 927b2a7cfc5ef956e77aae1a7dd7660f8d9a738a Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Mon, 1 Apr 2024 13:05:16 -0400
Subject: [PATCH 33/50] Put judgements below the notes, and remove COMBO word.

---
 source/funkin/play/PlayState.hx             | 6 ++++--
 source/funkin/play/components/PopUpStuff.hx | 2 +-
 2 files changed, 5 insertions(+), 3 deletions(-)

diff --git a/source/funkin/play/PlayState.hx b/source/funkin/play/PlayState.hx
index 274ee4fe8..97dd96016 100644
--- a/source/funkin/play/PlayState.hx
+++ b/source/funkin/play/PlayState.hx
@@ -728,6 +728,10 @@ class PlayState extends MusicBeatSubState
     #end
 
     initialized = true;
+
+    // This step ensures z-indexes are applied properly,
+    // and it's important to call it last so all elements get affected.
+    refresh();
   }
 
   public override function draw():Void
@@ -1720,8 +1724,6 @@ class PlayState extends MusicBeatSubState
       playerStrumline.fadeInArrows();
       opponentStrumline.fadeInArrows();
     }
-
-    this.refresh();
   }
 
   /**
diff --git a/source/funkin/play/components/PopUpStuff.hx b/source/funkin/play/components/PopUpStuff.hx
index 39fc192a0..724bf0cb9 100644
--- a/source/funkin/play/components/PopUpStuff.hx
+++ b/source/funkin/play/components/PopUpStuff.hx
@@ -85,7 +85,7 @@ class PopUpStuff extends FlxTypedGroup<FlxSprite>
     comboSpr.velocity.y -= 150;
     comboSpr.velocity.x += FlxG.random.int(1, 10);
 
-    add(comboSpr);
+    // add(comboSpr);
 
     if (PlayState.instance.currentStageId.startsWith('school'))
     {

From 67e096e4430a84bac3e04da02fe249240866c7e0 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Mon, 1 Apr 2024 21:59:53 -0400
Subject: [PATCH 34/50] Bunch of results screen fixes

---
 source/funkin/Highscore.hx                    |  4 ++
 source/funkin/play/PauseSubState.hx           |  2 +
 source/funkin/play/PlayState.hx               | 43 +++++++++---
 source/funkin/play/ResultState.hx             | 68 +++++++++++++------
 source/funkin/play/components/TallyCounter.hx | 23 +++++--
 source/funkin/ui/freeplay/FreeplayState.hx    |  4 +-
 6 files changed, 106 insertions(+), 38 deletions(-)

diff --git a/source/funkin/Highscore.hx b/source/funkin/Highscore.hx
index 996e2367e..94f41cea4 100644
--- a/source/funkin/Highscore.hx
+++ b/source/funkin/Highscore.hx
@@ -59,6 +59,7 @@ abstract Tallies(RawTallies)
         totalNotes: 0,
         totalNotesHit: 0,
         maxCombo: 0,
+        score: 0,
         isNewHighscore: false
       }
   }
@@ -81,6 +82,9 @@ typedef RawTallies =
   var good:Int;
   var sick:Int;
   var maxCombo:Int;
+
+  var score:Int;
+
   var isNewHighscore:Bool;
 
   /**
diff --git a/source/funkin/play/PauseSubState.hx b/source/funkin/play/PauseSubState.hx
index ed847402a..2af04749f 100644
--- a/source/funkin/play/PauseSubState.hx
+++ b/source/funkin/play/PauseSubState.hx
@@ -567,6 +567,8 @@ class PauseSubState extends MusicBeatSubState
     PlayStatePlaylist.campaignDifficulty = difficulty;
     PlayState.instance.currentDifficulty = PlayStatePlaylist.campaignDifficulty;
 
+    FreeplayState.rememberedDifficulty = difficulty;
+
     PlayState.instance.needsReset = true;
 
     state.close();
diff --git a/source/funkin/play/PlayState.hx b/source/funkin/play/PlayState.hx
index 274ee4fe8..438941f90 100644
--- a/source/funkin/play/PlayState.hx
+++ b/source/funkin/play/PlayState.hx
@@ -2731,7 +2731,7 @@ class PlayState extends MusicBeatSubState
    */
   public function endSong(rightGoddamnNow:Bool = false):Void
   {
-    FlxG.sound.music.volume = 0;
+    if (FlxG.sound.music != null) FlxG.sound.music.volume = 0;
     vocals.volume = 0;
     mayPauseGame = false;
 
@@ -2749,6 +2749,8 @@ class PlayState extends MusicBeatSubState
 
     deathCounter = 0;
 
+    var isNewHighscore = false;
+
     if (currentSong != null && currentSong.validScore)
     {
       // crackhead double thingie, sets whether was new highscore, AND saves the song!
@@ -2779,11 +2781,14 @@ class PlayState extends MusicBeatSubState
         #if newgrounds
         NGio.postScore(score, currentSong.id);
         #end
+        isNewHighscore = true;
       }
     }
 
     if (PlayStatePlaylist.isStoryMode)
     {
+      isNewHighscore = false;
+
       PlayStatePlaylist.campaignScore += songScore;
 
       // Pop the next song ID from the list.
@@ -2833,6 +2838,7 @@ class PlayState extends MusicBeatSubState
             #if newgrounds
             NGio.postScore(score, 'Level ${PlayStatePlaylist.campaignId}');
             #end
+            isNewHighscore = true;
           }
         }
 
@@ -2844,11 +2850,11 @@ class PlayState extends MusicBeatSubState
         {
           if (rightGoddamnNow)
           {
-            moveToResultsScreen();
+            moveToResultsScreen(isNewHighscore);
           }
           else
           {
-            zoomIntoResultsScreen();
+            zoomIntoResultsScreen(isNewHighscore);
           }
         }
       }
@@ -2909,11 +2915,11 @@ class PlayState extends MusicBeatSubState
       {
         if (rightGoddamnNow)
         {
-          moveToResultsScreen();
+          moveToResultsScreen(isNewHighscore);
         }
         else
         {
-          zoomIntoResultsScreen();
+          zoomIntoResultsScreen(isNewHighscore);
         }
       }
     }
@@ -2987,7 +2993,7 @@ class PlayState extends MusicBeatSubState
   /**
    * Play the camera zoom animation and then move to the results screen once it's done.
    */
-  function zoomIntoResultsScreen():Void
+  function zoomIntoResultsScreen(isNewHighscore:Bool):Void
   {
     trace('WENT TO RESULTS SCREEN!');
 
@@ -3044,7 +3050,7 @@ class PlayState extends MusicBeatSubState
         {
           ease: FlxEase.expoIn,
           onComplete: function(_) {
-            moveToResultsScreen();
+            moveToResultsScreen(isNewHighscore);
           }
         });
     });
@@ -3053,7 +3059,7 @@ class PlayState extends MusicBeatSubState
   /**
    * Move to the results screen right goddamn now.
    */
-  function moveToResultsScreen():Void
+  function moveToResultsScreen(isNewHighscore:Bool):Void
   {
     persistentUpdate = false;
     vocals.stop();
@@ -3065,7 +3071,24 @@ class PlayState extends MusicBeatSubState
       {
         storyMode: PlayStatePlaylist.isStoryMode,
         title: PlayStatePlaylist.isStoryMode ? ('${PlayStatePlaylist.campaignTitle}') : ('${currentChart.songName} by ${currentChart.songArtist}'),
-        tallies: talliesToUse,
+        scoreData:
+          {
+            score: songScore,
+            tallies:
+              {
+                sick: Highscore.tallies.sick,
+                good: Highscore.tallies.good,
+                bad: Highscore.tallies.bad,
+                shit: Highscore.tallies.shit,
+                missed: Highscore.tallies.missed,
+                combo: Highscore.tallies.combo,
+                maxCombo: Highscore.tallies.maxCombo,
+                totalNotesHit: Highscore.tallies.totalNotesHit,
+                totalNotes: Highscore.tallies.totalNotes,
+              },
+            accuracy: Highscore.tallies.totalNotesHit / Highscore.tallies.totalNotes,
+          },
+        isNewHighscore: isNewHighscore
       });
     res.camera = camHUD;
     openSubState(res);
@@ -3212,7 +3235,7 @@ class PlayState extends MusicBeatSubState
     // Don't go back in time to before the song started.
     targetTimeMs = Math.max(0, targetTimeMs);
 
-    FlxG.sound.music.time = targetTimeMs;
+    if (FlxG.sound.music != null) FlxG.sound.music.time = targetTimeMs;
 
     handleSkippedNotes();
     SongEventRegistry.handleSkippedEvents(songEvents, Conductor.instance.songPosition);
diff --git a/source/funkin/play/ResultState.hx b/source/funkin/play/ResultState.hx
index 821f4ba3c..7dbaf087f 100644
--- a/source/funkin/play/ResultState.hx
+++ b/source/funkin/play/ResultState.hx
@@ -1,5 +1,6 @@
 package funkin.play;
 
+import funkin.util.MathUtil;
 import funkin.ui.story.StoryMenuState;
 import funkin.graphics.adobeanimate.FlxAtlasSprite;
 import flixel.FlxSprite;
@@ -16,6 +17,8 @@ import flixel.tweens.FlxTween;
 import funkin.audio.FunkinSound;
 import flixel.util.FlxGradient;
 import flixel.util.FlxTimer;
+import funkin.save.Save;
+import funkin.save.Save.SaveScoreData;
 import funkin.graphics.shaders.LeftMaskShader;
 import funkin.play.components.TallyCounter;
 
@@ -42,12 +45,15 @@ class ResultState extends MusicBeatSubState
 
   override function create():Void
   {
-    if (params.tallies.sick == params.tallies.totalNotesHit
-      && params.tallies.maxCombo == params.tallies.totalNotesHit) resultsVariation = PERFECT;
-    else if (params.tallies.missed + params.tallies.bad + params.tallies.shit >= params.tallies.totalNotes * 0.50)
-      resultsVariation = SHIT; // if more than half of your song was missed, bad, or shit notes, you get shit ending!
-    else
-      resultsVariation = NORMAL;
+    /*
+      if (params.scoreData.sick == params.scoreData.totalNotesHit
+        && params.scoreData.maxCombo == params.scoreData.totalNotesHit) resultsVariation = PERFECT;
+      else if (params.scoreData.missed + params.scoreData.bad + params.scoreData.shit >= params.scoreData.totalNotes * 0.50)
+        resultsVariation = SHIT; // if more than half of your song was missed, bad, or shit notes, you get shit ending!
+      else
+        resultsVariation = NORMAL;
+     */
+    resultsVariation = NORMAL;
 
     FunkinSound.playMusic('results$resultsVariation',
       {
@@ -130,12 +136,16 @@ class ResultState extends MusicBeatSubState
 
     var diffSpr:String = switch (PlayState.instance.currentDifficulty)
     {
-      case 'EASY':
+      case 'easy':
         'difEasy';
-      case 'NORMAL':
+      case 'normal':
         'difNormal';
-      case 'HARD':
+      case 'hard':
         'difHard';
+      case 'erect':
+        'difErect';
+      case 'nightmare':
+        'difNightmare';
       case _:
         'difNormal';
     }
@@ -195,29 +205,33 @@ class ResultState extends MusicBeatSubState
      * NOTE: We display how many notes were HIT, not how many notes there were in total.
      *
      */
-    var totalHit:TallyCounter = new TallyCounter(375, hStuf * 3, params.tallies.totalNotesHit);
+    var totalHit:TallyCounter = new TallyCounter(375, hStuf * 3, params.scoreData.tallies.totalNotesHit);
     ratingGrp.add(totalHit);
 
-    var maxCombo:TallyCounter = new TallyCounter(375, hStuf * 4, params.tallies.maxCombo);
+    var maxCombo:TallyCounter = new TallyCounter(375, hStuf * 4, params.scoreData.tallies.maxCombo);
     ratingGrp.add(maxCombo);
 
     hStuf += 2;
     var extraYOffset:Float = 5;
-    var tallySick:TallyCounter = new TallyCounter(230, (hStuf * 5) + extraYOffset, params.tallies.sick, 0xFF89E59E);
+    var tallySick:TallyCounter = new TallyCounter(230, (hStuf * 5) + extraYOffset, params.scoreData.tallies.sick, 0xFF89E59E);
     ratingGrp.add(tallySick);
 
-    var tallyGood:TallyCounter = new TallyCounter(210, (hStuf * 6) + extraYOffset, params.tallies.good, 0xFF89C9E5);
+    var tallyGood:TallyCounter = new TallyCounter(210, (hStuf * 6) + extraYOffset, params.scoreData.tallies.good, 0xFF89C9E5);
     ratingGrp.add(tallyGood);
 
-    var tallyBad:TallyCounter = new TallyCounter(190, (hStuf * 7) + extraYOffset, params.tallies.bad, 0xFFE6CF8A);
+    var tallyBad:TallyCounter = new TallyCounter(190, (hStuf * 7) + extraYOffset, params.scoreData.tallies.bad, 0xFFE6CF8A);
     ratingGrp.add(tallyBad);
 
-    var tallyShit:TallyCounter = new TallyCounter(220, (hStuf * 8) + extraYOffset, params.tallies.shit, 0xFFE68C8A);
+    var tallyShit:TallyCounter = new TallyCounter(220, (hStuf * 8) + extraYOffset, params.scoreData.tallies.shit, 0xFFE68C8A);
     ratingGrp.add(tallyShit);
 
-    var tallyMissed:TallyCounter = new TallyCounter(260, (hStuf * 9) + extraYOffset, params.tallies.missed, 0xFFC68AE6);
+    var tallyMissed:TallyCounter = new TallyCounter(260, (hStuf * 9) + extraYOffset, params.scoreData.tallies.missed, 0xFFC68AE6);
     ratingGrp.add(tallyMissed);
 
+    var score:TallyCounter = new TallyCounter(825, 630, params.scoreData.score, RIGHT);
+    score.scale.set(2, 2);
+    ratingGrp.add(score);
+
     for (ind => rating in ratingGrp.members)
     {
       rating.visible = false;
@@ -235,9 +249,16 @@ class ResultState extends MusicBeatSubState
         scorePopin.animation.play("score");
         scorePopin.visible = true;
 
-        highscoreNew.visible = true;
-        highscoreNew.animation.play("new");
-        FlxTween.tween(highscoreNew, {y: highscoreNew.y + 10}, 0.8, {ease: FlxEase.quartOut});
+        if (params.isNewHighscore)
+        {
+          highscoreNew.visible = true;
+          highscoreNew.animation.play("new");
+          FlxTween.tween(highscoreNew, {y: highscoreNew.y + 10}, 0.8, {ease: FlxEase.quartOut});
+        }
+        else
+        {
+          highscoreNew.visible = false;
+        }
       };
 
       switch (resultsVariation)
@@ -276,8 +297,6 @@ class ResultState extends MusicBeatSubState
       }
     });
 
-    if (params.tallies.isNewHighscore) trace("ITS A NEW HIGHSCORE!!!");
-
     super.create();
   }
 
@@ -393,8 +412,13 @@ typedef ResultsStateParams =
    */
   var title:String;
 
+  /**
+   * Whether the displayed score is a new highscore
+   */
+  var isNewHighscore:Bool;
+
   /**
    * The score, accuracy, and judgements.
    */
-  var tallies:Highscore.Tallies;
+  var scoreData:SaveScoreData;
 };
diff --git a/source/funkin/play/components/TallyCounter.hx b/source/funkin/play/components/TallyCounter.hx
index 77e6ef4ec..35a8f3f51 100644
--- a/source/funkin/play/components/TallyCounter.hx
+++ b/source/funkin/play/components/TallyCounter.hx
@@ -6,6 +6,8 @@ import flixel.group.FlxSpriteGroup.FlxTypedSpriteGroup;
 import flixel.math.FlxMath;
 import flixel.tweens.FlxEase;
 import flixel.tweens.FlxTween;
+import flixel.text.FlxText.FlxTextAlign;
+import funkin.util.MathUtil;
 
 /**
  * Numerical counters used next to each judgement in the Results screen.
@@ -13,18 +15,23 @@ import flixel.tweens.FlxTween;
 class TallyCounter extends FlxTypedSpriteGroup<FlxSprite>
 {
   public var curNumber:Float = 0;
-
   public var neededNumber:Int = 0;
+
   public var flavour:Int = 0xFFFFFFFF;
 
-  public function new(x:Float, y:Float, neededNumber:Int = 0, ?flavour:Int = 0xFFFFFFFF)
+  public var align:FlxTextAlign = FlxTextAlign.LEFT;
+
+  public function new(x:Float, y:Float, neededNumber:Int = 0, ?flavour:Int = 0xFFFFFFFF, align:FlxTextAlign = FlxTextAlign.LEFT)
   {
     super(x, y);
 
+    this.align = align;
+
     this.flavour = flavour;
 
     this.neededNumber = neededNumber;
-    drawNumbers();
+
+    if (curNumber == neededNumber) drawNumbers();
   }
 
   var tmr:Float = 0;
@@ -41,6 +48,8 @@ class TallyCounter extends FlxTypedSpriteGroup<FlxSprite>
     var seperatedScore:Array<Int> = [];
     var tempCombo:Int = Math.round(curNumber);
 
+    var fullNumberDigits:Int = Std.int(Math.max(1, Math.ceil(MathUtil.logBase(10, neededNumber))));
+
     while (tempCombo != 0)
     {
       seperatedScore.push(tempCombo % 10);
@@ -55,7 +64,13 @@ class TallyCounter extends FlxTypedSpriteGroup<FlxSprite>
     {
       if (ind >= members.length)
       {
-        var numb:TallyNumber = new TallyNumber(ind * 43, 0, num);
+        var xPos = ind * (43 * this.scale.x);
+        if (this.align == FlxTextAlign.RIGHT)
+        {
+          xPos -= (fullNumberDigits * (43 * this.scale.x));
+        }
+        var numb:TallyNumber = new TallyNumber(xPos, 0, num);
+        numb.scale.set(this.scale.x, this.scale.y);
         add(numb);
         numb.color = flavour;
       }
diff --git a/source/funkin/ui/freeplay/FreeplayState.hx b/source/funkin/ui/freeplay/FreeplayState.hx
index 6cb0d1d9a..058f61a5b 100644
--- a/source/funkin/ui/freeplay/FreeplayState.hx
+++ b/source/funkin/ui/freeplay/FreeplayState.hx
@@ -133,8 +133,8 @@ class FreeplayState extends MusicBeatSubState
 
   var stickerSubState:StickerSubState;
 
-  static var rememberedDifficulty:Null<String> = Constants.DEFAULT_DIFFICULTY;
-  static var rememberedSongId:Null<String> = null;
+  public static var rememberedDifficulty:Null<String> = Constants.DEFAULT_DIFFICULTY;
+  public static var rememberedSongId:Null<String> = null;
 
   public function new(?params:FreeplayStateParams, ?stickers:StickerSubState)
   {

From 626cc5cc78d6543fae7b1095049e97609c2e9f59 Mon Sep 17 00:00:00 2001
From: Cameron Taylor <cameron.taylor.ninja@gmail.com>
Date: Mon, 1 Apr 2024 22:06:44 -0400
Subject: [PATCH 35/50] assets submod

---
 assets | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/assets b/assets
index b2144938c..d7e85ef60 160000
--- a/assets
+++ b/assets
@@ -1 +1 @@
-Subproject commit b2144938c899e4a5d2d05466f710aa75ff4e1d1c
+Subproject commit d7e85ef60933ca93d47e1db6295aba8aa64fcbdf

From 02b8aa0f1246f4e5c710759668e5f8b9a7fa9d19 Mon Sep 17 00:00:00 2001
From: Cameron Taylor <cameron.taylor.ninja@gmail.com>
Date: Mon, 1 Apr 2024 22:13:38 -0400
Subject: [PATCH 36/50] assets submod

---
 assets | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/assets b/assets
index 04605b7e3..d7e85ef60 160000
--- a/assets
+++ b/assets
@@ -1 +1 @@
-Subproject commit 04605b7e3ab7556b395476aa31b8853ff5243c6f
+Subproject commit d7e85ef60933ca93d47e1db6295aba8aa64fcbdf

From d4117c2e6a263152a0a8bc278b3fb5318497d3d1 Mon Sep 17 00:00:00 2001
From: Cameron Taylor <cameron.taylor.ninja@gmail.com>
Date: Mon, 1 Apr 2024 22:21:10 -0400
Subject: [PATCH 37/50] assets submod

---
 assets | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/assets b/assets
index 349b3e018..346da48a8 160000
--- a/assets
+++ b/assets
@@ -1 +1 @@
-Subproject commit 349b3e01813ce7174cd4406be3fbefe94c61946e
+Subproject commit 346da48a86f07a9c6372bf92c64d68802ae75078

From 5ecedef88aac329c8e1262fc0702f26f275fee3d Mon Sep 17 00:00:00 2001
From: Cameron Taylor <cameron.taylor.ninja@gmail.com>
Date: Mon, 1 Apr 2024 22:30:54 -0400
Subject: [PATCH 38/50] assets submod

---
 assets | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/assets b/assets
index 208d1ab80..dfa5ca25a 160000
--- a/assets
+++ b/assets
@@ -1 +1 @@
-Subproject commit 208d1ab80739cb728de3a64535ef3e3bee0843b0
+Subproject commit dfa5ca25a32c834c1f21c39b0c4d6e4830d6f799

From 8cb11d081d2f6ab40175a4ca0901d59b297dadc9 Mon Sep 17 00:00:00 2001
From: Cameron Taylor <cameron.taylor.ninja@gmail.com>
Date: Mon, 1 Apr 2024 22:31:19 -0400
Subject: [PATCH 39/50] art submod

---
 art | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/art b/art
index 03e7c2a23..00463685f 160000
--- a/art
+++ b/art
@@ -1 +1 @@
-Subproject commit 03e7c2a2353b184e45955c96d763b7cdf1acbc34
+Subproject commit 00463685fa570f0c853d08e250b46ef80f30bc48

From ddda7f6a01f83f52bbfe6122161cda906aea32cf Mon Sep 17 00:00:00 2001
From: Cameron Taylor <cameron.taylor.ninja@gmail.com>
Date: Mon, 1 Apr 2024 22:35:56 -0400
Subject: [PATCH 40/50] assets submod

---
 assets | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/assets b/assets
index 44d74c089..cb862903e 160000
--- a/assets
+++ b/assets
@@ -1 +1 @@
-Subproject commit 44d74c0898b630a98344ef22987be73140e932c3
+Subproject commit cb862903ec0975364c13080297ccbfb13f26f5cb

From 7ae44c56201ed1c5e9bd58c61cd13eb7d76b8a9c Mon Sep 17 00:00:00 2001
From: Cameron Taylor <cameron.taylor.ninja@gmail.com>
Date: Mon, 1 Apr 2024 22:39:48 -0400
Subject: [PATCH 41/50] assets submod

---
 assets | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/assets b/assets
index 77e64da9e..a54eb8517 160000
--- a/assets
+++ b/assets
@@ -1 +1 @@
-Subproject commit 77e64da9e9836c0272ff7351444881aa90f60eb6
+Subproject commit a54eb8517914e2a90b77e122ecd81cf73d60adaa

From 90cf53b959ed014946a7d0d87841f5924d5b74ad Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Tue, 2 Apr 2024 17:27:29 -0400
Subject: [PATCH 42/50] Someone forgot to merge assets from the credits branch.

---
 assets                                         | 2 +-
 source/funkin/ui/credits/CreditsDataHandler.hx | 5 +++++
 2 files changed, 6 insertions(+), 1 deletion(-)

diff --git a/assets b/assets
index a54eb8517..3ccfe33ac 160000
--- a/assets
+++ b/assets
@@ -1 +1 @@
-Subproject commit a54eb8517914e2a90b77e122ecd81cf73d60adaa
+Subproject commit 3ccfe33acef6e62c40317af583af764838544a24
diff --git a/source/funkin/ui/credits/CreditsDataHandler.hx b/source/funkin/ui/credits/CreditsDataHandler.hx
index f2722ffbf..86afdafd1 100644
--- a/source/funkin/ui/credits/CreditsDataHandler.hx
+++ b/source/funkin/ui/credits/CreditsDataHandler.hx
@@ -20,7 +20,12 @@ class CreditsDataHandler
     if (data == null)
     {
       trace('CreditsData(NULL)');
+      return;
+    }
 
+    if (data.entries == null || data.entries.length == 0)
+    {
+      trace('CreditsData(EMPTY)');
       return;
     }
 

From f7ff381bc7d526977d55a61f059fc60de747ef4a Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Tue, 2 Apr 2024 23:33:10 -0400
Subject: [PATCH 43/50] Fix some build issues with HTML5.

---
 source/funkin/ui/debug/latency/LatencyState.hx | 5 +----
 source/funkin/ui/transition/LoadingState.hx    | 4 ++--
 2 files changed, 3 insertions(+), 6 deletions(-)

diff --git a/source/funkin/ui/debug/latency/LatencyState.hx b/source/funkin/ui/debug/latency/LatencyState.hx
index 7b2eabb1c..875a956e0 100644
--- a/source/funkin/ui/debug/latency/LatencyState.hx
+++ b/source/funkin/ui/debug/latency/LatencyState.hx
@@ -171,10 +171,7 @@ class LatencyState extends MusicBeatSubState
       trace(FlxG.sound.music._channel.position);
      */
 
-    #if FLX_DEBUG
-    funnyStatsGraph.update(FlxG.sound.music.time % 500);
-    realStats.update(swagSong.getTimeWithDiff() % 500);
-    #end
+    localConductor.update(swagSong.time, false);
 
     if (FlxG.keys.justPressed.S)
     {
diff --git a/source/funkin/ui/transition/LoadingState.hx b/source/funkin/ui/transition/LoadingState.hx
index e4f4bf004..af8798ae2 100644
--- a/source/funkin/ui/transition/LoadingState.hx
+++ b/source/funkin/ui/transition/LoadingState.hx
@@ -222,7 +222,7 @@ class LoadingState extends MusicBeatSubState
 
     #if NO_PRELOAD_ALL
     // Switch to loading state while we load assets (default on HTML5 target).
-    var loadStateCtor:NextState = function() {
+    var loadStateCtor = function() {
       var result = new LoadingState(playStateCtor, shouldStopMusic, params);
       @:privateAccess
       result.asSubState = asSubState;
@@ -230,7 +230,7 @@ class LoadingState extends MusicBeatSubState
     }
     if (asSubState)
     {
-      FlxG.state.openSubState(loadStateCtor);
+      FlxG.state.openSubState(cast loadStateCtor());
     }
     else
     {

From 2b4bf42ac4c55ec3524be7d0109e6814e241bf6a Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Wed, 3 Apr 2024 01:40:08 -0400
Subject: [PATCH 44/50] Fix multiple music, and crashes in freeplay

---
 source/funkin/audio/FunkinSound.hx         | 20 +++++++-------------
 source/funkin/ui/freeplay/FreeplayState.hx |  9 +++++++--
 2 files changed, 14 insertions(+), 15 deletions(-)

diff --git a/source/funkin/audio/FunkinSound.hx b/source/funkin/audio/FunkinSound.hx
index 100cee262..8c1bf3b41 100644
--- a/source/funkin/audio/FunkinSound.hx
+++ b/source/funkin/audio/FunkinSound.hx
@@ -321,6 +321,13 @@ class FunkinSound extends FlxSound implements ICloneable<FunkinSound>
       }
     }
 
+    if (FlxG.sound.music != null)
+    {
+      FlxG.sound.music.fadeTween?.cancel();
+      FlxG.sound.music.stop();
+      FlxG.sound.music.kill();
+    }
+
     if (params?.mapTimeChanges ?? true)
     {
       var songMusicData:Null<SongMusicData> = SongRegistry.instance.parseMusicData(key);
@@ -335,19 +342,6 @@ class FunkinSound extends FlxSound implements ICloneable<FunkinSound>
       }
     }
 
-    if (FlxG.sound.music != null)
-    {
-      FlxG.sound.music.fadeTween?.cancel();
-      FlxG.sound.music.stop();
-      FlxG.sound.music.kill();
-    }
-
-    // Apparently HaxeFlixel isn't null safe.
-    @:nullSafety(Off)
-    {
-      FlxG.sound.music = FunkinSound.load(Paths.music('$key/$key'), params?.startingVolume ?? 1.0, true, false, true);
-    }
-
     var music = FunkinSound.load(Paths.music('$key/$key'), params?.startingVolume ?? 1.0, params.loop ?? true, false, true);
     if (music != null)
     {
diff --git a/source/funkin/ui/freeplay/FreeplayState.hx b/source/funkin/ui/freeplay/FreeplayState.hx
index 531167a95..455805479 100644
--- a/source/funkin/ui/freeplay/FreeplayState.hx
+++ b/source/funkin/ui/freeplay/FreeplayState.hx
@@ -134,7 +134,7 @@ class FreeplayState extends MusicBeatSubState
   var stickerSubState:StickerSubState;
 
   public static var rememberedDifficulty:Null<String> = Constants.DEFAULT_DIFFICULTY;
-  public static var rememberedSongId:Null<String> = null;
+  public static var rememberedSongId:Null<String> = 'tutorial';
 
   public function new(?params:FreeplayStateParams, ?stickers:StickerSubState)
   {
@@ -596,7 +596,7 @@ class FreeplayState extends MusicBeatSubState
 
     // Only now do we know that the filter is actually changing.
 
-    rememberedSongId = grpCapsules.members[curSelected]?.songData?.songId;
+    rememberedSongId = grpCapsules.members[curSelected]?.songData?.songId ?? rememberedSongId;
 
     for (cap in grpCapsules.members)
     {
@@ -939,6 +939,11 @@ class FreeplayState extends MusicBeatSubState
         FlxTransitionableState.skipNextTransOut = true;
         if (Type.getClass(FlxG.state) == MainMenuState)
         {
+          FunkinSound.playMusic('freakyMenu',
+            {
+              overrideExisting: true,
+              restartTrack: false
+            });
           close();
         }
         else

From f7141e7096f25a4a3a9986afe6c26357376ae49e Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Wed, 3 Apr 2024 01:01:58 -0400
Subject: [PATCH 45/50] Fixed an issue with save data not loading defaults
 properly.

---
 source/funkin/save/Save.hx                    |  8 +--
 .../funkin/save/migrator/SaveDataMigrator.hx  |  3 +-
 source/funkin/ui/freeplay/FreeplayState.hx    |  2 +-
 source/funkin/util/StructureUtil.hx           | 61 +++++++++++++++++++
 4 files changed, 68 insertions(+), 6 deletions(-)
 create mode 100644 source/funkin/util/StructureUtil.hx

diff --git a/source/funkin/save/Save.hx b/source/funkin/save/Save.hx
index 73ba8efa0..dc7c5f989 100644
--- a/source/funkin/save/Save.hx
+++ b/source/funkin/save/Save.hx
@@ -14,7 +14,7 @@ import thx.semver.Version;
 class Save
 {
   // Version 2.0.2 adds attributes to `optionsChartEditor`, that should return default values if they are null.
-  public static final SAVE_DATA_VERSION:thx.semver.Version = "2.0.2";
+  public static final SAVE_DATA_VERSION:thx.semver.Version = "2.0.3";
   public static final SAVE_DATA_VERSION_RULE:thx.semver.VersionRule = "2.0.x";
 
   // We load this version's saves from a new save path, to maintain SOME level of backwards compatibility.
@@ -650,9 +650,9 @@ class Save
       if (legacySaveData != null)
       {
         trace('[SAVE] Found legacy save data, converting...');
-        var gameSave = SaveDataMigrator.migrate(legacySaveData);
+        var gameSave = SaveDataMigrator.migrateFromLegacy(legacySaveData);
         @:privateAccess
-        FlxG.save.mergeData(gameSave.data);
+        FlxG.save.mergeData(gameSave.data, true);
       }
       else
       {
@@ -664,7 +664,7 @@ class Save
       trace('[SAVE] Loaded save data.');
       @:privateAccess
       var gameSave = SaveDataMigrator.migrate(FlxG.save.data);
-      FlxG.save.mergeData(gameSave.data);
+      FlxG.save.mergeData(gameSave.data, true);
     }
   }
 
diff --git a/source/funkin/save/migrator/SaveDataMigrator.hx b/source/funkin/save/migrator/SaveDataMigrator.hx
index 00637d52a..3ed59e726 100644
--- a/source/funkin/save/migrator/SaveDataMigrator.hx
+++ b/source/funkin/save/migrator/SaveDataMigrator.hx
@@ -3,6 +3,7 @@ package funkin.save.migrator;
 import funkin.save.Save;
 import funkin.save.migrator.RawSaveData_v1_0_0;
 import thx.semver.Version;
+import funkin.util.StructureUtil;
 import funkin.util.VersionUtil;
 
 @:nullSafety
@@ -26,7 +27,7 @@ class SaveDataMigrator
       if (VersionUtil.validateVersion(version, Save.SAVE_DATA_VERSION_RULE))
       {
         // Simply import the structured data.
-        var save:Save = new Save(inputData);
+        var save:Save = new Save(StructureUtil.deepMerge(Save.getDefault(), inputData));
         return save;
       }
       else
diff --git a/source/funkin/ui/freeplay/FreeplayState.hx b/source/funkin/ui/freeplay/FreeplayState.hx
index 455805479..66c829e11 100644
--- a/source/funkin/ui/freeplay/FreeplayState.hx
+++ b/source/funkin/ui/freeplay/FreeplayState.hx
@@ -962,7 +962,7 @@ class FreeplayState extends MusicBeatSubState
   public override function destroy():Void
   {
     super.destroy();
-    var daSong:Null<FreeplaySongData> = grpCapsules.members[curSelected]?.songData;
+    var daSong:Null<FreeplaySongData> = currentFilteredSongs[curSelected];
     if (daSong != null)
     {
       clearDaCache(daSong.songName);
diff --git a/source/funkin/util/StructureUtil.hx b/source/funkin/util/StructureUtil.hx
new file mode 100644
index 000000000..351d0e0a8
--- /dev/null
+++ b/source/funkin/util/StructureUtil.hx
@@ -0,0 +1,61 @@
+package funkin.util;
+
+import haxe.DynamicAccess;
+
+/**
+ * Utilities for working with anonymous structures.
+ */
+class StructureUtil
+{
+  /**
+   * Merge two structures, with the second overwriting the first.
+   * Performs a SHALLOW clone, where child structures are not merged.
+   * @param a The base structure.
+   * @param b The new structure.
+   * @return The merged structure.
+   */
+  public static function merge(a:Dynamic, b:Dynamic):Dynamic
+  {
+    var result:DynamicAccess<Dynamic> = Reflect.copy(a);
+
+    for (field in Reflect.fields(b))
+    {
+      result.set(field, Reflect.field(b, field));
+    }
+
+    return result;
+  }
+
+  /**
+   * Merge two structures, with the second overwriting the first.
+   * Performs a DEEP clone, where child structures are also merged recursively.
+   * @param a The base structure.
+   * @param b The new structure.
+   * @return The merged structure.
+   */
+  public static function deepMerge(a:Dynamic, b:Dynamic):Dynamic
+  {
+    if (a == null) return b;
+    if (b == null) return null;
+    if (!Reflect.isObject(a) || !Reflect.isObject(b)) return b;
+
+    var result:DynamicAccess<Dynamic> = Reflect.copy(a);
+
+    for (field in Reflect.fields(b))
+    {
+      if (Reflect.isObject(b))
+      {
+        // Note that isObject also returns true for class instances,
+        // but we just assume that's not a problem here.
+        result.set(field, deepMerge(Reflect.field(result, field), Reflect.field(b, field)));
+      }
+      else
+      {
+        // If we're here, b[field] is a primitive.
+        result.set(field, Reflect.field(b, field));
+      }
+    }
+
+    return result;
+  }
+}

From ad39ce3c2154ac7cb48723f570226c3462b2c606 Mon Sep 17 00:00:00 2001
From: Cameron Taylor <cameron.taylor.ninja@gmail.com>
Date: Wed, 3 Apr 2024 04:43:12 -0400
Subject: [PATCH 46/50] local conductor got removed somewhere?

---
 source/funkin/ui/debug/latency/LatencyState.hx | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/source/funkin/ui/debug/latency/LatencyState.hx b/source/funkin/ui/debug/latency/LatencyState.hx
index 875a956e0..620f0edd7 100644
--- a/source/funkin/ui/debug/latency/LatencyState.hx
+++ b/source/funkin/ui/debug/latency/LatencyState.hx
@@ -171,7 +171,7 @@ class LatencyState extends MusicBeatSubState
       trace(FlxG.sound.music._channel.position);
      */
 
-    localConductor.update(swagSong.time, false);
+    // localConductor.update(swagSong.time, false);
 
     if (FlxG.keys.justPressed.S)
     {

From 4f2f28cb317f38ee506548061a3ef9d211fbdf02 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Wed, 3 Apr 2024 15:05:54 -0400
Subject: [PATCH 47/50] Fix issue with deepMerge() caused by handling maps
 incorrectly, causing an unhandleable crash.

---
 source/funkin/util/StructureUtil.hx | 64 +++++++++++++++++++++++++++++
 1 file changed, 64 insertions(+)

diff --git a/source/funkin/util/StructureUtil.hx b/source/funkin/util/StructureUtil.hx
index 351d0e0a8..f94de4652 100644
--- a/source/funkin/util/StructureUtil.hx
+++ b/source/funkin/util/StructureUtil.hx
@@ -1,5 +1,6 @@
 package funkin.util;
 
+import funkin.util.tools.MapTools;
 import haxe.DynamicAccess;
 
 /**
@@ -26,6 +27,57 @@ class StructureUtil
     return result;
   }
 
+  public static function toMap(a:Dynamic):haxe.ds.Map<String, Dynamic>
+  {
+    var result:haxe.ds.Map<String, Dynamic> = [];
+
+    for (field in Reflect.fields(a))
+    {
+      result.set(field, Reflect.field(a, field));
+    }
+
+    return result;
+  }
+
+  public static function isMap(a:Dynamic):Bool
+  {
+    return Std.isOfType(a, haxe.Constraints.IMap);
+  }
+
+  public static function isObject(a:Dynamic):Bool
+  {
+    switch (Type.typeof(a))
+    {
+      case TObject:
+        return true;
+      default:
+        return false;
+    }
+  }
+
+  public static function isPrimitive(a:Dynamic):Bool
+  {
+    switch (Type.typeof(a))
+    {
+      case TInt | TFloat | TBool:
+        return true;
+      case TClass(c):
+        return false;
+      case TEnum(e):
+        return false;
+      case TObject:
+        return false;
+      case TFunction:
+        return false;
+      case TNull:
+        return true;
+      case TUnknown:
+        return false;
+      default:
+        return false;
+    }
+  }
+
   /**
    * Merge two structures, with the second overwriting the first.
    * Performs a DEEP clone, where child structures are also merged recursively.
@@ -37,6 +89,18 @@ class StructureUtil
   {
     if (a == null) return b;
     if (b == null) return null;
+    if (isPrimitive(a) && isPrimitive(b)) return b;
+    if (isMap(b))
+    {
+      if (isMap(a))
+      {
+        return MapTools.merge(a, b);
+      }
+      else
+      {
+        return StructureUtil.toMap(a).merge(b);
+      }
+    }
     if (!Reflect.isObject(a) || !Reflect.isObject(b)) return b;
 
     var result:DynamicAccess<Dynamic> = Reflect.copy(a);

From b39712d33f6d36ddcf14c8fb050572abb9562549 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Wed, 3 Apr 2024 20:31:34 -0400
Subject: [PATCH 48/50] Prevent crashes when the game attempts to load bad save
 data.

---
 hmm.json                             |  2 +-
 source/funkin/save/Save.hx           |  4 +++
 source/funkin/util/SerializerUtil.hx | 48 ++++++++++++++++++++++++++++
 3 files changed, 53 insertions(+), 1 deletion(-)

diff --git a/hmm.json b/hmm.json
index 0dfe88ded..641ef1bbd 100644
--- a/hmm.json
+++ b/hmm.json
@@ -139,7 +139,7 @@
       "name": "openfl",
       "type": "git",
       "dir": null,
-      "ref": "f229d76361c7e31025a048fe7909847f75bb5d5e",
+      "ref": "228c1b5063911e2ad75cef6e3168ef0a4b9f9134",
       "url": "https://github.com/FunkinCrew/openfl"
     },
     {
diff --git a/source/funkin/save/Save.hx b/source/funkin/save/Save.hx
index dc7c5f989..6f2146a7a 100644
--- a/source/funkin/save/Save.hx
+++ b/source/funkin/save/Save.hx
@@ -9,6 +9,7 @@ import funkin.save.migrator.SaveDataMigrator;
 import funkin.ui.debug.charting.ChartEditorState.ChartEditorLiveInputStyle;
 import funkin.ui.debug.charting.ChartEditorState.ChartEditorTheme;
 import thx.semver.Version;
+import funkin.util.SerializerUtil;
 
 @:nullSafety
 class Save
@@ -641,6 +642,9 @@ class Save
   {
     trace("[SAVE] Loading save from slot " + slot + "...");
 
+    // Prevent crashes if the save data is corrupted.
+    SerializerUtil.initSerializer();
+
     FlxG.save.bind('$SAVE_NAME${slot}', SAVE_PATH);
 
     if (FlxG.save.isEmpty())
diff --git a/source/funkin/util/SerializerUtil.hx b/source/funkin/util/SerializerUtil.hx
index c87d3f6c0..fa602cc73 100644
--- a/source/funkin/util/SerializerUtil.hx
+++ b/source/funkin/util/SerializerUtil.hx
@@ -63,6 +63,31 @@ class SerializerUtil
     }
   }
 
+  public static function initSerializer():Void
+  {
+    haxe.Unserializer.DEFAULT_RESOLVER = new FunkinTypeResolver();
+  }
+
+  /**
+   * Serialize a Haxe object using the built-in Serializer.
+   * @param input The object to serialize
+   * @return The serialized object as a string
+   */
+  public static function fromHaxeObject(input:Dynamic):String
+  {
+    return haxe.Serializer.run(input);
+  }
+
+  /**
+   * Convert a serialized Haxe object back into a Haxe object.
+   * @param input The serialized object as a string
+   * @return The deserialized object
+   */
+  public static function toHaxeObject(input:String):Dynamic
+  {
+    return haxe.Unserializer.run(input);
+  }
+
   /**
    * Customize how certain types are serialized when converting to JSON.
    */
@@ -90,3 +115,26 @@ class SerializerUtil
     return result;
   }
 }
+
+class FunkinTypeResolver
+{
+  public function new()
+  {
+    // Blank constructor.
+  }
+
+  public function resolveClass(name:String):Class<Dynamic>
+  {
+    if (name == 'Dynamic')
+    {
+      FlxG.log.warn('Found invalid class type in save data, indicates partial save corruption.');
+      return null;
+    }
+    return Type.resolveClass(name);
+  };
+
+  public function resolveEnum(name:String):Enum<Dynamic>
+  {
+    return Type.resolveEnum(name);
+  };
+}

From d8903f138f0a3f9c3c8b65f7dcb26630e2453bac Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Wed, 3 Apr 2024 20:50:51 -0400
Subject: [PATCH 49/50] Additional save fix

---
 source/funkin/save/Save.hx | 16 ++++++++++++++++
 1 file changed, 16 insertions(+)

diff --git a/source/funkin/save/Save.hx b/source/funkin/save/Save.hx
index 6f2146a7a..af2730ddd 100644
--- a/source/funkin/save/Save.hx
+++ b/source/funkin/save/Save.hx
@@ -392,6 +392,22 @@ class Save
    */
   public function getLevelScore(levelId:String, difficultyId:String = 'normal'):Null<SaveScoreData>
   {
+    if (data.scores?.levels == null)
+    {
+      if (data.scores == null)
+      {
+        data.scores =
+          {
+            songs: [],
+            levels: []
+          };
+      }
+      else
+      {
+        data.scores.levels = [];
+      }
+    }
+
     var level = data.scores.levels.get(levelId);
     if (level == null)
     {

From 3ac466aa5ea866573e4e9181630b566e1416e444 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Thu, 4 Apr 2024 03:35:36 -0400
Subject: [PATCH 50/50] Add missing MapTools function from #459

---
 source/funkin/util/tools/MapTools.hx | 18 ++++++++++++++++++
 1 file changed, 18 insertions(+)

diff --git a/source/funkin/util/tools/MapTools.hx b/source/funkin/util/tools/MapTools.hx
index 1399fb791..b98cb0adf 100644
--- a/source/funkin/util/tools/MapTools.hx
+++ b/source/funkin/util/tools/MapTools.hx
@@ -33,6 +33,24 @@ class MapTools
     return map.copy();
   }
 
+  /**
+   * Create a new map which is a combination of the two given maps.
+   * @param a The base map.
+   * @param b The other map. The values from this take precedence.
+   * @return The combined map.
+   */
+  public static function merge<K, T>(a:Map<K, T>, b:Map<K, T>):Map<K, T>
+  {
+    var result = a.copy();
+
+    for (pair in b.keyValueIterator())
+    {
+      result.set(pair.key, pair.value);
+    }
+
+    return result;
+  }
+
   /**
    * Create a new array with clones of all elements of the given array, to prevent modifying the original.
    */