diff --git a/.github/actions/setup-haxeshit/action.yml b/.github/actions/setup-haxeshit/action.yml
index 38a504442..dcf5fd0a7 100644
--- a/.github/actions/setup-haxeshit/action.yml
+++ b/.github/actions/setup-haxeshit/action.yml
@@ -3,18 +3,28 @@ description: "sets up haxe shit, using HMM!"
 runs:
   using: "composite"
   steps:
-      - uses: krdlab/setup-haxe@v1.5.1
-        with:
-          haxe-version: 4.3.1
-      - name: Config haxelib
-        run: |
-          haxelib config
-        shell: bash
-      - name: Installing Haxe lol
-        run: |
-          haxe -version
-          haxelib git haxelib https://github.com/HaxeFoundation/haxelib.git development
-          haxelib version
-          haxelib --global install hmm
-          haxelib --global run hmm install --quiet
-        shell: bash
+    - uses: krdlab/setup-haxe@v1.5.1
+      with:
+        haxe-version: 4.3.1
+    - name: Config haxelib
+      run: |
+        haxelib config
+      shell: bash
+    - name: Installing Haxe lol
+      run: |
+        haxe -version
+        haxelib git haxelib https://github.com/HaxeFoundation/haxelib.git development
+        haxelib version
+        haxelib --global install hmm
+      shell: bash
+    - name: dependency install cache
+      id: cache-hmm
+      uses: actions/cache@v3
+      with:
+        path: .haxelib
+        key: ${{ runner.os }}-hmm-${{ hashFiles('**/hmm.json') }}
+    - if: ${{ steps.cache-hmm.outputs.cache-hit != 'true' }}
+      name: hmm install
+      run: |
+        haxelib --global run hmm install
+      shell: bash
diff --git a/.github/actions/upload-itch/action.yml b/.github/actions/upload-itch/action.yml
index 5abc31b16..7a4b45427 100644
--- a/.github/actions/upload-itch/action.yml
+++ b/.github/actions/upload-itch/action.yml
@@ -36,9 +36,9 @@ runs:
           ./butler -V
         shell: bash
       - name: Upload game to itch.io
-        env: 
+        env:
           BUTLER_API_KEY: ${{inputs.butler-key}}
         run: |
           ./butler login
-          ./butler push ${{inputs.build-dir}} ninja-muffin24/funkin-secret:${{inputs.target}}-${GITHUB_REF##*/}
-        shell: bash
\ No newline at end of file
+          ./butler push ${{inputs.build-dir}} ninja-muffin24/funkin-secret:${{inputs.target}}-${GITHUB_REF_NAME}
+        shell: bash
diff --git a/.github/hooks/README.md b/.github/hooks/README.md
new file mode 100644
index 000000000..544fbf365
--- /dev/null
+++ b/.github/hooks/README.md
@@ -0,0 +1,5 @@
+# Git Hooks
+These work even on Windows because of Git Bash.
+
+## Setup
+`git config core.hooksPath .github/hooks`
diff --git a/.github/hooks/post-checkout b/.github/hooks/post-checkout
new file mode 100644
index 000000000..12358c998
--- /dev/null
+++ b/.github/hooks/post-checkout
@@ -0,0 +1,2 @@
+#!/bin/sh
+git submodule update --init --recursive
diff --git a/.github/hooks/post-merge b/.github/hooks/post-merge
new file mode 100644
index 000000000..12358c998
--- /dev/null
+++ b/.github/hooks/post-merge
@@ -0,0 +1,2 @@
+#!/bin/sh
+git submodule update --init --recursive
diff --git a/.github/hooks/pre-push b/.github/hooks/pre-push
new file mode 100644
index 000000000..ec4c820ac
--- /dev/null
+++ b/.github/hooks/pre-push
@@ -0,0 +1,5 @@
+#!/bin/sh
+if git diff --cached --submodule | grep -q "^+"; then
+  echo "WARNING: You have unpushed changes in submodules."
+  exit 1
+fi
diff --git a/.github/workflows/build-shit.yml b/.github/workflows/build-shit.yml
index ed509b44d..ed10cbdc2 100644
--- a/.github/workflows/build-shit.yml
+++ b/.github/workflows/build-shit.yml
@@ -26,9 +26,11 @@ jobs:
       - uses: actions/checkout@v3
         with:
           submodules: 'recursive'
+          token: ${{ secrets.GH_RO_PAT }}
       - uses: ./.github/actions/setup-haxeshit
       - name: Build game
         run: |
+          sudo apt-get update
           sudo apt-get install -y libx11-dev xorg-dev libgl-dev libxi-dev libxext-dev libasound2-dev libxinerama-dev libxrandr-dev libgl1-mesa-dev
           haxelib run lime build html5 -release --times
           ls
@@ -48,28 +50,44 @@ jobs:
       - uses: actions/checkout@v3
         with:
           submodules: 'recursive'
+          token: ${{ secrets.GH_RO_PAT }}
       - uses: ./.github/actions/setup-haxeshit
+      - name: Make HXCPP cache dir
+        run: |
+          mkdir -p ${{ runner.temp }}\hxcpp_cache
+      - name: Restore build cache
+        id: cache-build-win
+        uses: actions/cache@v3
+        with:
+          path: |
+            .haxelib
+            export
+            ${{ 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
           dir
+        env:
+          HXCPP_COMPILE_CACHE: "${{ runner.temp }}\\hxcpp_cache"
       - uses: ./.github/actions/upload-itch
         with:
-          butler-key: ${{ secrets.BUTLER_API_KEY}}
+          butler-key: ${{ secrets.BUTLER_API_KEY }}
           build-dir: export/release/windows/bin
           target: win
-  test-unit-win:
-    needs: create-nightly-win
-    runs-on: windows-latest
-    permissions:
-       contents: write
-       actions: write
-    steps:
-      - uses: actions/checkout@v3
-        with:
-          submodules: 'recursive'
-      - uses: ./.github/actions/setup-haxeshit
-      - name: Run unit tests
-        run: |
-          cd ./tests/unit/
-          ./start-win-native.bat
+#  test-unit-win:
+#    needs: create-nightly-win
+#    runs-on: windows-latest
+#    permissions:
+#       contents: write
+#       actions: write
+#    steps:
+#      - uses: actions/checkout@v3
+#        with:
+#          submodules: 'recursive'
+#          token: ${{ secrets.GH_RO_PAT }}
+#      - uses: ./.github/actions/setup-haxeshit
+#      - name: Run unit tests
+#        run: |
+#          cd ./tests/unit/
+#          ./start-win-native.bat
diff --git a/.gitignore b/.gitignore
index d4aba58ac..b2fe731ea 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,4 +3,5 @@
 APIStuff.hx
 dump/
 export/
-RECOVER_*.fla
\ No newline at end of file
+RECOVER_*.fla
+shitAudio/
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 a62e7e50d..8104d43e5 160000
--- a/assets
+++ b/assets
@@ -1 +1 @@
-Subproject commit a62e7e50d59c14d256c75da651b79dea77e1620e
+Subproject commit 8104d43e584a1f25e574438d7b21a7e671358969
diff --git a/hmm.json b/hmm.json
index 34e9efb02..070d96cd0 100644
--- a/hmm.json
+++ b/hmm.json
@@ -32,7 +32,7 @@
       "name": "flxanimate",
       "type": "git",
       "dir": null,
-      "ref": "a9136359271cae6ea3016b7fd9023c5c42562933",
+      "ref": "dd2903f7dc7024335b981edf2a770760cec912e1",
       "url": "https://github.com/ninjamuffin99/flxanimate"
     },
     {
@@ -97,8 +97,8 @@
       "name": "json2object",
       "type": "git",
       "dir": null,
-      "ref": "429986134031cbb1980f09d0d3d642b4b4cbcd6a",
-      "url": "https://github.com/elnabo/json2object"
+      "ref": "f4df19cfa196f85eece55c3367021fc965f1fa9a",
+      "url": "https://github.com/EliteMasterEric/json2object"
     },
     {
       "name": "lime",
diff --git a/source/funkin/FreeplayState.hx b/source/funkin/FreeplayState.hx
index 00d7422c8..4e7674e93 100644
--- a/source/funkin/FreeplayState.hx
+++ b/source/funkin/FreeplayState.hx
@@ -1,43 +1,48 @@
 package funkin;
 
-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.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.save.Save;
-import funkin.save.Save.SaveScoreData;
 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.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;
 
@@ -45,7 +50,6 @@ class FreeplayState extends MusicBeatSubState
 {
   var songs:Array<FreeplaySongData> = [];
 
-  // var selector:FlxText;
   var curSelected:Int = 0;
   var curDifficulty:Int = 1;
 
@@ -71,6 +75,7 @@ class FreeplayState extends MusicBeatSubState
 
   var grpSongs:FlxTypedGroup<Alphabet>;
   var grpCapsules:FlxTypedGroup<SongMenuItem>;
+  var curCapsule:SongMenuItem;
   var curPlaying:Bool = false;
 
   var dj:DJBoyfriend;
@@ -103,8 +108,6 @@ class FreeplayState extends MusicBeatSubState
 
       openSubState(stickerSubState);
       stickerSubState.degenStickers();
-
-      // resetSubState();
     }
 
     #if discord_rpc
@@ -116,44 +119,27 @@ 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'));
     }
 
-    // if (StoryMenuState.weekUnlocked[2] || isDebug)
-    addWeek(['Bopeebo', 'Fresh', 'Dadbattle'], 'week1', ['dad']);
+    // Add a null entry that represents the RANDOM option
+    songs.push(null);
 
-    // if (StoryMenuState.weekUnlocked[2] || isDebug)
-    addWeek(['Spookeez', 'South', 'Monster'], 'week2', ['spooky', 'spooky', 'monster']);
-
-    // if (StoryMenuState.weekUnlocked[3] || isDebug)
-    addWeek(['Pico', 'Philly-Nice', 'Blammed'], 'week3', ['pico']);
-
-    // if (StoryMenuState.weekUnlocked[4] || isDebug)
-    addWeek(['Satin-Panties', 'High', 'MILF'], 'week4', ['mom']);
-
-    // if (StoryMenuState.weekUnlocked[5] || isDebug)
-    addWeek(['Cocoa', 'Eggnog', 'Winter-Horrorland'], 'week5', ['parents-christmas', 'parents-christmas', 'monster-christmas']);
-
-    // if (StoryMenuState.weekUnlocked[6] || isDebug)
-    addWeek(['Senpai', 'Roses', 'Thorns'], 'week6', ['senpai', 'senpai', 'spirit']);
-
-    // if (StoryMenuState.weekUnlocked[7] || isDebug)
-    addWeek(['Ugh', 'Guns', 'Stress'], 'week7', ['tankman']);
-
-    addWeek(["Darnell", "lit-up", "2hot", "blazin"], 'weekend1', ['darnell']);
+    // programmatically adds the songs via LevelRegistry and SongRegistry
+    for (coolWeek in LevelRegistry.instance.listBaseGameLevelIds())
+    {
+      for (songId in LevelRegistry.instance.parseEntryData(coolWeek).songs)
+      {
+        var metadata = SongRegistry.instance.parseEntryMetadata(songId);
+        var char = metadata.playData.characters.opponent;
+        var songName = metadata.songName;
+        addSong(songId, songName, coolWeek, char);
+      }
+    }
 
     // LOAD MUSIC
 
@@ -171,7 +157,7 @@ class FreeplayState extends MusicBeatSubState
     FlxTween.tween(pinkBack, {x: 0}, 0.6, {ease: FlxEase.quartOut});
     add(pinkBack);
 
-    var orangeBackShit:FlxSprite = new FlxSprite(84, FlxG.height * 0.68).makeGraphic(Std.int(pinkBack.width), 50, 0xFFffd400);
+    var orangeBackShit:FlxSprite = new FlxSprite(84, 440).makeGraphic(Std.int(pinkBack.width), 75, 0xFFfeda00);
     add(orangeBackShit);
 
     var alsoOrangeLOL:FlxSprite = new FlxSprite(0, orangeBackShit.y).makeGraphic(100, Std.int(orangeBackShit.height), 0xFFffd400);
@@ -193,9 +179,11 @@ class FreeplayState extends MusicBeatSubState
     add(grpTxtScrolls);
     grpTxtScrolls.visible = false;
 
-    var moreWays:BGScrollingText = new BGScrollingText(0, 200, "HOT BLOODED IN MORE WAYS THAN ONE", FlxG.width);
+    FlxG.debugger.addTrackerProfile(new TrackerProfile(BGScrollingText, ["x", "y", "speed", "size"]));
+
+    var moreWays:BGScrollingText = new BGScrollingText(0, 160, "HOT BLOODED IN MORE WAYS THAN ONE", FlxG.width, true, 43);
     moreWays.funnyColor = 0xFFfff383;
-    moreWays.speed = 4;
+    moreWays.speed = 6.8;
     grpTxtScrolls.add(moreWays);
 
     exitMovers.set([moreWays],
@@ -204,9 +192,9 @@ class FreeplayState extends MusicBeatSubState
         speed: 0.4,
       });
 
-    var funnyScroll:BGScrollingText = new BGScrollingText(0, 250, "BOYFRIEND", FlxG.width / 2);
+    var funnyScroll:BGScrollingText = new BGScrollingText(0, 220, "BOYFRIEND", FlxG.width / 2, false, 60);
     funnyScroll.funnyColor = 0xFFff9963;
-    funnyScroll.speed = -1;
+    funnyScroll.speed = -3.8;
     grpTxtScrolls.add(funnyScroll);
 
     exitMovers.set([funnyScroll],
@@ -217,7 +205,8 @@ class FreeplayState extends MusicBeatSubState
         wait: 0
       });
 
-    var txtNuts:BGScrollingText = new BGScrollingText(0, 300, "PROTECT YO NUTS", FlxG.width / 2);
+    var txtNuts:BGScrollingText = new BGScrollingText(0, 285, "PROTECT YO NUTS", FlxG.width / 2, true, 43);
+    txtNuts.speed = 3.5;
     grpTxtScrolls.add(txtNuts);
     exitMovers.set([txtNuts],
       {
@@ -225,9 +214,9 @@ class FreeplayState extends MusicBeatSubState
         speed: 0.4,
       });
 
-    var funnyScroll2:BGScrollingText = new BGScrollingText(0, 340, "BOYFRIEND", FlxG.width / 2);
+    var funnyScroll2:BGScrollingText = new BGScrollingText(0, 335, "BOYFRIEND", FlxG.width / 2, false, 60);
     funnyScroll2.funnyColor = 0xFFff9963;
-    funnyScroll2.speed = -1.2;
+    funnyScroll2.speed = -3.8;
     grpTxtScrolls.add(funnyScroll2);
 
     exitMovers.set([funnyScroll2],
@@ -236,9 +225,9 @@ class FreeplayState extends MusicBeatSubState
         speed: 0.5,
       });
 
-    var moreWays2:BGScrollingText = new BGScrollingText(0, 400, "HOT BLOODED IN MORE WAYS THAN ONE", FlxG.width);
+    var moreWays2:BGScrollingText = new BGScrollingText(0, 397, "HOT BLOODED IN MORE WAYS THAN ONE", FlxG.width, true, 43);
     moreWays2.funnyColor = 0xFFfff383;
-    moreWays2.speed = 4.4;
+    moreWays2.speed = 6.8;
     grpTxtScrolls.add(moreWays2);
 
     exitMovers.set([moreWays2],
@@ -247,9 +236,9 @@ class FreeplayState extends MusicBeatSubState
         speed: 0.4
       });
 
-    var funnyScroll3:BGScrollingText = new BGScrollingText(0, orangeBackShit.y, "BOYFRIEND", FlxG.width / 2);
-    funnyScroll3.funnyColor = 0xFFff9963;
-    funnyScroll3.speed = -0.8;
+    var funnyScroll3:BGScrollingText = new BGScrollingText(0, orangeBackShit.y + 10, "BOYFRIEND", FlxG.width / 2, 60);
+    funnyScroll3.funnyColor = 0xFFfea400;
+    funnyScroll3.speed = -3.8;
     grpTxtScrolls.add(funnyScroll3);
 
     exitMovers.set([funnyScroll3],
@@ -258,7 +247,7 @@ class FreeplayState extends MusicBeatSubState
         speed: 0.3
       });
 
-    dj = new DJBoyfriend(0, -100);
+    dj = new DJBoyfriend(640, 366);
     exitMovers.set([dj],
       {
         x: -dj.width * 1.6,
@@ -314,6 +303,49 @@ class FreeplayState extends MusicBeatSubState
 
     grpDifficulties.group.members[curDifficulty].visible = true;
 
+    var albumArt:FlxAtlasSprite = new FlxAtlasSprite(640, 360, Paths.animateAtlas("freeplay/albumRoll"));
+    albumArt.visible = false;
+    add(albumArt);
+
+    exitMovers.set([albumArt],
+      {
+        x: FlxG.width,
+        speed: 0.4,
+        wait: 0
+      });
+
+    var albumTitle:FlxSprite = new FlxSprite(947, 491).loadGraphic(Paths.image('freeplay/albumTitle-fnfvol1'));
+    var albumArtist:FlxSprite = new FlxSprite(1010, 607).loadGraphic(Paths.image('freeplay/albumArtist-kawaisprite'));
+    var difficultyStars:DifficultyStars = new DifficultyStars(140, 39);
+
+    difficultyStars.stars.visible = false;
+    albumTitle.visible = false;
+    albumArtist.visible = false;
+
+    exitMovers.set([albumTitle],
+      {
+        x: FlxG.width,
+        speed: 0.2,
+        wait: 0.1
+      });
+
+    exitMovers.set([albumArtist],
+      {
+        x: FlxG.width * 1.1,
+        speed: 0.2,
+        wait: 0.2
+      });
+    exitMovers.set([difficultyStars],
+      {
+        x: FlxG.width * 1.2,
+        speed: 0.2,
+        wait: 0.3
+      });
+
+    add(albumTitle);
+    add(albumArtist);
+    add(difficultyStars);
+
     var overhangStuff:FlxSprite = new FlxSprite().makeGraphic(FlxG.width, 64, FlxColor.BLACK);
     overhangStuff.y -= overhangStuff.height;
     add(overhangStuff);
@@ -357,6 +389,28 @@ class FreeplayState extends MusicBeatSubState
     txtCompletion.visible = false;
     add(txtCompletion);
 
+    var letterSort:LetterSort = new LetterSort(400, 75);
+    add(letterSort);
+    letterSort.visible = false;
+
+    exitMovers.set([letterSort],
+      {
+        y: -100,
+        speed: 0.3
+      });
+
+    letterSort.changeSelectionCallback = (str) -> {
+      switch (str)
+      {
+        case "fav":
+          generateSongList({filterType: FAVORITE}, true);
+        case "ALL":
+          generateSongList(null, true);
+        default:
+          generateSongList({filterType: REGEXP, filterData: str}, true);
+      }
+    };
+
     exitMovers.set([fp, txtCompletion, fnfHighscoreSpr],
       {
         x: FlxG.width,
@@ -364,6 +418,23 @@ class FreeplayState extends MusicBeatSubState
       });
 
     dj.onIntroDone.add(function() {
+      // when boyfriend hits dat shiii
+
+      albumArt.visible = true;
+      albumArt.anim.play("");
+      albumArt.anim.onComplete = function() {
+        albumArt.anim.pause();
+      };
+
+      new FlxTimer().start(1, function(_) {
+        albumTitle.visible = true;
+      });
+
+      new FlxTimer().start(35 / 24, function(_) {
+        albumArtist.visible = true;
+        difficultyStars.stars.visible = true;
+      });
+
       FlxTween.tween(grpDifficulties, {x: 90}, 0.6, {ease: FlxEase.quartOut});
 
       var diffSelLeft = new DifficultySelector(20, grpDifficulties.y - 10, false, controls);
@@ -372,33 +443,14 @@ class FreeplayState extends MusicBeatSubState
       add(diffSelLeft);
       add(diffSelRight);
 
+      letterSort.visible = true;
+
       exitMovers.set([diffSelLeft, diffSelRight],
         {
           x: -diffSelLeft.width * 2,
           speed: 0.26
         });
 
-      var letterSort:LetterSort = new LetterSort(300, 100);
-      add(letterSort);
-
-      exitMovers.set([letterSort],
-        {
-          y: -100,
-          speed: 0.3
-        });
-
-      letterSort.changeSelectionCallback = (str) -> {
-        switch (str)
-        {
-          case "fav":
-            generateSongList({filterType: FAVORITE}, true);
-          case "ALL":
-            generateSongList(null, true);
-          default:
-            generateSongList({filterType: STARTSWITH, filterData: str}, true);
-        }
-      };
-
       new FlxTimer().start(1 / 24, function(handShit) {
         fnfHighscoreSpr.visible = true;
         fnfFreeplay.visible = true;
@@ -411,53 +463,28 @@ class FreeplayState extends MusicBeatSubState
         new FlxTimer().start(1.5 / 24, function(bold) {
           sillyStroke.width = 0;
           sillyStroke.height = 0;
+          changeSelection();
         });
       });
 
       pinkBack.color = 0xFFffd863;
-      // fnfFreeplay.visible = true;
       bgDad.visible = true;
       orangeBackShit.visible = true;
       alsoOrangeLOL.visible = true;
       grpTxtScrolls.visible = true;
     });
 
-    generateSongList();
-
-    // 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);
+    generateSongList(null, false);
 
     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);
     };
 
@@ -468,17 +495,24 @@ class FreeplayState extends MusicBeatSubState
 
   public function generateSongList(?filterStuff:SongFilter, force:Bool = false)
   {
-    curSelected = 0;
+    curSelected = 1;
 
-    grpCapsules.clear();
+    for (cap in grpCapsules.members)
+      cap.kill();
 
-    // var regexp:EReg = regexp;
     var tempSongs:Array<FreeplaySongData> = songs;
 
     if (filterStuff != null)
     {
       switch (filterStuff.filterType)
       {
+        case REGEXP:
+          // filterStuff.filterData has a string with the first letter of the sorting range, and the second one
+          // this creates a filter to return all the songs that start with a letter between those two
+          var filterRegexp = new EReg("^[" + filterStuff.filterData + "].*", "i");
+          tempSongs = tempSongs.filter(str -> {
+            return filterRegexp.match(str.songName);
+          });
         case STARTSWITH:
           tempSongs = tempSongs.filter(str -> {
             return str.songName.toLowerCase().startsWith(filterStuff.filterData);
@@ -494,74 +528,57 @@ class FreeplayState extends MusicBeatSubState
       }
     }
 
+    var hsvShader:HSVShader = new HSVShader();
+
+    var randomCapsule:SongMenuItem = grpCapsules.recycle(SongMenuItem);
+    randomCapsule.init(FlxG.width, 0, "Random");
+    randomCapsule.onConfirm = function() {
+      capsuleOnConfirmRandom(randomCapsule);
+    };
+    randomCapsule.y = randomCapsule.intendedY(0) + 10;
+    randomCapsule.targetPos.x = randomCapsule.x;
+    randomCapsule.alpha = 0.5;
+    randomCapsule.songText.visible = false;
+    randomCapsule.favIcon.visible = false;
+    randomCapsule.initJumpIn(0, force);
+    randomCapsule.hsvShader = hsvShader;
+    grpCapsules.add(randomCapsule);
+
     for (i in 0...tempSongs.length)
     {
-      var funnyMenu:SongMenuItem = new SongMenuItem(FlxG.width, (i * 150) + 160, tempSongs[i].songName);
+      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() {
+        capsuleOnConfirmDefault(funnyMenu);
+      };
+      funnyMenu.y = funnyMenu.intendedY(i + 1) + 10;
       funnyMenu.targetPos.x = funnyMenu.x;
       funnyMenu.ID = i;
-      funnyMenu.alpha = 0.5;
+      funnyMenu.capsule.alpha = 0.5;
       funnyMenu.songText.visible = false;
       funnyMenu.favIcon.visible = tempSongs[i].isFav;
+      funnyMenu.hsvShader = hsvShader;
 
-      // fp.updateScore(0);
-
-      var maxTimer:Float = Math.min(i, 4);
-
-      new FlxTimer().start((1 / 24) * maxTimer, function(doShit) {
-        funnyMenu.doJumpIn = true;
-      });
-
-      new FlxTimer().start((0.09 * maxTimer) + 0.85, function(lerpTmr) {
-        funnyMenu.doLerp = true;
-      });
-
-      if (!force)
-      {
-        new FlxTimer().start(((0.20 * maxTimer) / (1 + maxTimer)) + 0.75, function(swagShi) {
-          funnyMenu.songText.visible = true;
-          funnyMenu.alpha = 1;
-        });
-      }
+      if (i < 8) funnyMenu.initJumpIn(Math.min(i, 4), force);
       else
-      {
-        funnyMenu.songText.visible = true;
-        funnyMenu.alpha = 1;
-      }
+        funnyMenu.forcePosition();
 
       grpCapsules.add(funnyMenu);
-
-      var songText:Alphabet = new Alphabet(0, (70 * i) + 30, tempSongs[i].songName, true, false);
-      songText.x += 100;
-      songText.isMenuItem = true;
-      songText.targetY = i;
-
-      // grpSongs.add(songText);
-
-      // songText.x += 40;
-      // DONT PUT X IN THE FIRST PARAMETER OF new ALPHABET() !!
-      // songText.screenCenter(X);
     }
 
+    FlxG.console.registerFunction("changeSelection", changeSelection);
+
     changeSelection();
     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;
@@ -578,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});
+          });
+        }
       }
     }
 
@@ -626,6 +648,13 @@ class FreeplayState extends MusicBeatSubState
 
     txtCompletion.text = Math.floor(lerpCompletion * 100) + "%";
 
+    handleInputs(elapsed);
+  }
+
+  function handleInputs(elapsed:Float):Void
+  {
+    if (busy) return;
+
     var upP = controls.UI_UP_P;
     var downP = controls.UI_DOWN_P;
     var accepted = controls.ACCEPT;
@@ -648,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)
@@ -693,7 +713,6 @@ class FreeplayState extends MusicBeatSubState
           touchY = touch.screenY;
 
           if (dyTouch != 0) dyTouch < 0 ? changeSelection(1) : changeSelection(-1);
-          // changeSelection(1);
         }
       }
       else
@@ -816,82 +835,23 @@ class FreeplayState extends MusicBeatSubState
         {
           FlxG.switchState(new MainMenuState());
         }
-        //
-        // close();
       });
     }
 
     if (accepted)
     {
-      // if (Assets.exists())
-
-      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;
-      }*/
-
-      PlayStatePlaylist.isStoryMode = false;
-      var songId:String = songs[curSelected].songName.toLowerCase();
-      var targetSong:Song = SongRegistry.instance.fetchEntry(songId);
-      var targetDifficulty:String = switch (curDifficulty)
-      {
-        case 0:
-          'easy';
-        case 1:
-          'normal';
-        case 2:
-          'hard';
-        default: 'normal';
-      };
-
-      // TODO: Implement additional difficulties into the interface properly.
-      if (FlxG.keys.pressed.E)
-      {
-        targetDifficulty = 'erect';
-      }
-
-      // TODO: Implement Pico into the interface properly.
-      var targetCharacter:String = 'bf';
-      if (FlxG.keys.pressed.P)
-      {
-        targetCharacter = 'pico';
-      }
-
-      PlayStatePlaylist.campaignId = songs[curSelected].levelId;
-
-      // Visual and audio effects.
-      FlxG.sound.play(Paths.sound('confirmMenu'));
-      dj.confirm();
-
-      if (targetSong != null)
-      {
-        // Load and cache the song's charts.
-        // TODO: Do this in the loading state.
-        targetSong.cacheCharts(true);
-      }
-
-      new FlxTimer().start(1, function(tmr:FlxTimer) {
-        LoadingState.loadAndSwitchState(new PlayState(
-          {
-            targetSong: targetSong,
-            targetDifficulty: targetDifficulty,
-            targetCharacter: targetCharacter,
-          }), true);
-      });
+      grpCapsules.members[curSelected].onConfirm();
     }
   }
 
   @: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);
   }
 
@@ -915,9 +875,18 @@ class FreeplayState extends MusicBeatSubState
       default: 'normal';
     };
 
-    var songScore:SaveScoreData = Save.get().getSongScore(songs[curSelected].songName, targetDifficulty);
-    intendedScore = songScore.score;
-    intendedCompletion = songScore.accuracy;
+    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;
@@ -939,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);
@@ -947,19 +917,76 @@ class FreeplayState extends MusicBeatSubState
     }
   }
 
+  function capsuleOnConfirmRandom(cap:SongMenuItem):Void
+  {
+    trace("RANDOM SELECTED");
+
+    busy = true;
+  }
+
+  function capsuleOnConfirmDefault(cap:SongMenuItem):Void
+  {
+    busy = true;
+
+    PlayStatePlaylist.isStoryMode = false;
+
+    var songId:String = cap.songTitle.toLowerCase();
+    var targetSong:Song = SongRegistry.instance.fetchEntry(songId);
+    var targetDifficulty:String = switch (curDifficulty)
+    {
+      case 0:
+        'easy';
+      case 1:
+        'normal';
+      case 2:
+        'hard';
+      default: 'normal';
+    };
+
+    // TODO: Implement additional difficulties into the interface properly.
+    if (FlxG.keys.pressed.E)
+    {
+      targetDifficulty = 'erect';
+    }
+
+    // TODO: Implement Pico into the interface properly.
+    var targetCharacter:String = 'bf';
+    if (FlxG.keys.pressed.P)
+    {
+      targetCharacter = 'pico';
+    }
+
+    PlayStatePlaylist.campaignId = songs[curSelected].levelId;
+
+    // Visual and audio effects.
+    FlxG.sound.play(Paths.sound('confirmMenu'));
+    dj.confirm();
+
+    // Load and cache the song's charts.
+    // TODO: Do this in the loading state.
+    targetSong.cacheCharts(true);
+
+    new FlxTimer().start(1, function(tmr:FlxTimer) {
+      Paths.setCurrentLevel(songs[curSelected].levelId);
+      LoadingState.loadAndSwitchState(new PlayState(
+        {
+          targetSong: targetSong,
+          targetDifficulty: targetDifficulty,
+          targetCharacter: targetCharacter,
+        }), true);
+    });
+  }
+
   function changeSelection(change:Int = 0)
   {
-    // fp.updateScore(12345);
-
-    NGio.logEvent('Fresh');
-
     // NGio.logEvent('Fresh');
     FlxG.sound.play(Paths.sound('scrollMenu'), 0.4);
+    // FlxG.sound.playMusic(Paths.inst(songs[curSelected].songName));
 
     curSelected += change;
 
-    if (curSelected < 0) curSelected = grpCapsules.members.length - 1;
-    if (curSelected >= grpCapsules.members.length) curSelected = 0;
+    if (curSelected < 0) curSelected = grpCapsules.countLiving() - 1;
+    if (curSelected >= grpCapsules.countLiving()) curSelected = 0;
 
     var targetDifficulty:String = switch (curDifficulty)
     {
@@ -972,28 +999,40 @@ class FreeplayState extends MusicBeatSubState
       default: 'normal';
     };
 
-    var songScore:SaveScoreData = Save.get().getSongScore(songs[curSelected].songName, targetDifficulty);
-    intendedScore = songScore.score;
-    intendedCompletion = songScore.accuracy;
-
-    #if PRELOAD_ALL
-    // FlxG.sound.playMusic(Paths.inst(songs[curSelected].songName), 0);
-    #end
-
-    var bullShit:Int = 0;
+    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.0;
+    }
 
     for (index => capsule in grpCapsules.members)
     {
-      capsule.selected = false;
+      index += 1;
 
-      capsule.targetPos.y = ((index - curSelected) * 150) + 160;
+      capsule.selected = index == curSelected + 1;
+
+      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
     }
 
-    if (grpCapsules.members.length > 0) grpCapsules.members[curSelected].selected = true;
+    if (grpCapsules.countLiving() > 0)
+    {
+      if (curSelected == 0)
+      {
+        FlxG.sound.playMusic(Paths.music('freeplay/freeplayRandom'), 0);
+        FlxG.sound.music.fadeIn(2, 0, 0.8);
+      }
+      grpCapsules.members[curSelected].selected = true;
+    }
   }
 }
 
@@ -1033,7 +1072,10 @@ class DifficultySelector extends FlxSprite
 
     whiteShader.colorSet = true;
 
+    scale.x = scale.y = 0.5;
+
     new FlxTimer().start(2 / 24, function(tmr) {
+      scale.x = scale.y = 1;
       whiteShader.colorSet = false;
       updateHitbox();
     });
@@ -1049,20 +1091,23 @@ typedef SongFilter =
 enum abstract FilterType(String)
 {
   var STARTSWITH;
+  var REGEXP;
   var FAVORITE;
   var ALL;
 }
 
 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/Paths.hx b/source/funkin/Paths.hx
index ee2dfe5fd..07a15dae1 100644
--- a/source/funkin/Paths.hx
+++ b/source/funkin/Paths.hx
@@ -6,9 +6,6 @@ import openfl.utils.Assets as OpenFlAssets;
 
 class Paths
 {
-  public static var SOUND_EXT = #if web "mp3" #else "ogg" #end;
-  public static var VIDEO_EXT = "mp4";
-
   static var currentLevel:String;
 
   static public function setCurrentLevel(name:String)
@@ -52,9 +49,9 @@ class Paths
     return getPath(file, type, library);
   }
 
-  public static inline function animateAtlas(path:String, library:String)
+  public static inline function animateAtlas(path:String, ?library:String)
   {
-    return getLibraryPathForce('images/$path', library);
+    return getLibraryPath('images/$path', library);
   }
 
   inline static public function txt(key:String, ?library:String)
@@ -84,7 +81,7 @@ class Paths
 
   static public function sound(key:String, ?library:String)
   {
-    return getPath('sounds/$key.$SOUND_EXT', SOUND, library);
+    return getPath('sounds/$key.${Constants.EXT_SOUND}', SOUND, library);
   }
 
   inline static public function soundRandom(key:String, min:Int, max:Int, ?library:String)
@@ -94,24 +91,24 @@ class Paths
 
   inline static public function music(key:String, ?library:String)
   {
-    return getPath('music/$key.$SOUND_EXT', MUSIC, library);
+    return getPath('music/$key.${Constants.EXT_SOUND}', MUSIC, library);
   }
 
   inline static public function videos(key:String, ?library:String)
   {
-    return getPath('videos/$key.$VIDEO_EXT', BINARY, library);
+    return getPath('videos/$key.${Constants.EXT_VIDEO}', BINARY, library);
   }
 
   inline static public function voices(song:String, ?suffix:String = '')
   {
     if (suffix == null) suffix = ""; // no suffix, for a sorta backwards compatibility with older-ish voice files
 
-    return 'songs:assets/songs/${song.toLowerCase()}/Voices$suffix.$SOUND_EXT';
+    return 'songs:assets/songs/${song.toLowerCase()}/Voices$suffix.${Constants.EXT_SOUND}';
   }
 
   inline static public function inst(song:String, ?suffix:String = '')
   {
-    return 'songs:assets/songs/${song.toLowerCase()}/Inst$suffix.$SOUND_EXT';
+    return 'songs:assets/songs/${song.toLowerCase()}/Inst$suffix.${Constants.EXT_SOUND}';
   }
 
   inline static public function image(key:String, ?library:String)
diff --git a/source/funkin/PauseSubState.hx b/source/funkin/PauseSubState.hx
index f93e5a450..54c3a530b 100644
--- a/source/funkin/PauseSubState.hx
+++ b/source/funkin/PauseSubState.hx
@@ -150,6 +150,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;
@@ -229,9 +234,14 @@ 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();
diff --git a/source/funkin/audio/FlxStreamSound.hx b/source/funkin/audio/FlxStreamSound.hx
new file mode 100644
index 000000000..a572ad436
--- /dev/null
+++ b/source/funkin/audio/FlxStreamSound.hx
@@ -0,0 +1,49 @@
+package funkin.audio;
+
+import flash.media.Sound;
+#if flash11
+import flash.utils.ByteArray;
+#end
+import flixel.sound.FlxSound;
+import flixel.system.FlxAssets.FlxSoundAsset;
+import openfl.Assets;
+#if (openfl >= "8.0.0")
+import openfl.utils.AssetType;
+#end
+
+/**
+ * a FlxSound that just overrides loadEmbedded to allow for "streamed" sounds to load with better performance!
+ */
+class FlxStreamSound extends FlxSound
+{
+  public function new()
+  {
+    super();
+  }
+
+  override public function loadEmbedded(EmbeddedSound:FlxSoundAsset, Looped:Bool = false, AutoDestroy:Bool = false, ?OnComplete:Void->Void):FlxSound
+  {
+    if (EmbeddedSound == null) return this;
+
+    cleanup(true);
+
+    if ((EmbeddedSound is Sound))
+    {
+      _sound = EmbeddedSound;
+    }
+    else if ((EmbeddedSound is Class))
+    {
+      _sound = Type.createInstance(EmbeddedSound, []);
+    }
+    else if ((EmbeddedSound is String))
+    {
+      if (Assets.exists(EmbeddedSound, AssetType.SOUND)
+        || Assets.exists(EmbeddedSound, AssetType.MUSIC)) _sound = Assets.getMusic(EmbeddedSound);
+      else
+        FlxG.log.error('Could not find a Sound asset with an ID of \'$EmbeddedSound\'.');
+    }
+
+    // NOTE: can't pull ID3 info from embedded sound currently
+    return init(Looped, AutoDestroy, OnComplete);
+  }
+}
diff --git a/source/funkin/data/BaseRegistry.hx b/source/funkin/data/BaseRegistry.hx
index 24d0de476..70615069b 100644
--- a/source/funkin/data/BaseRegistry.hx
+++ b/source/funkin/data/BaseRegistry.hx
@@ -4,9 +4,6 @@ import openfl.Assets;
 import funkin.util.assets.DataAssets;
 import funkin.util.VersionUtil;
 import haxe.Constraints.Constructible;
-import json2object.Position;
-import json2object.Position.Line;
-import json2object.Error;
 
 /**
  * The entry's constructor function must take a single argument, the entry's ID.
@@ -179,6 +176,15 @@ abstract class BaseRegistry<T:(IRegistryEntry<J> & Constructible<EntryConstructo
    */
   public abstract function parseEntryData(id:String):Null<J>;
 
+  /**
+   * Parse and validate the JSON data and produce the corresponding data object.
+   *
+   * NOTE: Must be implemented on the implementation class.
+   * @param contents The JSON as a string.
+   * @param fileName An optional file name for error reporting.
+   */
+  public abstract function parseEntryDataRaw(contents:String, ?fileName:String):Null<J>;
+
   /**
    * Read, parse, and validate the JSON data and produce the corresponding data object,
    * accounting for old versions of the data.
@@ -226,79 +232,12 @@ abstract class BaseRegistry<T:(IRegistryEntry<J> & Constructible<EntryConstructo
    */
   abstract function createScriptedEntry(clsName:String):Null<T>;
 
-  function printErrors(errors:Array<Error>, id:String = ''):Void
+  function printErrors(errors:Array<json2object.Error>, id:String = ''):Void
   {
     trace('[${registryId}] Failed to parse entry data: ${id}');
 
     for (error in errors)
-      printError(error);
-  }
-
-  function printError(error:Error):Void
-  {
-    switch (error)
-    {
-      case IncorrectType(vari, expected, pos):
-        trace('  Expected field "$vari" to be of type "$expected".');
-        printPos(pos);
-      case IncorrectEnumValue(value, expected, pos):
-        trace('  Invalid enum value (expected "$expected", got "$value")');
-        printPos(pos);
-      case InvalidEnumConstructor(value, expected, pos):
-        trace('  Invalid enum constructor (epxected "$expected", got "$value")');
-        printPos(pos);
-      case UninitializedVariable(vari, pos):
-        trace('  Uninitialized variable "$vari"');
-        printPos(pos);
-      case UnknownVariable(vari, pos):
-        trace('  Unknown variable "$vari"');
-        printPos(pos);
-      case ParserError(message, pos):
-        trace('  Parsing error: ${message}');
-        printPos(pos);
-      case CustomFunctionException(e, pos):
-        if (Std.isOfType(e, String))
-        {
-          trace('  ${e}');
-        }
-        else
-        {
-          printUnknownError(e);
-        }
-        printPos(pos);
-      default:
-        printUnknownError(error);
-    }
-  }
-
-  function printUnknownError(e:Dynamic):Void
-  {
-    switch (Type.typeof(e))
-    {
-      case TClass(c):
-        trace('  [${Type.getClassName(c)}] ${e.toString()}');
-      case TEnum(c):
-        trace('  [${Type.getEnumName(c)}] ${e.toString()}');
-      default:
-        trace('  [${Type.typeof(e)}] ${e.toString()}');
-    }
-  }
-
-  /**
-   * TODO: Figure out the nicest way to print this.
-   * Maybe look up how other JSON parsers format their errors?
-   * @see https://github.com/elnabo/json2object/blob/master/src/json2object/Position.hx
-   */
-  function printPos(pos:Position):Void
-  {
-    if (pos.lines[0].number == pos.lines[pos.lines.length - 1].number)
-    {
-      trace('    at ${(pos.file == '') ? 'line ' : '${pos.file}:'}${pos.lines[0].number}');
-    }
-    else
-    {
-      trace('    at ${(pos.file == '') ? 'line ' : '${pos.file}:'}${pos.lines[0].number}-${pos.lines[pos.lines.length - 1].number}');
-    }
+      DataError.printError(error);
   }
 }
 
diff --git a/source/funkin/data/DataError.hx b/source/funkin/data/DataError.hx
new file mode 100644
index 000000000..87c99fff5
--- /dev/null
+++ b/source/funkin/data/DataError.hx
@@ -0,0 +1,75 @@
+package funkin.data;
+
+import json2object.Position;
+import json2object.Position.Line;
+import json2object.Error;
+
+class DataError
+{
+  public static function printError(error:Error):Void
+  {
+    switch (error)
+    {
+      case IncorrectType(vari, expected, pos):
+        trace('  Expected field "$vari" to be of type "$expected".');
+        printPos(pos);
+      case IncorrectEnumValue(value, expected, pos):
+        trace('  Invalid enum value (expected "$expected", got "$value")');
+        printPos(pos);
+      case InvalidEnumConstructor(value, expected, pos):
+        trace('  Invalid enum constructor (epxected "$expected", got "$value")');
+        printPos(pos);
+      case UninitializedVariable(vari, pos):
+        trace('  Uninitialized variable "$vari"');
+        printPos(pos);
+      case UnknownVariable(vari, pos):
+        trace('  Unknown variable "$vari"');
+        printPos(pos);
+      case ParserError(message, pos):
+        trace('  Parsing error: ${message}');
+        printPos(pos);
+      case CustomFunctionException(e, pos):
+        if (Std.isOfType(e, String))
+        {
+          trace('  ${e}');
+        }
+        else
+        {
+          printUnknownError(e);
+        }
+        printPos(pos);
+      default:
+        printUnknownError(error);
+    }
+  }
+
+  public static function printUnknownError(e:Dynamic):Void
+  {
+    switch (Type.typeof(e))
+    {
+      case TClass(c):
+        trace('  [${Type.getClassName(c)}] ${e.toString()}');
+      case TEnum(c):
+        trace('  [${Type.getEnumName(c)}] ${e.toString()}');
+      default:
+        trace('  [${Type.typeof(e)}] ${e.toString()}');
+    }
+  }
+
+  /**
+   * TODO: Figure out the nicest way to print this.
+   * Maybe look up how other JSON parsers format their errors?
+   * @see https://github.com/elnabo/json2object/blob/master/src/json2object/Position.hx
+   */
+  static function printPos(pos:Position):Void
+  {
+    if (pos.lines[0].number == pos.lines[pos.lines.length - 1].number)
+    {
+      trace('    at ${(pos.file == '') ? 'line ' : '${pos.file}:'}${pos.lines[0].number}');
+    }
+    else
+    {
+      trace('    at ${(pos.file == '') ? 'line ' : '${pos.file}:'}${pos.lines[0].number}-${pos.lines[pos.lines.length - 1].number}');
+    }
+  }
+}
diff --git a/source/funkin/data/DataParse.hx b/source/funkin/data/DataParse.hx
index f6b5dd659..4a422b368 100644
--- a/source/funkin/data/DataParse.hx
+++ b/source/funkin/data/DataParse.hx
@@ -1,7 +1,13 @@
 package funkin.data;
 
+import funkin.data.song.importer.FNFLegacyData.LegacyNote;
 import hxjsonast.Json;
+import hxjsonast.Tools;
 import hxjsonast.Json.JObjectField;
+import haxe.ds.Either;
+import funkin.data.song.importer.FNFLegacyData.LegacyNoteSection;
+import funkin.data.song.importer.FNFLegacyData.LegacyNoteData;
+import funkin.data.song.importer.FNFLegacyData.LegacyScrollSpeeds;
 
 /**
  * `json2object` has an annotation `@:jcustomparse` which allows for mutation of parsed values.
@@ -39,36 +45,40 @@ class DataParse
    */
   public static function dynamicValue(json:Json, name:String):Dynamic
   {
-    return jsonToDynamic(json);
+    return Tools.getValue(json);
   }
 
   /**
-   * Parser which outputs a Dynamic value, which must be an object with properties.
-   * @param json
-   * @param name
-   * @return Dynamic
+   * Parser which outputs a `Either<Array<LegacyNoteSection>, LegacyNoteData>`.
+   * Used by the FNF legacy JSON importer.
    */
-  public static function dynamicObject(json:Json, name:String):Dynamic
+  public static function eitherLegacyNoteData(json:Json, name:String):Either<Array<LegacyNoteSection>, LegacyNoteData>
   {
     switch (json.value)
     {
+      case JArray(values):
+        return Either.Left(legacyNoteSectionArray(json, name));
       case JObject(fields):
-        return jsonFieldsToDynamicObject(fields);
+        return Either.Right(cast Tools.getValue(json));
       default:
-        throw 'Expected property $name to be an object, but it was ${json.value}.';
+        throw 'Expected property $name to be note data, but it was ${json.value}.';
     }
   }
 
-  static function jsonToDynamic(json:Json):Null<Dynamic>
+  /**
+   * Parser which outputs a `Either<Float, LegacyScrollSpeeds>`.
+   * Used by the FNF legacy JSON importer.
+   */
+  public static function eitherLegacyScrollSpeeds(json:Json, name:String):Either<Float, LegacyScrollSpeeds>
   {
-    return switch (json.value)
+    switch (json.value)
     {
-      case JString(s): s;
-      case JNumber(n): Std.parseInt(n);
-      case JBool(b): b;
-      case JNull: null;
-      case JObject(fields): jsonFieldsToDynamicObject(fields);
-      case JArray(values): jsonArrayToDynamicArray(values);
+      case JNumber(f):
+        return Either.Left(Std.parseFloat(f));
+      case JObject(fields):
+        return Either.Right(cast Tools.getValue(json));
+      default:
+        throw 'Expected property $name to be scroll speeds, but it was ${json.value}.';
     }
   }
 
@@ -82,7 +92,7 @@ class DataParse
     var result:Dynamic = {};
     for (field in fields)
     {
-      Reflect.setField(result, field.name, jsonToDynamic(field.value));
+      Reflect.setField(result, field.name, Tools.getValue(field.value));
     }
     return result;
   }
@@ -94,6 +104,67 @@ class DataParse
    */
   static function jsonArrayToDynamicArray(jsons:Array<Json>):Array<Null<Dynamic>>
   {
-    return [for (json in jsons) jsonToDynamic(json)];
+    return [for (json in jsons) Tools.getValue(json)];
+  }
+
+  static function legacyNoteSectionArray(json:Json, name:String):Array<LegacyNoteSection>
+  {
+    switch (json.value)
+    {
+      case JArray(values):
+        return [for (value in values) legacyNoteSection(value, name)];
+      default:
+        throw 'Expected property to be an array, but it was ${json.value}.';
+    }
+  }
+
+  static function legacyNoteSection(json:Json, name:String):LegacyNoteSection
+  {
+    switch (json.value)
+    {
+      case JObject(fields):
+        return cast Tools.getValue(json);
+      default:
+        throw 'Expected property $name to be an object, but it was ${json.value}.';
+    }
+  }
+
+  public static function legacyNoteData(json:Json, name:String):LegacyNoteData
+  {
+    switch (json.value)
+    {
+      case JObject(fields):
+        return cast Tools.getValue(json);
+      default:
+        throw 'Expected property $name to be an object, but it was ${json.value}.';
+    }
+  }
+
+  public static function legacyNotes(json:Json, name:String):Array<LegacyNote>
+  {
+    switch (json.value)
+    {
+      case JArray(values):
+        return [for (value in values) legacyNote(value, name)];
+      default:
+        throw 'Expected property $name to be an array of notes, but it was ${json.value}.';
+    }
+  }
+
+  public static function legacyNote(json:Json, name:String):LegacyNote
+  {
+    switch (json.value)
+    {
+      case JArray(values):
+        // var time:Null<Float> = values[0] == null ? null : Tools.getValue(values[0]);
+        // var data:Null<Int> = values[1] == null ? null : Tools.getValue(values[1]);
+        // var length:Null<Float> = values[2] == null ? null : Tools.getValue(values[2]);
+        // var alt:Null<Bool> = values[3] == null ? null : Tools.getValue(values[3]);
+
+        // return new LegacyNote(time, data, length, alt);
+        return null;
+      default:
+        throw 'Expected property $name to be a note, but it was ${json.value}.';
+    }
   }
 }
diff --git a/source/funkin/data/DataWrite.hx b/source/funkin/data/DataWrite.hx
index 2ff7672da..41993107f 100644
--- a/source/funkin/data/DataWrite.hx
+++ b/source/funkin/data/DataWrite.hx
@@ -1,8 +1,17 @@
 package funkin.data;
 
+import funkin.util.SerializerUtil;
+
 /**
  * `json2object` has an annotation `@:jcustomwrite` which allows for custom serialization of values to be written to JSON.
  *
  * Functions must be of the signature `(T) -> String`, where `T` is the type of the property.
  */
-class DataWrite {}
+class DataWrite
+{
+  public static function dynamicValue(value:Dynamic):String
+  {
+    // Is this cheating? Yes. Do I care? No.
+    return SerializerUtil.toJSON(value);
+  }
+}
diff --git a/source/funkin/data/animation/AnimationData.hx b/source/funkin/data/animation/AnimationData.hx
index 2116109db..9765f784c 100644
--- a/source/funkin/data/animation/AnimationData.hx
+++ b/source/funkin/data/animation/AnimationData.hx
@@ -67,7 +67,6 @@ typedef UnnamedAnimationData =
    * ONLY for use by MultiSparrow characters.
    * @default The assetPath of the parent sprite
    */
-  @:default(null)
   @:optional
   var assetPath:Null<String>;
 
@@ -85,7 +84,7 @@ typedef UnnamedAnimationData =
    */
   @:default(false)
   @:optional
-  var looped:Null<Bool>;
+  var looped:Bool;
 
   /**
    * Whether the animation's sprites should be flipped horizontally.
diff --git a/source/funkin/data/level/LevelRegistry.hx b/source/funkin/data/level/LevelRegistry.hx
index d135e1241..75b0b11f6 100644
--- a/source/funkin/data/level/LevelRegistry.hx
+++ b/source/funkin/data/level/LevelRegistry.hx
@@ -47,6 +47,26 @@ class LevelRegistry extends BaseRegistry<Level, LevelData>
     return parser.value;
   }
 
+  /**
+   * Parse and validate the JSON data and produce the corresponding data object.
+   *
+   * NOTE: Must be implemented on the implementation class.
+   * @param contents The JSON as a string.
+   * @param fileName An optional file name for error reporting.
+   */
+  public function parseEntryDataRaw(contents:String, ?fileName:String):Null<LevelData>
+  {
+    var parser = new json2object.JsonParser<LevelData>();
+    parser.fromJson(contents, fileName);
+
+    if (parser.errors.length > 0)
+    {
+      printErrors(parser.errors, fileName);
+      return null;
+    }
+    return parser.value;
+  }
+
   function createScriptedEntry(clsName:String):Level
   {
     return ScriptedLevel.init(clsName, "unknown");
diff --git a/source/funkin/data/notestyle/NoteStyleRegistry.hx b/source/funkin/data/notestyle/NoteStyleRegistry.hx
index bb594bca4..da45da5f2 100644
--- a/source/funkin/data/notestyle/NoteStyleRegistry.hx
+++ b/source/funkin/data/notestyle/NoteStyleRegistry.hx
@@ -54,6 +54,26 @@ class NoteStyleRegistry extends BaseRegistry<NoteStyle, NoteStyleData>
     return parser.value;
   }
 
+  /**
+   * Parse and validate the JSON data and produce the corresponding data object.
+   *
+   * NOTE: Must be implemented on the implementation class.
+   * @param contents The JSON as a string.
+   * @param fileName An optional file name for error reporting.
+   */
+  public function parseEntryDataRaw(contents:String, ?fileName:String):Null<NoteStyleData>
+  {
+    var parser = new json2object.JsonParser<NoteStyleData>();
+    parser.fromJson(contents, fileName);
+
+    if (parser.errors.length > 0)
+    {
+      printErrors(parser.errors, fileName);
+      return null;
+    }
+    return parser.value;
+  }
+
   function createScriptedEntry(clsName:String):NoteStyle
   {
     return ScriptedNoteStyle.init(clsName, "unknown");
diff --git a/source/funkin/data/song/SongData.hx b/source/funkin/data/song/SongData.hx
index 59f8fcaf1..9340e46c9 100644
--- a/source/funkin/data/song/SongData.hx
+++ b/source/funkin/data/song/SongData.hx
@@ -1,11 +1,10 @@
 package funkin.data.song;
 
 import flixel.util.typeLimit.OneOfTwo;
-import funkin.play.song.SongMigrator;
-import funkin.play.song.SongValidator;
 import funkin.data.song.SongRegistry;
 import thx.semver.Version;
 
+@:nullSafety
 class SongMetadata
 {
   /**
@@ -44,35 +43,36 @@ 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 = 'default';
+  public var variation:String;
 
-  public function new(songName:String, artist:String, variation:String = 'default')
+  public function new(songName:String, artist:String, ?variation:String)
   {
-    this.version = SongMigrator.CHART_VERSION;
+    this.version = SongRegistry.SONG_METADATA_VERSION;
     this.songName = songName;
     this.artist = artist;
     this.timeFormat = 'ms';
     this.divisions = null;
     this.timeChanges = [new SongTimeChange(0, 100)];
     this.looped = false;
-    this.playData =
-      {
-        songVariations: [],
-        difficulties: ['normal'],
-
-        playableChars: ['bf' => new SongPlayableChar('gf', 'dad')],
-
-        stage: 'mainStage',
-        noteSkin: 'Normal'
-      };
+    this.playData = new SongPlayData();
+    this.playData.songVariations = [];
+    this.playData.difficulties = [];
+    this.playData.characters = new SongCharacterData('bf', 'gf', 'dad');
+    this.playData.stage = 'mainStage';
+    this.playData.noteSkin = 'funkin';
     this.generatedBy = SongRegistry.DEFAULT_GENERATEDBY;
     // Variation ID.
-    this.variation = variation;
+    this.variation = (variation == null) ? Constants.DEFAULT_VARIATION : variation;
   }
 
+  /**
+   * Create a copy of this SongMetadata with the same information.
+   * @param newVariation Set to a new variation ID to change the new metadata.
+   * @return The cloned SongMetadata
+   */
   public function clone(?newVariation:String = null):SongMetadata
   {
     var result:SongMetadata = new SongMetadata(this.songName, this.artist, newVariation == null ? this.variation : newVariation);
@@ -87,6 +87,22 @@ class SongMetadata
     return result;
   }
 
+  /**
+   * Serialize this SongMetadata into a JSON string.
+   * @return The JSON string.
+   */
+  public function serialize(pretty:Bool = true):String
+  {
+    var writer = new json2object.JsonWriter<SongMetadata>();
+    // I believe @:jignored should be iggnored by the writer?
+    // var output = this.clone();
+    // output.variation = null; // Not sure how to make a field optional on the reader and ignored on the writer.
+    return writer.write(this, pretty ? '  ' : null);
+  }
+
+  /**
+   * Produces a string representation suitable for debugging.
+   */
   public function toString():String
   {
     return 'SongMetadata(${this.songName} by ${this.artist}, variation ${this.variation})';
@@ -121,7 +137,6 @@ class SongTimeChange
    */
   @:optional
   @:alias("b")
-  // @:default(funkin.data.song.SongData.SongTimeChange.DEFAULT_BEAT_TIME)
   public var beatTime:Null<Float>;
 
   /**
@@ -168,6 +183,9 @@ class SongTimeChange
     this.beatTuplets = beatTuplets == null ? DEFAULT_BEAT_TUPLETS : beatTuplets;
   }
 
+  /**
+   * Produces a string representation suitable for debugging.
+   */
   public function toString():String
   {
     return 'SongTimeChange(${this.timeStamp}ms,${this.bpm}bpm)';
@@ -199,7 +217,7 @@ class SongMusicData
 
   @:optional
   @:default(false)
-  public var looped:Bool;
+  public var looped:Null<Bool>;
 
   // @:default(funkin.data.song.SongRegistry.DEFAULT_GENERATEDBY)
   public var generatedBy:String;
@@ -211,14 +229,14 @@ 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 = 'default';
+  public var variation:String;
 
   public function new(songName:String, artist:String, variation:String = 'default')
   {
-    this.version = SongMigrator.CHART_VERSION;
+    this.version = SongRegistry.SONG_CHART_DATA_VERSION;
     this.songName = songName;
     this.artist = artist;
     this.timeFormat = 'ms';
@@ -227,7 +245,7 @@ class SongMusicData
     this.looped = false;
     this.generatedBy = SongRegistry.DEFAULT_GENERATEDBY;
     // Variation ID.
-    this.variation = variation;
+    this.variation = variation == null ? Constants.DEFAULT_VARIATION : variation;
   }
 
   public function clone(?newVariation:String = null):SongMusicData
@@ -243,53 +261,106 @@ class SongMusicData
     return result;
   }
 
+  /**
+   * Produces a string representation suitable for debugging.
+   */
   public function toString():String
   {
     return 'SongMusicData(${this.songName} by ${this.artist}, variation ${this.variation})';
   }
 }
 
-typedef SongPlayData =
+class SongPlayData
 {
+  /**
+   * The variations this song has. The associated metadata files should exist.
+   */
   public var songVariations:Array<String>;
+
+  /**
+   * The difficulties contained in this song's chart file.
+   */
   public var difficulties:Array<String>;
 
   /**
-   * Keys are the player characters and the values give info on what opponent/GF/inst to use.
+   * The characters used by this song.
    */
-  public var playableChars:Map<String, SongPlayableChar>;
+  public var characters:SongCharacterData;
 
+  /**
+   * The stage used by this song.
+   */
   public var stage:String;
+
+  /**
+   * The note style used by this song.
+   * TODO: Rename to `noteStyle`? Renaming values is a breaking change to the metadata format.
+   */
   public var noteSkin:String;
+
+  /**
+   * The difficulty rating for this song as displayed in Freeplay.
+   * TODO: Adding this is a non-breaking change to the metadata format.
+   */
+  // public var rating:Int;
+
+  /**
+   * The album ID for the album to display in Freeplay.
+   * TODO: Adding this is a non-breaking change to the metadata format.
+   */
+  // public var album:String;
+
+  public function new() {}
+
+  /**
+   * Produces a string representation suitable for debugging.
+   */
+  public function toString():String
+  {
+    return 'SongPlayData(${this.songVariations}, ${this.difficulties})';
+  }
 }
 
-class SongPlayableChar
+/**
+ * Information about the characters used in this variation of the song.
+ * Create a new variation if you want to change the characters.
+ */
+class SongCharacterData
 {
-  @:alias('g')
+  @:optional
+  @:default('')
+  public var player:String = '';
+
   @:optional
   @:default('')
   public var girlfriend:String = '';
 
-  @:alias('o')
   @:optional
   @:default('')
   public var opponent:String = '';
 
-  @:alias('i')
   @:optional
   @:default('')
-  public var inst:String = '';
+  public var instrumental:String = '';
 
-  public function new(girlfriend:String = '', opponent:String = '', inst:String = '')
+  @:optional
+  @:default([])
+  public var altInstrumentals:Array<String> = [];
+
+  public function new(player:String = '', girlfriend:String = '', opponent:String = '', instrumental:String = '')
   {
+    this.player = player;
     this.girlfriend = girlfriend;
     this.opponent = opponent;
-    this.inst = inst;
+    this.instrumental = instrumental;
   }
 
+  /**
+   * Produces a string representation suitable for debugging.
+   */
   public function toString():String
   {
-    return 'SongPlayableChar(${this.girlfriend}, ${this.opponent}, ${this.inst})';
+    return 'SongCharacterData(${this.player}, ${this.girlfriend}, ${this.opponent}, ${this.instrumental}, [${this.altInstrumentals.join(', ')}])';
   }
 }
 
@@ -305,6 +376,12 @@ 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;
+
   public function new(scrollSpeed:Map<String, Float>, events:Array<SongEventData>, notes:Map<String, Array<SongNoteData>>)
   {
     this.version = SongRegistry.SONG_CHART_DATA_VERSION;
@@ -346,14 +423,21 @@ class SongChartData
     return value;
   }
 
-  public function getEvents():Array<SongEventData>
+  /**
+   * Convert this SongChartData into a JSON string.
+   */
+  public function serialize(pretty:Bool = true):String
   {
-    return this.events;
+    var writer = new json2object.JsonWriter<SongChartData>();
+    return writer.write(this, pretty ? '  ' : null);
   }
 
-  public function setEvents(value:Array<SongEventData>):Array<SongEventData>
+  /**
+   * Produces a string representation suitable for debugging.
+   */
+  public function toString():String
   {
-    return this.events = value;
+    return 'SongChartData(${this.events.length} events, ${this.notes.size()} difficulties, ${generatedBy})';
   }
 }
 
@@ -387,6 +471,7 @@ class SongEventData
   @:alias("v")
   @:optional
   @:jcustomparse(funkin.data.DataParse.dynamicValue)
+  @:jcustomwrite(funkin.data.DataWrite.dynamicValue)
   public var value:Dynamic = null;
 
   /**
@@ -484,6 +569,9 @@ class SongEventData
     return this.time <= other.time;
   }
 
+  /**
+   * Produces a string representation suitable for debugging.
+   */
   public function toString():String
   {
     return 'SongEventData(${this.time}ms, ${this.event}: ${this.value})';
@@ -703,6 +791,9 @@ class SongNoteData
     return this.time <= other.time;
   }
 
+  /**
+   * Produces a string representation suitable for debugging.
+   */
   public function toString():String
   {
     return 'SongNoteData(${this.time}ms, ' + (this.length > 0 ? '[${this.length}ms hold]' : '') + ' ${this.data}'
diff --git a/source/funkin/data/song/SongDataUtils.hx b/source/funkin/data/song/SongDataUtils.hx
index d15a2b19a..ee3dfe98c 100644
--- a/source/funkin/data/song/SongDataUtils.hx
+++ b/source/funkin/data/song/SongDataUtils.hx
@@ -8,6 +8,9 @@ import funkin.util.SerializerUtil;
 
 using Lambda;
 
+/**
+ * Utility functions for working with song data, including note data, event data, metadata, etc.
+ */
 class SongDataUtils
 {
   /**
@@ -18,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;
   }
 
   /**
@@ -33,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);
@@ -149,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);
 
@@ -167,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;
     }
   }
@@ -227,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 9bc1278c8..889fca707 100644
--- a/source/funkin/data/song/SongRegistry.hx
+++ b/source/funkin/data/song/SongRegistry.hx
@@ -1,6 +1,7 @@
 package funkin.data.song;
 
 import funkin.data.song.SongData;
+import funkin.data.song.migrator.SongData_v2_0_0.SongMetadata_v2_0_0;
 import funkin.data.song.SongData.SongChartData;
 import funkin.data.song.SongData.SongMetadata;
 import funkin.play.song.ScriptedSong;
@@ -8,6 +9,8 @@ import funkin.play.song.Song;
 import funkin.util.assets.DataAssets;
 import funkin.util.VersionUtil;
 
+using funkin.data.song.migrator.SongDataMigrator;
+
 class SongRegistry extends BaseRegistry<Song, SongMetadata>
 {
   /**
@@ -15,14 +18,18 @@ class SongRegistry extends BaseRegistry<Song, SongMetadata>
    * Handle breaking changes by incrementing this value
    * and adding migration to the `migrateStageData()` function.
    */
-  public static final SONG_METADATA_VERSION:thx.semver.Version = "2.0.0";
+  public static final SONG_METADATA_VERSION:thx.semver.Version = "2.1.0";
 
-  public static final SONG_METADATA_VERSION_RULE:thx.semver.VersionRule = "2.0.x";
+  public static final SONG_METADATA_VERSION_RULE:thx.semver.VersionRule = "2.1.x";
 
   public static final SONG_CHART_DATA_VERSION:thx.semver.Version = "2.0.0";
 
   public static final SONG_CHART_DATA_VERSION_RULE:thx.semver.VersionRule = "2.0.x";
 
+  public static final SONG_MUSIC_DATA_VERSION:thx.semver.Version = "2.0.0";
+
+  public static final SONG_MUSIC_DATA_VERSION_RULE:thx.semver.VersionRule = "2.0.x";
+
   public static var DEFAULT_GENERATEDBY(get, null):String;
 
   static function get_DEFAULT_GENERATEDBY():String
@@ -30,6 +37,10 @@ class SongRegistry extends BaseRegistry<Song, SongMetadata>
     return '${Constants.TITLE} - ${Constants.VERSION}';
   }
 
+  /**
+   * TODO: What if there was a Singleton macro which created static functions
+   * that redirected to the instance?
+   */
   public static final instance:SongRegistry = new SongRegistry();
 
   public function new()
@@ -101,12 +112,91 @@ class SongRegistry extends BaseRegistry<Song, SongMetadata>
     return parseEntryMetadata(id);
   }
 
-  public function parseEntryMetadata(id:String, variation:String = ""):Null<SongMetadata>
+  /**
+   * Parse, and validate the JSON data and produce the corresponding data object.
+   */
+  public function parseEntryDataRaw(contents:String, ?fileName:String = 'raw'):Null<SongMetadata>
   {
-    // JsonParser does not take type parameters,
-    // otherwise this function would be in BaseRegistry.
+    return parseEntryMetadataRaw(contents);
+  }
+
+  public function parseEntryMetadata(id:String, ?variation:String):Null<SongMetadata>
+  {
+    variation = variation == null ? Constants.DEFAULT_VARIATION : variation;
 
     var parser = new json2object.JsonParser<SongMetadata>();
+    switch (loadEntryMetadataFile(id, variation))
+    {
+      case {fileName: fileName, contents: contents}:
+        parser.fromJson(contents, fileName);
+      default:
+        return null;
+    }
+
+    if (parser.errors.length > 0)
+    {
+      printErrors(parser.errors, id);
+      return null;
+    }
+    return cleanMetadata(parser.value, variation);
+  }
+
+  public function parseEntryMetadataRaw(contents:String, ?fileName:String = 'raw', ?variation:String):Null<SongMetadata>
+  {
+    variation = variation == null ? Constants.DEFAULT_VARIATION : variation;
+
+    var parser = new json2object.JsonParser<SongMetadata>();
+    parser.fromJson(contents, fileName);
+
+    if (parser.errors.length > 0)
+    {
+      printErrors(parser.errors, fileName);
+      return null;
+    }
+    return cleanMetadata(parser.value, variation);
+  }
+
+  public function parseEntryMetadataWithMigration(id:String, variation:String, version:thx.semver.Version):Null<SongMetadata>
+  {
+    variation = variation == null ? Constants.DEFAULT_VARIATION : variation;
+
+    // If a version rule is not specified, do not check against it.
+    if (SONG_METADATA_VERSION_RULE == null || VersionUtil.validateVersion(version, SONG_METADATA_VERSION_RULE))
+    {
+      return parseEntryMetadata(id, variation);
+    }
+    else if (VersionUtil.validateVersion(version, "2.0.x"))
+    {
+      return parseEntryMetadata_v2_0_0(id, variation);
+    }
+    else
+    {
+      throw '[${registryId}] Metadata entry ${id}:${variation} does not support migration to version ${SONG_METADATA_VERSION_RULE}.';
+    }
+  }
+
+  public function parseEntryMetadataRawWithMigration(contents:String, ?fileName:String = 'raw', version:thx.semver.Version):Null<SongMetadata>
+  {
+    // If a version rule is not specified, do not check against it.
+    if (SONG_METADATA_VERSION_RULE == null || VersionUtil.validateVersion(version, SONG_METADATA_VERSION_RULE))
+    {
+      return parseEntryMetadataRaw(contents, fileName);
+    }
+    else if (VersionUtil.validateVersion(version, "2.0.x"))
+    {
+      return parseEntryMetadataRaw_v2_0_0(contents, fileName);
+    }
+    else
+    {
+      throw '[${registryId}] Metadata entry "${fileName}" does not support migration to version ${SONG_METADATA_VERSION_RULE}.';
+    }
+  }
+
+  function parseEntryMetadata_v2_0_0(id:String, ?variation:String):Null<SongMetadata>
+  {
+    variation = variation == null ? Constants.DEFAULT_VARIATION : variation;
+
+    var parser = new json2object.JsonParser<SongMetadata_v2_0_0>();
     switch (loadEntryMetadataFile(id))
     {
       case {fileName: fileName, contents: contents}:
@@ -114,6 +204,39 @@ class SongRegistry extends BaseRegistry<Song, SongMetadata>
       default:
         return null;
     }
+    if (parser.errors.length > 0)
+    {
+      printErrors(parser.errors, id);
+      return null;
+    }
+    return parser.value.migrate();
+  }
+
+  function parseEntryMetadataRaw_v2_0_0(contents:String, ?fileName:String = 'raw'):Null<SongMetadata>
+  {
+    var parser = new json2object.JsonParser<SongMetadata_v2_0_0>();
+    parser.fromJson(contents, fileName);
+
+    if (parser.errors.length > 0)
+    {
+      printErrors(parser.errors, fileName);
+      return null;
+    }
+    return parser.value.migrate();
+  }
+
+  public function parseMusicData(id:String, ?variation:String):Null<SongMusicData>
+  {
+    variation = variation == null ? Constants.DEFAULT_VARIATION : variation;
+
+    var parser = new json2object.JsonParser<SongMusicData>();
+    switch (loadMusicDataFile(id, variation))
+    {
+      case {fileName: fileName, contents: contents}:
+        parser.fromJson(contents, fileName);
+      default:
+        return null;
+    }
 
     if (parser.errors.length > 0)
     {
@@ -123,48 +246,54 @@ class SongRegistry extends BaseRegistry<Song, SongMetadata>
     return parser.value;
   }
 
-  public function parseEntryMetadataWithMigration(id:String, variation:String = '', version:thx.semver.Version):Null<SongMetadata>
+  public function parseMusicDataRaw(contents:String, ?fileName:String = 'raw'):Null<SongMusicData>
   {
-    // If a version rule is not specified, do not check against it.
-    if (SONG_METADATA_VERSION_RULE == null || VersionUtil.validateVersion(version, SONG_METADATA_VERSION_RULE))
+    var parser = new json2object.JsonParser<SongMusicData>();
+    parser.fromJson(contents, fileName);
+
+    if (parser.errors.length > 0)
     {
-      return parseEntryMetadata(id);
+      printErrors(parser.errors, fileName);
+      return null;
+    }
+    return parser.value;
+  }
+
+  public function parseMusicDataWithMigration(id:String, ?variation:String, version:thx.semver.Version):Null<SongMusicData>
+  {
+    variation = variation == null ? Constants.DEFAULT_VARIATION : variation;
+
+    // If a version rule is not specified, do not check against it.
+    if (SONG_MUSIC_DATA_VERSION_RULE == null || VersionUtil.validateVersion(version, SONG_MUSIC_DATA_VERSION_RULE))
+    {
+      return parseMusicData(id, variation);
     }
     else
     {
-      throw '[${registryId}] Metadata entry ${id}:${variation == '' ? 'default' : variation} does not support migration to version ${SONG_METADATA_VERSION_RULE}.';
+      throw '[${registryId}] Chart entry ${id}:${variation} does not support migration to version ${SONG_CHART_DATA_VERSION_RULE}.';
     }
   }
 
-  public function parseMusicData(id:String, variation:String = ""):Null<SongMusicData>
+  public function parseMusicDataRawWithMigration(contents:String, ?fileName:String = 'raw', version:thx.semver.Version):Null<SongMusicData>
   {
-    // JsonParser does not take type parameters,
-    // otherwise this function would be in BaseRegistry.
-
-    var parser = new json2object.JsonParser<SongMusicData>();
-    switch (loadMusicDataFile(id))
+    // If a version rule is not specified, do not check against it.
+    if (SONG_MUSIC_DATA_VERSION_RULE == null || VersionUtil.validateVersion(version, SONG_MUSIC_DATA_VERSION_RULE))
     {
-      case {fileName: fileName, contents: contents}:
-        parser.fromJson(contents, fileName);
-      default:
-        return null;
+      return parseMusicDataRaw(contents, fileName);
     }
-
-    if (parser.errors.length > 0)
+    else
     {
-      printErrors(parser.errors, id);
-      return null;
+      throw '[${registryId}] Chart entry "$fileName" does not support migration to version ${SONG_CHART_DATA_VERSION_RULE}.';
     }
-    return parser.value;
   }
 
-  public function parseEntryChartData(id:String, variation:String = ''):Null<SongChartData>
+  public function parseEntryChartData(id:String, ?variation:String):Null<SongChartData>
   {
-    // JsonParser does not take type parameters,
-    // otherwise this function would be in BaseRegistry.
+    variation = variation == null ? Constants.DEFAULT_VARIATION : variation;
+
     var parser = new json2object.JsonParser<SongChartData>();
 
-    switch (loadEntryChartFile(id))
+    switch (loadEntryChartFile(id, variation))
     {
       case {fileName: fileName, contents: contents}:
         parser.fromJson(contents, fileName);
@@ -177,11 +306,28 @@ class SongRegistry extends BaseRegistry<Song, SongMetadata>
       printErrors(parser.errors, id);
       return null;
     }
-    return parser.value;
+    return cleanChartData(parser.value, variation);
   }
 
-  public function parseEntryChartDataWithMigration(id:String, variation:String = '', version:thx.semver.Version):Null<SongChartData>
+  public function parseEntryChartDataRaw(contents:String, ?fileName:String = 'raw', ?variation:String):Null<SongChartData>
   {
+    variation = variation == null ? Constants.DEFAULT_VARIATION : variation;
+
+    var parser = new json2object.JsonParser<SongChartData>();
+    parser.fromJson(contents, fileName);
+
+    if (parser.errors.length > 0)
+    {
+      printErrors(parser.errors, fileName);
+      return null;
+    }
+    return cleanChartData(parser.value, variation);
+  }
+
+  public function parseEntryChartDataWithMigration(id:String, ?variation:String, version:thx.semver.Version):Null<SongChartData>
+  {
+    variation = variation == null ? Constants.DEFAULT_VARIATION : variation;
+
     // If a version rule is not specified, do not check against it.
     if (SONG_CHART_DATA_VERSION_RULE == null || VersionUtil.validateVersion(version, SONG_CHART_DATA_VERSION_RULE))
     {
@@ -189,7 +335,20 @@ class SongRegistry extends BaseRegistry<Song, SongMetadata>
     }
     else
     {
-      throw '[${registryId}] Chart entry ${id}:${variation == '' ? 'default' : variation} does not support migration to version ${SONG_CHART_DATA_VERSION_RULE}.';
+      throw '[${registryId}] Chart entry ${id}:${variation} does not support migration to version ${SONG_CHART_DATA_VERSION_RULE}.';
+    }
+  }
+
+  public function parseEntryChartDataRawWithMigration(contents:String, ?fileName:String = 'raw', version:thx.semver.Version):Null<SongChartData>
+  {
+    // If a version rule is not specified, do not check against it.
+    if (SONG_CHART_DATA_VERSION_RULE == null || VersionUtil.validateVersion(version, SONG_CHART_DATA_VERSION_RULE))
+    {
+      return parseEntryChartDataRaw(contents, fileName);
+    }
+    else
+    {
+      throw '[${registryId}] Chart entry "${fileName}" does not support migration to version ${SONG_CHART_DATA_VERSION_RULE}.';
     }
   }
 
@@ -203,9 +362,10 @@ class SongRegistry extends BaseRegistry<Song, SongMetadata>
     return ScriptedSong.listScriptClasses();
   }
 
-  function loadEntryMetadataFile(id:String, variation:String = ''):Null<BaseRegistry.JsonFile>
+  function loadEntryMetadataFile(id:String, ?variation:String):Null<BaseRegistry.JsonFile>
   {
-    var entryFilePath:String = Paths.json('$dataFilePath/$id/$id${variation == '' ? '' : '-$variation'}-metadata');
+    variation = variation == null ? Constants.DEFAULT_VARIATION : variation;
+    var entryFilePath:String = Paths.json('$dataFilePath/$id/$id-metadata${variation == Constants.DEFAULT_VARIATION ? '' : '-$variation'}');
     if (!openfl.Assets.exists(entryFilePath)) return null;
     var rawJson:Null<String> = openfl.Assets.getText(entryFilePath);
     if (rawJson == null) return null;
@@ -213,9 +373,10 @@ class SongRegistry extends BaseRegistry<Song, SongMetadata>
     return {fileName: entryFilePath, contents: rawJson};
   }
 
-  function loadMusicDataFile(id:String, variation:String = ''):Null<BaseRegistry.JsonFile>
+  function loadMusicDataFile(id:String, ?variation:String):Null<BaseRegistry.JsonFile>
   {
-    var entryFilePath:String = Paths.file('music/$id/$id${variation == '' ? '' : '-$variation'}-metadata.json');
+    variation = variation == null ? Constants.DEFAULT_VARIATION : variation;
+    var entryFilePath:String = Paths.file('music/$id/$id-metadata${variation == Constants.DEFAULT_VARIATION ? '' : '-$variation'}.json');
     if (!openfl.Assets.exists(entryFilePath)) return null;
     var rawJson:String = openfl.Assets.getText(entryFilePath);
     if (rawJson == null) return null;
@@ -223,9 +384,10 @@ class SongRegistry extends BaseRegistry<Song, SongMetadata>
     return {fileName: entryFilePath, contents: rawJson};
   }
 
-  function loadEntryChartFile(id:String, variation:String = ''):Null<BaseRegistry.JsonFile>
+  function loadEntryChartFile(id:String, ?variation:String):Null<BaseRegistry.JsonFile>
   {
-    var entryFilePath:String = Paths.json('$dataFilePath/$id/$id${variation == '' ? '' : '-$variation'}-chart');
+    variation = variation == null ? Constants.DEFAULT_VARIATION : variation;
+    var entryFilePath:String = Paths.json('$dataFilePath/$id/$id-chart${variation == Constants.DEFAULT_VARIATION ? '' : '-$variation'}');
     if (!openfl.Assets.exists(entryFilePath)) return null;
     var rawJson:String = openfl.Assets.getText(entryFilePath);
     if (rawJson == null) return null;
@@ -233,20 +395,36 @@ class SongRegistry extends BaseRegistry<Song, SongMetadata>
     return {fileName: entryFilePath, contents: rawJson};
   }
 
-  public function fetchEntryMetadataVersion(id:String, variation:String = ''):Null<thx.semver.Version>
+  public function fetchEntryMetadataVersion(id:String, ?variation:String):Null<thx.semver.Version>
   {
+    variation = variation == null ? Constants.DEFAULT_VARIATION : variation;
     var entryStr:Null<String> = loadEntryMetadataFile(id, variation)?.contents;
     var entryVersion:thx.semver.Version = VersionUtil.getVersionFromJSON(entryStr);
     return entryVersion;
   }
 
-  public function fetchEntryChartVersion(id:String, variation:String = ''):Null<thx.semver.Version>
+  public function fetchEntryChartVersion(id:String, ?variation:String):Null<thx.semver.Version>
   {
+    variation = variation == null ? Constants.DEFAULT_VARIATION : variation;
     var entryStr:Null<String> = loadEntryChartFile(id, variation)?.contents;
     var entryVersion:thx.semver.Version = VersionUtil.getVersionFromJSON(entryStr);
     return entryVersion;
   }
 
+  function cleanMetadata(metadata:SongMetadata, variation:String):SongMetadata
+  {
+    metadata.variation = variation;
+
+    return metadata;
+  }
+
+  function cleanChartData(chartData:SongChartData, variation:String):SongChartData
+  {
+    chartData.variation = variation;
+
+    return chartData;
+  }
+
   /**
    * A list of all the story weeks from the base game, in order.
    * TODO: Should this be hardcoded?
diff --git a/source/funkin/data/song/importer/FNFLegacyData.hx b/source/funkin/data/song/importer/FNFLegacyData.hx
new file mode 100644
index 000000000..5b75368c9
--- /dev/null
+++ b/source/funkin/data/song/importer/FNFLegacyData.hx
@@ -0,0 +1,124 @@
+package funkin.data.song.importer;
+
+import haxe.ds.Either;
+
+/**
+ * A data structure representing a song in the old chart format.
+ * This only works for charts compatible with Week 7, so you'll need a custom program
+ * to handle importing charts from mods or other engines.
+ */
+class FNFLegacyData
+{
+  public var song:LegacySongData;
+}
+
+class LegacySongData
+{
+  public var player1:String; // Boyfriend
+  public var player2:String; // Opponent
+
+  @:jcustomparse(funkin.data.DataParse.eitherLegacyScrollSpeeds)
+  public var speed:Either<Float, LegacyScrollSpeeds>;
+  public var stageDefault:String;
+  public var bpm:Float;
+
+  @:jcustomparse(funkin.data.DataParse.eitherLegacyNoteData)
+  public var notes:Either<Array<LegacyNoteSection>, LegacyNoteData>;
+  public var song:String; // Song name
+
+  public function new() {}
+
+  public function toString():String
+  {
+    var notesStr:String = switch (notes)
+    {
+      case Left(sections): 'single difficulty w/ ${sections.length} sections';
+      case Right(data):
+        var difficultyCount:Int = 0;
+        if (data.easy != null) difficultyCount++;
+        if (data.normal != null) difficultyCount++;
+        if (data.hard != null) difficultyCount++;
+        '${difficultyCount} difficulties';
+    };
+    return 'LegacySongData($player1, $player2, $notesStr)';
+  }
+}
+
+typedef LegacyScrollSpeeds =
+{
+  public var ?easy:Float;
+  public var ?normal:Float;
+  public var ?hard:Float;
+};
+
+typedef LegacyNoteData =
+{
+  /**
+   * The easy difficulty.
+   */
+  public var ?easy:Array<LegacyNoteSection>;
+
+  /**
+   * The normal difficulty.
+   */
+  public var ?normal:Array<LegacyNoteSection>;
+
+  /**
+   * The hard difficulty.
+   */
+  public var ?hard:Array<LegacyNoteSection>;
+};
+
+typedef LegacyNoteSection =
+{
+  /**
+   * Whether the section is a must-hit section.
+   * If true, 0-3 are boyfriends notes, 4-7 are opponents notes.
+   * If false, 0-3 are opponents notes, 4-7 are boyfriends notes.
+   */
+  public var mustHitSection:Bool;
+
+  /**
+   * Array of note data:
+   * - Direction
+   * - Time (ms)
+   * - Sustain Duration (ms)
+   * - Note kind (true = "alt", or string)
+   */
+  public var sectionNotes:Array<LegacyNote>;
+
+  public var ?typeOfSection:Int;
+
+  public var ?lengthInSteps:Int;
+
+  // BPM changes
+  public var ?changeBPM:Bool;
+  public var ?bpm:Float;
+}
+
+/**
+ * Notes in the old format are stored as an Array<Dynamic>
+ * We use a custom parser to manage this.
+ */
+@:jcustomparse(funkin.data.DataParse.legacyNote)
+class LegacyNote
+{
+  public var time:Float;
+  public var data:Int;
+  public var length:Float;
+  public var alt:Bool;
+
+  public function new(time:Float, data:Int, ?length:Float, ?alt:Bool)
+  {
+    this.time = time;
+    this.data = data;
+
+    this.length = length ?? 0.0;
+    this.alt = alt ?? false;
+  }
+
+  public inline function getKind():String
+  {
+    return this.alt ? 'alt' : 'normal';
+  }
+}
diff --git a/source/funkin/data/song/importer/FNFLegacyImporter.hx b/source/funkin/data/song/importer/FNFLegacyImporter.hx
new file mode 100644
index 000000000..ee68513dc
--- /dev/null
+++ b/source/funkin/data/song/importer/FNFLegacyImporter.hx
@@ -0,0 +1,202 @@
+package funkin.data.song.importer; // import is a reserved word dumbass
+
+import funkin.data.song.SongData.SongMetadata;
+import funkin.data.song.SongData.SongChartData;
+import funkin.data.song.SongData.SongCharacterData;
+import funkin.data.song.SongData.SongEventData;
+import funkin.data.song.SongData.SongNoteData;
+import funkin.data.song.SongData.SongTimeChange;
+import funkin.data.song.importer.FNFLegacyData;
+import funkin.data.song.importer.FNFLegacyData.LegacyNoteSection;
+
+class FNFLegacyImporter
+{
+  public static function parseLegacyDataRaw(input:String, fileName:String = 'raw'):FNFLegacyData
+  {
+    var parser = new json2object.JsonParser<FNFLegacyData>();
+    parser.fromJson(input, fileName);
+
+    if (parser.errors.length > 0)
+    {
+      trace('[FNFLegacyImporter] Error parsing JSON data from ' + fileName + ':');
+      for (error in parser.errors)
+        DataError.printError(error);
+      return null;
+    }
+    return parser.value;
+  }
+
+  /**
+   * @param data The raw parsed JSON data to migrate, as a Dynamic.
+   * @param difficulty
+   * @return SongMetadata
+   */
+  public static function migrateMetadata(songData:FNFLegacyData, difficulty:String = 'normal'):SongMetadata
+  {
+    trace('Migrating song metadata from FNF Legacy.');
+
+    var songMetadata:SongMetadata = new SongMetadata('Import', 'Kawai Sprite', 'default');
+
+    var hadError:Bool = false;
+
+    // Set generatedBy string for debugging.
+    songMetadata.generatedBy = 'Chart Editor Import (FNF Legacy)';
+
+    songMetadata.playData.stage = songData?.song?.stageDefault ?? 'mainStage';
+    songMetadata.songName = songData?.song?.song ?? 'Import';
+    songMetadata.playData.difficulties = [];
+
+    if (songData?.song?.notes != null)
+    {
+      switch (songData.song.notes)
+      {
+        case Left(notes):
+          // One difficulty of notes.
+          songMetadata.playData.difficulties.push(difficulty);
+        case Right(difficulties):
+          if (difficulties.easy != null) songMetadata.playData.difficulties.push('easy');
+          if (difficulties.normal != null) songMetadata.playData.difficulties.push('normal');
+          if (difficulties.hard != null) songMetadata.playData.difficulties.push('hard');
+      }
+    }
+
+    songMetadata.playData.songVariations = [];
+
+    songMetadata.timeChanges = rebuildTimeChanges(songData);
+
+    songMetadata.playData.characters = new SongCharacterData(songData?.song?.player1 ?? 'bf', 'gf', songData?.song?.player2 ?? 'dad', 'mom');
+
+    return songMetadata;
+  }
+
+  public static function migrateChartData(songData:FNFLegacyData, difficulty:String = 'normal'):SongChartData
+  {
+    trace('Migrating song chart data from FNF Legacy.');
+
+    var songChartData:SongChartData = new SongChartData([difficulty => 1.0], [], [difficulty => []]);
+
+    if (songData?.song?.notes != null)
+    {
+      switch (songData.song.notes)
+      {
+        case Left(notes):
+          // One difficulty of notes.
+          songChartData.notes.set(difficulty, migrateNoteSections(notes));
+        case Right(difficulties):
+          var baseDifficulty = null;
+          if (difficulties.easy != null) songChartData.notes.set('easy', migrateNoteSections(difficulties.easy));
+          if (difficulties.normal != null) songChartData.notes.set('normal', migrateNoteSections(difficulties.normal));
+          if (difficulties.hard != null) songChartData.notes.set('hard', migrateNoteSections(difficulties.hard));
+      }
+    }
+
+    // Import event data.
+    songChartData.events = rebuildEventData(songData);
+
+    switch (songData.song.speed)
+    {
+      case Left(speed):
+        // All difficulties will use the one scroll speed.
+        songChartData.scrollSpeed.set('default', speed);
+      case Right(speeds):
+        if (speeds.easy != null) songChartData.scrollSpeed.set('easy', speeds.easy);
+        if (speeds.normal != null) songChartData.scrollSpeed.set('normal', speeds.normal);
+        if (speeds.hard != null) songChartData.scrollSpeed.set('hard', speeds.hard);
+    }
+
+    return songChartData;
+  }
+
+  /**
+   * FNF Legacy doesn't have song events, but without them the song won't look right,
+   * so we insert camera events when the character changes.
+   */
+  static function rebuildEventData(songData:FNFLegacyData):Array<SongEventData>
+  {
+    var result:Array<SongEventData> = [];
+
+    var noteSections = [];
+    switch (songData.song.notes)
+    {
+      case Left(notes):
+        // All difficulties will use the one scroll speed.
+        noteSections = notes;
+      case Right(difficulties):
+        if (difficulties.normal != null) noteSections = difficulties.normal;
+        if (difficulties.hard != null) noteSections = difficulties.normal;
+        if (difficulties.easy != null) noteSections = difficulties.normal;
+    }
+
+    if (noteSections == null || noteSections.length == 0) return result;
+
+    // Add camera events.
+    var lastSectionWasMustHit:Null<Bool> = null;
+    for (section in noteSections)
+    {
+      // Skip empty sections.
+      if (section.sectionNotes.length == 0) continue;
+
+      if (section.mustHitSection != lastSectionWasMustHit)
+      {
+        lastSectionWasMustHit = section.mustHitSection;
+
+        var firstNote:LegacyNote = section.sectionNotes[0];
+
+        result.push(new SongEventData(firstNote.time, 'FocusCamera', {char: section.mustHitSection ? 0 : 1}));
+      }
+    }
+
+    return result;
+  }
+
+  /**
+   * Port over time changes from FNF Legacy.
+   * If a section contains a BPM change, it will be applied at the timestamp of the first note in that section.
+   */
+  static function rebuildTimeChanges(songData:FNFLegacyData):Array<SongTimeChange>
+  {
+    var result:Array<SongTimeChange> = [];
+
+    result.push(new SongTimeChange(0, songData?.song?.bpm ?? Constants.DEFAULT_BPM));
+
+    var noteSections = [];
+    switch (songData.song.notes)
+    {
+      case Left(notes):
+        // All difficulties will use the one scroll speed.
+        noteSections = notes;
+      case Right(difficulties):
+        if (difficulties.normal != null) noteSections = difficulties.normal;
+        if (difficulties.hard != null) noteSections = difficulties.normal;
+        if (difficulties.easy != null) noteSections = difficulties.normal;
+    }
+
+    if (noteSections == null || noteSections.length == 0) return result;
+
+    for (noteSection in noteSections)
+    {
+      if (noteSection.changeBPM ?? false)
+      {
+        var firstNote:LegacyNote = noteSection.sectionNotes[0];
+        if (firstNote != null) result.push(new SongTimeChange(firstNote.time, noteSection.bpm));
+      }
+    }
+
+    return result;
+  }
+
+  static function migrateNoteSections(input:Array<LegacyNoteSection>):Array<SongNoteData>
+  {
+    var result:Array<SongNoteData> = [];
+
+    for (section in input)
+    {
+      for (note in section.sectionNotes)
+      {
+        result.push(new SongNoteData(note.time, note.data, note.length, note.getKind()));
+      }
+    }
+
+    return result;
+  }
+}
diff --git a/source/funkin/data/song/migrator/SongDataMigrator.hx b/source/funkin/data/song/migrator/SongDataMigrator.hx
new file mode 100644
index 000000000..b5e08c832
--- /dev/null
+++ b/source/funkin/data/song/migrator/SongDataMigrator.hx
@@ -0,0 +1,66 @@
+package funkin.data.song.migrator;
+
+import funkin.data.song.SongData.SongMetadata;
+import funkin.data.song.SongData.SongPlayData;
+import funkin.data.song.SongData.SongCharacterData;
+import funkin.data.song.migrator.SongData_v2_0_0.SongMetadata_v2_0_0;
+import funkin.data.song.migrator.SongData_v2_0_0.SongPlayData_v2_0_0;
+import funkin.data.song.migrator.SongData_v2_0_0.SongPlayableChar_v2_0_0;
+
+/**
+ * This class contains functions to migrate older data formats to the current one.
+ *
+ * Utilizes static extensions with overloaded inline functions to make migration as easy as `.migrate()`.
+ * @see https://try.haxe.org/#e1c1cf22
+ */
+class SongDataMigrator
+{
+  public static overload extern inline function migrate(input:SongData_v2_0_0.SongMetadata_v2_0_0):SongMetadata
+  {
+    return migrate_SongMetadata_v2_0_0(input);
+  }
+
+  public static function migrate_SongMetadata_v2_0_0(input:SongData_v2_0_0.SongMetadata_v2_0_0):SongMetadata
+  {
+    var result:SongMetadata = new SongMetadata(input.songName, input.artist, input.variation);
+    result.version = input.version;
+    result.timeFormat = input.timeFormat;
+    result.divisions = input.divisions;
+    result.timeChanges = input.timeChanges;
+    result.looped = input.looped;
+    result.playData = migrate_SongPlayData_v2_0_0(input.playData);
+    result.generatedBy = input.generatedBy;
+
+    return result;
+  }
+
+  public static overload extern inline function migrate(input:SongData_v2_0_0.SongPlayData_v2_0_0):SongPlayData
+  {
+    return migrate_SongPlayData_v2_0_0(input);
+  }
+
+  public static function migrate_SongPlayData_v2_0_0(input:SongData_v2_0_0.SongPlayData_v2_0_0):SongPlayData
+  {
+    var result:SongPlayData = new SongPlayData();
+    result.songVariations = input.songVariations;
+    result.difficulties = input.difficulties;
+    result.stage = input.stage;
+    result.noteSkin = input.noteSkin;
+
+    // Fetch the first playable character and migrate it.
+    var firstCharKey:Null<String> = input.playableChars.size() == 0 ? null : input.playableChars.keys().array()[0];
+    var firstCharData:Null<SongPlayableChar_v2_0_0> = input.playableChars.get(firstCharKey);
+
+    if (firstCharData == null)
+    {
+      // Fill in a default playable character.
+      result.characters = new SongCharacterData('bf', 'gf', 'dad');
+    }
+    else
+    {
+      result.characters = new SongCharacterData(firstCharKey, firstCharData.girlfriend, firstCharData.opponent, firstCharData.inst);
+    }
+
+    return result;
+  }
+}
diff --git a/source/funkin/data/song/migrator/SongData_v2_0_0.hx b/source/funkin/data/song/migrator/SongData_v2_0_0.hx
new file mode 100644
index 000000000..935e7349c
--- /dev/null
+++ b/source/funkin/data/song/migrator/SongData_v2_0_0.hx
@@ -0,0 +1,122 @@
+package funkin.data.song.migrator;
+
+import thx.semver.Version;
+import funkin.data.song.SongData;
+
+class SongMetadata_v2_0_0
+{
+  // ==========
+  // MODIFIED VALUES
+  // ===========
+
+  /**
+   * In metadata `v2.1.0`, `SongPlayData` was refactored.
+   */
+  public var playData:SongPlayData_v2_0_0;
+
+  /**
+   * In metadata `v2.1.0`, `variation` was set to `ignore` when writing.
+   */
+  @:optional
+  @:default('default')
+  public var variation:String;
+
+  // ==========
+  // UNMODIFIED VALUES
+  // ==========
+  public var version:Version;
+
+  @:default("Unknown")
+  public var songName:String;
+
+  @:default("Unknown")
+  public var artist:String;
+
+  @:optional
+  @:default(96)
+  public var divisions:Null<Int>; // Optional field
+
+  @:optional
+  @:default(false)
+  public var looped:Bool;
+
+  public var generatedBy:String;
+
+  public var timeFormat:SongData.SongTimeFormat;
+
+  public var timeChanges:Array<SongData.SongTimeChange>;
+
+  public function new() {}
+
+  /**
+   * Produces a string representation suitable for debugging.
+   */
+  public function toString():String
+  {
+    return 'SongMetadata[LEGACY:v2.0.0](${this.songName} by ${this.artist}, variation ${this.variation})';
+  }
+}
+
+class SongPlayData_v2_0_0
+{
+  // ==========
+  // MODIFIED VALUES
+  // ===========
+
+  /**
+   * In metadata version `v2.1.0`, this was refactored to a single `SongCharacterData` object.
+   */
+  public var playableChars:Map<String, SongPlayableChar_v2_0_0>;
+
+  // ==========
+  // UNMODIFIED VALUES
+  // ==========
+  public var songVariations:Array<String>;
+  public var difficulties:Array<String>;
+
+  public var stage:String;
+  public var noteSkin:String;
+
+  public function new() {}
+
+  /**
+   * Produces a string representation suitable for debugging.
+   */
+  public function toString():String
+  {
+    return 'SongPlayData[LEGACY:v2.0.0](${this.songVariations}, ${this.difficulties})';
+  }
+}
+
+class SongPlayableChar_v2_0_0
+{
+  @:alias('g')
+  @:optional
+  @:default('')
+  public var girlfriend:String = '';
+
+  @:alias('o')
+  @:optional
+  @:default('')
+  public var opponent:String = '';
+
+  @:alias('i')
+  @:optional
+  @:default('')
+  public var inst:String = '';
+
+  public function new(girlfriend:String = '', opponent:String = '', inst:String = '')
+  {
+    this.girlfriend = girlfriend;
+    this.opponent = opponent;
+    this.inst = inst;
+  }
+
+  /**
+   * Produces a string representation suitable for debugging.
+   */
+  public function toString():String
+  {
+    return 'SongPlayableChar[LEGACY:v2.0.0](${this.girlfriend}, ${this.opponent}, ${this.inst})';
+  }
+}
diff --git a/source/funkin/freeplayStuff/BGScrollingText.hx b/source/funkin/freeplayStuff/BGScrollingText.hx
index 9fa6dd49b..586f83822 100644
--- a/source/funkin/freeplayStuff/BGScrollingText.hx
+++ b/source/funkin/freeplayStuff/BGScrollingText.hx
@@ -7,6 +7,7 @@ import flixel.math.FlxMath;
 import flixel.text.FlxText;
 import flixel.util.FlxColor;
 import flixel.util.FlxSort;
+import flixel.util.FlxTimer;
 
 // its kinda like marqeee html lol!
 class BGScrollingText extends FlxSpriteGroup
@@ -16,36 +17,53 @@ class BGScrollingText extends FlxSpriteGroup
   public var widthShit:Float = FlxG.width;
   public var placementOffset:Float = 20;
   public var speed:Float = 1;
+  public var size(default, set):Int = 48;
 
   public var funnyColor(default, set):Int = 0xFFFFFFFF;
 
-  public function new(x:Float, y:Float, text:String, widthShit:Float = 100)
+  public function new(x:Float, y:Float, text:String, widthShit:Float = 100, ?bold:Bool = false, ?size:Int = 48)
   {
     super(x, y);
 
     this.widthShit = widthShit;
+    if (size != null) this.size = size;
 
     grpTexts = new FlxTypedSpriteGroup<FlxText>();
     add(grpTexts);
 
-    var testText:FlxText = new FlxText(0, 0, 0, text, 48);
+    var testText:FlxText = new FlxText(0, 0, 0, text, this.size);
     testText.font = "5by7";
+    testText.bold = bold;
     testText.updateHitbox();
     grpTexts.add(testText);
 
-    var needed:Int = Math.ceil(widthShit / testText.frameWidth);
+    var needed:Int = Math.ceil(widthShit / testText.frameWidth) + 1;
 
     for (i in 0...needed)
     {
       var lmfao:Int = i + 1;
 
-      var coolText:FlxText = new FlxText((lmfao * testText.frameWidth) + (lmfao * 20), 0, 0, text, 48);
+      var coolText:FlxText = new FlxText((lmfao * testText.frameWidth) + (lmfao * 20), 0, 0, text, this.size);
+
       coolText.font = "5by7";
+      coolText.bold = bold;
       coolText.updateHitbox();
       grpTexts.add(coolText);
     }
   }
 
+  function set_size(value:Int):Int
+  {
+    if (grpTexts != null)
+    {
+      grpTexts.forEach(function(txt:FlxText) {
+        txt.size = value;
+      });
+    }
+    this.size = value;
+    return value;
+  }
+
   function set_funnyColor(col:Int):Int
   {
     grpTexts.forEach(function(txt) {
@@ -55,7 +73,7 @@ class BGScrollingText extends FlxSpriteGroup
     return col;
   }
 
-  override function update(elapsed:Float)
+  override public function update(elapsed:Float)
   {
     for (txt in grpTexts.group)
     {
@@ -66,14 +84,16 @@ class BGScrollingText extends FlxSpriteGroup
         if (txt.x < -txt.frameWidth)
         {
           txt.x = grpTexts.group.members[grpTexts.length - 1].x + grpTexts.group.members[grpTexts.length - 1].frameWidth + placementOffset;
+
           sortTextShit();
         }
       }
       else
       {
-        if (txt.x > widthShit)
+        if (txt.x > txt.frameWidth * 2)
         {
           txt.x = grpTexts.group.members[0].x - grpTexts.group.members[0].frameWidth - placementOffset;
+
           sortTextShit();
         }
       }
diff --git a/source/funkin/freeplayStuff/CapsuleText.hx b/source/funkin/freeplayStuff/CapsuleText.hx
new file mode 100644
index 000000000..dda687f5e
--- /dev/null
+++ b/source/funkin/freeplayStuff/CapsuleText.hx
@@ -0,0 +1,49 @@
+package funkin.freeplayStuff;
+
+import openfl.filters.BitmapFilterQuality;
+import flixel.text.FlxText;
+import flixel.group.FlxSpriteGroup;
+import funkin.shaderslmfao.GaussianBlurShader;
+
+class CapsuleText extends FlxSpriteGroup
+{
+  public var blurredText:FlxText;
+
+  var whiteText:FlxText;
+
+  public var text(default, set):String;
+
+  public function new(x:Float, y:Float, songTitle:String, size:Float)
+  {
+    super(x, y);
+
+    blurredText = initText(songTitle, size);
+    blurredText.shader = new GaussianBlurShader(1);
+    whiteText = initText(songTitle, size);
+    // whiteText.shader = new GaussianBlurShader(0.3);
+    text = songTitle;
+
+    blurredText.color = 0xFF00ccff;
+    whiteText.color = 0xFFFFFFFF;
+    add(blurredText);
+    add(whiteText);
+  }
+
+  function initText(songTitle, size:Float):FlxText
+  {
+    var text:FlxText = new FlxText(0, 0, 0, songTitle, Std.int(size));
+    text.font = "5by7";
+    return text;
+  }
+
+  function set_text(value:String):String
+  {
+    blurredText.text = value;
+    whiteText.text = value;
+    whiteText.textField.filters = [
+      new openfl.filters.GlowFilter(0x00ccff, 1, 5, 5, 210, BitmapFilterQuality.MEDIUM),
+      // new openfl.filters.BlurFilter(5, 5, BitmapFilterQuality.LOW)
+    ];
+    return value;
+  }
+}
diff --git a/source/funkin/freeplayStuff/DJBoyfriend.hx b/source/funkin/freeplayStuff/DJBoyfriend.hx
index 5bee4129a..ba0ce464d 100644
--- a/source/funkin/freeplayStuff/DJBoyfriend.hx
+++ b/source/funkin/freeplayStuff/DJBoyfriend.hx
@@ -3,8 +3,12 @@ package funkin.freeplayStuff;
 import flixel.FlxSprite;
 import flixel.util.FlxSignal;
 import funkin.util.assets.FlxAnimationUtil;
+import funkin.graphics.adobeanimate.FlxAtlasSprite;
+import flixel.system.FlxSound;
+import flixel.util.FlxTimer;
+import funkin.audio.FlxStreamSound;
 
-class DJBoyfriend extends FlxSprite
+class DJBoyfriend extends FlxAtlasSprite
 {
   // Represents the sprite's current status.
   // Without state machines I would have driven myself crazy years ago.
@@ -20,20 +24,55 @@ class DJBoyfriend extends FlxSprite
   // TODO: Switch this class to use SwagSprite instead.
   public var animOffsets:Map<String, Array<Dynamic>>;
 
-  static final SPOOK_PERIOD:Float = 180.0;
+  var gotSpooked:Bool = false;
+
+  static final SPOOK_PERIOD:Float = 120.0;
+  static final TV_PERIOD:Float = 180.0;
 
   // Time since dad last SPOOKED you.
   var timeSinceSpook:Float = 0;
 
   public function new(x:Float, y:Float)
   {
-    super(x, y);
+    super(x, y, Paths.animateAtlas("freeplay/freeplay-boyfriend", "preload"));
 
     animOffsets = new Map<String, Array<Dynamic>>();
 
-    setupAnimations();
+    anim.callback = function(name, number) {
+      switch (name)
+      {
+        case "Boyfriend DJ watchin tv OG":
+          if (number == 85) runTvLogic();
+        default:
+      }
+    };
 
-    animation.finishCallback = onFinishAnim;
+    setupAnimations();
+    trace(listAnimations());
+
+    FlxG.debugger.track(this);
+    FlxG.console.registerObject("dj", this);
+
+    anim.onComplete = onFinishAnim;
+
+    FlxG.console.registerFunction("tv", function() {
+      currentState = TV;
+    });
+  }
+
+  /*
+    [remote hand under,boyfriend top head,brim piece,arm cringe l,red lazer,dj arm in,bf fist pump arm,hand raised right,forearm left,fist shaking,bf smile eyes closed face,arm cringe r,bf clenched face,face shrug,boyfriend falling,blue tint 1,shirt sleeve,bf clenched fist,head BF relaxed,blue tint 2,hand down left,blue tint 3,blue tint 4,head less smooshed,blue tint 5,boyfriend freeplay,BF head slight turn,blue tint 6,arm shrug l,blue tint 7,shoulder raised w sleeve,blue tint 8,fist pump face,blue tint 9,foot rested light,hand turnaround,arm chill right,Boyfriend DJ,arm shrug r,head back bf,hat top piece,dad bod,face surprise snap,Boyfriend DJ fist pump,office chair,foot rested right,chest down,office chair upright,body chill,bf dj afk,head mouth open dad,BF Head defalt HAIR BLOWING,hand shrug l,face piece,foot wag,turn table,shoulder up left,turntable lights,boyfriend dj body shirt blowing,body chunk turned,hand down right,dj arm out,hand shrug r,body chest out,rave hand,palm,chill face default,head back semi bf,boyfriend bottom head,DJ arm,shoulder right dad,bf surprise,boyfriend dj body,hs1,Boyfriend DJ watchin tv OG,spinning disk,hs2,arm chill left,boyfriend dj intro,hs3,hs4,chill face extra,hs5,remote hand upright,hs6,pant over table,face surprise,bf arm peace,arm turnaround,bf eyes 1,arm slammed table,eye squit,leg BF,head mid piece,arm backing,arm swoopin in,shoe right lowering,forearm right,hand out,blue tint 10,body falling back,remote thumb press,shoulder,hair spike single,bf bent
+    arm,crt,foot raised right,dad hand,chill face 1,chill face 2,clenched fist,head SMOOSHED,shoulder left dad,df1,body chunk upright,df2,df3,df4,hat front piece,df5,foot rested right 2,hand in,arm spun,shoe raised left,bf 1 finger hand,bf mouth 1,Boyfriend DJ confirm,forearm down ,hand raised left,remote thumb up]
+   */
+  override public function listAnimations():Array<String>
+  {
+    var anims:Array<String> = [];
+    @:privateAccess
+    for (animKey in anim.symbolDictionary)
+    {
+      anims.push(animKey.name);
+    }
+    return anims;
   }
 
   public override function update(elapsed:Float):Void
@@ -44,51 +83,68 @@ class DJBoyfriend extends FlxSprite
     {
       case Intro:
         // Play the intro animation then leave this state immediately.
-        if (getCurrentAnimation() != 'intro') playAnimation('intro', true);
+        if (getCurrentAnimation() != 'boyfriend dj intro') playFlashAnimation('boyfriend dj intro', true);
         timeSinceSpook = 0;
       case Idle:
         // We are in this state the majority of the time.
-        if (getCurrentAnimation() != 'idle' || animation.finished)
+        if (getCurrentAnimation() != 'Boyfriend DJ' || anim.finished)
         {
-          if (timeSinceSpook > SPOOK_PERIOD)
+          if (timeSinceSpook > SPOOK_PERIOD && !gotSpooked)
           {
             currentState = Spook;
           }
+          else if (timeSinceSpook > TV_PERIOD)
+          {
+            currentState = TV;
+          }
           else
           {
-            playAnimation('idle', false);
+            playFlashAnimation('Boyfriend DJ', false);
           }
         }
         timeSinceSpook += elapsed;
       case Confirm:
-        if (getCurrentAnimation() != 'confirm') playAnimation('confirm', false);
+        if (getCurrentAnimation() != 'Boyfriend DJ confirm') playFlashAnimation('Boyfriend DJ confirm', false);
         timeSinceSpook = 0;
       case Spook:
-        if (getCurrentAnimation() != 'spook')
+        if (getCurrentAnimation() != 'bf dj afk')
         {
           onSpook.dispatch();
-          playAnimation('spook', false);
+          playFlashAnimation('bf dj afk', false);
         }
         timeSinceSpook = 0;
+      case TV:
+        if (getCurrentAnimation() != 'Boyfriend DJ watchin tv OG') playFlashAnimation('Boyfriend DJ watchin tv OG', true);
+        timeSinceSpook = 0;
       default:
         // I shit myself.
     }
   }
 
-  function onFinishAnim(name:String):Void
+  function onFinishAnim():Void
   {
+    var name = anim.curSymbol.name;
     switch (name)
     {
-      case "intro":
+      case "boyfriend dj intro":
         // trace('Finished intro');
         currentState = Idle;
         onIntroDone.dispatch();
-      case "idle":
+      case "Boyfriend DJ":
       // trace('Finished idle');
-      case "spook":
+      case "bf dj afk":
         // trace('Finished spook');
         currentState = Idle;
-      case "confirm":
+      case "Boyfriend DJ confirm":
+
+      case "Boyfriend DJ watchin tv OG":
+        var frame:Int = FlxG.random.bool(33) ? 112 : 166;
+        if (FlxG.random.bool(10))
+        {
+          frame = 60;
+          // boyfriend switches channel code?
+        }
+        anim.play("Boyfriend DJ watchin tv OG", true, false, frame);
         // trace('Finished confirm');
     }
   }
@@ -100,19 +156,66 @@ class DJBoyfriend extends FlxSprite
 
   function setupAnimations():Void
   {
-    frames = FlxAnimationUtil.combineFramesCollections(Paths.getSparrowAtlas('freeplay/bfFreeplay'), Paths.getSparrowAtlas('freeplay/bf-freeplay-afk'));
+    // frames = FlxAnimationUtil.combineFramesCollections(Paths.getSparrowAtlas('freeplay/bfFreeplay'), Paths.getSparrowAtlas('freeplay/bf-freeplay-afk'));
 
-    animation.addByPrefix('intro', "boyfriend dj intro", 24, false);
-    addOffset('intro', 0, 0);
+    // animation.addByPrefix('intro', "boyfriend dj intro", 24, false);
+    addOffset('boyfriend dj intro', 8, 3);
 
-    animation.addByPrefix('idle', "Boyfriend DJ0", 24, false);
-    addOffset('idle', -4, -426);
+    // animation.addByPrefix('idle', "Boyfriend DJ0", 24, false);
+    addOffset('Boyfriend DJ', 0, 0);
 
-    animation.addByPrefix('confirm', "Boyfriend DJ confirm", 24, false);
-    addOffset('confirm', 40, -451);
+    // animation.addByPrefix('confirm', "Boyfriend DJ confirm", 24, false);
+    addOffset('Boyfriend DJ confirm', 0, 0);
 
-    animation.addByPrefix('spook', "bf dj afk0", 24, false);
-    addOffset('spook', -3, -272);
+    // animation.addByPrefix('spook', "bf dj afk0", 24, false);
+    addOffset('bf dj afk', 0, 0);
+  }
+
+  var cartoonSnd:FlxStreamSound;
+
+  public var playingCartoon:Bool = false;
+
+  public function runTvLogic()
+  {
+    if (cartoonSnd == null)
+    {
+      // tv is OFF, but getting turned on
+      FlxG.sound.play(Paths.sound('tv_on'));
+
+      cartoonSnd = new FlxStreamSound();
+      FlxG.sound.defaultSoundGroup.add(cartoonSnd);
+    }
+    else
+    {
+      // plays it smidge after the click
+      new FlxTimer().start(0.1, function(_) {
+        FlxG.sound.play(Paths.sound('channel_switch'));
+      });
+    }
+    // cartoonSnd.loadEmbedded(Paths.sound("cartoons/peck"));
+    // cartoonSnd.play();
+
+    loadCartoon();
+  }
+
+  function loadCartoon()
+  {
+    cartoonSnd.loadEmbedded(Paths.sound(getRandomFlashToon()), false, false, function() {
+      anim.play("Boyfriend DJ watchin tv OG", true, false, 60);
+    });
+    cartoonSnd.play(true, FlxG.random.float(0, cartoonSnd.length));
+  }
+
+  var cartoonList:Array<String> = openfl.utils.Assets.list().filter(function(path) return path.startsWith("assets/sounds/cartoons/"));
+
+  function getRandomFlashToon():String
+  {
+    var randomFile = FlxG.random.getObject(cartoonList);
+
+    randomFile = randomFile.replace("assets/sounds/", "");
+    randomFile = randomFile.substring(0, randomFile.length - 4);
+
+    return randomFile;
   }
 
   public function confirm():Void
@@ -125,15 +228,15 @@ class DJBoyfriend extends FlxSprite
     animOffsets[name] = [x, y];
   }
 
-  public function getCurrentAnimation():String
+  override public function getCurrentAnimation():String
   {
-    if (this.animation == null || this.animation.curAnim == null) return "";
-    return this.animation.curAnim.name;
+    if (this.anim == null || this.anim.curSymbol == null) return "";
+    return this.anim.curSymbol.name;
   }
 
-  public function playAnimation(AnimName:String, Force:Bool = false, Reversed:Bool = false, Frame:Int = 0):Void
+  public function playFlashAnimation(id:String, ?Force:Bool = false, ?Reverse:Bool = false, ?Frame:Int = 0):Void
   {
-    animation.play(AnimName, Force, Reversed, Frame);
+    anim.play(id, Force, Reverse, Frame);
     applyAnimOffset();
   }
 
@@ -156,4 +259,5 @@ enum DJBoyfriendState
   Idle;
   Confirm;
   Spook;
+  TV;
 }
diff --git a/source/funkin/freeplayStuff/DifficultyStars.hx b/source/funkin/freeplayStuff/DifficultyStars.hx
new file mode 100644
index 000000000..8611727be
--- /dev/null
+++ b/source/funkin/freeplayStuff/DifficultyStars.hx
@@ -0,0 +1,106 @@
+package funkin.freeplayStuff;
+
+import flixel.group.FlxSpriteGroup;
+import funkin.graphics.adobeanimate.FlxAtlasSprite;
+import funkin.shaderslmfao.HSVShader;
+
+class DifficultyStars extends FlxSpriteGroup
+{
+  /**
+   * Internal handler var for difficulty... ranges from 0... to 15
+   * 0 is 1 star... 15 is 0 stars!
+   */
+  var curDifficulty(default, set):Int = 0;
+
+  /**
+   * Range between 0 and 15
+   */
+  public var difficulty(default, set):Int = 1;
+
+  public var stars:FlxAtlasSprite;
+
+  var flames:FreeplayFlames;
+
+  var hsvShader:HSVShader;
+
+  public function new(x:Float, y:Float)
+  {
+    super(x, y);
+
+    hsvShader = new HSVShader();
+
+    flames = new FreeplayFlames(0, 0);
+    add(flames);
+
+    stars = new FlxAtlasSprite(0, 0, Paths.animateAtlas("freeplay/freeplayStars"));
+    stars.anim.play("diff stars");
+    add(stars);
+
+    stars.shader = hsvShader;
+
+    for (memb in flames.members)
+      memb.shader = hsvShader;
+  }
+
+  override function update(elapsed:Float):Void
+  {
+    super.update(elapsed);
+
+    // "loops" the current animation
+    // for clarity, the animation file looks like
+    // frame : stars
+    // 0-99: 1 star
+    // 100-199: 2 stars
+    // ......
+    // 1300-1499: 15 stars
+    // 1500 : 0 stars
+    if (curDifficulty < 15 && stars.anim.curFrame >= (curDifficulty + 1) * 100)
+    {
+      stars.anim.play("diff stars", true, false, curDifficulty * 100);
+    }
+  }
+
+  function set_difficulty(value:Int):Int
+  {
+    difficulty = value;
+
+    if (difficulty <= 0)
+    {
+      difficulty = 0;
+      curDifficulty = 15;
+    }
+    else if (difficulty <= 15)
+    {
+      difficulty = value;
+      curDifficulty = difficulty - 1;
+    }
+    else
+    {
+      difficulty = 15;
+      curDifficulty = difficulty - 1;
+    }
+
+    if (difficulty > 10) flames.flameCount = difficulty - 10;
+    else
+      flames.flameCount = 0;
+
+    return difficulty;
+  }
+
+  function set_curDifficulty(value:Int):Int
+  {
+    curDifficulty = value;
+    if (curDifficulty == 15)
+    {
+      stars.anim.play("diff stars", true, false, 1500);
+      stars.anim.pause();
+    }
+    else
+    {
+      stars.anim.curFrame = Std.int(curDifficulty * 100);
+      stars.anim.play("diff stars", true, false, curDifficulty * 100);
+    }
+
+    return curDifficulty;
+  }
+}
diff --git a/source/funkin/freeplayStuff/FreeplayFlames.hx b/source/funkin/freeplayStuff/FreeplayFlames.hx
new file mode 100644
index 000000000..8f54d210b
--- /dev/null
+++ b/source/funkin/freeplayStuff/FreeplayFlames.hx
@@ -0,0 +1,117 @@
+package funkin.freeplayStuff;
+
+import flixel.group.FlxSpriteGroup;
+import flixel.FlxSprite;
+import flixel.util.FlxTimer;
+
+class FreeplayFlames extends FlxSpriteGroup
+{
+  var flameX(default, set):Float = 917;
+  var flameY(default, set):Float = 103;
+  var flameSpreadX(default, set):Float = 29;
+  var flameSpreadY(default, set):Float = 6;
+
+  public var flameCount(default, set):Int = 0;
+
+  var flameTimer:Float = 0.25;
+
+  public function new(x:Float, y:Float)
+  {
+    super(x, y);
+
+    for (i in 0...5)
+    {
+      var flame:FlxSprite = new FlxSprite(flameX + (flameSpreadX * i), flameY + (flameSpreadY * i));
+      flame.frames = Paths.getSparrowAtlas("freeplay/freeplayFlame");
+      flame.animation.addByPrefix("flame", "fire loop", FlxG.random.int(23, 25), false);
+      flame.animation.play("flame");
+      flame.visible = false;
+      flameCount = 0;
+
+      // sets the loop... maybe better way to do this lol!
+      flame.animation.finishCallback = function(_) {
+        flame.animation.play("flame", true, false, 2);
+      };
+      add(flame);
+    }
+  }
+
+  var properPositions:Bool = false;
+
+  override public function update(elapsed:Float):Void
+  {
+    super.update(elapsed);
+    // doesn't work in create()/new() for some reason
+    // so putting it here bwah!
+    if (!properPositions)
+    {
+      setFlamePositions();
+      properPositions = true;
+    }
+  }
+
+  function set_flameCount(value:Int):Int
+  {
+    this.flameCount = value;
+    var visibleCount:Int = 0;
+    for (i in 0...5)
+    {
+      if (members[i] == null) continue;
+      var flame:FlxSprite = members[i];
+      if (i < flameCount)
+      {
+        if (!flame.visible)
+        {
+          new FlxTimer().start(flameTimer * visibleCount, function(_) {
+            flame.animation.play("flame", true);
+            flame.visible = true;
+          });
+          visibleCount++;
+        }
+      }
+      else
+      {
+        flame.visible = false;
+      }
+    }
+    return this.flameCount;
+  }
+
+  function setFlamePositions()
+  {
+    for (i in 0...5)
+    {
+      var flame:FlxSprite = members[i];
+      flame.x = flameX + (flameSpreadX * i);
+      flame.y = flameY + (flameSpreadY * i);
+    }
+  }
+
+  function set_flameX(value:Float):Float
+  {
+    this.flameX = value;
+    setFlamePositions();
+    return this.flameX;
+  }
+
+  function set_flameY(value:Float):Float
+  {
+    this.flameY = value;
+    setFlamePositions();
+    return this.flameY;
+  }
+
+  function set_flameSpreadX(value:Float):Float
+  {
+    this.flameSpreadX = value;
+    setFlamePositions();
+    return this.flameSpreadX;
+  }
+
+  function set_flameSpreadY(value:Float):Float
+  {
+    this.flameSpreadY = value;
+    setFlamePositions();
+    return this.flameSpreadY;
+  }
+}
diff --git a/source/funkin/freeplayStuff/LetterSort.hx b/source/funkin/freeplayStuff/LetterSort.hx
index c3b22f973..e6d923c90 100644
--- a/source/funkin/freeplayStuff/LetterSort.hx
+++ b/source/funkin/freeplayStuff/LetterSort.hx
@@ -4,38 +4,68 @@ import flixel.FlxSprite;
 import flixel.group.FlxGroup.FlxTypedGroup;
 import flixel.group.FlxGroup;
 import flixel.group.FlxSpriteGroup.FlxTypedSpriteGroup;
+import flixel.tweens.FlxTween;
+import flixel.tweens.FlxEase;
+import flixel.util.FlxColor;
+import flixel.util.FlxTimer;
+import funkin.graphics.adobeanimate.FlxAtlasSprite;
 
-class LetterSort extends FlxTypedSpriteGroup<FreeplayLetter>
+class LetterSort extends FlxTypedSpriteGroup<FlxSprite>
 {
   public var letters:Array<FreeplayLetter> = [];
 
-  var curSelection:Int = 0;
+  // starts at 2, cuz that's the middle letter on start (accounting for fav and #, it should begin at ALL filter)
+  var curSelection:Int = 2;
 
   public var changeSelectionCallback:String->Void;
 
+  var leftArrow:FlxSprite;
+  var rightArrow:FlxSprite;
+  var grpSeperators:Array<FlxSprite> = [];
+
   public function new(x, y)
   {
     super(x, y);
 
-    var leftArrow:FreeplayLetter = new FreeplayLetter(-20, 0);
-    leftArrow.animation.play("arrow");
+    leftArrow = new FlxSprite(-20, 15).loadGraphic(Paths.image("freeplay/miniArrow"));
+    // leftArrow.animation.play("arrow");
+    leftArrow.flipX = true;
     add(leftArrow);
 
-    for (i in 0...6)
+    for (i in 0...5)
     {
       var letter:FreeplayLetter = new FreeplayLetter(i * 80, 0, i);
+      letter.x += 50;
+      letter.y += 50;
+      letter.ogY = y;
+      // letter.visible = false;
       add(letter);
 
       letters.push(letter);
 
-      if (i == 3) letter.alpha = 0.6;
+      if (i != 2) letter.scale.x = letter.scale.y = 0.8;
 
-      var sep:FreeplayLetter = new FreeplayLetter((i * 80) + 50, 0);
-      sep.animation.play("seperator");
+      var darkness:Float = Math.abs(i - 2) / 6;
+
+      letter.color = letter.color.getDarkened(darkness);
+
+      // don't put the last seperator
+      if (i == 4) continue;
+
+      var sep:FlxSprite = new FlxSprite((i * 80) + 55, 20).loadGraphic(Paths.image("freeplay/seperator"));
+      // sep.animation.play("seperator");
+      sep.color = letter.color.getDarkened(darkness);
       add(sep);
+
+      grpSeperators.push(sep);
     }
 
-    // changeSelection(-3);
+    rightArrow = new FlxSprite(380, 15).loadGraphic(Paths.image("freeplay/miniArrow"));
+
+    // rightArrow.animation.play("arrow");
+    add(rightArrow);
+
+    changeSelection(0);
   }
 
   override function update(elapsed:Float)
@@ -48,53 +78,168 @@ class LetterSort extends FlxTypedSpriteGroup<FreeplayLetter>
 
   public function changeSelection(diff:Int = 0)
   {
-    for (letter in letters)
-      letter.changeLetter(diff);
+    var ezTimer:Int->FlxSprite->Float->Void = function(frameNum:Int, spr:FlxSprite, offsetNum:Float) {
+      new FlxTimer().start(frameNum / 24, function(_) {
+        spr.offset.x = offsetNum;
+      });
+    };
 
-    if (changeSelectionCallback != null) changeSelectionCallback(letters[3].arr[letters[3].curLetter]); // bullshit and long lol!
+    var positions:Array<Float> = [-10, -22, 2, 0];
+
+    if (diff < 0)
+    {
+      for (sep in grpSeperators)
+      {
+        ezTimer(0, sep, positions[0]);
+        ezTimer(1, sep, positions[1]);
+        ezTimer(2, sep, positions[2]);
+        ezTimer(3, sep, positions[3]);
+      }
+
+      for (index => letter in letters)
+      {
+        letter.offset.x = positions[0];
+
+        new FlxTimer().start(1 / 24, function(_) {
+          letter.offset.x = positions[1];
+          if (index == 0) letter.visible = false;
+        });
+
+        new FlxTimer().start(2 / 24, function(_) {
+          letter.offset.x = positions[2];
+          if (index == 0.) letter.visible = true;
+        });
+
+        if (index == 2)
+        {
+          ezTimer(3, letter, 0);
+          // letter.offset.x = 0;
+          continue;
+        }
+
+        ezTimer(3, letter, positions[3]);
+      }
+
+      leftArrow.offset.x = 3;
+      new FlxTimer().start(2 / 24, function(_) {
+        leftArrow.offset.x = 0;
+      });
+    }
+    else if (diff > 0)
+    {
+      for (sep in grpSeperators)
+      {
+        ezTimer(0, sep, -positions[0]);
+        ezTimer(1, sep, -positions[1]);
+        ezTimer(2, sep, -positions[2]);
+        ezTimer(3, sep, -positions[3]);
+      }
+      // same timing and functions and shit as the left one... except to the right!!
+
+      for (index => letter in letters)
+      {
+        letter.offset.x = -positions[0];
+
+        new FlxTimer().start(1 / 24, function(_) {
+          letter.offset.x = -positions[1];
+          if (index == 0) letter.visible = false;
+        });
+
+        new FlxTimer().start(2 / 24, function(_) {
+          letter.offset.x = -positions[2];
+          if (index == 0) letter.visible = true;
+        });
+
+        if (index == 2)
+        {
+          ezTimer(3, letter, 0);
+          // letter.offset.x = 0;
+          continue;
+        }
+
+        ezTimer(3, letter, -positions[3]);
+      }
+
+      rightArrow.offset.x = -3;
+      new FlxTimer().start(2 / 24, function(_) {
+        rightArrow.offset.x = 0;
+      });
+    }
+
+    curSelection += diff;
+    if (curSelection < 0) curSelection = letters[0].arr.length - 1;
+    if (curSelection >= letters[0].arr.length) curSelection = 0;
+
+    for (letter in letters)
+      letter.changeLetter(diff, curSelection);
+
+    if (changeSelectionCallback != null) changeSelectionCallback(letters[2].arr[letters[2].curLetter]); // bullshit and long lol!
   }
 }
 
-class FreeplayLetter extends FlxSprite
+class FreeplayLetter extends FlxAtlasSprite
 {
   public var arr:Array<String> = [];
 
   public var curLetter:Int = 0;
 
+  public var ogY:Float = 0;
+
   public function new(x:Float, y:Float, ?letterInd:Int)
   {
-    super(x, y);
+    super(x, y, Paths.animateAtlas("freeplay/sortedLetters"));
+    // frames = Paths.getSparrowAtlas("freeplay/letterStuff");
+    // this.anim.play("AB");
+    // trace(this.anim.symbolDictionary);
 
-    frames = Paths.getSparrowAtlas("freeplay/letterStuff");
-
-    var alphabet:String = "abcdefghijklmnopqrstuvwxyz";
-    arr = alphabet.split("");
-    arr.insert(0, "#");
+    var alphabet:String = "AB-CD-EH-I L-MN-OR-s-t-UZ";
+    arr = alphabet.split("-");
     arr.insert(0, "ALL");
     arr.insert(0, "fav");
+    arr.insert(0, "#");
 
-    for (str in arr)
-    {
-      animation.addByPrefix(str, str + " "); // string followed by a space! intentional!
-    }
+    // trace(arr);
 
-    animation.addByPrefix("arrow", "mini arrow");
-    animation.addByPrefix("seperator", "seperator");
+    // for (str in arr)
+    // {
+    //   animation.addByPrefix(str, str + " "); // string followed by a space! intentional!
+    // }
+
+    // animation.addByPrefix("arrow", "mini arrow");
+    // animation.addByPrefix("seperator", "seperator");
 
     if (letterInd != null)
     {
-      animation.play(arr[letterInd]);
+      this.anim.play(arr[letterInd] + " move");
+      this.anim.pause();
       curLetter = letterInd;
     }
   }
 
-  public function changeLetter(diff:Int = 0)
+  public function changeLetter(diff:Int = 0, ?curSelection:Int)
   {
     curLetter += diff;
 
     if (curLetter < 0) curLetter = arr.length - 1;
     if (curLetter >= arr.length) curLetter = 0;
 
-    animation.play(arr[curLetter]);
+    var animName:String = arr[curLetter] + " move";
+
+    switch (arr[curLetter])
+    {
+      case "I L":
+        animName = "IL move";
+      case "s":
+        animName = "S move";
+      case "t":
+        animName = "T move";
+    }
+
+    this.anim.play(animName);
+    if (curSelection != curLetter)
+    {
+      this.anim.pause();
+    }
+    // updateHitbox();
   }
 }
diff --git a/source/funkin/freeplayStuff/SongMenuItem.hx b/source/funkin/freeplayStuff/SongMenuItem.hx
index 37198f6d7..5fd7eb576 100644
--- a/source/funkin/freeplayStuff/SongMenuItem.hx
+++ b/source/funkin/freeplayStuff/SongMenuItem.hx
@@ -1,5 +1,8 @@
 package funkin.freeplayStuff;
 
+import funkin.shaderslmfao.HSVShader;
+import funkin.shaderslmfao.GaussianBlurShader;
+import flixel.group.FlxGroup;
 import flixel.FlxSprite;
 import flixel.graphics.frames.FlxAtlasFrames;
 import flixel.group.FlxSpriteGroup.FlxTypedSpriteGroup;
@@ -7,17 +10,29 @@ import flixel.group.FlxSpriteGroup;
 import flixel.math.FlxMath;
 import flixel.math.FlxPoint;
 import flixel.text.FlxText;
+import flixel.util.FlxTimer;
+import funkin.shaderslmfao.Grayscale;
 
 class SongMenuItem extends FlxSpriteGroup
 {
-  var capsule:FlxSprite;
+  public var capsule:FlxSprite;
 
-  public var selected(default, set):Bool = false;
+  var pixelIcon:FlxSprite;
+
+  public var selected(default, set):Bool;
 
   public var songTitle:String = "Test";
 
-  public var songText:FlxText;
+  public var songText:CapsuleText;
   public var favIcon:FlxSprite;
+  public var ranking:FlxSprite;
+
+  var ranks:Array<String> = ["fail", "average", "great", "excellent", "perfect"];
+
+  // lol...
+  var diffRanks:Array<String> = [
+    "00", "01", "02", "03", "04", "05", "06", "07", "08", "09", "10", "11", "12", "14", "15"
+  ];
 
   public var targetPos:FlxPoint = new FlxPoint();
   public var doLerp:Bool = false;
@@ -25,7 +40,12 @@ class SongMenuItem extends FlxSpriteGroup
 
   public var doJumpOut:Bool = false;
 
-  public function new(x:Float, y:Float, song:String)
+  public var onConfirm:Void->Void;
+  public var diffGrayscale:Grayscale;
+
+  public var hsvShader(default, set):HSVShader;
+
+  public function new(x:Float, y:Float, song:String, ?character:String)
   {
     super(x, y);
 
@@ -38,19 +58,144 @@ class SongMenuItem extends FlxSpriteGroup
     // capsule.animation
     add(capsule);
 
-    songText = new FlxText(120, 40, 0, songTitle, 40);
-    songText.font = "5by7";
-    songText.color = 0xFF43C1EA;
-    add(songText);
+    // doesn't get added, simply is here to help with visibility of things for the pop in!
+    grpHide = new FlxGroup();
 
-    favIcon = new FlxSprite(440, 40);
+    var rank:String = FlxG.random.getObject(ranks);
+
+    ranking = new FlxSprite(capsule.width * 0.84, 30);
+    ranking.loadGraphic(Paths.image("freeplay/ranks/" + rank));
+    ranking.scale.x = ranking.scale.y = realScaled;
+    ranking.alpha = 0.75;
+    ranking.origin.set(capsule.origin.x - ranking.x, capsule.origin.y - ranking.y);
+    add(ranking);
+    grpHide.add(ranking);
+
+    diffGrayscale = new Grayscale(1);
+
+    var diffRank = new FlxSprite(145, 90).loadGraphic(Paths.image("freeplay/diffRankings/diff" + FlxG.random.getObject(diffRanks)));
+    diffRank.shader = diffGrayscale;
+    diffRank.visible = false;
+    add(diffRank);
+    diffRank.origin.set(capsule.origin.x - diffRank.x, capsule.origin.y - diffRank.y);
+    grpHide.add(diffRank);
+
+    switch (rank)
+    {
+      case "perfect":
+        ranking.x -= 10;
+    }
+
+    songText = new CapsuleText(capsule.width * 0.26, 45, songTitle, Std.int(40 * realScaled));
+    add(songText);
+    grpHide.add(songText);
+
+    pixelIcon = new FlxSprite(155, 15);
+    pixelIcon.makeGraphic(32, 32, 0x00000000);
+    pixelIcon.antialiasing = false;
+    pixelIcon.active = false;
+    add(pixelIcon);
+    grpHide.add(pixelIcon);
+
+    if (character != null) setCharacter(character);
+
+    favIcon = new FlxSprite(400, 40);
     favIcon.frames = Paths.getSparrowAtlas('freeplay/favHeart');
     favIcon.animation.addByPrefix('fav', "favorite heart", 24, false);
     favIcon.animation.play('fav');
-    favIcon.setGraphicSize(60, 60);
+    favIcon.setGraphicSize(50, 50);
+    favIcon.visible = false;
     add(favIcon);
+    // grpHide.add(favIcon);
 
-    selected = selected; // just to kickstart the set_selected
+    setVisibleGrp(false);
+  }
+
+  function set_hsvShader(value:HSVShader):HSVShader
+  {
+    this.hsvShader = value;
+    capsule.shader = hsvShader;
+    songText.shader = hsvShader;
+
+    return value;
+  }
+
+  function textAppear()
+  {
+    songText.scale.x = 1.7;
+    songText.scale.y = 0.2;
+
+    new FlxTimer().start(1 / 24, function(_) {
+      songText.scale.x = 0.4;
+      songText.scale.y = 1.4;
+    });
+
+    new FlxTimer().start(2 / 24, function(_) {
+      songText.scale.x = songText.scale.y = 1;
+    });
+  }
+
+  function setVisibleGrp(value:Bool)
+  {
+    for (spr in grpHide.members)
+    {
+      spr.visible = value;
+    }
+
+    if (value) textAppear();
+
+    selectedAlpha();
+  }
+
+  public function init(x:Float, y:Float, song:String, ?character:String)
+  {
+    this.x = x;
+    this.y = y;
+    this.songTitle = song;
+    songText.text = this.songTitle;
+    if (character != null) setCharacter(character);
+
+    selected = selected;
+  }
+
+  /**
+   * Set the character displayed next to this song in the freeplay menu.
+   * @param char The character ID used by this song.
+   *             If the character has no freeplay icon, a warning will be thrown and nothing will display.
+   */
+  public function setCharacter(char:String)
+  {
+    var charPath:String = "freeplay/icons/";
+
+    trace(char);
+
+    switch (char)
+    {
+      case "monster-christmas":
+        charPath += "monsterpixel";
+      case "mom-car":
+        charPath += "mommypixel";
+      case "dad":
+        charPath += "daddypixel";
+      case "darnell-blazin":
+        charPath += "darnellpixel";
+      case "senpai-angry":
+        charPath += "senpaipixel";
+      default:
+        charPath += char + "pixel";
+    }
+
+    if (!openfl.utils.Assets.exists(Paths.image(charPath)))
+    {
+      trace('[WARN] Character ${char} has no freeplay icon.');
+      return;
+    }
+
+    pixelIcon.loadGraphic(Paths.image(charPath));
+    pixelIcon.scale.x = pixelIcon.scale.y = 2;
+    pixelIcon.origin.x = 100;
+    // pixelIcon.origin.x = capsule.origin.x;
+    // pixelIcon.offset.x -= pixelIcon.origin.x;
   }
 
   var frameInTicker:Float = 0;
@@ -63,6 +208,63 @@ class SongMenuItem extends FlxSpriteGroup
   var xPosLerpLol:Array<Float> = [0.9, 0.4, 0.16, 0.16, 0.22, 0.22, 0.245]; // NUMBERS ARE JANK CUZ THE SCALING OR WHATEVER
   var xPosOutLerpLol:Array<Float> = [0.245, 0.75, 0.98, 0.98, 1.2]; // NUMBERS ARE JANK CUZ THE SCALING OR WHATEVER
 
+  public var realScaled:Float = 0.8;
+
+  public function initJumpIn(maxTimer:Float, ?force:Bool):Void
+  {
+    frameInTypeBeat = 0;
+
+    new FlxTimer().start((1 / 24) * maxTimer, function(doShit) {
+      doJumpIn = true;
+    });
+
+    new FlxTimer().start((0.09 * maxTimer) + 0.85, function(lerpTmr) {
+      doLerp = true;
+    });
+
+    if (force)
+    {
+      visible = true;
+      capsule.alpha = 1;
+      setVisibleGrp(true);
+    }
+    else
+    {
+      new FlxTimer().start((xFrames.length / 24) * 2.5, function(_) {
+        visible = true;
+        capsule.alpha = 1;
+        setVisibleGrp(true);
+      });
+    }
+  }
+
+  var grpHide:FlxGroup;
+
+  public function forcePosition()
+  {
+    visible = true;
+    capsule.alpha = 1;
+    selectedAlpha();
+    doLerp = true;
+    doJumpIn = false;
+    doJumpOut = false;
+
+    frameInTypeBeat = xFrames.length;
+    frameOutTypeBeat = 0;
+
+    capsule.scale.x = xFrames[frameInTypeBeat - 1];
+    capsule.scale.y = 1 / xFrames[frameInTypeBeat - 1];
+    // x = FlxG.width * xPosLerpLol[Std.int(Math.min(frameInTypeBeat - 1, xPosLerpLol.length - 1))];
+
+    x = targetPos.x;
+    y = targetPos.y;
+
+    capsule.scale.x *= realScaled;
+    capsule.scale.y *= realScaled;
+
+    setVisibleGrp(true);
+  }
+
   override function update(elapsed:Float)
   {
     if (doJumpIn)
@@ -73,10 +275,13 @@ class SongMenuItem extends FlxSpriteGroup
       {
         frameInTicker = 0;
 
-        scale.x = xFrames[frameInTypeBeat];
-        scale.y = 1 / xFrames[frameInTypeBeat];
+        capsule.scale.x = xFrames[frameInTypeBeat];
+        capsule.scale.y = 1 / xFrames[frameInTypeBeat];
         x = FlxG.width * xPosLerpLol[Std.int(Math.min(frameInTypeBeat, xPosLerpLol.length - 1))];
 
+        capsule.scale.x *= realScaled;
+        capsule.scale.y *= realScaled;
+
         frameInTypeBeat += 1;
       }
     }
@@ -89,10 +294,13 @@ class SongMenuItem extends FlxSpriteGroup
       {
         frameOutTicker = 0;
 
-        scale.x = xFrames[frameOutTypeBeat];
-        scale.y = 1 / xFrames[frameOutTypeBeat];
+        capsule.scale.x = xFrames[frameOutTypeBeat];
+        capsule.scale.y = 1 / xFrames[frameOutTypeBeat];
         x = FlxG.width * xPosOutLerpLol[Std.int(Math.min(frameOutTypeBeat, xPosOutLerpLol.length - 1))];
 
+        capsule.scale.x *= realScaled;
+        capsule.scale.y *= realScaled;
+
         frameOutTypeBeat += 1;
       }
     }
@@ -106,14 +314,29 @@ class SongMenuItem extends FlxSpriteGroup
     super.update(elapsed);
   }
 
+  public function intendedY(index:Int):Float
+  {
+    return (index * ((height * realScaled) + 10)) + 120;
+  }
+
+  /**
+   * Merely a helper function to call set_selected, to make sure that the alpha is correct on the rankings/selections
+   */
+  public function selectedAlpha():Void
+  {
+    selected = selected;
+  }
+
   function set_selected(value:Bool):Bool
   {
-    // trace(value);
-
     // cute one liners, lol!
+    diffGrayscale.setAmount(value ? 0 : 0.8);
     songText.alpha = value ? 1 : 0.6;
+    songText.blurredText.visible = value ? true : false;
     capsule.offset.x = value ? 0 : -5;
     capsule.animation.play(value ? "selected" : "unselected");
+    ranking.alpha = value ? 1 : 0.7;
+    ranking.color = value ? 0xFFFFFFFF : 0xFFAAAAAA;
     return value;
   }
 }
diff --git a/source/funkin/play/PlayState.hx b/source/funkin/play/PlayState.hx
index ce3000645..f04cb57c4 100644
--- a/source/funkin/play/PlayState.hx
+++ b/source/funkin/play/PlayState.hx
@@ -47,7 +47,7 @@ import funkin.play.song.Song;
 import funkin.data.song.SongRegistry;
 import funkin.data.song.SongData.SongEventData;
 import funkin.data.song.SongData.SongNoteData;
-import funkin.data.song.SongData.SongPlayableChar;
+import funkin.data.song.SongData.SongCharacterData;
 import funkin.play.stage.Stage;
 import funkin.play.stage.StageData.StageDataParser;
 import funkin.ui.PopUpStuff;
@@ -512,41 +512,7 @@ class PlayState extends MusicBeatSubState
 
     NoteSplash.buildSplashFrames();
 
-    // Returns null if the song failed to load or doesn't have the selected difficulty.
-    if (currentSong == null || currentChart == null)
-    {
-      // We have encountered a critical error. Prevent Flixel from trying to run any gameplay logic.
-      criticalFailure = true;
-
-      // Choose an error message.
-      var message:String = 'There was a critical error. Click OK to return to the main menu.';
-      if (currentSong == null)
-      {
-        message = 'The was a critical error loading this song\'s chart. Click OK to return to the main menu.';
-      }
-      else if (currentDifficulty == null)
-      {
-        message = 'The was a critical error selecting a difficulty for this song. Click OK to return to the main menu.';
-      }
-      else if (currentSong.getDifficulty(currentDifficulty) == null)
-      {
-        message = 'The was a critical error retrieving data for this song on "$currentDifficulty" difficulty. Click OK to return to the main menu.';
-      }
-
-      // Display a popup. This blocks the application until the user clicks OK.
-      lime.app.Application.current.window.alert(message, 'Error loading PlayState');
-
-      // Force the user back to the main menu.
-      if (isSubState)
-      {
-        this.close();
-      }
-      else
-      {
-        FlxG.switchState(new MainMenuState());
-      }
-      return;
-    }
+    if (!assertChartExists()) return;
 
     if (false)
     {
@@ -575,8 +541,8 @@ class PlayState extends MusicBeatSubState
     // Prepare the current song's instrumental and vocals to be played.
     if (!overrideMusic && currentChart != null)
     {
-      currentChart.cacheInst(currentPlayerId);
-      currentChart.cacheVocals(currentPlayerId);
+      currentChart.cacheInst();
+      currentChart.cacheVocals();
     }
 
     // Prepare the Conductor.
@@ -661,6 +627,47 @@ class PlayState extends MusicBeatSubState
     initialized = true;
   }
 
+  function assertChartExists():Bool
+  {
+    // Returns null if the song failed to load or doesn't have the selected difficulty.
+    if (currentSong == null || currentChart == null)
+    {
+      // We have encountered a critical error. Prevent Flixel from trying to run any gameplay logic.
+      criticalFailure = true;
+
+      // Choose an error message.
+      var message:String = 'There was a critical error. Click OK to return to the main menu.';
+      if (currentSong == null)
+      {
+        message = 'The was a critical error loading this song\'s chart. Click OK to return to the main menu.';
+      }
+      else if (currentDifficulty == null)
+      {
+        message = 'The was a critical error selecting a difficulty for this song. Click OK to return to the main menu.';
+      }
+      else if (currentSong.getDifficulty(currentDifficulty) == null)
+      {
+        message = 'The was a critical error retrieving data for this song on "$currentDifficulty" difficulty. Click OK to return to the main menu.';
+      }
+
+      // Display a popup. This blocks the application until the user clicks OK.
+      lime.app.Application.current.window.alert(message, 'Error loading PlayState');
+
+      // Force the user back to the main menu.
+      if (isSubState)
+      {
+        this.close();
+      }
+      else
+      {
+        FlxG.switchState(new MainMenuState());
+      }
+      return false;
+    }
+
+    return true;
+  }
+
   public override function update(elapsed:Float):Void
   {
     if (criticalFailure) return;
@@ -673,6 +680,8 @@ class PlayState extends MusicBeatSubState
     // Handle restarting the song when needed (player death or pressing Retry)
     if (needsReset)
     {
+      if (!assertChartExists()) return;
+
       dispatchEvent(new ScriptEvent(ScriptEvent.SONG_RETRY));
 
       resetCamera();
@@ -687,8 +696,10 @@ class PlayState extends MusicBeatSubState
       // Reset music properly.
 
       FlxG.sound.music.pause();
-      vocals.pause();
       FlxG.sound.music.time = (startTimestamp);
+
+      vocals = currentChart.buildVocals();
+      vocals.pause();
       vocals.time = 0;
 
       FlxG.sound.music.volume = 1;
@@ -734,7 +745,7 @@ class PlayState extends MusicBeatSubState
       // DO NOT FORGET TO REMOVE THE HARDCODE! WHEN I MAKE BETTER OFFSET SYSTEM!
 
       // :nerd: um ackshually it's not 13 it's 11.97278911564
-      if (Paths.SOUND_EXT == 'mp3') Conductor.offset = Constants.MP3_DELAY_MS;
+      if (Constants.EXT_SOUND == 'mp3') Conductor.offset = Constants.MP3_DELAY_MS;
 
       Conductor.update();
 
@@ -1344,34 +1355,20 @@ class PlayState extends MusicBeatSubState
       trace('Song difficulty could not be loaded.');
     }
 
-    // Switch the character we are playing as by manipulating currentPlayerId.
-    // TODO: How to choose which one to use for story mode?
-    var playableChars:Array<String> = currentChart.getPlayableChars();
-
-    if (playableChars.length == 0)
-    {
-      trace('WARNING: No playable characters found for this song.');
-    }
-    else if (playableChars.indexOf(currentPlayerId) == -1)
-    {
-      currentPlayerId = playableChars[0];
-    }
-
-    //
-    var currentCharData:SongPlayableChar = currentChart.getPlayableChar(currentPlayerId);
+    var currentCharacterData:SongCharacterData = currentChart.characters; // Switch the character we are playing as by manipulating currentPlayerId.
 
     //
     // GIRLFRIEND
     //
-    var girlfriend:BaseCharacter = CharacterDataParser.fetchCharacter(currentCharData.girlfriend);
+    var girlfriend:BaseCharacter = CharacterDataParser.fetchCharacter(currentCharacterData.girlfriend);
 
     if (girlfriend != null)
     {
       girlfriend.characterType = CharacterType.GF;
     }
-    else if (currentCharData.girlfriend != '')
+    else if (currentCharacterData.girlfriend != '')
     {
-      trace('WARNING: Could not load girlfriend character with ID ${currentCharData.girlfriend}, skipping...');
+      trace('WARNING: Could not load girlfriend character with ID ${currentCharacterData.girlfriend}, skipping...');
     }
     else
     {
@@ -1381,7 +1378,7 @@ class PlayState extends MusicBeatSubState
     //
     // DAD
     //
-    var dad:BaseCharacter = CharacterDataParser.fetchCharacter(currentCharData.opponent);
+    var dad:BaseCharacter = CharacterDataParser.fetchCharacter(currentCharacterData.opponent);
 
     if (dad != null)
     {
@@ -1400,7 +1397,7 @@ class PlayState extends MusicBeatSubState
     //
     // BOYFRIEND
     //
-    var boyfriend:BaseCharacter = CharacterDataParser.fetchCharacter(currentPlayerId);
+    var boyfriend:BaseCharacter = CharacterDataParser.fetchCharacter(currentCharacterData.player);
 
     if (boyfriend != null)
     {
@@ -1549,7 +1546,7 @@ class PlayState extends MusicBeatSubState
 
     if (!overrideMusic)
     {
-      vocals = currentChart.buildVocals(currentPlayerId);
+      vocals = currentChart.buildVocals();
 
       if (vocals.members.length == 0)
       {
@@ -1893,6 +1890,7 @@ class PlayState extends MusicBeatSubState
       {
         // Grant the player health.
         health += Constants.HEALTH_HOLD_BONUS_PER_SECOND * elapsed;
+        songScore += Std.int(Constants.SCORE_HOLD_BONUS_PER_SECOND * elapsed);
       }
 
       // TODO: Potential penalty for dropping a hold note?
@@ -2021,19 +2019,16 @@ class PlayState extends MusicBeatSubState
     // Calling event.cancelEvent() skips all the other logic! Neat!
     if (event.eventCanceled) return;
 
-    if (!note.isHoldNote)
-    {
-      Highscore.tallies.combo++;
-      Highscore.tallies.totalNotesHit++;
+    Highscore.tallies.combo++;
+    Highscore.tallies.totalNotesHit++;
 
-      if (Highscore.tallies.combo > Highscore.tallies.maxCombo) Highscore.tallies.maxCombo = Highscore.tallies.combo;
+    if (Highscore.tallies.combo > Highscore.tallies.maxCombo) Highscore.tallies.maxCombo = Highscore.tallies.combo;
 
-      popUpScore(note, input);
-    }
+    popUpScore(note, input);
 
     playerStrumline.hitNote(note);
 
-    if (note.holdNoteSprite != null)
+    if (note.isHoldNote && note.holdNoteSprite != null)
     {
       playerStrumline.playNoteHoldCover(note.holdNoteSprite);
     }
diff --git a/source/funkin/play/cutscene/dialogue/ConversationData.hx b/source/funkin/play/cutscene/dialogue/ConversationData.hx
index 749f1b7a1..8c4aa9684 100644
--- a/source/funkin/play/cutscene/dialogue/ConversationData.hx
+++ b/source/funkin/play/cutscene/dialogue/ConversationData.hx
@@ -172,9 +172,13 @@ enum abstract BackdropType(String) from String to String
 class MusicData
 {
   public var asset:String;
-  public var looped:Bool;
+
   public var fadeTime:Float;
 
+  @:optional
+  @:default(false)
+  public var looped:Bool;
+
   public function new(asset:String, looped:Bool, fadeTime:Float = 0.0)
   {
     this.asset = asset;
diff --git a/source/funkin/play/cutscene/dialogue/ConversationDataParser.hx b/source/funkin/play/cutscene/dialogue/ConversationDataParser.hx
index c25b3e87f..9f80f8f9b 100644
--- a/source/funkin/play/cutscene/dialogue/ConversationDataParser.hx
+++ b/source/funkin/play/cutscene/dialogue/ConversationDataParser.hx
@@ -6,6 +6,7 @@ import funkin.play.cutscene.dialogue.ScriptedConversation;
 
 /**
  * Contains utilities for loading and parsing conversation data.
+ * TODO: Refactor to use the json2object + BaseRegistry system that actually validates things for you.
  */
 class ConversationDataParser
 {
diff --git a/source/funkin/play/song/Song.hx b/source/funkin/play/song/Song.hx
index e32eb8186..d11c7744b 100644
--- a/source/funkin/play/song/Song.hx
+++ b/source/funkin/play/song/Song.hx
@@ -11,7 +11,7 @@ import funkin.data.song.SongData.SongEventData;
 import funkin.data.song.SongData.SongNoteData;
 import funkin.data.song.SongRegistry;
 import funkin.data.song.SongData.SongMetadata;
-import funkin.data.song.SongData.SongPlayableChar;
+import funkin.data.song.SongData.SongCharacterData;
 import funkin.data.song.SongData.SongTimeChange;
 import funkin.data.song.SongData.SongTimeFormat;
 import funkin.data.IRegistryEntry;
@@ -92,6 +92,15 @@ class Song implements IPlayStateScriptedClass implements IRegistryEntry<SongMeta
 
     _metadata = _data == null ? [] : [_data];
 
+    variations.clear();
+    variations.push(Constants.DEFAULT_VARIATION);
+
+    if (_data != null && _data.playData != null)
+    {
+      for (vari in _data.playData.songVariations)
+        variations.push(vari);
+    }
+
     for (meta in fetchVariationMetadata(id))
       _metadata.push(meta);
 
@@ -101,15 +110,7 @@ class Song implements IPlayStateScriptedClass implements IRegistryEntry<SongMeta
       return;
     }
 
-    variations.clear();
-    variations.push('default');
-    if (_data != null && _data.playData != null)
-    {
-      for (vari in _data.playData.songVariations)
-        variations.push(vari);
-
-      populateFromMetadata();
-    }
+    populateDifficulties();
   }
 
   @:allow(funkin.play.song.Song)
@@ -128,7 +129,7 @@ class Song implements IPlayStateScriptedClass implements IRegistryEntry<SongMeta
 
     result.difficultyIds.clear();
 
-    result.populateFromMetadata();
+    result.populateDifficulties();
 
     for (variation => chartData in charts)
       result.applyChartData(chartData, variation);
@@ -144,10 +145,10 @@ class Song implements IPlayStateScriptedClass implements IRegistryEntry<SongMeta
   }
 
   /**
-   * Populate the song data from the provided metadata,
-   * including data from individual difficulties. Does not load chart data.
+   * Populate the difficulty data from the provided metadata.
+   * Does not load chart data (that is triggered later when we want to play the song).
    */
-  function populateFromMetadata():Void
+  function populateDifficulties():Void
   {
     if (_metadata == null || _metadata.length == 0) return;
 
@@ -176,18 +177,11 @@ class Song implements IPlayStateScriptedClass implements IRegistryEntry<SongMeta
         difficulty.generatedBy = metadata.generatedBy;
 
         difficulty.stage = metadata.playData.stage;
-        // difficulty.noteSkin = metadata.playData.noteSkin;
+        difficulty.noteStyle = metadata.playData.noteSkin;
 
         difficulties.set(diffId, difficulty);
 
-        difficulty.chars = new Map<String, SongPlayableChar>();
-        if (metadata.playData.playableChars == null) continue;
-        for (charId in metadata.playData.playableChars.keys())
-        {
-          var char:Null<SongPlayableChar> = metadata.playData.playableChars.get(charId);
-          if (char == null) continue;
-          difficulty.chars.set(charId, char);
-        }
+        difficulty.characters = metadata.playData.characters;
       }
     }
   }
@@ -321,7 +315,7 @@ class Song implements IPlayStateScriptedClass implements IRegistryEntry<SongMeta
     trace('Fetching song metadata for $id');
     var version:Null<thx.semver.Version> = SongRegistry.instance.fetchEntryMetadataVersion(id);
     if (version == null) return null;
-    return SongRegistry.instance.parseEntryMetadataWithMigration(id, '', version);
+    return SongRegistry.instance.parseEntryMetadataWithMigration(id, Constants.DEFAULT_VARIATION, version);
   }
 
   function fetchVariationMetadata(id:String):Array<SongMetadata>
@@ -365,19 +359,20 @@ class SongDifficulty
    */
   public var events:Array<SongEventData>;
 
-  public var songName:String = SongValidator.DEFAULT_SONGNAME;
-  public var songArtist:String = SongValidator.DEFAULT_ARTIST;
-  public var timeFormat:SongTimeFormat = SongValidator.DEFAULT_TIMEFORMAT;
-  public var divisions:Null<Int> = SongValidator.DEFAULT_DIVISIONS;
-  public var looped:Bool = SongValidator.DEFAULT_LOOPED;
+  public var songName:String = Constants.DEFAULT_SONGNAME;
+  public var songArtist:String = Constants.DEFAULT_ARTIST;
+  public var timeFormat:SongTimeFormat = Constants.DEFAULT_TIMEFORMAT;
+  public var divisions:Null<Int> = null;
+  public var looped:Bool = false;
   public var generatedBy:String = SongRegistry.DEFAULT_GENERATEDBY;
 
   public var timeChanges:Array<SongTimeChange> = [];
 
-  public var stage:String = SongValidator.DEFAULT_STAGE;
-  public var chars:Map<String, SongPlayableChar> = null;
+  public var stage:String = Constants.DEFAULT_STAGE;
+  public var noteStyle:String = Constants.DEFAULT_NOTE_STYLE;
+  public var characters:SongCharacterData = null;
 
-  public var scrollSpeed:Float = SongValidator.DEFAULT_SCROLLSPEED;
+  public var scrollSpeed:Float = Constants.DEFAULT_SCROLLSPEED;
 
   public function new(song:Song, diffId:String, variation:String)
   {
@@ -401,28 +396,24 @@ class SongDifficulty
     return timeChanges[0].bpm;
   }
 
-  public function getPlayableChar(id:String):Null<SongPlayableChar>
-  {
-    if (id == null || id == '') return null;
-    return chars.get(id);
-  }
-
-  public function getPlayableChars():Array<String>
-  {
-    return chars.keys().array();
-  }
-
   public function getEvents():Array<SongEventData>
   {
     return cast events;
   }
 
-  public inline function cacheInst(?currentPlayerId:String = null):Void
+  public function cacheInst(instrumental = ''):Void
   {
-    var currentPlayer:Null<SongPlayableChar> = getPlayableChar(currentPlayerId);
-    if (currentPlayer != null)
+    if (characters != null)
     {
-      FlxG.sound.cache(Paths.inst(this.song.id, currentPlayer.inst));
+      if (instrumental != '' && characters.altInstrumentals.contains(instrumental))
+      {
+        FlxG.sound.cache(Paths.inst(this.song.id, instrumental));
+      }
+      else
+      {
+        // Fallback to default instrumental.
+        FlxG.sound.cache(Paths.inst(this.song.id, characters.instrumental));
+      }
     }
     else
     {
@@ -440,9 +431,9 @@ class SongDifficulty
    * Cache the vocals for a given character.
    * @param id The character we are about to play.
    */
-  public inline function cacheVocals(?id:String = 'bf'):Void
+  public inline function cacheVocals():Void
   {
-    for (voice in buildVoiceList(id))
+    for (voice in buildVoiceList())
     {
       FlxG.sound.cache(voice);
     }
@@ -454,22 +445,15 @@ class SongDifficulty
    *
    * @param id The character we are about to play.
    */
-  public function buildVoiceList(?id:String = 'bf'):Array<String>
+  public function buildVoiceList():Array<String>
   {
-    var playableCharData:SongPlayableChar = getPlayableChar(id);
-    if (playableCharData == null)
-    {
-      trace('Could not find playable char $id for song ${this.song.id}');
-      return [];
-    }
-
     var suffix:String = (variation != null && variation != '' && variation != 'default') ? '-$variation' : '';
 
     // Automatically resolve voices by removing suffixes.
     // For example, if `Voices-bf-car.ogg` does not exist, check for `Voices-bf.ogg`.
 
-    var playerId:String = id;
-    var voicePlayer:String = Paths.voices(this.song.id, '-$id$suffix');
+    var playerId:String = characters.player;
+    var voicePlayer:String = Paths.voices(this.song.id, '-$playerId$suffix');
     while (voicePlayer != null && !Assets.exists(voicePlayer))
     {
       // Remove the last suffix.
@@ -479,7 +463,7 @@ class SongDifficulty
       voicePlayer = playerId == '' ? null : Paths.voices(this.song.id, '-${playerId}$suffix');
     }
 
-    var opponentId:String = playableCharData.opponent;
+    var opponentId:String = characters.opponent;
     var voiceOpponent:String = Paths.voices(this.song.id, '-${opponentId}$suffix');
     while (voiceOpponent != null && !Assets.exists(voiceOpponent))
     {
@@ -505,11 +489,11 @@ class SongDifficulty
    * @param charId The player ID.
    * @return The generated vocal group.
    */
-  public function buildVocals(charId:String = 'bf'):VoicesGroup
+  public function buildVocals():VoicesGroup
   {
     var result:VoicesGroup = new VoicesGroup();
 
-    var voiceList:Array<String> = buildVoiceList(charId);
+    var voiceList:Array<String> = buildVoiceList();
 
     if (voiceList.length == 0)
     {
diff --git a/source/funkin/play/song/SongMigrator.hx b/source/funkin/play/song/SongMigrator.hx
deleted file mode 100644
index 43393fa4e..000000000
--- a/source/funkin/play/song/SongMigrator.hx
+++ /dev/null
@@ -1,256 +0,0 @@
-package funkin.play.song;
-
-import funkin.play.song.formats.FNFLegacy;
-import funkin.data.song.SongData.SongChartData;
-import funkin.data.song.SongData.SongEventData;
-import funkin.data.song.SongData.SongMetadata;
-import funkin.data.song.SongData.SongNoteData;
-import funkin.data.song.SongData.SongPlayableChar;
-import funkin.util.VersionUtil;
-
-class SongMigrator
-{
-  /**
-   * The current latest version string for the song data format.
-   * Handle breaking changes by incrementing this value
-   * and adding migration to the SongMigrator class.
-   */
-  public static final CHART_VERSION:String = '2.0.0';
-
-  /**
-   * Version rule for which chart versions are compatible with the current version.
-   */
-  public static final CHART_VERSION_RULE:String = '2.0.x';
-
-  /**
-   * Migrate song data from an older chart version to the current version.
-   * @param jsonData The song metadata to migrate.
-   * @param songId The ID of the song (only used for error reporting).
-   * @return The migrated song metadata, or null if the migration failed.
-   */
-  public static function migrateSongMetadata(jsonData:Dynamic, songId:String):SongMetadata
-  {
-    if (jsonData.version != null)
-    {
-      if (VersionUtil.validateVersionStr(jsonData.version, CHART_VERSION_RULE))
-      {
-        trace('Song (${songId}) metadata version (${jsonData.version}) is valid and up-to-date.');
-
-        var songMetadata:SongMetadata = cast jsonData;
-
-        return songMetadata;
-      }
-      else
-      {
-        trace('Song (${songId}) metadata version (${jsonData.version}) is outdated.');
-        switch (jsonData.version)
-        {
-          case '1.0.0':
-            return migrateSongMetadataFromLegacy(jsonData);
-          default:
-            trace('Song (${songId}) has unknown metadata version (${jsonData.version}), assuming FNF Legacy.');
-            return migrateSongMetadataFromLegacy(jsonData);
-        }
-      }
-    }
-    else
-    {
-      trace('Song metadata version is missing.');
-    }
-    return null;
-  }
-
-  /**
-   * Migrate song chart data from an older chart version to the current version.
-   * @param jsonData The song chart data to migrate.
-   * @param songId The ID of the song (only used for error reporting).
-   * @return The migrated song chart data, or null if the migration failed.
-   */
-  public static function migrateSongChartData(jsonData:Dynamic, songId:String):SongChartData
-  {
-    if (jsonData.version)
-    {
-      if (VersionUtil.validateVersionStr(jsonData.version, CHART_VERSION_RULE))
-      {
-        trace('Song (${songId}) chart version (${jsonData.version}) is valid and up-to-date.');
-
-        var songChartData:SongChartData = cast jsonData;
-
-        return songChartData;
-      }
-      else
-      {
-        trace('Song (${songId}) chart version (${jsonData.version}) is outdated.');
-        switch (jsonData.version)
-        {
-          // TODO: Add migration functions as cases here.
-          default:
-            // Unknown version.
-            trace('Song (${songId}) unknown chart version: ${jsonData.version}');
-        }
-      }
-    }
-    else
-    {
-      trace('Song chart version is missing.');
-    }
-    return null;
-  }
-
-  /**
-   * Migrate song metadata from FNF Legacy chart version to the current version.
-   * @param jsonData The song metadata to migrate.
-   * @param songId The ID of the song (only used for error reporting).
-   * @return The migrated song metadata, or null if the migration failed.
-   */
-  public static function migrateSongMetadataFromLegacy(jsonData:Dynamic, difficulty:String = 'normal'):SongMetadata
-  {
-    trace('Migrating song metadata from FNF Legacy.');
-
-    var songData:FNFLegacy = cast jsonData;
-
-    var songMetadata:SongMetadata = new SongMetadata('Import', 'Kawai Sprite', 'default');
-
-    var hadError:Bool = false;
-
-    // Set generatedBy string for debugging.
-    songMetadata.generatedBy = 'Chart Editor Import (FNF Legacy)';
-
-    try
-    {
-      // Set the song's BPM.
-      songMetadata.timeChanges[0].bpm = songData.song.bpm;
-    }
-    catch (e)
-    {
-      trace("Couldn't parse BPM!");
-      hadError = true;
-    }
-
-    try
-    {
-      // Set the song's stage.
-      songMetadata.playData.stage = songData.song.stageDefault;
-    }
-    catch (e)
-    {
-      trace("Couldn't parse stage!");
-      hadError = true;
-    }
-
-    try
-    {
-      // Set's the song's name.
-      songMetadata.songName = songData.song.song;
-    }
-    catch (e)
-    {
-      trace("Couldn't parse song name!");
-      hadError = true;
-    }
-
-    songMetadata.playData.difficulties = [];
-    if (songData.song != null && songData.song.notes != null)
-    {
-      if (Std.isOfType(songData.song.notes, Array))
-      {
-        // One difficulty of notes.
-        songMetadata.playData.difficulties.push(difficulty);
-      }
-      else
-      {
-        // Multiple difficulties of notes.
-        var songNoteDataDynamic:haxe.DynamicAccess<Dynamic> = cast songData.song.notes;
-        for (difficultyKey in songNoteDataDynamic.keys())
-        {
-          songMetadata.playData.difficulties.push(difficultyKey);
-        }
-      }
-    }
-    else
-    {
-      trace("Couldn't parse difficulties!");
-      hadError = true;
-    }
-
-    songMetadata.playData.songVariations = [];
-
-    // Set the song's song variations.
-    songMetadata.playData.playableChars = [];
-    try
-    {
-      songMetadata.playData.playableChars.set(songData.song.player1, new SongPlayableChar('', songData.song.player2));
-    }
-    catch (e)
-    {
-      trace("Couldn't parse characters!");
-      hadError = true;
-    }
-
-    return songMetadata;
-  }
-
-  /**
-   * Migrate song chart data from FNF Legacy chart version to the current version.
-   * @param jsonData The song data to migrate.
-   * @param songId The ID of the song (only used for error reporting).
-   * @param difficulty The difficulty to migrate.
-   * @return The migrated song chart data, or null if the migration failed.
-   */
-  public static function migrateSongChartDataFromLegacy(jsonData:Dynamic, difficulty:String = 'normal'):SongChartData
-  {
-    trace('Migrating song chart data from FNF Legacy.');
-
-    var songData:FNFLegacy = cast jsonData;
-
-    var songChartData:SongChartData = new SongChartData(["normal" => 1.0], [], ["normal" => []]);
-
-    var songEventsEmpty:Bool = songChartData.getEvents() == null || songChartData.getEvents().length == 0;
-    if (songEventsEmpty) songChartData.setEvents(migrateSongEventDataFromLegacy(songData.song.notes));
-    songChartData.setNotes(migrateSongNoteDataFromLegacy(songData.song.notes), difficulty);
-    songChartData.setScrollSpeed(songData.song.speed, difficulty);
-
-    return songChartData;
-  }
-
-  static function migrateSongNoteDataFromLegacy(sections:Array<LegacyNoteSection>):Array<SongNoteData>
-  {
-    var songNotes:Array<SongNoteData> = [];
-
-    for (section in sections)
-    {
-      // Skip empty sections.
-      if (section.sectionNotes.length == 0) continue;
-
-      for (note in section.sectionNotes)
-      {
-        songNotes.push(new SongNoteData(note.time, note.getData(section.mustHitSection), note.length, note.kind));
-      }
-    }
-
-    return songNotes;
-  }
-
-  static function migrateSongEventDataFromLegacy(sections:Array<LegacyNoteSection>):Array<SongEventData>
-  {
-    var songEvents:Array<SongEventData> = [];
-
-    var lastSectionWasMustHit:Null<Bool> = null;
-    for (section in sections)
-    {
-      // Skip empty sections.
-      if (section.sectionNotes.length == 0) continue;
-
-      if (section.mustHitSection != lastSectionWasMustHit)
-      {
-        lastSectionWasMustHit = section.mustHitSection;
-
-        var firstNote:LegacyNote = section.sectionNotes[0];
-
-        songEvents.push(new SongEventData(firstNote.time, 'FocusCamera', {char: section.mustHitSection ? 0 : 1}));
-      }
-    }
-
-    return songEvents;
-  }
-}
diff --git a/source/funkin/play/song/SongSerializer.hx b/source/funkin/play/song/SongSerializer.hx
index a0a468c5b..10296e5b4 100644
--- a/source/funkin/play/song/SongSerializer.hx
+++ b/source/funkin/play/song/SongSerializer.hx
@@ -3,14 +3,14 @@ package funkin.play.song;
 import funkin.data.song.SongData.SongChartData;
 import funkin.data.song.SongData.SongMetadata;
 import funkin.util.SerializerUtil;
+import funkin.util.FileUtil;
 import lime.utils.Bytes;
 import openfl.events.Event;
 import openfl.events.IOErrorEvent;
 import openfl.net.FileReference;
 
 /**
- * Utilities for exporting a chart to a JSON file.
- * Primarily used for the chart editor.
+ * TODO: Refactor and remove this.
  */
 class SongSerializer
 {
@@ -20,7 +20,7 @@ class SongSerializer
    */
   public static function importSongChartDataSync(path:String):SongChartData
   {
-    var fileData = readFile(path);
+    var fileData = FileUtil.readStringFromPath(path);
 
     if (fileData == null) return null;
 
@@ -35,7 +35,7 @@ class SongSerializer
    */
   public static function importSongMetadataSync(path:String):SongMetadata
   {
-    var fileData = readFile(path);
+    var fileData = FileUtil.readStringFromPath(path);
 
     if (fileData == null) return null;
 
@@ -50,7 +50,7 @@ class SongSerializer
    */
   public static function importSongChartDataAsync(callback:SongChartData->Void):Void
   {
-    browseFileReference(function(fileReference:FileReference) {
+    FileUtil.browseFileReference(function(fileReference:FileReference) {
       var data = fileReference.data.toString();
 
       if (data == null) return;
@@ -67,7 +67,7 @@ class SongSerializer
    */
   public static function importSongMetadataAsync(callback:SongMetadata->Void):Void
   {
-    browseFileReference(function(fileReference:FileReference) {
+    FileUtil.browseFileReference(function(fileReference:FileReference) {
       var data = fileReference.data.toString();
 
       if (data == null) return;
@@ -77,126 +77,4 @@ class SongSerializer
       if (songMetadata != null) callback(songMetadata);
     });
   }
-
-  /**
-   * Save a SongChartData object as a JSON file to an automatically generated path.
-   * Works great on HTML5 and desktop.
-   */
-  public static function exportSongChartData(data:SongChartData, songId:String)
-  {
-    var path = '${songId}-chart.json';
-    exportSongChartDataAs(path, data);
-  }
-
-  /**
-   * Save a SongMetadata object as a JSON file to an automatically generated path.
-   * Works great on HTML5 and desktop.
-   */
-  public static function exportSongMetadata(data:SongMetadata, songId:String)
-  {
-    var path = '${songId}-metadata.json';
-    exportSongMetadataAs(path, data);
-  }
-
-  /**
-   * Save a SongChartData object as a JSON file to a specified path.
-   * Works great on HTML5 and desktop.
-   *
-   * @param	path The file path to save to.
-   */
-  public static function exportSongChartDataAs(path:String, data:SongChartData)
-  {
-    var dataString = SerializerUtil.toJSON(data);
-
-    writeFileReference(path, dataString);
-  }
-
-  /**
-   * Save a SongMetadata object as a JSON file to a specified path.
-   * Works great on HTML5 and desktop.
-   *
-   * @param	path The file path to save to.
-   */
-  public static function exportSongMetadataAs(path:String, data:SongMetadata)
-  {
-    var dataString = SerializerUtil.toJSON(data);
-
-    writeFileReference(path, dataString);
-  }
-
-  /**
-   * Read the string contents of a file.
-   * Only works on desktop platforms.
-   * @param	path The file path to read from.
-   */
-  static function readFile(path:String):String
-  {
-    #if sys
-    var fileBytes:Bytes = sys.io.File.getBytes(path);
-
-    if (fileBytes == null) return null;
-
-    return fileBytes.toString();
-    #end
-
-    trace('ERROR: readFile not implemented for this platform');
-    return null;
-  }
-
-  /**
-   * Write string contents to a file.
-   * Only works on desktop platforms.
-   * @param	path The file path to read from.
-   */
-  static function writeFile(path:String, data:String):Void
-  {
-    #if sys
-    sys.io.File.saveContent(path, data);
-    return;
-    #end
-    trace('ERROR: writeFile not implemented for this platform');
-    return;
-  }
-
-  /**
-   * Browse for a file to read and execute a callback once we have a file reference.
-   * Works great on HTML5 or desktop.
-   *
-   * @param	callback The function to call when the file is loaded.
-   */
-  static function browseFileReference(callback:FileReference->Void)
-  {
-    var file = new FileReference();
-
-    file.addEventListener(Event.SELECT, function(e) {
-      var selectedFileRef:FileReference = e.target;
-      trace('Selected file: ' + selectedFileRef.name);
-      selectedFileRef.addEventListener(Event.COMPLETE, function(e) {
-        var loadedFileRef:FileReference = e.target;
-        trace('Loaded file: ' + loadedFileRef.name);
-        callback(loadedFileRef);
-      });
-      selectedFileRef.load();
-    });
-
-    file.browse();
-  }
-
-  /**
-   * Prompts the user to save a file to their computer.
-   */
-  static function writeFileReference(path:String, data:String)
-  {
-    var file = new FileReference();
-    file.addEventListener(Event.COMPLETE, function(e:Event) {
-      trace('Successfully wrote file.');
-    });
-    file.addEventListener(Event.CANCEL, function(e:Event) {
-      trace('Cancelled writing file.');
-    });
-    file.addEventListener(IOErrorEvent.IO_ERROR, function(e:IOErrorEvent) {
-      trace('IO error writing file.');
-    });
-    file.save(data, path);
-  }
 }
diff --git a/source/funkin/play/song/SongValidator.hx b/source/funkin/play/song/SongValidator.hx
deleted file mode 100644
index e33ddd87c..000000000
--- a/source/funkin/play/song/SongValidator.hx
+++ /dev/null
@@ -1,149 +0,0 @@
-package funkin.play.song;
-
-import funkin.data.song.SongRegistry;
-import funkin.data.song.SongData.SongChartData;
-import funkin.data.song.SongData.SongMetadata;
-import funkin.data.song.SongData.SongPlayData;
-import funkin.data.song.SongData.SongTimeChange;
-import funkin.data.song.SongData.SongTimeFormat;
-
-/**
- * For SongMetadata and SongChartData objects,
- * ensures mandatory fields are present and populates optional fields with default values.
- */
-class SongValidator
-{
-  public static final DEFAULT_SONGNAME:String = "Unknown";
-  public static final DEFAULT_ARTIST:String = "Unknown";
-  public static final DEFAULT_TIMEFORMAT:SongTimeFormat = SongTimeFormat.MILLISECONDS;
-  public static final DEFAULT_DIVISIONS:Null<Int> = null;
-  public static final DEFAULT_LOOPED:Bool = false;
-  public static final DEFAULT_STAGE:String = "mainStage";
-  public static final DEFAULT_SCROLLSPEED:Float = 1.0;
-
-  public static var DEFAULT_GENERATEDBY(get, never):String;
-
-  static function get_DEFAULT_GENERATEDBY():String
-  {
-    return '${Constants.TITLE} - ${Constants.VERSION}';
-  }
-
-  /**
-   * Validates the fields of a SongMetadata object (excluding the version field).
-   *
-   * @param input The SongMetadata object to validate.
-   * @param songId The ID of the song being validated. Only used for error messages.
-   * @return The validated SongMetadata object.
-   */
-  public static function validateSongMetadata(input:SongMetadata, songId:String = 'unknown'):SongMetadata
-  {
-    if (input == null)
-    {
-      trace('[SONGDATA] Could not parse metadata for song ${songId}');
-      return null;
-    }
-
-    if (input.songName == null)
-    {
-      trace('[SONGDATA] Song ${songId} is missing a songName field. ');
-      input.songName = DEFAULT_SONGNAME;
-    }
-    if (input.artist == null)
-    {
-      trace('[SONGDATA] Song ${songId} is missing an artist field. ');
-      input.artist = DEFAULT_ARTIST;
-    }
-    if (input.timeFormat == null)
-    {
-      trace('[SONGDATA] Song ${songId} is missing a timeFormat field. ');
-      input.timeFormat = DEFAULT_TIMEFORMAT;
-    }
-    if (input.generatedBy == null)
-    {
-      input.generatedBy = SongRegistry.DEFAULT_GENERATEDBY;
-    }
-
-    input.timeChanges = validateTimeChanges(input.timeChanges, songId);
-    if (input.timeChanges == null)
-    {
-      trace('[SONGDATA] Song ${songId} is missing a timeChanges field. ');
-      return null;
-    }
-
-    input.playData = validatePlayData(input.playData, songId);
-
-    if (input.variation == null) input.variation = '';
-
-    return input;
-  }
-
-  /**
-   * Validates the fields of a SongPlayData object.
-   *
-   * @param input The SongPlayData object to validate.
-   * @param songId The ID of the song being validated. Only used for error messages.
-   * @return The validated SongPlayData object.
-   */
-  public static function validatePlayData(input:SongPlayData, songId:String = 'unknown'):SongPlayData
-  {
-    if (input == null)
-    {
-      trace('[SONGDATA] Could not parse metadata.playData for song ${songId}');
-      return null;
-    }
-
-    return input;
-  }
-
-  /**
-   * Validates the fields of a TimeChange object.
-   *
-   * @param input The TimeChange object to validate.
-   * @param songId The ID of the song being validated. Only used for error messages.
-   * @return The validated TimeChange object.
-   */
-  public static function validateTimeChange(input:SongTimeChange, songId:String = 'unknown'):SongTimeChange
-  {
-    if (input == null)
-    {
-      trace('[SONGDATA] Could not parse metadata.timeChange for song ${songId}');
-      return null;
-    }
-
-    return input;
-  }
-
-  /**
-   * Validates multiple TimeChange objects in an array.
-   */
-  public static function validateTimeChanges(input:Array<SongTimeChange>, songId:String = 'unknown'):Array<SongTimeChange>
-  {
-    if (input == null)
-    {
-      trace('[SONGDATA] Could not parse metadata.timeChange for song ${songId}');
-      return null;
-    }
-
-    input = input.map((timeChange) -> validateTimeChange(timeChange, songId));
-
-    return input;
-  }
-
-  /**
-   * Validates the fields of a SongChartData object (excluding the version field).
-   *
-   * @param input The SongChartData object to validate.
-   * @param songId The ID of the song being validated. Only used for error messages.
-   * @return The validated SongChartData object.
-   */
-  public static function validateSongChartData(input:SongChartData, songId:String = 'unknown'):SongChartData
-  {
-    if (input == null)
-    {
-      trace('[SONGDATA] Could not parse chart data for song ${songId}');
-      return null;
-    }
-
-    return input;
-  }
-}
diff --git a/source/funkin/play/song/formats/FNFLegacy.hx b/source/funkin/play/song/formats/FNFLegacy.hx
deleted file mode 100644
index a64e461bd..000000000
--- a/source/funkin/play/song/formats/FNFLegacy.hx
+++ /dev/null
@@ -1,131 +0,0 @@
-package funkin.play.song.formats;
-
-typedef FNFLegacy =
-{
-  var song:LegacySongData;
-}
-
-typedef LegacySongData =
-{
-  var player1:String; // Boyfriend
-  var player2:String; // Opponent
-
-  var speed:Float;
-  var stageDefault:String;
-  var bpm:Float;
-  var notes:Array<LegacyNoteSection>;
-  var song:String; // Song name
-};
-
-typedef LegacyScrollSpeeds =
-{
-  var easy:Float;
-  var normal:Float;
-  var hard:Float;
-};
-
-typedef LegacyNoteData =
-{
-  /**
-   * The easy difficulty.
-   */
-  var ?easy:Array<LegacyNoteSection>;
-
-  /**
-   * The normal difficulty.
-   */
-  var ?normal:Array<LegacyNoteSection>;
-
-  /**
-   * The hard difficulty.
-   */
-  var ?hard:Array<LegacyNoteSection>;
-};
-
-typedef LegacyNoteSection =
-{
-  /**
-   * Whether the section is a must-hit section.
-   * If true, 0-3 are boyfriends notes, 4-7 are opponents notes.
-   * If false, 0-3 are opponents notes, 4-7 are boyfriends notes.
-   */
-  var mustHitSection:Bool;
-
-  /**
-   * Array of note data:
-   * - Direction
-   * - Time (ms)
-   * - Sustain Duration (ms)
-   * - Note kind (true = "alt", or string)
-   */
-  var sectionNotes:Array<LegacyNote>;
-
-  var typeOfSection:Int;
-  var lengthInSteps:Int;
-}
-
-/**
- * Notes in the old format are stored as an Array<Dynamic>
- */
-abstract LegacyNote(Array<Dynamic>)
-{
-  public var time(get, set):Float;
-
-  function get_time():Float
-  {
-    return this[0];
-  }
-
-  function set_time(value:Float):Float
-  {
-    return this[0] = value;
-  }
-
-  public var data(get, set):Int;
-
-  function get_data():Int
-  {
-    return this[1];
-  }
-
-  function set_data(value:Int):Int
-  {
-    return this[1] = value;
-  }
-
-  public function getData(mustHitSection:Bool):Int
-  {
-    if (mustHitSection) return this[1];
-
-    return (this[1] + 4) % 8;
-  }
-
-  public var length(get, set):Float;
-
-  function get_length():Float
-  {
-    if (this.length < 3) return 0.0;
-    return this[2];
-  }
-
-  function set_length(value:Float):Float
-  {
-    return this[2] = value;
-  }
-
-  public var kind(get, set):String;
-
-  function get_kind():String
-  {
-    if (this.length < 4) return 'normal';
-
-    if (Std.isOfType(this[3], Bool)) return this[3] ? 'alt' : 'normal';
-
-    return this[3];
-  }
-
-  function set_kind(value:String):String
-  {
-    return this[3] = value;
-  }
-}
diff --git a/source/funkin/play/stage/Stage.hx b/source/funkin/play/stage/Stage.hx
index 1ac9b0b67..d9875e456 100644
--- a/source/funkin/play/stage/Stage.hx
+++ b/source/funkin/play/stage/Stage.hx
@@ -649,16 +649,20 @@ class Stage extends FlxSpriteGroup implements IPlayStateScriptedClass
     }
     boppers = [];
 
-    for (sprite in this.group)
+    if (group != null)
     {
-      if (sprite != null)
+      for (sprite in this.group)
       {
-        sprite.kill();
-        sprite.destroy();
-        remove(sprite);
+        if (sprite != null)
+        {
+          sprite.kill();
+          sprite.destroy();
+          remove(sprite);
+        }
       }
+      group.clear();
     }
-    group.clear();
+
     if (debugIconGroup != null && debugIconGroup.group != null)
     {
       debugIconGroup.kill();
diff --git a/source/funkin/shaderslmfao/BlendModesShader.hx b/source/funkin/shaderslmfao/BlendModesShader.hx
new file mode 100644
index 000000000..6807a65c0
--- /dev/null
+++ b/source/funkin/shaderslmfao/BlendModesShader.hx
@@ -0,0 +1,23 @@
+package funkin.shaderslmfao;
+
+import flixel.addons.display.FlxRuntimeShader;
+import funkin.Paths;
+import openfl.utils.Assets;
+import openfl.display.BitmapData;
+
+class BlendModesShader extends FlxRuntimeShader
+{
+  public var camera:BitmapData;
+
+  public function new()
+  {
+    super(Assets.getText(Paths.frag('blendModes')));
+  }
+
+  public function setCamera(camera:BitmapData):Void
+  {
+    this.camera = camera;
+
+    this.setBitmapData('camera', camera);
+  }
+}
diff --git a/source/funkin/shaderslmfao/GaussianBlurShader.hx b/source/funkin/shaderslmfao/GaussianBlurShader.hx
new file mode 100644
index 000000000..ad472ac31
--- /dev/null
+++ b/source/funkin/shaderslmfao/GaussianBlurShader.hx
@@ -0,0 +1,25 @@
+package funkin.shaderslmfao;
+
+import flixel.addons.display.FlxRuntimeShader;
+import funkin.Paths;
+import openfl.utils.Assets;
+
+/**
+ * Note... not actually gaussian!
+ */
+class GaussianBlurShader extends FlxRuntimeShader
+{
+  public var amount:Float;
+
+  public function new(amount:Float = 1.0)
+  {
+    super(Assets.getText(Paths.frag("gaussianBlur")));
+    setAmount(amount);
+  }
+
+  public function setAmount(value:Float):Void
+  {
+    this.amount = value;
+    this.setFloat("amount", amount);
+  }
+}
diff --git a/source/funkin/shaderslmfao/Grayscale.hx b/source/funkin/shaderslmfao/Grayscale.hx
new file mode 100644
index 000000000..016d64b46
--- /dev/null
+++ b/source/funkin/shaderslmfao/Grayscale.hx
@@ -0,0 +1,22 @@
+package funkin.shaderslmfao;
+
+import flixel.addons.display.FlxRuntimeShader;
+import funkin.Paths;
+import openfl.utils.Assets;
+
+class Grayscale extends FlxRuntimeShader
+{
+  public var amount:Float = 1;
+
+  public function new(amount:Float = 1)
+  {
+    super(Assets.getText(Paths.frag("grayscale")));
+    setAmount(amount);
+  }
+
+  public function setAmount(value:Float):Void
+  {
+    amount = value;
+    this.setFloat("amount", amount);
+  }
+}
diff --git a/source/funkin/shaderslmfao/HSVShader.hx b/source/funkin/shaderslmfao/HSVShader.hx
new file mode 100644
index 000000000..066a49c96
--- /dev/null
+++ b/source/funkin/shaderslmfao/HSVShader.hx
@@ -0,0 +1,44 @@
+package funkin.shaderslmfao;
+
+import flixel.addons.display.FlxRuntimeShader;
+import funkin.Paths;
+import openfl.utils.Assets;
+
+class HSVShader extends FlxRuntimeShader
+{
+  public var hue(default, set):Float;
+  public var saturation(default, set):Float;
+  public var value(default, set):Float;
+
+  public function new()
+  {
+    super(Assets.getText(Paths.frag('hsv')));
+    hue = 1;
+    saturation = 1;
+    value = 1;
+  }
+
+  function set_hue(value:Float):Float
+  {
+    this.setFloat('hue', value);
+    this.hue = value;
+
+    return this.hue;
+  }
+
+  function set_saturation(value:Float):Float
+  {
+    this.setFloat('sat', value);
+    this.saturation = value;
+
+    return this.saturation;
+  }
+
+  function set_value(value:Float):Float
+  {
+    this.setFloat('val', value);
+    this.value = value;
+
+    return this.value;
+  }
+}
diff --git a/source/funkin/ui/StickerSubState.hx b/source/funkin/ui/StickerSubState.hx
index 1d9edab0e..a4e3a6acb 100644
--- a/source/funkin/ui/StickerSubState.hx
+++ b/source/funkin/ui/StickerSubState.hx
@@ -17,6 +17,9 @@ import openfl.geom.Matrix;
 import openfl.display.Sprite;
 import openfl.display.Bitmap;
 
+using Lambda;
+using StringTools;
+
 class StickerSubState extends MusicBeatSubState
 {
   public var grpStickers:FlxTypedGroup<StickerSprite>;
@@ -26,10 +29,60 @@ class StickerSubState extends MusicBeatSubState
 
   var nextState:NEXTSTATE = FREEPLAY;
 
+  // what "folders" to potentially load from (as of writing only "keys" exist)
+  var soundSelections:Array<String> = [];
+  // what "folder" was randomly selected
+  var soundSelection:String = "";
+  var sounds:Array<String> = [];
+
   public function new(?oldStickers:Array<StickerSprite>, ?nextState:NEXTSTATE = FREEPLAY):Void
   {
     super();
 
+    // todo still
+    // make sure that ONLY plays mp3/ogg files
+    // if there's no mp3/ogg file, then it regenerates/reloads the random folder
+
+    var assetsInList = openfl.utils.Assets.list();
+
+    var soundFilterFunc = function(a:String) {
+      return a.startsWith('assets/shared/sounds/stickersounds/');
+    };
+
+    soundSelections = assetsInList.filter(soundFilterFunc);
+    soundSelections = soundSelections.map(function(a:String) {
+      return a.replace('assets/shared/sounds/stickersounds/', '').split('/')[0];
+    });
+
+    // cracked cleanup... yuchh...
+    for (i in soundSelections)
+    {
+      while (soundSelections.contains(i))
+      {
+        soundSelections.remove(i);
+      }
+      soundSelections.push(i);
+    }
+
+    trace(soundSelections);
+
+    soundSelection = FlxG.random.getObject(soundSelections);
+
+    var filterFunc = function(a:String) {
+      return a.startsWith('assets/shared/sounds/stickersounds/' + soundSelection + '/');
+    };
+    var assetsInList3 = openfl.utils.Assets.list();
+    sounds = assetsInList3.filter(filterFunc);
+    for (i in 0...sounds.length)
+    {
+      sounds[i] = sounds[i].replace('assets/shared/sounds/', '');
+      sounds[i] = sounds[i].substring(0, sounds[i].lastIndexOf('.'));
+    }
+
+    trace(sounds);
+
+    // trace(assetsInList);
+
     this.nextState = nextState;
 
     grpStickers = new FlxTypedGroup<StickerSprite>();
@@ -66,6 +119,8 @@ class StickerSubState extends MusicBeatSubState
     {
       new FlxTimer().start(sticker.timing, _ -> {
         sticker.visible = false;
+        var daSound:String = FlxG.random.getObject(sounds);
+        FlxG.sound.play(Paths.sound(daSound));
 
         if (grpStickers == null || ind == grpStickers.members.length - 1)
         {
@@ -151,7 +206,11 @@ 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));
 
         var frameTimer:Int = FlxG.random.int(0, 2);
 
@@ -212,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/debug/charting/ChartEditorAudioHandler.hx b/source/funkin/ui/debug/charting/ChartEditorAudioHandler.hx
new file mode 100644
index 000000000..b5a6f36be
--- /dev/null
+++ b/source/funkin/ui/debug/charting/ChartEditorAudioHandler.hx
@@ -0,0 +1,273 @@
+package funkin.ui.debug.charting;
+
+import flixel.system.FlxAssets.FlxSoundAsset;
+import flixel.system.FlxSound;
+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.
+ */
+@:nullSafety
+@:allow(funkin.ui.debug.charting.ChartEditorState)
+@:allow(funkin.ui.debug.charting.ChartEditorDialogHandler)
+@:allow(funkin.ui.debug.charting.ChartEditorImportExportHandler)
+class ChartEditorAudioHandler
+{
+  /**
+   * Loads and stores byte data for a vocal track from an absolute file path
+   *
+   * @param path The absolute path to the audio file.
+   * @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, charId:String, instId:String = ''):Bool
+  {
+    #if sys
+    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;
+    #end
+  }
+
+  /**
+   * Loads and stores byte data for a vocal track from an asset
+   *
+   * @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, charId:String, instId:String = ''):Bool
+  {
+    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 BF:
+          state.audioVocalTrackGroup.addPlayerVoice(vocalTrack);
+          return true;
+        case DAD:
+          state.audioVocalTrackGroup.addOpponentVoice(vocalTrack);
+          return true;
+        case OTHER:
+          state.audioVocalTrackGroup.add(vocalTrack);
+          return true;
+        default:
+          // Do nothing.
+      }
+    }
+    return false;
+  }
+
+  static function stopExistingVocals(state:ChartEditorState):Void
+  {
+    if (state.audioVocalTrackGroup != null)
+    {
+      state.audioVocalTrackGroup.clear();
+    }
+  }
+
+  /**
+   * 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 79f58a098..3328336e6 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;
@@ -64,7 +66,7 @@ class AddNotesCommand implements ChartEditorCommand
       state.currentEventSelection = [];
     }
 
-    state.playSound(Paths.sound('funnyNoise/funnyNoise-08'));
+    ChartEditorAudioHandler.playSound(Paths.sound('funnyNoise/funnyNoise-08'));
 
     state.saveDataDirty = true;
     state.noteDisplayDirty = true;
@@ -78,7 +80,7 @@ class AddNotesCommand implements ChartEditorCommand
     state.currentSongChartNoteData = SongDataUtils.subtractNotes(state.currentSongChartNoteData, notes);
     state.currentNoteSelection = [];
     state.currentEventSelection = [];
-    state.playSound(Paths.sound('funnyNoise/funnyNoise-01'));
+    ChartEditorAudioHandler.playSound(Paths.sound('funnyNoise/funnyNoise-01'));
 
     state.saveDataDirty = true;
     state.noteDisplayDirty = true;
@@ -114,7 +116,7 @@ class RemoveNotesCommand implements ChartEditorCommand
     state.currentSongChartNoteData = SongDataUtils.subtractNotes(state.currentSongChartNoteData, notes);
     state.currentNoteSelection = [];
     state.currentEventSelection = [];
-    state.playSound(Paths.sound('funnyNoise/funnyNoise-01'));
+    ChartEditorAudioHandler.playSound(Paths.sound('funnyNoise/funnyNoise-01'));
 
     state.saveDataDirty = true;
     state.noteDisplayDirty = true;
@@ -131,7 +133,7 @@ class RemoveNotesCommand implements ChartEditorCommand
     }
     state.currentNoteSelection = notes;
     state.currentEventSelection = [];
-    state.playSound(Paths.sound('funnyNoise/funnyNoise-08'));
+    ChartEditorAudioHandler.playSound(Paths.sound('funnyNoise/funnyNoise-08'));
 
     state.saveDataDirty = true;
     state.noteDisplayDirty = true;
@@ -252,7 +254,7 @@ class AddEventsCommand implements ChartEditorCommand
       state.currentEventSelection = events;
     }
 
-    state.playSound(Paths.sound('funnyNoise/funnyNoise-08'));
+    ChartEditorAudioHandler.playSound(Paths.sound('funnyNoise/funnyNoise-08'));
 
     state.saveDataDirty = true;
     state.noteDisplayDirty = true;
@@ -296,7 +298,7 @@ class RemoveEventsCommand implements ChartEditorCommand
   {
     state.currentSongChartEventData = SongDataUtils.subtractEvents(state.currentSongChartEventData, events);
     state.currentEventSelection = [];
-    state.playSound(Paths.sound('funnyNoise/funnyNoise-01'));
+    ChartEditorAudioHandler.playSound(Paths.sound('funnyNoise/funnyNoise-01'));
 
     state.saveDataDirty = true;
     state.noteDisplayDirty = true;
@@ -312,7 +314,7 @@ class RemoveEventsCommand implements ChartEditorCommand
       state.currentSongChartEventData.push(event);
     }
     state.currentEventSelection = events;
-    state.playSound(Paths.sound('funnyNoise/funnyNoise-08'));
+    ChartEditorAudioHandler.playSound(Paths.sound('funnyNoise/funnyNoise-08'));
 
     state.saveDataDirty = true;
     state.noteDisplayDirty = true;
@@ -352,7 +354,7 @@ class RemoveItemsCommand implements ChartEditorCommand
     state.currentNoteSelection = [];
     state.currentEventSelection = [];
 
-    state.playSound(Paths.sound('funnyNoise/funnyNoise-01'));
+    ChartEditorAudioHandler.playSound(Paths.sound('funnyNoise/funnyNoise-01'));
 
     state.saveDataDirty = true;
     state.noteDisplayDirty = true;
@@ -376,7 +378,7 @@ class RemoveItemsCommand implements ChartEditorCommand
     state.currentNoteSelection = notes;
     state.currentEventSelection = events;
 
-    state.playSound(Paths.sound('funnyNoise/funnyNoise-08'));
+    ChartEditorAudioHandler.playSound(Paths.sound('funnyNoise/funnyNoise-08'));
 
     state.saveDataDirty = true;
     state.noteDisplayDirty = true;
@@ -760,6 +762,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));
 
@@ -773,6 +791,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 6f44f89a2..30f0381c6 100644
--- a/source/funkin/ui/debug/charting/ChartEditorDialogHandler.hx
+++ b/source/funkin/ui/debug/charting/ChartEditorDialogHandler.hx
@@ -1,40 +1,45 @@
 package funkin.ui.debug.charting;
 
-import funkin.play.character.CharacterData;
-import funkin.util.Constants;
-import funkin.util.SerializerUtil;
+import funkin.ui.haxeui.components.FunkinDropDown;
+import flixel.util.FlxTimer;
+import funkin.data.song.importer.FNFLegacyData;
+import funkin.data.song.importer.FNFLegacyImporter;
+import funkin.data.song.SongData.SongCharacterData;
 import funkin.data.song.SongData.SongChartData;
 import funkin.data.song.SongData.SongMetadata;
-import flixel.util.FlxTimer;
-import funkin.ui.haxeui.components.FunkinLink;
-import funkin.util.SortUtil;
+import funkin.data.song.SongData.SongTimeChange;
+import funkin.data.song.SongRegistry;
 import funkin.input.Cursor;
 import funkin.play.character.BaseCharacter;
+import funkin.play.character.CharacterData;
 import funkin.play.character.CharacterData.CharacterDataParser;
 import funkin.play.song.Song;
-import funkin.play.song.SongMigrator;
-import funkin.play.song.SongValidator;
-import funkin.data.song.SongRegistry;
-import funkin.data.song.SongData.SongPlayableChar;
-import funkin.data.song.SongData.SongTimeChange;
+import funkin.play.stage.StageData;
+import funkin.ui.haxeui.components.FunkinLink;
+import funkin.util.Constants;
 import funkin.util.FileUtil;
+import funkin.util.SerializerUtil;
+import funkin.util.SortUtil;
+import funkin.util.VersionUtil;
 import haxe.io.Path;
 import haxe.ui.components.Button;
 import haxe.ui.components.DropDown;
 import haxe.ui.components.Label;
 import haxe.ui.components.Link;
 import haxe.ui.components.NumberStepper;
+import haxe.ui.components.Slider;
 import haxe.ui.components.TextField;
 import haxe.ui.containers.Box;
 import haxe.ui.containers.dialogs.Dialog;
+import haxe.ui.containers.dialogs.Dialog.DialogButton;
 import haxe.ui.containers.dialogs.Dialogs;
-import haxe.ui.containers.properties.PropertyGrid;
-import haxe.ui.containers.properties.PropertyGroup;
+import haxe.ui.containers.Form;
 import haxe.ui.containers.VBox;
 import haxe.ui.core.Component;
 import haxe.ui.events.UIEvent;
 import haxe.ui.notifications.NotificationManager;
 import haxe.ui.notifications.NotificationType;
+import thx.semver.Version;
 
 using Lambda;
 
@@ -48,13 +53,14 @@ class ChartEditorDialogHandler
   static final CHART_EDITOR_DIALOG_WELCOME_LAYOUT:String = Paths.ui('chart-editor/dialogs/welcome');
   static final CHART_EDITOR_DIALOG_UPLOAD_INST_LAYOUT:String = Paths.ui('chart-editor/dialogs/upload-inst');
   static final CHART_EDITOR_DIALOG_SONG_METADATA_LAYOUT:String = Paths.ui('chart-editor/dialogs/song-metadata');
-  static final CHART_EDITOR_DIALOG_SONG_METADATA_CHARGROUP_LAYOUT:String = Paths.ui('chart-editor/dialogs/song-metadata-chargroup');
   static final CHART_EDITOR_DIALOG_UPLOAD_VOCALS_LAYOUT:String = Paths.ui('chart-editor/dialogs/upload-vocals');
   static final CHART_EDITOR_DIALOG_UPLOAD_VOCALS_ENTRY_LAYOUT:String = Paths.ui('chart-editor/dialogs/upload-vocals-entry');
   static final CHART_EDITOR_DIALOG_OPEN_CHART_LAYOUT:String = Paths.ui('chart-editor/dialogs/open-chart');
   static final CHART_EDITOR_DIALOG_OPEN_CHART_ENTRY_LAYOUT:String = Paths.ui('chart-editor/dialogs/open-chart-entry');
   static final CHART_EDITOR_DIALOG_IMPORT_CHART_LAYOUT:String = Paths.ui('chart-editor/dialogs/import-chart');
   static final CHART_EDITOR_DIALOG_USER_GUIDE_LAYOUT:String = Paths.ui('chart-editor/dialogs/user-guide');
+  static final CHART_EDITOR_DIALOG_ADD_VARIATION_LAYOUT:String = Paths.ui('chart-editor/dialogs/add-variation');
+  static final CHART_EDITOR_DIALOG_ADD_DIFFICULTY_LAYOUT:String = Paths.ui('chart-editor/dialogs/add-difficulty');
 
   /**
    * Builds and opens a dialog giving brief credits for the chart editor.
@@ -77,17 +83,31 @@ 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) {
       // Hide the welcome dialog
       dialog.hideDialog(DialogButton.CANCEL);
+      state.stopWelcomeMusic();
+
+      //
+      // Create Song Wizard
+      //
+      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
       //
-      openCreateSongWizard(state, false);
+      openCreateSongWizardErect(state, false);
     }
 
     var linkImportChartLegacy:Null<Link> = dialog.findComponent('splashImportChartLegacy', Link);
@@ -95,6 +115,7 @@ class ChartEditorDialogHandler
     linkImportChartLegacy.onClick = function(_event) {
       // Hide the welcome dialog
       dialog.hideDialog(DialogButton.CANCEL);
+      state.stopWelcomeMusic();
 
       // Open the "Import Chart" dialog
       openImportChartWizard(state, 'legacy', false);
@@ -105,6 +126,7 @@ class ChartEditorDialogHandler
     buttonBrowse.onClick = function(_event) {
       // Hide the welcome dialog
       dialog.hideDialog(DialogButton.CANCEL);
+      state.stopWelcomeMusic();
 
       // Open the "Open Chart" dialog
       openBrowseWizard(state, false);
@@ -133,14 +155,16 @@ class ChartEditorDialogHandler
       linkTemplateSong.text = songName;
       linkTemplateSong.onClick = function(_event) {
         dialog.hideDialog(DialogButton.CANCEL);
+        state.stopWelcomeMusic();
 
         // Load song from template
-        state.loadSongAsTemplate(targetSongId);
+        ChartEditorImportExportHandler.loadSongAsTemplate(state, targetSongId);
       }
 
       splashTemplateContainer.addComponent(linkTemplateSong);
     }
 
+    state.fadeInWelcomeMusic();
     return dialog;
   }
 
@@ -226,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);
       }
     };
@@ -291,6 +393,8 @@ class ChartEditorDialogHandler
       Cursor.cursorMode = Default;
     }
 
+    var instId:String = state.currentInstrumentalId;
+
     var onDropFile:String->Void;
 
     instrumentalBox.onClick = function(_event) {
@@ -298,14 +402,14 @@ class ChartEditorDialogHandler
         {label: 'Audio File (.ogg)', extension: 'ogg'}], function(selectedFile:SelectedFileInfo) {
           if (selectedFile != null && selectedFile.bytes != null)
           {
-            if (state.loadInstrumentalFromBytes(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
                 });
@@ -322,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
                 });
@@ -335,14 +439,14 @@ class ChartEditorDialogHandler
     onDropFile = function(pathStr:String) {
       var path:Path = new Path(pathStr);
       trace('Dropped file (${path})');
-      if (state.loadInstrumentalFromPath(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
           });
@@ -359,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.
@@ -446,73 +550,114 @@ 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) {
       dialog.hideDialog(DialogButton.CANCEL);
     }
 
-    var dialogSongName:Null<TextField> = dialog.findComponent('dialogSongName', TextField);
-    if (dialogSongName == null) throw 'Could not locate dialogSongName TextField in Song Metadata dialog';
-    dialogSongName.onChange = function(event:UIEvent) {
+    var newSongMetadata:SongMetadata = new SongMetadata('', '', 'default');
+
+    var inputSongName:Null<TextField> = dialog.findComponent('inputSongName', TextField);
+    if (inputSongName == null) throw 'Could not locate inputSongName TextField in Song Metadata dialog';
+    inputSongName.onChange = function(event:UIEvent) {
       var valid:Bool = event.target.text != null && event.target.text != '';
 
       if (valid)
       {
-        dialogSongName.removeClass('invalid-value');
-        state.currentSongMetadata.songName = event.target.text;
+        inputSongName.removeClass('invalid-value');
+        newSongMetadata.songName = event.target.text;
       }
       else
       {
-        state.currentSongMetadata.songName = "";
+        newSongMetadata.songName = "";
       }
     };
-    state.currentSongMetadata.songName = "";
+    inputSongName.text = "";
 
-    var dialogSongArtist:Null<TextField> = dialog.findComponent('dialogSongArtist', TextField);
-    if (dialogSongArtist == null) throw 'Could not locate dialogSongArtist TextField in Song Metadata dialog';
-    dialogSongArtist.onChange = function(event:UIEvent) {
+    var inputSongArtist:Null<TextField> = dialog.findComponent('inputSongArtist', TextField);
+    if (inputSongArtist == null) throw 'Could not locate inputSongArtist TextField in Song Metadata dialog';
+    inputSongArtist.onChange = function(event:UIEvent) {
       var valid:Bool = event.target.text != null && event.target.text != '';
 
       if (valid)
       {
-        dialogSongArtist.removeClass('invalid-value');
-        state.currentSongMetadata.artist = event.target.text;
+        inputSongArtist.removeClass('invalid-value');
+        newSongMetadata.artist = event.target.text;
       }
       else
       {
-        state.currentSongMetadata.artist = "";
+        newSongMetadata.artist = "";
       }
     };
-    state.currentSongMetadata.artist = "";
+    inputSongArtist.text = "";
 
-    var dialogStage:Null<DropDown> = dialog.findComponent('dialogStage', DropDown);
-    if (dialogStage == null) throw 'Could not locate dialogStage DropDown in Song Metadata dialog';
-    dialogStage.onChange = function(event:UIEvent) {
+    var inputStage:Null<DropDown> = dialog.findComponent('inputStage', DropDown);
+    if (inputStage == null) throw 'Could not locate inputStage DropDown in Song Metadata dialog';
+    inputStage.onChange = function(event:UIEvent) {
       if (event.data == null && event.data.id == null) return;
-      state.currentSongMetadata.playData.stage = event.data.id;
+      newSongMetadata.playData.stage = event.data.id;
     };
-    state.currentSongMetadata.playData.stage = 'mainStage';
+    var startingValueStage = ChartEditorDropdowns.populateDropdownWithStages(inputStage, newSongMetadata.playData.stage);
+    inputStage.value = startingValueStage;
 
-    var dialogNoteSkin:Null<DropDown> = dialog.findComponent('dialogNoteSkin', DropDown);
-    if (dialogNoteSkin == null) throw 'Could not locate dialogNoteSkin DropDown in Song Metadata dialog';
-    dialogNoteSkin.onChange = function(event:UIEvent) {
+    var inputNoteStyle:Null<FunkinDropDown> = dialog.findComponent('inputNoteStyle', FunkinDropDown);
+    if (inputNoteStyle == null) throw 'Could not locate inputNoteStyle DropDown in Song Metadata dialog';
+    inputNoteStyle.onChange = function(event:UIEvent) {
       if (event.data.id == null) return;
-      state.currentSongNoteSkin = event.data.id;
+      newSongMetadata.playData.noteSkin = event.data.id;
     };
-    state.currentSongNoteSkin = 'funkin';
+    var startingValueNoteStyle = ChartEditorDropdowns.populateDropdownWithNoteStyles(inputNoteStyle, newSongMetadata.playData.noteSkin);
+    inputNoteStyle.value = startingValueNoteStyle;
+
+    var inputCharacterPlayer:Null<FunkinDropDown> = dialog.findComponent('inputCharacterPlayer', FunkinDropDown);
+    if (inputCharacterPlayer == null) throw 'ChartEditorToolboxHandler.buildToolboxMetadataLayout() - Could not find inputCharacterPlayer component.';
+    inputCharacterPlayer.onChange = function(event:UIEvent) {
+      if (event.data?.id == null) return;
+      newSongMetadata.playData.characters.player = event.data.id;
+    };
+    var startingValuePlayer = ChartEditorDropdowns.populateDropdownWithCharacters(inputCharacterPlayer, CharacterType.BF,
+      newSongMetadata.playData.characters.player);
+    inputCharacterPlayer.value = startingValuePlayer;
+
+    var inputCharacterOpponent:Null<FunkinDropDown> = dialog.findComponent('inputCharacterOpponent', FunkinDropDown);
+    if (inputCharacterOpponent == null) throw 'ChartEditorToolboxHandler.buildToolboxMetadataLayout() - Could not find inputCharacterOpponent component.';
+    inputCharacterOpponent.onChange = function(event:UIEvent) {
+      if (event.data?.id == null) return;
+      newSongMetadata.playData.characters.opponent = event.data.id;
+    };
+    var startingValueOpponent = ChartEditorDropdowns.populateDropdownWithCharacters(inputCharacterOpponent, CharacterType.DAD,
+      newSongMetadata.playData.characters.opponent);
+    inputCharacterOpponent.value = startingValueOpponent;
+
+    var inputCharacterGirlfriend:Null<FunkinDropDown> = dialog.findComponent('inputCharacterGirlfriend', FunkinDropDown);
+    if (inputCharacterGirlfriend == null) throw 'ChartEditorToolboxHandler.buildToolboxMetadataLayout() - Could not find inputCharacterGirlfriend component.';
+    inputCharacterGirlfriend.onChange = function(event:UIEvent) {
+      if (event.data?.id == null) return;
+      newSongMetadata.playData.characters.girlfriend = event.data.id == "none" ? "" : event.data.id;
+    };
+    var startingValueGirlfriend = ChartEditorDropdowns.populateDropdownWithCharacters(inputCharacterGirlfriend, CharacterType.GF,
+      newSongMetadata.playData.characters.girlfriend);
+    inputCharacterGirlfriend.value = startingValueGirlfriend;
 
     var dialogBPM:Null<NumberStepper> = dialog.findComponent('dialogBPM', NumberStepper);
     if (dialogBPM == null) throw 'Could not locate dialogBPM NumberStepper in Song Metadata dialog';
     dialogBPM.onChange = function(event:UIEvent) {
       if (event.value == null || event.value <= 0) return;
 
-      var timeChanges:Array<SongTimeChange> = state.currentSongMetadata.timeChanges;
+      var timeChanges:Array<SongTimeChange> = newSongMetadata.timeChanges;
       if (timeChanges == null || timeChanges.length == 0)
       {
         timeChanges = [new SongTimeChange(0, event.value)];
@@ -524,103 +669,20 @@ class ChartEditorDialogHandler
 
       Conductor.forceBPM(event.value);
 
-      state.currentSongMetadata.timeChanges = timeChanges;
+      newSongMetadata.timeChanges = timeChanges;
     };
 
-    var dialogCharGrid:Null<PropertyGrid> = dialog.findComponent('dialogCharGrid', PropertyGrid);
-    if (dialogCharGrid == null) throw 'Could not locate dialogCharGrid PropertyGrid in Song Metadata dialog';
-    var dialogCharAdd:Null<Button> = dialog.findComponent('dialogCharAdd', Button);
-    if (dialogCharAdd == null) throw 'Could not locate dialogCharAdd Button in Song Metadata dialog';
-    dialogCharAdd.onClick = function(event:UIEvent) {
-      var charGroup:PropertyGroup;
-      charGroup = buildCharGroup(state, null, () -> dialogCharGrid.removeComponent(charGroup));
-      dialogCharGrid.addComponent(charGroup);
-    };
-
-    // Empty the character list.
-    state.currentSongMetadata.playData.playableChars = [];
-    // Add at least one character group with no Remove button.
-    dialogCharGrid.addComponent(buildCharGroup(state, 'bf'));
-
     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);
+
+      dialog.hideDialog(DialogButton.APPLY);
+    }
 
     return dialog;
   }
 
-  static function buildCharGroup(state:ChartEditorState, key:String = '', removeFunc:Void->Void = null):PropertyGroup
-  {
-    var groupKey:String = key;
-
-    var getCharData:Void->Null<SongPlayableChar> = function():Null<SongPlayableChar> {
-      if (state.currentSongMetadata.playData == null) return null;
-      if (groupKey == null) groupKey = 'newChar${state.currentSongMetadata.playData.playableChars.keys().count()}';
-
-      var result = state.currentSongMetadata.playData.playableChars.get(groupKey);
-      if (result == null)
-      {
-        result = new SongPlayableChar('', 'dad');
-        state.currentSongMetadata.playData.playableChars.set(groupKey, result);
-      }
-      return result;
-    }
-
-    var moveCharGroup:String->Void = function(target:String):Void {
-      var charData:Null<SongPlayableChar> = getCharData();
-      if (charData == null) return;
-
-      if (state.currentSongMetadata.playData.playableChars == null) return;
-      state.currentSongMetadata.playData.playableChars.remove(groupKey);
-      state.currentSongMetadata.playData.playableChars.set(target, charData);
-      groupKey = target;
-    }
-
-    var removeGroup:Void->Void = function():Void {
-      if (state?.currentSongMetadata?.playData?.playableChars == null) return;
-      state.currentSongMetadata.playData.playableChars.remove(groupKey);
-      if (removeFunc != null) removeFunc();
-    }
-
-    var charData:Null<SongPlayableChar> = getCharData();
-
-    var charGroup:PropertyGroup = cast state.buildComponent(CHART_EDITOR_DIALOG_SONG_METADATA_CHARGROUP_LAYOUT);
-
-    var charGroupPlayer:Null<DropDown> = charGroup.findComponent('charGroupPlayer', DropDown);
-    if (charGroupPlayer == null) throw 'Could not locate charGroupPlayer DropDown in Song Metadata dialog';
-    charGroupPlayer.onChange = function(event:UIEvent):Void {
-      if (charData != null) return;
-      charGroup.text = event.data.text;
-      moveCharGroup(event.data.id);
-    };
-
-    var charGroupOpponent:Null<DropDown> = charGroup.findComponent('charGroupOpponent', DropDown);
-    if (charGroupOpponent == null) throw 'Could not locate charGroupOpponent DropDown in Song Metadata dialog';
-    charGroupOpponent.onChange = function(event:UIEvent):Void {
-      if (charData == null) return;
-      charData.opponent = event.data.id;
-    };
-    charGroupOpponent.value = charData.opponent;
-
-    var charGroupGirlfriend:Null<DropDown> = charGroup.findComponent('charGroupGirlfriend', DropDown);
-    if (charGroupGirlfriend == null) throw 'Could not locate charGroupGirlfriend DropDown in Song Metadata dialog';
-    charGroupGirlfriend.onChange = function(event:UIEvent):Void {
-      if (charData == null) return;
-      charData.girlfriend = event.data.id;
-    };
-    charGroupGirlfriend.value = charData.girlfriend;
-
-    var charGroupRemove:Null<Button> = charGroup.findComponent('charGroupRemove', Button);
-    if (charGroupRemove == null) throw 'Could not locate charGroupRemove Button in Song Metadata dialog';
-    charGroupRemove.onClick = function(event:UIEvent):Void {
-      removeGroup();
-    };
-
-    if (removeFunc == null) charGroupRemove.hidden = true;
-
-    return charGroup;
-  }
-
   /**
    * Builds and opens a dialog where the user uploads vocals for the current song.
    * @param state The current chart editor state.
@@ -629,15 +691,13 @@ class ChartEditorDialogHandler
    */
   public static function openUploadVocalsDialog(state:ChartEditorState, closable:Bool = true):Dialog
   {
+    var instId:String = state.currentInstrumentalId;
     var charIdsForVocals:Array<String> = [];
 
-    for (charKey in state.currentSongMetadata.playData.playableChars.keys())
-    {
-      var charData:Null<SongPlayableChar> = state.currentSongMetadata.playData.playableChars.get(charKey);
-      if (charData == null) continue;
-      charIdsForVocals.push(charKey);
-      if (charData.opponent != null) charIdsForVocals.push(charData.opponent);
-    }
+    var charData:SongCharacterData = state.currentSongMetadata.playData.characters;
+
+    charIdsForVocals.push(charData.player);
+    charIdsForVocals.push(charData.opponent);
 
     var dialog:Null<Dialog> = openDialog(state, CHART_EDITOR_DIALOG_UPLOAD_VOCALS_LAYOUT, true, closable);
     if (dialog == null) throw 'Could not locate Upload Vocals dialog';
@@ -678,14 +738,14 @@ class ChartEditorDialogHandler
         trace('Selected file: $pathStr');
         var path:Path = new Path(pathStr);
 
-        if (state.loadVocalsFromPath(path, charKey))
+        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
             });
@@ -701,21 +761,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
             });
@@ -735,14 +788,46 @@ 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
-              state.loadVocalsFromBytes(selectedFile.bytes, charKey);
-              dialogNoVocals.hidden = true;
-              removeDropHandler(onDropFile);
+              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
+              }
             }
         });
       }
@@ -793,7 +878,7 @@ class ChartEditorDialogHandler
     var buttonContinue:Null<Button> = dialog.findComponent('dialogContinue', Button);
     if (buttonContinue == null) throw 'Could not locate dialogContinue button in Open Chart dialog';
     buttonContinue.onClick = function(_event) {
-      state.loadSong(songMetadata, songChartData);
+      ChartEditorImportExportHandler.loadSong(state, songMetadata, songChartData);
 
       dialog.hideDialog(DialogButton.APPLY);
     }
@@ -880,9 +965,26 @@ class ChartEditorDialogHandler
       var path:Path = new Path(pathStr);
       trace('Dropped JSON file (${path})');
 
-      var songMetadataJson:Dynamic = FileUtil.readJSONFromPath(path.toString());
-      var songMetadataVariation:SongMetadata = SongMigrator.migrateSongMetadata(songMetadataJson, 'import');
-      songMetadataVariation = SongValidator.validateSongMetadata(songMetadataVariation, 'import');
+      var songMetadataTxt:String = FileUtil.readStringFromPath(path.toString());
+
+      var songMetadataVersion:Null<Version> = VersionUtil.getVersionFromJSON(songMetadataTxt);
+      if (songMetadataVersion == null)
+      {
+        // Tell the user the load was not successful.
+        #if !mac
+        NotificationManager.instance.addNotification(
+          {
+            title: 'Failure',
+            body: 'Could not parse metadata file version (${path.file}.${path.ext})',
+            type: NotificationType.Error,
+            expiryMs: ChartEditorState.NOTIFICATION_DISMISS_TIME
+          });
+        #end
+        return;
+      }
+
+      var songMetadataVariation:Null<SongMetadata> = SongRegistry.instance.parseEntryMetadataRawWithMigration(songMetadataTxt, path.toString(),
+        songMetadataVersion);
 
       if (songMetadataVariation == null)
       {
@@ -928,31 +1030,63 @@ class ChartEditorDialogHandler
           {
             trace('Selected file: ' + selectedFile.name);
 
-            var songMetadataJson:Dynamic = SerializerUtil.fromJSONBytes(selectedFile.bytes);
-            var songMetadataVariation:SongMetadata = SongMigrator.migrateSongMetadata(songMetadataJson, 'import');
-            songMetadataVariation = SongValidator.validateSongMetadata(songMetadataVariation, 'import');
-            songMetadataVariation.variation = variation;
+            var songMetadataTxt:String = selectedFile.bytes.toString();
 
-            songMetadata.set(variation, songMetadataVariation);
+            var songMetadataVersion:Null<Version> = VersionUtil.getVersionFromJSON(songMetadataTxt);
+            if (songMetadataVersion == null)
+            {
+              // Tell the user the load was not successful.
+              #if !mac
+              NotificationManager.instance.addNotification(
+                {
+                  title: 'Failure',
+                  body: 'Could not parse metadata file version (${selectedFile.name})',
+                  type: NotificationType.Error,
+                  expiryMs: ChartEditorState.NOTIFICATION_DISMISS_TIME
+                });
+              #end
+              return;
+            }
 
-            // Tell the user the load was successful.
-            #if !mac
-            NotificationManager.instance.addNotification(
-              {
-                title: 'Success',
-                body: 'Loaded metadata file (${selectedFile.name})',
-                type: NotificationType.Success,
-                expiryMs: ChartEditorState.NOTIFICATION_DISMISS_TIME
-              });
-            #end
+            var songMetadataVariation:Null<SongMetadata> = SongRegistry.instance.parseEntryMetadataRawWithMigration(songMetadataTxt, selectedFile.name,
+              songMetadataVersion);
 
-            #if FILE_DROP_SUPPORTED
-            label.text = 'Metadata file (drag and drop, or click to browse)\nSelected file: ${selectedFile.name}';
-            #else
-            label.text = 'Metadata file (click to browse)\n${selectedFile.name}';
-            #end
+            if (songMetadataVariation != null)
+            {
+              songMetadata.set(variation, songMetadataVariation);
 
-            if (variation == Constants.DEFAULT_VARIATION) constructVariationEntries(songMetadataVariation.playData.songVariations);
+              // Tell the user the load was successful.
+              #if !mac
+              NotificationManager.instance.addNotification(
+                {
+                  title: 'Success',
+                  body: 'Loaded metadata file (${selectedFile.name})',
+                  type: NotificationType.Success,
+                  expiryMs: ChartEditorState.NOTIFICATION_DISMISS_TIME
+                });
+              #end
+
+              #if FILE_DROP_SUPPORTED
+              label.text = 'Metadata file (drag and drop, or click to browse)\nSelected file: ${selectedFile.name}';
+              #else
+              label.text = 'Metadata file (click to browse)\n${selectedFile.name}';
+              #end
+
+              if (variation == Constants.DEFAULT_VARIATION) constructVariationEntries(songMetadataVariation.playData.songVariations);
+            }
+            else
+            {
+              // Tell the user the load was unsuccessful.
+              #if !mac
+              NotificationManager.instance.addNotification(
+                {
+                  title: 'Failure',
+                  body: 'Failed to load metadata file (${selectedFile.name})',
+                  type: NotificationType.Error,
+                  expiryMs: ChartEditorState.NOTIFICATION_DISMISS_TIME
+                });
+              #end
+            }
           }
       });
     }
@@ -961,31 +1095,64 @@ class ChartEditorDialogHandler
       var path:Path = new Path(pathStr);
       trace('Dropped JSON file (${path})');
 
-      var songChartDataJson:Dynamic = FileUtil.readJSONFromPath(path.toString());
-      var songChartDataVariation:SongChartData = SongMigrator.migrateSongChartData(songChartDataJson, 'import');
-      songChartDataVariation = SongValidator.validateSongChartData(songChartDataVariation, 'import');
+      var songChartDataTxt:String = FileUtil.readStringFromPath(path.toString());
 
-      songChartData.set(variation, songChartDataVariation);
-      state.notePreviewDirty = true;
-      state.notePreviewViewportBoundsDirty = true;
-      state.noteDisplayDirty = true;
+      var songChartDataVersion:Null<Version> = VersionUtil.getVersionFromJSON(songChartDataTxt);
+      if (songChartDataVersion == null)
+      {
+        // Tell the user the load was not successful.
+        #if !mac
+        NotificationManager.instance.addNotification(
+          {
+            title: 'Failure',
+            body: 'Could not parse chart data file version (${path.file}.${path.ext})',
+            type: NotificationType.Error,
+            expiryMs: ChartEditorState.NOTIFICATION_DISMISS_TIME
+          });
+        #end
+        return;
+      }
 
-      // Tell the user the load was successful.
-      #if !mac
-      NotificationManager.instance.addNotification(
-        {
-          title: 'Success',
-          body: 'Loaded chart data file (${path.file}.${path.ext})',
-          type: NotificationType.Success,
-          expiryMs: ChartEditorState.NOTIFICATION_DISMISS_TIME
-        });
-      #end
+      var songChartDataVariation:Null<SongChartData> = SongRegistry.instance.parseEntryChartDataRawWithMigration(songChartDataTxt, path.toString(),
+        songChartDataVersion);
 
-      #if FILE_DROP_SUPPORTED
-      label.text = 'Chart data file (drag and drop, or click to browse)\nSelected file: ${path.file}.${path.ext}';
-      #else
-      label.text = 'Chart data file (click to browse)\n${path.file}.${path.ext}';
-      #end
+      if (songChartDataVariation != null)
+      {
+        songChartData.set(variation, songChartDataVariation);
+        state.notePreviewDirty = true;
+        state.notePreviewViewportBoundsDirty = true;
+        state.noteDisplayDirty = true;
+
+        // Tell the user the load was successful.
+        #if !mac
+        NotificationManager.instance.addNotification(
+          {
+            title: 'Success',
+            body: 'Loaded chart data file (${path.file}.${path.ext})',
+            type: NotificationType.Success,
+            expiryMs: ChartEditorState.NOTIFICATION_DISMISS_TIME
+          });
+        #end
+
+        #if FILE_DROP_SUPPORTED
+        label.text = 'Chart data file (drag and drop, or click to browse)\nSelected file: ${path.file}.${path.ext}';
+        #else
+        label.text = 'Chart data file (click to browse)\n${path.file}.${path.ext}';
+        #end
+      }
+      else
+      {
+        // Tell the user the load was unsuccessful.
+        #if !mac
+        NotificationManager.instance.addNotification(
+          {
+            title: 'Failure',
+            body: 'Failed to load chart data file (${path.file}.${path.ext})',
+            type: NotificationType.Error,
+            expiryMs: ChartEditorState.NOTIFICATION_DISMISS_TIME
+          });
+        #end
+      }
     };
 
     onClickChartDataVariation = function(variation:String, label:Label, _event:UIEvent) {
@@ -995,31 +1162,51 @@ class ChartEditorDialogHandler
           {
             trace('Selected file: ' + selectedFile.name);
 
-            var songChartDataJson:Dynamic = SerializerUtil.fromJSONBytes(selectedFile.bytes);
-            var songChartDataVariation:SongChartData = SongMigrator.migrateSongChartData(songChartDataJson, 'import');
-            songChartDataVariation = SongValidator.validateSongChartData(songChartDataVariation, 'import');
+            var songChartDataTxt:String = selectedFile.bytes.toString();
 
-            songChartData.set(variation, songChartDataVariation);
-            state.notePreviewDirty = true;
-            state.notePreviewViewportBoundsDirty = true;
-            state.noteDisplayDirty = true;
+            var songChartDataVersion:Null<Version> = VersionUtil.getVersionFromJSON(songChartDataTxt);
+            if (songChartDataVersion == null)
+            {
+              // Tell the user the load was not successful.
+              #if !mac
+              NotificationManager.instance.addNotification(
+                {
+                  title: 'Failure',
+                  body: 'Could not parse chart data file version (${selectedFile.name})',
+                  type: NotificationType.Error,
+                  expiryMs: ChartEditorState.NOTIFICATION_DISMISS_TIME
+                });
+              #end
+              return;
+            }
 
-            // Tell the user the load was successful.
-            #if !mac
-            NotificationManager.instance.addNotification(
-              {
-                title: 'Success',
-                body: 'Loaded chart data file (${selectedFile.name})',
-                type: NotificationType.Success,
-                expiryMs: ChartEditorState.NOTIFICATION_DISMISS_TIME
-              });
-            #end
+            var songChartDataVariation:Null<SongChartData> = SongRegistry.instance.parseEntryChartDataRawWithMigration(songChartDataTxt, selectedFile.name,
+              songChartDataVersion);
 
-            #if FILE_DROP_SUPPORTED
-            label.text = 'Chart data file (drag and drop, or click to browse)\nSelected file: ${selectedFile.name}';
-            #else
-            label.text = 'Chart data file (click to browse)\n${selectedFile.name}';
-            #end
+            if (songChartDataVariation != null)
+            {
+              songChartData.set(variation, songChartDataVariation);
+              state.notePreviewDirty = true;
+              state.notePreviewViewportBoundsDirty = true;
+              state.noteDisplayDirty = true;
+
+              // Tell the user the load was successful.
+              #if !mac
+              NotificationManager.instance.addNotification(
+                {
+                  title: 'Success',
+                  body: 'Loaded chart data file (${selectedFile.name})',
+                  type: NotificationType.Success,
+                  expiryMs: ChartEditorState.NOTIFICATION_DISMISS_TIME
+                });
+              #end
+
+              #if FILE_DROP_SUPPORTED
+              label.text = 'Chart data file (drag and drop, or click to browse)\nSelected file: ${selectedFile.name}';
+              #else
+              label.text = 'Chart data file (click to browse)\n${selectedFile.name}';
+              #end
+            }
           }
       });
     }
@@ -1102,11 +1289,27 @@ class ChartEditorDialogHandler
         if (selectedFile != null && selectedFile.bytes != null)
         {
           trace('Selected file: ' + selectedFile.fullPath);
-          var selectedFileJson:Dynamic = SerializerUtil.fromJSONBytes(selectedFile.bytes);
-          var songMetadata:SongMetadata = SongMigrator.migrateSongMetadataFromLegacy(selectedFileJson);
-          var songChartData:SongChartData = SongMigrator.migrateSongChartDataFromLegacy(selectedFileJson);
+          var selectedFileTxt:String = selectedFile.bytes.toString();
+          var fnfLegacyData:Null<FNFLegacyData> = FNFLegacyImporter.parseLegacyDataRaw(selectedFileTxt, selectedFile.fullPath);
 
-          state.loadSong([Constants.DEFAULT_VARIATION => songMetadata], [Constants.DEFAULT_VARIATION => songChartData]);
+          if (fnfLegacyData == null)
+          {
+            #if !mac
+            NotificationManager.instance.addNotification(
+              {
+                title: 'Failure',
+                body: 'Failed to parse FNF chart file (${selectedFile.name})',
+                type: NotificationType.Error,
+                expiryMs: ChartEditorState.NOTIFICATION_DISMISS_TIME
+              });
+            #end
+            return;
+          }
+
+          var songMetadata:SongMetadata = FNFLegacyImporter.migrateMetadata(fnfLegacyData);
+          var songChartData:SongChartData = FNFLegacyImporter.migrateChartData(fnfLegacyData);
+
+          ChartEditorImportExportHandler.loadSong(state, [Constants.DEFAULT_VARIATION => songMetadata], [Constants.DEFAULT_VARIATION => songChartData]);
 
           dialog.hideDialog(DialogButton.APPLY);
           #if !mac
@@ -1124,11 +1327,12 @@ class ChartEditorDialogHandler
 
     onDropFile = function(pathStr:String) {
       var path:Path = new Path(pathStr);
-      var selectedFileJson:Dynamic = FileUtil.readJSONFromPath(path.toString());
-      var songMetadata:SongMetadata = SongMigrator.migrateSongMetadataFromLegacy(selectedFileJson);
-      var songChartData:SongChartData = SongMigrator.migrateSongChartDataFromLegacy(selectedFileJson);
+      var selectedFileText:String = FileUtil.readStringFromPath(path.toString());
+      var selectedFileData:FNFLegacyData = FNFLegacyImporter.parseLegacyDataRaw(selectedFileText, path.toString());
+      var songMetadata:SongMetadata = FNFLegacyImporter.migrateMetadata(selectedFileData);
+      var songChartData:SongChartData = FNFLegacyImporter.migrateChartData(selectedFileData);
 
-      state.loadSong([Constants.DEFAULT_VARIATION => songMetadata], [Constants.DEFAULT_VARIATION => songChartData]);
+      ChartEditorImportExportHandler.loadSong(state, [Constants.DEFAULT_VARIATION => songMetadata], [Constants.DEFAULT_VARIATION => songChartData]);
 
       dialog.hideDialog(DialogButton.APPLY);
       #if !mac
@@ -1181,4 +1385,161 @@ class ChartEditorDialogHandler
 
     return dialog;
   }
+
+  /**
+   * Builds and opens a dialog where the user can add a new variation for a song.
+   * @param state The current chart editor state.
+   * @param closable Whether the dialog can be closed by the user.
+   * @return The dialog that was opened.
+   */
+  public static function openAddVariationDialog(state:ChartEditorState, closable:Bool = true):Dialog
+  {
+    var dialog:Null<Dialog> = openDialog(state, CHART_EDITOR_DIALOG_ADD_VARIATION_LAYOUT, true, false);
+    if (dialog == null) throw 'Could not locate Add Variation dialog';
+
+    var variationForm:Null<Form> = dialog.findComponent('variationForm', Form);
+    if (variationForm == null) throw 'Could not locate variationForm Form in Add Variation dialog';
+
+    var buttonCancel:Null<Button> = dialog.findComponent('dialogCancel', Button);
+    if (buttonCancel == null) throw 'Could not locate dialogCancel button in Add Variation dialog';
+    buttonCancel.onClick = function(_event) {
+      dialog.hideDialog(DialogButton.CANCEL);
+    }
+
+    var buttonAdd:Null<Button> = dialog.findComponent('dialogAdd', Button);
+    if (buttonAdd == null) throw 'Could not locate dialogAdd button in Add Variation dialog';
+    buttonAdd.onClick = function(_event) {
+      // This performs validation before the onSubmit callback is called.
+      variationForm.submit();
+    }
+
+    var dialogSongName:Null<TextField> = dialog.findComponent('dialogSongName', TextField);
+    if (dialogSongName == null) throw 'Could not locate dialogSongName TextField in Add Variation dialog';
+    dialogSongName.value = state.currentSongMetadata.songName;
+
+    var dialogSongArtist:Null<TextField> = dialog.findComponent('dialogSongArtist', TextField);
+    if (dialogSongArtist == null) throw 'Could not locate dialogSongArtist TextField in Add Variation dialog';
+    dialogSongArtist.value = state.currentSongMetadata.artist;
+
+    var dialogStage:Null<DropDown> = dialog.findComponent('dialogStage', DropDown);
+    if (dialogStage == null) throw 'Could not locate dialogStage DropDown in Add Variation dialog';
+    var startingValueStage = ChartEditorDropdowns.populateDropdownWithStages(dialogStage, state.currentSongMetadata.playData.stage);
+    dialogStage.value = startingValueStage;
+
+    var dialogNoteStyle:Null<DropDown> = dialog.findComponent('dialogNoteStyle', DropDown);
+    if (dialogNoteStyle == null) throw 'Could not locate dialogNoteStyle DropDown in Add Variation dialog';
+    dialogNoteStyle.value = state.currentSongMetadata.playData.noteSkin;
+
+    var dialogCharacterPlayer:Null<DropDown> = dialog.findComponent('dialogCharacterPlayer', DropDown);
+    if (dialogCharacterPlayer == null) throw 'Could not locate dialogCharacterPlayer DropDown in Add Variation dialog';
+    dialogCharacterPlayer.value = ChartEditorDropdowns.populateDropdownWithCharacters(dialogCharacterPlayer, CharacterType.BF,
+      state.currentSongMetadata.playData.characters.player);
+
+    var dialogCharacterOpponent:Null<DropDown> = dialog.findComponent('dialogCharacterOpponent', DropDown);
+    if (dialogCharacterOpponent == null) throw 'Could not locate dialogCharacterOpponent DropDown in Add Variation dialog';
+    dialogCharacterOpponent.value = ChartEditorDropdowns.populateDropdownWithCharacters(dialogCharacterOpponent, CharacterType.DAD,
+      state.currentSongMetadata.playData.characters.opponent);
+
+    var dialogCharacterGirlfriend:Null<DropDown> = dialog.findComponent('dialogCharacterGirlfriend', DropDown);
+    if (dialogCharacterGirlfriend == null) throw 'Could not locate dialogCharacterGirlfriend DropDown in Add Variation dialog';
+    dialogCharacterGirlfriend.value = ChartEditorDropdowns.populateDropdownWithCharacters(dialogCharacterGirlfriend, CharacterType.GF,
+      state.currentSongMetadata.playData.characters.girlfriend);
+
+    var dialogBPM:Null<NumberStepper> = dialog.findComponent('dialogBPM', NumberStepper);
+    if (dialogBPM == null) throw 'Could not locate dialogBPM NumberStepper in Add Variation dialog';
+    dialogBPM.value = state.currentSongMetadata.timeChanges[0].bpm;
+
+    // If all validators succeeded, this callback is called.
+
+    variationForm.onSubmit = function(_event) {
+      trace('Add Variation dialog submitted, validation succeeded!');
+
+      var dialogVariationName:Null<TextField> = dialog.findComponent('dialogVariationName', TextField);
+      if (dialogVariationName == null) throw 'Could not locate dialogVariationName TextField in Add Variation dialog';
+
+      var pendingVariation:SongMetadata = new SongMetadata(dialogSongName.text, dialogSongArtist.text, dialogVariationName.text.toLowerCase());
+
+      pendingVariation.playData.stage = dialogStage.value.id;
+      pendingVariation.playData.noteSkin = dialogNoteStyle.value;
+      pendingVariation.timeChanges[0].bpm = dialogBPM.value;
+
+      state.songMetadata.set(pendingVariation.variation, pendingVariation);
+      state.difficultySelectDirty = true; // Force the Difficulty toolbox to update.
+      #if !mac
+      NotificationManager.instance.addNotification(
+        {
+          title: "Add Variation",
+          body: 'Added new variation "${pendingVariation.variation}"',
+          type: NotificationType.Success
+        });
+      #end
+      dialog.hideDialog(DialogButton.APPLY);
+    }
+
+    return dialog;
+  }
+
+  /**
+   * Builds and opens a dialog where the user can add a new difficulty for a song.
+   * @param state The current chart editor state.
+   * @param closable Whether the dialog can be closed by the user.
+   * @return The dialog that was opened.
+   */
+  public static function openAddDifficultyDialog(state:ChartEditorState, closable:Bool = true):Dialog
+  {
+    var dialog:Null<Dialog> = openDialog(state, CHART_EDITOR_DIALOG_ADD_DIFFICULTY_LAYOUT, true, false);
+    if (dialog == null) throw 'Could not locate Add Difficulty dialog';
+
+    var difficultyForm:Null<Form> = dialog.findComponent('difficultyForm', Form);
+    if (difficultyForm == null) throw 'Could not locate difficultyForm Form in Add Difficulty dialog';
+
+    var buttonCancel:Null<Button> = dialog.findComponent('dialogCancel', Button);
+    if (buttonCancel == null) throw 'Could not locate dialogCancel button in Add Difficulty dialog';
+    buttonCancel.onClick = function(_event) {
+      dialog.hideDialog(DialogButton.CANCEL);
+    }
+
+    var buttonAdd:Null<Button> = dialog.findComponent('dialogAdd', Button);
+    if (buttonAdd == null) throw 'Could not locate dialogAdd button in Add Difficulty dialog';
+    buttonAdd.onClick = function(_event) {
+      // This performs validation before the onSubmit callback is called.
+      difficultyForm.submit();
+    }
+
+    var dialogVariation:Null<DropDown> = dialog.findComponent('dialogVariation', DropDown);
+    if (dialogVariation == null) throw 'Could not locate dialogVariation DropDown in Add Variation dialog';
+    dialogVariation.value = ChartEditorDropdowns.populateDropdownWithVariations(dialogVariation, state, true);
+
+    var labelScrollSpeed:Null<Label> = dialog.findComponent('labelScrollSpeed', Label);
+    if (labelScrollSpeed == null) throw 'Could not find labelScrollSpeed component.';
+
+    var inputScrollSpeed:Null<Slider> = dialog.findComponent('inputScrollSpeed', Slider);
+    if (inputScrollSpeed == null) throw 'Could not find inputScrollSpeed component.';
+    inputScrollSpeed.onChange = function(event:UIEvent) {
+      labelScrollSpeed.text = 'Scroll Speed: ${inputScrollSpeed.value}x';
+    };
+    inputScrollSpeed.value = state.currentSongChartScrollSpeed;
+    labelScrollSpeed.text = 'Scroll Speed: ${inputScrollSpeed.value}x';
+
+    difficultyForm.onSubmit = function(_event) {
+      trace('Add Difficulty dialog submitted, validation succeeded!');
+
+      var dialogDifficultyName:Null<TextField> = dialog.findComponent('dialogDifficultyName', TextField);
+      if (dialogDifficultyName == null) throw 'Could not locate dialogDifficultyName TextField in Add Difficulty dialog';
+
+      state.createDifficulty(dialogVariation.value.id, dialogDifficultyName.text.toLowerCase(), inputScrollSpeed.value ?? 1.0);
+
+      #if !mac
+      NotificationManager.instance.addNotification(
+        {
+          title: "Add Difficulty",
+          body: 'Added new difficulty "${dialogDifficultyName.text.toLowerCase()}"',
+          type: NotificationType.Success
+        });
+      #end
+      dialog.hideDialog(DialogButton.APPLY);
+    }
+
+    return dialog;
+  }
 }
diff --git a/source/funkin/ui/debug/charting/ChartEditorDropdowns.hx b/source/funkin/ui/debug/charting/ChartEditorDropdowns.hx
new file mode 100644
index 000000000..ec41de9c0
--- /dev/null
+++ b/source/funkin/ui/debug/charting/ChartEditorDropdowns.hx
@@ -0,0 +1,129 @@
+package funkin.ui.debug.charting;
+
+import funkin.data.notestyle.NoteStyleRegistry;
+import funkin.play.notes.notestyle.NoteStyle;
+import funkin.play.stage.StageData;
+import funkin.play.stage.StageData.StageDataParser;
+import funkin.play.character.CharacterData;
+import haxe.ui.components.DropDown;
+import funkin.play.character.BaseCharacter.CharacterType;
+import funkin.play.character.CharacterData.CharacterDataParser;
+
+/**
+ * This class contains functions for populating dropdowns based on game data.
+ * These get used by both dialogs and toolboxes so they're in their own class to prevent "reaching over."
+ */
+@:nullSafety
+@:access(ChartEditorState)
+class ChartEditorDropdowns
+{
+  public static function populateDropdownWithCharacters(dropDown:DropDown, charType:CharacterType, startingCharId:String):DropDownEntry
+  {
+    dropDown.dataSource.clear();
+
+    // TODO: Filter based on charType.
+    var charIds:Array<String> = CharacterDataParser.listCharacterIds();
+
+    var returnValue:DropDownEntry = switch (charType)
+    {
+      case BF: {id: "bf", text: "Boyfriend"};
+      case DAD: {id: "dad", text: "Daddy Dearest"};
+      default: {
+          dropDown.dataSource.add({id: "none", text: ""});
+          {id: "none", text: "None"};
+        }
+    }
+
+    for (charId in charIds)
+    {
+      var character:Null<CharacterData> = CharacterDataParser.fetchCharacterData(charId);
+      if (character == null) continue;
+
+      var value = {id: charId, text: character.name};
+      if (startingCharId == charId) returnValue = value;
+
+      dropDown.dataSource.add(value);
+    }
+
+    dropDown.dataSource.sort('text', ASCENDING);
+
+    return returnValue;
+  }
+
+  public static function populateDropdownWithStages(dropDown:DropDown, startingStageId:String):DropDownEntry
+  {
+    dropDown.dataSource.clear();
+
+    var stageIds:Array<String> = StageDataParser.listStageIds();
+
+    var returnValue:DropDownEntry = {id: "mainStage", text: "Main Stage"};
+
+    for (stageId in stageIds)
+    {
+      var stage:Null<StageData> = StageDataParser.parseStageData(stageId);
+      if (stage == null) continue;
+
+      var value = {id: stageId, text: stage.name};
+      if (startingStageId == stageId) returnValue = value;
+
+      dropDown.dataSource.add(value);
+    }
+
+    dropDown.dataSource.sort('text', ASCENDING);
+
+    return returnValue;
+  }
+
+  public static function populateDropdownWithNoteStyles(dropDown:DropDown, startingStyleId:String):DropDownEntry
+  {
+    dropDown.dataSource.clear();
+
+    var noteStyleIds:Array<String> = NoteStyleRegistry.instance.listEntryIds();
+
+    var returnValue:DropDownEntry = {id: "funkin", text: "Funkin'"};
+
+    for (noteStyleId in noteStyleIds)
+    {
+      var noteStyle:Null<NoteStyle> = NoteStyleRegistry.instance.fetchEntry(noteStyleId);
+      if (noteStyle == null) continue;
+
+      var value = {id: noteStyleId, text: noteStyle.getName()};
+      if (startingStyleId == noteStyleId) returnValue = value;
+
+      dropDown.dataSource.add(value);
+    }
+
+    dropDown.dataSource.sort('text', ASCENDING);
+
+    return returnValue;
+  }
+
+  public static function populateDropdownWithVariations(dropDown:DropDown, state:ChartEditorState, includeNone:Bool = true):DropDownEntry
+  {
+    dropDown.dataSource.clear();
+
+    var variationIds:Array<String> = state.availableVariations;
+
+    if (includeNone)
+    {
+      dropDown.dataSource.add({id: "none", text: ""});
+    }
+
+    var returnValue:DropDownEntry = includeNone ? ({id: "none", text: ""}) : ({id: "default", text: "Default"});
+
+    for (variationId in variationIds)
+    {
+      dropDown.dataSource.add({id: variationId, text: variationId.toTitleCase()});
+    }
+
+    dropDown.dataSource.sort('text', ASCENDING);
+
+    return returnValue;
+  }
+}
+
+typedef DropDownEntry =
+{
+  id:String,
+  text:String
+};
diff --git a/source/funkin/ui/debug/charting/ChartEditorImportExportHandler.hx b/source/funkin/ui/debug/charting/ChartEditorImportExportHandler.hx
new file mode 100644
index 000000000..4d8ff18cb
--- /dev/null
+++ b/source/funkin/ui/debug/charting/ChartEditorImportExportHandler.hx
@@ -0,0 +1,211 @@
+package funkin.ui.debug.charting;
+
+import haxe.ui.notifications.NotificationType;
+import funkin.util.DateUtil;
+import haxe.io.Path;
+import funkin.util.SerializerUtil;
+import haxe.ui.notifications.NotificationManager;
+import funkin.util.FileUtil;
+import funkin.util.FileUtil;
+import funkin.play.song.Song;
+import funkin.data.song.SongData.SongChartData;
+import funkin.data.song.SongData.SongMetadata;
+import funkin.data.song.SongRegistry;
+
+/**
+ * Contains functions for importing, loading, saving, and exporting charts.
+ */
+@:nullSafety
+@:allow(funkin.ui.debug.charting.ChartEditorState)
+class ChartEditorImportExportHandler
+{
+  /**
+   * Fetch's a song's existing chart and audio and loads it, replacing the current song.
+   */
+  public static function loadSongAsTemplate(state:ChartEditorState, songId:String):Void
+  {
+    var song:Null<Song> = SongRegistry.instance.fetchEntry(songId);
+
+    if (song == null) return;
+
+    // Load the song metadata.
+    var rawSongMetadata:Array<SongMetadata> = song.getRawMetadata();
+    var songMetadata:Map<String, SongMetadata> = [];
+    var songChartData:Map<String, SongChartData> = [];
+
+    for (metadata in rawSongMetadata)
+    {
+      if (metadata == null) continue;
+      var variation = (metadata.variation == null || metadata.variation == '') ? Constants.DEFAULT_VARIATION : metadata.variation;
+
+      // Clone to prevent modifying the original.
+      var metadataClone:SongMetadata = metadata.clone(variation);
+      if (metadataClone != null) songMetadata.set(variation, metadataClone);
+
+      var chartData:Null<SongChartData> = SongRegistry.instance.parseEntryChartData(songId, metadata.variation);
+      if (chartData != null) songChartData.set(variation, chartData);
+    }
+
+    loadSong(state, songMetadata, songChartData);
+
+    state.sortChartData();
+
+    state.clearVocals();
+
+    var variations:Array<String> = state.availableVariations;
+    for (variation in variations)
+    {
+      if (variation == Constants.DEFAULT_VARIATION)
+      {
+        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
+    NotificationManager.instance.addNotification(
+      {
+        title: 'Success',
+        body: 'Loaded song (${rawSongMetadata[0].songName})',
+        type: NotificationType.Success,
+        expiryMs: ChartEditorState.NOTIFICATION_DISMISS_TIME
+      });
+    #end
+  }
+
+  /**
+   * Loads song metadata and chart data into the editor.
+   * @param newSongMetadata The song metadata to load.
+   * @param newSongChartData The song chart data to load.
+   */
+  public static function loadSong(state:ChartEditorState, newSongMetadata:Map<String, SongMetadata>, newSongChartData:Map<String, SongChartData>):Void
+  {
+    state.songMetadata = newSongMetadata;
+    state.songChartData = newSongChartData;
+
+    Conductor.forceBPM(null); // Disable the forced BPM.
+    Conductor.mapTimeChanges(state.currentSongMetadata.timeChanges);
+
+    state.notePreviewDirty = true;
+    state.notePreviewViewportBoundsDirty = true;
+    state.difficultySelectDirty = true;
+    state.opponentPreviewDirty = true;
+    state.playerPreviewDirty = true;
+
+    // Remove instrumental and vocal tracks, they will be loaded next.
+    if (state.audioInstTrack != null)
+    {
+      state.audioInstTrack.stop();
+      state.audioInstTrack = null;
+    }
+    if (state.audioVocalTrackGroup != null)
+    {
+      state.audioVocalTrackGroup.stop();
+      state.audioVocalTrackGroup.clear();
+    }
+  }
+
+  /**
+   * @param force Whether to force the export without prompting the user for a file location.
+   */
+  public static function exportAllSongData(state:ChartEditorState, force:Bool = false):Void
+  {
+    var tmp = false;
+    var zipEntries:Array<haxe.zip.Entry> = [];
+
+    for (variation in state.availableVariations)
+    {
+      var variationId:String = variation;
+      if (variation == '' || variation == 'default' || variation == 'normal')
+      {
+        variationId = '';
+      }
+
+      if (variationId == '')
+      {
+        var variationMetadata:Null<SongMetadata> = state.songMetadata.get(variation);
+        if (variationMetadata != null) zipEntries.push(FileUtil.makeZIPEntry('${state.currentSongId}-metadata.json', variationMetadata.serialize()));
+        var variationChart:Null<SongChartData> = state.songChartData.get(variation);
+        if (variationChart != null) zipEntries.push(FileUtil.makeZIPEntry('${state.currentSongId}-chart.json', variationChart.serialize()));
+      }
+      else
+      {
+        var variationMetadata:Null<SongMetadata> = state.songMetadata.get(variation);
+        if (variationMetadata != null) zipEntries.push(FileUtil.makeZIPEntry('${state.currentSongId}-metadata-$variationId.json',
+          SerializerUtil.toJSON(variationMetadata)));
+        var variationChart:Null<SongChartData> = state.songChartData.get(variation);
+        if (variationChart != null) zipEntries.push(FileUtil.makeZIPEntry('${state.currentSongId}-chart-$variationId.json',
+          SerializerUtil.toJSON(variationChart)));
+      }
+    }
+
+    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...');
+
+    if (force)
+    {
+      var targetPath:String = if (tmp)
+      {
+        Path.join([
+          FileUtil.getTempDir(),
+          'chart-editor-exit-${DateUtil.generateTimestamp()}.${Constants.EXT_CHART}'
+        ]);
+      }
+      else
+      {
+        Path.join([
+          './backups/',
+          'chart-editor-exit-${DateUtil.generateTimestamp()}.${Constants.EXT_CHART}'
+        ]);
+      }
+
+      // We have to force write because the program will die before the save dialog is closed.
+      trace('Force exporting to $targetPath...');
+      FileUtil.saveFilesAsZIPToPath(zipEntries, targetPath);
+      return;
+    }
+
+    // Prompt and save.
+    var onSave:Array<String>->Void = function(paths:Array<String>) {
+      trace('Successfully exported files.');
+    };
+
+    var onCancel:Void->Void = function() {
+      trace('Export cancelled.');
+    };
+
+    FileUtil.saveMultipleFiles(zipEntries, onSave, onCancel, '${state.currentSongId}-chart.${Constants.EXT_CHART}');
+  }
+}
diff --git a/source/funkin/ui/debug/charting/ChartEditorNoteSprite.hx b/source/funkin/ui/debug/charting/ChartEditorNoteSprite.hx
index 4e0972621..77954087b 100644
--- a/source/funkin/ui/debug/charting/ChartEditorNoteSprite.hx
+++ b/source/funkin/ui/debug/charting/ChartEditorNoteSprite.hx
@@ -19,7 +19,7 @@ class ChartEditorNoteSprite extends FlxSprite
   /**
    * The list of available note skin to validate against.
    */
-  public static final NOTE_STYLES:Array<String> = ['Normal', 'Pixel'];
+  public static final NOTE_STYLES:Array<String> = ['funkin', 'pixel'];
 
   /**
    * The ChartEditorState this note belongs to.
@@ -54,20 +54,20 @@ class ChartEditorNoteSprite extends FlxSprite
 
     // Initialize all the animations, not just the one we're going to use immediately,
     // so that later we can reuse the sprite without having to initialize more animations during scrolling.
-    this.animation.addByPrefix('tapLeftNormal', 'purple instance');
-    this.animation.addByPrefix('tapDownNormal', 'blue instance');
-    this.animation.addByPrefix('tapUpNormal', 'green instance');
-    this.animation.addByPrefix('tapRightNormal', 'red instance');
+    this.animation.addByPrefix('tapLeftFunkin', 'purple instance');
+    this.animation.addByPrefix('tapDownFunkin', 'blue instance');
+    this.animation.addByPrefix('tapUpFunkin', 'green instance');
+    this.animation.addByPrefix('tapRightFunkin', 'red instance');
 
-    this.animation.addByPrefix('holdLeftNormal', 'LeftHoldPiece');
-    this.animation.addByPrefix('holdDownNormal', 'DownHoldPiece');
-    this.animation.addByPrefix('holdUpNormal', 'UpHoldPiece');
-    this.animation.addByPrefix('holdRightNormal', 'RightHoldPiece');
+    this.animation.addByPrefix('holdLeftFunkin', 'LeftHoldPiece');
+    this.animation.addByPrefix('holdDownFunkin', 'DownHoldPiece');
+    this.animation.addByPrefix('holdUpFunkin', 'UpHoldPiece');
+    this.animation.addByPrefix('holdRightFunkin', 'RightHoldPiece');
 
-    this.animation.addByPrefix('holdEndLeftNormal', 'LeftHoldEnd');
-    this.animation.addByPrefix('holdEndDownNormal', 'DownHoldEnd');
-    this.animation.addByPrefix('holdEndUpNormal', 'UpHoldEnd');
-    this.animation.addByPrefix('holdEndRightNormal', 'RightHoldEnd');
+    this.animation.addByPrefix('holdEndLeftFunkin', 'LeftHoldEnd');
+    this.animation.addByPrefix('holdEndDownFunkin', 'DownHoldEnd');
+    this.animation.addByPrefix('holdEndUpFunkin', 'UpHoldEnd');
+    this.animation.addByPrefix('holdEndRightFunkin', 'RightHoldEnd');
 
     this.animation.addByPrefix('tapLeftPixel', 'pixel4');
     this.animation.addByPrefix('tapDownPixel', 'pixel5');
@@ -187,8 +187,8 @@ class ChartEditorNoteSprite extends FlxSprite
 
   function get_noteStyle():String
   {
-    // Fall back to 'Normal' if it's not a valid note style.
-    return if (NOTE_STYLES.contains(this.parentState.currentSongNoteSkin)) this.parentState.currentSongNoteSkin else 'Normal';
+    // Fall back to Funkin' if it's not a valid note style.
+    return if (NOTE_STYLES.contains(this.parentState.currentSongNoteStyle)) this.parentState.currentSongNoteStyle else 'funkin';
   }
 
   public function playNoteAnimation():Void
@@ -199,7 +199,7 @@ class ChartEditorNoteSprite extends FlxSprite
     var baseAnimationName:String = 'tap';
 
     // Play the appropriate animation for the type, direction, and skin.
-    var animationName:String = '${baseAnimationName}${this.noteData.getDirectionName()}${this.noteStyle}';
+    var animationName:String = '${baseAnimationName}${this.noteData.getDirectionName()}${this.noteStyle.toTitleCase()}';
 
     this.animation.play(animationName);
 
@@ -213,7 +213,7 @@ class ChartEditorNoteSprite extends FlxSprite
     this.updateHitbox();
 
     // TODO: Make this an attribute of the note skin.
-    this.antialiasing = (this.parentState.currentSongNoteSkin != 'Pixel');
+    this.antialiasing = (this.parentState.currentSongNoteStyle != 'Pixel');
   }
 
   /**
diff --git a/source/funkin/ui/debug/charting/ChartEditorState.hx b/source/funkin/ui/debug/charting/ChartEditorState.hx
index add65c5bf..b4c8c3483 100644
--- a/source/funkin/ui/debug/charting/ChartEditorState.hx
+++ b/source/funkin/ui/debug/charting/ChartEditorState.hx
@@ -1,5 +1,8 @@
 package funkin.ui.debug.charting;
 
+import funkin.play.stage.StageData;
+import funkin.play.character.CharacterData.CharacterDataParser;
+import funkin.play.character.CharacterData;
 import flixel.system.FlxAssets.FlxSoundAsset;
 import flixel.math.FlxMath;
 import haxe.ui.components.TextField;
@@ -41,7 +44,7 @@ import funkin.data.song.SongRegistry;
 import funkin.data.song.SongData.SongEventData;
 import funkin.data.song.SongData.SongMetadata;
 import funkin.data.song.SongData.SongNoteData;
-import funkin.data.song.SongData.SongPlayableChar;
+import funkin.data.song.SongData.SongCharacterData;
 import funkin.data.song.SongDataUtils;
 import funkin.ui.debug.charting.ChartEditorCommand;
 import funkin.ui.debug.charting.ChartEditorCommand;
@@ -88,8 +91,11 @@ using Lambda;
 // @:nullSafety(Loose) // Enable this while developing, then disable to keep unit tests functional!
 
 @:allow(funkin.ui.debug.charting.ChartEditorCommand)
+@:allow(funkin.ui.debug.charting.ChartEditorDropdowns)
 @:allow(funkin.ui.debug.charting.ChartEditorDialogHandler)
 @:allow(funkin.ui.debug.charting.ChartEditorThemeHandler)
+@:allow(funkin.ui.debug.charting.ChartEditorAudioHandler)
+@:allow(funkin.ui.debug.charting.ChartEditorImportExportHandler)
 @:allow(funkin.ui.debug.charting.ChartEditorToolboxHandler)
 class ChartEditorState extends HaxeUIState
 {
@@ -108,7 +114,6 @@ class ChartEditorState extends HaxeUIState
   static final CHART_EDITOR_TOOLBOX_EVENTDATA_LAYOUT:String = Paths.ui('chart-editor/toolbox/eventdata');
   static final CHART_EDITOR_TOOLBOX_METADATA_LAYOUT:String = Paths.ui('chart-editor/toolbox/metadata');
   static final CHART_EDITOR_TOOLBOX_DIFFICULTY_LAYOUT:String = Paths.ui('chart-editor/toolbox/difficulty');
-  static final CHART_EDITOR_TOOLBOX_CHARACTERS_LAYOUT:String = Paths.ui('chart-editor/toolbox/characters');
   static final CHART_EDITOR_TOOLBOX_PLAYER_PREVIEW_LAYOUT:String = Paths.ui('chart-editor/toolbox/player-preview');
   static final CHART_EDITOR_TOOLBOX_OPPONENT_PREVIEW_LAYOUT:String = Paths.ui('chart-editor/toolbox/opponent-preview');
 
@@ -456,6 +461,8 @@ class ChartEditorState extends HaxeUIState
     notePreviewDirty = true;
     notePreviewViewportBoundsDirty = true;
     this.scrollPositionInPixels = this.scrollPositionInPixels;
+    // Characters have probably changed too.
+    healthIconsDirty = true;
 
     return isViewDownscroll;
   }
@@ -514,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.
@@ -523,6 +536,8 @@ class ChartEditorState extends HaxeUIState
     notePreviewDirty = true;
     notePreviewViewportBoundsDirty = true;
 
+    switchToCurrentInstrumental();
+
     return selectedVariation;
   }
 
@@ -543,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.
    */
@@ -587,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.
    */
@@ -690,6 +727,16 @@ class ChartEditorState extends HaxeUIState
    */
   var downKeyHandler:TurboKeyHandler = TurboKeyHandler.build(FlxKey.DOWN);
 
+  /**
+   * Variable used to track how long the user has been holding the W keybind.
+   */
+  var wKeyHandler:TurboKeyHandler = TurboKeyHandler.build(FlxKey.W);
+
+  /**
+   * Variable used to track how long the user has been holding the S keybind.
+   */
+  var sKeyHandler:TurboKeyHandler = TurboKeyHandler.build(FlxKey.S);
+
   /**
    * Variable used to track how long the user has been holding the page-up keybind.
    */
@@ -751,30 +798,36 @@ class ChartEditorState extends HaxeUIState
    */
   // ==============================
 
+  /**
+   * The chill audio track that plays when you open the Chart Editor.
+   */
+  public var welcomeMusic:FlxSound = new FlxSound();
+
   /**
    * 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> = [];
 
@@ -950,19 +1003,19 @@ class ChartEditorState extends HaxeUIState
     return currentSongChartData.events = value;
   }
 
-  public var currentSongNoteSkin(get, set):String;
+  public var currentSongNoteStyle(get, set):String;
 
-  function get_currentSongNoteSkin():String
+  function get_currentSongNoteStyle():String
   {
     if (currentSongMetadata.playData.noteSkin == null)
     {
       // Initialize to the default value if not set.
-      currentSongMetadata.playData.noteSkin = 'Normal';
+      currentSongMetadata.playData.noteSkin = 'funkin';
     }
     return currentSongMetadata.playData.noteSkin;
   }
 
-  function set_currentSongNoteSkin(value:String):String
+  function set_currentSongNoteStyle(value:String):String
   {
     return currentSongMetadata.playData.noteSkin = value;
   }
@@ -1025,59 +1078,6 @@ class ChartEditorState extends HaxeUIState
     return currentSongMetadata.artist = value;
   }
 
-  var currentSongPlayableCharacters(get, never):Array<String>;
-
-  function get_currentSongPlayableCharacters():Array<String>
-  {
-    return currentSongMetadata.playData.playableChars.keys().array();
-  }
-
-  var currentSongCharacterPlayer(get, set):String;
-
-  function get_currentSongCharacterPlayer():String
-  {
-    // Validate selected character before returning it.
-    if (!currentSongPlayableCharacters.contains(selectedCharacter))
-    {
-      trace('Invalid character selected: ' + selectedCharacter);
-      selectedCharacter = currentSongPlayableCharacters[0];
-    }
-
-    return selectedCharacter;
-  }
-
-  function set_currentSongCharacterPlayer(value:String):String
-  {
-    if (!currentSongPlayableCharacters.contains(value))
-    {
-      trace('Invalid character selected: ' + value);
-      return value;
-    }
-
-    return selectedCharacter = value;
-  }
-
-  var currentSongCharacterOpponent(get, set):String;
-
-  function get_currentSongCharacterOpponent():String
-  {
-    // Validate selected character before returning it.
-    if (!currentSongPlayableCharacters.contains(selectedCharacter))
-    {
-      trace('Invalid character selected: ' + selectedCharacter);
-      selectedCharacter = currentSongPlayableCharacters[0];
-    }
-
-    var playableCharData:SongPlayableChar = currentSongMetadata.playData.playableChars.get(selectedCharacter);
-    return playableCharData.opponent;
-  }
-
-  function set_currentSongCharacterOpponent(value:String):String
-  {
-    var playableCharData:SongPlayableChar = currentSongMetadata.playData.playableChars.get(selectedCharacter);
-    return playableCharData.opponent = value;
-  }
-
   /**
    * SIGNALS
    */
@@ -1249,6 +1249,9 @@ class ChartEditorState extends HaxeUIState
     // Get rid of any music from the previous state.
     FlxG.sound.music.stop();
 
+    // Play the welcome music.
+    setupWelcomeMusic();
+
     buildDefaultSongData();
 
     buildBackground();
@@ -1273,6 +1276,26 @@ class ChartEditorState extends HaxeUIState
     ChartEditorDialogHandler.openWelcomeDialog(this, false);
   }
 
+  function setupWelcomeMusic()
+  {
+    this.welcomeMusic.loadEmbedded(Paths.music('chartEditorLoop/chartEditorLoop'));
+    this.welcomeMusic.looped = true;
+    // this.welcomeMusic.play();
+    // fadeInWelcomeMusic();
+  }
+
+  public function fadeInWelcomeMusic():Void
+  {
+    this.welcomeMusic.play();
+    this.welcomeMusic.fadeIn(4, 0, 1.0);
+  }
+
+  public function stopWelcomeMusic():Void
+  {
+    // this.welcomeMusic.fadeOut(4, 0);
+    this.welcomeMusic.pause();
+  }
+
   function buildDefaultSongData():Void
   {
     selectedVariation = Constants.DEFAULT_VARIATION;
@@ -1365,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);
@@ -1373,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;
@@ -1470,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)
@@ -1518,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
@@ -1602,9 +1631,10 @@ class ChartEditorState extends HaxeUIState
 
     addUIClickListener('menubarItemNewChart', _ -> ChartEditorDialogHandler.openWelcomeDialog(this, true));
     addUIClickListener('menubarItemOpenChart', _ -> ChartEditorDialogHandler.openBrowseWizard(this, true));
-    addUIClickListener('menubarItemSaveChartAs', _ -> exportAllSongData());
+    addUIClickListener('menubarItemSaveChartAs', _ -> ChartEditorImportExportHandler.exportAllSongData(this));
     addUIClickListener('menubarItemLoadInst', _ -> ChartEditorDialogHandler.openUploadInstDialog(this, true));
     addUIClickListener('menubarItemImportChart', _ -> ChartEditorDialogHandler.openImportChartDialog(this, 'legacy', true));
+    addUIClickListener('menubarItemExit', _ -> quitChartEditor());
 
     addUIClickListener('menubarItemUndo', _ -> undoLastCommand());
 
@@ -1632,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)
@@ -1659,19 +1700,24 @@ class ChartEditorState extends HaxeUIState
 
     addUIClickListener('menubarItemSelectNone', _ -> performCommand(new DeselectAllItemsCommand(currentNoteSelection, currentEventSelection)));
 
-    // TODO: Implement these.
-    // addUIClickListener('menubarItemSelectRegion', _ -> doSomething());
-    // addUIClickListener('menubarItemSelectBeforeCursor', _ -> doSomething());
-    // addUIClickListener('menubarItemSelectAfterCursor', _ -> doSomething());
-
     addUIClickListener('menubarItemPlaytestFull', _ -> testSongInPlayState(false));
     addUIClickListener('menubarItemPlaytestMinimal', _ -> testSongInPlayState(true));
 
-    addUIChangeListener('menubarItemInputStyleGroup', function(event:UIEvent) {
-      trace('Change input style: ${event.target}');
+    addUIClickListener('menuBarItemNoteSnapDecrease', _ -> noteSnapQuantIndex--);
+    addUIClickListener('menuBarItemNoteSnapIncrease', _ -> noteSnapQuantIndex++);
+
+    addUIChangeListener('menuBarItemInputStyleNone', function(event:UIEvent) {
+      currentLiveInputStyle = None;
+    });
+    addUIChangeListener('menuBarItemInputStyleNumberKeys', function(event:UIEvent) {
+      currentLiveInputStyle = NumberKeys;
+    });
+    addUIChangeListener('menuBarItemInputStyleWASD', function(event:UIEvent) {
+      currentLiveInputStyle = WASD;
     });
 
     addUIClickListener('menubarItemAbout', _ -> ChartEditorDialogHandler.openAboutDialog(this));
+    addUIClickListener('menubarItemWelcomeDialog', _ -> ChartEditorDialogHandler.openWelcomeDialog(this, true));
 
     addUIClickListener('menubarItemUserGuide', _ -> ChartEditorDialogHandler.openUserGuideDialog(this));
 
@@ -1694,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);
 
@@ -1707,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)}%';
       });
@@ -1717,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)}%';
       });
@@ -1738,18 +1789,14 @@ class ChartEditorState extends HaxeUIState
       });
     }
 
-    addUIChangeListener('menubarItemToggleToolboxTools',
-      event -> ChartEditorToolboxHandler.setToolboxState(this, CHART_EDITOR_TOOLBOX_TOOLS_LAYOUT, event.value));
-    addUIChangeListener('menubarItemToggleToolboxNotes',
-      event -> ChartEditorToolboxHandler.setToolboxState(this, CHART_EDITOR_TOOLBOX_NOTEDATA_LAYOUT, event.value));
-    addUIChangeListener('menubarItemToggleToolboxEvents',
-      event -> ChartEditorToolboxHandler.setToolboxState(this, CHART_EDITOR_TOOLBOX_EVENTDATA_LAYOUT, event.value));
     addUIChangeListener('menubarItemToggleToolboxDifficulty',
       event -> ChartEditorToolboxHandler.setToolboxState(this, CHART_EDITOR_TOOLBOX_DIFFICULTY_LAYOUT, event.value));
     addUIChangeListener('menubarItemToggleToolboxMetadata',
       event -> ChartEditorToolboxHandler.setToolboxState(this, CHART_EDITOR_TOOLBOX_METADATA_LAYOUT, event.value));
-    addUIChangeListener('menubarItemToggleToolboxCharacters',
-      event -> ChartEditorToolboxHandler.setToolboxState(this, CHART_EDITOR_TOOLBOX_CHARACTERS_LAYOUT, event.value));
+    addUIChangeListener('menubarItemToggleToolboxNotes',
+      event -> ChartEditorToolboxHandler.setToolboxState(this, CHART_EDITOR_TOOLBOX_NOTEDATA_LAYOUT, event.value));
+    addUIChangeListener('menubarItemToggleToolboxEvents',
+      event -> ChartEditorToolboxHandler.setToolboxState(this, CHART_EDITOR_TOOLBOX_EVENTDATA_LAYOUT, event.value));
     addUIChangeListener('menubarItemToggleToolboxPlayerPreview',
       event -> ChartEditorToolboxHandler.setToolboxState(this, CHART_EDITOR_TOOLBOX_PLAYER_PREVIEW_LAYOUT, event.value));
     addUIChangeListener('menubarItemToggleToolboxOpponentPreview',
@@ -1769,6 +1816,8 @@ class ChartEditorState extends HaxeUIState
     add(redoKeyHandler);
     add(upKeyHandler);
     add(downKeyHandler);
+    add(wKeyHandler);
+    add(sKeyHandler);
     add(pageUpKeyHandler);
     add(pageDownKeyHandler);
   }
@@ -1795,7 +1844,7 @@ class ChartEditorState extends HaxeUIState
     // Auto-save to local storage.
     #else
     // Auto-save to temp file.
-    exportAllSongData(true, true);
+    ChartEditorImportExportHandler.exportAllSongData(this, true);
     #end
   }
 
@@ -1806,7 +1855,7 @@ class ChartEditorState extends HaxeUIState
 
     if (saveDataDirty)
     {
-      exportAllSongData(true);
+      ChartEditorImportExportHandler.exportAllSongData(this, true);
     }
   }
 
@@ -1817,6 +1866,13 @@ class ChartEditorState extends HaxeUIState
 
   public override function update(elapsed:Float):Void
   {
+    // Override F4 behavior to include the autosave.
+    if (FlxG.keys.justPressed.F4)
+    {
+      quitChartEditor();
+      return;
+    }
+
     // dispatchEvent gets called here.
     super.update(elapsed);
 
@@ -1896,20 +1952,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 != LiveInputStyle.WASD)
+    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 != LiveInputStyle.WASD)
+    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 * 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 * 25.0;
       shouldPause = true;
     }
 
@@ -1974,7 +2043,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)
@@ -2045,14 +2114,17 @@ class ChartEditorState extends HaxeUIState
 
   function handleSnap():Void
   {
-    if (FlxG.keys.justPressed.LEFT && !FlxG.keys.pressed.CONTROL)
+    if (currentLiveInputStyle == None)
     {
-      noteSnapQuantIndex--;
-    }
+      if (FlxG.keys.justPressed.LEFT && !FlxG.keys.pressed.CONTROL)
+      {
+        noteSnapQuantIndex--;
+      }
 
-    if (FlxG.keys.justPressed.RIGHT && !FlxG.keys.pressed.CONTROL)
-    {
-      noteSnapQuantIndex++;
+      if (FlxG.keys.justPressed.RIGHT && !FlxG.keys.pressed.CONTROL)
+      {
+        noteSnapQuantIndex++;
+      }
     }
   }
 
@@ -2274,7 +2346,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))
@@ -2282,7 +2353,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();
             }
 
@@ -2407,13 +2477,20 @@ class ChartEditorState extends HaxeUIState
         var dragLengthMs:Float = dragLengthSteps * Conductor.stepLengthMs;
         var dragLengthPixels:Float = dragLengthSteps * GRID_SIZE;
 
-        gridGhostHoldNote.visible = true;
-        gridGhostHoldNote.noteData = gridGhostNote.noteData;
-        gridGhostHoldNote.noteDirection = gridGhostNote.noteData.getDirection();
+        if (dragLengthSteps > 0)
+        {
+          gridGhostHoldNote.visible = true;
+          gridGhostHoldNote.noteData = gridGhostNote.noteData;
+          gridGhostHoldNote.noteDirection = gridGhostNote.noteData.getDirection();
 
-        gridGhostHoldNote.setHeightDirectly(dragLengthPixels);
+          gridGhostHoldNote.setHeightDirectly(dragLengthPixels);
 
-        gridGhostHoldNote.updateHoldNotePosition(renderedHoldNotes);
+          gridGhostHoldNote.updateHoldNotePosition(renderedHoldNotes);
+        }
+        else
+        {
+          gridGhostHoldNote.visible = false;
+        }
 
         if (FlxG.mouse.justReleased)
         {
@@ -2900,8 +2977,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;
         }
       }
 
@@ -2932,6 +3009,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);
   }
 
   /**
@@ -2939,6 +3018,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)
     {
@@ -2955,6 +3040,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);
   }
 
@@ -3016,13 +3103,25 @@ class ChartEditorState extends HaxeUIState
       ChartEditorDialogHandler.openBrowseWizard(this, true);
     }
 
+    // CTRL + SHIFT + S = Save As
+    if (FlxG.keys.pressed.CONTROL && FlxG.keys.pressed.SHIFT && FlxG.keys.justPressed.S)
+    {
+      ChartEditorImportExportHandler.exportAllSongData(this, false);
+    }
+
     // CTRL + Q = Quit to Menu
     if (FlxG.keys.pressed.CONTROL && FlxG.keys.justPressed.Q)
     {
-      FlxG.switchState(new MainMenuState());
+      quitChartEditor();
     }
   }
 
+  function quitChartEditor():Void
+  {
+    autoSave();
+    FlxG.switchState(new MainMenuState());
+  }
+
   /**
    * Handle keybinds for edit menu items.
    */
@@ -3062,8 +3161,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
@@ -3111,13 +3222,17 @@ class ChartEditorState extends HaxeUIState
    */
   function handleViewKeybinds():Void
   {
-    if (FlxG.keys.pressed.CONTROL && FlxG.keys.justPressed.LEFT)
+    if (currentLiveInputStyle == None)
     {
-      incrementDifficulty(-1);
-    }
-    if (FlxG.keys.pressed.CONTROL && FlxG.keys.justPressed.RIGHT)
-    {
-      incrementDifficulty(1);
+      if (FlxG.keys.pressed.CONTROL && FlxG.keys.justPressed.LEFT)
+      {
+        incrementDifficulty(-1);
+      }
+      if (FlxG.keys.pressed.CONTROL && FlxG.keys.justPressed.RIGHT)
+      {
+        incrementDifficulty(1);
+      }
+      // Would bind Ctrl+A and Ctrl+D here, but they are already bound to Select All and Select None.
     }
   }
 
@@ -3167,7 +3282,7 @@ class ChartEditorState extends HaxeUIState
         selectedDifficulty = prevDifficulty;
 
         refreshDifficultyTreeSelection();
-        refreshSongMetadataToolbox();
+        refreshMetadataToolbox();
       }
       else
       {
@@ -3176,7 +3291,7 @@ class ChartEditorState extends HaxeUIState
         selectedDifficulty = prevDifficulty;
 
         refreshDifficultyTreeSelection();
-        refreshSongMetadataToolbox();
+        refreshMetadataToolbox();
       }
     }
     else
@@ -3195,7 +3310,7 @@ class ChartEditorState extends HaxeUIState
         selectedDifficulty = nextDifficulty;
 
         refreshDifficultyTreeSelection();
-        refreshSongMetadataToolbox();
+        refreshMetadataToolbox();
       }
       else
       {
@@ -3204,7 +3319,7 @@ class ChartEditorState extends HaxeUIState
         selectedDifficulty = nextDifficulty;
 
         refreshDifficultyTreeSelection();
-        refreshSongMetadataToolbox();
+        refreshMetadataToolbox();
       }
     }
 
@@ -3224,7 +3339,7 @@ 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;
       testSongInPlayState(minimal);
@@ -3296,6 +3411,28 @@ class ChartEditorState extends HaxeUIState
     }
   }
 
+  public function createDifficulty(variation:String, difficulty:String, scrollSpeed:Float = 1.0)
+  {
+    var variationMetadata:Null<SongMetadata> = songMetadata.get(variation);
+    if (variationMetadata == null) return;
+
+    variationMetadata.playData.difficulties.push(difficulty);
+
+    var resultChartData = songChartData.get(variation);
+    if (resultChartData == null)
+    {
+      resultChartData = new SongChartData([difficulty => scrollSpeed], [], [difficulty => []]);
+      songChartData.set(variation, resultChartData);
+    }
+    else
+    {
+      resultChartData.scrollSpeed.set(difficulty, scrollSpeed);
+      resultChartData.notes.set(difficulty, []);
+    }
+
+    difficultySelectDirty = true; // Force the Difficulty toolbox to update.
+  }
+
   function refreshDifficultyTreeSelection(?treeView:TreeView):Void
   {
     if (treeView == null)
@@ -3328,11 +3465,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;
@@ -3364,11 +3501,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;
@@ -3469,7 +3606,7 @@ class ChartEditorState extends HaxeUIState
           selectedVariation = variation;
           selectedDifficulty = difficulty;
           // refreshDifficultyTreeSelection(treeView);
-          refreshSongMetadataToolbox();
+          refreshMetadataToolbox();
         }
       // case 'song':
       // case 'variation':
@@ -3478,14 +3615,14 @@ class ChartEditorState extends HaxeUIState
         trace('Selected wrong node type, resetting selection.');
         var currentTreeDifficultyNode = getCurrentTreeDifficultyNode(treeView);
         if (currentTreeDifficultyNode != null) treeView.selectedNode = currentTreeDifficultyNode;
-        refreshSongMetadataToolbox();
+        refreshMetadataToolbox();
     }
   }
 
   /**
    * When the difficulty changes, update the song metadata toolbox to reflect the new data.
    */
-  function refreshSongMetadataToolbox():Void
+  function refreshMetadataToolbox():Void
   {
     var toolbox:Null<CollapsibleDialog> = ChartEditorToolboxHandler.getToolbox(this, CHART_EDITOR_TOOLBOX_METADATA_LAYOUT);
     if (toolbox == null) return;
@@ -3499,8 +3636,8 @@ class ChartEditorState extends HaxeUIState
     var inputStage:Null<DropDown> = toolbox.findComponent('inputStage', DropDown);
     if (inputStage != null) inputStage.value = currentSongMetadata.playData.stage;
 
-    var inputNoteSkin:Null<DropDown> = toolbox.findComponent('inputNoteSkin', DropDown);
-    if (inputNoteSkin != null) inputNoteSkin.value = currentSongMetadata.playData.noteSkin;
+    var inputNoteStyle:Null<DropDown> = toolbox.findComponent('inputNoteStyle', DropDown);
+    if (inputNoteStyle != null) inputNoteStyle.value = currentSongMetadata.playData.noteSkin;
 
     var inputBPM:Null<NumberStepper> = toolbox.findComponent('inputBPM', NumberStepper);
     if (inputBPM != null) inputBPM.value = currentSongMetadata.timeChanges[0].bpm;
@@ -3515,16 +3652,54 @@ class ChartEditorState extends HaxeUIState
     if (frameVariation != null) frameVariation.text = 'Variation: ${selectedVariation.toTitleCase()}';
     var frameDifficulty:Null<Frame> = toolbox.findComponent('frameDifficulty', Frame);
     if (frameDifficulty != null) frameDifficulty.text = 'Difficulty: ${selectedDifficulty.toTitleCase()}';
-  }
 
-  function addDifficulty(variation:String):Void {}
+    var inputStage:Null<DropDown> = toolbox.findComponent('inputStage', DropDown);
+    var stageId:String = currentSongMetadata.playData.stage;
+    var stageData:Null<StageData> = StageDataParser.parseStageData(stageId);
+    if (stageData != null)
+    {
+      inputStage.value = {id: stageId, text: stageData.name};
+    }
+    else
+    {
+      inputStage.value = {id: "mainStage", text: "Main Stage"};
+    }
 
-  function addVariation(variationId:String):Void
-  {
-    // Create a new variation with the specified ID.
-    songMetadata.set(variationId, currentSongMetadata.clone(variationId));
-    // Switch to the new variation.
-    selectedVariation = variationId;
+    var inputCharacterPlayer:Null<DropDown> = toolbox.findComponent('inputCharacterPlayer', DropDown);
+    var charIdPlayer:String = currentSongMetadata.playData.characters.player;
+    var charDataPlayer:Null<CharacterData> = CharacterDataParser.fetchCharacterData(charIdPlayer);
+    if (charDataPlayer != null)
+    {
+      inputCharacterPlayer.value = {id: charIdPlayer, text: charDataPlayer.name};
+    }
+    else
+    {
+      inputCharacterPlayer.value = {id: "bf", text: "Boyfriend"};
+    }
+
+    var inputCharacterOpponent:Null<DropDown> = toolbox.findComponent('inputCharacterOpponent', DropDown);
+    var charIdOpponent:String = currentSongMetadata.playData.characters.opponent;
+    var charDataOpponent:Null<CharacterData> = CharacterDataParser.fetchCharacterData(charIdOpponent);
+    if (charDataOpponent != null)
+    {
+      inputCharacterOpponent.value = {id: charIdOpponent, text: charDataOpponent.name};
+    }
+    else
+    {
+      inputCharacterOpponent.value = {id: "dad", text: "Dad"};
+    }
+
+    var inputCharacterGirlfriend:Null<DropDown> = toolbox.findComponent('inputCharacterGirlfriend', DropDown);
+    var charIdGirlfriend:String = currentSongMetadata.playData.characters.girlfriend;
+    var charDataGirlfriend:Null<CharacterData> = CharacterDataParser.fetchCharacterData(charIdGirlfriend);
+    if (charDataGirlfriend != null)
+    {
+      inputCharacterGirlfriend.value = {id: charIdGirlfriend, text: charDataGirlfriend.name};
+    }
+    else
+    {
+      inputCharacterGirlfriend.value = {id: "none", text: "None"};
+    }
   }
 
   /**
@@ -3710,9 +3885,9 @@ class ChartEditorState extends HaxeUIState
       switch (noteData.getStrumlineIndex())
       {
         case 0: // Player
-          if (hitsoundsEnabledPlayer) playSound(Paths.sound('funnyNoise/funnyNoise-09'));
+          if (hitsoundsEnabledPlayer) ChartEditorAudioHandler.playSound(Paths.sound('ui/chart-editor/playerHitsound'));
         case 1: // Opponent
-          if (hitsoundsEnabledOpponent) playSound(Paths.sound('funnyNoise/funnyNoise-010'));
+          if (hitsoundsEnabledOpponent) ChartEditorAudioHandler.playSound(Paths.sound('ui/chart-editor/opponentHitsound'));
       }
     }
   }
@@ -3756,25 +3931,26 @@ class ChartEditorState extends HaxeUIState
     switch (currentLiveInputStyle)
     {
       case LiveInputStyle.WASD:
-        if (FlxG.keys.justPressed.A) placeNoteAtPlayhead(0);
-        if (FlxG.keys.justPressed.S) placeNoteAtPlayhead(1);
-        if (FlxG.keys.justPressed.W) placeNoteAtPlayhead(2);
-        if (FlxG.keys.justPressed.D) placeNoteAtPlayhead(3);
+        if (FlxG.keys.justPressed.A) placeNoteAtPlayhead(4);
+        if (FlxG.keys.justPressed.S) placeNoteAtPlayhead(5);
+        if (FlxG.keys.justPressed.W) placeNoteAtPlayhead(6);
+        if (FlxG.keys.justPressed.D) placeNoteAtPlayhead(7);
 
-        if (FlxG.keys.justPressed.LEFT) placeNoteAtPlayhead(4);
-        if (FlxG.keys.justPressed.DOWN) placeNoteAtPlayhead(5);
-        if (FlxG.keys.justPressed.UP) placeNoteAtPlayhead(6);
-        if (FlxG.keys.justPressed.RIGHT) placeNoteAtPlayhead(7);
+        if (FlxG.keys.justPressed.LEFT) placeNoteAtPlayhead(0);
+        if (FlxG.keys.justPressed.DOWN) placeNoteAtPlayhead(1);
+        if (FlxG.keys.justPressed.UP) placeNoteAtPlayhead(2);
+        if (FlxG.keys.justPressed.RIGHT) placeNoteAtPlayhead(3);
       case LiveInputStyle.NumberKeys:
-        if (FlxG.keys.justPressed.ONE) placeNoteAtPlayhead(0);
-        if (FlxG.keys.justPressed.TWO) placeNoteAtPlayhead(1);
-        if (FlxG.keys.justPressed.THREE) placeNoteAtPlayhead(2);
-        if (FlxG.keys.justPressed.FOUR) placeNoteAtPlayhead(3);
+        // Flipped because Dad is on the left but represents data 0-3.
+        if (FlxG.keys.justPressed.ONE) placeNoteAtPlayhead(4);
+        if (FlxG.keys.justPressed.TWO) placeNoteAtPlayhead(5);
+        if (FlxG.keys.justPressed.THREE) placeNoteAtPlayhead(6);
+        if (FlxG.keys.justPressed.FOUR) placeNoteAtPlayhead(7);
 
-        if (FlxG.keys.justPressed.FIVE) placeNoteAtPlayhead(4);
-        if (FlxG.keys.justPressed.SIX) placeNoteAtPlayhead(5);
-        if (FlxG.keys.justPressed.SEVEN) placeNoteAtPlayhead(6);
-        if (FlxG.keys.justPressed.EIGHT) placeNoteAtPlayhead(7);
+        if (FlxG.keys.justPressed.FIVE) placeNoteAtPlayhead(0);
+        if (FlxG.keys.justPressed.SIX) placeNoteAtPlayhead(1);
+        if (FlxG.keys.justPressed.SEVEN) placeNoteAtPlayhead(2);
+        if (FlxG.keys.justPressed.EIGHT) placeNoteAtPlayhead(3);
       case LiveInputStyle.None:
         // Do nothing.
     }
@@ -3783,12 +3959,24 @@ class ChartEditorState extends HaxeUIState
   function placeNoteAtPlayhead(column:Int):Void
   {
     var playheadPos:Float = scrollPositionInPixels + playheadPositionInPixels;
-    var playheadPosFractionalStep:Float = playheadPos / GRID_SIZE / (16 / noteSnapQuant);
+    var playheadPosFractionalStep:Float = playheadPos / GRID_SIZE / noteSnapRatio;
     var playheadPosStep:Int = Std.int(Math.floor(playheadPosFractionalStep));
-    var playheadPosMs:Float = playheadPosStep * Conductor.stepLengthMs * (16 / noteSnapQuant);
+    var playheadPosSnappedMs:Float = playheadPosStep * Conductor.stepLengthMs * noteSnapRatio;
 
-    var newNoteData:SongNoteData = new SongNoteData(playheadPosMs, column, 0, selectedNoteKind);
-    performCommand(new AddNotesCommand([newNoteData], FlxG.keys.pressed.CONTROL));
+    // Look for notes within 1 step of the playhead.
+    var notesAtPos:Array<SongNoteData> = SongDataUtils.getNotesInTimeRange(currentSongChartNoteData, playheadPosSnappedMs,
+      playheadPosSnappedMs + Conductor.stepLengthMs * noteSnapRatio);
+    notesAtPos = SongDataUtils.getNotesWithData(notesAtPos, [column]);
+
+    if (notesAtPos.length == 0)
+    {
+      var newNoteData:SongNoteData = new SongNoteData(playheadPosSnappedMs, column, 0, selectedNoteKind);
+      performCommand(new AddNotesCommand([newNoteData], FlxG.keys.pressed.CONTROL));
+    }
+    else
+    {
+      trace('Already a note there.');
+    }
   }
 
   function set_scrollPositionInPixels(value:Float):Float
@@ -3847,6 +4035,8 @@ class ChartEditorState extends HaxeUIState
    */
   public function testSongInPlayState(minimal:Bool = false):Void
   {
+    autoSave();
+
     var startTimestamp:Float = 0;
     if (playtestStartTime) startTimestamp = scrollPositionInMs + playheadPositionInMs;
 
@@ -3913,77 +4103,6 @@ class ChartEditorState extends HaxeUIState
     Conductor.update(targetPos);
   }
 
-  /**
-   * 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.
-   */
-  public function loadInstrumentalFromPath(path:Path):Bool
-  {
-    #if sys
-    // Validate file extension.
-    if (path.ext != null && !SUPPORTED_MUSIC_FORMATS.contains(path.ext))
-    {
-      return false;
-    }
-
-    var fileBytes:haxe.io.Bytes = sys.io.File.getBytes(path.toString());
-    return loadInstrumentalFromBytes(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.
-   */
-  public function loadInstrumentalFromBytes(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);
-    audioInstTrack = FlxG.sound.load(openflSound, 1.0, false);
-    audioInstTrack.autoDestroy = false;
-    audioInstTrack.pause();
-
-    audioInstTrackData = bytes;
-
-    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.
-   */
-  public function loadInstrumentalFromAsset(path:String):Bool
-  {
-    var instTrack:FlxSound = FlxG.sound.load(path, 1.0, false);
-    if (instTrack != null)
-    {
-      audioInstTrack = instTrack;
-
-      audioInstTrackData = Assets.getBytes(path);
-
-      postLoadInstrumental();
-      return true;
-    }
-
-    return false;
-  }
-
   public function postLoadInstrumental():Void
   {
     if (audioInstTrack != null)
@@ -4005,32 +4124,21 @@ 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();
   }
 
-  /**
-   * Loads 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.
-   * @return Success or failure.
-   */
-  public function loadVocalsFromPath(path:Path, charKey:String = 'default'):Bool
-  {
-    #if sys
-    var fileBytes:haxe.io.Bytes = sys.io.File.getBytes(path.toString());
-    return loadVocalsFromBytes(fileBytes, charKey);
-    #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
-  }
-
   /**
    * Clear the voices group.
    */
@@ -4039,141 +4147,6 @@ class ChartEditorState extends HaxeUIState
     if (audioVocalTrackGroup != null) audioVocalTrackGroup.clear();
   }
 
-  /**
-   * Load a vocal track for a given song and character and add it to the voices group.
-   *
-   * @param path ID of the asset.
-   * @param charKey Character to load the vocal track for.
-   * @return Success or failure.
-   */
-  public function loadVocalsFromAsset(path:String, charType:CharacterType = OTHER):Bool
-  {
-    var vocalTrack:FlxSound = FlxG.sound.load(path, 1.0, false);
-    if (vocalTrack != null)
-    {
-      switch (charType)
-      {
-        case CharacterType.BF:
-          if (audioVocalTrackGroup != null) audioVocalTrackGroup.addPlayerVoice(vocalTrack);
-          audioVocalTrackData.set(currentSongCharacterPlayer, Assets.getBytes(path));
-        case CharacterType.DAD:
-          if (audioVocalTrackGroup != null) audioVocalTrackGroup.addOpponentVoice(vocalTrack);
-          audioVocalTrackData.set(currentSongCharacterOpponent, Assets.getBytes(path));
-        default:
-          if (audioVocalTrackGroup != null) audioVocalTrackGroup.add(vocalTrack);
-          audioVocalTrackData.set('default', Assets.getBytes(path));
-      }
-
-      return true;
-    }
-    return false;
-  }
-
-  /**
-   * Loads a vocal track from audio byte data.
-   */
-  public function loadVocalsFromBytes(bytes:haxe.io.Bytes, charKey:String = ''):Bool
-  {
-    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 (audioVocalTrackGroup != null) audioVocalTrackGroup.add(vocalTrack);
-    audioVocalTrackData.set(charKey, bytes);
-    return true;
-  }
-
-  /**
-   * Fetch's a song's existing chart and audio and loads it, replacing the current song.
-   */
-  public function loadSongAsTemplate(songId:String):Void
-  {
-    var song:Null<Song> = SongRegistry.instance.fetchEntry(songId);
-
-    if (song == null) return;
-
-    // Load the song metadata.
-    var rawSongMetadata:Array<SongMetadata> = song.getRawMetadata();
-    var songMetadata:Map<String, SongMetadata> = [];
-    var songChartData:Map<String, SongChartData> = [];
-
-    for (metadata in rawSongMetadata)
-    {
-      if (metadata == null) continue;
-      var variation = (metadata.variation == null || metadata.variation == '') ? 'default' : metadata.variation;
-
-      // Clone to prevent modifying the original.
-      var metadataClone:SongMetadata = metadata.clone(variation);
-      if (metadataClone != null) songMetadata.set(variation, metadataClone);
-
-      songChartData.set(variation, SongRegistry.instance.parseEntryChartData(songId, metadata.variation));
-    }
-
-    loadSong(songMetadata, songChartData);
-
-    sortChartData();
-
-    clearVocals();
-
-    loadInstrumentalFromAsset(Paths.inst(songId));
-
-    var diff:Null<SongDifficulty> = song.getDifficulty(selectedDifficulty);
-    var voiceList:Array<String> = diff != null ? diff.buildVoiceList(currentSongCharacterPlayer) : [];
-    if (voiceList.length == 2)
-    {
-      loadVocalsFromAsset(voiceList[0], BF);
-      loadVocalsFromAsset(voiceList[1], DAD);
-    }
-    else
-    {
-      for (voicePath in voiceList)
-      {
-        loadVocalsFromAsset(voicePath);
-      }
-    }
-
-    #if !mac
-    NotificationManager.instance.addNotification(
-      {
-        title: 'Success',
-        body: 'Loaded song (${rawSongMetadata[0].songName})',
-        type: NotificationType.Success,
-        expiryMs: ChartEditorState.NOTIFICATION_DISMISS_TIME
-      });
-    #end
-  }
-
-  /**
-   * Loads song metadata and chart data into the editor.
-   * @param newSongMetadata The song metadata to load.
-   * @param newSongChartData The song chart data to load.
-   */
-  public function loadSong(newSongMetadata:Map<String, SongMetadata>, newSongChartData:Map<String, SongChartData>):Void
-  {
-    this.songMetadata = newSongMetadata;
-    this.songChartData = newSongChartData;
-
-    Conductor.forceBPM(null); // Disable the forced BPM.
-    Conductor.mapTimeChanges(currentSongMetadata.timeChanges);
-
-    notePreviewDirty = true;
-    notePreviewViewportBoundsDirty = true;
-    difficultySelectDirty = true;
-    opponentPreviewDirty = true;
-    playerPreviewDirty = true;
-
-    // Remove instrumental and vocal tracks, they will be loaded next.
-    if (audioInstTrack != null)
-    {
-      audioInstTrack.stop();
-      audioInstTrack = null;
-    }
-    if (audioVocalTrackGroup != null)
-    {
-      audioVocalTrackGroup.stop();
-      audioVocalTrackGroup.clear();
-    }
-  }
-
   /**
    * When setting the scroll position, except when automatically scrolling during song playback,
    * we need to update the conductor's current step time and the timestamp of the audio tracks.
@@ -4291,7 +4264,7 @@ class ChartEditorState extends HaxeUIState
 
   function playMetronomeTick(high:Bool = false):Void
   {
-    playSound(Paths.sound('pianoStuff/piano-${high ? '001' : '008'}'));
+    ChartEditorAudioHandler.playSound(Paths.sound('pianoStuff/piano-${high ? '001' : '008'}'));
   }
 
   function isNoteSelected(note:Null<SongNoteData>):Bool
@@ -4304,27 +4277,6 @@ class ChartEditorState extends HaxeUIState
     return event != null && currentEventSelection.indexOf(event) != -1;
   }
 
-  /**
-   * Play a sound effect.
-   * Automatically cleans up after itself and recycles previous FlxSound instances if available, for performance.
-   */
-  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();
-  }
-
   override function destroy():Void
   {
     super.destroy();
@@ -4345,78 +4297,6 @@ class ChartEditorState extends HaxeUIState
   {
     NotificationManager.instance.clearNotifications();
   }
-
-  /**
-   * @param force Whether to force the export without prompting the user for a file location.
-   * @param tmp If true, save to the temporary directory instead of the local `backup` directory.
-   */
-  public function exportAllSongData(force:Bool = false, tmp:Bool = false):Void
-  {
-    var zipEntries:Array<haxe.zip.Entry> = [];
-
-    for (variation in availableVariations)
-    {
-      var variationId:String = variation;
-      if (variation == '' || variation == 'default' || variation == 'normal')
-      {
-        variationId = '';
-      }
-
-      if (variationId == '')
-      {
-        var variationMetadata:Null<SongMetadata> = songMetadata.get(variation);
-        if (variationMetadata != null) zipEntries.push(FileUtil.makeZIPEntry('$currentSongId-metadata.json', SerializerUtil.toJSON(variationMetadata)));
-        var variationChart:Null<SongChartData> = songChartData.get(variation);
-        if (variationChart != null) zipEntries.push(FileUtil.makeZIPEntry('$currentSongId-chart.json', SerializerUtil.toJSON(variationChart)));
-      }
-      else
-      {
-        var variationMetadata:Null<SongMetadata> = songMetadata.get(variation);
-        if (variationMetadata != null) zipEntries.push(FileUtil.makeZIPEntry('$currentSongId-metadata-$variationId.json',
-          SerializerUtil.toJSON(variationMetadata)));
-        var variationChart:Null<SongChartData> = songChartData.get(variation);
-        if (variationChart != null) zipEntries.push(FileUtil.makeZIPEntry('$currentSongId-chart-$variationId.json', SerializerUtil.toJSON(variationChart)));
-      }
-    }
-
-    if (audioInstTrackData != null) zipEntries.push(FileUtil.makeZIPEntryFromBytes('Inst.ogg', audioInstTrackData));
-    for (charId in audioVocalTrackData.keys())
-    {
-      var entryData = audioVocalTrackData.get(charId);
-      if (entryData == null) continue;
-      zipEntries.push(FileUtil.makeZIPEntryFromBytes('Vocals-$charId.ogg', entryData));
-    }
-
-    trace('Exporting ${zipEntries.length} files to ZIP...');
-
-    if (force)
-    {
-      var targetPath:String = if (tmp)
-      {
-        Path.join([FileUtil.getTempDir(), 'chart-editor-exit-${DateUtil.generateTimestamp()}.zip']);
-      }
-      else
-      {
-        Path.join(['./backups/', 'chart-editor-exit-${DateUtil.generateTimestamp()}.zip']);
-      }
-
-      // We have to force write because the program will die before the save dialog is closed.
-      trace('Force exporting to $targetPath...');
-      FileUtil.saveFilesAsZIPToPath(zipEntries, targetPath);
-      return;
-    }
-
-    // Prompt and save.
-    var onSave:Array<String>->Void = function(paths:Array<String>) {
-      trace('Successfully exported files.');
-    };
-
-    var onCancel:Void->Void = function() {
-      trace('Export cancelled.');
-    };
-
-    FileUtil.saveMultipleFiles(zipEntries, onSave, onCancel, '$currentSongId-chart.zip');
-  }
 }
 
 enum LiveInputStyle
diff --git a/source/funkin/ui/debug/charting/ChartEditorToolboxHandler.hx b/source/funkin/ui/debug/charting/ChartEditorToolboxHandler.hx
index db090542d..6f89b6b63 100644
--- a/source/funkin/ui/debug/charting/ChartEditorToolboxHandler.hx
+++ b/source/funkin/ui/debug/charting/ChartEditorToolboxHandler.hx
@@ -1,5 +1,10 @@
 package funkin.ui.debug.charting;
 
+import funkin.ui.haxeui.components.FunkinDropDown;
+import funkin.play.stage.StageData.StageDataParser;
+import funkin.play.stage.StageData;
+import funkin.play.character.CharacterData;
+import funkin.play.character.CharacterData.CharacterDataParser;
 import haxe.ui.components.HorizontalSlider;
 import haxe.ui.containers.TreeView;
 import haxe.ui.containers.TreeViewNode;
@@ -9,6 +14,7 @@ import funkin.data.event.SongEventData;
 import funkin.data.song.SongData.SongTimeChange;
 import funkin.play.song.SongSerializer;
 import funkin.ui.haxeui.components.CharacterPlayer;
+import funkin.util.FileUtil;
 import haxe.ui.components.Button;
 import haxe.ui.components.CheckBox;
 import haxe.ui.components.DropDown;
@@ -78,8 +84,6 @@ class ChartEditorToolboxHandler
           onShowToolboxDifficulty(state, toolbox);
         case ChartEditorState.CHART_EDITOR_TOOLBOX_METADATA_LAYOUT:
           onShowToolboxMetadata(state, toolbox);
-        case ChartEditorState.CHART_EDITOR_TOOLBOX_CHARACTERS_LAYOUT:
-          onShowToolboxCharacters(state, toolbox);
         case ChartEditorState.CHART_EDITOR_TOOLBOX_PLAYER_PREVIEW_LAYOUT:
           onShowToolboxPlayerPreview(state, toolbox);
         case ChartEditorState.CHART_EDITOR_TOOLBOX_OPPONENT_PREVIEW_LAYOUT:
@@ -117,8 +121,6 @@ class ChartEditorToolboxHandler
           onHideToolboxDifficulty(state, toolbox);
         case ChartEditorState.CHART_EDITOR_TOOLBOX_METADATA_LAYOUT:
           onHideToolboxMetadata(state, toolbox);
-        case ChartEditorState.CHART_EDITOR_TOOLBOX_CHARACTERS_LAYOUT:
-          onHideToolboxCharacters(state, toolbox);
         case ChartEditorState.CHART_EDITOR_TOOLBOX_PLAYER_PREVIEW_LAYOUT:
           onHideToolboxPlayerPreview(state, toolbox);
         case ChartEditorState.CHART_EDITOR_TOOLBOX_OPPONENT_PREVIEW_LAYOUT:
@@ -167,8 +169,6 @@ class ChartEditorToolboxHandler
         toolbox = buildToolboxDifficultyLayout(state);
       case ChartEditorState.CHART_EDITOR_TOOLBOX_METADATA_LAYOUT:
         toolbox = buildToolboxMetadataLayout(state);
-      case ChartEditorState.CHART_EDITOR_TOOLBOX_CHARACTERS_LAYOUT:
-        toolbox = buildToolboxCharactersLayout(state);
       case ChartEditorState.CHART_EDITOR_TOOLBOX_PLAYER_PREVIEW_LAYOUT:
         toolbox = buildToolboxPlayerPreviewLayout(state);
       case ChartEditorState.CHART_EDITOR_TOOLBOX_OPPONENT_PREVIEW_LAYOUT:
@@ -445,14 +445,20 @@ class ChartEditorToolboxHandler
       state.setUICheckboxSelected('menubarItemToggleToolboxDifficulty', false);
     }
 
+    var difficultyToolboxAddVariation:Null<Button> = toolbox.findComponent('difficultyToolboxAddVariation', Button);
+    if (difficultyToolboxAddVariation == null)
+      throw 'ChartEditorToolboxHandler.buildToolboxDifficultyLayout() - Could not find difficultyToolboxAddVariation component.';
+    var difficultyToolboxAddDifficulty:Null<Button> = toolbox.findComponent('difficultyToolboxAddDifficulty', Button);
+    if (difficultyToolboxAddDifficulty == null)
+      throw 'ChartEditorToolboxHandler.buildToolboxDifficultyLayout() - Could not find difficultyToolboxAddDifficulty component.';
     var difficultyToolboxSaveMetadata:Null<Button> = toolbox.findComponent('difficultyToolboxSaveMetadata', Button);
     if (difficultyToolboxSaveMetadata == null)
       throw 'ChartEditorToolboxHandler.buildToolboxDifficultyLayout() - Could not find difficultyToolboxSaveMetadata component.';
     var difficultyToolboxSaveChart:Null<Button> = toolbox.findComponent('difficultyToolboxSaveChart', Button);
     if (difficultyToolboxSaveChart == null)
       throw 'ChartEditorToolboxHandler.buildToolboxDifficultyLayout() - Could not find difficultyToolboxSaveChart component.';
-    var difficultyToolboxSaveAll:Null<Button> = toolbox.findComponent('difficultyToolboxSaveAll', Button);
-    if (difficultyToolboxSaveAll == null) throw 'ChartEditorToolboxHandler.buildToolboxDifficultyLayout() - Could not find difficultyToolboxSaveAll component.';
+    // var difficultyToolboxSaveAll:Null<Button> = toolbox.findComponent('difficultyToolboxSaveAll', Button);
+    // if (difficultyToolboxSaveAll == null) throw 'ChartEditorToolboxHandler.buildToolboxDifficultyLayout() - Could not find difficultyToolboxSaveAll component.';
     var difficultyToolboxLoadMetadata:Null<Button> = toolbox.findComponent('difficultyToolboxLoadMetadata', Button);
     if (difficultyToolboxLoadMetadata == null)
       throw 'ChartEditorToolboxHandler.buildToolboxDifficultyLayout() - Could not find difficultyToolboxLoadMetadata component.';
@@ -460,26 +466,32 @@ class ChartEditorToolboxHandler
     if (difficultyToolboxLoadChart == null)
       throw 'ChartEditorToolboxHandler.buildToolboxDifficultyLayout() - Could not find difficultyToolboxLoadChart component.';
 
-    difficultyToolboxSaveMetadata.onClick = function(event:UIEvent) {
-      SongSerializer.exportSongMetadata(state.currentSongMetadata, state.currentSongId);
+    difficultyToolboxAddVariation.onClick = function(_:UIEvent) {
+      ChartEditorDialogHandler.openAddVariationDialog(state, true);
     };
 
-    difficultyToolboxSaveChart.onClick = function(event:UIEvent) {
-      SongSerializer.exportSongChartData(state.currentSongChartData, state.currentSongId);
+    difficultyToolboxAddDifficulty.onClick = function(_:UIEvent) {
+      ChartEditorDialogHandler.openAddDifficultyDialog(state, true);
     };
 
-    difficultyToolboxSaveAll.onClick = function(event:UIEvent) {
-      state.exportAllSongData();
+    difficultyToolboxSaveMetadata.onClick = function(_:UIEvent) {
+      var vari:String = state.selectedVariation != Constants.DEFAULT_VARIATION ? '-${state.selectedVariation}' : '';
+      FileUtil.writeFileReference('${state.currentSongId}$vari-metadata.json', state.currentSongMetadata.serialize());
     };
 
-    difficultyToolboxLoadMetadata.onClick = function(event:UIEvent) {
+    difficultyToolboxSaveChart.onClick = function(_:UIEvent) {
+      var vari:String = state.selectedVariation != Constants.DEFAULT_VARIATION ? '-${state.selectedVariation}' : '';
+      FileUtil.writeFileReference('${state.currentSongId}$vari-chart.json', state.currentSongChartData.serialize());
+    };
+
+    difficultyToolboxLoadMetadata.onClick = function(_:UIEvent) {
       // Replace metadata for current variation.
       SongSerializer.importSongMetadataAsync(function(songMetadata) {
         state.currentSongMetadata = songMetadata;
       });
     };
 
-    difficultyToolboxLoadChart.onClick = function(event:UIEvent) {
+    difficultyToolboxLoadChart.onClick = function(_:UIEvent) {
       // Replace chart data for current variation.
       SongSerializer.importSongChartDataAsync(function(songChartData) {
         state.currentSongChartData = songChartData;
@@ -554,7 +566,7 @@ class ChartEditorToolboxHandler
     };
     inputSongArtist.value = state.currentSongMetadata.artist;
 
-    var inputStage:Null<DropDown> = toolbox.findComponent('inputStage', DropDown);
+    var inputStage:Null<FunkinDropDown> = toolbox.findComponent('inputStage', FunkinDropDown);
     if (inputStage == null) throw 'ChartEditorToolboxHandler.buildToolboxMetadataLayout() - Could not find inputStage component.';
     inputStage.onChange = function(event:UIEvent) {
       var valid:Bool = event.data != null && event.data.id != null;
@@ -564,15 +576,48 @@ class ChartEditorToolboxHandler
         state.currentSongMetadata.playData.stage = event.data.id;
       }
     };
-    inputStage.value = state.currentSongMetadata.playData.stage;
+    var startingValueStage = ChartEditorDropdowns.populateDropdownWithStages(inputStage, state.currentSongMetadata.playData.stage);
+    inputStage.value = startingValueStage;
 
-    var inputNoteSkin:Null<DropDown> = toolbox.findComponent('inputNoteSkin', DropDown);
-    if (inputNoteSkin == null) throw 'ChartEditorToolboxHandler.buildToolboxMetadataLayout() - Could not find inputNoteSkin component.';
-    inputNoteSkin.onChange = function(event:UIEvent) {
-      if ((event?.data?.id ?? null) == null) return;
-      state.currentSongNoteSkin = event.data.id;
+    var inputNoteStyle:Null<FunkinDropDown> = toolbox.findComponent('inputNoteStyle', FunkinDropDown);
+    if (inputNoteStyle == null) throw 'ChartEditorToolboxHandler.buildToolboxMetadataLayout() - Could not find inputNoteStyle component.';
+    inputNoteStyle.onChange = function(event:UIEvent) {
+      if (event.data?.id == null) return;
+      state.currentSongNoteStyle = event.data.id;
     };
-    inputNoteSkin.value = state.currentSongNoteSkin;
+    inputNoteStyle.value = state.currentSongNoteStyle;
+
+    // By using this flag, we prevent the dropdown value from changing while it is being populated.
+
+    var inputCharacterPlayer:Null<FunkinDropDown> = toolbox.findComponent('inputCharacterPlayer', FunkinDropDown);
+    if (inputCharacterPlayer == null) throw 'ChartEditorToolboxHandler.buildToolboxMetadataLayout() - Could not find inputCharacterPlayer component.';
+    inputCharacterPlayer.onChange = function(event:UIEvent) {
+      if (event.data?.id == null) return;
+      state.currentSongMetadata.playData.characters.player = event.data.id;
+    };
+    var startingValuePlayer = ChartEditorDropdowns.populateDropdownWithCharacters(inputCharacterPlayer, CharacterType.BF,
+      state.currentSongMetadata.playData.characters.player);
+    inputCharacterPlayer.value = startingValuePlayer;
+
+    var inputCharacterOpponent:Null<FunkinDropDown> = toolbox.findComponent('inputCharacterOpponent', FunkinDropDown);
+    if (inputCharacterOpponent == null) throw 'ChartEditorToolboxHandler.buildToolboxMetadataLayout() - Could not find inputCharacterOpponent component.';
+    inputCharacterOpponent.onChange = function(event:UIEvent) {
+      if (event.data?.id == null) return;
+      state.currentSongMetadata.playData.characters.opponent = event.data.id;
+    };
+    var startingValueOpponent = ChartEditorDropdowns.populateDropdownWithCharacters(inputCharacterOpponent, CharacterType.DAD,
+      state.currentSongMetadata.playData.characters.opponent);
+    inputCharacterOpponent.value = startingValueOpponent;
+
+    var inputCharacterGirlfriend:Null<FunkinDropDown> = toolbox.findComponent('inputCharacterGirlfriend', FunkinDropDown);
+    if (inputCharacterGirlfriend == null) throw 'ChartEditorToolboxHandler.buildToolboxMetadataLayout() - Could not find inputCharacterGirlfriend component.';
+    inputCharacterGirlfriend.onChange = function(event:UIEvent) {
+      if (event.data?.id == null) return;
+      state.currentSongMetadata.playData.characters.girlfriend = event.data.id == "none" ? "" : event.data.id;
+    };
+    var startingValueGirlfriend = ChartEditorDropdowns.populateDropdownWithCharacters(inputCharacterGirlfriend, CharacterType.GF,
+      state.currentSongMetadata.playData.characters.girlfriend);
+    inputCharacterGirlfriend.value = startingValueGirlfriend;
 
     var inputBPM:Null<NumberStepper> = toolbox.findComponent('inputBPM', NumberStepper);
     if (inputBPM == null) throw 'ChartEditorToolboxHandler.buildToolboxMetadataLayout() - Could not find inputBPM component.';
@@ -630,32 +675,11 @@ class ChartEditorToolboxHandler
 
   static function onShowToolboxMetadata(state:ChartEditorState, toolbox:CollapsibleDialog):Void
   {
-    state.refreshSongMetadataToolbox();
+    state.refreshMetadataToolbox();
   }
 
   static function onHideToolboxMetadata(state:ChartEditorState, toolbox:CollapsibleDialog):Void {}
 
-  static function buildToolboxCharactersLayout(state:ChartEditorState):Null<CollapsibleDialog>
-  {
-    var toolbox:CollapsibleDialog = cast state.buildComponent(ChartEditorState.CHART_EDITOR_TOOLBOX_CHARACTERS_LAYOUT);
-
-    if (toolbox == null) return null;
-
-    // Starting position.
-    toolbox.x = 175;
-    toolbox.y = 300;
-
-    toolbox.onDialogClosed = function(event:DialogEvent) {
-      state.setUICheckboxSelected('menubarItemToggleToolboxCharacters', false);
-    }
-
-    return toolbox;
-  }
-
-  static function onShowToolboxCharacters(state:ChartEditorState, toolbox:CollapsibleDialog):Void {}
-
-  static function onHideToolboxCharacters(state:ChartEditorState, toolbox:CollapsibleDialog):Void {}
-
   static function buildToolboxPlayerPreviewLayout(state:ChartEditorState):Null<CollapsibleDialog>
   {
     var toolbox:CollapsibleDialog = cast state.buildComponent(ChartEditorState.CHART_EDITOR_TOOLBOX_PLAYER_PREVIEW_LAYOUT);
diff --git a/source/funkin/ui/haxeui/components/FunkinDropdown.hx b/source/funkin/ui/haxeui/components/FunkinDropDown.hx
similarity index 100%
rename from source/funkin/ui/haxeui/components/FunkinDropdown.hx
rename to source/funkin/ui/haxeui/components/FunkinDropDown.hx
diff --git a/source/funkin/ui/title/FlxSpriteOverlay.hx b/source/funkin/ui/title/FlxSpriteOverlay.hx
new file mode 100644
index 000000000..ddf58bbfd
--- /dev/null
+++ b/source/funkin/ui/title/FlxSpriteOverlay.hx
@@ -0,0 +1,74 @@
+package funkin.ui.title;
+
+import flixel.FlxSprite;
+import funkin.shaderslmfao.BlendModesShader;
+import openfl.display.BitmapData;
+import flixel.FlxCamera;
+import flixel.FlxG;
+import flixel.graphics.frames.FlxFrame.FlxFrameAngle;
+
+class FlxSpriteOverlay extends FlxSprite
+{
+  var blendShader:BlendModesShader;
+  var dipshitBitmap:BitmapData;
+  var temp:FlxSprite;
+
+  public function new(x:Float, y:Float)
+  {
+    super(x, y);
+    temp = new FlxSprite().makeGraphic(FlxG.width, FlxG.height, 0xFF000000);
+    blendShader = new BlendModesShader();
+    dipshitBitmap = new BitmapData(2180, 1720, true, 0xFFCC00CC);
+  }
+
+  override function drawComplex(camera:FlxCamera):Void
+  {
+    _frame.prepareMatrix(_matrix, FlxFrameAngle.ANGLE_0, checkFlipX(), checkFlipY());
+    _matrix.translate(-origin.x, -origin.y);
+    _matrix.scale(scale.x, scale.y);
+    if (bakedRotationAngle <= 0)
+    {
+      updateTrig();
+      if (angle != 0) _matrix.rotateWithTrig(_cosAngle, _sinAngle);
+    }
+    getScreenPosition(_point, camera).subtractPoint(offset);
+    _point.add(origin.x, origin.y);
+    _matrix.translate(_point.x, _point.y);
+    if (isPixelPerfectRender(camera))
+    {
+      _matrix.tx = Math.floor(_matrix.tx);
+      _matrix.ty = Math.floor(_matrix.ty);
+    }
+
+    var sprRect = getScreenBounds();
+
+    // dipshitBitmap.draw(camera.canvas, camera.canvas.transform.matrix);
+    // blendShader.setCamera(dipshitBitmap);
+
+    // FlxG.bitmapLog.add(dipshitBitmap);
+
+    camera.drawPixels(_frame, framePixels, _matrix, colorTransform, blend, antialiasing, shader);
+  }
+
+  function copyToFlash(rect):openfl.geom.Rectangle
+  {
+    var flashRect = new openfl.geom.Rectangle();
+    flashRect.x = rect.x;
+    flashRect.y = rect.y;
+    flashRect.width = rect.width;
+    flashRect.height = rect.height;
+    return flashRect;
+  }
+
+  override public function isSimpleRender(?camera:FlxCamera):Bool
+  {
+    if (FlxG.renderBlit)
+    {
+      return super.isSimpleRender(camera);
+    }
+    else
+    {
+      return false;
+    }
+  }
+}
diff --git a/source/funkin/ui/title/TitleState.hx b/source/funkin/ui/title/TitleState.hx
index 313c578a3..9820e4ecc 100644
--- a/source/funkin/ui/title/TitleState.hx
+++ b/source/funkin/ui/title/TitleState.hx
@@ -23,6 +23,7 @@ import openfl.events.MouseEvent;
 import openfl.events.NetStatusEvent;
 import openfl.media.Video;
 import openfl.net.NetStream;
+import openfl.display.BlendMode;
 
 #if desktop
 #end
@@ -101,7 +102,7 @@ class TitleState extends MusicBeatState
   var logoBl:FlxSprite;
   var outlineShaderShit:TitleOutline;
 
-  var gfDance:FlxSprite;
+  var gfDance:FlxSpriteOverlay;
   var danceLeft:Bool = false;
   var titleText:FlxSprite;
   var maskShader = new LeftMaskShader();
@@ -124,13 +125,11 @@ class TitleState extends MusicBeatState
 
     outlineShaderShit = new TitleOutline();
 
-    gfDance = new FlxSprite(FlxG.width * 0.4, FlxG.height * 0.07);
+    gfDance = new FlxSpriteOverlay(FlxG.width * 0.4, FlxG.height * 0.07);
     gfDance.frames = Paths.getSparrowAtlas('gfDanceTitle');
     gfDance.animation.addByIndices('danceLeft', 'gfDance', [30, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14], "", 24, false);
     gfDance.animation.addByIndices('danceRight', 'gfDance', [15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29], "", 24, false);
 
-    add(gfDance);
-
     // maskShader.swagSprX = gfDance.x;
     // maskShader.swagMaskX = gfDance.x + 200;
     // maskShader.frameUV = gfDance.frame.uv;
@@ -142,6 +141,8 @@ class TitleState extends MusicBeatState
 
     add(logoBl);
 
+    add(gfDance);
+
     titleText = new FlxSprite(100, FlxG.height * 0.8);
     titleText.frames = Paths.getSparrowAtlas('titleEnter');
     titleText.animation.addByPrefix('idle', "Press Enter to Begin", 24);
@@ -245,6 +246,8 @@ class TitleState extends MusicBeatState
 
   override function update(elapsed:Float)
   {
+    FlxG.bitmapLog.add(FlxG.camera.buffer);
+
     #if HAS_PITCH
     if (FlxG.keys.pressed.UP) FlxG.sound.music.pitch += 0.5 * elapsed;
 
diff --git a/source/funkin/util/Constants.hx b/source/funkin/util/Constants.hx
index c87f632de..c606e469f 100644
--- a/source/funkin/util/Constants.hx
+++ b/source/funkin/util/Constants.hx
@@ -2,6 +2,7 @@ package funkin.util;
 
 import flixel.util.FlxColor;
 import lime.app.Application;
+import funkin.data.song.SongData.SongTimeFormat;
 
 /**
  * A store of unchanging, globally relevant values.
@@ -25,13 +26,23 @@ class Constants
    */
   public static var VERSION(get, never):String;
 
+  /**
+   * The generatedBy string embedded in the chart files made by this application.
+   */
+  public static var GENERATED_BY(get, never):String;
+
+  static function get_GENERATED_BY():String
+  {
+    return '${Constants.TITLE} - ${Constants.VERSION}';
+  }
+
   /**
    * A suffix to add to the game version.
    * Add a suffix to prototype builds and remove it for releases.
    */
   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;
@@ -63,7 +74,7 @@ class Constants
    */
   // ==============================
 
-  #if debug
+  #if (debug || FORCE_DEBUG_VERSION)
   /**
    * The current Git branch.
    */
@@ -143,7 +154,32 @@ class Constants
   /**
    * The default BPM for charts, so things don't break if none is specified.
    */
-  public static final DEFAULT_BPM:Int = 100;
+  public static final DEFAULT_BPM:Float = 100.0;
+
+  /**
+   * The default name for songs.
+   */
+  public static final DEFAULT_SONGNAME:String = "Unknown";
+
+  /**
+   * The default artist for songs.
+   */
+  public static final DEFAULT_ARTIST:String = "Unknown";
+
+  /**
+   * The default note style for songs.
+   */
+  public static final DEFAULT_NOTE_STYLE:String = "funkin";
+
+  /**
+   * The default timing format for songs.
+   */
+  public static final DEFAULT_TIMEFORMAT:SongTimeFormat = SongTimeFormat.MILLISECONDS;
+
+  /**
+   * The default scroll speed for songs.
+   */
+  public static final DEFAULT_SCROLLSPEED:Float = 1.0;
 
   /**
    * Default numerator for the time signature.
@@ -291,16 +327,60 @@ class Constants
   public static final HEALTH_MINE_PENALTY:Float = 15.0 / 100.0 * HEALTH_MAX; // 15.0%
 
   /**
-   * If true, the player will not receive the ghost miss penalty if there are no notes within the hit window.
-   * This is the thing people have been begging for forever lolol.
+   * SCORE VALUES
    */
-  public static final GHOST_TAPPING:Bool = false;
+  // ==============================
+
+  /**
+   * The amount of score the player gains for every send they hold a hold note.
+   * A fraction of this value is granted every frame.
+   */
+  public static final SCORE_HOLD_BONUS_PER_SECOND:Float = 250.0;
+
+  /**
+   * FILE EXTENSIONS
+   */
+  // ==============================
+
+  /**
+   * The file extension used when exporting chart files.
+   *
+   * - "I made a new file format"
+   * - "Actually new or just a renamed ZIP?"
+   */
+  public static final EXT_CHART = "fnfc";
+
+  /**
+   * The file extension used when loading audio files.
+   */
+  public static final EXT_SOUND = #if web "mp3" #else "ogg" #end;
+
+  /**
+   * The file extension used when loading video files.
+   */
+  public static final EXT_VIDEO = "mp4";
+
+  /**
+   * The file extension used when loading image files.
+   */
+  public static final EXT_IMAGE = "png";
+
+  /**
+   * The file extension used when loading data files.
+   */
+  public static final EXT_DATA = "json";
 
   /**
    * OTHER
    */
   // ==============================
 
+  /**
+   * If true, the player will not receive the ghost miss penalty if there are no notes within the hit window.
+   * This is the thing people have been begging for forever lolol.
+   */
+  public static final GHOST_TAPPING:Bool = false;
+
   /**
    * The separator between an asset library and the asset path.
    */
diff --git a/source/funkin/util/FileUtil.hx b/source/funkin/util/FileUtil.hx
index 3a6f4e330..bae3126fb 100644
--- a/source/funkin/util/FileUtil.hx
+++ b/source/funkin/util/FileUtil.hx
@@ -5,10 +5,9 @@ import lime.utils.Bytes;
 import lime.ui.FileDialog;
 import openfl.net.FileFilter;
 import haxe.io.Path;
-#if html5
 import openfl.net.FileReference;
 import openfl.events.Event;
-#end
+import openfl.events.IOErrorEvent;
 
 /**
  * Utilities for reading and writing files on various platforms.
@@ -260,8 +259,7 @@ class FileUtil
   /**
    * Takes an array of file entries and prompts the user to save them as a ZIP file.
    */
-  public static function saveFilesAsZIP(resources:Array<Entry>, ?onSave:Array<String>->Void, ?onCancel:Void->Void, ?defaultPath:String,
-      force:Bool = false):Bool
+  public static function saveFilesAsZIP(resources:Array<Entry>, ?onSave:Array<String>->Void, ?onCancel:Void->Void, ?defaultPath:String, force:Bool = false):Bool
   {
     // Create a ZIP file.
     var zipBytes:Bytes = createZIPFromEntries(resources);
@@ -309,6 +307,7 @@ class FileUtil
     #if sys
     return sys.io.File.getContent(path);
     #else
+    trace('ERROR: readStringFromPath not implemented for this platform');
     return null;
     #end
   }
@@ -329,6 +328,48 @@ class FileUtil
     #end
   }
 
+  /**
+   * Browse for a file to read and execute a callback once we have a file reference.
+   * Works great on HTML5 or desktop.
+   *
+   * @param	callback The function to call when the file is loaded.
+   */
+  public static function browseFileReference(callback:FileReference->Void)
+  {
+    var file = new FileReference();
+
+    file.addEventListener(Event.SELECT, function(e) {
+      var selectedFileRef:FileReference = e.target;
+      trace('Selected file: ' + selectedFileRef.name);
+      selectedFileRef.addEventListener(Event.COMPLETE, function(e) {
+        var loadedFileRef:FileReference = e.target;
+        trace('Loaded file: ' + loadedFileRef.name);
+        callback(loadedFileRef);
+      });
+      selectedFileRef.load();
+    });
+
+    file.browse();
+  }
+
+  /**
+   * Prompts the user to save a file to their computer.
+   */
+  public static function writeFileReference(path:String, data:String)
+  {
+    var file = new FileReference();
+    file.addEventListener(Event.COMPLETE, function(e:Event) {
+      trace('Successfully wrote file.');
+    });
+    file.addEventListener(Event.CANCEL, function(e:Event) {
+      trace('Cancelled writing file.');
+    });
+    file.addEventListener(IOErrorEvent.IO_ERROR, function(e:IOErrorEvent) {
+      trace('IO error writing file.');
+    });
+    file.save(data, path);
+  }
+
   /**
    * Read JSON file contents directly from a given path.
    * Only works on desktop.
diff --git a/source/funkin/util/SerializerUtil.hx b/source/funkin/util/SerializerUtil.hx
index 26563efce..0af0fc9ea 100644
--- a/source/funkin/util/SerializerUtil.hx
+++ b/source/funkin/util/SerializerUtil.hx
@@ -13,6 +13,7 @@ typedef ScoreInput =
 
 /**
  * A class of functions dedicated to serializing and deserializing data.
+ * TODO: Rewrite/refactor this to use json2object.
  */
 class SerializerUtil
 {
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
 {
   /**