diff --git a/.github/actions/setup-haxeshit/action.yml b/.github/actions/setup-haxeshit/action.yml
index 0cc544cf7..dcf5fd0a7 100644
--- a/.github/actions/setup-haxeshit/action.yml
+++ b/.github/actions/setup-haxeshit/action.yml
@@ -23,8 +23,6 @@ runs:
       with:
         path: .haxelib
         key: ${{ runner.os }}-hmm-${{ hashFiles('**/hmm.json') }}
-        restore-keys: |
-          ${{ runner.os }}-hmm-
     - if: ${{ steps.cache-hmm.outputs.cache-hit != 'true' }}
       name: hmm install
       run: |
diff --git a/.github/workflows/build-shit.yml b/.github/workflows/build-shit.yml
index 809a8b94b..ed10cbdc2 100644
--- a/.github/workflows/build-shit.yml
+++ b/.github/workflows/build-shit.yml
@@ -53,9 +53,8 @@ jobs:
           token: ${{ secrets.GH_RO_PAT }}
       - uses: ./.github/actions/setup-haxeshit
       - name: Make HXCPP cache dir
-        shell: bash
         run: |
-          mkdir -p ${{ runner.temp }}\\hxcpp_cache
+          mkdir -p ${{ runner.temp }}\hxcpp_cache
       - name: Restore build cache
         id: cache-build-win
         uses: actions/cache@v3
@@ -63,10 +62,8 @@ jobs:
           path: |
             .haxelib
             export
-            ${{ runner.temp }}\\hxcpp_cache
-          key: ${{ runner.os }}-build-win-${{ github.ref_name }}
-          restore-keys: |
-            ${{ runner.os }}-build-win-
+            ${{ runner.temp }}\hxcpp_cache
+          key: ${{ runner.os }}-build-win-${{ github.ref_name }}-${{ hashFiles('**/hmm.json') }}
       - name: Build game
         run: |
           haxelib run lime build windows -release -DNO_REDIRECT_ASSETS_FOLDER
diff --git a/Project.xml b/Project.xml
index ccf6c83a3..69400d8b1 100644
--- a/Project.xml
+++ b/Project.xml
@@ -156,7 +156,6 @@
 	<haxedef name="HXCPP_CHECK_POINTER" />
 	<haxedef name="HXCPP_STACK_LINE" />
 	<haxedef name="HXCPP_STACK_TRACE" />
-	<haxedef name="openfl-enable-handle-error" />
 	<!-- This macro allows addition of new functionality to existing Flixel. -->
 	<haxeflag name="--macro" value="addMetadata('@:build(funkin.util.macro.FlxMacro.buildFlxBasic())', 'flixel.FlxBasic')" />
 	<!--Place custom nodes like icons here (higher priority to override the HaxeFlixel icon)-->
@@ -196,6 +195,22 @@
 		<haxedef name="REDIRECT_ASSETS_FOLDER" />
 	</section>
 
+
+	<section>
+		<!--
+			This flag enables the popup/crashlog error handler.
+			However, it also messes with breakpoints on some platforms.
+		-->
+		<haxedef name="openfl-enable-handle-error" />
+	</section>
+
+	<section>
+		<!-- TODO: Add a flag to Github Actions to turn this on or something. -->
+
+		<!-- Forces the version string to include the Git hash even on release builds (which are used for performance reasons). -->
+		<haxedef name="FORCE_DEBUG_VERSION" />
+	</section>
+
 	<!-- Run a script before and after building. -->
 	<postbuild haxe="source/Prebuild.hx"/> -->
 	<postbuild haxe="source/Postbuild.hx"/> -->
diff --git a/assets b/assets
index 05973b6bb..a66eb8353 160000
--- a/assets
+++ b/assets
@@ -1 +1 @@
-Subproject commit 05973b6bb816464b5cb46631285f17477d05cf08
+Subproject commit a66eb835318b7b4cf639d5add1de98a1a155d209
diff --git a/hmm.json b/hmm.json
index 3f420ac48..070d96cd0 100644
--- a/hmm.json
+++ b/hmm.json
@@ -104,7 +104,7 @@
       "name": "lime",
       "type": "git",
       "dir": null,
-      "ref": "f195121ebec688b417e38ab115185c8d93c349d3",
+      "ref": "737b86f121cdc90358d59e2e527934f267c94a2c",
       "url": "https://github.com/EliteMasterEric/lime"
     },
     {
@@ -139,7 +139,7 @@
       "name": "openfl",
       "type": "git",
       "dir": null,
-      "ref": "de9395d2f367a80f93f082e1b639b9cde2258bf1",
+      "ref": "f229d76361c7e31025a048fe7909847f75bb5d5e",
       "url": "https://github.com/EliteMasterEric/openfl"
     },
     {
diff --git a/source/Main.hx b/source/Main.hx
index 72209cd30..dffe666b7 100644
--- a/source/Main.hx
+++ b/source/Main.hx
@@ -4,6 +4,7 @@ import flixel.FlxGame;
 import flixel.FlxState;
 import funkin.util.logging.CrashHandler;
 import funkin.MemoryCounter;
+import funkin.save.Save;
 import haxe.ui.Toolkit;
 import openfl.display.FPS;
 import openfl.display.Sprite;
@@ -84,20 +85,21 @@ class Main extends Sprite
 
     initHaxeUI();
 
+    fpsCounter = new FPS(10, 3, 0xFFFFFF);
+    // addChild(fpsCounter); // Handled by Preferences.init
+    #if !html5
+    memoryCounter = new MemoryCounter(10, 13, 0xFFFFFF);
+    // addChild(memoryCounter);
+    #end
+
+    // George recommends binding the save before FlxGame is created.
+    Save.load();
+
     addChild(new FlxGame(gameWidth, gameHeight, initialState, framerate, framerate, skipSplash, startFullscreen));
 
     #if hxcpp_debug_server
     trace('hxcpp_debug_server is enabled! You can now connect to the game with a debugger.');
     #end
-
-    #if debug
-    fpsCounter = new FPS(10, 3, 0xFFFFFF);
-    addChild(fpsCounter);
-    #if !html5
-    memoryCounter = new MemoryCounter(10, 13, 0xFFFFFF);
-    addChild(memoryCounter);
-    #end
-    #end
   }
 
   function initHaxeUI():Void
diff --git a/source/funkin/Controls.hx b/source/funkin/Controls.hx
index 81055fb34..9372c4dc6 100644
--- a/source/funkin/Controls.hx
+++ b/source/funkin/Controls.hx
@@ -1,5 +1,7 @@
+
 package funkin;
 
+import flixel.input.gamepad.FlxGamepad;
 import flixel.util.FlxDirectionFlags;
 import flixel.FlxObject;
 import flixel.input.FlxInput;
@@ -832,6 +834,14 @@ class Controls extends FlxActionSet
     fromSaveData(padData, Gamepad(id));
   }
 
+  public function getGamepadIds():Array<Int> {
+    return gamepadsAdded;
+  }
+
+  public function getGamepads():Array<FlxGamepad> {
+    return [for (id in gamepadsAdded) FlxG.gamepads.getByID(id)];
+  }
+
   inline function addGamepadLiteral(id:Int, ?buttonMap:Map<Control, Array<FlxGamepadInputID>>):Void
   {
     gamepadsAdded.push(id);
diff --git a/source/funkin/FreeplayState.hx b/source/funkin/FreeplayState.hx
index 3ae32c2e4..4e7674e93 100644
--- a/source/funkin/FreeplayState.hx
+++ b/source/funkin/FreeplayState.hx
@@ -1,54 +1,55 @@
 package funkin;
 
-import funkin.shaderslmfao.HSVShader;
-import funkin.ui.StickerSubState;
 import flash.text.TextField;
+import flixel.addons.display.FlxGridOverlay;
+import flixel.addons.transition.FlxTransitionableState;
+import flixel.addons.ui.FlxInputText;
 import flixel.FlxCamera;
 import flixel.FlxGame;
 import flixel.FlxSprite;
 import flixel.FlxState;
-import flixel.addons.display.FlxGridOverlay;
-import flixel.addons.transition.FlxTransitionableState;
-import flixel.addons.ui.FlxInputText;
-import flixel.group.FlxGroup.FlxTypedGroup;
 import flixel.group.FlxGroup;
+import flixel.group.FlxGroup.FlxTypedGroup;
 import flixel.group.FlxSpriteGroup;
-import flixel.system.debug.watch.Tracker.TrackerProfile;
 import flixel.input.touch.FlxTouch;
 import flixel.math.FlxAngle;
 import flixel.math.FlxMath;
 import flixel.math.FlxPoint;
+import flixel.system.debug.watch.Tracker.TrackerProfile;
 import flixel.text.FlxText;
 import flixel.tweens.FlxEase;
 import flixel.tweens.FlxTween;
 import flixel.util.FlxColor;
-import funkin.data.song.SongRegistry;
-import funkin.data.level.LevelRegistry;
 import flixel.util.FlxSpriteUtil;
 import flixel.util.FlxTimer;
 import funkin.Controls.Control;
+import funkin.data.level.LevelRegistry;
+import funkin.data.song.SongRegistry;
 import funkin.freeplayStuff.BGScrollingText;
+import funkin.freeplayStuff.DifficultyStars;
 import funkin.freeplayStuff.DJBoyfriend;
 import funkin.freeplayStuff.FreeplayScore;
 import funkin.freeplayStuff.LetterSort;
 import funkin.freeplayStuff.SongMenuItem;
-import funkin.freeplayStuff.DifficultyStars;
+import funkin.graphics.adobeanimate.FlxAtlasSprite;
 import funkin.play.HealthIcon;
 import funkin.play.PlayState;
-import funkin.shaderslmfao.AngleMask;
-import funkin.shaderslmfao.PureColor;
-import funkin.shaderslmfao.StrokeShader;
 import funkin.play.PlayStatePlaylist;
 import funkin.play.song.Song;
+import funkin.save.Save;
+import funkin.save.Save.SaveScoreData;
+import funkin.shaderslmfao.AngleMask;
+import funkin.shaderslmfao.HSVShader;
+import funkin.shaderslmfao.PureColor;
+import funkin.shaderslmfao.StrokeShader;
+import funkin.ui.StickerSubState;
 import lime.app.Future;
 import lime.utils.Assets;
-import funkin.graphics.adobeanimate.FlxAtlasSprite;
 
 class FreeplayState extends MusicBeatSubState
 {
   var songs:Array<FreeplaySongData> = [];
 
-  // var selector:FlxText;
   var curSelected:Int = 0;
   var curDifficulty:Int = 1;
 
@@ -107,8 +108,6 @@ class FreeplayState extends MusicBeatSubState
 
       openSubState(stickerSubState);
       stickerSubState.degenStickers();
-
-      // resetSubState();
     }
 
     #if discord_rpc
@@ -120,31 +119,25 @@ class FreeplayState extends MusicBeatSubState
 
     #if debug
     isDebug = true;
-    // addSong('Test', 'tutorial', 'bf-pixel');
-    // addSong('Pyro', 'weekend1', 'darnell');
     #end
 
-    // var initSonglist = CoolUtil.coolTextFile(Paths.txt('freeplaySonglist'));
-
-    // for (i in 0...initSonglist.length)
-    // {
-    //   songs.push(new FreeplaySongData(initSonglist[i], 'tutorial', 'gf'));
-    // }
-
     if (FlxG.sound.music != null)
     {
       if (!FlxG.sound.music.playing) FlxG.sound.playMusic(Paths.music('freakyMenu/freakyMenu'));
     }
 
+    // Add a null entry that represents the RANDOM option
+    songs.push(null);
+
     // programmatically adds the songs via LevelRegistry and SongRegistry
     for (coolWeek in LevelRegistry.instance.listBaseGameLevelIds())
     {
-      for (coolSong in LevelRegistry.instance.parseEntryData(coolWeek).songs)
+      for (songId in LevelRegistry.instance.parseEntryData(coolWeek).songs)
       {
-        var metadata = SongRegistry.instance.parseEntryMetadata(coolSong);
+        var metadata = SongRegistry.instance.parseEntryMetadata(songId);
         var char = metadata.playData.characters.opponent;
         var songName = metadata.songName;
-        addSong(songName, coolWeek, char);
+        addSong(songId, songName, coolWeek, char);
       }
     }
 
@@ -254,7 +247,6 @@ class FreeplayState extends MusicBeatSubState
         speed: 0.3
       });
 
-    // dj = new DJBoyfriend(0, -100);
     dj = new DJBoyfriend(640, 366);
     exitMovers.set([dj],
       {
@@ -427,7 +419,6 @@ class FreeplayState extends MusicBeatSubState
 
     dj.onIntroDone.add(function() {
       // when boyfriend hits dat shiii
-      //
 
       albumArt.visible = true;
       albumArt.anim.play("");
@@ -485,40 +476,15 @@ class FreeplayState extends MusicBeatSubState
 
     generateSongList(null, false);
 
-    // FlxG.sound.playMusic(Paths.music('title'), 0);
-    // FlxG.sound.music.fadeIn(2, 0, 0.8);
-    // selector = new FlxText();
-
-    // selector.size = 40;
-    // selector.text = ">";
-    // add(selector);
-
     var swag:Alphabet = new Alphabet(1, 0, "swag");
 
-    // JUST DOIN THIS SHIT FOR TESTING!!!
-    /*
-      var md:String = Markdown.markdownToHtml(Assets.getText('CHANGELOG.md'));
-
-      var texFel:TextField = new TextField();
-      texFel.width = FlxG.width;
-      texFel.height = FlxG.height;
-      // texFel.
-      texFel.htmlText = md;
-
-      FlxG.stage.addChild(texFel);
-
-      trace(md);
-     */
-
     var funnyCam = new FlxCamera(0, 0, FlxG.width, FlxG.height);
     funnyCam.bgColor = FlxColor.TRANSPARENT;
     FlxG.cameras.add(funnyCam);
 
     typing = new FlxInputText(100, 100);
-    // add(typing);
 
     typing.callback = function(txt, action) {
-      // generateSongList(new EReg(txt.trim(), "ig"));
       trace(action);
     };
 
@@ -534,9 +500,6 @@ class FreeplayState extends MusicBeatSubState
     for (cap in grpCapsules.members)
       cap.kill();
 
-    // grpCapsules.clear();
-
-    // var regexp:EReg = regexp;
     var tempSongs:Array<FreeplaySongData> = songs;
 
     if (filterStuff != null)
@@ -570,7 +533,7 @@ class FreeplayState extends MusicBeatSubState
     var randomCapsule:SongMenuItem = grpCapsules.recycle(SongMenuItem);
     randomCapsule.init(FlxG.width, 0, "Random");
     randomCapsule.onConfirm = function() {
-      trace("RANDOM SELECTED");
+      capsuleOnConfirmRandom(randomCapsule);
     };
     randomCapsule.y = randomCapsule.intendedY(0) + 10;
     randomCapsule.targetPos.x = randomCapsule.x;
@@ -583,7 +546,10 @@ class FreeplayState extends MusicBeatSubState
 
     for (i in 0...tempSongs.length)
     {
+      if (tempSongs[i] == null) continue;
+
       var funnyMenu:SongMenuItem = grpCapsules.recycle(SongMenuItem);
+
       funnyMenu.init(FlxG.width, 0, tempSongs[i].songName);
       if (tempSongs[i].songCharacter != null) funnyMenu.setCharacter(tempSongs[i].songCharacter);
       funnyMenu.onConfirm = function() {
@@ -596,7 +562,6 @@ class FreeplayState extends MusicBeatSubState
       funnyMenu.songText.visible = false;
       funnyMenu.favIcon.visible = tempSongs[i].isFav;
       funnyMenu.hsvShader = hsvShader;
-      // fp.updateScore(0);
 
       if (i < 8) funnyMenu.initJumpIn(Math.min(i, 4), force);
       else
@@ -611,22 +576,9 @@ class FreeplayState extends MusicBeatSubState
     changeDiff();
   }
 
-  public function addSong(songName:String, levelId:String, songCharacter:String)
+  public function addSong(songId:String, songName:String, levelId:String, songCharacter:String)
   {
-    songs.push(new FreeplaySongData(songName, levelId, songCharacter));
-  }
-
-  public function addWeek(songs:Array<String>, levelId:String, ?songCharacters:Array<String>)
-  {
-    if (songCharacters == null) songCharacters = ['bf'];
-
-    var num:Int = 0;
-    for (song in songs)
-    {
-      addSong(song, levelId, songCharacters[num]);
-
-      if (songCharacters.length != 1) num++;
-    }
+    songs.push(new FreeplaySongData(songId, songName, levelId, songCharacter));
   }
 
   var touchY:Float = 0;
@@ -643,34 +595,39 @@ class FreeplayState extends MusicBeatSubState
   var spamTimer:Float = 0;
   var spamming:Bool = false;
 
+  var busy:Bool = false; // Set to true once the user has pressed enter to select a song.
+
   override function update(elapsed:Float)
   {
     super.update(elapsed);
 
     if (FlxG.keys.justPressed.F)
     {
-      var realShit = curSelected;
-      songs[curSelected].isFav = !songs[curSelected].isFav;
-      if (songs[curSelected].isFav)
+      if (songs[curSelected] != null)
       {
-        FlxTween.tween(grpCapsules.members[realShit], {angle: 360}, 0.4,
-          {
-            ease: FlxEase.elasticOut,
-            onComplete: _ -> {
-              grpCapsules.members[realShit].favIcon.visible = true;
-              grpCapsules.members[realShit].favIcon.animation.play("fav");
-            }
+        var realShit = curSelected;
+        songs[curSelected].isFav = !songs[curSelected].isFav;
+        if (songs[curSelected].isFav)
+        {
+          FlxTween.tween(grpCapsules.members[realShit], {angle: 360}, 0.4,
+            {
+              ease: FlxEase.elasticOut,
+              onComplete: _ -> {
+                grpCapsules.members[realShit].favIcon.visible = true;
+                grpCapsules.members[realShit].favIcon.animation.play("fav");
+              }
+            });
+        }
+        else
+        {
+          grpCapsules.members[realShit].favIcon.animation.play('fav', false, true);
+          new FlxTimer().start((1 / 24) * 14, _ -> {
+            grpCapsules.members[realShit].favIcon.visible = false;
           });
-      }
-      else
-      {
-        grpCapsules.members[realShit].favIcon.animation.play('fav', false, true);
-        new FlxTimer().start((1 / 24) * 14, _ -> {
-          grpCapsules.members[realShit].favIcon.visible = false;
-        });
-        new FlxTimer().start((1 / 24) * 24, _ -> {
-          FlxTween.tween(grpCapsules.members[realShit], {angle: 0}, 0.4, {ease: FlxEase.elasticOut});
-        });
+          new FlxTimer().start((1 / 24) * 24, _ -> {
+            FlxTween.tween(grpCapsules.members[realShit], {angle: 0}, 0.4, {ease: FlxEase.elasticOut});
+          });
+        }
       }
     }
 
@@ -690,11 +647,13 @@ class FreeplayState extends MusicBeatSubState
     fp.updateScore(Std.int(lerpScore));
 
     txtCompletion.text = Math.floor(lerpCompletion * 100) + "%";
-    // trace(Highscore.getCompletion(songs[curSelected].songName, curDifficulty));
 
-    // trace(intendedScore);
-    // trace(lerpScore);
-    // Highscore.getAllScores();
+    handleInputs(elapsed);
+  }
+
+  function handleInputs(elapsed:Float):Void
+  {
+    if (busy) return;
 
     var upP = controls.UI_UP_P;
     var downP = controls.UI_DOWN_P;
@@ -718,16 +677,7 @@ class FreeplayState extends MusicBeatSubState
 
           FlxG.watch.addQuick("LENGTH", length);
           FlxG.watch.addQuick("ANGLE", Math.round(FlxAngle.asDegrees(angle)));
-          // trace("ANGLE", Math.round(FlxAngle.asDegrees(angle)));
         }
-
-        /* switch (inputID)
-          {
-            case FlxObject.UP:
-              return
-            case FlxObject.DOWN:
-          }
-         */
       }
 
       if (FlxG.touches.getFirst() != null)
@@ -763,7 +713,6 @@ class FreeplayState extends MusicBeatSubState
           touchY = touch.screenY;
 
           if (dyTouch != 0) dyTouch < 0 ? changeSelection(1) : changeSelection(-1);
-          // changeSelection(1);
         }
       }
       else
@@ -841,8 +790,6 @@ class FreeplayState extends MusicBeatSubState
 
       FlxG.sound.play(Paths.sound('cancelMenu'));
 
-      // FlxTween.tween(dj, {x: -dj.width}, 0.2, {ease: FlxEase.quartOut});
-
       var longestTimer:Float = 0;
 
       for (grpSpr in exitMovers.keys())
@@ -888,15 +835,11 @@ class FreeplayState extends MusicBeatSubState
         {
           FlxG.switchState(new MainMenuState());
         }
-        //
-        // close();
       });
     }
 
     if (accepted)
     {
-      // if (Assets.exists())
-
       grpCapsules.members[curSelected].onConfirm();
     }
   }
@@ -904,7 +847,11 @@ class FreeplayState extends MusicBeatSubState
   @:haxe.warning("-WDeprecated")
   override function switchTo(nextState:FlxState):Bool
   {
-    clearDaCache(songs[curSelected].songName);
+    var daSong = songs[curSelected];
+    if (daSong != null)
+    {
+      clearDaCache(daSong.songName);
+    }
     return super.switchTo(nextState);
   }
 
@@ -917,9 +864,29 @@ class FreeplayState extends MusicBeatSubState
     if (curDifficulty < 0) curDifficulty = 2;
     if (curDifficulty > 2) curDifficulty = 0;
 
-    // intendedScore = Highscore.getScore(songs[curSelected].songName, curDifficulty);
-    intendedScore = Highscore.getScore(songs[curSelected].songName, curDifficulty);
-    intendedCompletion = Highscore.getCompletion(songs[curSelected].songName, curDifficulty);
+    var targetDifficulty:String = switch (curDifficulty)
+    {
+      case 0:
+        'easy';
+      case 1:
+        'normal';
+      case 2:
+        'hard';
+      default: 'normal';
+    };
+
+    var daSong = songs[curSelected];
+    if (daSong != null)
+    {
+      var songScore:SaveScoreData = Save.get().getSongScore(songs[curSelected].songId, targetDifficulty);
+      intendedScore = songScore?.score ?? 0;
+      intendedCompletion = songScore?.accuracy ?? 0.0;
+    }
+    else
+    {
+      intendedScore = 0;
+      intendedCompletion = 0.0;
+    }
 
     grpDifficulties.group.forEach(function(spr) {
       spr.visible = false;
@@ -941,6 +908,7 @@ class FreeplayState extends MusicBeatSubState
   {
     for (song in songs)
     {
+      if (song == null) continue;
       if (song.songName != actualSongTho)
       {
         trace('trying to remove: ' + song.songName);
@@ -949,19 +917,16 @@ class FreeplayState extends MusicBeatSubState
     }
   }
 
+  function capsuleOnConfirmRandom(cap:SongMenuItem):Void
+  {
+    trace("RANDOM SELECTED");
+
+    busy = true;
+  }
+
   function capsuleOnConfirmDefault(cap:SongMenuItem):Void
   {
-    // var poop:String = songs[curSelected].songName.toLowerCase();
-
-    // does not work properly, always just accidentally sets it to normal anyways!
-    /* if (!Assets.exists(Paths.json(songs[curSelected].songName + '/' + poop)))
-      {
-        // defaults to normal if HARD / EASY doesn't exist
-        // does not account if NORMAL doesn't exist!
-        FlxG.log.warn("CURRENT DIFFICULTY IS NOT CHARTED, DEFAULTING TO NORMAL!");
-        poop = Highscore.formatSong(songs[curSelected].songName.toLowerCase(), 1);
-        curDifficulty = 1;
-    }*/
+    busy = true;
 
     PlayStatePlaylist.isStoryMode = false;
 
@@ -1002,6 +967,7 @@ class FreeplayState extends MusicBeatSubState
     targetSong.cacheCharts(true);
 
     new FlxTimer().start(1, function(tmr:FlxTimer) {
+      Paths.setCurrentLevel(songs[curSelected].levelId);
       LoadingState.loadAndSwitchState(new PlayState(
         {
           targetSong: targetSong,
@@ -1013,8 +979,6 @@ class FreeplayState extends MusicBeatSubState
 
   function changeSelection(change:Int = 0)
   {
-    // fp.updateScore(12345);
-
     // NGio.logEvent('Fresh');
     FlxG.sound.play(Paths.sound('scrollMenu'), 0.4);
     // FlxG.sound.playMusic(Paths.inst(songs[curSelected].songName));
@@ -1024,27 +988,30 @@ class FreeplayState extends MusicBeatSubState
     if (curSelected < 0) curSelected = grpCapsules.countLiving() - 1;
     if (curSelected >= grpCapsules.countLiving()) curSelected = 0;
 
-    // selector.y = (70 * curSelected) + 30;
-
-    // intendedScore = Highscore.getScore(songs[curSelected].songName, curDifficulty);
-
-    if (songs[curSelected] != null)
+    var targetDifficulty:String = switch (curDifficulty)
     {
-      intendedScore = Highscore.getScore(songs[curSelected].songName, curDifficulty);
-      intendedCompletion = Highscore.getCompletion(songs[curSelected].songName, curDifficulty);
+      case 0:
+        'easy';
+      case 1:
+        'normal';
+      case 2:
+        'hard';
+      default: 'normal';
+    };
+
+    var daSong = songs[curSelected];
+    if (daSong != null)
+    {
+      var songScore:SaveScoreData = Save.get().getSongScore(daSong.songId, targetDifficulty);
+      intendedScore = songScore?.score ?? 0;
+      intendedCompletion = songScore?.accuracy ?? 0.0;
     }
     else
     {
       intendedScore = 0;
-      intendedCompletion = 0;
+      intendedCompletion = 0.0;
     }
 
-    // lerpScore = 0;
-
-    #if PRELOAD_ALL
-    // FlxG.sound.playMusic(Paths.inst(songs[curSelected].songName), 0);
-    #end
-
     for (index => capsule in grpCapsules.members)
     {
       index += 1;
@@ -1053,7 +1020,6 @@ class FreeplayState extends MusicBeatSubState
 
       capsule.targetPos.y = capsule.intendedY(index - curSelected);
       capsule.targetPos.x = 270 + (60 * (Math.sin(index - curSelected)));
-      // capsule.targetPos.x = 320 + (40 * (index - curSelected));
 
       if (index < curSelected) capsule.targetPos.y -= 100; // another 100 for good measure
     }
@@ -1132,14 +1098,16 @@ enum abstract FilterType(String)
 
 class FreeplaySongData
 {
+  public var songId:String = "";
   public var songName:String = "";
   public var levelId:String = "";
   public var songCharacter:String = "";
   public var isFav:Bool = false;
 
-  public function new(song:String, levelId:String, songCharacter:String, isFav:Bool = false)
+  public function new(songId:String, songName:String, levelId:String, songCharacter:String, isFav:Bool = false)
   {
-    this.songName = song;
+    this.songId = songId;
+    this.songName = songName;
     this.levelId = levelId;
     this.songCharacter = songCharacter;
     this.isFav = isFav;
diff --git a/source/funkin/Highscore.hx b/source/funkin/Highscore.hx
index 46e98d8dc..3c9fd82e4 100644
--- a/source/funkin/Highscore.hx
+++ b/source/funkin/Highscore.hx
@@ -2,183 +2,9 @@ package funkin;
 
 class Highscore
 {
-  #if (haxe >= "4.0.0")
-  public static var songScores:Map<String, Int> = new Map();
-  #else
-  public static var songScores:Map<String, Int> = new Map<String, Int>();
-  #end
-
-  #if (haxe >= "4.0.0")
-  public static var songCompletion:Map<String, Float> = new Map();
-  #else
-  public static var songCompletion:Map<String, Float> = new Map<String, Float>();
-  #end
-
   public static var tallies:Tallies = new Tallies();
-
-  public static function saveScore(song:String, score:Int = 0, ?diff:Int = 0):Bool
-  {
-    var formattedSong:String = formatSong(song, diff);
-
-    #if newgrounds
-    NGio.postScore(score, song);
-    #end
-
-    if (songScores.exists(formattedSong))
-    {
-      if (songScores.get(formattedSong) < score)
-      {
-        setScore(formattedSong, score);
-        return true;
-        // new highscore
-      }
-    }
-    else
-      setScore(formattedSong, score);
-
-    return false;
-  }
-
-  public static function saveScoreForDifficulty(song:String, score:Int = 0, diff:String = 'normal'):Bool
-  {
-    var diffInt:Int = 1;
-
-    if (diff == 'easy') diffInt = 0;
-    else if (diff == 'hard') diffInt = 2;
-
-    return saveScore(song, score, diffInt);
-  }
-
-  public static function saveCompletion(song:String, completion:Float, diff:Int = 0):Bool
-  {
-    var formattedSong:String = formatSong(song, diff);
-
-    if (songCompletion.exists(formattedSong))
-    {
-      if (songCompletion.get(formattedSong) < completion)
-      {
-        setCompletion(formattedSong, completion);
-        return true;
-      }
-    }
-    else
-      setCompletion(formattedSong, completion);
-
-    return false;
-  }
-
-  public static function saveCompletionForDifficulty(song:String, completion:Float, diff:String = 'normal'):Bool
-  {
-    var diffInt:Int = 1;
-
-    if (diff == 'easy') diffInt = 0;
-    else if (diff == 'hard') diffInt = 2;
-
-    return saveCompletion(song, completion, diffInt);
-  }
-
-  public static function saveWeekScore(week:String, score:Int = 0, diff:Int = 0):Void
-  {
-    #if newgrounds
-    NGio.postScore(score, 'Campaign ID $week');
-    #end
-
-    var formattedSong:String = formatSong(week, diff);
-
-    if (songScores.exists(formattedSong))
-    {
-      if (songScores.get(formattedSong) < score) setScore(formattedSong, score);
-    }
-    else
-    {
-      setScore(formattedSong, score);
-    }
-  }
-
-  public static function saveWeekScoreForDifficulty(week:String, score:Int = 0, diff:String = 'normal'):Void
-  {
-    var diffInt:Int = 1;
-
-    if (diff == 'easy') diffInt = 0;
-    else if (diff == 'hard') diffInt = 2;
-
-    saveWeekScore(week, score, diffInt);
-  }
-
-  static function setCompletion(formattedSong:String, completion:Float):Void
-  {
-    songCompletion.set(formattedSong, completion);
-    FlxG.save.data.songCompletion = songCompletion;
-    FlxG.save.flush();
-  }
-
-  /**
-   * YOU SHOULD FORMAT SONG WITH formatSong() BEFORE TOSSING IN SONG VARIABLE
-   */
-  static function setScore(formattedSong:String, score:Int):Void
-  {
-    /** GeoKureli
-     * References to Highscore were wrapped in `#if !switch` blocks. I wasn't sure if this
-     * is because switch doesn't use NGio, or because switch has a different saving method.
-     * I moved the compiler flag here, rather than using it everywhere else.
-     */
-    #if ! switch
-    // Reminder that I don't need to format this song, it should come formatted!
-    songScores.set(formattedSong, score);
-    FlxG.save.data.songScores = songScores;
-    FlxG.save.flush();
-    #end
-  }
-
-  public static function formatSong(song:String, diff:Int):String
-  {
-    var daSong:String = song;
-
-    if (diff == 0) daSong += '-easy';
-    else if (diff == 2) daSong += '-hard';
-
-    return daSong;
-  }
-
-  public static function getScore(song:String, diff:Int):Int
-  {
-    if (!songScores.exists(formatSong(song, diff))) setScore(formatSong(song, diff), 0);
-
-    return songScores.get(formatSong(song, diff));
-  }
-
-  public static function getCompletion(song, diff):Float
-  {
-    if (!songCompletion.exists(formatSong(song, diff))) setCompletion(formatSong(song, diff), 0);
-
-    return songCompletion.get(formatSong(song, diff));
-  }
-
-  public static function getAllScores():Void
-  {
-    trace(songScores.toString());
-  }
-
-  public static function getWeekScore(week:Int, diff:Int):Int
-  {
-    if (!songScores.exists(formatSong('week' + week, diff))) setScore(formatSong('week' + week, diff), 0);
-
-    return songScores.get(formatSong('week' + week, diff));
-  }
-
-  public static function load():Void
-  {
-    if (FlxG.save.data.songScores != null)
-    {
-      songScores = FlxG.save.data.songScores;
-    }
-
-    if (FlxG.save.data.songCompletion != null) songCompletion = FlxG.save.data.songCompletion;
-  }
 }
 
-// i only do forward metadata cuz george did!
-
 @:forward
 abstract Tallies(RawTallies)
 {
diff --git a/source/funkin/InitState.hx b/source/funkin/InitState.hx
index e7060abd7..ecfa32eb3 100644
--- a/source/funkin/InitState.hx
+++ b/source/funkin/InitState.hx
@@ -46,7 +46,11 @@ class InitState extends FlxState
   {
     setupShit();
 
-    loadSaveData();
+    // loadSaveData(); // Moved to Main.hx
+    // Load player options from save data.
+    Preferences.init();
+    // Load controls from save data.
+    PlayerSettings.init();
 
     startGame();
   }
@@ -73,10 +77,6 @@ class InitState extends FlxState
     FlxG.sound.volumeDownKeys = [];
     FlxG.sound.muteKeys = [];
 
-    // TODO: Make sure volume still saves/loads properly.
-    // if (FlxG.save.data.volume != null) FlxG.sound.volume = FlxG.save.data.volume;
-    // if (FlxG.save.data.mute != null) FlxG.sound.muted = FlxG.save.data.mute;
-
     // Set the game to a lower frame rate while it is in the background.
     FlxG.game.focusLostFramerate = 30;
 
@@ -212,24 +212,6 @@ class InitState extends FlxState
     ModuleHandler.callOnCreate();
   }
 
-  /**
-   * Retrive and parse data from the user's save.
-   */
-  function loadSaveData()
-  {
-    // Bind save data.
-    // TODO: Migrate save data to a better format.
-    FlxG.save.bind('funkin', 'ninjamuffin99');
-
-    // Load player options from save data.
-    PreferencesMenu.initPrefs();
-    // Load controls from save data.
-    PlayerSettings.init();
-    // Load highscores from save data.
-    Highscore.load();
-    // TODO: Load level/character/cosmetic unlocks from save data.
-  }
-
   /**
    * Start the game.
    *
diff --git a/source/funkin/MainMenuState.hx b/source/funkin/MainMenuState.hx
index 7c54357bb..7267a6da8 100644
--- a/source/funkin/MainMenuState.hx
+++ b/source/funkin/MainMenuState.hx
@@ -13,23 +13,13 @@ import flixel.input.touch.FlxTouch;
 import flixel.text.FlxText;
 import flixel.tweens.FlxEase;
 import flixel.tweens.FlxTween;
-import flixel.util.FlxColor;
 import flixel.util.FlxTimer;
-import funkin.NGio;
-import funkin.modding.events.ScriptEvent.UpdateScriptEvent;
-import funkin.modding.module.ModuleHandler;
-import funkin.shaderslmfao.ScreenWipeShader;
 import funkin.ui.AtlasMenuList;
-import funkin.ui.MenuList.MenuItem;
 import funkin.ui.MenuList;
 import funkin.ui.title.TitleState;
 import funkin.ui.story.StoryMenuState;
-import funkin.ui.OptionsState;
-import funkin.ui.PreferencesMenu;
 import funkin.ui.Prompt;
 import funkin.util.WindowUtil;
-import lime.app.Application;
-import openfl.filters.ShaderFilter;
 #if discord_rpc
 import Discord.DiscordClient;
 #end
@@ -82,8 +72,10 @@ class MainMenuState extends MusicBeatState
     magenta.y = bg.y;
     magenta.visible = false;
     magenta.color = 0xFFfd719b;
-    if (PreferencesMenu.preferences.get('flashing-menu')) add(magenta);
-    // magenta.scrollFactor.set();
+
+    // TODO: Why doesn't this line compile I'm going fucking feral
+
+    if (Preferences.flashingLights) add(magenta);
 
     menuItems = new MenuTypedList<AtlasMenuItem>();
     add(menuItems);
@@ -116,7 +108,7 @@ class MainMenuState extends MusicBeatState
     #end
 
     createMenuItem('options', 'mainmenu/options', function() {
-      startExitState(new OptionsState());
+      startExitState(new funkin.ui.OptionsState());
     });
 
     // Reset position of menu items.
diff --git a/source/funkin/NGio.hx b/source/funkin/NGio.hx
index f2afe84db..e5f60c8b5 100644
--- a/source/funkin/NGio.hx
+++ b/source/funkin/NGio.hx
@@ -86,10 +86,10 @@ class NGio
     #end
 
     var onSessionFail:Error->Void = null;
-    if (sessionId == null && FlxG.save.data.sessionId != null)
+    if (sessionId == null && Save.get().ngSessionId != null)
     {
       trace("using stored session id");
-      sessionId = FlxG.save.data.sessionId;
+      sessionId = Save.get().ngSessionId;
       onSessionFail = function(error) savedSessionFailed = true;
     }
     #end
@@ -159,8 +159,8 @@ class NGio
   static function onNGLogin():Void
   {
     trace('logged in! user:${NG.core.user.name}');
-    FlxG.save.data.sessionId = NG.core.sessionId;
-    FlxG.save.flush();
+    Save.get().ngSessionId = NG.core.sessionId;
+    Save.get().flush();
     // Load medals then call onNGMedalFetch()
     NG.core.requestMedals(onNGMedalFetch);
 
@@ -174,8 +174,8 @@ class NGio
   {
     NG.core.logOut();
 
-    FlxG.save.data.sessionId = null;
-    FlxG.save.flush();
+    Save.get().ngSessionId = null;
+    Save.get().flush();
   }
 
   // --- MEDALS
diff --git a/source/funkin/PauseSubState.hx b/source/funkin/PauseSubState.hx
index f93e5a450..a074410ea 100644
--- a/source/funkin/PauseSubState.hx
+++ b/source/funkin/PauseSubState.hx
@@ -16,17 +16,18 @@ class PauseSubState extends MusicBeatSubState
 {
   var grpMenuShit:FlxTypedGroup<Alphabet>;
 
-  var pauseOptionsBase:Array<String> = [
+  final pauseOptionsBase:Array<String> = [
     'Resume',
     'Restart Song',
     'Change Difficulty',
     'Toggle Practice Mode',
     'Exit to Menu'
   ];
+  final pauseOptionsCharting:Array<String> = ['Resume', 'Restart Song', 'Exit to Chart Editor'];
 
-  var pauseOptionsDifficulty:Array<String> = ['EASY', 'NORMAL', 'HARD', 'ERECT', 'BACK'];
+  final pauseOptionsDifficultyBase:Array<String> = ['BACK'];
 
-  var pauseOptionsCharting:Array<String> = ['Resume', 'Restart Song', 'Exit to Chart Editor'];
+  var pauseOptionsDifficulty:Array<String> = []; // AUTO-POPULATED
 
   var menuItems:Array<String> = [];
   var curSelected:Int = 0;
@@ -48,6 +49,12 @@ class PauseSubState extends MusicBeatSubState
     this.isChartingMode = isChartingMode;
 
     menuItems = this.isChartingMode ? pauseOptionsCharting : pauseOptionsBase;
+    var difficultiesInVariation = PlayState.instance.currentSong.listDifficulties(PlayState.instance.currentChart.variation);
+    trace('DIFFICULTIES: ${difficultiesInVariation}');
+
+    pauseOptionsDifficulty = difficultiesInVariation.map(function(item:String):String {
+      return item.toUpperCase();
+    }).concat(pauseOptionsDifficultyBase);
 
     if (PlayStatePlaylist.campaignId == 'week6')
     {
@@ -150,6 +157,11 @@ class PauseSubState extends MusicBeatSubState
 
     super.update(elapsed);
 
+    handleInputs();
+  }
+
+  function handleInputs():Void
+  {
     var upP = controls.UI_UP_P;
     var downP = controls.UI_DOWN_P;
     var accepted = controls.ACCEPT;
@@ -196,18 +208,6 @@ class PauseSubState extends MusicBeatSubState
             menuItems = pauseOptionsDifficulty;
             regenMenu();
 
-          case 'EASY' | 'NORMAL' | 'HARD' | 'ERECT':
-            PlayState.instance.currentSong = SongRegistry.instance.fetchEntry(PlayState.instance.currentSong.id.toLowerCase());
-
-            PlayState.instance.currentDifficulty = daSelected.toLowerCase();
-
-            PlayState.instance.needsReset = true;
-
-            close();
-          case 'BACK':
-            menuItems = this.isChartingMode ? pauseOptionsCharting : pauseOptionsBase;
-            regenMenu();
-
           case 'Toggle Practice Mode':
             PlayState.instance.isPracticeMode = true;
             practiceText.visible = PlayState.instance.isPracticeMode;
@@ -229,14 +229,43 @@ class PauseSubState extends MusicBeatSubState
             FlxTransitionableState.skipNextTransIn = true;
             FlxTransitionableState.skipNextTransOut = true;
 
-            if (PlayStatePlaylist.isStoryMode) openSubState(new funkin.ui.StickerSubState(null, STORY));
+            if (PlayStatePlaylist.isStoryMode)
+            {
+              openSubState(new funkin.ui.StickerSubState(null, STORY));
+            }
             else
+            {
               openSubState(new funkin.ui.StickerSubState(null, FREEPLAY));
+            }
 
           case 'Exit to Chart Editor':
             this.close();
             if (FlxG.sound.music != null) FlxG.sound.music.stop();
             PlayState.instance.close(); // This only works because PlayState is a substate!
+
+          case 'BACK':
+            menuItems = this.isChartingMode ? pauseOptionsCharting : pauseOptionsBase;
+            regenMenu();
+
+          default:
+            if (pauseOptionsDifficulty.contains(daSelected))
+            {
+              PlayState.instance.currentSong = SongRegistry.instance.fetchEntry(PlayState.instance.currentSong.id.toLowerCase());
+
+              // Reset campaign score when changing difficulty
+              // So if you switch difficulty on the last song of a week you get a really low overall score.
+              PlayStatePlaylist.campaignScore = 0;
+              PlayStatePlaylist.campaignDifficulty = daSelected.toLowerCase();
+              PlayState.instance.currentDifficulty = PlayStatePlaylist.campaignDifficulty;
+
+              PlayState.instance.needsReset = true;
+
+              close();
+            }
+            else
+            {
+              trace('[WARN] Unhandled pause menu option: ${daSelected}');
+            }
         }
       }
 
diff --git a/source/funkin/PlayerSettings.hx b/source/funkin/PlayerSettings.hx
index 54fd559fb..e97cfe384 100644
--- a/source/funkin/PlayerSettings.hx
+++ b/source/funkin/PlayerSettings.hx
@@ -1,5 +1,6 @@
 package funkin;
 
+import funkin.save.Save;
 import funkin.Controls;
 import flixel.FlxCamera;
 import funkin.input.PreciseInputManager;
@@ -11,121 +12,36 @@ import flixel.util.FlxSignal;
 // import props.Player;
 class PlayerSettings
 {
-  static public var numPlayers(default, null) = 0;
-  static public var numAvatars(default, null) = 0;
-  static public var player1(default, null):PlayerSettings;
-  static public var player2(default, null):PlayerSettings;
+  public static var numPlayers(default, null) = 0;
+  public static var numAvatars(default, null) = 0;
+  public static var player1(default, null):PlayerSettings;
+  public static var player2(default, null):PlayerSettings;
 
-  static public var onAvatarAdd(default, null) = new FlxTypedSignal<PlayerSettings->Void>();
-  static public var onAvatarRemove(default, null) = new FlxTypedSignal<PlayerSettings->Void>();
+  public static var onAvatarAdd(default, null) = new FlxTypedSignal<PlayerSettings->Void>();
+  public static var onAvatarRemove(default, null) = new FlxTypedSignal<PlayerSettings->Void>();
 
   public var id(default, null):Int;
 
   public var controls(default, null):Controls;
 
-  // public var avatar:Player;
-  // public var camera(get, never):PlayCamera;
-
-  function new(id:Int)
+  /**
+   * Return the PlayerSettings for the given player number, or `null` if that player isn't active.
+   */
+  public static function get(id:Int):Null<PlayerSettings>
   {
-    trace('loading player settings for id: $id');
-
-    this.id = id;
-    this.controls = new Controls('player$id', None);
-
-    #if CLEAR_INPUT_SAVE
-    FlxG.save.data.controls = null;
-    FlxG.save.flush();
-    #end
-
-    var useDefault = true;
-    var controlData = FlxG.save.data.controls;
-    if (controlData != null)
+    return switch (id)
     {
-      var keyData:Dynamic = null;
-      if (id == 0 && controlData.p1 != null && controlData.p1.keys != null) keyData = controlData.p1.keys;
-      else if (id == 1 && controlData.p2 != null && controlData.p2.keys != null) keyData = controlData.p2.keys;
-
-      if (keyData != null)
-      {
-        useDefault = false;
-        trace("loaded key data: " + haxe.Json.stringify(keyData));
-        controls.fromSaveData(keyData, Keys);
-      }
-    }
-
-    if (useDefault)
-    {
-      trace("falling back to default control scheme");
-      controls.setKeyboardScheme(Solo);
-    }
-
-    // Apply loaded settings.
-    PreciseInputManager.instance.initializeKeys(controls);
+      case 1: player1;
+      case 2: player2;
+      default: null;
+    };
   }
 
-  function addGamepad(gamepad:FlxGamepad)
-  {
-    var useDefault = true;
-    var controlData = FlxG.save.data.controls;
-    if (controlData != null)
-    {
-      var padData:Dynamic = null;
-      if (id == 0 && controlData.p1 != null && controlData.p1.pad != null) padData = controlData.p1.pad;
-      else if (id == 1 && controlData.p2 != null && controlData.p2.pad != null) padData = controlData.p2.pad;
-
-      if (padData != null)
-      {
-        useDefault = false;
-        trace("loaded pad data: " + haxe.Json.stringify(padData));
-        controls.addGamepadWithSaveData(gamepad.id, padData);
-      }
-    }
-
-    if (useDefault) controls.addDefaultGamepad(gamepad.id);
-  }
-
-  public function saveControls()
-  {
-    if (FlxG.save.data.controls == null) FlxG.save.data.controls = {};
-
-    var playerData:{?keys:Dynamic, ?pad:Dynamic}
-    if (id == 0)
-    {
-      if (FlxG.save.data.controls.p1 == null) FlxG.save.data.controls.p1 = {};
-      playerData = FlxG.save.data.controls.p1;
-    }
-    else
-    {
-      if (FlxG.save.data.controls.p2 == null) FlxG.save.data.controls.p2 = {};
-      playerData = FlxG.save.data.controls.p2;
-    }
-
-    var keyData = controls.createSaveData(Keys);
-    if (keyData != null)
-    {
-      playerData.keys = keyData;
-      trace("saving key data: " + haxe.Json.stringify(keyData));
-    }
-
-    if (controls.gamepadsAdded.length > 0)
-    {
-      var padData = controls.createSaveData(Gamepad(controls.gamepadsAdded[0]));
-      if (padData != null)
-      {
-        trace("saving pad data: " + haxe.Json.stringify(padData));
-        playerData.pad = padData;
-      }
-    }
-
-    FlxG.save.flush();
-  }
-
-  static public function init():Void
+  public static function init():Void
   {
     if (player1 == null)
     {
-      player1 = new PlayerSettings(0);
+      player1 = new PlayerSettings(1);
       ++numPlayers;
     }
 
@@ -137,26 +53,13 @@ class PlayerSettings
       var gamepad = FlxG.gamepads.getByID(i);
       if (gamepad != null) onGamepadAdded(gamepad);
     }
+  }
 
-    // 	player1.controls.addDefaultGamepad(0);
-    // }
-
-    // if (numGamepads > 1)
-    // {
-    // 	if (player2 == null)
-    // 	{
-    // 		player2 = new PlayerSettings(1, None);
-    // 		++numPlayers;
-    // 	}
-
-    // 	var gamepad = FlxG.gamepads.getByID(1);
-    // 	if (gamepad == null)
-    // 		throw 'Unexpected null gamepad. id:0';
-
-    // 	player2.controls.addDefaultGamepad(1);
-    // }
-
-    // DeviceManager.init();
+  public static function reset()
+  {
+    player1 = null;
+    player2 = null;
+    numPlayers = 0;
   }
 
   static function onGamepadAdded(gamepad:FlxGamepad)
@@ -164,86 +67,90 @@ class PlayerSettings
     player1.addGamepad(gamepad);
   }
 
-  /*
-    public function setKeyboardScheme(scheme)
-    {
-      controls.setKeyboardScheme(scheme);
-    }
-
-    static public function addAvatar(avatar:Player):PlayerSettings
-    {
-      var settings:PlayerSettings;
-
-      if (player1 == null)
-      {
-        player1 = new PlayerSettings(0, Solo);
-        ++numPlayers;
-      }
-
-      if (player1.avatar == null)
-        settings = player1;
-      else
-      {
-        if (player2 == null)
-        {
-          if (player1.controls.keyboardScheme.match(Duo(true)))
-            player2 = new PlayerSettings(1, Duo(false));
-          else
-            player2 = new PlayerSettings(1, None);
-          ++numPlayers;
-        }
-
-        if (player2.avatar == null)
-          settings = player2;
-        else
-          throw throw 'Invalid number of players: ${numPlayers + 1}';
-      }
-      ++numAvatars;
-      settings.avatar = avatar;
-      avatar.settings = settings;
-
-      splitCameras();
-
-      onAvatarAdd.dispatch(settings);
-
-      return settings;
-    }
-
-    static public function removeAvatar(avatar:Player):Void
-    {
-      var settings:PlayerSettings;
-
-      if (player1 != null && player1.avatar == avatar)
-        settings = player1;
-      else if (player2 != null && player2.avatar == avatar)
-      {
-        settings = player2;
-        if (player1.controls.keyboardScheme.match(Duo(_)))
-          player1.setKeyboardScheme(Solo);
-      }
-      else
-        throw "Cannot remove avatar that is not for a player";
-
-      settings.avatar = null;
-      while (settings.controls.gamepadsAdded.length > 0)
-      {
-        final id = settings.controls.gamepadsAdded.shift();
-        settings.controls.removeGamepad(id);
-        DeviceManager.releaseGamepad(FlxG.gamepads.getByID(id));
-      }
-
-      --numAvatars;
-
-      splitCameras();
-
-      onAvatarRemove.dispatch(avatar.settings);
-    }
-
+  /**
+   * @param id The player number this represents. This was refactored to START AT `1`.
    */
-  static public function reset()
+  private function new(id:Int)
   {
-    player1 = null;
-    player2 = null;
-    numPlayers = 0;
+    trace('loading player settings for id: $id');
+
+    this.id = id;
+    this.controls = new Controls('player$id', None);
+
+    addKeyboard();
+  }
+
+  function addKeyboard():Void
+  {
+    var useDefault = true;
+    if (Save.get().hasControls(id, Keys))
+    {
+      var keyControlData = Save.get().getControls(id, Keys);
+      trace("keyControlData: " + haxe.Json.stringify(keyControlData));
+      useDefault = false;
+      controls.fromSaveData(keyControlData, Keys);
+    }
+    else
+    {
+      useDefault = true;
+    }
+
+    if (useDefault)
+    {
+      trace("Loading default keyboard control scheme");
+      controls.setKeyboardScheme(Solo);
+    }
+
+    PreciseInputManager.instance.initializeKeys(controls);
+  }
+
+  /**
+   * Called after an FlxGamepad has been detected.
+   * @param gamepad The gamepad that was detected.
+   */
+  function addGamepad(gamepad:FlxGamepad)
+  {
+    var useDefault = true;
+    if (Save.get().hasControls(id, Gamepad(gamepad.id)))
+    {
+      var padControlData = Save.get().getControls(id, Gamepad(gamepad.id));
+      trace("padControlData: " + haxe.Json.stringify(padControlData));
+      useDefault = false;
+      controls.addGamepadWithSaveData(gamepad.id, padControlData);
+    }
+    else
+    {
+      useDefault = true;
+    }
+
+    if (useDefault)
+    {
+      trace("Loading gamepad control scheme");
+      controls.addDefaultGamepad(gamepad.id);
+    }
+    PreciseInputManager.instance.initializeButtons(controls, gamepad);
+  }
+
+  /**
+   * Save this player's controls to the game's persistent save.
+   */
+  public function saveControls()
+  {
+    var keyData = controls.createSaveData(Keys);
+    if (keyData != null)
+    {
+      trace("saving key data: " + haxe.Json.stringify(keyData));
+      Save.get().setControls(id, Keys, keyData);
+    }
+
+    if (controls.gamepadsAdded.length > 0)
+    {
+      var padData = controls.createSaveData(Gamepad(controls.gamepadsAdded[0]));
+      if (padData != null)
+      {
+        trace("saving pad data: " + haxe.Json.stringify(padData));
+        Save.get().setControls(id, Gamepad(controls.gamepadsAdded[0]), padData);
+      }
+    }
   }
 }
diff --git a/source/funkin/Preferences.hx b/source/funkin/Preferences.hx
new file mode 100644
index 000000000..7e3c3c6d7
--- /dev/null
+++ b/source/funkin/Preferences.hx
@@ -0,0 +1,138 @@
+package funkin;
+
+import funkin.save.Save;
+
+/**
+ * A store of user-configurable, globally relevant values.
+ */
+class Preferences
+{
+  /**
+   * Whether some particularly fowl language is displayed.
+   * @default `true`
+   */
+  public static var naughtyness(get, set):Bool;
+
+  static function get_naughtyness():Bool
+  {
+    return Save.get().options.naughtyness;
+  }
+
+  static function set_naughtyness(value:Bool):Bool
+  {
+    return Save.get().options.naughtyness = value;
+  }
+
+  /**
+   * If enabled, the strumline is at the bottom of the screen rather than the top.
+   * @default `false`
+   */
+  public static var downscroll(get, set):Bool;
+
+  static function get_downscroll():Bool
+  {
+    return Save.get().options.downscroll;
+  }
+
+  static function set_downscroll(value:Bool):Bool
+  {
+    return Save.get().options.downscroll = value;
+  }
+
+  /**
+   * If disabled, flashing lights in the main menu and other areas will be less intense.
+   * @default `true`
+   */
+  public static var flashingLights(get, set):Bool;
+
+  static function get_flashingLights():Bool
+  {
+    return Save.get().options.flashingLights;
+  }
+
+  static function set_flashingLights(value:Bool):Bool
+  {
+    return Save.get().options.flashingLights = value;
+  }
+
+  /**
+   * If disabled, the camera bump synchronized to the beat.
+   * @default `false`
+   */
+  public static var zoomCamera(get, set):Bool;
+
+  static function get_zoomCamera():Bool
+  {
+    return Save.get().options.zoomCamera;
+  }
+
+  static function set_zoomCamera(value:Bool):Bool
+  {
+    return Save.get().options.zoomCamera = value;
+  }
+
+  /**
+   * If enabled, an FPS and memory counter will be displayed even if this is not a debug build.
+   * @default `false`
+   */
+  public static var debugDisplay(get, set):Bool;
+
+  static function get_debugDisplay():Bool
+  {
+    return Save.get().options.debugDisplay;
+  }
+
+  static function set_debugDisplay(value:Bool):Bool
+  {
+    if (value != Save.get().options.debugDisplay)
+    {
+      toggleDebugDisplay(value);
+    }
+
+    return Save.get().options.debugDisplay = value;
+  }
+
+  /**
+   * If enabled, the game will automatically pause when tabbing out.
+   * @default `true`
+   */
+  public static var autoPause(get, set):Bool;
+
+  static function get_autoPause():Bool
+  {
+    return Save.get().options.autoPause;
+  }
+
+  static function set_autoPause(value:Bool):Bool
+  {
+    if (value != Save.get().options.autoPause) FlxG.autoPause = value;
+
+    return Save.get().options.autoPause = value;
+  }
+
+  public static function init():Void
+  {
+    FlxG.autoPause = Preferences.autoPause;
+    toggleDebugDisplay(Preferences.debugDisplay);
+  }
+
+  static function toggleDebugDisplay(show:Bool):Void
+  {
+    if (show)
+    {
+      // Enable the debug display.
+      FlxG.stage.addChild(Main.fpsCounter);
+      #if !html5
+      FlxG.stage.addChild(Main.memoryCounter);
+      #end
+    }
+    else
+    {
+      // Disable the debug display.
+      FlxG.stage.removeChild(Main.fpsCounter);
+      #if !html5
+      FlxG.stage.removeChild(Main.memoryCounter);
+      #end
+    }
+  }
+}
diff --git a/source/funkin/api/newgrounds/NGUtil.hx b/source/funkin/api/newgrounds/NGUtil.hx
index 773e2f98f..ba7d5f916 100644
--- a/source/funkin/api/newgrounds/NGUtil.hx
+++ b/source/funkin/api/newgrounds/NGUtil.hx
@@ -86,10 +86,10 @@ class NGUtil
     #end
 
     var onSessionFail:Error->Void = null;
-    if (sessionId == null && FlxG.save.data.sessionId != null)
+    if (sessionId == null && Save.get().ngSessionId != null)
     {
       trace("using stored session id");
-      sessionId = FlxG.save.data.sessionId;
+      sessionId = Save.get().ngSessionId;
       onSessionFail = function(error) savedSessionFailed = true;
     }
     #end
@@ -159,8 +159,8 @@ class NGUtil
   static function onNGLogin():Void
   {
     trace('logged in! user:${NG.core.user.name}');
-    FlxG.save.data.sessionId = NG.core.sessionId;
-    FlxG.save.flush();
+    Save.get().ngSessionId = NG.core.sessionId;
+    Save.get().flush();
     // Load medals then call onNGMedalFetch()
     NG.core.requestMedals(onNGMedalFetch);
 
@@ -174,8 +174,8 @@ class NGUtil
   {
     NG.core.logOut();
 
-    FlxG.save.data.sessionId = null;
-    FlxG.save.flush();
+    Save.get().ngSessionId = null;
+    Save.get().flush();
   }
 
   // --- MEDALS
diff --git a/source/funkin/audiovis/ABotVis.hx b/source/funkin/audiovis/ABotVis.hx
index 2018a99b3..060bddcf7 100644
--- a/source/funkin/audiovis/ABotVis.hx
+++ b/source/funkin/audiovis/ABotVis.hx
@@ -7,7 +7,6 @@ import flixel.graphics.frames.FlxAtlasFrames;
 import flixel.group.FlxSpriteGroup.FlxTypedSpriteGroup;
 import flixel.math.FlxMath;
 import flixel.sound.FlxSound;
-import funkin.ui.PreferencesMenu.CheckboxThingie;
 
 using Lambda;
 
diff --git a/source/funkin/data/song/SongData.hx b/source/funkin/data/song/SongData.hx
index d557bd39c..9340e46c9 100644
--- a/source/funkin/data/song/SongData.hx
+++ b/source/funkin/data/song/SongData.hx
@@ -4,6 +4,7 @@ import flixel.util.typeLimit.OneOfTwo;
 import funkin.data.song.SongRegistry;
 import thx.semver.Version;
 
+@:nullSafety
 class SongMetadata
 {
   /**
@@ -42,7 +43,7 @@ class SongMetadata
   public var timeChanges:Array<SongTimeChange>;
 
   /**
-   * Defaults to `default` or `''`. Populated later.
+   * Defaults to `Constants.DEFAULT_VARIATION`. Populated later.
    */
   @:jignored
   public var variation:String;
@@ -228,10 +229,10 @@ class SongMusicData
   public var timeChanges:Array<SongTimeChange>;
 
   /**
-   * Defaults to `default` or `''`. Populated later.
+   * Defaults to `Constants.DEFAULT_VARIATION`. Populated later.
    */
   @:jignored
-  public var variation:String = Constants.DEFAULT_VARIATION;
+  public var variation:String;
 
   public function new(songName:String, artist:String, variation:String = 'default')
   {
@@ -375,6 +376,9 @@ class SongChartData
   @:default(funkin.data.song.SongRegistry.DEFAULT_GENERATEDBY)
   public var generatedBy:String;
 
+  /**
+   * Defaults to `Constants.DEFAULT_VARIATION`. Populated later.
+   */
   @:jignored
   public var variation:String;
 
diff --git a/source/funkin/data/song/SongDataUtils.hx b/source/funkin/data/song/SongDataUtils.hx
index 4b9318df2..ee3dfe98c 100644
--- a/source/funkin/data/song/SongDataUtils.hx
+++ b/source/funkin/data/song/SongDataUtils.hx
@@ -21,11 +21,21 @@ class SongDataUtils
    * @param notes The notes to modify.
    * @param offset The time difference to apply in milliseconds.
    */
-  public static function offsetSongNoteData(notes:Array<SongNoteData>, offset:Int):Array<SongNoteData>
+  public static function offsetSongNoteData(notes:Array<SongNoteData>, offset:Float):Array<SongNoteData>
   {
-    return notes.map(function(note:SongNoteData):SongNoteData {
-      return new SongNoteData(note.time + offset, note.data, note.length, note.kind);
-    });
+    var offsetNote = function(note:SongNoteData):SongNoteData {
+      var time:Float = note.time + offset;
+      var data:Int = note.data;
+      var length:Float = note.length;
+      var kind:String = note.kind;
+      return new SongNoteData(time, data, length, kind);
+    };
+
+    trace(notes);
+    trace(notes[0]);
+    var result = [for (i in 0...notes.length) offsetNote(notes[i])];
+    trace(result);
+    return result;
   }
 
   /**
@@ -36,7 +46,7 @@ class SongDataUtils
    * @param events The events to modify.
    * @param offset The time difference to apply in milliseconds.
    */
-  public static function offsetSongEventData(events:Array<SongEventData>, offset:Int):Array<SongEventData>
+  public static function offsetSongEventData(events:Array<SongEventData>, offset:Float):Array<SongEventData>
   {
     return events.map(function(event:SongEventData):SongEventData {
       return new SongEventData(event.time + offset, event.event, event.value);
@@ -152,7 +162,8 @@ class SongDataUtils
    */
   public static function writeItemsToClipboard(data:SongClipboardItems):Void
   {
-    var dataString = SerializerUtil.toJSON(data);
+    var writer = new json2object.JsonWriter<SongClipboardItems>();
+    var dataString:String = writer.write(data, '  ');
 
     ClipboardUtil.setClipboard(dataString);
 
@@ -170,19 +181,24 @@ class SongDataUtils
 
     trace('Read ${notesString.length} characters from clipboard.');
 
-    var data:SongClipboardItems = notesString.parseJSON();
-
-    if (data == null)
+    var parser = new json2object.JsonParser<SongClipboardItems>();
+    parser.fromJson(notesString, 'clipboard');
+    if (parser.errors.length > 0)
     {
-      trace('Failed to parse notes from clipboard.');
+      trace('[SongDataUtils] Error parsing note JSON data from clipboard.');
+      for (error in parser.errors)
+        DataError.printError(error);
       return {
+        valid: false,
         notes: [],
         events: []
       };
     }
     else
     {
+      var data:SongClipboardItems = parser.value;
       trace('Parsed ' + data.notes.length + ' notes and ' + data.events.length + ' from clipboard.');
+      data.valid = true;
       return data;
     }
   }
@@ -230,6 +246,7 @@ class SongDataUtils
 
 typedef SongClipboardItems =
 {
+  ?valid:Bool,
   notes:Array<SongNoteData>,
   events:Array<SongEventData>
 }
diff --git a/source/funkin/data/song/SongRegistry.hx b/source/funkin/data/song/SongRegistry.hx
index cf2da14f7..889fca707 100644
--- a/source/funkin/data/song/SongRegistry.hx
+++ b/source/funkin/data/song/SongRegistry.hx
@@ -156,7 +156,7 @@ class SongRegistry extends BaseRegistry<Song, SongMetadata>
     return cleanMetadata(parser.value, variation);
   }
 
-  public function parseEntryMetadataWithMigration(id:String, ?variation:String, version:thx.semver.Version):Null<SongMetadata>
+  public function parseEntryMetadataWithMigration(id:String, variation:String, version:thx.semver.Version):Null<SongMetadata>
   {
     variation = variation == null ? Constants.DEFAULT_VARIATION : variation;
 
@@ -192,7 +192,7 @@ class SongRegistry extends BaseRegistry<Song, SongMetadata>
     }
   }
 
-  function parseEntryMetadata_v2_0_0(id:String, variation:String = ""):Null<SongMetadata>
+  function parseEntryMetadata_v2_0_0(id:String, ?variation:String):Null<SongMetadata>
   {
     variation = variation == null ? Constants.DEFAULT_VARIATION : variation;
 
diff --git a/source/funkin/import.hx b/source/funkin/import.hx
index 06fe2bfa8..1c3a0fdb4 100644
--- a/source/funkin/import.hx
+++ b/source/funkin/import.hx
@@ -4,6 +4,7 @@ package;
 // Only import these when we aren't in a macro.
 import funkin.util.Constants;
 import funkin.Paths;
+import funkin.Preferences;
 import flixel.FlxG; // This one in particular causes a compile error if you're using macros.
 
 // These are great.
diff --git a/source/funkin/input/PreciseInputManager.hx b/source/funkin/input/PreciseInputManager.hx
index 4cce0964d..59e6610a5 100644
--- a/source/funkin/input/PreciseInputManager.hx
+++ b/source/funkin/input/PreciseInputManager.hx
@@ -1,18 +1,25 @@
 package funkin.input;
 
-import openfl.ui.Keyboard;
-import funkin.play.notes.NoteDirection;
-import flixel.input.keyboard.FlxKeyboard.FlxKeyInput;
-import openfl.events.KeyboardEvent;
 import flixel.FlxG;
+import flixel.input.FlxInput;
 import flixel.input.FlxInput.FlxInputState;
 import flixel.input.FlxKeyManager;
+import flixel.input.gamepad.FlxGamepad;
+import flixel.input.gamepad.FlxGamepadInputID;
 import flixel.input.keyboard.FlxKey;
+import flixel.input.keyboard.FlxKeyboard.FlxKeyInput;
 import flixel.input.keyboard.FlxKeyList;
 import flixel.util.FlxSignal.FlxTypedSignal;
+import funkin.play.notes.NoteDirection;
+import funkin.util.FlxGamepadUtil;
 import haxe.Int64;
+import lime.ui.Gamepad as LimeGamepad;
+import lime.ui.GamepadAxis as LimeGamepadAxis;
+import lime.ui.GamepadButton as LimeGamepadButton;
 import lime.ui.KeyCode;
 import lime.ui.KeyModifier;
+import openfl.events.KeyboardEvent;
+import openfl.ui.Keyboard;
 
 /**
  * A precise input manager that:
@@ -43,6 +50,20 @@ class PreciseInputManager extends FlxKeyManager<FlxKey, PreciseInputList>
    */
   var _keyListDir:Map<FlxKey, NoteDirection>;
 
+  /**
+   * A FlxGamepadID->Array<FlxGamepadInputID>, with FlxGamepadInputID being the counterpart to FlxKey.
+   */
+  var _buttonList:Map<Int, Array<FlxGamepadInputID>>;
+
+  var _buttonListArray:Array<FlxInput<FlxGamepadInputID>>;
+
+  var _buttonListMap:Map<Int, Map<FlxGamepadInputID, FlxInput<FlxGamepadInputID>>>;
+
+  /**
+   * A FlxGamepadID->FlxGamepadInputID->NoteDirection, with FlxGamepadInputID being the counterpart to FlxKey.
+   */
+  var _buttonListDir:Map<Int, Map<FlxGamepadInputID, NoteDirection>>;
+
   /**
    * The timestamp at which a given note direction was last pressed.
    */
@@ -53,15 +74,32 @@ class PreciseInputManager extends FlxKeyManager<FlxKey, PreciseInputList>
    */
   var _dirReleaseTimestamps:Map<NoteDirection, Int64>;
 
+  var _deviceBinds:Map<FlxGamepad,
+    {
+      onButtonDown:LimeGamepadButton->Int64->Void,
+      onButtonUp:LimeGamepadButton->Int64->Void
+    }>;
+
   public function new()
   {
     super(PreciseInputList.new);
 
+    _deviceBinds = [];
+
     _keyList = [];
-    _dirPressTimestamps = new Map<NoteDirection, Int64>();
-    _dirReleaseTimestamps = new Map<NoteDirection, Int64>();
+    // _keyListMap
+    // _keyListArray
     _keyListDir = new Map<FlxKey, NoteDirection>();
 
+    _buttonList = [];
+    _buttonListMap = [];
+    _buttonListArray = [];
+    _buttonListDir = new Map<Int, Map<FlxGamepadInputID, NoteDirection>>();
+
+    _dirPressTimestamps = new Map<NoteDirection, Int64>();
+    _dirReleaseTimestamps = new Map<NoteDirection, Int64>();
+
+    // Keyboard
     FlxG.stage.removeEventListener(KeyboardEvent.KEY_DOWN, onKeyDown);
     FlxG.stage.removeEventListener(KeyboardEvent.KEY_UP, onKeyUp);
     FlxG.stage.application.window.onKeyDownPrecise.add(handleKeyDown);
@@ -84,6 +122,17 @@ class PreciseInputManager extends FlxKeyManager<FlxKey, PreciseInputList>
     };
   }
 
+  public static function getButtonsForDirection(controls:Controls, noteDirection:NoteDirection)
+  {
+    return switch (noteDirection)
+    {
+      case NoteDirection.LEFT: controls.getButtonsForAction(NOTE_LEFT);
+      case NoteDirection.DOWN: controls.getButtonsForAction(NOTE_DOWN);
+      case NoteDirection.UP: controls.getButtonsForAction(NOTE_UP);
+      case NoteDirection.RIGHT: controls.getButtonsForAction(NOTE_RIGHT);
+    };
+  }
+
   /**
    * Convert from int to Int64.
    */
@@ -138,6 +187,43 @@ class PreciseInputManager extends FlxKeyManager<FlxKey, PreciseInputList>
     }
   }
 
+  public function initializeButtons(controls:Controls, gamepad:FlxGamepad):Void
+  {
+    clearButtons();
+
+    var limeGamepad = FlxGamepadUtil.getLimeGamepad(gamepad);
+    var callbacks =
+      {
+        onButtonDown: handleButtonDown.bind(gamepad),
+        onButtonUp: handleButtonUp.bind(gamepad)
+      };
+    limeGamepad.onButtonDownPrecise.add(callbacks.onButtonDown);
+    limeGamepad.onButtonUpPrecise.add(callbacks.onButtonUp);
+
+    for (noteDirection in DIRECTIONS)
+    {
+      var buttons = getButtonsForDirection(controls, noteDirection);
+      for (button in buttons)
+      {
+        var input = new FlxInput<FlxGamepadInputID>(button);
+
+        var buttonListEntry = _buttonList.get(gamepad.id);
+        if (buttonListEntry == null) _buttonList.set(gamepad.id, buttonListEntry = []);
+        buttonListEntry.push(button);
+
+        _buttonListArray.push(input);
+
+        var buttonListMapEntry = _buttonListMap.get(gamepad.id);
+        if (buttonListMapEntry == null) _buttonListMap.set(gamepad.id, buttonListMapEntry = new Map<FlxGamepadInputID, FlxInput<FlxGamepadInputID>>());
+        buttonListMapEntry.set(button, input);
+
+        var buttonListDirEntry = _buttonListDir.get(gamepad.id);
+        if (buttonListDirEntry == null) _buttonListDir.set(gamepad.id, buttonListDirEntry = new Map<FlxGamepadInputID, NoteDirection>());
+        buttonListDirEntry.set(button, noteDirection);
+      }
+    }
+  }
+
   /**
    * Get the time, in nanoseconds, since the given note direction was last pressed.
    * @param noteDirection The note direction to check.
@@ -165,11 +251,41 @@ class PreciseInputManager extends FlxKeyManager<FlxKey, PreciseInputList>
     return _keyListMap.get(key);
   }
 
+  public function getInputByButton(gamepad:FlxGamepad, button:FlxGamepadInputID):FlxInput<FlxGamepadInputID>
+  {
+    return _buttonListMap.get(gamepad.id).get(button);
+  }
+
   public function getDirectionForKey(key:FlxKey):NoteDirection
   {
     return _keyListDir.get(key);
   }
 
+  public function getDirectionForButton(gamepad:FlxGamepad, button:FlxGamepadInputID):NoteDirection
+  {
+    return _buttonListDir.get(gamepad.id).get(button);
+  }
+
+  function getButton(gamepad:FlxGamepad, button:FlxGamepadInputID):FlxInput<FlxGamepadInputID>
+  {
+    return _buttonListMap.get(gamepad.id).get(button);
+  }
+
+  function updateButtonStates(gamepad:FlxGamepad, button:FlxGamepadInputID, down:Bool):Void
+  {
+    var input = getButton(gamepad, button);
+    if (input == null) return;
+
+    if (down)
+    {
+      input.press();
+    }
+    else
+    {
+      input.release();
+    }
+  }
+
   function handleKeyDown(keyCode:KeyCode, _:KeyModifier, timestamp:Int64):Void
   {
     var key:FlxKey = convertKeyCode(keyCode);
@@ -198,7 +314,7 @@ class PreciseInputManager extends FlxKeyManager<FlxKey, PreciseInputList>
     if (_keyList.indexOf(key) == -1) return;
 
     // TODO: Remove this line with SDL3 when timestamps change meaning.
-    // This is because SDL3's timestamps are measured in nanoseconds, not milliseconds.
+    // This is because SDL3's timestamps ar e measured in nanoseconds, not milliseconds.
     timestamp *= Constants.NS_PER_MS;
 
     updateKeyStates(key, false);
@@ -214,6 +330,54 @@ class PreciseInputManager extends FlxKeyManager<FlxKey, PreciseInputList>
     }
   }
 
+  function handleButtonDown(gamepad:FlxGamepad, button:LimeGamepadButton, timestamp:Int64):Void
+  {
+    var buttonId:FlxGamepadInputID = FlxGamepadUtil.getInputID(gamepad, button);
+
+    var buttonListEntry = _buttonList.get(gamepad.id);
+    if (buttonListEntry == null || buttonListEntry.indexOf(buttonId) == -1) return;
+
+    // TODO: Remove this line with SDL3 when timestamps change meaning.
+    // This is because SDL3's timestamps ar e measured in nanoseconds, not milliseconds.
+    timestamp *= Constants.NS_PER_MS;
+
+    updateButtonStates(gamepad, buttonId, true);
+
+    if (getInputByButton(gamepad, buttonId)?.justPressed ?? false)
+    {
+      onInputPressed.dispatch(
+        {
+          noteDirection: getDirectionForButton(gamepad, buttonId),
+          timestamp: timestamp
+        });
+      _dirPressTimestamps.set(getDirectionForButton(gamepad, buttonId), timestamp);
+    }
+  }
+
+  function handleButtonUp(gamepad:FlxGamepad, button:LimeGamepadButton, timestamp:Int64):Void
+  {
+    var buttonId:FlxGamepadInputID = FlxGamepadUtil.getInputID(gamepad, button);
+
+    var buttonListEntry = _buttonList.get(gamepad.id);
+    if (buttonListEntry == null || buttonListEntry.indexOf(buttonId) == -1) return;
+
+    // TODO: Remove this line with SDL3 when timestamps change meaning.
+    // This is because SDL3's timestamps ar e measured in nanoseconds, not milliseconds.
+    timestamp *= Constants.NS_PER_MS;
+
+    updateButtonStates(gamepad, buttonId, false);
+
+    if (getInputByButton(gamepad, buttonId)?.justReleased ?? false)
+    {
+      onInputReleased.dispatch(
+        {
+          noteDirection: getDirectionForButton(gamepad, buttonId),
+          timestamp: timestamp
+        });
+      _dirReleaseTimestamps.set(getDirectionForButton(gamepad, buttonId), timestamp);
+    }
+  }
+
   static function convertKeyCode(input:KeyCode):FlxKey
   {
     @:privateAccess
@@ -228,6 +392,31 @@ class PreciseInputManager extends FlxKeyManager<FlxKey, PreciseInputList>
     _keyListMap.clear();
     _keyListDir.clear();
   }
+
+  function clearButtons():Void
+  {
+    _buttonListArray = [];
+    _buttonListDir.clear();
+
+    for (gamepad in _deviceBinds.keys())
+    {
+      var callbacks = _deviceBinds.get(gamepad);
+      var limeGamepad = FlxGamepadUtil.getLimeGamepad(gamepad);
+      limeGamepad.onButtonDownPrecise.remove(callbacks.onButtonDown);
+      limeGamepad.onButtonUpPrecise.remove(callbacks.onButtonUp);
+    }
+    _deviceBinds.clear();
+  }
+
+  public override function destroy():Void
+  {
+    // Keyboard
+    FlxG.stage.application.window.onKeyDownPrecise.remove(handleKeyDown);
+    FlxG.stage.application.window.onKeyUpPrecise.remove(handleKeyUp);
+
+    clearKeys();
+    clearButtons();
+  }
 }
 
 class PreciseInputList extends FlxKeyList
diff --git a/source/funkin/modding/PolymodHandler.hx b/source/funkin/modding/PolymodHandler.hx
index f7f69428b..7716f0f02 100644
--- a/source/funkin/modding/PolymodHandler.hx
+++ b/source/funkin/modding/PolymodHandler.hx
@@ -14,6 +14,7 @@ import funkin.data.level.LevelRegistry;
 import funkin.data.notestyle.NoteStyleRegistry;
 import funkin.play.cutscene.dialogue.ConversationDataParser;
 import funkin.play.cutscene.dialogue.DialogueBoxDataParser;
+import funkin.save.Save;
 import funkin.play.cutscene.dialogue.SpeakerDataParser;
 import funkin.data.song.SongRegistry;
 
@@ -59,7 +60,7 @@ class PolymodHandler
     createModRoot();
 
     trace("Initializing Polymod (using configured mods)...");
-    loadModsById(getEnabledModIds());
+    loadModsById(Save.get().enabledModIds);
   }
 
   /**
@@ -232,33 +233,9 @@ class PolymodHandler
     return modIds;
   }
 
-  public static function setEnabledMods(newModList:Array<String>):Void
-  {
-    FlxG.save.data.enabledMods = newModList;
-    // Make sure to COMMIT the changes.
-    FlxG.save.flush();
-  }
-
-  /**
-   * Returns the list of enabled mods.
-   * @return Array<String>
-   */
-  public static function getEnabledModIds():Array<String>
-  {
-    if (FlxG.save.data.enabledMods == null)
-    {
-      // NOTE: If the value is null, the enabled mod list is unconfigured.
-      // Currently, we default to disabling newly installed mods.
-      // If we want to auto-enable new mods, but otherwise leave the configured list in place,
-      // we will need some custom logic.
-      FlxG.save.data.enabledMods = [];
-    }
-    return FlxG.save.data.enabledMods;
-  }
-
   public static function getEnabledMods():Array<ModMetadata>
   {
-    var modIds = getEnabledModIds();
+    var modIds = Save.get().enabledModIds;
     var modMetadata = getAllMods();
     var enabledMods = [];
     for (item in modMetadata)
diff --git a/source/funkin/play/GameOverSubState.hx b/source/funkin/play/GameOverSubState.hx
index 15ed0421e..c5d9b4b0b 100644
--- a/source/funkin/play/GameOverSubState.hx
+++ b/source/funkin/play/GameOverSubState.hx
@@ -11,7 +11,6 @@ import funkin.modding.events.ScriptEvent;
 import funkin.modding.events.ScriptEventDispatcher;
 import funkin.play.PlayState;
 import funkin.play.character.BaseCharacter;
-import funkin.ui.PreferencesMenu;
 
 /**
  * A substate which renders over the PlayState when the player dies.
@@ -103,6 +102,9 @@ class GameOverSubState extends MusicBeatSubState
     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;
@@ -292,7 +294,7 @@ class GameOverSubState extends MusicBeatSubState
   {
     var randomCensor:Array<Int> = [];
 
-    if (PreferencesMenu.getPref('censor-naughty')) randomCensor = [1, 3, 8, 13, 17, 21];
+    if (!Preferences.naughtyness) randomCensor = [1, 3, 8, 13, 17, 21];
 
     FlxG.sound.play(Paths.sound('jeffGameover/jeffGameover-' + FlxG.random.int(1, 25, randomCensor)), 1, false, null, true, function() {
       // Once the quote ends, fade in the game over music.
diff --git a/source/funkin/play/PlayState.hx b/source/funkin/play/PlayState.hx
index d7c2a2a4c..048b6ed6e 100644
--- a/source/funkin/play/PlayState.hx
+++ b/source/funkin/play/PlayState.hx
@@ -25,6 +25,7 @@ import flixel.ui.FlxBar;
 import flixel.util.FlxColor;
 import flixel.util.FlxTimer;
 import funkin.audio.VoicesGroup;
+import funkin.save.Save;
 import funkin.Highscore.Tallies;
 import funkin.input.PreciseInputManager;
 import funkin.modding.events.ScriptEvent;
@@ -919,7 +920,6 @@ class PlayState extends MusicBeatSubState
     }
 
     // Handle keybinds.
-    // if (!isInCutscene && !disableKeys) keyShit(true);
     processInputQueue();
     if (!isInCutscene && !disableKeys) debugKeyShit();
     if (isInCutscene && !disableKeys) handleCutsceneKeys(elapsed);
@@ -1267,7 +1267,7 @@ class PlayState extends MusicBeatSubState
    */
   function initHealthBar():Void
   {
-    var healthBarYPos:Float = PreferencesMenu.getPref('downscroll') ? FlxG.height * 0.1 : FlxG.height * 0.9;
+    var healthBarYPos:Float = Preferences.downscroll ? FlxG.height * 0.1 : FlxG.height * 0.9;
     healthBarBG = new FlxSprite(0, healthBarYPos).loadGraphic(Paths.image('healthBar'));
     healthBarBG.screenCenter(X);
     healthBarBG.scrollFactor.set(0, 0);
@@ -1476,13 +1476,13 @@ class PlayState extends MusicBeatSubState
     // Position the player strumline on the right half of the screen
     playerStrumline.x = FlxG.width / 2 + Constants.STRUMLINE_X_OFFSET; // Classic style
     // playerStrumline.x = FlxG.width - playerStrumline.width - Constants.STRUMLINE_X_OFFSET; // Centered style
-    playerStrumline.y = PreferencesMenu.getPref('downscroll') ? FlxG.height - playerStrumline.height - Constants.STRUMLINE_Y_OFFSET : Constants.STRUMLINE_Y_OFFSET;
+    playerStrumline.y = Preferences.downscroll ? FlxG.height - playerStrumline.height - Constants.STRUMLINE_Y_OFFSET : Constants.STRUMLINE_Y_OFFSET;
     playerStrumline.zIndex = 200;
     playerStrumline.cameras = [camHUD];
 
     // Position the opponent strumline on the left half of the screen
     opponentStrumline.x = Constants.STRUMLINE_X_OFFSET;
-    opponentStrumline.y = PreferencesMenu.getPref('downscroll') ? FlxG.height - opponentStrumline.height - Constants.STRUMLINE_Y_OFFSET : Constants.STRUMLINE_Y_OFFSET;
+    opponentStrumline.y = Preferences.downscroll ? FlxG.height - opponentStrumline.height - Constants.STRUMLINE_Y_OFFSET : Constants.STRUMLINE_Y_OFFSET;
     opponentStrumline.zIndex = 100;
     opponentStrumline.cameras = [camHUD];
 
@@ -1641,7 +1641,7 @@ class PlayState extends MusicBeatSubState
    */
   function onConversationComplete():Void
   {
-    isInCutscene = true;
+    isInCutscene = false;
     remove(currentConversation);
     currentConversation = null;
 
@@ -2393,9 +2393,32 @@ class PlayState extends MusicBeatSubState
     if (currentSong != null && currentSong.validScore)
     {
       // crackhead double thingie, sets whether was new highscore, AND saves the song!
-      Highscore.tallies.isNewHighscore = Highscore.saveScoreForDifficulty(currentSong.id, songScore, currentDifficulty);
+      var data =
+        {
+          score: songScore,
+          tallies:
+            {
+              killer: Highscore.tallies.killer,
+              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,
+        };
 
-      Highscore.saveCompletionForDifficulty(currentSong.id, Highscore.tallies.totalNotesHit / Highscore.tallies.totalNotes, currentDifficulty);
+      if (Save.get().isSongHighScore(currentSong.id, currentDifficulty, data))
+      {
+        Save.get().setSongScore(currentSong.id, currentDifficulty, data);
+        #if newgrounds
+        NGio.postScore(score, currentSong.id);
+        #end
+      }
     }
 
     if (PlayStatePlaylist.isStoryMode)
@@ -2419,11 +2442,35 @@ class PlayState extends MusicBeatSubState
         if (currentSong.validScore)
         {
           NGio.unlockMedal(60961);
-          Highscore.saveWeekScoreForDifficulty(PlayStatePlaylist.campaignId, PlayStatePlaylist.campaignScore, currentDifficulty);
-        }
 
-        // FlxG.save.data.weekUnlocked = StoryMenuState.weekUnlocked;
-        FlxG.save.flush();
+          var data =
+            {
+              score: PlayStatePlaylist.campaignScore,
+              tallies:
+                {
+                  // TODO: Sum up the values for the whole level!
+                  killer: 0,
+                  sick: 0,
+                  good: 0,
+                  bad: 0,
+                  shit: 0,
+                  missed: 0,
+                  combo: 0,
+                  maxCombo: 0,
+                  totalNotesHit: 0,
+                  totalNotes: 0,
+                },
+              accuracy: Highscore.tallies.totalNotesHit / Highscore.tallies.totalNotes,
+            };
+
+          if (Save.get().isLevelHighScore(PlayStatePlaylist.campaignId, PlayStatePlaylist.campaignDifficulty, data))
+          {
+            Save.get().setLevelScore(PlayStatePlaylist.campaignId, PlayStatePlaylist.campaignDifficulty, data);
+            #if newgrounds
+            NGio.postScore(score, 'Level ${PlayStatePlaylist.campaignId}');
+            #end
+          }
+        }
 
         if (isSubState)
         {
@@ -2466,7 +2513,7 @@ class PlayState extends MusicBeatSubState
             var nextPlayState:PlayState = new PlayState(
               {
                 targetSong: targetSong,
-                targetDifficulty: currentDifficulty,
+                targetDifficulty: PlayStatePlaylist.campaignDifficulty,
                 targetCharacter: currentPlayerId,
               });
             nextPlayState.previousCameraFollowPoint = new FlxSprite(cameraFollowPoint.x, cameraFollowPoint.y);
@@ -2482,7 +2529,7 @@ class PlayState extends MusicBeatSubState
           var nextPlayState:PlayState = new PlayState(
             {
               targetSong: targetSong,
-              targetDifficulty: currentDifficulty,
+              targetDifficulty: PlayStatePlaylist.campaignDifficulty,
               targetCharacter: currentPlayerId,
             });
           nextPlayState.previousCameraFollowPoint = new FlxSprite(cameraFollowPoint.x, cameraFollowPoint.y);
@@ -2608,7 +2655,12 @@ class PlayState extends MusicBeatSubState
             persistentUpdate = false;
             vocals.stop();
             camHUD.alpha = 1;
-            var res:ResultState = new ResultState();
+            var res:ResultState = new ResultState(
+              {
+                storyMode: PlayStatePlaylist.isStoryMode,
+                title: PlayStatePlaylist.isStoryMode ? ('${PlayStatePlaylist.campaignTitle}') : ('${currentChart.songName} by ${currentChart.songArtist}'),
+                tallies: Highscore.tallies,
+              });
             res.camera = camHUD;
             openSubState(res);
           }
diff --git a/source/funkin/play/PlayStatePlaylist.hx b/source/funkin/play/PlayStatePlaylist.hx
index 6b754878c..3b0fb01f6 100644
--- a/source/funkin/play/PlayStatePlaylist.hx
+++ b/source/funkin/play/PlayStatePlaylist.hx
@@ -34,10 +34,7 @@ class PlayStatePlaylist
    */
   public static var campaignId:String = 'unknown';
 
-  /**
-   * The current difficulty selected for this level (as a named ID).
-   */
-  public static var currentDifficulty(default, default):String = Constants.DEFAULT_DIFFICULTY;
+  public static var campaignDifficulty:String = Constants.DEFAULT_DIFFICULTY;
 
   /**
    * Resets the playlist to its default state.
@@ -49,6 +46,6 @@ class PlayStatePlaylist
     campaignScore = 0;
     campaignTitle = 'UNKNOWN';
     campaignId = 'unknown';
-    currentDifficulty = Constants.DEFAULT_DIFFICULTY;
+    campaignDifficulty = Constants.DEFAULT_DIFFICULTY;
   }
 }
diff --git a/source/funkin/play/ResultState.hx b/source/funkin/play/ResultState.hx
index 0c2984719..3f7231c2a 100644
--- a/source/funkin/play/ResultState.hx
+++ b/source/funkin/play/ResultState.hx
@@ -22,6 +22,8 @@ import flxanimate.FlxAnimate.Settings;
 
 class ResultState extends MusicBeatSubState
 {
+  final params:ResultsStateParams;
+
   var resultsVariation:ResultVariations;
   var songName:FlxBitmapText;
   var difficulty:FlxSprite;
@@ -29,13 +31,18 @@ class ResultState extends MusicBeatSubState
   var maskShaderSongName = new LeftMaskShader();
   var maskShaderDifficulty = new LeftMaskShader();
 
+  public function new(params:ResultsStateParams)
+  {
+    super();
+
+    this.params = params;
+  }
+
   override function create():Void
   {
-    if (Highscore.tallies.sick == Highscore.tallies.totalNotesHit
-      && Highscore.tallies.maxCombo == Highscore.tallies.totalNotesHit) resultsVariation = PERFECT;
-    else if (Highscore.tallies.missed
-      + Highscore.tallies.bad
-      + Highscore.tallies.shit >= Highscore.tallies.totalNotes * 0.50)
+    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;
@@ -135,17 +142,7 @@ class ResultState extends MusicBeatSubState
 
     var fontLetters:String = "AaBbCcDdEeFfGgHhiIJjKkLlMmNnOoPpQqRrSsTtUuVvWwXxYyZz:1234567890";
     songName = new FlxBitmapText(FlxBitmapFont.fromMonospace(Paths.image("resultScreen/tardlingSpritesheet"), fontLetters, FlxPoint.get(49, 62)));
-
-    // stole this from PauseSubState, I think eric wrote it!!
-    if (PlayState.instance.currentChart != null)
-    {
-      songName.text += '${PlayState.instance.currentChart.songName}:${PlayState.instance.currentChart.songArtist}';
-    }
-    else
-    {
-      songName.text += PlayState.instance.currentSong.id;
-    }
-
+    songName.text = params.title;
     songName.letterSpacing = -15;
     songName.angle = -4.1;
     add(songName);
@@ -194,27 +191,27 @@ class ResultState extends MusicBeatSubState
     var ratingGrp:FlxTypedGroup<TallyCounter> = new FlxTypedGroup<TallyCounter>();
     add(ratingGrp);
 
-    var totalHit:TallyCounter = new TallyCounter(375, hStuf * 3, Highscore.tallies.totalNotesHit);
+    var totalHit:TallyCounter = new TallyCounter(375, hStuf * 3, params.tallies.totalNotesHit);
     ratingGrp.add(totalHit);
 
-    var maxCombo:TallyCounter = new TallyCounter(375, hStuf * 4, Highscore.tallies.maxCombo);
+    var maxCombo:TallyCounter = new TallyCounter(375, hStuf * 4, params.tallies.maxCombo);
     ratingGrp.add(maxCombo);
 
     hStuf += 2;
     var extraYOffset:Float = 5;
-    var tallySick:TallyCounter = new TallyCounter(230, (hStuf * 5) + extraYOffset, Highscore.tallies.sick, 0xFF89E59E);
+    var tallySick:TallyCounter = new TallyCounter(230, (hStuf * 5) + extraYOffset, params.tallies.sick, 0xFF89E59E);
     ratingGrp.add(tallySick);
 
-    var tallyGood:TallyCounter = new TallyCounter(210, (hStuf * 6) + extraYOffset, Highscore.tallies.good, 0xFF89C9E5);
+    var tallyGood:TallyCounter = new TallyCounter(210, (hStuf * 6) + extraYOffset, params.tallies.good, 0xFF89C9E5);
     ratingGrp.add(tallyGood);
 
-    var tallyBad:TallyCounter = new TallyCounter(190, (hStuf * 7) + extraYOffset, Highscore.tallies.bad, 0xffE6CF8A);
+    var tallyBad:TallyCounter = new TallyCounter(190, (hStuf * 7) + extraYOffset, params.tallies.bad, 0xffE6CF8A);
     ratingGrp.add(tallyBad);
 
-    var tallyShit:TallyCounter = new TallyCounter(220, (hStuf * 8) + extraYOffset, Highscore.tallies.shit, 0xFFE68C8A);
+    var tallyShit:TallyCounter = new TallyCounter(220, (hStuf * 8) + extraYOffset, params.tallies.shit, 0xFFE68C8A);
     ratingGrp.add(tallyShit);
 
-    var tallyMissed:TallyCounter = new TallyCounter(260, (hStuf * 9) + extraYOffset, Highscore.tallies.missed, 0xFFC68AE6);
+    var tallyMissed:TallyCounter = new TallyCounter(260, (hStuf * 9) + extraYOffset, params.tallies.missed, 0xFFC68AE6);
     ratingGrp.add(tallyMissed);
 
     for (ind => rating in ratingGrp.members)
@@ -275,7 +272,7 @@ class ResultState extends MusicBeatSubState
       }
     });
 
-    if (Highscore.tallies.isNewHighscore) trace("ITS A NEW HIGHSCORE!!!");
+    if (params.tallies.isNewHighscore) trace("ITS A NEW HIGHSCORE!!!");
 
     super.create();
   }
@@ -351,7 +348,7 @@ class ResultState extends MusicBeatSubState
 
     if (controls.PAUSE)
     {
-      if (PlayStatePlaylist.isStoryMode)
+      if (params.storyMode)
       {
         FlxG.switchState(new StoryMenuState());
       }
@@ -372,3 +369,21 @@ enum abstract ResultVariations(String)
   var NORMAL;
   var SHIT;
 }
+
+typedef ResultsStateParams =
+{
+  /**
+   * True if results are for a level, false if results are for a single song.
+   */
+  var storyMode:Bool;
+
+  /**
+   * Either "Song Name by Artist Name" or "Week Name"
+   */
+  var title:String;
+
+  /**
+   * The score, accuracy, and judgements.
+   */
+  var tallies:Highscore.Tallies;
+};
diff --git a/source/funkin/play/character/BaseCharacter.hx b/source/funkin/play/character/BaseCharacter.hx
index 30b549fd3..5346ced61 100644
--- a/source/funkin/play/character/BaseCharacter.hx
+++ b/source/funkin/play/character/BaseCharacter.hx
@@ -188,6 +188,11 @@ class BaseCharacter extends Bopper
     shouldBop = false;
   }
 
+  public function getDeathCameraOffsets():Array<Float>
+  {
+    return _data.death?.cameraOffsets ?? [0.0, 0.0];
+  }
+
   /**
    * Gets the value of flipX from the character data.
    * `!getFlipX()` is the direction Boyfriend should face.
@@ -580,8 +585,7 @@ class BaseCharacter extends Bopper
 
   public override function playAnimation(name:String, restart:Bool = false, ignoreOther:Bool = false, reversed:Bool = false):Void
   {
-    FlxG.watch.addQuick('playAnim(${characterName})', name);
-    // trace('playAnim(${characterName}): ${name}');
+    // FlxG.watch.addQuick('playAnim(${characterName})', name);
     super.playAnimation(name, restart, ignoreOther, reversed);
   }
 }
diff --git a/source/funkin/play/character/CharacterData.hx b/source/funkin/play/character/CharacterData.hx
index f1b316b7f..8be9f25c7 100644
--- a/source/funkin/play/character/CharacterData.hx
+++ b/source/funkin/play/character/CharacterData.hx
@@ -19,8 +19,10 @@ class CharacterDataParser
    * The current version string for the stage data format.
    * Handle breaking changes by incrementing this value
    * and adding migration to the `migrateStageData()` function.
+   *
+   * - Version 1.0.1 adds `death.cameraOffsets`
    */
-  public static final CHARACTER_DATA_VERSION:String = '1.0.0';
+  public static final CHARACTER_DATA_VERSION:String = '1.0.1';
 
   /**
    * The current version rule check for the stage data format.
@@ -603,6 +605,8 @@ typedef CharacterData =
    */
   var healthIcon:Null<HealthIconData>;
 
+  var death:Null<DeathData>;
+
   /**
    * The global offset to the character's position, in pixels.
    * @default [0, 0]
@@ -695,3 +699,13 @@ typedef HealthIconData =
    */
   var offsets:Null<Array<Float>>;
 }
+
+typedef DeathData =
+{
+  /**
+   * The amount to offset the camera by while focusing on this character as they die.
+   * Default value focuses on the character's graphic midpoint.
+   * @default [0, 0]
+   */
+  var ?cameraOffsets:Array<Float>;
+}
diff --git a/source/funkin/play/notes/Strumline.hx b/source/funkin/play/notes/Strumline.hx
index 7bd6e7ae7..60b995c06 100644
--- a/source/funkin/play/notes/Strumline.hx
+++ b/source/funkin/play/notes/Strumline.hx
@@ -231,7 +231,7 @@ class Strumline extends FlxSpriteGroup
       notesVwoosh.add(note);
 
       var targetY:Float = FlxG.height + note.y;
-      if (PreferencesMenu.getPref('downscroll')) targetY = 0 - note.height;
+      if (Preferences.downscroll) targetY = 0 - note.height;
       FlxTween.tween(note, {y: targetY}, 0.5,
         {
           ease: FlxEase.expoIn,
@@ -252,7 +252,7 @@ class Strumline extends FlxSpriteGroup
       holdNotesVwoosh.add(holdNote);
 
       var targetY:Float = FlxG.height + holdNote.y;
-      if (PreferencesMenu.getPref('downscroll')) targetY = 0 - holdNote.height;
+      if (Preferences.downscroll) targetY = 0 - holdNote.height;
       FlxTween.tween(holdNote, {y: targetY}, 0.5,
         {
           ease: FlxEase.expoIn,
@@ -277,7 +277,7 @@ class Strumline extends FlxSpriteGroup
     var vwoosh:Float = (strumTime < Conductor.songPosition) && vwoosh ? 2.0 : 1.0;
     var scrollSpeed:Float = PlayState.instance?.currentChart?.scrollSpeed ?? 1.0;
 
-    return Constants.PIXELS_PER_MS * (Conductor.songPosition - strumTime) * scrollSpeed * vwoosh * (PreferencesMenu.getPref('downscroll') ? 1 : -1);
+    return Constants.PIXELS_PER_MS * (Conductor.songPosition - strumTime) * scrollSpeed * vwoosh * (Preferences.downscroll ? 1 : -1);
   }
 
   function updateNotes():Void
@@ -321,7 +321,7 @@ class Strumline extends FlxSpriteGroup
       note.y = this.y - INITIAL_OFFSET + calculateNoteYPos(note.strumTime, vwoosh);
 
       // If the note is miss
-      var isOffscreen = PreferencesMenu.getPref('downscroll') ? note.y > FlxG.height : note.y < -note.height;
+      var isOffscreen = Preferences.downscroll ? note.y > FlxG.height : note.y < -note.height;
       if (note.handledMiss && isOffscreen)
       {
         killNote(note);
@@ -388,7 +388,7 @@ class Strumline extends FlxSpriteGroup
 
         var vwoosh:Bool = false;
 
-        if (PreferencesMenu.getPref('downscroll'))
+        if (Preferences.downscroll)
         {
           holdNote.y = this.y + calculateNoteYPos(holdNote.strumTime, vwoosh) - holdNote.height + STRUMLINE_SIZE / 2;
         }
@@ -410,7 +410,7 @@ class Strumline extends FlxSpriteGroup
           holdNote.visible = false;
         }
 
-        if (PreferencesMenu.getPref('downscroll'))
+        if (Preferences.downscroll)
         {
           holdNote.y = this.y - holdNote.height + STRUMLINE_SIZE / 2;
         }
@@ -425,7 +425,7 @@ class Strumline extends FlxSpriteGroup
         holdNote.visible = true;
         var vwoosh:Bool = false;
 
-        if (PreferencesMenu.getPref('downscroll'))
+        if (Preferences.downscroll)
         {
           holdNote.y = this.y + calculateNoteYPos(holdNote.strumTime, vwoosh) - holdNote.height + STRUMLINE_SIZE / 2;
         }
diff --git a/source/funkin/play/notes/SustainTrail.hx b/source/funkin/play/notes/SustainTrail.hx
index 37bc674a5..f55799828 100644
--- a/source/funkin/play/notes/SustainTrail.hx
+++ b/source/funkin/play/notes/SustainTrail.hx
@@ -114,7 +114,7 @@ class SustainTrail extends FlxSprite
     height = sustainHeight(sustainLength, getScrollSpeed());
     // instead of scrollSpeed, PlayState.SONG.speed
 
-    flipY = PreferencesMenu.getPref('downscroll');
+    flipY = Preferences.downscroll;
 
     // alpha = 0.6;
     alpha = 1.0;
diff --git a/source/funkin/play/song/Song.hx b/source/funkin/play/song/Song.hx
index d11c7744b..f996d75ef 100644
--- a/source/funkin/play/song/Song.hx
+++ b/source/funkin/play/song/Song.hx
@@ -1,5 +1,6 @@
 package funkin.play.song;
 
+import funkin.util.SortUtil;
 import flixel.sound.FlxSound;
 import openfl.utils.Assets;
 import funkin.modding.events.ScriptEvent;
@@ -56,8 +57,6 @@ class Song implements IPlayStateScriptedClass implements IRegistryEntry<SongMeta
    */
   public var validScore:Bool = true;
 
-  var difficultyIds:Array<String>;
-
   public var songName(get, never):String;
 
   function get_songName():String
@@ -85,7 +84,6 @@ class Song implements IPlayStateScriptedClass implements IRegistryEntry<SongMeta
     this.id = id;
 
     variations = [];
-    difficultyIds = [];
     difficulties = new Map<String, SongDifficulty>();
 
     _data = _fetchData(id);
@@ -127,8 +125,7 @@ class Song implements IPlayStateScriptedClass implements IRegistryEntry<SongMeta
     for (vari in variations)
       result.variations.push(vari);
 
-    result.difficultyIds.clear();
-
+    result.difficulties.clear();
     result.populateDifficulties();
 
     for (variation => chartData in charts)
@@ -162,8 +159,6 @@ class Song implements IPlayStateScriptedClass implements IRegistryEntry<SongMeta
       // but all the difficulties in the metadata must be in the chart file.
       for (diffId in metadata.playData.difficulties)
       {
-        difficultyIds.push(diffId);
-
         var difficulty:SongDifficulty = new SongDifficulty(this, diffId, metadata.variation);
 
         variations.push(metadata.variation);
@@ -237,19 +232,37 @@ class Song implements IPlayStateScriptedClass implements IRegistryEntry<SongMeta
    */
   public inline function getDifficulty(?diffId:String):Null<SongDifficulty>
   {
-    if (diffId == null) diffId = difficulties.keys().array()[0];
+    if (diffId == null) diffId = listDifficulties()[0];
 
     return difficulties.get(diffId);
   }
 
-  public function listDifficulties():Array<String>
+  /**
+   * List all the difficulties in this song.
+   * @param variationId Optionally filter by variation.
+   * @return The list of difficulties.
+   */
+  public function listDifficulties(?variationId:String):Array<String>
   {
-    return difficultyIds;
+    if (variationId == '') variationId = null;
+
+    var diffFiltered:Array<String> = difficulties.keys().array().filter(function(diffId:String):Bool {
+      if (variationId == null) return true;
+      var difficulty:Null<SongDifficulty> = difficulties.get(diffId);
+      if (difficulty == null) return false;
+      return difficulty.variation == variationId;
+    });
+
+    diffFiltered.sort(SortUtil.defaultsThenAlphabetically.bind(Constants.DEFAULT_DIFFICULTY_LIST));
+
+    return diffFiltered;
   }
 
-  public function hasDifficulty(diffId:String):Bool
+  public function hasDifficulty(diffId:String, ?variationId:String):Bool
   {
-    return difficulties.exists(diffId);
+    if (variationId == '') variationId = null;
+    var difficulty:Null<SongDifficulty> = difficulties.get(diffId);
+    return variationId == null ? (difficulty != null) : (difficulty != null && difficulty.variation == variationId);
   }
 
   /**
diff --git a/source/funkin/save/Save.hx b/source/funkin/save/Save.hx
new file mode 100644
index 000000000..2666d2bff
--- /dev/null
+++ b/source/funkin/save/Save.hx
@@ -0,0 +1,700 @@
+package funkin.save;
+
+import flixel.util.FlxSave;
+import funkin.save.migrator.SaveDataMigrator;
+import thx.semver.Version;
+import funkin.Controls.Device;
+import funkin.save.migrator.RawSaveData_v1_0_0;
+
+@:nullSafety
+@:forward(volume, mute)
+abstract Save(RawSaveData)
+{
+  public static final SAVE_DATA_VERSION:thx.semver.Version = "2.0.0";
+  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.
+  static final SAVE_PATH:String = 'FunkinCrew';
+  static final SAVE_NAME:String = 'Funkin';
+
+  static final SAVE_PATH_LEGACY:String = 'ninjamuffin99';
+  static final SAVE_NAME_LEGACY:String = 'funkin';
+
+  public static function load():Void
+  {
+    trace("[SAVE] Loading save...");
+
+    // Bind save data.
+    loadFromSlot(1);
+  }
+
+  public static function get():Save
+  {
+    return FlxG.save.data;
+  }
+
+  /**
+   * Constructing a new Save will load the default values.
+   */
+  public function new()
+  {
+    this =
+      {
+        version: Save.SAVE_DATA_VERSION,
+
+        volume: 1.0,
+        mute: false,
+
+        api:
+          {
+            newgrounds:
+              {
+                sessionId: null,
+              }
+          },
+        scores:
+          {
+            // No saved scores.
+            levels: [],
+            songs: [],
+          },
+        options:
+          {
+            // Reasonable defaults.
+            naughtyness: true,
+            downscroll: false,
+            flashingLights: true,
+            zoomCamera: true,
+            debugDisplay: false,
+            autoPause: true,
+
+            controls:
+              {
+                // Leave controls blank so defaults are loaded.
+                p1:
+                  {
+                    keyboard: {},
+                    gamepad: {},
+                  },
+                p2:
+                  {
+                    keyboard: {},
+                    gamepad: {},
+                  },
+              },
+          },
+
+        mods:
+          {
+            // No mods enabled.
+            enabledMods: [],
+            modOptions: [],
+          },
+
+        optionsChartEditor:
+          {
+            // Reasonable defaults.
+          },
+      };
+  }
+
+  public var options(get, never):SaveDataOptions;
+
+  function get_options():SaveDataOptions
+  {
+    return this.options;
+  }
+
+  public var modOptions(get, never):Map<String, Dynamic>;
+
+  function get_modOptions():Map<String, Dynamic>
+  {
+    return this.mods.modOptions;
+  }
+
+  /**
+   * The current session ID for the logged-in Newgrounds user, or null if the user is cringe.
+   */
+  public var ngSessionId(get, set):Null<String>;
+
+  function get_ngSessionId():Null<String>
+  {
+    return this.api.newgrounds.sessionId;
+  }
+
+  function set_ngSessionId(value:Null<String>):Null<String>
+  {
+    return this.api.newgrounds.sessionId = value;
+  }
+
+  public var enabledModIds(get, set):Array<String>;
+
+  function get_enabledModIds():Array<String>
+  {
+    return this.mods.enabledMods;
+  }
+
+  function set_enabledModIds(value:Array<String>):Array<String>
+  {
+    return this.mods.enabledMods = value;
+  }
+
+  /**
+   * Return the score the user achieved for a given level on a given difficulty.
+   *
+   * @param levelId The ID of the level/week.
+   * @param difficultyId The difficulty to check.
+   * @return A data structure containing score, judgement counts, and accuracy. Returns `null` if no score is saved.
+   */
+  public function getLevelScore(levelId:String, difficultyId:String = 'normal'):Null<SaveScoreData>
+  {
+    var level = this.scores.levels.get(levelId);
+    if (level == null)
+    {
+      level = [];
+      this.scores.levels.set(levelId, level);
+    }
+
+    return level.get(difficultyId);
+  }
+
+  /**
+   * Apply the score the user achieved for a given level on a given difficulty.
+   */
+  public function setLevelScore(levelId:String, difficultyId:String, score:SaveScoreData):Void
+  {
+    var level = this.scores.levels.get(levelId);
+    if (level == null)
+    {
+      level = [];
+      this.scores.levels.set(levelId, level);
+    }
+    level.set(difficultyId, score);
+
+    flush();
+  }
+
+  public function isLevelHighScore(levelId:String, difficultyId:String = 'normal', score:SaveScoreData):Bool
+  {
+    var level = this.scores.levels.get(levelId);
+    if (level == null)
+    {
+      level = [];
+      this.scores.levels.set(levelId, level);
+    }
+
+    var currentScore = level.get(difficultyId);
+    if (currentScore == null)
+    {
+      return true;
+    }
+
+    return score.score > currentScore.score;
+  }
+
+  public function hasBeatenLevel(levelId:String, ?difficultyList:Array<String>):Bool
+  {
+    if (difficultyList == null)
+    {
+      difficultyList = ['easy', 'normal', 'hard'];
+    }
+    for (difficulty in difficultyList)
+    {
+      var score:Null<SaveScoreData> = getLevelScore(levelId, difficulty);
+      // TODO: Do we need to check accuracy/score here?
+      if (score != null)
+      {
+        return true;
+      }
+    }
+    return false;
+  }
+
+  /**
+   * Return the score the user achieved for a given song on a given difficulty.
+   *
+   * @param songId The ID of the song.
+   * @param difficultyId The difficulty to check.
+   * @return A data structure containing score, judgement counts, and accuracy. Returns `null` if no score is saved.
+   */
+  public function getSongScore(songId:String, difficultyId:String = 'normal'):Null<SaveScoreData>
+  {
+    var song = this.scores.songs.get(songId);
+    if (song == null)
+    {
+      song = [];
+      this.scores.songs.set(songId, song);
+    }
+    return song.get(difficultyId);
+  }
+
+  /**
+   * Apply the score the user achieved for a given song on a given difficulty.
+   */
+  public function setSongScore(songId:String, difficultyId:String, score:SaveScoreData):Void
+  {
+    var song = this.scores.songs.get(songId);
+    if (song == null)
+    {
+      song = [];
+      this.scores.songs.set(songId, song);
+    }
+    song.set(difficultyId, score);
+
+    flush();
+  }
+
+  /**
+   * Is the provided score data better than the current high score for the given song?
+   * @param songId The song ID to check.
+   * @param difficultyId The difficulty to check.
+   * @param score The score to check.
+   * @return Whether the score is better than the current high score.
+   */
+  public function isSongHighScore(songId:String, difficultyId:String = 'normal', score:SaveScoreData):Bool
+  {
+    var song = this.scores.songs.get(songId);
+    if (song == null)
+    {
+      song = [];
+      this.scores.songs.set(songId, song);
+    }
+
+    var currentScore = song.get(difficultyId);
+    if (currentScore == null)
+    {
+      return true;
+    }
+
+    return score.score > currentScore.score;
+  }
+
+  /**
+   * Has the provided song been beaten on one of the listed difficulties?
+   * @param songId The song ID to check.
+   * @param difficultyList The difficulties to check. Defaults to `easy`, `normal`, and `hard`.
+   * @return Whether the song has been beaten on any of the listed difficulties.
+   */
+  public function hasBeatenSong(songId:String, ?difficultyList:Array<String>):Bool
+  {
+    if (difficultyList == null)
+    {
+      difficultyList = ['easy', 'normal', 'hard'];
+    }
+    for (difficulty in difficultyList)
+    {
+      var score:Null<SaveScoreData> = getSongScore(songId, difficulty);
+      // TODO: Do we need to check accuracy/score here?
+      if (score != null)
+      {
+        return true;
+      }
+    }
+    return false;
+  }
+
+  public function getControls(playerId:Int, inputType:Device):SaveControlsData
+  {
+    switch (inputType)
+    {
+      case Keys:
+        return (playerId == 0) ? this.options.controls.p1.keyboard : this.options.controls.p2.keyboard;
+      case Gamepad(_):
+        return (playerId == 0) ? this.options.controls.p1.gamepad : this.options.controls.p2.gamepad;
+    }
+  }
+
+  public function hasControls(playerId:Int, inputType:Device):Bool
+  {
+    var controls = getControls(playerId, inputType);
+    var controlsFields = Reflect.fields(controls);
+    return controlsFields.length > 0;
+  }
+
+  public function setControls(playerId:Int, inputType:Device, controls:SaveControlsData):Void
+  {
+    switch (inputType)
+    {
+      case Keys:
+        if (playerId == 0)
+        {
+          this.options.controls.p1.keyboard = controls;
+        }
+        else
+        {
+          this.options.controls.p2.keyboard = controls;
+        }
+      case Gamepad(_):
+        if (playerId == 0)
+        {
+          this.options.controls.p1.gamepad = controls;
+        }
+        else
+        {
+          this.options.controls.p2.gamepad = controls;
+        }
+    }
+
+    flush();
+  }
+
+  public function isCharacterUnlocked(characterId:String):Bool
+  {
+    switch (characterId)
+    {
+      case 'bf':
+        return true;
+      case 'pico':
+        return hasBeatenLevel('weekend1');
+      default:
+        trace('Unknown character ID: ' + characterId);
+        return true;
+    }
+  }
+
+  /**
+   * Call this to make sure the save data is written to disk.
+   */
+  public function flush():Void
+  {
+    FlxG.save.flush();
+  }
+
+  /**
+   * If you set slot to `2`, it will load an independe
+   * @param slot
+   */
+  static function loadFromSlot(slot:Int):Void
+  {
+    trace("[SAVE] Loading save from slot " + slot + "...");
+
+    FlxG.save.bind('$SAVE_NAME${slot}', SAVE_PATH);
+
+    if (FlxG.save.isEmpty())
+    {
+      trace('[SAVE] Save data is empty, checking for legacy save data...');
+      var legacySaveData = fetchLegacySaveData();
+      if (legacySaveData != null)
+      {
+        trace('[SAVE] Found legacy save data, converting...');
+        FlxG.save.mergeData(SaveDataMigrator.migrateFromLegacy(legacySaveData));
+      }
+    }
+    else
+    {
+      trace('[SAVE] Loaded save data.');
+      FlxG.save.mergeData(SaveDataMigrator.migrate(FlxG.save.data));
+    }
+
+    trace('[SAVE] Done loading save data.');
+    trace(FlxG.save.data);
+  }
+
+  static function fetchLegacySaveData():Null<RawSaveData_v1_0_0>
+  {
+    trace("[SAVE] Checking for legacy save data...");
+    var legacySave:FlxSave = new FlxSave();
+    legacySave.bind(SAVE_NAME_LEGACY, SAVE_PATH_LEGACY);
+    if (legacySave?.data == null)
+    {
+      trace("[SAVE] No legacy save data found.");
+      return null;
+    }
+    else
+    {
+      trace("[SAVE] Legacy save data found.");
+      trace(legacySave.data);
+      return cast legacySave.data;
+    }
+  }
+}
+
+/**
+ * An anonymous structure containingg all the user's save data.
+ */
+typedef RawSaveData =
+{
+  // Flixel save data.
+  var volume:Float;
+  var mute:Bool;
+
+  /**
+   * A semantic versioning string for the save data format.
+   */
+  var version:Version;
+
+  var api:SaveApiData;
+
+  /**
+   * The user's saved scores.
+   */
+  var scores:SaveHighScoresData;
+
+  /**
+   * The user's preferences.
+   */
+  var options:SaveDataOptions;
+
+  var mods:SaveDataMods;
+
+  /**
+   * The user's preferences specific to the Chart Editor.
+   */
+  var optionsChartEditor:SaveDataChartEditorOptions;
+};
+
+typedef SaveApiData =
+{
+  var newgrounds:SaveApiNewgroundsData;
+}
+
+typedef SaveApiNewgroundsData =
+{
+  var sessionId:Null<String>;
+}
+
+/**
+ * An anoymous structure containing options about the user's high scores.
+ */
+typedef SaveHighScoresData =
+{
+  /**
+   * Scores for each level (or week).
+   */
+  var levels:SaveScoreLevelsData;
+
+  /**
+   * Scores for individual songs.
+   */
+  var songs:SaveScoreSongsData;
+};
+
+typedef SaveDataMods =
+{
+  var enabledMods:Array<String>;
+  var modOptions:Map<String, Dynamic>;
+}
+
+/**
+ * Key is the level ID, value is the SaveScoreLevelData.
+ */
+typedef SaveScoreLevelsData = Map<String, SaveScoreDifficultiesData>;
+
+/**
+ * Key is the song ID, value is the data for each difficulty.
+ */
+typedef SaveScoreSongsData = Map<String, SaveScoreDifficultiesData>;
+
+/**
+ * Key is the difficulty ID, value is the score.
+ */
+typedef SaveScoreDifficultiesData = Map<String, SaveScoreData>;
+
+/**
+ * An individual score. Contains the score, accuracy, and count of each judgement hit.
+ */
+typedef SaveScoreData =
+{
+  /**
+   * The score achieved.
+   */
+  var score:Int;
+
+  /**
+   * The count of each judgement hit.
+   */
+  var tallies:SaveScoreTallyData;
+
+  /**
+   * The accuracy percentage.
+   */
+  var accuracy:Float;
+}
+
+typedef SaveScoreTallyData =
+{
+  var killer:Int;
+  var sick:Int;
+  var good:Int;
+  var bad:Int;
+  var shit:Int;
+  var missed:Int;
+  var combo:Int;
+  var maxCombo:Int;
+  var totalNotesHit:Int;
+  var totalNotes:Int;
+}
+
+/**
+ * An anonymous structure containing all the user's options and preferences for the main game.
+ * Every time you add a new option, it needs to be added here.
+ */
+typedef SaveDataOptions =
+{
+  /**
+   * Whether some particularly fowl language is displayed.
+   * @default `true`
+   */
+  var naughtyness:Bool;
+
+  /**
+   * If enabled, the strumline is at the bottom of the screen rather than the top.
+   * @default `false`
+   */
+  var downscroll:Bool;
+
+  /**
+   * If disabled, flashing lights in the main menu and other areas will be less intense.
+   * @default `true`
+   */
+  var flashingLights:Bool;
+
+  /**
+   * If disabled, the camera bump synchronized to the beat.
+   * @default `false`
+   */
+  var zoomCamera:Bool;
+
+  /**
+   * If enabled, an FPS and memory counter will be displayed even if this is not a debug build.
+   * @default `false`
+   */
+  var debugDisplay:Bool;
+
+  /**
+   * If enabled, the game will automatically pause when tabbing out.
+   * @default `true`
+   */
+  var autoPause:Bool;
+
+  var controls:
+    {
+      var p1:
+        {
+          var keyboard:SaveControlsData;
+          var gamepad:SaveControlsData;
+        };
+      var p2:
+        {
+          var keyboard:SaveControlsData;
+          var gamepad:SaveControlsData;
+        };
+    };
+};
+
+/**
+ * An anonymous structure containing a specific player's bound keys.
+ * Each key is an action name and each value is an array of keycodes.
+ *
+ * If a keybind is `null`, it needs to be reinitialized to the default.
+ * If a keybind is `[]`, it is UNBOUND by the user and should not be rebound.
+ */
+typedef SaveControlsData =
+{
+  /**
+   * Keybind for navigating in the menu.
+   * @default `Up Arrow`
+   */
+  var ?UI_UP:Array<Int>;
+
+  /**
+   * Keybind for navigating in the menu.
+   * @default `Left Arrow`
+   */
+  var ?UI_LEFT:Array<Int>;
+
+  /**
+   * Keybind for navigating in the menu.
+   * @default `Right Arrow`
+   */
+  var ?UI_RIGHT:Array<Int>;
+
+  /**
+   * Keybind for navigating in the menu.
+   * @default `Down Arrow`
+   */
+  var ?UI_DOWN:Array<Int>;
+
+  /**
+   * Keybind for hitting notes.
+   * @default `A` and `Left Arrow`
+   */
+  var ?NOTE_LEFT:Array<Int>;
+
+  /**
+   * Keybind for hitting notes.
+   * @default `W` and `Up Arrow`
+   */
+  var ?NOTE_UP:Array<Int>;
+
+  /**
+   * Keybind for hitting notes.
+   * @default `S` and `Down Arrow`
+   */
+  var ?NOTE_DOWN:Array<Int>;
+
+  /**
+   * Keybind for hitting notes.
+   * @default `D` and `Right Arrow`
+   */
+  var ?NOTE_RIGHT:Array<Int>;
+
+  /**
+   * Keybind for continue/OK in menus.
+   * @default `Enter` and `Space`
+   */
+  var ?ACCEPT:Array<Int>;
+
+  /**
+   * Keybind for back/cancel in menus.
+   * @default `Escape`
+   */
+  var ?BACK:Array<Int>;
+
+  /**
+   * Keybind for pausing the game.
+   * @default `Escape`
+   */
+  var ?PAUSE:Array<Int>;
+
+  /**
+   * Keybind for advancing cutscenes.
+   * @default `Z` and `Space` and `Enter`
+   */
+  var ?CUTSCENE_ADVANCE:Array<Int>;
+
+  /**
+   * Keybind for skipping a cutscene.
+   * @default `Escape`
+   */
+  var ?CUTSCENE_SKIP:Array<Int>;
+
+  /**
+   * Keybind for increasing volume.
+   * @default `Plus`
+   */
+  var ?VOLUME_UP:Array<Int>;
+
+  /**
+   * Keybind for decreasing volume.
+   * @default `Minus`
+   */
+  var ?VOLUME_DOWN:Array<Int>;
+
+  /**
+   * Keybind for muting/unmuting volume.
+   * @default `Zero`
+   */
+  var ?VOLUME_MUTE:Array<Int>;
+
+  /**
+   * Keybind for restarting a song.
+   * @default `R`
+   */
+  var ?RESET:Array<Int>;
+}
+
+/**
+ * An anonymous structure containing all the user's options and preferences, specific to the Chart Editor.
+ */
+typedef SaveDataChartEditorOptions = {};
diff --git a/source/funkin/save/migrator/RawSaveData_v1_0_0.hx b/source/funkin/save/migrator/RawSaveData_v1_0_0.hx
new file mode 100644
index 000000000..b71102cce
--- /dev/null
+++ b/source/funkin/save/migrator/RawSaveData_v1_0_0.hx
@@ -0,0 +1,52 @@
+package funkin.save.migrator;
+
+import thx.semver.Version;
+
+typedef RawSaveData_v1_0_0 =
+{
+  var seenVideo:Bool;
+  var mute:Bool;
+  var volume:Float;
+
+  var sessionId:String;
+
+  var songCompletion:Map<String, Float>;
+
+  var songScores:Map<String, Int>;
+
+  var ?controls:
+    {
+      ?p1:SavePlayerControlsData_v1_0_0,
+      ?p2:SavePlayerControlsData_v1_0_0
+    };
+  var enabledMods:Array<String>;
+  var weeksUnlocked:Array<Bool>;
+  var windowSettings:Array<Bool>;
+}
+
+typedef SavePlayerControlsData_v1_0_0 =
+{
+  var keys:SaveControlsData_v1_0_0;
+  var pad:SaveControlsData_v1_0_0;
+};
+
+typedef SaveControlsData_v1_0_0 =
+{
+  var ?ACCEPT:Array<Int>;
+  var ?BACK:Array<Int>;
+  var ?CUTSCENE_ADVANCE:Array<Int>;
+  var ?CUTSCENE_SKIP:Array<Int>;
+  var ?NOTE_DOWN:Array<Int>;
+  var ?NOTE_LEFT:Array<Int>;
+  var ?NOTE_RIGHT:Array<Int>;
+  var ?NOTE_UP:Array<Int>;
+  var ?PAUSE:Array<Int>;
+  var ?RESET:Array<Int>;
+  var ?UI_DOWN:Array<Int>;
+  var ?UI_LEFT:Array<Int>;
+  var ?UI_RIGHT:Array<Int>;
+  var ?UI_UP:Array<Int>;
+  var ?VOLUME_DOWN:Array<Int>;
+  var ?VOLUME_MUTE:Array<Int>;
+  var ?VOLUME_UP:Array<Int>;
+};
diff --git a/source/funkin/save/migrator/SaveDataMigrator.hx b/source/funkin/save/migrator/SaveDataMigrator.hx
new file mode 100644
index 000000000..e7b7c7583
--- /dev/null
+++ b/source/funkin/save/migrator/SaveDataMigrator.hx
@@ -0,0 +1,322 @@
+package funkin.save.migrator;
+
+import funkin.save.Save;
+import funkin.save.migrator.RawSaveData_v1_0_0;
+import thx.semver.Version;
+import funkin.util.VersionUtil;
+
+@:nullSafety
+class SaveDataMigrator
+{
+  /**
+   * Migrate from one 2.x version to another.
+   */
+  public static function migrate(inputData:Dynamic):Save
+  {
+    // This deserializes directly into a `Version` object, not a `String`.
+    var version:Null<Version> = inputData?.version ?? null;
+
+    if (version == null)
+    {
+      trace('[SAVE] No version found in save data! Returning blank data.');
+      trace(inputData);
+      return new Save();
+    }
+    else
+    {
+      if (VersionUtil.validateVersionStr(version, Save.SAVE_DATA_VERSION_RULE))
+      {
+        // Simply cast the structured data.
+        var save:Save = inputData;
+        return save;
+      }
+      else
+      {
+        trace('[SAVE] Invalid save data version! Returning blank data.');
+        trace(inputData);
+        return new Save();
+      }
+    }
+  }
+
+  /**
+   * Migrate from 1.x to the latest version.
+   */
+  public static function migrateFromLegacy(inputData:Dynamic):Save
+  {
+    var inputSaveData:RawSaveData_v1_0_0 = cast inputData;
+
+    var result:Save = new Save();
+
+    result.volume = inputSaveData.volume;
+    result.mute = inputSaveData.mute;
+
+    result.ngSessionId = inputSaveData.sessionId;
+
+    // TODO: Port over the save data from the legacy save data format.
+    migrateLegacyScores(result, inputSaveData);
+
+    migrateLegacyControls(result, inputSaveData);
+
+    return result;
+  }
+
+  static function migrateLegacyScores(result:Save, inputSaveData:RawSaveData_v1_0_0):Void
+  {
+    if (inputSaveData.songCompletion == null)
+    {
+      inputSaveData.songCompletion = [];
+    }
+
+    if (inputSaveData.songScores == null)
+    {
+      inputSaveData.songScores = [];
+    }
+
+    migrateLegacyLevelScore(result, inputSaveData, 'week0');
+    migrateLegacyLevelScore(result, inputSaveData, 'week1');
+    migrateLegacyLevelScore(result, inputSaveData, 'week2');
+    migrateLegacyLevelScore(result, inputSaveData, 'week3');
+    migrateLegacyLevelScore(result, inputSaveData, 'week4');
+    migrateLegacyLevelScore(result, inputSaveData, 'week5');
+    migrateLegacyLevelScore(result, inputSaveData, 'week6');
+    migrateLegacyLevelScore(result, inputSaveData, 'week7');
+
+    migrateLegacySongScore(result, inputSaveData, ['tutorial', 'Tutorial']);
+
+    migrateLegacySongScore(result, inputSaveData, ['bopeebo', 'Bopeebo']);
+    migrateLegacySongScore(result, inputSaveData, ['fresh', 'Fresh']);
+    migrateLegacySongScore(result, inputSaveData, ['dadbattle', 'Dadbattle']);
+
+    migrateLegacySongScore(result, inputSaveData, ['monster', 'Monster']);
+    migrateLegacySongScore(result, inputSaveData, ['south', 'South']);
+    migrateLegacySongScore(result, inputSaveData, ['spookeez', 'Spookeez']);
+
+    migrateLegacySongScore(result, inputSaveData, ['pico', 'Pico']);
+    migrateLegacySongScore(result, inputSaveData, ['philly-nice', 'Philly', 'philly', 'Philly-Nice']);
+    migrateLegacySongScore(result, inputSaveData, ['blammed', 'Blammed']);
+
+    migrateLegacySongScore(result, inputSaveData, ['satin-panties', 'Satin-Panties']);
+    migrateLegacySongScore(result, inputSaveData, ['high', 'High']);
+    migrateLegacySongScore(result, inputSaveData, ['milf', 'Milf', 'MILF']);
+
+    migrateLegacySongScore(result, inputSaveData, ['cocoa', 'Cocoa']);
+    migrateLegacySongScore(result, inputSaveData, ['eggnog', 'Eggnog']);
+    migrateLegacySongScore(result, inputSaveData, ['winter-horrorland', 'Winter-Horrorland']);
+
+    migrateLegacySongScore(result, inputSaveData, ['senpai', 'Senpai']);
+    migrateLegacySongScore(result, inputSaveData, ['roses', 'Roses']);
+    migrateLegacySongScore(result, inputSaveData, ['thorns', 'Thorns']);
+
+    migrateLegacySongScore(result, inputSaveData, ['ugh', 'Ugh']);
+    migrateLegacySongScore(result, inputSaveData, ['guns', 'Guns']);
+    migrateLegacySongScore(result, inputSaveData, ['stress', 'Stress']);
+  }
+
+  static function migrateLegacyLevelScore(result:Save, inputSaveData:RawSaveData_v1_0_0, levelId:String):Void
+  {
+    var scoreDataEasy:SaveScoreData =
+      {
+        score: inputSaveData.songScores.get('${levelId}-easy') ?? 0,
+        accuracy: inputSaveData.songCompletion.get('${levelId}-easy') ?? 0.0,
+        tallies:
+          {
+            killer: 0,
+            sick: 0,
+            good: 0,
+            bad: 0,
+            shit: 0,
+            missed: 0,
+            combo: 0,
+            maxCombo: 0,
+            totalNotesHit: 0,
+            totalNotes: 0,
+          }
+      };
+    result.setLevelScore(levelId, 'easy', scoreDataEasy);
+
+    var scoreDataNormal:SaveScoreData =
+      {
+        score: inputSaveData.songScores.get('${levelId}') ?? 0,
+        accuracy: inputSaveData.songCompletion.get('${levelId}') ?? 0.0,
+        tallies:
+          {
+            killer: 0,
+            sick: 0,
+            good: 0,
+            bad: 0,
+            shit: 0,
+            missed: 0,
+            combo: 0,
+            maxCombo: 0,
+            totalNotesHit: 0,
+            totalNotes: 0,
+          }
+      };
+    result.setLevelScore(levelId, 'normal', scoreDataNormal);
+
+    var scoreDataHard:SaveScoreData =
+      {
+        score: inputSaveData.songScores.get('${levelId}-hard') ?? 0,
+        accuracy: inputSaveData.songCompletion.get('${levelId}-hard') ?? 0.0,
+        tallies:
+          {
+            killer: 0,
+            sick: 0,
+            good: 0,
+            bad: 0,
+            shit: 0,
+            missed: 0,
+            combo: 0,
+            maxCombo: 0,
+            totalNotesHit: 0,
+            totalNotes: 0,
+          }
+      };
+    result.setLevelScore(levelId, 'hard', scoreDataHard);
+  }
+
+  static function migrateLegacySongScore(result:Save, inputSaveData:RawSaveData_v1_0_0, songIds:Array<String>):Void
+  {
+    var scoreDataEasy:SaveScoreData =
+      {
+        score: 0,
+        accuracy: 0,
+        tallies:
+          {
+            killer: 0,
+            sick: 0,
+            good: 0,
+            bad: 0,
+            shit: 0,
+            missed: 0,
+            combo: 0,
+            maxCombo: 0,
+            totalNotesHit: 0,
+            totalNotes: 0,
+          }
+      };
+
+    for (songId in songIds)
+    {
+      scoreDataEasy.score = Std.int(Math.max(scoreDataEasy.score, inputSaveData.songScores.get('${songId}-easy') ?? 0));
+      scoreDataEasy.accuracy = Math.max(scoreDataEasy.accuracy, inputSaveData.songCompletion.get('${songId}-easy') ?? 0.0);
+    }
+    result.setSongScore(songIds[0], 'easy', scoreDataEasy);
+
+    var scoreDataNormal:SaveScoreData =
+      {
+        score: 0,
+        accuracy: 0,
+        tallies:
+          {
+            killer: 0,
+            sick: 0,
+            good: 0,
+            bad: 0,
+            shit: 0,
+            missed: 0,
+            combo: 0,
+            maxCombo: 0,
+            totalNotesHit: 0,
+            totalNotes: 0,
+          }
+      };
+
+    for (songId in songIds)
+    {
+      scoreDataNormal.score = Std.int(Math.max(scoreDataNormal.score, inputSaveData.songScores.get('${songId}') ?? 0));
+      scoreDataNormal.accuracy = Math.max(scoreDataNormal.accuracy, inputSaveData.songCompletion.get('${songId}') ?? 0.0);
+    }
+    result.setSongScore(songIds[0], 'normal', scoreDataNormal);
+
+    var scoreDataHard:SaveScoreData =
+      {
+        score: 0,
+        accuracy: 0,
+        tallies:
+          {
+            killer: 0,
+            sick: 0,
+            good: 0,
+            bad: 0,
+            shit: 0,
+            missed: 0,
+            combo: 0,
+            maxCombo: 0,
+            totalNotesHit: 0,
+            totalNotes: 0,
+          }
+      };
+
+    for (songId in songIds)
+    {
+      scoreDataHard.score = Std.int(Math.max(scoreDataHard.score, inputSaveData.songScores.get('${songId}-hard') ?? 0));
+      scoreDataHard.accuracy = Math.max(scoreDataHard.accuracy, inputSaveData.songCompletion.get('${songId}-hard') ?? 0.0);
+    }
+    result.setSongScore(songIds[0], 'hard', scoreDataHard);
+  }
+
+  static function migrateLegacyControls(result:Save, inputSaveData:RawSaveData_v1_0_0):Void
+  {
+    var p1Data = inputSaveData?.controls?.p1;
+    if (p1Data != null)
+    {
+      migrateLegacyPlayerControls(result, 1, p1Data);
+    }
+
+    var p2Data = inputSaveData?.controls?.p2;
+    if (p2Data != null)
+    {
+      migrateLegacyPlayerControls(result, 2, p2Data);
+    }
+  }
+
+  static function migrateLegacyPlayerControls(result:Save, playerId:Int, controlsData:SavePlayerControlsData_v1_0_0):Void
+  {
+    var outputKeyControls:SaveControlsData =
+      {
+        ACCEPT: controlsData?.keys?.ACCEPT ?? null,
+        BACK: controlsData?.keys?.BACK ?? null,
+        CUTSCENE_ADVANCE: controlsData?.keys?.CUTSCENE_ADVANCE ?? null,
+        CUTSCENE_SKIP: controlsData?.keys?.CUTSCENE_SKIP ?? null,
+        NOTE_DOWN: controlsData?.keys?.NOTE_DOWN ?? null,
+        NOTE_LEFT: controlsData?.keys?.NOTE_LEFT ?? null,
+        NOTE_RIGHT: controlsData?.keys?.NOTE_RIGHT ?? null,
+        NOTE_UP: controlsData?.keys?.NOTE_UP ?? null,
+        PAUSE: controlsData?.keys?.PAUSE ?? null,
+        RESET: controlsData?.keys?.RESET ?? null,
+        UI_DOWN: controlsData?.keys?.UI_DOWN ?? null,
+        UI_LEFT: controlsData?.keys?.UI_LEFT ?? null,
+        UI_RIGHT: controlsData?.keys?.UI_RIGHT ?? null,
+        UI_UP: controlsData?.keys?.UI_UP ?? null,
+        VOLUME_DOWN: controlsData?.keys?.VOLUME_DOWN ?? null,
+        VOLUME_MUTE: controlsData?.keys?.VOLUME_MUTE ?? null,
+        VOLUME_UP: controlsData?.keys?.VOLUME_UP ?? null,
+      };
+
+    var outputPadControls:SaveControlsData =
+      {
+        ACCEPT: controlsData?.pad?.ACCEPT ?? null,
+        BACK: controlsData?.pad?.BACK ?? null,
+        CUTSCENE_ADVANCE: controlsData?.pad?.CUTSCENE_ADVANCE ?? null,
+        CUTSCENE_SKIP: controlsData?.pad?.CUTSCENE_SKIP ?? null,
+        NOTE_DOWN: controlsData?.pad?.NOTE_DOWN ?? null,
+        NOTE_LEFT: controlsData?.pad?.NOTE_LEFT ?? null,
+        NOTE_RIGHT: controlsData?.pad?.NOTE_RIGHT ?? null,
+        NOTE_UP: controlsData?.pad?.NOTE_UP ?? null,
+        PAUSE: controlsData?.pad?.PAUSE ?? null,
+        RESET: controlsData?.pad?.RESET ?? null,
+        UI_DOWN: controlsData?.pad?.UI_DOWN ?? null,
+        UI_LEFT: controlsData?.pad?.UI_LEFT ?? null,
+        UI_RIGHT: controlsData?.pad?.UI_RIGHT ?? null,
+        UI_UP: controlsData?.pad?.UI_UP ?? null,
+        VOLUME_DOWN: controlsData?.pad?.VOLUME_DOWN ?? null,
+        VOLUME_MUTE: controlsData?.pad?.VOLUME_MUTE ?? null,
+        VOLUME_UP: controlsData?.pad?.VOLUME_UP ?? null,
+      };
+
+    result.setControls(playerId, Keys, outputKeyControls);
+    result.setControls(playerId, Gamepad(0), outputPadControls);
+  }
+}
diff --git a/source/funkin/ui/ControlsMenu.hx b/source/funkin/ui/ControlsMenu.hx
index 0d9db5b34..8197424ee 100644
--- a/source/funkin/ui/ControlsMenu.hx
+++ b/source/funkin/ui/ControlsMenu.hx
@@ -163,7 +163,17 @@ class ControlsMenu extends funkin.ui.OptionsState.Page
 
   function onSelect():Void
   {
-    keyUsedToEnterPrompt = FlxG.keys.firstJustPressed();
+    switch (currentDevice)
+    {
+      case Keys:
+        {
+          keyUsedToEnterPrompt = FlxG.keys.firstJustPressed();
+        }
+      case Gamepad(id):
+        {
+          buttonUsedToEnterPrompt = FlxG.gamepads.getByID(id).firstJustPressedID();
+        }
+    }
 
     controlGrid.enabled = false;
     canExit = false;
@@ -204,6 +214,7 @@ class ControlsMenu extends funkin.ui.OptionsState.Page
   }
 
   var keyUsedToEnterPrompt:Null<Int> = null;
+  var buttonUsedToEnterPrompt:Null<Int> = null;
 
   override function update(elapsed:Float):Void
   {
@@ -246,19 +257,49 @@ class ControlsMenu extends funkin.ui.OptionsState.Page
         case Gamepad(id):
           {
             var button = FlxG.gamepads.getByID(id).firstJustReleasedID();
-            if (button != NONE && button != keyUsedToEnterPrompt)
+            if (button != NONE && button != buttonUsedToEnterPrompt)
             {
               if (button != BACK) onInputSelect(button);
               closePrompt();
             }
+
+            var key = FlxG.keys.firstJustReleased();
+            if (key != NONE && key != keyUsedToEnterPrompt)
+            {
+              if (key == ESCAPE)
+              {
+                closePrompt();
+              }
+              else if (key == BACKSPACE)
+              {
+                onInputSelect(NONE);
+                closePrompt();
+              }
+            }
           }
       }
     }
 
-    var keyJustReleased:Int = FlxG.keys.firstJustReleased();
-    if (keyJustReleased != NONE && keyJustReleased == keyUsedToEnterPrompt)
+    switch (currentDevice)
     {
-      keyUsedToEnterPrompt = null;
+      case Keys:
+        {
+          var keyJustReleased:Int = FlxG.keys.firstJustReleased();
+          if (keyJustReleased != NONE && keyJustReleased == keyUsedToEnterPrompt)
+          {
+            keyUsedToEnterPrompt = null;
+          }
+          buttonUsedToEnterPrompt = null;
+        }
+      case Gamepad(id):
+        {
+          var buttonJustReleased:Int = FlxG.gamepads.getByID(id).firstJustReleasedID();
+          if (buttonJustReleased != NONE && buttonJustReleased == buttonUsedToEnterPrompt)
+          {
+            buttonUsedToEnterPrompt = null;
+          }
+          keyUsedToEnterPrompt = null;
+        }
     }
   }
 
diff --git a/source/funkin/ui/PreferencesMenu.hx b/source/funkin/ui/PreferencesMenu.hx
index 4fa8f7f5b..812d0ab49 100644
--- a/source/funkin/ui/PreferencesMenu.hx
+++ b/source/funkin/ui/PreferencesMenu.hx
@@ -3,17 +3,16 @@ package funkin.ui;
 import flixel.FlxCamera;
 import flixel.FlxObject;
 import flixel.FlxSprite;
+import flixel.group.FlxSpriteGroup.FlxTypedSpriteGroup;
 import funkin.ui.AtlasText.AtlasFont;
 import funkin.ui.OptionsState.Page;
 import funkin.ui.TextMenuList.TextMenuItem;
 
 class PreferencesMenu extends Page
 {
-  public static var preferences:Map<String, Dynamic> = new Map();
-
   var items:TextMenuList;
+  var preferenceItems:FlxTypedSpriteGroup<FlxSprite>;
 
-  var checkboxes:Array<CheckboxThingie> = [];
   var menuCamera:FlxCamera;
   var camFollow:FlxObject;
 
@@ -27,13 +26,9 @@ class PreferencesMenu extends Page
     camera = menuCamera;
 
     add(items = new TextMenuList());
+    add(preferenceItems = new FlxTypedSpriteGroup<FlxSprite>());
 
-    createPrefItem('naughtyness', 'censor-naughty', true);
-    createPrefItem('downscroll', 'downscroll', false);
-    createPrefItem('flashing menu', 'flashing-menu', true);
-    createPrefItem('Camera Zooming on Beat', 'camera-zoom', true);
-    createPrefItem('FPS Counter', 'fps-counter', true);
-    createPrefItem('Auto Pause', 'auto-pause', false);
+    createPrefItems();
 
     camFollow = new FlxObject(FlxG.width / 2, 0, 140, 70);
     if (items != null) camFollow.y = items.selectedItem.y;
@@ -48,128 +43,63 @@ class PreferencesMenu extends Page
     });
   }
 
-  public static function getPref(pref:String):Dynamic
+  /**
+   * Create the menu items for each of the preferences.
+   */
+  function createPrefItems():Void
   {
-    return preferences.get(pref);
+    createPrefItemCheckbox('Naughtyness', 'Toggle displaying raunchy content', function(value:Bool):Void {
+      Preferences.naughtyness = value;
+    }, Preferences.naughtyness);
+    createPrefItemCheckbox('Downscroll', 'Enable to make notes move downwards', function(value:Bool):Void {
+      Preferences.downscroll = value;
+    }, Preferences.downscroll);
+    createPrefItemCheckbox('Flashing Lights', 'Disable to dampen flashing effects', function(value:Bool):Void {
+      Preferences.flashingLights = value;
+    }, Preferences.flashingLights);
+    createPrefItemCheckbox('Camera Zooming on Beat', 'Disable to stop the camera bouncing to the song', function(value:Bool):Void {
+      Preferences.zoomCamera = value;
+    }, Preferences.zoomCamera);
+    createPrefItemCheckbox('Debug Display', 'Enable to show FPS and other debug stats', function(value:Bool):Void {
+      Preferences.debugDisplay = value;
+    }, Preferences.debugDisplay);
+    createPrefItemCheckbox('Auto Pause', 'Automatically pause the game when it loses focus', function(value:Bool):Void {
+      Preferences.autoPause = value;
+    }, Preferences.autoPause);
   }
 
-  // easy shorthand?
-  public static function setPref(pref:String, value:Dynamic):Void
+  function createPrefItemCheckbox(prefName:String, prefDesc:String, onChange:Bool->Void, defaultValue:Bool):Void
   {
-    preferences.set(pref, value);
-  }
+    var checkbox:CheckboxPreferenceItem = new CheckboxPreferenceItem(0, 120 * (items.length - 1 + 1), defaultValue);
 
-  public static function initPrefs():Void
-  {
-    preferenceCheck('censor-naughty', true);
-    preferenceCheck('downscroll', false);
-    preferenceCheck('flashing-menu', true);
-    preferenceCheck('camera-zoom', true);
-    preferenceCheck('fps-counter', true);
-    preferenceCheck('auto-pause', false);
-    preferenceCheck('master-volume', 1);
-
-    #if muted
-    setPref('master-volume', 0);
-    FlxG.sound.muted = true;
-    #end
-
-    if (!getPref('fps-counter')) FlxG.stage.removeChild(Main.fpsCounter);
-
-    FlxG.autoPause = getPref('auto-pause');
-  }
-
-  function createPrefItem(prefName:String, prefString:String, prefValue:Dynamic):Void
-  {
     items.createItem(120, (120 * items.length) + 30, prefName, AtlasFont.BOLD, function() {
-      preferenceCheck(prefString, prefValue);
-
-      switch (Type.typeof(prefValue).getName())
-      {
-        case 'TBool':
-          prefToggle(prefString);
-
-        default:
-          trace('swag');
-      }
+      var value = !checkbox.currentValue;
+      onChange(value);
+      checkbox.currentValue = value;
     });
 
-    switch (Type.typeof(prefValue).getName())
-    {
-      case 'TBool':
-        createCheckbox(prefString);
-
-      default:
-        trace('swag');
-    }
-
-    trace(Type.typeof(prefValue).getName());
-  }
-
-  function createCheckbox(prefString:String)
-  {
-    var checkbox:CheckboxThingie = new CheckboxThingie(0, 120 * (items.length - 1), preferences.get(prefString));
-    checkboxes.push(checkbox);
-    add(checkbox);
-  }
-
-  /**
-   * Assumes that the preference has already been checked/set?
-   */
-  function prefToggle(prefName:String)
-  {
-    var daSwap:Bool = preferences.get(prefName);
-    daSwap = !daSwap;
-    preferences.set(prefName, daSwap);
-    checkboxes[items.selectedIndex].daValue = daSwap;
-    trace('toggled? ' + preferences.get(prefName));
-
-    switch (prefName)
-    {
-      case 'fps-counter':
-        if (getPref('fps-counter')) FlxG.stage.addChild(Main.fpsCounter);
-        else
-          FlxG.stage.removeChild(Main.fpsCounter);
-      case 'auto-pause':
-        FlxG.autoPause = getPref('auto-pause');
-    }
-
-    if (prefName == 'fps-counter') {}
+    preferenceItems.add(checkbox);
   }
 
   override function update(elapsed:Float)
   {
     super.update(elapsed);
 
-    // menuCamera.followLerp = CoolUtil.camLerpShit(0.05);
-
+    // Indent the selected item.
+    // TODO: Only do this on menu change?
     items.forEach(function(daItem:TextMenuItem) {
       if (items.selectedItem == daItem) daItem.x = 150;
       else
         daItem.x = 120;
     });
   }
-
-  static function preferenceCheck(prefString:String, defaultValue:Dynamic):Void
-  {
-    if (preferences.get(prefString) == null)
-    {
-      // Set the value to default.
-      preferences.set(prefString, defaultValue);
-      trace('Set preference to default: ${prefString} = ${defaultValue}');
-    }
-    else
-    {
-      trace('Found preference: ${prefString} = ${preferences.get(prefString)}');
-    }
-  }
 }
 
-class CheckboxThingie extends FlxSprite
+class CheckboxPreferenceItem extends FlxSprite
 {
-  public var daValue(default, set):Bool;
+  public var currentValue(default, set):Bool;
 
-  public function new(x:Float, y:Float, daValue:Bool = false)
+  public function new(x:Float, y:Float, defaultValue:Bool = false)
   {
     super(x, y);
 
@@ -180,7 +110,7 @@ class CheckboxThingie extends FlxSprite
     setGraphicSize(Std.int(width * 0.7));
     updateHitbox();
 
-    this.daValue = daValue;
+    this.currentValue = defaultValue;
   }
 
   override function update(elapsed:Float)
@@ -196,12 +126,17 @@ class CheckboxThingie extends FlxSprite
     }
   }
 
-  function set_daValue(value:Bool):Bool
+  function set_currentValue(value:Bool):Bool
   {
-    if (value) animation.play('checked', true);
+    if (value)
+    {
+      animation.play('checked', true);
+    }
     else
+    {
       animation.play('static');
+    }
 
-    return value;
+    return currentValue = value;
   }
 }
diff --git a/source/funkin/ui/StickerSubState.hx b/source/funkin/ui/StickerSubState.hx
index bde36b160..a4e3a6acb 100644
--- a/source/funkin/ui/StickerSubState.hx
+++ b/source/funkin/ui/StickerSubState.hx
@@ -122,7 +122,7 @@ class StickerSubState extends MusicBeatSubState
         var daSound:String = FlxG.random.getObject(sounds);
         FlxG.sound.play(Paths.sound(daSound));
 
-        if (ind == grpStickers.members.length - 1)
+        if (grpStickers == null || ind == grpStickers.members.length - 1)
         {
           switchingState = false;
           close();
@@ -206,6 +206,8 @@ class StickerSubState extends MusicBeatSubState
       sticker.timing = FlxMath.remapToRange(ind, 0, grpStickers.members.length, 0, 0.9);
 
       new FlxTimer().start(sticker.timing, _ -> {
+        if (grpStickers == null) return;
+
         sticker.visible = true;
         var daSound:String = FlxG.random.getObject(sounds);
         FlxG.sound.play(Paths.sound(daSound));
@@ -269,10 +271,10 @@ class StickerSubState extends MusicBeatSubState
   {
     super.update(elapsed);
 
-    if (FlxG.keys.justPressed.ANY)
-    {
-      regenStickers();
-    }
+    // if (FlxG.keys.justPressed.ANY)
+    // {
+    //   regenStickers();
+    // }
   }
 
   var switchingState:Bool = false;
diff --git a/source/funkin/ui/TextMenuList.hx b/source/funkin/ui/TextMenuList.hx
index 0c9f9eb8b..521f46faf 100644
--- a/source/funkin/ui/TextMenuList.hx
+++ b/source/funkin/ui/TextMenuList.hx
@@ -10,7 +10,7 @@ class TextMenuList extends MenuTypedList<TextMenuItem>
     super(navControls, wrapMode);
   }
 
-  public function createItem(x = 0.0, y = 0.0, name:String, font:AtlasFont = BOLD, callback, fireInstantly = false)
+  public function createItem(x = 0.0, y = 0.0, name:String, font:AtlasFont = BOLD, ?callback:Void->Void, fireInstantly = false)
   {
     var item = new TextMenuItem(x, y, name, font, callback);
     item.fireInstantly = fireInstantly;
@@ -20,7 +20,7 @@ class TextMenuList extends MenuTypedList<TextMenuItem>
 
 class TextMenuItem extends TextTypedMenuItem<AtlasText>
 {
-  public function new(x = 0.0, y = 0.0, name:String, font:AtlasFont = BOLD, callback)
+  public function new(x = 0.0, y = 0.0, name:String, font:AtlasFont = BOLD, ?callback:Void->Void)
   {
     super(x, y, new AtlasText(0, 0, name, font), name, callback);
     setEmptyBackground();
@@ -29,7 +29,7 @@ class TextMenuItem extends TextTypedMenuItem<AtlasText>
 
 class TextTypedMenuItem<T:AtlasText> extends MenuTypedItem<T>
 {
-  public function new(x = 0.0, y = 0.0, label:T, name:String, callback)
+  public function new(x = 0.0, y = 0.0, label:T, name:String, ?callback:Void->Void)
   {
     super(x, y, label, name, callback);
   }
diff --git a/source/funkin/ui/debug/charting/ChartEditorAudioHandler.hx b/source/funkin/ui/debug/charting/ChartEditorAudioHandler.hx
index e852dff0a..b5a6f36be 100644
--- a/source/funkin/ui/debug/charting/ChartEditorAudioHandler.hx
+++ b/source/funkin/ui/debug/charting/ChartEditorAudioHandler.hx
@@ -1,11 +1,14 @@
 package funkin.ui.debug.charting;
 
-import openfl.utils.Assets;
 import flixel.system.FlxAssets.FlxSoundAsset;
 import flixel.system.FlxSound;
-import funkin.play.character.BaseCharacter.CharacterType;
 import flixel.system.FlxSound;
+import funkin.audio.VoicesGroup;
+import funkin.play.character.BaseCharacter.CharacterType;
+import funkin.util.FileUtil;
+import haxe.io.Bytes;
 import haxe.io.Path;
+import openfl.utils.Assets;
 
 /**
  * Functions for loading audio for the chart editor.
@@ -17,16 +20,18 @@ import haxe.io.Path;
 class ChartEditorAudioHandler
 {
   /**
-   * Loads a vocal track from an absolute file path.
+   * Loads and stores byte data for a vocal track from an absolute file path
+   *
    * @param path The absolute path to the audio file.
-   * @param charKey The character to load the vocal track for.
+   * @param charId The character this vocal track will be for.
+   * @param instId The instrumental this vocal track will be for.
    * @return Success or failure.
    */
-  static function loadVocalsFromPath(state:ChartEditorState, path:Path, charKey:String = 'default'):Bool
+  static function loadVocalsFromPath(state:ChartEditorState, path:Path, charId:String, instId:String = ''):Bool
   {
     #if sys
-    var fileBytes:haxe.io.Bytes = sys.io.File.getBytes(path.toString());
-    return loadVocalsFromBytes(state, fileBytes, charKey);
+    var fileBytes:Bytes = sys.io.File.getBytes(path.toString());
+    return loadVocalsFromBytes(state, fileBytes, charId, instId);
     #else
     trace("[WARN] This platform can't load audio from a file path, you'll need to fetch the bytes some other way.");
     return false;
@@ -34,137 +39,235 @@ class ChartEditorAudioHandler
   }
 
   /**
-   * Load a vocal track for a given song and character and add it to the voices group.
+   * Loads and stores byte data for a vocal track from an asset
    *
-   * @param path ID of the asset.
-   * @param charKey Character to load the vocal track for.
+   * @param path The path to the asset. Use `Paths` to build this.
+   * @param charId The character this vocal track will be for.
+   * @param instId The instrumental this vocal track will be for.
    * @return Success or failure.
    */
-  static function loadVocalsFromAsset(state:ChartEditorState, path:String, charType:CharacterType = OTHER):Bool
+  static function loadVocalsFromAsset(state:ChartEditorState, path:String, charId:String, instId:String = ''):Bool
   {
-    var vocalTrack:FlxSound = FlxG.sound.load(path, 1.0, false);
+    var trackData:Null<Bytes> = Assets.getBytes(path);
+    if (trackData != null)
+    {
+      return loadVocalsFromBytes(state, trackData, charId, instId);
+    }
+    return false;
+  }
+
+  /**
+   * Loads and stores byte data for a vocal track
+   *
+   * @param bytes The audio byte data.
+   * @param charId The character this vocal track will be for.
+   * @param instId The instrumental this vocal track will be for.
+   */
+  static function loadVocalsFromBytes(state:ChartEditorState, bytes:Bytes, charId:String, instId:String = ''):Bool
+  {
+    var trackId:String = '${charId}${instId == '' ? '' : '-${instId}'}';
+    state.audioVocalTrackData.set(trackId, bytes);
+    return true;
+  }
+
+  /**
+   * Loads and stores byte data for an instrumental track from an absolute file path
+   *
+   * @param path The absolute path to the audio file.
+   * @param instId The instrumental this vocal track will be for.
+   * @return Success or failure.
+   */
+  static function loadInstFromPath(state:ChartEditorState, path:Path, instId:String = ''):Bool
+  {
+    #if sys
+    var fileBytes:Bytes = sys.io.File.getBytes(path.toString());
+    return loadInstFromBytes(state, fileBytes, instId);
+    #else
+    trace("[WARN] This platform can't load audio from a file path, you'll need to fetch the bytes some other way.");
+    return false;
+    #end
+  }
+
+  /**
+   * Loads and stores byte data for an instrumental track from an asset
+   *
+   * @param path The path to the asset. Use `Paths` to build this.
+   * @param instId The instrumental this vocal track will be for.
+   * @return Success or failure.
+   */
+  static function loadInstFromAsset(state:ChartEditorState, path:String, instId:String = ''):Bool
+  {
+    var trackData:Null<Bytes> = Assets.getBytes(path);
+    if (trackData != null)
+    {
+      return loadInstFromBytes(state, trackData, instId);
+    }
+    return false;
+  }
+
+  /**
+   * Loads and stores byte data for a vocal track
+   *
+   * @param bytes The audio byte data.
+   * @param charId The character this vocal track will be for.
+   * @param instId The instrumental this vocal track will be for.
+   */
+  static function loadInstFromBytes(state:ChartEditorState, bytes:Bytes, instId:String = ''):Bool
+  {
+    if (instId == '') instId = 'default';
+    state.audioInstTrackData.set(instId, bytes);
+    return true;
+  }
+
+  public static function switchToInstrumental(state:ChartEditorState, instId:String = '', playerId:String, opponentId:String):Bool
+  {
+    var result:Bool = playInstrumental(state, instId);
+    if (!result) return false;
+
+    stopExistingVocals(state);
+    result = playVocals(state, BF, playerId, instId);
+    if (!result) return false;
+    result = playVocals(state, DAD, opponentId, instId);
+    if (!result) return false;
+
+    return true;
+  }
+
+  /**
+   * Tell the Chart Editor to select a specific instrumental track, that is already loaded.
+   */
+  static function playInstrumental(state:ChartEditorState, instId:String = ''):Bool
+  {
+    if (instId == '') instId = 'default';
+    var instTrackData:Null<Bytes> = state.audioInstTrackData.get(instId);
+    var instTrack:Null<FlxSound> = buildFlxSoundFromBytes(instTrackData);
+    if (instTrack == null) return false;
+
+    stopExistingInstrumental(state);
+    state.audioInstTrack = instTrack;
+    state.postLoadInstrumental();
+    return true;
+  }
+
+  static function stopExistingInstrumental(state:ChartEditorState):Void
+  {
+    if (state.audioInstTrack != null)
+    {
+      state.audioInstTrack.stop();
+      state.audioInstTrack.destroy();
+      state.audioInstTrack = null;
+    }
+  }
+
+  /**
+   * Tell the Chart Editor to select a specific vocal track, that is already loaded.
+   */
+  static function playVocals(state:ChartEditorState, charType:CharacterType, charId:String, instId:String = ''):Bool
+  {
+    var trackId:String = '${charId}${instId == '' ? '' : '-${instId}'}';
+    var vocalTrackData:Null<Bytes> = state.audioVocalTrackData.get(trackId);
+    var vocalTrack:Null<FlxSound> = buildFlxSoundFromBytes(vocalTrackData);
+
+    if (state.audioVocalTrackGroup == null) state.audioVocalTrackGroup = new VoicesGroup();
+
     if (vocalTrack != null)
     {
       switch (charType)
       {
-        case CharacterType.BF:
-          if (state.audioVocalTrackGroup != null) state.audioVocalTrackGroup.addPlayerVoice(vocalTrack);
-          state.audioVocalTrackData.set(state.currentSongCharacterPlayer, Assets.getBytes(path));
-        case CharacterType.DAD:
-          if (state.audioVocalTrackGroup != null) state.audioVocalTrackGroup.addOpponentVoice(vocalTrack);
-          state.audioVocalTrackData.set(state.currentSongCharacterOpponent, Assets.getBytes(path));
+        case BF:
+          state.audioVocalTrackGroup.addPlayerVoice(vocalTrack);
+          return true;
+        case DAD:
+          state.audioVocalTrackGroup.addOpponentVoice(vocalTrack);
+          return true;
+        case OTHER:
+          state.audioVocalTrackGroup.add(vocalTrack);
+          return true;
         default:
-          if (state.audioVocalTrackGroup != null) state.audioVocalTrackGroup.add(vocalTrack);
-          state.audioVocalTrackData.set('default', Assets.getBytes(path));
+          // Do nothing.
       }
-
-      return true;
     }
     return false;
   }
 
-  /**
-   * Loads a vocal track from audio byte data.
-   */
-  static function loadVocalsFromBytes(state:ChartEditorState, bytes:haxe.io.Bytes, charKey:String = ''):Bool
+  static function stopExistingVocals(state:ChartEditorState):Void
   {
-    var openflSound:openfl.media.Sound = new openfl.media.Sound();
-    openflSound.loadCompressedDataFromByteArray(openfl.utils.ByteArray.fromBytes(bytes), bytes.length);
-    var vocalTrack:FlxSound = FlxG.sound.load(openflSound, 1.0, false);
-    if (state.audioVocalTrackGroup != null) state.audioVocalTrackGroup.add(vocalTrack);
-    state.audioVocalTrackData.set(charKey, bytes);
-    return true;
-  }
-
-  /**
-   * Loads an instrumental from an absolute file path, replacing the current instrumental.
-   *
-   * @param path The absolute path to the audio file.
-   *
-   * @return Success or failure.
-   */
-  static function loadInstrumentalFromPath(state:ChartEditorState, path:Path):Bool
-  {
-    #if sys
-    // Validate file extension.
-    if (path.ext != null && !ChartEditorState.SUPPORTED_MUSIC_FORMATS.contains(path.ext))
+    if (state.audioVocalTrackGroup != null)
     {
-      return false;
+      state.audioVocalTrackGroup.clear();
     }
-
-    var fileBytes:haxe.io.Bytes = sys.io.File.getBytes(path.toString());
-    return loadInstrumentalFromBytes(state, fileBytes, '${path.file}.${path.ext}');
-    #else
-    trace("[WARN] This platform can't load audio from a file path, you'll need to fetch the bytes some other way.");
-    return false;
-    #end
-  }
-
-  /**
-   * Loads an instrumental from audio byte data, replacing the current instrumental.
-   * @param bytes The audio byte data.
-   * @param fileName The name of the file, if available. Used for notifications.
-   * @return Success or failure.
-   */
-  static function loadInstrumentalFromBytes(state:ChartEditorState, bytes:haxe.io.Bytes, fileName:String = null):Bool
-  {
-    if (bytes == null)
-    {
-      return false;
-    }
-
-    var openflSound:openfl.media.Sound = new openfl.media.Sound();
-    openflSound.loadCompressedDataFromByteArray(openfl.utils.ByteArray.fromBytes(bytes), bytes.length);
-    state.audioInstTrack = FlxG.sound.load(openflSound, 1.0, false);
-    state.audioInstTrack.autoDestroy = false;
-    state.audioInstTrack.pause();
-
-    state.audioInstTrackData = bytes;
-
-    state.postLoadInstrumental();
-
-    return true;
-  }
-
-  /**
-   * Loads an instrumental from an OpenFL asset, replacing the current instrumental.
-   * @param path The path to the asset. Use `Paths` to build this.
-   * @return Success or failure.
-   */
-  static function loadInstrumentalFromAsset(state:ChartEditorState, path:String):Bool
-  {
-    var instTrack:FlxSound = FlxG.sound.load(path, 1.0, false);
-    if (instTrack != null)
-    {
-      state.audioInstTrack = instTrack;
-
-      state.audioInstTrackData = Assets.getBytes(path);
-
-      state.postLoadInstrumental();
-      return true;
-    }
-
-    return false;
   }
 
   /**
    * Play a sound effect.
    * Automatically cleans up after itself and recycles previous FlxSound instances if available, for performance.
+   * @param path The path to the sound effect. Use `Paths` to build this.
    */
   public static function playSound(path:String):Void
   {
     var snd:FlxSound = FlxG.sound.list.recycle(FlxSound) ?? new FlxSound();
-
     var asset:Null<FlxSoundAsset> = FlxG.sound.cache(path);
     if (asset == null)
     {
       trace('WARN: Failed to play sound $path, asset not found.');
       return;
     }
-
     snd.loadEmbedded(asset);
     snd.autoDestroy = true;
     FlxG.sound.list.add(snd);
     snd.play();
   }
+
+  /**
+   * Convert byte data into a playable sound.
+   *
+   * @param input The byte data.
+   * @return The playable sound, or `null` if loading failed.
+   */
+  public static function buildFlxSoundFromBytes(input:Null<Bytes>):Null<FlxSound>
+  {
+    if (input == null) return null;
+
+    var openflSound:openfl.media.Sound = new openfl.media.Sound();
+    openflSound.loadCompressedDataFromByteArray(openfl.utils.ByteArray.fromBytes(input), input.length);
+    var output:FlxSound = FlxG.sound.load(openflSound, 1.0, false);
+    return output;
+  }
+
+  static function makeZIPEntriesFromInstrumentals(state:ChartEditorState):Array<haxe.zip.Entry>
+  {
+    var zipEntries = [];
+
+    for (key in state.audioInstTrackData.keys())
+    {
+      if (key == 'default')
+      {
+        var data:Null<Bytes> = state.audioInstTrackData.get('default');
+        if (data == null) continue;
+        zipEntries.push(FileUtil.makeZIPEntryFromBytes('Inst.ogg', data));
+      }
+      else
+      {
+        var data:Null<Bytes> = state.audioInstTrackData.get(key);
+        if (data == null) continue;
+        zipEntries.push(FileUtil.makeZIPEntryFromBytes('Inst-${key}.ogg', data));
+      }
+    }
+
+    return zipEntries;
+  }
+
+  static function makeZIPEntriesFromVocals(state:ChartEditorState):Array<haxe.zip.Entry>
+  {
+    var zipEntries = [];
+
+    for (key in state.audioVocalTrackData.keys())
+    {
+      var data:Null<Bytes> = state.audioVocalTrackData.get(key);
+      if (data == null) continue;
+      zipEntries.push(FileUtil.makeZIPEntryFromBytes('Vocals-${key}.ogg', data));
+    }
+
+    return zipEntries;
+  }
 }
diff --git a/source/funkin/ui/debug/charting/ChartEditorCommand.hx b/source/funkin/ui/debug/charting/ChartEditorCommand.hx
index e6caf61e7..1014e67c2 100644
--- a/source/funkin/ui/debug/charting/ChartEditorCommand.hx
+++ b/source/funkin/ui/debug/charting/ChartEditorCommand.hx
@@ -1,5 +1,7 @@
 package funkin.ui.debug.charting;
 
+import haxe.ui.notifications.NotificationType;
+import haxe.ui.notifications.NotificationManager;
 import funkin.data.song.SongData.SongEventData;
 import funkin.data.song.SongData.SongNoteData;
 import funkin.data.song.SongDataUtils;
@@ -762,6 +764,22 @@ class PasteItemsCommand implements ChartEditorCommand
   {
     var currentClipboard:SongClipboardItems = SongDataUtils.readItemsFromClipboard();
 
+    if (currentClipboard.valid != true)
+    {
+      #if !mac
+      NotificationManager.instance.addNotification(
+        {
+          title: 'Failed to Paste',
+          body: 'Could not parse clipboard contents.',
+          type: NotificationType.Error,
+          expiryMs: ChartEditorState.NOTIFICATION_DISMISS_TIME
+        });
+      #end
+      return;
+    }
+
+    trace(currentClipboard.notes);
+
     addedNotes = SongDataUtils.offsetSongNoteData(currentClipboard.notes, Std.int(targetTimestamp));
     addedEvents = SongDataUtils.offsetSongEventData(currentClipboard.events, Std.int(targetTimestamp));
 
@@ -775,6 +793,16 @@ class PasteItemsCommand implements ChartEditorCommand
     state.notePreviewDirty = true;
 
     state.sortChartData();
+
+    #if !mac
+    NotificationManager.instance.addNotification(
+      {
+        title: 'Paste Successful',
+        body: 'Successfully pasted clipboard contents.',
+        type: NotificationType.Success,
+        expiryMs: ChartEditorState.NOTIFICATION_DISMISS_TIME
+      });
+    #end
   }
 
   public function undo(state:ChartEditorState):Void
diff --git a/source/funkin/ui/debug/charting/ChartEditorDialogHandler.hx b/source/funkin/ui/debug/charting/ChartEditorDialogHandler.hx
index 736851d16..91576f2ee 100644
--- a/source/funkin/ui/debug/charting/ChartEditorDialogHandler.hx
+++ b/source/funkin/ui/debug/charting/ChartEditorDialogHandler.hx
@@ -83,7 +83,7 @@ class ChartEditorDialogHandler
     var dialog:Null<Dialog> = openDialog(state, CHART_EDITOR_DIALOG_WELCOME_LAYOUT, true, closable);
     if (dialog == null) throw 'Could not locate Welcome dialog';
 
-    // Add handlers to the "Create From Song" section.
+    // Create New Song "Easy/Normal/Hard"
     var linkCreateBasic:Null<Link> = dialog.findComponent('splashCreateFromSongBasic', Link);
     if (linkCreateBasic == null) throw 'Could not locate splashCreateFromSongBasic link in Welcome dialog';
     linkCreateBasic.onClick = function(_event) {
@@ -94,7 +94,20 @@ class ChartEditorDialogHandler
       //
       // Create Song Wizard
       //
-      openCreateSongWizard(state, false);
+      openCreateSongWizardBasic(state, false);
+    }
+
+    // Create New Song "Erect/Nightmare"
+    var linkCreateErect:Null<Link> = dialog.findComponent('splashCreateFromSongErect', Link);
+    if (linkCreateErect == null) throw 'Could not locate splashCreateFromSongErect link in Welcome dialog';
+    linkCreateErect.onClick = function(_event) {
+      // Hide the welcome dialog
+      dialog.hideDialog(DialogButton.CANCEL);
+
+      //
+      // Create Song Wizard
+      //
+      openCreateSongWizardErect(state, false);
     }
 
     var linkImportChartLegacy:Null<Link> = dialog.findComponent('splashImportChartLegacy', Link);
@@ -237,34 +250,112 @@ class ChartEditorDialogHandler
     };
   }
 
-  public static function openCreateSongWizard(state:ChartEditorState, closable:Bool):Void
+  public static function openCreateSongWizardBasic(state:ChartEditorState, closable:Bool):Void
   {
-    // Step 1. Upload Instrumental
-    var uploadInstDialog:Dialog = openUploadInstDialog(state, closable);
-    uploadInstDialog.onDialogClosed = function(_event) {
+    // Step 1. Song Metadata
+    var songMetadataDialog:Dialog = openSongMetadataDialog(state);
+    songMetadataDialog.onDialogClosed = function(_event) {
       state.isHaxeUIDialogOpen = false;
       if (_event.button == DialogButton.APPLY)
       {
-        // Step 2. Song Metadata
-        var songMetadataDialog:Dialog = openSongMetadataDialog(state);
-        songMetadataDialog.onDialogClosed = function(_event) {
+        // Step 2. Upload Instrumental
+        var uploadInstDialog:Dialog = openUploadInstDialog(state, closable);
+        uploadInstDialog.onDialogClosed = function(_event) {
           state.isHaxeUIDialogOpen = false;
           if (_event.button == DialogButton.APPLY)
           {
             // Step 3. Upload Vocals
             // NOTE: Uploading vocals is optional, so we don't need to check if the user cancelled the wizard.
-            openUploadVocalsDialog(state, false); // var uploadVocalsDialog:Dialog
+            var uploadVocalsDialog:Dialog = openUploadVocalsDialog(state, closable); // var uploadVocalsDialog:Dialog
+            uploadVocalsDialog.onDialogClosed = function(_event) {
+              state.isHaxeUIDialogOpen = false;
+              state.switchToCurrentInstrumental();
+              state.postLoadInstrumental();
+            }
           }
           else
           {
-            // User cancelled the wizard! Back to the welcome dialog.
+            // User cancelled the wizard at Step 2! Back to the welcome dialog.
             openWelcomeDialog(state);
           }
         };
       }
       else
       {
-        // User cancelled the wizard! Back to the welcome dialog.
+        // User cancelled the wizard at Step 1! Back to the welcome dialog.
+        openWelcomeDialog(state);
+      }
+    };
+  }
+
+  public static function openCreateSongWizardErect(state:ChartEditorState, closable:Bool):Void
+  {
+    // Step 1. Song Metadata
+    var songMetadataDialog:Dialog = openSongMetadataDialog(state);
+    songMetadataDialog.onDialogClosed = function(_event) {
+      state.isHaxeUIDialogOpen = false;
+      if (_event.button == DialogButton.APPLY)
+      {
+        // Step 2. Upload Instrumental
+        var uploadInstDialog:Dialog = openUploadInstDialog(state, closable);
+        uploadInstDialog.onDialogClosed = function(_event) {
+          state.isHaxeUIDialogOpen = false;
+          if (_event.button == DialogButton.APPLY)
+          {
+            // Step 3. Upload Vocals
+            // NOTE: Uploading vocals is optional, so we don't need to check if the user cancelled the wizard.
+            var uploadVocalsDialog:Dialog = openUploadVocalsDialog(state, closable); // var uploadVocalsDialog:Dialog
+            uploadVocalsDialog.onDialogClosed = function(_event) {
+              state.switchToCurrentInstrumental();
+              // Step 4. Song Metadata (Erect)
+              var songMetadataDialogErect:Dialog = openSongMetadataDialog(state, 'erect');
+              songMetadataDialogErect.onDialogClosed = function(_event) {
+                state.isHaxeUIDialogOpen = false;
+                if (_event.button == DialogButton.APPLY)
+                {
+                  // Switch to the Erect variation so uploading the instrumental applies properly.
+                  state.selectedVariation = 'erect';
+
+                  // Step 5. Upload Instrumental (Erect)
+                  var uploadInstDialogErect:Dialog = openUploadInstDialog(state, closable);
+                  uploadInstDialogErect.onDialogClosed = function(_event) {
+                    state.isHaxeUIDialogOpen = false;
+                    if (_event.button == DialogButton.APPLY)
+                    {
+                      // Step 6. Upload Vocals (Erect)
+                      // NOTE: Uploading vocals is optional, so we don't need to check if the user cancelled the wizard.
+                      var uploadVocalsDialogErect:Dialog = openUploadVocalsDialog(state, closable); // var uploadVocalsDialog:Dialog
+                      uploadVocalsDialogErect.onDialogClosed = function(_event) {
+                        state.isHaxeUIDialogOpen = false;
+                        state.switchToCurrentInstrumental();
+                        state.postLoadInstrumental();
+                      }
+                    }
+                    else
+                    {
+                      // User cancelled the wizard at Step 5! Back to the welcome dialog.
+                      openWelcomeDialog(state);
+                    }
+                  };
+                }
+                else
+                {
+                  // User cancelled the wizard at Step 4! Back to the welcome dialog.
+                  openWelcomeDialog(state);
+                }
+              }
+            }
+          }
+          else
+          {
+            // User cancelled the wizard at Step 2! Back to the welcome dialog.
+            openWelcomeDialog(state);
+          }
+        };
+      }
+      else
+      {
+        // User cancelled the wizard at Step 1! Back to the welcome dialog.
         openWelcomeDialog(state);
       }
     };
@@ -302,6 +393,8 @@ class ChartEditorDialogHandler
       Cursor.cursorMode = Default;
     }
 
+    var instId:String = state.currentInstrumentalId;
+
     var onDropFile:String->Void;
 
     instrumentalBox.onClick = function(_event) {
@@ -309,14 +402,14 @@ class ChartEditorDialogHandler
         {label: 'Audio File (.ogg)', extension: 'ogg'}], function(selectedFile:SelectedFileInfo) {
           if (selectedFile != null && selectedFile.bytes != null)
           {
-            if (ChartEditorAudioHandler.loadInstrumentalFromBytes(state, selectedFile.bytes))
+            if (ChartEditorAudioHandler.loadInstFromBytes(state, selectedFile.bytes, instId))
             {
               trace('Selected file: ' + selectedFile.fullPath);
               #if !mac
               NotificationManager.instance.addNotification(
                 {
                   title: 'Success',
-                  body: 'Loaded instrumental track (${selectedFile.name})',
+                  body: 'Loaded instrumental track (${selectedFile.name}) for variation (${state.selectedVariation})',
                   type: NotificationType.Success,
                   expiryMs: ChartEditorState.NOTIFICATION_DISMISS_TIME
                 });
@@ -333,7 +426,7 @@ class ChartEditorDialogHandler
               NotificationManager.instance.addNotification(
                 {
                   title: 'Failure',
-                  body: 'Failed to load instrumental track (${selectedFile.name})',
+                  body: 'Failed to load instrumental track (${selectedFile.name}) for variation (${state.selectedVariation})',
                   type: NotificationType.Error,
                   expiryMs: ChartEditorState.NOTIFICATION_DISMISS_TIME
                 });
@@ -346,14 +439,14 @@ class ChartEditorDialogHandler
     onDropFile = function(pathStr:String) {
       var path:Path = new Path(pathStr);
       trace('Dropped file (${path})');
-      if (ChartEditorAudioHandler.loadInstrumentalFromPath(state, path))
+      if (ChartEditorAudioHandler.loadInstFromPath(state, path, instId))
       {
         // Tell the user the load was successful.
         #if !mac
         NotificationManager.instance.addNotification(
           {
             title: 'Success',
-            body: 'Loaded instrumental track (${path.file}.${path.ext})',
+            body: 'Loaded instrumental track (${path.file}.${path.ext}) for variation (${state.selectedVariation})',
             type: NotificationType.Success,
             expiryMs: ChartEditorState.NOTIFICATION_DISMISS_TIME
           });
@@ -370,7 +463,7 @@ class ChartEditorDialogHandler
         }
         else
         {
-          'Failed to load instrumental track (${path.file}.${path.ext})';
+          'Failed to load instrumental track (${path.file}.${path.ext}) for variation (${state.selectedVariation})';
         }
 
         // Tell the user the load was successful.
@@ -457,11 +550,18 @@ class ChartEditorDialogHandler
    * @return The dialog to open.
    */
   @:haxe.warning("-WVarInit")
-  public static function openSongMetadataDialog(state:ChartEditorState):Dialog
+  public static function openSongMetadataDialog(state:ChartEditorState, ?targetVariation:String):Dialog
   {
+    if (targetVariation == null) targetVariation = Constants.DEFAULT_VARIATION;
+
     var dialog:Null<Dialog> = openDialog(state, CHART_EDITOR_DIALOG_SONG_METADATA_LAYOUT, true, false);
     if (dialog == null) throw 'Could not locate Song Metadata dialog';
 
+    if (targetVariation != Constants.DEFAULT_VARIATION)
+    {
+      dialog.title = 'New Chart - Provide Song Metadata (${targetVariation.toTitleCase()})';
+    }
+
     var buttonCancel:Null<Button> = dialog.findComponent('dialogCancel', Button);
     if (buttonCancel == null) throw 'Could not locate dialogCancel button in Song Metadata dialog';
     buttonCancel.onClick = function(_event) {
@@ -567,14 +667,18 @@ class ChartEditorDialogHandler
         timeChanges[0].bpm = event.value;
       }
 
-      Conductor.forceBPM(event.value);
-
       newSongMetadata.timeChanges = timeChanges;
     };
 
     var dialogContinue:Null<Button> = dialog.findComponent('dialogContinue', Button);
     if (dialogContinue == null) throw 'Could not locate dialogContinue button in Song Metadata dialog';
-    dialogContinue.onClick = (_event) -> dialog.hideDialog(DialogButton.APPLY);
+    dialogContinue.onClick = (_event) -> {
+      state.songMetadata.set(targetVariation, newSongMetadata);
+
+      Conductor.mapTimeChanges(state.currentSongMetadata.timeChanges);
+
+      dialog.hideDialog(DialogButton.APPLY);
+    }
 
     return dialog;
   }
@@ -587,10 +691,13 @@ class ChartEditorDialogHandler
    */
   public static function openUploadVocalsDialog(state:ChartEditorState, closable:Bool = true):Dialog
   {
+    var instId:String = state.currentInstrumentalId;
     var charIdsForVocals:Array<String> = [];
 
     var charData:SongCharacterData = state.currentSongMetadata.playData.characters;
 
+    var hasClearedVocals:Bool = false;
+
     charIdsForVocals.push(charData.player);
     charIdsForVocals.push(charData.opponent);
 
@@ -610,6 +717,7 @@ class ChartEditorDialogHandler
     if (dialogNoVocals == null) throw 'Could not locate dialogNoVocals button in Upload Vocals dialog';
     dialogNoVocals.onClick = function(_event) {
       // Dismiss
+      ChartEditorAudioHandler.stopExistingVocals(state);
       dialog.hideDialog(DialogButton.APPLY);
     };
 
@@ -633,14 +741,20 @@ class ChartEditorDialogHandler
         trace('Selected file: $pathStr');
         var path:Path = new Path(pathStr);
 
-        if (ChartEditorAudioHandler.loadVocalsFromPath(state, path, charKey))
+        if (!hasClearedVocals)
+        {
+          hasClearedVocals = true;
+          ChartEditorAudioHandler.stopExistingVocals(state);
+        }
+
+        if (ChartEditorAudioHandler.loadVocalsFromPath(state, path, charKey, instId))
         {
           // Tell the user the load was successful.
           #if !mac
           NotificationManager.instance.addNotification(
             {
               title: 'Success',
-              body: 'Loaded vocal track for $charName (${path.file}.${path.ext})',
+              body: 'Loaded vocals for $charName (${path.file}.${path.ext}), variation ${state.selectedVariation}',
               type: NotificationType.Success,
               expiryMs: ChartEditorState.NOTIFICATION_DISMISS_TIME
             });
@@ -656,21 +770,14 @@ class ChartEditorDialogHandler
         }
         else
         {
-          var message:String = if (!ChartEditorState.SUPPORTED_MUSIC_FORMATS.contains(path.ext ?? ''))
-          {
-            'File format (${path.ext}) not supported for vocal track (${path.file}.${path.ext})';
-          }
-          else
-          {
-            'Failed to load vocal track (${path.file}.${path.ext})';
-          }
+          trace('Failed to load vocal track (${path.file}.${path.ext})');
 
           // Vocals failed to load.
           #if !mac
           NotificationManager.instance.addNotification(
             {
               title: 'Failure',
-              body: message,
+              body: 'Failed to load vocal track (${path.file}.${path.ext}) for variation (${state.selectedVariation})',
               type: NotificationType.Error,
               expiryMs: ChartEditorState.NOTIFICATION_DISMISS_TIME
             });
@@ -690,14 +797,51 @@ class ChartEditorDialogHandler
             if (selectedFile != null && selectedFile.bytes != null)
             {
               trace('Selected file: ' + selectedFile.name);
-              #if FILE_DROP_SUPPORTED
-              vocalsEntryLabel.text = 'Vocals for $charName (drag and drop, or click to browse)\nSelected file: ${selectedFile.name}';
-              #else
-              vocalsEntryLabel.text = 'Vocals for $charName (click to browse)\n${selectedFile.name}';
-              #end
-              ChartEditorAudioHandler.loadVocalsFromBytes(state, selectedFile.bytes, charKey);
-              dialogNoVocals.hidden = true;
-              removeDropHandler(onDropFile);
+              if (!hasClearedVocals)
+              {
+                hasClearedVocals = true;
+                ChartEditorAudioHandler.stopExistingVocals(state);
+              }
+              if (ChartEditorAudioHandler.loadVocalsFromBytes(state, selectedFile.bytes, charKey, instId))
+              {
+                // Tell the user the load was successful.
+                #if !mac
+                NotificationManager.instance.addNotification(
+                  {
+                    title: 'Success',
+                    body: 'Loaded vocals for $charName (${selectedFile.name}), variation ${state.selectedVariation}',
+                    type: NotificationType.Success,
+                    expiryMs: ChartEditorState.NOTIFICATION_DISMISS_TIME
+                  });
+                #end
+                #if FILE_DROP_SUPPORTED
+                vocalsEntryLabel.text = 'Vocals for $charName (drag and drop, or click to browse)\nSelected file: ${selectedFile.name}';
+                #else
+                vocalsEntryLabel.text = 'Vocals for $charName (click to browse)\n${selectedFile.name}';
+                #end
+
+                dialogNoVocals.hidden = true;
+              }
+              else
+              {
+                trace('Failed to load vocal track (${selectedFile.fullPath})');
+
+                #if !mac
+                NotificationManager.instance.addNotification(
+                  {
+                    title: 'Failure',
+                    body: 'Failed to load vocal track (${selectedFile.name}) for variation (${state.selectedVariation})',
+                    type: NotificationType.Error,
+                    expiryMs: ChartEditorState.NOTIFICATION_DISMISS_TIME
+                  });
+                #end
+
+                #if FILE_DROP_SUPPORTED
+                vocalsEntryLabel.text = 'Drag and drop vocals for $charName here, or click to browse.';
+                #else
+                vocalsEntryLabel.text = 'Click to browse for vocals for $charName.';
+                #end
+              }
             }
         });
       }
diff --git a/source/funkin/ui/debug/charting/ChartEditorImportExportHandler.hx b/source/funkin/ui/debug/charting/ChartEditorImportExportHandler.hx
index f116ad3f1..4d8ff18cb 100644
--- a/source/funkin/ui/debug/charting/ChartEditorImportExportHandler.hx
+++ b/source/funkin/ui/debug/charting/ChartEditorImportExportHandler.hx
@@ -36,7 +36,7 @@ class ChartEditorImportExportHandler
     for (metadata in rawSongMetadata)
     {
       if (metadata == null) continue;
-      var variation = (metadata.variation == null || metadata.variation == '') ? 'default' : metadata.variation;
+      var variation = (metadata.variation == null || metadata.variation == '') ? Constants.DEFAULT_VARIATION : metadata.variation;
 
       // Clone to prevent modifying the original.
       var metadataClone:SongMetadata = metadata.clone(variation);
@@ -52,23 +52,44 @@ class ChartEditorImportExportHandler
 
     state.clearVocals();
 
-    ChartEditorAudioHandler.loadInstrumentalFromAsset(state, Paths.inst(songId));
-
-    var diff:Null<SongDifficulty> = song.getDifficulty(state.selectedDifficulty);
-    var voiceList:Array<String> = diff != null ? diff.buildVoiceList() : [];
-    if (voiceList.length == 2)
+    var variations:Array<String> = state.availableVariations;
+    for (variation in variations)
     {
-      ChartEditorAudioHandler.loadVocalsFromAsset(state, voiceList[0], BF);
-      ChartEditorAudioHandler.loadVocalsFromAsset(state, voiceList[1], DAD);
-    }
-    else
-    {
-      for (voicePath in voiceList)
+      if (variation == Constants.DEFAULT_VARIATION)
       {
-        ChartEditorAudioHandler.loadVocalsFromAsset(state, voicePath);
+        ChartEditorAudioHandler.loadInstFromAsset(state, Paths.inst(songId));
+      }
+      else
+      {
+        ChartEditorAudioHandler.loadInstFromAsset(state, Paths.inst(songId, '-$variation'), variation);
       }
     }
 
+    for (difficultyId in song.listDifficulties())
+    {
+      var diff:Null<SongDifficulty> = song.getDifficulty(difficultyId);
+      if (diff == null) continue;
+
+      var instId:String = diff.variation == Constants.DEFAULT_VARIATION ? '' : diff.variation;
+      var voiceList:Array<String> = diff.buildVoiceList(); // SongDifficulty accounts for variation already.
+
+      if (voiceList.length == 2)
+      {
+        ChartEditorAudioHandler.loadVocalsFromAsset(state, voiceList[0], diff.characters.player, instId);
+        ChartEditorAudioHandler.loadVocalsFromAsset(state, voiceList[1], diff.characters.opponent, instId);
+      }
+      else if (voiceList.length == 1)
+      {
+        ChartEditorAudioHandler.loadVocalsFromAsset(state, voiceList[0], diff.characters.player, instId);
+      }
+      else
+      {
+        trace('[WARN] Strange quantity of voice paths for difficulty ${difficultyId}: ${voiceList.length}');
+      }
+    }
+
+    state.switchToCurrentInstrumental();
+
     state.refreshMetadataToolbox();
 
     #if !mac
@@ -148,13 +169,8 @@ class ChartEditorImportExportHandler
       }
     }
 
-    if (state.audioInstTrackData != null) zipEntries.push(FileUtil.makeZIPEntryFromBytes('Inst.ogg', state.audioInstTrackData));
-    for (charId in state.audioVocalTrackData.keys())
-    {
-      var entryData = state.audioVocalTrackData.get(charId);
-      if (entryData == null) continue;
-      zipEntries.push(FileUtil.makeZIPEntryFromBytes('Vocals-$charId.ogg', entryData));
-    }
+    if (state.audioInstTrackData != null) zipEntries.concat(ChartEditorAudioHandler.makeZIPEntriesFromInstrumentals(state));
+    if (state.audioVocalTrackData != null) zipEntries.concat(ChartEditorAudioHandler.makeZIPEntriesFromVocals(state));
 
     trace('Exporting ${zipEntries.length} files to ZIP...');
 
diff --git a/source/funkin/ui/debug/charting/ChartEditorState.hx b/source/funkin/ui/debug/charting/ChartEditorState.hx
index b23df1d3a..70ceadf80 100644
--- a/source/funkin/ui/debug/charting/ChartEditorState.hx
+++ b/source/funkin/ui/debug/charting/ChartEditorState.hx
@@ -461,6 +461,8 @@ class ChartEditorState extends HaxeUIState
     notePreviewDirty = true;
     notePreviewViewportBoundsDirty = true;
     this.scrollPositionInPixels = this.scrollPositionInPixels;
+    // Characters have probably changed too.
+    healthIconsDirty = true;
 
     return isViewDownscroll;
   }
@@ -519,8 +521,14 @@ class ChartEditorState extends HaxeUIState
    */
   var selectedVariation(default, set):String = Constants.DEFAULT_VARIATION;
 
+  /**
+   * Setter called when we are switching variations.
+   * We will likely need to switch instrumentals as well.
+   */
   function set_selectedVariation(value:String):String
   {
+    // Don't update if we're already on the variation.
+    if (selectedVariation == value) return selectedVariation;
     selectedVariation = value;
 
     // Make sure view is updated when the variation changes.
@@ -528,6 +536,8 @@ class ChartEditorState extends HaxeUIState
     notePreviewDirty = true;
     notePreviewViewportBoundsDirty = true;
 
+    switchToCurrentInstrumental();
+
     return selectedVariation;
   }
 
@@ -548,6 +558,23 @@ class ChartEditorState extends HaxeUIState
     return selectedDifficulty;
   }
 
+  /**
+   * The instrumental ID which is currently selected.
+   */
+  var currentInstrumentalId(get, set):String;
+
+  function get_currentInstrumentalId():String
+  {
+    var instId:Null<String> = currentSongMetadata.playData.characters.instrumental;
+    if (instId == null || instId == '') instId = (selectedVariation == Constants.DEFAULT_VARIATION) ? '' : selectedVariation;
+    return instId;
+  }
+
+  function set_currentInstrumentalId(value:String):String
+  {
+    return currentSongMetadata.playData.characters.instrumental = value;
+  }
+
   /**
    * The character ID for the character which is currently selected.
    */
@@ -592,6 +619,11 @@ class ChartEditorState extends HaxeUIState
    */
   var noteDisplayDirty:Bool = true;
 
+  /**
+   * Whether the selected charactesr have been modified and the health icons need to be updated.
+   */
+  var healthIconsDirty:Bool = true;
+
   /**
    * Whether the note preview graphic needs to be FULLY rebuilt.
    */
@@ -773,28 +805,29 @@ class ChartEditorState extends HaxeUIState
 
   /**
    * The audio track for the instrumental.
+   * Replaced when switching instrumentals.
    * `null` until an instrumental track is loaded.
    */
   var audioInstTrack:Null<FlxSound> = null;
 
   /**
-   * The raw byte data for the instrumental audio track.
+   * The raw byte data for the instrumental audio tracks.
+   * Key is the instrumental name.
    * `null` until an instrumental track is loaded.
    */
-  var audioInstTrackData:Null<Bytes> = null;
+  var audioInstTrackData:Map<String, Bytes> = [];
 
   /**
    * The audio track for the vocals.
    * `null` until vocal track(s) are loaded.
+   * When switching characters, the elements of the VoicesGroup will be swapped to match the new character.
    */
   var audioVocalTrackGroup:Null<VoicesGroup> = null;
 
   /**
    * A map of the audio tracks for each character's vocals.
-   * - Keys are the character IDs.
-   * - Values are the FlxSound objects to play that character's vocals.
-   *
-   * When switching characters, the elements of the VoicesGroup will be swapped to match the new character.
+   * - Keys are `characterId-variation` (with `characterId` being the default variation).
+   * - Values are the byte data for the audio track.
    */
   var audioVocalTrackData:Map<String, Bytes> = [];
 
@@ -1045,30 +1078,6 @@ class ChartEditorState extends HaxeUIState
     return currentSongMetadata.artist = value;
   }
 
-  var currentSongCharacterPlayer(get, set):String;
-
-  function get_currentSongCharacterPlayer():String
-  {
-    return currentSongMetadata.playData.characters.player;
-  }
-
-  function set_currentSongCharacterPlayer(value:String):String
-  {
-    return currentSongMetadata.playData.characters.player = value;
-  }
-
-  var currentSongCharacterOpponent(get, set):String;
-
-  function get_currentSongCharacterOpponent():String
-  {
-    return currentSongMetadata.playData.characters.opponent;
-  }
-
-  function set_currentSongCharacterOpponent(value:String):String
-  {
-    return currentSongMetadata.playData.characters.opponent = value;
-  }
-
   /**
    * SIGNALS
    */
@@ -1379,7 +1388,7 @@ class ChartEditorState extends HaxeUIState
     gridPlayhead.add(playheadBlock);
 
     // Character icons.
-    healthIconDad = new HealthIcon(currentSongCharacterOpponent);
+    healthIconDad = new HealthIcon(currentSongMetadata.playData.characters.opponent);
     healthIconDad.autoUpdate = false;
     healthIconDad.size.set(0.5, 0.5);
     healthIconDad.x = gridTiledSprite.x - 15 - (HealthIcon.HEALTH_ICON_SIZE * 0.5);
@@ -1387,7 +1396,7 @@ class ChartEditorState extends HaxeUIState
     add(healthIconDad);
     healthIconDad.zIndex = 30;
 
-    healthIconBF = new HealthIcon(currentSongCharacterPlayer);
+    healthIconBF = new HealthIcon(currentSongMetadata.playData.characters.player);
     healthIconBF.autoUpdate = false;
     healthIconBF.size.set(0.5, 0.5);
     healthIconBF.x = gridTiledSprite.x + gridTiledSprite.width + 15;
@@ -1484,6 +1493,12 @@ class ChartEditorState extends HaxeUIState
     return bounds;
   }
 
+  public function switchToCurrentInstrumental():Void
+  {
+    ChartEditorAudioHandler.switchToInstrumental(this, currentInstrumentalId, currentSongMetadata.playData.characters.player,
+      currentSongMetadata.playData.characters.opponent);
+  }
+
   function setNotePreviewViewportBounds(bounds:FlxRect = null):Void
   {
     if (notePreviewViewport == null)
@@ -1532,11 +1547,11 @@ class ChartEditorState extends HaxeUIState
 
     renderedEvents.setPosition(gridTiledSprite.x, gridTiledSprite.y);
     add(renderedEvents);
-    renderedNotes.zIndex = 25;
+    renderedEvents.zIndex = 25;
 
     renderedSelectionSquares.setPosition(gridTiledSprite.x, gridTiledSprite.y);
     add(renderedSelectionSquares);
-    renderedNotes.zIndex = 26;
+    renderedSelectionSquares.zIndex = 26;
   }
 
   function buildAdditionalUI():Void
@@ -1647,7 +1662,18 @@ class ChartEditorState extends HaxeUIState
 
     addUIClickListener('menubarItemCut', _ -> performCommand(new CutItemsCommand(currentNoteSelection, currentEventSelection)));
 
-    addUIClickListener('menubarItemPaste', _ -> performCommand(new PasteItemsCommand(scrollPositionInMs + playheadPositionInMs)));
+    addUIClickListener('menubarItemPaste', _ -> {
+      var targetMs:Float = scrollPositionInMs + playheadPositionInMs;
+      var targetStep:Float = Conductor.getTimeInSteps(targetMs);
+      var targetSnappedStep:Float = Math.floor(targetStep / noteSnapRatio) * noteSnapRatio;
+      var targetSnappedMs:Float = Conductor.getStepTimeInMs(targetSnappedStep);
+      performCommand(new PasteItemsCommand(targetSnappedMs));
+    });
+
+    addUIClickListener('menubarItemPasteUnsnapped', _ -> {
+      var targetMs:Float = scrollPositionInMs + playheadPositionInMs;
+      performCommand(new PasteItemsCommand(targetMs));
+    });
 
     addUIClickListener('menubarItemDelete', function(_) {
       if (currentNoteSelection.length > 0 && currentEventSelection.length > 0)
@@ -1691,6 +1717,7 @@ class ChartEditorState extends HaxeUIState
     });
 
     addUIClickListener('menubarItemAbout', _ -> ChartEditorDialogHandler.openAboutDialog(this));
+    addUIClickListener('menubarItemWelcomeDialog', _ -> ChartEditorDialogHandler.openWelcomeDialog(this, true));
 
     addUIClickListener('menubarItemUserGuide', _ -> ChartEditorDialogHandler.openUserGuideDialog(this));
 
@@ -1713,6 +1740,11 @@ class ChartEditorState extends HaxeUIState
     });
     setUICheckboxSelected('menuBarItemThemeDark', currentTheme == ChartEditorTheme.Dark);
 
+    addUIClickListener('menubarItemPlayPause', _ -> toggleAudioPlayback());
+
+    addUIClickListener('menubarItemLoadInstrumental', _ -> ChartEditorDialogHandler.openUploadInstDialog(this, true));
+    addUIClickListener('menubarItemLoadVocals', _ -> ChartEditorDialogHandler.openUploadVocalsDialog(this, true));
+
     addUIChangeListener('menubarItemMetronomeEnabled', event -> isMetronomeEnabled = event.value);
     setUICheckboxSelected('menubarItemMetronomeEnabled', isMetronomeEnabled);
 
@@ -1726,7 +1758,7 @@ class ChartEditorState extends HaxeUIState
     if (instVolumeLabel != null)
     {
       addUIChangeListener('menubarItemVolumeInstrumental', function(event:UIEvent) {
-        var volume:Float = event?.value ?? 0 / 100.0;
+        var volume:Float = (event?.value ?? 0) / 100.0;
         if (audioInstTrack != null) audioInstTrack.volume = volume;
         instVolumeLabel.text = 'Instrumental - ${Std.int(event.value)}%';
       });
@@ -1736,7 +1768,7 @@ class ChartEditorState extends HaxeUIState
     if (vocalsVolumeLabel != null)
     {
       addUIChangeListener('menubarItemVolumeVocals', function(event:UIEvent) {
-        var volume:Float = event?.value ?? 0 / 100.0;
+        var volume:Float = (event?.value ?? 0) / 100.0;
         if (audioVocalTrackGroup != null) audioVocalTrackGroup.volume = volume;
         vocalsVolumeLabel.text = 'Vocals - ${Std.int(event.value)}%';
       });
@@ -1865,6 +1897,16 @@ class ChartEditorState extends HaxeUIState
     handleViewKeybinds();
     handleTestKeybinds();
     handleHelpKeybinds();
+
+    #if debug
+    handleQuickWatch();
+    #end
+  }
+
+  function handleQuickWatch():Void
+  {
+    FlxG.watch.addQuick('scrollPosInPixels', scrollPositionInPixels);
+    FlxG.watch.addQuick('playheadPosInPixels', playheadPositionInPixels);
   }
 
   /**
@@ -1920,33 +1962,33 @@ class ChartEditorState extends HaxeUIState
     // Mouse Wheel = Scroll
     if (FlxG.mouse.wheel != 0 && !FlxG.keys.pressed.CONTROL)
     {
-      scrollAmount = -10 * FlxG.mouse.wheel;
+      scrollAmount = -50 * FlxG.mouse.wheel;
       shouldPause = true;
     }
 
     // Up Arrow = Scroll Up
     if (upKeyHandler.activated && currentLiveInputStyle == None)
     {
-      scrollAmount = -GRID_SIZE * 0.25 * 5.0;
+      scrollAmount = -GRID_SIZE * 0.25 * 25.0;
       shouldPause = true;
     }
     // Down Arrow = Scroll Down
     if (downKeyHandler.activated && currentLiveInputStyle == None)
     {
-      scrollAmount = GRID_SIZE * 0.25 * 5.0;
+      scrollAmount = GRID_SIZE * 0.25 * 25.0;
       shouldPause = true;
     }
 
     // W = Scroll Up (doesn't work with Ctrl+Scroll)
     if (wKeyHandler.activated && currentLiveInputStyle == None && !FlxG.keys.pressed.CONTROL)
     {
-      scrollAmount = -GRID_SIZE * 0.25 * 5.0;
+      scrollAmount = -GRID_SIZE * 0.25 * 25.0;
       shouldPause = true;
     }
     // S = Scroll Down (doesn't work with Ctrl+Scroll)
     if (sKeyHandler.activated && currentLiveInputStyle == None && !FlxG.keys.pressed.CONTROL)
     {
-      scrollAmount = GRID_SIZE * 0.25 * 5.0;
+      scrollAmount = GRID_SIZE * 0.25 * 25.0;
       shouldPause = true;
     }
 
@@ -2011,7 +2053,7 @@ class ChartEditorState extends HaxeUIState
     // SHIFT + Scroll = Scroll Fast
     if (FlxG.keys.pressed.SHIFT)
     {
-      scrollAmount *= 5;
+      scrollAmount *= 2;
     }
     // CONTROL + Scroll = Scroll Precise
     if (FlxG.keys.pressed.CONTROL)
@@ -2321,7 +2363,6 @@ class ChartEditorState extends HaxeUIState
               // Scroll up.
               var diff:Float = MENU_BAR_HEIGHT - FlxG.mouse.screenY;
               scrollPositionInPixels -= diff * 0.5; // Too fast!
-              trace('Scroll up: ' + diff);
               moveSongToScrollPosition();
             }
             else if (FlxG.mouse.screenY > (playbarHeadLayout?.y ?? 0.0))
@@ -2329,7 +2370,6 @@ class ChartEditorState extends HaxeUIState
               // Scroll down.
               var diff:Float = FlxG.mouse.screenY - (playbarHeadLayout?.y ?? 0.0);
               scrollPositionInPixels += diff * 0.5; // Too fast!
-              trace('Scroll down: ' + diff);
               moveSongToScrollPosition();
             }
 
@@ -2963,8 +3003,8 @@ class ChartEditorState extends HaxeUIState
           // Set the position and size (because we might be recycling one with bad values).
           selectionSquare.x = noteSprite.x;
           selectionSquare.y = noteSprite.y;
-          selectionSquare.width = noteSprite.width;
-          selectionSquare.height = noteSprite.height;
+          selectionSquare.width = GRID_SIZE;
+          selectionSquare.height = GRID_SIZE;
         }
       }
 
@@ -2995,6 +3035,8 @@ class ChartEditorState extends HaxeUIState
     FlxG.watch.addQuick("tapNotesRendered", renderedNotes.members.length);
     FlxG.watch.addQuick("holdNotesRendered", renderedHoldNotes.members.length);
     FlxG.watch.addQuick("eventsRendered", renderedEvents.members.length);
+    FlxG.watch.addQuick("notesSelected", currentNoteSelection.length);
+    FlxG.watch.addQuick("eventsSelected", currentEventSelection.length);
   }
 
   /**
@@ -3002,6 +3044,12 @@ class ChartEditorState extends HaxeUIState
    */
   function handleHealthIcons():Void
   {
+    if (healthIconsDirty)
+    {
+      if (healthIconBF != null) healthIconBF.characterId = currentSongMetadata.playData.characters.player;
+      if (healthIconDad != null) healthIconDad.characterId = currentSongMetadata.playData.characters.opponent;
+    }
+
     // Right align the BF health icon.
     if (healthIconBF != null)
     {
@@ -3018,6 +3066,8 @@ class ChartEditorState extends HaxeUIState
     if (selectionSquareBitmap == null)
       throw "ERROR: Tried to build selection square, but selectionSquareBitmap is null! Check ChartEditorThemeHandler.updateSelectionSquare()";
 
+    FlxG.bitmapLog.add(selectionSquareBitmap, "selectionSquareBitmap");
+
     return new FlxSprite().loadGraphic(selectionSquareBitmap);
   }
 
@@ -3095,6 +3145,7 @@ class ChartEditorState extends HaxeUIState
   function quitChartEditor():Void
   {
     autoSave();
+    stopWelcomeMusic();
     FlxG.switchState(new MainMenuState());
   }
 
@@ -3137,8 +3188,20 @@ class ChartEditorState extends HaxeUIState
     // CTRL + V = Paste
     if (FlxG.keys.pressed.CONTROL && FlxG.keys.justPressed.V)
     {
-      // Paste notes from clipboard, at the playhead.
-      performCommand(new PasteItemsCommand(scrollPositionInMs + playheadPositionInMs));
+      // CTRL + SHIFT + V = Paste Unsnapped.
+      var targetMs:Float = if (FlxG.keys.pressed.SHIFT)
+      {
+        scrollPositionInMs + playheadPositionInMs;
+      }
+      else
+      {
+        var targetMs:Float = scrollPositionInMs + playheadPositionInMs;
+        var targetStep:Float = Conductor.getTimeInSteps(targetMs);
+        var targetSnappedStep:Float = Math.floor(targetStep / noteSnapRatio) * noteSnapRatio;
+        var targetSnappedMs:Float = Conductor.getStepTimeInMs(targetSnappedStep);
+        targetSnappedMs;
+      }
+      performCommand(new PasteItemsCommand(targetMs));
     }
 
     // DELETE = Delete
@@ -3303,9 +3366,10 @@ class ChartEditorState extends HaxeUIState
    */
   function handleTestKeybinds():Void
   {
-    if (!isHaxeUIDialogOpen && FlxG.keys.justPressed.ENTER)
+    if (!isHaxeUIDialogOpen && !isCursorOverHaxeUI && FlxG.keys.justPressed.ENTER)
     {
       var minimal = FlxG.keys.pressed.SHIFT;
+      ChartEditorToolboxHandler.hideAllToolboxes(this);
       testSongInPlayState(minimal);
     }
   }
@@ -3429,11 +3493,11 @@ class ChartEditorState extends HaxeUIState
     {
       playerPreviewDirty = false;
 
-      if (currentSongCharacterPlayer != charPlayer.charId)
+      if (currentSongMetadata.playData.characters.player != charPlayer.charId)
       {
-        if (healthIconBF != null) healthIconBF.characterId = currentSongCharacterPlayer;
+        if (healthIconBF != null) healthIconBF.characterId = currentSongMetadata.playData.characters.player;
 
-        charPlayer.loadCharacter(currentSongCharacterPlayer);
+        charPlayer.loadCharacter(currentSongMetadata.playData.characters.player);
         charPlayer.characterType = CharacterType.BF;
         charPlayer.flip = true;
         charPlayer.targetScale = 0.5;
@@ -3465,11 +3529,11 @@ class ChartEditorState extends HaxeUIState
     {
       opponentPreviewDirty = false;
 
-      if (currentSongCharacterOpponent != charPlayer.charId)
+      if (currentSongMetadata.playData.characters.opponent != charPlayer.charId)
       {
-        if (healthIconDad != null) healthIconDad.characterId = currentSongCharacterOpponent;
+        if (healthIconDad != null) healthIconDad.characterId = currentSongMetadata.playData.characters.opponent;
 
-        charPlayer.loadCharacter(currentSongCharacterOpponent);
+        charPlayer.loadCharacter(currentSongMetadata.playData.characters.opponent);
         charPlayer.characterType = CharacterType.DAD;
         charPlayer.flip = false;
         charPlayer.targetScale = 0.5;
@@ -4088,12 +4152,18 @@ class ChartEditorState extends HaxeUIState
 
       buildSpectrogram(audioInstTrack);
     }
+    else
+    {
+      trace('[WARN] Instrumental track was null!');
+    }
 
+    // Pretty much everything is going to need to be reset.
     scrollPositionInPixels = 0;
     playheadPositionInPixels = 0;
     notePreviewDirty = true;
     notePreviewViewportBoundsDirty = true;
     noteDisplayDirty = true;
+    healthIconsDirty = true;
     moveSongToScrollPosition();
   }
 
@@ -4111,14 +4181,13 @@ class ChartEditorState extends HaxeUIState
    */
   function moveSongToScrollPosition():Void
   {
-    // Update the songPosition in the Conductor.
-    var targetPos = scrollPositionInMs;
-    Conductor.update(targetPos);
-
     // Update the songPosition in the audio tracks.
     if (audioInstTrack != null) audioInstTrack.time = scrollPositionInMs + playheadPositionInMs;
     if (audioVocalTrackGroup != null) audioVocalTrackGroup.time = scrollPositionInMs + playheadPositionInMs;
 
+    // Update the songPosition in the Conductor.
+    Conductor.update(audioInstTrack.time);
+
     // We need to update the note sprites because we changed the scroll position.
     noteDisplayDirty = true;
   }
diff --git a/source/funkin/ui/debug/charting/ChartEditorToolboxHandler.hx b/source/funkin/ui/debug/charting/ChartEditorToolboxHandler.hx
index 25418a74e..7cee1edde 100644
--- a/source/funkin/ui/debug/charting/ChartEditorToolboxHandler.hx
+++ b/source/funkin/ui/debug/charting/ChartEditorToolboxHandler.hx
@@ -140,6 +140,18 @@ class ChartEditorToolboxHandler
     }
   }
 
+  public static function rememberOpenToolboxes(state:ChartEditorState):Void {}
+
+  public static function openRememberedToolboxes(state:ChartEditorState):Void {}
+
+  public static function hideAllToolboxes(state:ChartEditorState):Void
+  {
+    for (toolbox in state.activeToolboxes.values())
+    {
+      toolbox.hideDialog(DialogButton.CANCEL);
+    }
+  }
+
   public static function minimizeToolbox(state:ChartEditorState, id:String):Void
   {
     var toolbox:Null<CollapsibleDialog> = state.activeToolboxes.get(id);
@@ -638,9 +650,9 @@ class ChartEditorToolboxHandler
         timeChanges[0].bpm = event.value;
       }
 
-      Conductor.forceBPM(event.value);
-
       state.currentSongMetadata.timeChanges = timeChanges;
+
+      Conductor.mapTimeChanges(state.currentSongMetadata.timeChanges);
     };
     inputBPM.value = state.currentSongMetadata.timeChanges[0].bpm;
 
diff --git a/source/funkin/ui/story/Level.hx b/source/funkin/ui/story/Level.hx
index fd2e3ea49..e86241277 100644
--- a/source/funkin/ui/story/Level.hx
+++ b/source/funkin/ui/story/Level.hx
@@ -1,5 +1,6 @@
 package funkin.ui.story;
 
+import funkin.util.SortUtil;
 import flixel.FlxSprite;
 import flixel.util.FlxColor;
 import funkin.play.song.Song;
@@ -155,6 +156,8 @@ class Level implements IRegistryEntry<LevelData>
       }
     }
 
+    difficulties.sort(SortUtil.defaultsThenAlphabetically.bind(Constants.DEFAULT_DIFFICULTY_LIST));
+
     // Filter to only include difficulties that are present in all songs
     for (songIndex in 1...songList.length)
     {
diff --git a/source/funkin/ui/story/StoryMenuState.hx b/source/funkin/ui/story/StoryMenuState.hx
index 3a5a388a8..53cf7e55b 100644
--- a/source/funkin/ui/story/StoryMenuState.hx
+++ b/source/funkin/ui/story/StoryMenuState.hx
@@ -1,5 +1,7 @@
 package funkin.ui.story;
 
+import funkin.save.Save;
+import funkin.save.Save.SaveScoreData;
 import openfl.utils.Assets;
 import flixel.addons.transition.FlxTransitionableState;
 import flixel.FlxSprite;
@@ -521,6 +523,7 @@ class StoryMenuState extends MusicBeatState
 
     PlayStatePlaylist.campaignId = currentLevel.id;
     PlayStatePlaylist.campaignTitle = currentLevel.getTitle();
+    PlayStatePlaylist.campaignDifficulty = currentDifficultyId;
 
     if (targetSong != null)
     {
@@ -536,7 +539,7 @@ class StoryMenuState extends MusicBeatState
       LoadingState.loadAndSwitchState(new PlayState(
         {
           targetSong: targetSong,
-          targetDifficulty: currentDifficultyId,
+          targetDifficulty: PlayStatePlaylist.campaignDifficulty,
         }), true);
     });
   }
@@ -623,7 +626,8 @@ class StoryMenuState extends MusicBeatState
     tracklistText.screenCenter(X);
     tracklistText.x -= FlxG.width * 0.35;
 
-    // TODO: Fix this.
-    highScore = Highscore.getWeekScore(0, 0);
+    var levelScore:Null<SaveScoreData> = Save.get().getLevelScore(currentLevelId, currentDifficultyId);
+    highScore = levelScore?.score ?? 0;
+    // levelScore.accuracy
   }
 }
diff --git a/source/funkin/util/Constants.hx b/source/funkin/util/Constants.hx
index efabf10c3..b27a7d2f5 100644
--- a/source/funkin/util/Constants.hx
+++ b/source/funkin/util/Constants.hx
@@ -4,6 +4,9 @@ import flixel.util.FlxColor;
 import lime.app.Application;
 import funkin.data.song.SongData.SongTimeFormat;
 
+/**
+ * A store of unchanging, globally relevant values.
+ */
 class Constants
 {
   /**
@@ -39,7 +42,7 @@ class Constants
    */
   public static final VERSION_SUFFIX:String = ' PROTOTYPE';
 
-  #if debug
+  #if (debug || FORCE_DEBUG_VERSION)
   static function get_VERSION():String
   {
     return 'v${Application.current.meta.get('version')} (${GIT_BRANCH} : ${GIT_HASH})' + VERSION_SUFFIX;
@@ -71,7 +74,7 @@ class Constants
    */
   // ==============================
 
-  #if debug
+  #if (debug || FORCE_DEBUG_VERSION)
   /**
    * The current Git branch.
    */
@@ -118,6 +121,11 @@ class Constants
    */
   public static final DEFAULT_DIFFICULTY:String = 'normal';
 
+  /**
+   * Default list of difficulties for charts.
+   */
+  public static final DEFAULT_DIFFICULTY_LIST:Array<String> = ['easy', 'normal', 'hard'];
+
   /**
    * Default player character for charts.
    */
diff --git a/source/funkin/util/FlxGamepadUtil.hx b/source/funkin/util/FlxGamepadUtil.hx
new file mode 100644
index 000000000..d768b42c4
--- /dev/null
+++ b/source/funkin/util/FlxGamepadUtil.hx
@@ -0,0 +1,44 @@
+package funkin.util;
+
+import flixel.input.gamepad.FlxGamepad;
+import flixel.input.gamepad.FlxGamepadInputID;
+import lime.ui.Gamepad as LimeGamepad;
+import lime.ui.GamepadAxis as LimeGamepadAxis;
+import lime.ui.GamepadButton as LimeGamepadButton;
+
+class FlxGamepadUtil
+{
+  public static function getInputID(gamepad:FlxGamepad, button:LimeGamepadButton):FlxGamepadInputID
+  {
+    #if FLX_GAMEINPUT_API
+    // FLX_GAMEINPUT_API internally assigns 6 axes to IDs 0-5, which LimeGamepadButton doesn't account for, so we need to offset the ID by 6.
+    final OFFSET:Int = 6;
+    #else
+    final OFFSET:Int = 0;
+    #end
+
+    var result:FlxGamepadInputID = gamepad.mapping.getID(button + OFFSET);
+    if (result == NONE) return NONE;
+    return result;
+  }
+
+  public static function getLimeGamepad(input:FlxGamepad):Null<LimeGamepad>
+  {
+    #if FLX_GAMEINPUT_API @:privateAccess
+    return input._device.getLimeGamepad();
+    #else
+    return null;
+    #end
+  }
+
+  @:privateAccess
+  public static function getFlxGamepadByLimeGamepad(gamepad:LimeGamepad):FlxGamepad
+  {
+    // Why is this so elaborate?
+    @:privateAccess
+    var gameInputDevice:openfl.ui.GameInputDevice = openfl.ui.GameInput.__getDevice(gamepad);
+    @:privateAccess
+    var gamepadIndex:Int = FlxG.gamepads.findGamepadIndex(gameInputDevice);
+    return FlxG.gamepads.getByID(gamepadIndex);
+  }
+}
diff --git a/source/funkin/util/macro/GitCommit.hx b/source/funkin/util/macro/GitCommit.hx
index 0449857cd..d0c034828 100644
--- a/source/funkin/util/macro/GitCommit.hx
+++ b/source/funkin/util/macro/GitCommit.hx
@@ -1,6 +1,6 @@
 package funkin.util.macro;
 
-#if debug
+#if (debug || FORCE_DEBUG_VERSION)
 class GitCommit
 {
   /**