diff --git a/.github/ISSUE_TEMPLATE/bug.md b/.github/ISSUE_TEMPLATE/bug.md
index 45b983be3..32f95c0d1 100644
--- a/.github/ISSUE_TEMPLATE/bug.md
+++ b/.github/ISSUE_TEMPLATE/bug.md
@@ -1 +1 @@
-hi
+hi
\ No newline at end of file
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 35618dca9..755b3b3bc 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,35 @@ All notable changes will be documented in this file.
 The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
 and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
+## [0.4.1] - 2024-06-12
+### Added
+- Pressing ESCAPE on the title screen on desktop now exits the game, allowing you to exit the game while in fullscreen on desktop
+- Freeplay menu controls (favoriting and switching categories) are now rebindable from the Options menu, and now have default binds on controllers.
+### Changed
+- Highscores and ranks are now saved separately, which fixes the issue where people would overwrite their saves with higher scores,
+which would remove their rank if they had a lower one.
+- A-Bot speaker now reacts to the user's volume preference on desktop (thanks to [M7theguy for the issue report/suggestion](https://github.com/FunkinCrew/Funkin/issues/2744)!)
+- On Freeplay, heart icons are shifted to the right when you favorite a song that has no rank on it.
+- Only play `scrollMenu` sound effect when there's a real change on the freeplay menu ([thanks gamerbross for the PR!](https://github.com/FunkinCrew/Funkin/pull/2741))
+- Gave antialiasing to the edge of the dad graphic on Freeplay
+- Rearranged some controls in the controls menu
+- Made several chart revisions
+  - Re-enabled custom camera events in Roses (Erect/Nightmare)
+  - Tweaked the chart for Lit Up (Hard)
+  - Corrected the difficulty ratings for M.I.L.F. (Easy/Normal/Hard)
+### Fixed
+- Fixed an issue in the controls menu where some control binds would overlap their names
+- Fixed crash when attempting to exit the gameover screen when also attempting to retry the song ([thanks DMMaster636 for the PR!](https://github.com/FunkinCrew/Funkin/pull/2709))
+- Fix botplay sustain release bug ([thanks Hundrec!](Fix botplay sustain release bug #2683))
+- Fix for the camera not pausing during a gameplay pause ([thanks gamerbross!](https://github.com/FunkinCrew/Funkin/pull/2684))
+- Fixed issue where Pico's gameplay sprite would unintentionally appear on the gameover screen when dying on 2Hot from an explosion
+- Freeplay previews properly fade volume during the BF idle animation
+- Fixed bug where Dadbattle incorrectly appeared as Dadbattle Erect when returning to freeplay on Hard
+- Fixed 2Hot not appearing under the "#" category in Freeplay menu
+- Fixed a bug where the Chart Editor would crash when attempting to select an event with the Event toolbox open
+- Improved offsets for Pico and Tankman opponents so they don't slide around as much.
+- Fixed the black "temp" graphic on freeplay from being incorrectly sized / masked, now it's identical to the dad freeplay graphic
+
 ## [0.4.0] - 2024-06-06
 ### Added
 - 2 new Erect remixes, Eggnog and Satin Panties. Check them out from the Freeplay menu!
@@ -32,11 +61,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 - Custom note styles are now properly supported for songs; add new notestyles via JSON, then select it for use from the Chart Editor Metadata toolbox. (thanks Keoiki!)
 - Health icons now support a Winning frame without requiring a spritesheet, simply include a third frame in the icon file. (thanks gamerbross!)
   - Remember that for more complex behaviors such as animations or transitions, you should use an XML file to define each frame.
+- Improved the Event Toolbox in the Chart Editor; dropdowns are now bigger, include search field, and display elements in alphabetical order rather than a random order.
 ### Fixed
 - Fixed an issue where Nene's visualizer would not play on Desktop builds
 - Fixed a bug where the game would silently fail to load saves on HTML5
 - Fixed some bugs with the props on the Story Menu not bopping properly
-- Improved offsets for Pico and Tankman opponents so they don't slide around as much.
+- Additional fixes to the Loading bar on HTML5 (thanks lemz1!)
+- Fixed several bugs with the TitleState, including missing music when returning from the Main Menu (thanks gamerbross!)
+- Fixed a camera bug in the Main Menu (thanks richTrash21!)
+- Fixed a bug where changing difficulties in Story mode wouldn't update the score (thanks sectorA!)
+- Fixed a crash in Freeplay caused by a level referencing an invalid song (thanks gamerbross!)
+- Fixed a bug where pressing the volume keys would stop the Toy commercial (thanks gamerbross!)
+- Fixed a bug where the Chart Editor Playtest would crash when losing (thanks gamerbross!)
+- Fixed a bug where hold notes would display improperly in the Chart Editor when downscroll was enabled for gameplay (thanks gamerbross!)
+- Fixed a bug where hold notes would be positioned wrong on downscroll (thanks MaybeMaru!)
+- Removed a large number of unused imports to optimize builds (thanks Ethan-makes-music!)
+- Improved debug logging for unscripted stages (thanks gamerbross!)
+- Made improvements to compiling documentation (thanks gedehari!)
 - Fixed a crash on Linux caused by an old version of hxCodec (thanks Noobz4Life!)
 - Optimized animation handling for characters (thanks richTrash21!)
 - Made improvements to compiling documentation (thanks gedehari!)
diff --git a/Project.xml b/Project.xml
index e0e25883d..fae9c768b 100644
--- a/Project.xml
+++ b/Project.xml
@@ -2,7 +2,7 @@
 <project xmlns="http://lime.openfl.org/project/1.0.4" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
 xsi:schemaLocation="http://lime.openfl.org/project/1.0.4 http://lime.openfl.org/xsd/project-1.0.4.xsd">
 	<!-- _________________________ Application Settings _________________________ -->
-	<app title="Friday Night Funkin'" file="Funkin" packageName="com.funkin.fnf" package="com.funkin.fnf" main="Main" version="0.4.0" company="ninjamuffin99" />
+	<app title="Friday Night Funkin'" file="Funkin" packageName="com.funkin.fnf" package="com.funkin.fnf" main="Main" version="0.4.1" company="ninjamuffin99" />
 	<!--Switch Export with Unique ApplicationID and Icon-->
 	<set name="APP_ID" value="0x0100f6c013bbc000" />
 
diff --git a/art b/art
index 66572f85d..faeba700c 160000
--- a/art
+++ b/art
@@ -1 +1 @@
-Subproject commit 66572f85d826ce2ec1d45468c12733b161237ffa
+Subproject commit faeba700c5526bd4fd57ccc927d875c82b9d3553
diff --git a/assets b/assets
index 3b8235e95..2e1594ee4 160000
--- a/assets
+++ b/assets
@@ -1 +1 @@
-Subproject commit 3b8235e953505a6fe7f4ff253f5a99b9a7b9857a
+Subproject commit 2e1594ee4c04c7148628bae471bdd061c9deb6b7
diff --git a/docs/COMPILING.md b/docs/COMPILING.md
index e4bd8d7dd..e7c19875a 100644
--- a/docs/COMPILING.md
+++ b/docs/COMPILING.md
@@ -22,4 +22,5 @@
 
 # Troubleshooting
 
-- During the cloning process, you may experience an error along the lines of `error: RPC failed; curl 92 HTTP/2 stream 0 was not closed cleanly: PROTOCOL_ERROR (err 1)` due to poor connectivity. A common fix is to run ` git config --global http.postBuffer 4096M`.
\ No newline at end of file
+- During the cloning process, you may experience an error along the lines of `error: RPC failed; curl 92 HTTP/2 stream 0 was not closed cleanly: PROTOCOL_ERROR (err 1)` due to poor connectivity. A common fix is to run ` git config --global http.postBuffer 4096M`.
+
diff --git a/source/funkin/audio/FunkinSound.hx b/source/funkin/audio/FunkinSound.hx
index 4f61e70c2..c70f195d2 100644
--- a/source/funkin/audio/FunkinSound.hx
+++ b/source/funkin/audio/FunkinSound.hx
@@ -227,12 +227,12 @@ class FunkinSound extends FlxSound implements ICloneable<FunkinSound>
     // already paused before we lost focus.
     if (_lostFocus && !_alreadyPaused)
     {
-      trace('Resuming audio (${this._label}) on focus!');
+      // trace('Resuming audio (${this._label}) on focus!');
       resume();
     }
     else
     {
-      trace('Not resuming audio (${this._label}) on focus!');
+      // trace('Not resuming audio (${this._label}) on focus!');
     }
     _lostFocus = false;
   }
@@ -242,7 +242,7 @@ class FunkinSound extends FlxSound implements ICloneable<FunkinSound>
    */
   override function onFocusLost():Void
   {
-    trace('Focus lost, pausing audio!');
+    // trace('Focus lost, pausing audio!');
     _lostFocus = true;
     _alreadyPaused = _paused;
     pause();
diff --git a/source/funkin/audio/visualize/ABotVis.hx b/source/funkin/audio/visualize/ABotVis.hx
index 1b0463144..cf43a8add 100644
--- a/source/funkin/audio/visualize/ABotVis.hx
+++ b/source/funkin/audio/visualize/ABotVis.hx
@@ -54,12 +54,12 @@ class ABotVis extends FlxTypedSpriteGroup<FlxSprite>
   public function initAnalyzer()
   {
     @:privateAccess
-    analyzer = new SpectralAnalyzer(snd._channel.__source, 7, 0.1, 30);
+    analyzer = new SpectralAnalyzer(snd._channel.__source, 7, 0.1, 40);
 
     #if desktop
     // On desktop it uses FFT stuff that isn't as optimized as the direct browser stuff we use on HTML5
     // So we want to manually change it!
-    analyzer.fftN = 512;
+    analyzer.fftN = 256;
     #end
 
     // analyzer.maxDb = -35;
@@ -101,6 +101,10 @@ class ABotVis extends FlxTypedSpriteGroup<FlxSprite>
     {
       var animFrame:Int = Math.round(levels[i].value * 5);
 
+      #if desktop
+      animFrame = Math.round(animFrame * FlxG.sound.volume);
+      #end
+
       animFrame = Math.floor(Math.min(5, animFrame));
       animFrame = Math.floor(Math.max(0, animFrame));
 
diff --git a/source/funkin/graphics/shaders/AngleMask.hx b/source/funkin/graphics/shaders/AngleMask.hx
index 30e508a58..c5ef87b72 100644
--- a/source/funkin/graphics/shaders/AngleMask.hx
+++ b/source/funkin/graphics/shaders/AngleMask.hx
@@ -5,35 +5,73 @@ import flixel.system.FlxAssets.FlxShader;
 class AngleMask extends FlxShader
 {
   @:glFragmentSource('
-		#pragma header
-        uniform vec2 endPosition;
-		void main()
-		{
-			vec4 base = texture2D(bitmap, openfl_TextureCoordv);
+    #pragma header
 
-            vec2 uv = openfl_TextureCoordv.xy;
+    uniform vec2 endPosition;
+    vec2 hash22(vec2 p) {
+      vec3 p3 = fract(vec3(p.xyx) * vec3(.1031, .1030, .0973));
+      p3 += dot(p3, p3.yzx + 33.33);
+      return fract((p3.xx + p3.yz) * p3.zy);
+    }
 
 
 
-            vec2 start = vec2(0.0, 0.0);
-            vec2 end = vec2(endPosition.x / openfl_TextureSize.x, 1.0);
+    // ====== GAMMA CORRECTION ====== //
+    // Helps with color mixing -- good to have by default in almost any shader
+    // See https://www.shadertoy.com/view/lscSzl
+    vec3 gamma(in vec3 color) {
+      return pow(color, vec3(1.0 / 2.2));
+    }
 
-            float dx = end.x - start.x;
-            float dy = end.y - start.y;
+    vec4 mainPass(vec2 fragCoord) {
+      vec4 base = texture2D(bitmap, fragCoord);
 
-            float angle = atan(dy, dx);
+      vec2 uv = fragCoord.xy;
 
-            uv.x -= start.x;
-            uv.y -= start.y;
+      vec2 start = vec2(0.0, 0.0);
+      vec2 end = vec2(endPosition.x / openfl_TextureSize.x, 1.0);
 
-            float uvA = atan(uv.y, uv.x);
+      float dx = end.x - start.x;
+      float dy = end.y - start.y;
 
-            if (uvA < angle)
-                gl_FragColor = base;
-            else
-                gl_FragColor = vec4(0.0);
+      float angle = atan(dy, dx);
 
-		}')
+      uv.x -= start.x;
+      uv.y -= start.y;
+
+      float uvA = atan(uv.y, uv.x);
+
+      if (uvA < angle)
+        return base;
+      else
+        return vec4(0.0);
+    }
+
+    vec4 antialias(vec2 fragCoord) {
+
+      const float AA_STAGES = 2.0;
+
+      const float AA_TOTAL_PASSES = AA_STAGES * AA_STAGES + 1.0;
+      const float AA_JITTER = 0.5;
+
+      // Run the shader multiple times with a random subpixel offset each time and average the results
+      vec4 color = mainPass(fragCoord);
+      for (float x = 0.0; x < AA_STAGES; x++)
+      {
+          for (float y = 0.0; y < AA_STAGES; y++)
+          {
+              vec2 offset = AA_JITTER * (2.0 * hash22(vec2(x, y)) - 1.0) / openfl_TextureSize.xy;
+              color += mainPass(fragCoord + offset);
+          }
+      }
+      return color / AA_TOTAL_PASSES;
+    }
+
+    void main() {
+      vec4 col = antialias(openfl_TextureCoordv);
+      // col.xyz = gamma(col.xyz);
+      gl_FragColor = col;
+    }')
   public function new()
   {
     super();
diff --git a/source/funkin/input/Controls.hx b/source/funkin/input/Controls.hx
index 345791eef..e2cae5613 100644
--- a/source/funkin/input/Controls.hx
+++ b/source/funkin/input/Controls.hx
@@ -58,7 +58,11 @@ class Controls extends FlxActionSet
   var _back = new FunkinAction(Action.BACK);
   var _pause = new FunkinAction(Action.PAUSE);
   var _reset = new FunkinAction(Action.RESET);
-  var _screenshot = new FunkinAction(Action.SCREENSHOT);
+  var _window_screenshot = new FunkinAction(Action.WINDOW_SCREENSHOT);
+  var _window_fullscreen = new FunkinAction(Action.WINDOW_FULLSCREEN);
+  var _freeplay_favorite = new FunkinAction(Action.FREEPLAY_FAVORITE);
+  var _freeplay_left = new FunkinAction(Action.FREEPLAY_LEFT);
+  var _freeplay_right = new FunkinAction(Action.FREEPLAY_RIGHT);
   var _cutscene_advance = new FunkinAction(Action.CUTSCENE_ADVANCE);
   var _debug_menu = new FunkinAction(Action.DEBUG_MENU);
   var _debug_chart = new FunkinAction(Action.DEBUG_CHART);
@@ -66,7 +70,6 @@ class Controls extends FlxActionSet
   var _volume_up = new FunkinAction(Action.VOLUME_UP);
   var _volume_down = new FunkinAction(Action.VOLUME_DOWN);
   var _volume_mute = new FunkinAction(Action.VOLUME_MUTE);
-  var _fullscreen = new FunkinAction(Action.FULLSCREEN);
 
   var byName:Map<String, FunkinAction> = new Map<String, FunkinAction>();
 
@@ -233,10 +236,30 @@ class Controls extends FlxActionSet
   inline function get_RESET()
     return _reset.check();
 
-  public var SCREENSHOT(get, never):Bool;
+  public var WINDOW_FULLSCREEN(get, never):Bool;
 
-  inline function get_SCREENSHOT()
-    return _screenshot.check();
+  inline function get_WINDOW_FULLSCREEN()
+    return _window_fullscreen.check();
+
+  public var WINDOW_SCREENSHOT(get, never):Bool;
+
+  inline function get_WINDOW_SCREENSHOT()
+    return _window_screenshot.check();
+
+  public var FREEPLAY_FAVORITE(get, never):Bool;
+
+  inline function get_FREEPLAY_FAVORITE()
+    return _freeplay_favorite.check();
+
+  public var FREEPLAY_LEFT(get, never):Bool;
+
+  inline function get_FREEPLAY_LEFT()
+    return _freeplay_left.check();
+
+  public var FREEPLAY_RIGHT(get, never):Bool;
+
+  inline function get_FREEPLAY_RIGHT()
+    return _freeplay_right.check();
 
   public var CUTSCENE_ADVANCE(get, never):Bool;
 
@@ -273,11 +296,6 @@ class Controls extends FlxActionSet
   inline function get_VOLUME_MUTE()
     return _volume_mute.check();
 
-  public var FULLSCREEN(get, never):Bool;
-
-  inline function get_FULLSCREEN()
-    return _fullscreen.check();
-
   public function new(name, scheme:KeyboardScheme = null)
   {
     super(name);
@@ -294,7 +312,11 @@ class Controls extends FlxActionSet
     add(_back);
     add(_pause);
     add(_reset);
-    add(_screenshot);
+    add(_window_screenshot);
+    add(_window_fullscreen);
+    add(_freeplay_favorite);
+    add(_freeplay_left);
+    add(_freeplay_right);
     add(_cutscene_advance);
     add(_debug_menu);
     add(_debug_chart);
@@ -302,7 +324,6 @@ class Controls extends FlxActionSet
     add(_volume_up);
     add(_volume_down);
     add(_volume_mute);
-    add(_fullscreen);
 
     for (action in digitalActions) {
       if (Std.isOfType(action, FunkinAction)) {
@@ -398,7 +419,11 @@ class Controls extends FlxActionSet
       case BACK: _back;
       case PAUSE: _pause;
       case RESET: _reset;
-      case SCREENSHOT: _screenshot;
+      case WINDOW_SCREENSHOT: _window_screenshot;
+      case WINDOW_FULLSCREEN: _window_fullscreen;
+      case FREEPLAY_FAVORITE: _freeplay_favorite;
+      case FREEPLAY_LEFT: _freeplay_left;
+      case FREEPLAY_RIGHT: _freeplay_right;
       case CUTSCENE_ADVANCE: _cutscene_advance;
       case DEBUG_MENU: _debug_menu;
       case DEBUG_CHART: _debug_chart;
@@ -406,7 +431,6 @@ class Controls extends FlxActionSet
       case VOLUME_UP: _volume_up;
       case VOLUME_DOWN: _volume_down;
       case VOLUME_MUTE: _volume_mute;
-      case FULLSCREEN: _fullscreen;
     }
   }
 
@@ -466,8 +490,16 @@ class Controls extends FlxActionSet
         func(_pause, JUST_PRESSED);
       case RESET:
         func(_reset, JUST_PRESSED);
-      case SCREENSHOT:
-        func(_screenshot, JUST_PRESSED);
+      case WINDOW_SCREENSHOT:
+        func(_window_screenshot, JUST_PRESSED);
+      case WINDOW_FULLSCREEN:
+        func(_window_fullscreen, JUST_PRESSED);
+      case FREEPLAY_FAVORITE:
+        func(_freeplay_favorite, JUST_PRESSED);
+      case FREEPLAY_LEFT:
+        func(_freeplay_left, JUST_PRESSED);
+      case FREEPLAY_RIGHT:
+        func(_freeplay_right, JUST_PRESSED);
       case CUTSCENE_ADVANCE:
         func(_cutscene_advance, JUST_PRESSED);
       case DEBUG_MENU:
@@ -482,8 +514,6 @@ class Controls extends FlxActionSet
         func(_volume_down, JUST_PRESSED);
       case VOLUME_MUTE:
         func(_volume_mute, JUST_PRESSED);
-      case FULLSCREEN:
-        func(_fullscreen, JUST_PRESSED);
     }
   }
 
@@ -678,7 +708,11 @@ class Controls extends FlxActionSet
     bindKeys(Control.BACK, getDefaultKeybinds(scheme, Control.BACK));
     bindKeys(Control.PAUSE, getDefaultKeybinds(scheme, Control.PAUSE));
     bindKeys(Control.RESET, getDefaultKeybinds(scheme, Control.RESET));
-    bindKeys(Control.SCREENSHOT, getDefaultKeybinds(scheme, Control.SCREENSHOT));
+    bindKeys(Control.WINDOW_SCREENSHOT, getDefaultKeybinds(scheme, Control.WINDOW_SCREENSHOT));
+    bindKeys(Control.WINDOW_FULLSCREEN, getDefaultKeybinds(scheme, Control.WINDOW_FULLSCREEN));
+    bindKeys(Control.FREEPLAY_FAVORITE, getDefaultKeybinds(scheme, Control.FREEPLAY_FAVORITE));
+    bindKeys(Control.FREEPLAY_LEFT, getDefaultKeybinds(scheme, Control.FREEPLAY_LEFT));
+    bindKeys(Control.FREEPLAY_RIGHT, getDefaultKeybinds(scheme, Control.FREEPLAY_RIGHT));
     bindKeys(Control.CUTSCENE_ADVANCE, getDefaultKeybinds(scheme, Control.CUTSCENE_ADVANCE));
     bindKeys(Control.DEBUG_MENU, getDefaultKeybinds(scheme, Control.DEBUG_MENU));
     bindKeys(Control.DEBUG_CHART, getDefaultKeybinds(scheme, Control.DEBUG_CHART));
@@ -686,7 +720,6 @@ class Controls extends FlxActionSet
     bindKeys(Control.VOLUME_UP, getDefaultKeybinds(scheme, Control.VOLUME_UP));
     bindKeys(Control.VOLUME_DOWN, getDefaultKeybinds(scheme, Control.VOLUME_DOWN));
     bindKeys(Control.VOLUME_MUTE, getDefaultKeybinds(scheme, Control.VOLUME_MUTE));
-    bindKeys(Control.FULLSCREEN, getDefaultKeybinds(scheme, Control.FULLSCREEN));
 
     bindMobileLol();
   }
@@ -707,7 +740,11 @@ class Controls extends FlxActionSet
           case Control.BACK: return [X, BACKSPACE, ESCAPE];
           case Control.PAUSE: return [P, ENTER, ESCAPE];
           case Control.RESET: return [R];
-          case Control.SCREENSHOT: return [F3]; // TODO: Change this back to PrintScreen
+          case Control.WINDOW_FULLSCREEN: return [F11]; // We use F for other things LOL.
+          case Control.WINDOW_SCREENSHOT: return [F3];
+          case Control.FREEPLAY_FAVORITE: return [F]; // Favorite a song on the menu
+          case Control.FREEPLAY_LEFT: return [Q]; // Switch tabs on the menu
+          case Control.FREEPLAY_RIGHT: return [E]; // Switch tabs on the menu
           case Control.CUTSCENE_ADVANCE: return [Z, ENTER];
           case Control.DEBUG_MENU: return [GRAVEACCENT];
           case Control.DEBUG_CHART: return [];
@@ -715,8 +752,6 @@ class Controls extends FlxActionSet
           case Control.VOLUME_UP: return [PLUS, NUMPADPLUS];
           case Control.VOLUME_DOWN: return [MINUS, NUMPADMINUS];
           case Control.VOLUME_MUTE: return [ZERO, NUMPADZERO];
-          case Control.FULLSCREEN: return [FlxKey.F11]; // We use F for other things LOL.
-
         }
       case Duo(true):
         switch (control) {
@@ -732,7 +767,11 @@ class Controls extends FlxActionSet
           case Control.BACK: return [H, X];
           case Control.PAUSE: return [ONE];
           case Control.RESET: return [R];
-          case Control.SCREENSHOT: return [PRINTSCREEN];
+          case Control.WINDOW_SCREENSHOT: return [F3];
+          case Control.WINDOW_FULLSCREEN: return [F11];
+          case Control.FREEPLAY_FAVORITE: return [F]; // Favorite a song on the menu
+          case Control.FREEPLAY_LEFT: return [Q]; // Switch tabs on the menu
+          case Control.FREEPLAY_RIGHT: return [E]; // Switch tabs on the menu
           case Control.CUTSCENE_ADVANCE: return [G, Z];
           case Control.DEBUG_MENU: return [GRAVEACCENT];
           case Control.DEBUG_CHART: return [];
@@ -740,7 +779,6 @@ class Controls extends FlxActionSet
           case Control.VOLUME_UP: return [PLUS];
           case Control.VOLUME_DOWN: return [MINUS];
           case Control.VOLUME_MUTE: return [ZERO];
-          case Control.FULLSCREEN: return [FlxKey.F];
 
         }
       case Duo(false):
@@ -757,15 +795,18 @@ class Controls extends FlxActionSet
           case Control.BACK: return [ESCAPE];
           case Control.PAUSE: return [ONE];
           case Control.RESET: return [R];
-          case Control.SCREENSHOT: return [PRINTSCREEN];
+          case Control.WINDOW_SCREENSHOT: return [];
+          case Control.WINDOW_FULLSCREEN: return [];
+          case Control.FREEPLAY_FAVORITE: return [];
+          case Control.FREEPLAY_LEFT: return [];
+          case Control.FREEPLAY_RIGHT: return [];
           case Control.CUTSCENE_ADVANCE: return [ENTER];
-          case Control.DEBUG_MENU: return [GRAVEACCENT];
+          case Control.DEBUG_MENU: return [];
           case Control.DEBUG_CHART: return [];
           case Control.DEBUG_STAGE: return [];
           case Control.VOLUME_UP: return [NUMPADPLUS];
           case Control.VOLUME_DOWN: return [NUMPADMINUS];
           case Control.VOLUME_MUTE: return [NUMPADZERO];
-          case Control.FULLSCREEN: return [];
 
         }
       default:
@@ -856,34 +897,37 @@ class Controls extends FlxActionSet
   public function addDefaultGamepad(id):Void
   {
     addGamepadLiteral(id, [
-
       Control.ACCEPT => getDefaultGamepadBinds(Control.ACCEPT),
       Control.BACK => getDefaultGamepadBinds(Control.BACK),
       Control.UI_UP => getDefaultGamepadBinds(Control.UI_UP),
       Control.UI_DOWN => getDefaultGamepadBinds(Control.UI_DOWN),
       Control.UI_LEFT => getDefaultGamepadBinds(Control.UI_LEFT),
       Control.UI_RIGHT => getDefaultGamepadBinds(Control.UI_RIGHT),
-      // don't swap A/B or X/Y for switch on these. A is always the bottom face button
       Control.NOTE_UP => getDefaultGamepadBinds(Control.NOTE_UP),
       Control.NOTE_DOWN => getDefaultGamepadBinds(Control.NOTE_DOWN),
       Control.NOTE_LEFT => getDefaultGamepadBinds(Control.NOTE_LEFT),
       Control.NOTE_RIGHT => getDefaultGamepadBinds(Control.NOTE_RIGHT),
       Control.PAUSE => getDefaultGamepadBinds(Control.PAUSE),
       Control.RESET => getDefaultGamepadBinds(Control.RESET),
-      // Control.SCREENSHOT => [],
-      // Control.VOLUME_UP => [RIGHT_SHOULDER],
-      // Control.VOLUME_DOWN => [LEFT_SHOULDER],
-      // Control.VOLUME_MUTE => [RIGHT_TRIGGER],
+      Control.WINDOW_FULLSCREEN => getDefaultGamepadBinds(Control.WINDOW_FULLSCREEN),
+      Control.WINDOW_SCREENSHOT => getDefaultGamepadBinds(Control.WINDOW_SCREENSHOT),
       Control.CUTSCENE_ADVANCE => getDefaultGamepadBinds(Control.CUTSCENE_ADVANCE),
-      // Control.DEBUG_MENU
-      // Control.DEBUG_CHART
+      Control.FREEPLAY_FAVORITE => getDefaultGamepadBinds(Control.FREEPLAY_FAVORITE),
+      Control.FREEPLAY_LEFT => getDefaultGamepadBinds(Control.FREEPLAY_LEFT),
+      Control.FREEPLAY_RIGHT => getDefaultGamepadBinds(Control.FREEPLAY_RIGHT),
+      Control.VOLUME_UP => getDefaultGamepadBinds(Control.VOLUME_UP),
+      Control.VOLUME_DOWN => getDefaultGamepadBinds(Control.VOLUME_DOWN),
+      Control.VOLUME_MUTE => getDefaultGamepadBinds(Control.VOLUME_MUTE),
+      Control.DEBUG_MENU => getDefaultGamepadBinds(Control.DEBUG_MENU),
+      Control.DEBUG_CHART => getDefaultGamepadBinds(Control.DEBUG_CHART),
+      Control.DEBUG_STAGE => getDefaultGamepadBinds(Control.DEBUG_STAGE),
     ]);
   }
 
   function getDefaultGamepadBinds(control:Control):Array<FlxGamepadInputID> {
     switch(control) {
       case Control.ACCEPT: return [#if switch B #else A #end];
-      case Control.BACK: return [#if switch A #else B #end, FlxGamepadInputID.BACK];
+      case Control.BACK: return [#if switch A #else B #end];
       case Control.UI_UP: return [DPAD_UP, LEFT_STICK_DIGITAL_UP];
       case Control.UI_DOWN: return [DPAD_DOWN, LEFT_STICK_DIGITAL_DOWN];
       case Control.UI_LEFT: return [DPAD_LEFT, LEFT_STICK_DIGITAL_LEFT];
@@ -893,15 +937,19 @@ class Controls extends FlxActionSet
       case Control.NOTE_LEFT: return [DPAD_LEFT, X, LEFT_STICK_DIGITAL_LEFT, RIGHT_STICK_DIGITAL_LEFT];
       case Control.NOTE_RIGHT: return [DPAD_RIGHT, B, LEFT_STICK_DIGITAL_RIGHT, RIGHT_STICK_DIGITAL_RIGHT];
       case Control.PAUSE: return [START];
-      case Control.RESET: return [RIGHT_SHOULDER];
-      case Control.SCREENSHOT: return [];
-      case Control.VOLUME_UP: return [];
-      case Control.VOLUME_DOWN: return [];
-      case Control.VOLUME_MUTE: return [];
+      case Control.RESET: return [FlxGamepadInputID.BACK]; // Back (i.e. Select)
+      case Control.WINDOW_FULLSCREEN: [];
+      case Control.WINDOW_SCREENSHOT: [];
       case Control.CUTSCENE_ADVANCE: return [A];
-      case Control.DEBUG_MENU: return [];
-      case Control.DEBUG_CHART: return [];
-      case Control.FULLSCREEN: return [];
+      case Control.FREEPLAY_FAVORITE: [FlxGamepadInputID.BACK]; // Back (i.e. Select)
+      case Control.FREEPLAY_LEFT: [LEFT_SHOULDER];
+      case Control.FREEPLAY_RIGHT: [RIGHT_SHOULDER];
+      case Control.VOLUME_UP: [];
+      case Control.VOLUME_DOWN: [];
+      case Control.VOLUME_MUTE: [];
+      case Control.DEBUG_MENU: [];
+      case Control.DEBUG_CHART: [];
+      case Control.DEBUG_STAGE: [];
       default:
         // Fallthrough.
     }
@@ -1392,7 +1440,7 @@ class FlxActionInputDigitalAndroid extends FlxActionInputDigital
 
   override public function check(Action:FlxAction):Bool
   {
-    returnswitch(trigger)
+    return switch(trigger)
     {
       #if android
       case PRESSED: FlxG.android.checkStatus(inputID, PRESSED) || FlxG.android.checkStatus(inputID, PRESSED);
@@ -1425,14 +1473,18 @@ enum Control
   UI_RIGHT;
   UI_DOWN;
   RESET;
-  SCREENSHOT;
   ACCEPT;
   BACK;
   PAUSE;
-  FULLSCREEN;
   // CUTSCENE
   CUTSCENE_ADVANCE;
-  // SCREENSHOT
+  // FREEPLAY
+  FREEPLAY_FAVORITE;
+  FREEPLAY_LEFT;
+  FREEPLAY_RIGHT;
+  // WINDOW
+  WINDOW_SCREENSHOT;
+  WINDOW_FULLSCREEN;
   // VOLUME
   VOLUME_UP;
   VOLUME_DOWN;
@@ -1475,11 +1527,15 @@ enum abstract Action(String) to String from String
   var BACK = "back";
   var PAUSE = "pause";
   var RESET = "reset";
-  var FULLSCREEN = "fullscreen";
-  // SCREENSHOT
-  var SCREENSHOT = "screenshot";
+  // WINDOW
+  var WINDOW_FULLSCREEN = "window_fullscreen";
+  var WINDOW_SCREENSHOT = "window_screenshot";
   // CUTSCENE
   var CUTSCENE_ADVANCE = "cutscene_advance";
+  // FREEPLAY
+  var FREEPLAY_FAVORITE = "freeplay_favorite";
+  var FREEPLAY_LEFT = "freeplay_left";
+  var FREEPLAY_RIGHT = "freeplay_right";
   // VOLUME
   var VOLUME_UP = "volume_up";
   var VOLUME_DOWN = "volume_down";
diff --git a/source/funkin/play/GameOverSubState.hx b/source/funkin/play/GameOverSubState.hx
index 4d50d75cc..c84d5b154 100644
--- a/source/funkin/play/GameOverSubState.hx
+++ b/source/funkin/play/GameOverSubState.hx
@@ -71,7 +71,7 @@ class GameOverSubState extends MusicBeatSubState
   var gameOverMusic:Null<FunkinSound> = null;
 
   /**
-   * Whether the player has confirmed and prepared to restart the level.
+   * Whether the player has confirmed and prepared to restart the level or to go back to the freeplay menu.
    * This means the animation and transition have already started.
    */
   var isEnding:Bool = false;
@@ -237,15 +237,16 @@ class GameOverSubState extends MusicBeatSubState
     }
 
     // KEYBOARD ONLY: Restart the level when pressing the assigned key.
-    if (controls.ACCEPT && blueballed)
+    if (controls.ACCEPT && blueballed && !mustNotExit)
     {
       blueballed = false;
       confirmDeath();
     }
 
     // KEYBOARD ONLY: Return to the menu when pressing the assigned key.
-    if (controls.BACK && !mustNotExit)
+    if (controls.BACK && !mustNotExit && !isEnding)
     {
+      isEnding = true;
       blueballed = false;
       PlayState.instance.deathCounter = 0;
       // PlayState.seenCutscene = false; // old thing...
diff --git a/source/funkin/play/PauseSubState.hx b/source/funkin/play/PauseSubState.hx
index 8c45fac65..d0c759b16 100644
--- a/source/funkin/play/PauseSubState.hx
+++ b/source/funkin/play/PauseSubState.hx
@@ -449,13 +449,14 @@ class PauseSubState extends MusicBeatSubState
    */
   function changeSelection(change:Int = 0):Void
   {
-    FunkinSound.playOnce(Paths.sound('scrollMenu'), 0.4);
-
+    var prevEntry:Int = currentEntry;
     currentEntry += change;
 
     if (currentEntry < 0) currentEntry = currentMenuEntries.length - 1;
     if (currentEntry >= currentMenuEntries.length) currentEntry = 0;
 
+    if (currentEntry != prevEntry) FunkinSound.playOnce(Paths.sound('scrollMenu'), 0.4);
+
     for (entryIndex in 0...currentMenuEntries.length)
     {
       var isCurrent:Bool = entryIndex == currentEntry;
diff --git a/source/funkin/play/PlayState.hx b/source/funkin/play/PlayState.hx
index b3d0a9f8a..f55cef388 100644
--- a/source/funkin/play/PlayState.hx
+++ b/source/funkin/play/PlayState.hx
@@ -175,6 +175,12 @@ class PlayState extends MusicBeatSubState
    */
   public var currentVariation:String = Constants.DEFAULT_VARIATION;
 
+  /**
+   * The currently selected instrumental ID.
+   * @default `''`
+   */
+  public var currentInstrumental:String = '';
+
   /**
    * The currently active Stage. This is the object containing all the props.
    */
@@ -603,6 +609,7 @@ class PlayState extends MusicBeatSubState
     currentSong = params.targetSong;
     if (params.targetDifficulty != null) currentDifficulty = params.targetDifficulty;
     if (params.targetVariation != null) currentVariation = params.targetVariation;
+    if (params.targetInstrumental != null) currentInstrumental = params.targetInstrumental;
     isPracticeMode = params.practiceMode ?? false;
     isBotPlayMode = params.botPlayMode ?? false;
     isMinimalMode = params.minimalMode ?? false;
@@ -1974,7 +1981,7 @@ class PlayState extends MusicBeatSubState
 
     if (!overrideMusic && !isGamePaused && currentChart != null)
     {
-      currentChart.playInst(1.0, false);
+      currentChart.playInst(1.0, currentInstrumental, false);
     }
 
     if (FlxG.sound.music == null)
@@ -2284,7 +2291,7 @@ class PlayState extends MusicBeatSubState
           health += Constants.HEALTH_HOLD_BONUS_PER_SECOND * elapsed;
           songScore += Std.int(Constants.SCORE_HOLD_BONUS_PER_SECOND * elapsed);
         }
-        
+
         // Make sure the player keeps singing while the note is held by the bot.
         if (isBotPlayMode && currentStage != null && currentStage.getBoyfriend() != null && currentStage.getBoyfriend().isSinging())
         {
@@ -2818,8 +2825,13 @@ class PlayState extends MusicBeatSubState
 
     deathCounter = 0;
 
+    // TODO: This line of code makes me sad, but you can't really fix it without a breaking migration.
+    // `easy`, `erect`, `normal-pico`, etc.
+    var suffixedDifficulty = (currentVariation != Constants.DEFAULT_VARIATION
+      && currentVariation != 'erect') ? '$currentDifficulty-${currentVariation}' : currentDifficulty;
+
     var isNewHighscore = false;
-    var prevScoreData:Null<SaveScoreData> = Save.instance.getSongScore(currentSong.id, currentDifficulty);
+    var prevScoreData:Null<SaveScoreData> = Save.instance.getSongScore(currentSong.id, suffixedDifficulty);
 
     if (currentSong != null && currentSong.validScore)
     {
@@ -2844,13 +2856,21 @@ class PlayState extends MusicBeatSubState
       // adds current song data into the tallies for the level (story levels)
       Highscore.talliesLevel = Highscore.combineTallies(Highscore.tallies, Highscore.talliesLevel);
 
-      if (!isPracticeMode && !isBotPlayMode && Save.instance.isSongHighScore(currentSong.id, currentDifficulty, data))
+      if (!isPracticeMode && !isBotPlayMode)
       {
-        Save.instance.setSongScore(currentSong.id, currentDifficulty, data);
-        #if newgrounds
-        NGio.postScore(score, currentSong.id);
-        #end
-        isNewHighscore = true;
+        isNewHighscore = Save.instance.isSongHighScore(currentSong.id, suffixedDifficulty, data);
+
+        // If no high score is present, save both score and rank.
+        // If score or rank are better, save the highest one.
+        // If neither are higher, nothing will change.
+        Save.instance.applySongRank(currentSong.id, suffixedDifficulty, data);
+
+        if (isNewHighscore)
+        {
+          #if newgrounds
+          NGio.postScore(score, currentSong.id);
+          #end
+        }
       }
     }
 
diff --git a/source/funkin/play/components/HealthIcon.hx b/source/funkin/play/components/HealthIcon.hx
index a3204329a..2442b0dc5 100644
--- a/source/funkin/play/components/HealthIcon.hx
+++ b/source/funkin/play/components/HealthIcon.hx
@@ -53,8 +53,9 @@ class HealthIcon extends FunkinSprite
 
   /**
    * Apply the "bop" animation once every X steps.
+   * Defaults to once per beat.
    */
-  public var bopEvery:Int = 4;
+  public var bopEvery:Int = Constants.STEPS_PER_BEAT;
 
   /**
    * The amount, in degrees, to rotate the icon by when boping.
diff --git a/source/funkin/play/scoring/Scoring.hx b/source/funkin/play/scoring/Scoring.hx
index d6f71fc7e..dc2c40647 100644
--- a/source/funkin/play/scoring/Scoring.hx
+++ b/source/funkin/play/scoring/Scoring.hx
@@ -356,7 +356,10 @@ class Scoring
 
     // Perfect (Platinum) is a Sick Full Clear
     var isPerfectGold = scoreData.tallies.sick == scoreData.tallies.totalNotes;
-    if (isPerfectGold) return ScoringRank.PERFECT_GOLD;
+    if (isPerfectGold)
+    {
+      return ScoringRank.PERFECT_GOLD;
+    }
 
     // Else, use the standard grades
 
@@ -397,62 +400,79 @@ enum abstract ScoringRank(String)
   var GOOD;
   var SHIT;
 
-  @:op(A > B) static function compare(a:Null<ScoringRank>, b:Null<ScoringRank>):Bool
+  /**
+   * Converts ScoringRank to an integer value for comparison.
+   * Better ranks should be tied to a higher value.
+   */
+  static function getValue(rank:Null<ScoringRank>):Int
+  {
+    if (rank == null) return -1;
+    switch (rank)
+    {
+      case PERFECT_GOLD:
+        return 5;
+      case PERFECT:
+        return 4;
+      case EXCELLENT:
+        return 3;
+      case GREAT:
+        return 2;
+      case GOOD:
+        return 1;
+      case SHIT:
+        return 0;
+      default:
+        return -1;
+    }
+  }
+
+  // Yes, we really need a different function for each comparison operator.
+  @:op(A > B) static function compareGT(a:Null<ScoringRank>, b:Null<ScoringRank>):Bool
   {
     if (a != null && b == null) return true;
     if (a == null || b == null) return false;
 
-    var temp1:Int = 0;
-    var temp2:Int = 0;
+    var temp1:Int = getValue(a);
+    var temp2:Int = getValue(b);
 
-    // temp 1
-    switch (a)
-    {
-      case PERFECT_GOLD:
-        temp1 = 5;
-      case PERFECT:
-        temp1 = 4;
-      case EXCELLENT:
-        temp1 = 3;
-      case GREAT:
-        temp1 = 2;
-      case GOOD:
-        temp1 = 1;
-      case SHIT:
-        temp1 = 0;
-      default:
-        temp1 = -1;
-    }
-
-    // temp 2
-    switch (b)
-    {
-      case PERFECT_GOLD:
-        temp2 = 5;
-      case PERFECT:
-        temp2 = 4;
-      case EXCELLENT:
-        temp2 = 3;
-      case GREAT:
-        temp2 = 2;
-      case GOOD:
-        temp2 = 1;
-      case SHIT:
-        temp2 = 0;
-      default:
-        temp2 = -1;
-    }
-
-    if (temp1 > temp2)
-    {
-      return true;
-    }
-    else
-    {
-      return false;
-    }
+    return temp1 > temp2;
   }
 
+  @:op(A >= B) static function compareGTEQ(a:Null<ScoringRank>, b:Null<ScoringRank>):Bool
+  {
+    if (a != null && b == null) return true;
+    if (a == null || b == null) return false;
+
+    var temp1:Int = getValue(a);
+    var temp2:Int = getValue(b);
+
+    return temp1 >= temp2;
+  }
+
+  @:op(A < B) static function compareLT(a:Null<ScoringRank>, b:Null<ScoringRank>):Bool
+  {
+    if (a != null && b == null) return true;
+    if (a == null || b == null) return false;
+
+    var temp1:Int = getValue(a);
+    var temp2:Int = getValue(b);
+
+    return temp1 < temp2;
+  }
+
+  @:op(A <= B) static function compareLTEQ(a:Null<ScoringRank>, b:Null<ScoringRank>):Bool
+  {
+    if (a != null && b == null) return true;
+    if (a == null || b == null) return false;
+
+    var temp1:Int = getValue(a);
+    var temp2:Int = getValue(b);
+
+    return temp1 <= temp2;
+  }
+
+  // @:op(A == B) isn't necessary!
+
   /**
    * Delay in seconds
    */
@@ -462,15 +482,15 @@ enum abstract ScoringRank(String)
     {
       case PERFECT_GOLD | PERFECT:
         // return 2.5;
-        return 95/24;
+        return 95 / 24;
       case EXCELLENT:
         return 0;
       case GREAT:
-        return 5/24;
+        return 5 / 24;
       case GOOD:
-        return 3/24;
+        return 3 / 24;
       case SHIT:
-        return 2/24;
+        return 2 / 24;
       default:
         return 3.5;
     }
@@ -482,15 +502,15 @@ enum abstract ScoringRank(String)
     {
       case PERFECT_GOLD | PERFECT:
         // return 2.5;
-        return 95/24;
+        return 95 / 24;
       case EXCELLENT:
-        return 97/24;
+        return 97 / 24;
       case GREAT:
-        return 95/24;
+        return 95 / 24;
       case GOOD:
-        return 95/24;
+        return 95 / 24;
       case SHIT:
-        return 95/24;
+        return 95 / 24;
       default:
         return 3.5;
     }
@@ -502,15 +522,15 @@ enum abstract ScoringRank(String)
     {
       case PERFECT_GOLD | PERFECT:
         // return 2.5;
-        return 129/24;
+        return 129 / 24;
       case EXCELLENT:
-        return 122/24;
+        return 122 / 24;
       case GREAT:
-        return 109/24;
+        return 109 / 24;
       case GOOD:
-        return 107/24;
+        return 107 / 24;
       case SHIT:
-        return 186/24;
+        return 186 / 24;
       default:
         return 3.5;
     }
@@ -522,15 +542,15 @@ enum abstract ScoringRank(String)
     {
       case PERFECT_GOLD | PERFECT:
         // return 2.5;
-        return 140/24;
+        return 140 / 24;
       case EXCELLENT:
-        return 140/24;
+        return 140 / 24;
       case GREAT:
-        return 129/24;
+        return 129 / 24;
       case GOOD:
-        return 127/24;
+        return 127 / 24;
       case SHIT:
-        return 207/24;
+        return 207 / 24;
       default:
         return 3.5;
     }
diff --git a/source/funkin/play/song/Song.hx b/source/funkin/play/song/Song.hx
index df3e343e2..dde5ee7b8 100644
--- a/source/funkin/play/song/Song.hx
+++ b/source/funkin/play/song/Song.hx
@@ -682,9 +682,9 @@ class SongDifficulty
     FlxG.sound.cache(getInstPath(instrumental));
   }
 
-  public function playInst(volume:Float = 1.0, looped:Bool = false):Void
+  public function playInst(volume:Float = 1.0, instId:String = '', looped:Bool = false):Void
   {
-    var suffix:String = (variation != null && variation != '' && variation != 'default') ? '-$variation' : '';
+    var suffix:String = (instId != '') ? '-$instId' : '';
 
     FlxG.sound.music = FunkinSound.load(Paths.inst(this.song.id, suffix), volume, looped, false, true);
 
diff --git a/source/funkin/save/Save.hx b/source/funkin/save/Save.hx
index 2ff6b96cc..2900ce2be 100644
--- a/source/funkin/save/Save.hx
+++ b/source/funkin/save/Save.hx
@@ -1,6 +1,7 @@
 package funkin.save;
 
 import flixel.util.FlxSave;
+import funkin.util.FileUtil;
 import funkin.input.Controls.Device;
 import funkin.play.scoring.Scoring;
 import funkin.play.scoring.Scoring.ScoringRank;
@@ -58,7 +59,7 @@ class Save
       this.data = data;
 
     // Make sure the verison number is up to date before we flush.
-    this.data.version = Save.SAVE_DATA_VERSION;
+    updateVersionToLatest();
   }
 
   public static function getDefault():RawSaveData
@@ -503,7 +504,7 @@ class Save
   }
 
   /**
-   * Apply the score the user achieved for a given song on a given difficulty.
+   * Directly set the score the user achieved for a given song on a given difficulty.
    */
   public function setSongScore(songId:String, difficultyId:String, score:SaveScoreData):Void
   {
@@ -518,6 +519,44 @@ class Save
     flush();
   }
 
+  /**
+   * Only replace the ranking data for the song, because the old score is still better.
+   */
+  public function applySongRank(songId:String, difficultyId:String, newScoreData:SaveScoreData):Void
+  {
+    var newRank = Scoring.calculateRank(newScoreData);
+    if (newScoreData == null || newRank == null) return;
+
+    var song = data.scores.songs.get(songId);
+    if (song == null)
+    {
+      song = [];
+      data.scores.songs.set(songId, song);
+    }
+
+    var previousScoreData = song.get(difficultyId);
+
+    var previousRank = Scoring.calculateRank(previousScoreData);
+
+    if (previousScoreData == null || previousRank == null)
+    {
+      // Directly set the highscore.
+      setSongScore(songId, difficultyId, newScoreData);
+      return;
+    }
+
+    // Set the high score and the high rank separately.
+    var newScore:SaveScoreData =
+      {
+        score: (previousScoreData.score > newScoreData.score) ? previousScoreData.score : newScoreData.score,
+        tallies: (previousRank > newRank) ? previousScoreData.tallies : newScoreData.tallies
+      };
+
+    song.set(difficultyId, newScore);
+
+    flush();
+  }
+
   /**
    * Is the provided score data better than the current high score for the given song?
    * @param songId The song ID to check.
@@ -543,6 +582,39 @@ class Save
     return score.score > currentScore.score;
   }
 
+  /**
+   * Is the provided score data better than the current rank for the given song?
+   * @param songId The song ID to check.
+   * @param difficultyId The difficulty to check.
+   * @param score The score to check the rank for.
+   * @return Whether the score's rank is better than the current rank.
+   */
+  public function isSongHighRank(songId:String, difficultyId:String = 'normal', score:SaveScoreData):Bool
+  {
+    var newScoreRank = Scoring.calculateRank(score);
+    if (newScoreRank == null)
+    {
+      // The provided score is invalid.
+      return false;
+    }
+
+    var song = data.scores.songs.get(songId);
+    if (song == null)
+    {
+      song = [];
+      data.scores.songs.set(songId, song);
+    }
+    var currentScore = song.get(difficultyId);
+    var currentScoreRank = Scoring.calculateRank(currentScore);
+    if (currentScoreRank == null)
+    {
+      // There is no primary highscore for this song.
+      return true;
+    }
+
+    return newScoreRank > currentScoreRank;
+  }
+
   /**
    * Has the provided song been beaten on one of the listed difficulties?
    * @param songId The song ID to check.
@@ -832,6 +904,29 @@ class Save
       return cast legacySave.data;
     }
   }
+
+  /**
+   * Serialize this Save into a JSON string.
+   * @param pretty Whether the JSON should be big ol string (false),
+   * or formatted with tabs (true)
+   * @return The JSON string.
+   */
+  public function serialize(pretty:Bool = true):String
+  {
+    var ignoreNullOptionals = true;
+    var writer = new json2object.JsonWriter<RawSaveData>(ignoreNullOptionals);
+    return writer.write(data, pretty ? '  ' : null);
+  }
+
+  public function updateVersionToLatest():Void
+  {
+    this.data.version = Save.SAVE_DATA_VERSION;
+  }
+
+  public function debug_dumpSave():Void
+  {
+    FileUtil.saveFile(haxe.io.Bytes.ofString(this.serialize()), [FileUtil.FILE_FILTER_JSON], null, null, './save.json', 'Write save data as JSON...');
+  }
 }
 
 /**
@@ -904,6 +999,9 @@ typedef SaveHighScoresData =
 typedef SaveDataMods =
 {
   var enabledMods:Array<String>;
+
+  // TODO: Make this not trip up the serializer when debugging.
+  @:jignored
   var modOptions:Map<String, Dynamic>;
 }
 
diff --git a/source/funkin/ui/MenuList.hx b/source/funkin/ui/MenuList.hx
index c815e0adc..d7319abd6 100644
--- a/source/funkin/ui/MenuList.hx
+++ b/source/funkin/ui/MenuList.hx
@@ -94,7 +94,7 @@ class MenuTypedList<T:MenuListItem> extends FlxTypedGroup<T>
 
     if (newIndex != selectedIndex)
     {
-      FunkinSound.playOnce(Paths.sound('scrollMenu'));
+      FunkinSound.playOnce(Paths.sound('scrollMenu'), 0.4);
       selectItem(newIndex);
     }
 
diff --git a/source/funkin/ui/debug/charting/ChartEditorState.hx b/source/funkin/ui/debug/charting/ChartEditorState.hx
index 1e3f011c1..f72cca77f 100644
--- a/source/funkin/ui/debug/charting/ChartEditorState.hx
+++ b/source/funkin/ui/debug/charting/ChartEditorState.hx
@@ -904,7 +904,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
 
   function set_notePreviewDirty(value:Bool):Bool
   {
-    trace('Note preview dirtied!');
+    // trace('Note preview dirtied!');
     return notePreviewDirty = value;
   }
 
diff --git a/source/funkin/ui/debug/charting/commands/SetItemSelectionCommand.hx b/source/funkin/ui/debug/charting/commands/SetItemSelectionCommand.hx
index 73cf80fa0..661c44d85 100644
--- a/source/funkin/ui/debug/charting/commands/SetItemSelectionCommand.hx
+++ b/source/funkin/ui/debug/charting/commands/SetItemSelectionCommand.hx
@@ -35,7 +35,15 @@ class SetItemSelectionCommand implements ChartEditorCommand
     {
       var eventSelected = this.events[0];
 
-      state.eventKindToPlace = eventSelected.eventKind;
+      if (state.eventKindToPlace == eventSelected.eventKind)
+      {
+        trace('Target event kind matches selection: ${eventSelected.eventKind}');
+      }
+      else
+      {
+        trace('Switching target event kind to match selection: ${state.eventKindToPlace} != ${eventSelected.eventKind}');
+        state.eventKindToPlace = eventSelected.eventKind;
+      }
 
       // This code is here to parse event data that's not built as a struct for some reason.
       // TODO: Clean this up or get rid of it.
diff --git a/source/funkin/ui/debug/charting/handlers/ChartEditorThemeHandler.hx b/source/funkin/ui/debug/charting/handlers/ChartEditorThemeHandler.hx
index b1af0ce4c..e42102a52 100644
--- a/source/funkin/ui/debug/charting/handlers/ChartEditorThemeHandler.hx
+++ b/source/funkin/ui/debug/charting/handlers/ChartEditorThemeHandler.hx
@@ -201,7 +201,8 @@ class ChartEditorThemeHandler
     // Selection borders horizontally in the middle.
     for (i in 1...(Conductor.instance.stepsPerMeasure))
     {
-      if ((i % Conductor.instance.beatsPerMeasure) == 0)
+      // There may be a different number of beats per measure, but there's always 4 steps per beat.
+      if ((i % Constants.STEPS_PER_BEAT) == 0)
       {
         state.gridBitmap.fillRect(new Rectangle(0, (ChartEditorState.GRID_SIZE * i) - (GRID_BEAT_DIVIDER_WIDTH / 2), state.gridBitmap.width,
           GRID_BEAT_DIVIDER_WIDTH),
diff --git a/source/funkin/ui/debug/charting/toolboxes/ChartEditorEventDataToolbox.hx b/source/funkin/ui/debug/charting/toolboxes/ChartEditorEventDataToolbox.hx
index f0949846d..8f021840a 100644
--- a/source/funkin/ui/debug/charting/toolboxes/ChartEditorEventDataToolbox.hx
+++ b/source/funkin/ui/debug/charting/toolboxes/ChartEditorEventDataToolbox.hx
@@ -58,17 +58,8 @@ class ChartEditorEventDataToolbox extends ChartEditorBaseToolbox
 
   function initialize():Void
   {
-    toolboxEventsEventKind.dataSource = new ArrayDataSource();
-
-    var songEvents:Array<SongEvent> = SongEventRegistry.listEvents();
-
-    for (event in songEvents)
-    {
-      toolboxEventsEventKind.dataSource.add({text: event.getTitle(), value: event.id});
-    }
-
     toolboxEventsEventKind.onChange = function(event:UIEvent) {
-      var eventType:String = event.data.value;
+      var eventType:String = event.data.id;
 
       trace('ChartEditorToolboxHandler.buildToolboxEventDataLayout() - Event type changed: $eventType');
 
@@ -83,7 +74,7 @@ class ChartEditorEventDataToolbox extends ChartEditorBaseToolbox
         return;
       }
 
-      buildEventDataFormFromSchema(toolboxEventsDataGrid, schema);
+      buildEventDataFormFromSchema(toolboxEventsDataGrid, schema, chartEditorState.eventKindToPlace);
 
       if (!_initializing && chartEditorState.currentEventSelection.length > 0)
       {
@@ -98,14 +89,40 @@ class ChartEditorEventDataToolbox extends ChartEditorBaseToolbox
         chartEditorState.notePreviewDirty = true;
       }
     }
-    toolboxEventsEventKind.value = chartEditorState.eventKindToPlace;
+    var startingEventValue = ChartEditorDropdowns.populateDropdownWithSongEvents(toolboxEventsEventKind, chartEditorState.eventKindToPlace);
+    trace('ChartEditorToolboxHandler.buildToolboxEventDataLayout() - Starting event kind: ${startingEventValue}');
+    toolboxEventsEventKind.value = startingEventValue;
   }
 
   public override function refresh():Void
   {
     super.refresh();
 
-    toolboxEventsEventKind.value = chartEditorState.eventKindToPlace;
+    var newDropdownElement = ChartEditorDropdowns.findDropdownElement(chartEditorState.eventKindToPlace, toolboxEventsEventKind);
+
+    if (newDropdownElement == null)
+    {
+      throw 'ChartEditorToolboxHandler.buildToolboxEventDataLayout() - Event kind not in dropdown: ${chartEditorState.eventKindToPlace}';
+    }
+    else if (toolboxEventsEventKind.value != newDropdownElement || lastEventKind != toolboxEventsEventKind.value.id)
+    {
+      toolboxEventsEventKind.value = newDropdownElement;
+
+      var schema:SongEventSchema = SongEventRegistry.getEventSchema(chartEditorState.eventKindToPlace);
+      if (schema == null)
+      {
+        trace('ChartEditorToolboxHandler.buildToolboxEventDataLayout() - Unknown event kind: ${chartEditorState.eventKindToPlace}');
+      }
+      else
+      {
+        trace('ChartEditorToolboxHandler.buildToolboxEventDataLayout() - Event kind changed: ${toolboxEventsEventKind.value.id} != ${newDropdownElement.id} != ${lastEventKind}, rebuilding form');
+        buildEventDataFormFromSchema(toolboxEventsDataGrid, schema, chartEditorState.eventKindToPlace);
+      }
+    }
+    else
+    {
+      trace('ChartEditorToolboxHandler.buildToolboxEventDataLayout() - Event kind not changed: ${toolboxEventsEventKind.value} == ${newDropdownElement} == ${lastEventKind}');
+    }
 
     for (pair in chartEditorState.eventDataToPlace.keyValueIterator())
     {
@@ -116,7 +133,7 @@ class ChartEditorEventDataToolbox extends ChartEditorBaseToolbox
 
       if (field == null)
       {
-        throw 'ChartEditorToolboxHandler.refresh() - Field "${fieldId}" does not exist in the event data form.';
+        throw 'ChartEditorToolboxHandler.refresh() - Field "${fieldId}" does not exist in the event data form for kind ${lastEventKind}.';
       }
       else
       {
@@ -141,9 +158,15 @@ class ChartEditorEventDataToolbox extends ChartEditorBaseToolbox
     }
   }
 
-  function buildEventDataFormFromSchema(target:Box, schema:SongEventSchema):Void
+  var lastEventKind:String = 'unknown';
+
+  function buildEventDataFormFromSchema(target:Box, schema:SongEventSchema, eventKind:String):Void
   {
-    trace(schema);
+    trace('Building event data form from schema for event kind: ${eventKind}');
+    // trace(schema);
+
+    lastEventKind = eventKind ?? 'unknown';
+
     // Clear the frame.
     target.removeAllComponents();
 
@@ -188,6 +211,9 @@ class ChartEditorEventDataToolbox extends ChartEditorBaseToolbox
           var dropDown:DropDown = new DropDown();
           dropDown.id = field.name;
           dropDown.width = 200.0;
+          dropDown.dropdownSize = 10;
+          dropDown.dropdownWidth = 300;
+          dropDown.searchable = true;
           dropDown.dataSource = new ArrayDataSource();
 
           if (field.keys == null) throw 'Field "${field.name}" is of Enum type but has no keys.';
@@ -197,12 +223,15 @@ class ChartEditorEventDataToolbox extends ChartEditorBaseToolbox
           for (optionName in field.keys.keys())
           {
             var optionValue:Null<Dynamic> = field.keys.get(optionName);
-            trace('$optionName : $optionValue');
+            // trace('$optionName : $optionValue');
             dropDown.dataSource.add({value: optionValue, text: optionName});
           }
 
           dropDown.value = field.defaultValue;
 
+          // TODO: Add an option to customize sort.
+          dropDown.dataSource.sort('text', ASCENDING);
+
           input = dropDown;
         case STRING:
           input = new TextField();
diff --git a/source/funkin/ui/debug/charting/util/ChartEditorDropdowns.hx b/source/funkin/ui/debug/charting/util/ChartEditorDropdowns.hx
index d2a0a053e..55aab0ab0 100644
--- a/source/funkin/ui/debug/charting/util/ChartEditorDropdowns.hx
+++ b/source/funkin/ui/debug/charting/util/ChartEditorDropdowns.hx
@@ -3,11 +3,13 @@ package funkin.ui.debug.charting.util;
 import funkin.data.notestyle.NoteStyleRegistry;
 import funkin.play.notes.notestyle.NoteStyle;
 import funkin.data.stage.StageData;
+import funkin.play.event.SongEvent;
 import funkin.data.stage.StageRegistry;
 import funkin.play.character.CharacterData;
 import haxe.ui.components.DropDown;
 import funkin.play.stage.Stage;
 import funkin.play.character.BaseCharacter.CharacterType;
+import funkin.data.event.SongEventRegistry;
 import funkin.play.character.CharacterData.CharacterDataParser;
 
 /**
@@ -81,6 +83,42 @@ class ChartEditorDropdowns
     return returnValue;
   }
 
+  public static function populateDropdownWithSongEvents(dropDown:DropDown, startingEventId:String):DropDownEntry
+  {
+    dropDown.dataSource.clear();
+
+    var returnValue:DropDownEntry = {id: "FocusCamera", text: "Focus Camera"};
+
+    var songEvents:Array<SongEvent> = SongEventRegistry.listEvents();
+
+    for (event in songEvents)
+    {
+      var value = {id: event.id, text: event.getTitle()};
+      if (startingEventId == event.id) returnValue = value;
+      dropDown.dataSource.add(value);
+    }
+
+    dropDown.dataSource.sort('text', ASCENDING);
+
+    return returnValue;
+  }
+
+  /**
+   * Given the ID of a dropdown element, find the corresponding entry in the dropdown's dataSource.
+   */
+  public static function findDropdownElement(id:String, dropDown:DropDown):Null<DropDownEntry>
+  {
+    // Attempt to find the entry.
+    for (entryIndex in 0...dropDown.dataSource.size)
+    {
+      var entry = dropDown.dataSource.get(entryIndex);
+      if (entry.id == id) return entry;
+    }
+
+    // Not found.
+    return null;
+  }
+
   /**
    * Populate a dropdown with a list of note styles.
    */
diff --git a/source/funkin/ui/freeplay/DJBoyfriend.hx b/source/funkin/ui/freeplay/DJBoyfriend.hx
index bd2f73e42..bbf043dd4 100644
--- a/source/funkin/ui/freeplay/DJBoyfriend.hx
+++ b/source/funkin/ui/freeplay/DJBoyfriend.hx
@@ -266,7 +266,7 @@ class DJBoyfriend extends FlxAtlasSprite
 
     // Fade out music to 40% volume over 1 second.
     // This helps make the TV a bit more audible.
-    FlxG.sound.music.fadeOut(1.0, 0.4);
+    FlxG.sound.music.fadeOut(1.0, 0.1);
 
     // Play the cartoon at a random time between the start and 5 seconds from the end.
     cartoonSnd.time = FlxG.random.float(0, Math.max(cartoonSnd.length - (5 * Constants.MS_PER_SEC), 0.0));
diff --git a/source/funkin/ui/freeplay/FreeplayState.hx b/source/funkin/ui/freeplay/FreeplayState.hx
index 5e07fb396..0caaf4591 100644
--- a/source/funkin/ui/freeplay/FreeplayState.hx
+++ b/source/funkin/ui/freeplay/FreeplayState.hx
@@ -291,7 +291,10 @@ class FreeplayState extends MusicBeatSubState
 
         // Only display songs which actually have available difficulties for the current character.
         var displayedVariations = song.getVariationsByCharId(currentCharacter);
+        trace(songId);
+        trace(displayedVariations);
         var availableDifficultiesForSong:Array<String> = song.listDifficulties(displayedVariations, false);
+        trace(availableDifficultiesForSong);
         if (availableDifficultiesForSong.length == 0) continue;
 
         songs.push(new FreeplaySongData(levelId, songId, song, displayedVariations));
@@ -455,15 +458,20 @@ class FreeplayState extends MusicBeatSubState
 
     add(dj);
 
-    bgDad = new FlxSprite(pinkBack.width * 0.75, 0).loadGraphic(Paths.image('freeplay/freeplayBGdad'));
-    bgDad.setGraphicSize(0, FlxG.height);
-    bgDad.updateHitbox();
+    bgDad = new FlxSprite(pinkBack.width * 0.74, 0).loadGraphic(Paths.image('freeplay/freeplayBGdad'));
     bgDad.shader = new AngleMask();
     bgDad.visible = false;
 
     var blackOverlayBullshitLOLXD:FlxSprite = new FlxSprite(FlxG.width).makeGraphic(Std.int(bgDad.width), Std.int(bgDad.height), FlxColor.BLACK);
     add(blackOverlayBullshitLOLXD); // used to mask the text lol!
 
+    // this makes the texture sizes consistent, for the angle shader
+    bgDad.setGraphicSize(0, FlxG.height);
+    blackOverlayBullshitLOLXD.setGraphicSize(0, FlxG.height);
+
+    bgDad.updateHitbox();
+    blackOverlayBullshitLOLXD.updateHitbox();
+
     exitMovers.set([blackOverlayBullshitLOLXD, bgDad],
       {
         x: FlxG.width * 1.5,
@@ -472,7 +480,7 @@ class FreeplayState extends MusicBeatSubState
       });
 
     add(bgDad);
-    FlxTween.tween(blackOverlayBullshitLOLXD, {x: pinkBack.width * 0.76}, 0.7, {ease: FlxEase.quintOut});
+    FlxTween.tween(blackOverlayBullshitLOLXD, {x: pinkBack.width * 0.74}, 0.7, {ease: FlxEase.quintOut});
 
     blackOverlayBullshitLOLXD.shader = bgDad.shader;
 
@@ -589,6 +597,8 @@ class FreeplayState extends MusicBeatSubState
           generateSongList({filterType: FAVORITE}, true);
         case 'ALL':
           generateSongList(null, true);
+        case '#':
+          generateSongList({filterType: REGEXP, filterData: '0-9'}, true);
         default:
           generateSongList({filterType: REGEXP, filterData: str}, true);
       }
@@ -597,6 +607,7 @@ class FreeplayState extends MusicBeatSubState
       // that is, only if there's more than one song in the group!
       if (grpCapsules.members.length > 0)
       {
+        FunkinSound.playOnce(Paths.sound('scrollMenu'), 0.4);
         curSelected = 1;
         changeSelection();
       }
@@ -965,16 +976,20 @@ class FreeplayState extends MusicBeatSubState
     grpCapsules.members[curSelected].ranking.scale.set(20, 20);
     grpCapsules.members[curSelected].blurredRanking.scale.set(20, 20);
 
-    grpCapsules.members[curSelected].ranking.animation.play(fromResults.newRank.getFreeplayRankIconAsset(), true);
-    // grpCapsules.members[curSelected].ranking.animation.curAnim.name, true);
+    if (fromResults?.newRank != null)
+    {
+      grpCapsules.members[curSelected].ranking.animation.play(fromResults.newRank.getFreeplayRankIconAsset(), true);
+    }
 
     FlxTween.tween(grpCapsules.members[curSelected].ranking, {"scale.x": 1, "scale.y": 1}, 0.1);
 
-    grpCapsules.members[curSelected].blurredRanking.animation.play(fromResults.newRank.getFreeplayRankIconAsset(), true);
+    if (fromResults?.newRank != null)
+    {
+      grpCapsules.members[curSelected].blurredRanking.animation.play(fromResults.newRank.getFreeplayRankIconAsset(), true);
+    }
     FlxTween.tween(grpCapsules.members[curSelected].blurredRanking, {"scale.x": 1, "scale.y": 1}, 0.1);
 
     new FlxTimer().start(0.1, _ -> {
-      // trace(grpCapsules.members[curSelected].ranking.rank);
       if (fromResults?.oldRank != null)
       {
         grpCapsules.members[curSelected].fakeRanking.visible = false;
@@ -1003,7 +1018,6 @@ class FreeplayState extends MusicBeatSubState
           FunkinSound.playOnce(Paths.sound('ranks/rankinnormal'));
       }
       rankCamera.zoom = 1.3;
-      // FlxTween.tween(rankCamera, {"zoom": 1.4}, 0.3, {ease: FlxEase.elasticOut});
 
       FlxTween.tween(rankCamera, {"zoom": 1.5}, 0.3, {ease: FlxEase.backInOut});
 
@@ -1021,13 +1035,11 @@ class FreeplayState extends MusicBeatSubState
     new FlxTimer().start(0.4, _ -> {
       FlxTween.tween(funnyCam, {"zoom": 1}, 0.8, {ease: FlxEase.sineIn});
       FlxTween.tween(rankCamera, {"zoom": 1.2}, 0.8, {ease: FlxEase.backIn});
-      // IntervalShake.shake(grpCapsules.members[curSelected], 0.8 + 0.5, 1 / 24, 0, 2, FlxEase.quadIn);
       FlxTween.tween(grpCapsules.members[curSelected], {x: originalPos.x - 7, y: originalPos.y - 80}, 0.8 + 0.5, {ease: FlxEase.quartIn});
     });
 
     new FlxTimer().start(0.6, _ -> {
       rankAnimSlam(fromResults);
-      // IntervalShake.shake(grpCapsules.members[curSelected].capsule, 0.3, 1 / 30, 0, 0.3, FlxEase.quartIn);
     });
   }
 
@@ -1192,51 +1204,9 @@ class FreeplayState extends MusicBeatSubState
     // {
     //   rankAnimSlam(fromResultsParams);
     // }
-
-    if (FlxG.keys.justPressed.G)
-    {
-      sparks.y -= 2;
-      trace(sparks.x, sparks.y);
-    }
-    if (FlxG.keys.justPressed.V)
-    {
-      sparks.x -= 2;
-      trace(sparks.x, sparks.y);
-    }
-    if (FlxG.keys.justPressed.N)
-    {
-      sparks.x += 2;
-      trace(sparks.x, sparks.y);
-    }
-    if (FlxG.keys.justPressed.B)
-    {
-      sparks.y += 2;
-      trace(sparks.x, sparks.y);
-    }
-
-    if (FlxG.keys.justPressed.I)
-    {
-      sparksADD.y -= 2;
-      trace(sparksADD.x, sparksADD.y);
-    }
-    if (FlxG.keys.justPressed.J)
-    {
-      sparksADD.x -= 2;
-      trace(sparksADD.x, sparksADD.y);
-    }
-    if (FlxG.keys.justPressed.L)
-    {
-      sparksADD.x += 2;
-      trace(sparksADD.x, sparksADD.y);
-    }
-    if (FlxG.keys.justPressed.K)
-    {
-      sparksADD.y += 2;
-      trace(sparksADD.x, sparksADD.y);
-    }
     #end
 
-    if (FlxG.keys.justPressed.F && !busy)
+    if (controls.FREEPLAY_FAVORITE && !busy)
     {
       var targetSong = grpCapsules.members[curSelected]?.songData;
       if (targetSong != null)
@@ -1602,7 +1572,19 @@ class FreeplayState extends MusicBeatSubState
     var daSong:Null<FreeplaySongData> = grpCapsules.members[curSelected].songData;
     if (daSong != null)
     {
-      var songScore:SaveScoreData = Save.instance.getSongScore(grpCapsules.members[curSelected].songData.songId, currentDifficulty);
+      // TODO: Make this actually be the variation you're focused on. We don't need to fetch the song metadata just to calculate it.
+      var targetSong:Song = SongRegistry.instance.fetchEntry(grpCapsules.members[curSelected].songData.songId);
+      if (targetSong == null)
+      {
+        FlxG.log.warn('WARN: could not find song with id (${grpCapsules.members[curSelected].songData.songId})');
+        return;
+      }
+      var targetVariation:String = targetSong.getFirstValidVariation(currentDifficulty);
+
+      // TODO: This line of code makes me sad, but you can't really fix it without a breaking migration.
+      var suffixedDifficulty = (targetVariation != Constants.DEFAULT_VARIATION
+        && targetVariation != 'erect') ? '$currentDifficulty-${targetVariation}' : currentDifficulty;
+      var songScore:SaveScoreData = Save.instance.getSongScore(grpCapsules.members[curSelected].songData.songId, suffixedDifficulty);
       intendedScore = songScore?.score ?? 0;
       intendedCompletion = songScore == null ? 0.0 : ((songScore.tallies.sick + songScore.tallies.good) / songScore.tallies.totalNotes);
       rememberedDifficulty = currentDifficulty;
@@ -1737,11 +1719,20 @@ class FreeplayState extends MusicBeatSubState
       FlxG.log.warn('WARN: could not find song with id (${cap.songData.songId})');
       return;
     }
-    var targetDifficulty:String = currentDifficulty;
-    var targetVariation:String = targetSong.getFirstValidVariation(targetDifficulty);
-
+    var targetDifficultyId:String = currentDifficulty;
+    var targetVariation:String = targetSong.getFirstValidVariation(targetDifficultyId);
     PlayStatePlaylist.campaignId = cap.songData.levelId;
 
+    var targetDifficulty:SongDifficulty = targetSong.getDifficulty(targetDifficultyId, targetVariation);
+    if (targetDifficulty == null)
+    {
+      FlxG.log.warn('WARN: could not find difficulty with id (${targetDifficultyId})');
+      return;
+    }
+
+    // TODO: Change this with alternate instrumentals
+    var targetInstId:String = targetDifficulty.characters.instrumental;
+
     // Visual and audio effects.
     FunkinSound.playOnce(Paths.sound('confirmMenu'));
     dj.confirm();
@@ -1790,8 +1781,9 @@ class FreeplayState extends MusicBeatSubState
       LoadingState.loadPlayState(
         {
           targetSong: targetSong,
-          targetDifficulty: targetDifficulty,
+          targetDifficulty: targetDifficultyId,
           targetVariation: targetVariation,
+          targetInstrumental: targetInstId,
           practiceMode: false,
           minimalMode: false,
 
@@ -1828,12 +1820,12 @@ class FreeplayState extends MusicBeatSubState
 
   function changeSelection(change:Int = 0):Void
   {
-    if (!prepForNewRank) FunkinSound.playOnce(Paths.sound('scrollMenu'), 0.4);
-
     var prevSelected:Int = curSelected;
 
     curSelected += change;
 
+    if (!prepForNewRank && curSelected != prevSelected) FunkinSound.playOnce(Paths.sound('scrollMenu'), 0.4);
+
     if (curSelected < 0) curSelected = grpCapsules.countLiving() - 1;
     if (curSelected >= grpCapsules.countLiving()) curSelected = 0;
 
@@ -2087,7 +2079,7 @@ class FreeplaySongData
     this.songDifficulties = song.listDifficulties(null, variations, false, false);
     if (!this.songDifficulties.contains(currentDifficulty)) currentDifficulty = Constants.DEFAULT_DIFFICULTY;
 
-    var songDifficulty:SongDifficulty = song.getDifficulty(currentDifficulty, variations);
+    var songDifficulty:SongDifficulty = song.getDifficulty(currentDifficulty, null, variations);
     if (songDifficulty == null) return;
     this.songStartingBpm = songDifficulty.getStartingBPM();
     this.songName = songDifficulty.songName;
diff --git a/source/funkin/ui/freeplay/LetterSort.hx b/source/funkin/ui/freeplay/LetterSort.hx
index e813c9198..049e9194a 100644
--- a/source/funkin/ui/freeplay/LetterSort.hx
+++ b/source/funkin/ui/freeplay/LetterSort.hx
@@ -8,6 +8,7 @@ import flixel.tweens.FlxTween;
 import flixel.tweens.FlxEase;
 import flixel.util.FlxColor;
 import flixel.util.FlxTimer;
+import funkin.input.Controls;
 import funkin.graphics.adobeanimate.FlxAtlasSprite;
 
 class LetterSort extends FlxTypedSpriteGroup<FlxSprite>
@@ -69,14 +70,19 @@ class LetterSort extends FlxTypedSpriteGroup<FlxSprite>
     changeSelection(0);
   }
 
+  var controls(get, never):Controls;
+
+  inline function get_controls():Controls
+    return PlayerSettings.player1.controls;
+
   override function update(elapsed:Float):Void
   {
     super.update(elapsed);
 
     if (inputEnabled)
     {
-      if (FlxG.keys.justPressed.E) changeSelection(1);
-      if (FlxG.keys.justPressed.Q) changeSelection(-1);
+      if (controls.FREEPLAY_LEFT) changeSelection(-1);
+      if (controls.FREEPLAY_RIGHT) changeSelection(1);
     }
   }
 
diff --git a/source/funkin/ui/freeplay/SongMenuItem.hx b/source/funkin/ui/freeplay/SongMenuItem.hx
index dc30b4345..7708b3bcf 100644
--- a/source/funkin/ui/freeplay/SongMenuItem.hx
+++ b/source/funkin/ui/freeplay/SongMenuItem.hx
@@ -219,7 +219,7 @@ class SongMenuItem extends FlxSpriteGroup
     favIconBlurred.visible = false;
     add(favIconBlurred);
 
-    favIcon = new FlxSprite(380, 40);
+    favIcon = new FlxSprite(favIconBlurred.x, favIconBlurred.y);
     favIcon.frames = Paths.getSparrowAtlas('freeplay/favHeart');
     favIcon.animation.addByPrefix('fav', 'favorite heart', 24, false);
     favIcon.animation.play('fav');
@@ -294,21 +294,34 @@ class SongMenuItem extends FlxSpriteGroup
     }
   }
 
-  // 255, 27 normal
-  // 220, 27 favourited
+  /**
+   * Checks whether the song is favorited, and/or has a rank, and adjusts the clipping
+   * for the scenario when the text could be too long
+   */
   public function checkClip():Void
   {
     var clipSize:Int = 290;
     var clipType:Int = 0;
 
-    if (ranking.visible == true) clipType += 1;
-    if (favIcon.visible == true) clipType = 2;
+    if (ranking.visible)
+    {
+      favIconBlurred.x = this.x + 370;
+      favIcon.x = favIconBlurred.x;
+      clipType += 1;
+    }
+    else
+    {
+      favIconBlurred.x = favIcon.x = this.x + 405;
+    }
+
+    if (favIcon.visible) clipType += 1;
+
     switch (clipType)
     {
       case 2:
-        clipSize = 220;
+        clipSize = 210;
       case 1:
-        clipSize = 255;
+        clipSize = 245;
     }
     songText.clipWidth = clipSize;
   }
diff --git a/source/funkin/ui/mainmenu/MainMenuState.hx b/source/funkin/ui/mainmenu/MainMenuState.hx
index b6ec25e61..d09536eea 100644
--- a/source/funkin/ui/mainmenu/MainMenuState.hx
+++ b/source/funkin/ui/mainmenu/MainMenuState.hx
@@ -371,6 +371,33 @@ class MainMenuState extends MusicBeatState
             }
         });
     }
+
+    if (FlxG.keys.pressed.CONTROL && FlxG.keys.pressed.ALT && FlxG.keys.pressed.SHIFT && FlxG.keys.justPressed.R)
+    {
+      // Give the user a hypothetical overridden score,
+      // and see if we can maintain that golden P rank.
+      funkin.save.Save.instance.setSongScore('tutorial', 'easy',
+        {
+          score: 1234567,
+          tallies:
+            {
+              sick: 0,
+              good: 0,
+              bad: 0,
+              shit: 1,
+              missed: 0,
+              combo: 0,
+              maxCombo: 0,
+              totalNotesHit: 1,
+              totalNotes: 10,
+            }
+        });
+    }
+
+    if (FlxG.keys.pressed.CONTROL && FlxG.keys.pressed.ALT && FlxG.keys.pressed.SHIFT && FlxG.keys.justPressed.E)
+    {
+      funkin.save.Save.instance.debug_dumpSave();
+    }
     #end
 
     if (FlxG.sound.music != null && FlxG.sound.music.volume < 0.8)
diff --git a/source/funkin/ui/options/ControlsMenu.hx b/source/funkin/ui/options/ControlsMenu.hx
index dd7d5ff38..1f40a8455 100644
--- a/source/funkin/ui/options/ControlsMenu.hx
+++ b/source/funkin/ui/options/ControlsMenu.hx
@@ -28,6 +28,8 @@ class ControlsMenu extends funkin.ui.options.OptionsState.Page
     [NOTE_UP, NOTE_DOWN, NOTE_LEFT, NOTE_RIGHT],
     [UI_UP, UI_DOWN, UI_LEFT, UI_RIGHT, ACCEPT, BACK],
     [CUTSCENE_ADVANCE],
+    [FREEPLAY_FAVORITE, FREEPLAY_LEFT, FREEPLAY_RIGHT],
+    [WINDOW_FULLSCREEN, WINDOW_SCREENSHOT],
     [VOLUME_UP, VOLUME_DOWN, VOLUME_MUTE],
     [DEBUG_MENU, DEBUG_CHART]
   ];
@@ -108,6 +110,18 @@ class ControlsMenu extends funkin.ui.options.OptionsState.Page
         headers.add(new AtlasText(0, y, "CUTSCENE", AtlasFont.BOLD)).screenCenter(X);
         y += spacer;
       }
+      else if (currentHeader != "FREEPLAY_" && name.indexOf("FREEPLAY_") == 0)
+      {
+        currentHeader = "FREEPLAY_";
+        headers.add(new AtlasText(0, y, "FREEPLAY", AtlasFont.BOLD)).screenCenter(X);
+        y += spacer;
+      }
+      else if (currentHeader != "WINDOW_" && name.indexOf("WINDOW_") == 0)
+      {
+        currentHeader = "WINDOW_";
+        headers.add(new AtlasText(0, y, "WINDOW", AtlasFont.BOLD)).screenCenter(X);
+        y += spacer;
+      }
       else if (currentHeader != "VOLUME_" && name.indexOf("VOLUME_") == 0)
       {
         currentHeader = "VOLUME_";
@@ -123,10 +137,10 @@ class ControlsMenu extends funkin.ui.options.OptionsState.Page
 
       if (currentHeader != null && name.indexOf(currentHeader) == 0) name = name.substr(currentHeader.length);
 
-      var label = labels.add(new AtlasText(150, y, name, AtlasFont.BOLD));
+      var label = labels.add(new AtlasText(100, y, name, AtlasFont.BOLD));
       label.alpha = 0.6;
       for (i in 0...COLUMNS)
-        createItem(label.x + 400 + i * 300, y, control, i);
+        createItem(label.x + 550 + i * 400, y, control, i);
 
       y += spacer;
     }
diff --git a/source/funkin/ui/story/StoryMenuState.hx b/source/funkin/ui/story/StoryMenuState.hx
index c1a001e5d..06a83ab4d 100644
--- a/source/funkin/ui/story/StoryMenuState.hx
+++ b/source/funkin/ui/story/StoryMenuState.hx
@@ -387,6 +387,7 @@ class StoryMenuState extends MusicBeatState
   function changeLevel(change:Int = 0):Void
   {
     var currentIndex:Int = levelList.indexOf(currentLevelId);
+    var prevIndex:Int = currentIndex;
 
     currentIndex += change;
 
@@ -417,7 +418,7 @@ class StoryMenuState extends MusicBeatState
       }
     }
 
-    FunkinSound.playOnce(Paths.sound('scrollMenu'), 0.4);
+    if (currentIndex != prevIndex) FunkinSound.playOnce(Paths.sound('scrollMenu'), 0.4);
 
     updateText();
     updateBackground(previousLevelId);
diff --git a/source/funkin/ui/title/TitleState.hx b/source/funkin/ui/title/TitleState.hx
index c6dbcd505..8087916cb 100644
--- a/source/funkin/ui/title/TitleState.hx
+++ b/source/funkin/ui/title/TitleState.hx
@@ -265,6 +265,13 @@ class TitleState extends MusicBeatState
     if (FlxG.keys.pressed.DOWN) FlxG.sound.music.pitch -= 0.5 * elapsed;
     #end
 
+    #if desktop
+    if (FlxG.keys.justPressed.ESCAPE)
+    {
+      Sys.exit(0);
+    }
+    #end
+
     Conductor.instance.update();
 
     /* if (FlxG.onMobile)
diff --git a/source/funkin/util/FileUtil.hx b/source/funkin/util/FileUtil.hx
index 7a7b1422c..00a0a14b7 100644
--- a/source/funkin/util/FileUtil.hx
+++ b/source/funkin/util/FileUtil.hx
@@ -19,6 +19,7 @@ import haxe.ui.containers.dialogs.Dialogs.FileDialogExtensionInfo;
 class FileUtil
 {
   public static final FILE_FILTER_FNFC:FileFilter = new FileFilter("Friday Night Funkin' Chart (.fnfc)", "*.fnfc");
+  public static final FILE_FILTER_JSON:FileFilter = new FileFilter("JSON Data File (.json)", "*.json");
   public static final FILE_FILTER_ZIP:FileFilter = new FileFilter("ZIP Archive (.zip)", "*.zip");
   public static final FILE_FILTER_PNG:FileFilter = new FileFilter("PNG Image (.png)", "*.png");
 
diff --git a/source/funkin/util/WindowUtil.hx b/source/funkin/util/WindowUtil.hx
index 07f6bc13a..0fe63fe32 100644
--- a/source/funkin/util/WindowUtil.hx
+++ b/source/funkin/util/WindowUtil.hx
@@ -92,7 +92,7 @@ class WindowUtil
     });
 
     openfl.Lib.current.stage.addEventListener(openfl.events.KeyboardEvent.KEY_DOWN, (e:openfl.events.KeyboardEvent) -> {
-      for (key in PlayerSettings.player1.controls.getKeysForAction(FULLSCREEN))
+      for (key in PlayerSettings.player1.controls.getKeysForAction(WINDOW_FULLSCREEN))
       {
         if (e.keyCode == key)
         {
diff --git a/source/funkin/util/plugins/ScreenshotPlugin.hx b/source/funkin/util/plugins/ScreenshotPlugin.hx
index 9ac21d4b8..c859710de 100644
--- a/source/funkin/util/plugins/ScreenshotPlugin.hx
+++ b/source/funkin/util/plugins/ScreenshotPlugin.hx
@@ -103,7 +103,7 @@ class ScreenshotPlugin extends FlxBasic
 
   public function hasPressedScreenshot():Bool
   {
-    return PlayerSettings.player1.controls.SCREENSHOT;
+    return PlayerSettings.player1.controls.WINDOW_SCREENSHOT;
   }
 
   public function updatePreferences():Void