diff --git a/.github/actions/setup-haxeshit/action.yml b/.github/actions/setup-haxeshit/action.yml
index 38a504442..756530178 100644
--- a/.github/actions/setup-haxeshit/action.yml
+++ b/.github/actions/setup-haxeshit/action.yml
@@ -3,18 +3,31 @@ 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') }}
+        restore-keys: |
+          ${{ runner.os }}-hmm-
+          ${{ runner.os }}-
+    - if: ${{ steps.cache-hmm.outputs.cache-hit != 'true' }}
+      name: hmm install
+      run: |
+        haxelib --global run hmm install
+      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 0d9f1f2a4..3ce0d538b 100644
--- a/.github/workflows/build-shit.yml
+++ b/.github/workflows/build-shit.yml
@@ -30,6 +30,7 @@ jobs:
       - 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
@@ -60,18 +61,19 @@ jobs:
           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/assets b/assets
index a62e7e50d..7bc9407e0 160000
--- a/assets
+++ b/assets
@@ -1 +1 @@
-Subproject commit a62e7e50d59c14d256c75da651b79dea77e1620e
+Subproject commit 7bc9407e0e8141a643605ff4514ba63169cc41e2
diff --git a/hmm.json b/hmm.json
index 47460facf..aa032fb75 100644
--- a/hmm.json
+++ b/hmm.json
@@ -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",
@@ -139,7 +139,7 @@
       "name": "openfl",
       "type": "git",
       "dir": null,
-      "ref": "d33d489a137ff8fdece4994cf1302f0b6334ed08",
+      "ref": "de9395d2f367a80f93f082e1b639b9cde2258bf1",
       "url": "https://github.com/EliteMasterEric/openfl"
     },
     {
@@ -160,4 +160,4 @@
       "version": "0.11.0"
     }
   ]
-}
\ No newline at end of file
+}
diff --git a/source/funkin/Paths.hx b/source/funkin/Paths.hx
index ee2dfe5fd..c8c9c79b7 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)
@@ -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/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..d557bd39c 100644
--- a/source/funkin/data/song/SongData.hx
+++ b/source/funkin/data/song/SongData.hx
@@ -1,8 +1,6 @@
 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;
 
@@ -47,32 +45,33 @@ class SongMetadata
    * Defaults to `default` or `''`. 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 +86,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 +136,6 @@ class SongTimeChange
    */
   @:optional
   @:alias("b")
-  // @:default(funkin.data.song.SongData.SongTimeChange.DEFAULT_BEAT_TIME)
   public var beatTime:Null<Float>;
 
   /**
@@ -168,6 +182,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 +216,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;
@@ -214,11 +231,11 @@ class SongMusicData
    * Defaults to `default` or `''`. Populated later.
    */
   @:jignored
-  public var variation:String = 'default';
+  public var variation:String = Constants.DEFAULT_VARIATION;
 
   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 +244,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 +260,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 +375,9 @@ class SongChartData
   @:default(funkin.data.song.SongRegistry.DEFAULT_GENERATEDBY)
   public var generatedBy:String;
 
+  @: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 +419,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 +467,7 @@ class SongEventData
   @:alias("v")
   @:optional
   @:jcustomparse(funkin.data.DataParse.dynamicValue)
+  @:jcustomwrite(funkin.data.DataWrite.dynamicValue)
   public var value:Dynamic = null;
 
   /**
@@ -484,6 +565,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 +787,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..4b9318df2 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
 {
   /**
diff --git a/source/funkin/data/song/SongRegistry.hx b/source/funkin/data/song/SongRegistry.hx
index 9bc1278c8..cf2da14f7 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/play/PlayState.hx b/source/funkin/play/PlayState.hx
index 46938215b..ce72fa56c 100644
--- a/source/funkin/play/PlayState.hx
+++ b/source/funkin/play/PlayState.hx
@@ -46,7 +46,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;
@@ -574,8 +574,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.
@@ -733,7 +733,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 +1344,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 +1367,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 +1386,7 @@ class PlayState extends MusicBeatSubState
     //
     // BOYFRIEND
     //
-    var boyfriend:BaseCharacter = CharacterDataParser.fetchCharacter(currentPlayerId);
+    var boyfriend:BaseCharacter = CharacterDataParser.fetchCharacter(currentCharacterData.player);
 
     if (boyfriend != null)
     {
@@ -1549,7 +1535,7 @@ class PlayState extends MusicBeatSubState
 
     if (!overrideMusic)
     {
-      vocals = currentChart.buildVocals(currentPlayerId);
+      vocals = currentChart.buildVocals();
 
       if (vocals.members.length == 0)
       {
@@ -1893,6 +1879,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?
@@ -2013,103 +2000,6 @@ class PlayState extends MusicBeatSubState
     }
   }
 
-  /**
-   * Handle player inputs.
-   */
-  function keyShit(test:Bool):Void
-  {
-    // control arrays, order L D R U
-    var holdArray:Array<Bool> = [controls.NOTE_LEFT, controls.NOTE_DOWN, controls.NOTE_UP, controls.NOTE_RIGHT];
-    var pressArray:Array<Bool> = [
-      controls.NOTE_LEFT_P,
-      controls.NOTE_DOWN_P,
-      controls.NOTE_UP_P,
-      controls.NOTE_RIGHT_P
-    ];
-    var releaseArray:Array<Bool> = [
-      controls.NOTE_LEFT_R,
-      controls.NOTE_DOWN_R,
-      controls.NOTE_UP_R,
-      controls.NOTE_RIGHT_R
-    ];
-
-    // if (pressArray.contains(true))
-    // {
-    //   var lol:Array<Int> = cast pressArray;
-    //   inputSpitter.push(Std.int(Conductor.songPosition) + ' ' + lol.join(' '));
-    // }
-
-    // HOLDS, check for sustain notes
-    if (holdArray.contains(true) && generatedMusic)
-    {
-      /*
-        activeNotes.forEachAlive(function(daNote:Note) {
-          if (daNote.isSustainNote && daNote.canBeHit && daNote.mustPress && holdArray[daNote.data.noteData]) goodNoteHit(daNote);
-        });
-       */
-    }
-
-    // PRESSES, check for note hits
-    if (pressArray.contains(true) && generatedMusic)
-    {
-      Haptic.vibrate(100, 100);
-
-      if (currentStage != null && currentStage.getBoyfriend() != null)
-      {
-        currentStage.getBoyfriend().holdTimer = 0;
-      }
-
-      var possibleNotes:Array<NoteSprite> = []; // notes that can be hit
-      var directionList:Array<Int> = []; // directions that can be hit
-      var dumbNotes:Array<NoteSprite> = []; // notes to kill later
-
-      for (note in dumbNotes)
-      {
-        FlxG.log.add('killing dumb ass note at ' + note.noteData.time);
-        note.kill();
-        // activeNotes.remove(note, true);
-        note.destroy();
-      }
-
-      possibleNotes.sort((a, b) -> Std.int(a.noteData.time - b.noteData.time));
-
-      if (perfectMode)
-      {
-        goodNoteHit(possibleNotes[0], null);
-      }
-      else if (possibleNotes.length > 0)
-      {
-        for (shit in 0...pressArray.length)
-        { // if a direction is hit that shouldn't be
-          if (pressArray[shit] && !directionList.contains(shit)) ghostNoteMiss(shit);
-        }
-        for (coolNote in possibleNotes)
-        {
-          if (pressArray[coolNote.noteData.getDirection()]) goodNoteHit(coolNote, null);
-        }
-      }
-      else
-      {
-        // HNGGG I really want to add an option for ghost tapping
-        // L + ratio
-        for (shit in 0...pressArray.length)
-          if (pressArray[shit]) ghostNoteMiss(shit, false);
-      }
-    }
-
-    if (currentStage == null) return;
-
-    for (keyId => isPressed in pressArray)
-    {
-      if (playerStrumline == null) continue;
-
-      var dir:NoteDirection = Strumline.DIRECTIONS[keyId];
-
-      if (isPressed && !playerStrumline.isConfirm(dir)) playerStrumline.playPress(dir);
-      if (!holdArray[keyId]) playerStrumline.playStatic(dir);
-    }
-  }
-
   function goodNoteHit(note:NoteSprite, input:PreciseInputEvent):Void
   {
     var event:NoteScriptEvent = new NoteScriptEvent(ScriptEvent.NOTE_HIT, note, Highscore.tallies.combo + 1, true);
@@ -2118,19 +2008,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/ui/debug/charting/ChartEditorAudioHandler.hx b/source/funkin/ui/debug/charting/ChartEditorAudioHandler.hx
new file mode 100644
index 000000000..e852dff0a
--- /dev/null
+++ b/source/funkin/ui/debug/charting/ChartEditorAudioHandler.hx
@@ -0,0 +1,170 @@
+package funkin.ui.debug.charting;
+
+import openfl.utils.Assets;
+import flixel.system.FlxAssets.FlxSoundAsset;
+import flixel.system.FlxSound;
+import funkin.play.character.BaseCharacter.CharacterType;
+import flixel.system.FlxSound;
+import haxe.io.Path;
+
+/**
+ * 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 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.
+   */
+  static function loadVocalsFromPath(state:ChartEditorState, path:Path, charKey:String = 'default'):Bool
+  {
+    #if sys
+    var fileBytes:haxe.io.Bytes = sys.io.File.getBytes(path.toString());
+    return loadVocalsFromBytes(state, 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
+  }
+
+  /**
+   * 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.
+   */
+  static function loadVocalsFromAsset(state:ChartEditorState, 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 (state.audioVocalTrackGroup != null) state.audioVocalTrackGroup.addPlayerVoice(vocalTrack);
+          state.audioVocalTrackData.set(state.currentSongCharacterPlayer, Assets.getBytes(path));
+        case CharacterType.DAD:
+          if (state.audioVocalTrackGroup != null) state.audioVocalTrackGroup.addOpponentVoice(vocalTrack);
+          state.audioVocalTrackData.set(state.currentSongCharacterOpponent, Assets.getBytes(path));
+        default:
+          if (state.audioVocalTrackGroup != null) state.audioVocalTrackGroup.add(vocalTrack);
+          state.audioVocalTrackData.set('default', Assets.getBytes(path));
+      }
+
+      return true;
+    }
+    return false;
+  }
+
+  /**
+   * Loads a vocal track from audio byte data.
+   */
+  static function loadVocalsFromBytes(state:ChartEditorState, 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 (state.audioVocalTrackGroup != null) state.audioVocalTrackGroup.add(vocalTrack);
+    state.audioVocalTrackData.set(charKey, bytes);
+    return true;
+  }
+
+  /**
+   * Loads an instrumental from an absolute file path, replacing the current instrumental.
+   *
+   * @param path The absolute path to the audio file.
+   *
+   * @return Success or failure.
+   */
+  static function loadInstrumentalFromPath(state:ChartEditorState, path:Path):Bool
+  {
+    #if sys
+    // Validate file extension.
+    if (path.ext != null && !ChartEditorState.SUPPORTED_MUSIC_FORMATS.contains(path.ext))
+    {
+      return false;
+    }
+
+    var fileBytes:haxe.io.Bytes = sys.io.File.getBytes(path.toString());
+    return loadInstrumentalFromBytes(state, fileBytes, '${path.file}.${path.ext}');
+    #else
+    trace("[WARN] This platform can't load audio from a file path, you'll need to fetch the bytes some other way.");
+    return false;
+    #end
+  }
+
+  /**
+   * Loads an instrumental from audio byte data, replacing the current instrumental.
+   * @param bytes The audio byte data.
+   * @param fileName The name of the file, if available. Used for notifications.
+   * @return Success or failure.
+   */
+  static function loadInstrumentalFromBytes(state:ChartEditorState, bytes:haxe.io.Bytes, fileName:String = null):Bool
+  {
+    if (bytes == null)
+    {
+      return false;
+    }
+
+    var openflSound:openfl.media.Sound = new openfl.media.Sound();
+    openflSound.loadCompressedDataFromByteArray(openfl.utils.ByteArray.fromBytes(bytes), bytes.length);
+    state.audioInstTrack = FlxG.sound.load(openflSound, 1.0, false);
+    state.audioInstTrack.autoDestroy = false;
+    state.audioInstTrack.pause();
+
+    state.audioInstTrackData = bytes;
+
+    state.postLoadInstrumental();
+
+    return true;
+  }
+
+  /**
+   * Loads an instrumental from an OpenFL asset, replacing the current instrumental.
+   * @param path The path to the asset. Use `Paths` to build this.
+   * @return Success or failure.
+   */
+  static function loadInstrumentalFromAsset(state:ChartEditorState, path:String):Bool
+  {
+    var instTrack:FlxSound = FlxG.sound.load(path, 1.0, false);
+    if (instTrack != null)
+    {
+      state.audioInstTrack = instTrack;
+
+      state.audioInstTrackData = Assets.getBytes(path);
+
+      state.postLoadInstrumental();
+      return true;
+    }
+
+    return false;
+  }
+
+  /**
+   * Play a sound effect.
+   * Automatically cleans up after itself and recycles previous FlxSound instances if available, for performance.
+   */
+  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();
+  }
+}
diff --git a/source/funkin/ui/debug/charting/ChartEditorCommand.hx b/source/funkin/ui/debug/charting/ChartEditorCommand.hx
index 79f58a098..c358c1d3d 100644
--- a/source/funkin/ui/debug/charting/ChartEditorCommand.hx
+++ b/source/funkin/ui/debug/charting/ChartEditorCommand.hx
@@ -64,7 +64,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 +78,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 +114,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 +131,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 +252,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 +296,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 +312,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 +352,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 +376,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;
diff --git a/source/funkin/ui/debug/charting/ChartEditorDialogHandler.hx b/source/funkin/ui/debug/charting/ChartEditorDialogHandler.hx
index 6f44f89a2..736851d16 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.
@@ -83,6 +89,7 @@ class ChartEditorDialogHandler
     linkCreateBasic.onClick = function(_event) {
       // Hide the welcome dialog
       dialog.hideDialog(DialogButton.CANCEL);
+      state.stopWelcomeMusic();
 
       //
       // Create Song Wizard
@@ -95,6 +102,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 +113,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 +142,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;
   }
 
@@ -298,7 +309,7 @@ class ChartEditorDialogHandler
         {label: 'Audio File (.ogg)', extension: 'ogg'}], function(selectedFile:SelectedFileInfo) {
           if (selectedFile != null && selectedFile.bytes != null)
           {
-            if (state.loadInstrumentalFromBytes(selectedFile.bytes))
+            if (ChartEditorAudioHandler.loadInstrumentalFromBytes(state, selectedFile.bytes))
             {
               trace('Selected file: ' + selectedFile.fullPath);
               #if !mac
@@ -335,7 +346,7 @@ class ChartEditorDialogHandler
     onDropFile = function(pathStr:String) {
       var path:Path = new Path(pathStr);
       trace('Dropped file (${path})');
-      if (state.loadInstrumentalFromPath(path))
+      if (ChartEditorAudioHandler.loadInstrumentalFromPath(state, path))
       {
         // Tell the user the load was successful.
         #if !mac
@@ -457,62 +468,96 @@ class ChartEditorDialogHandler
       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,24 +569,9 @@ 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);
@@ -549,78 +579,6 @@ class ChartEditorDialogHandler
     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.
@@ -631,13 +589,10 @@ class ChartEditorDialogHandler
   {
     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,7 +633,7 @@ class ChartEditorDialogHandler
         trace('Selected file: $pathStr');
         var path:Path = new Path(pathStr);
 
-        if (state.loadVocalsFromPath(path, charKey))
+        if (ChartEditorAudioHandler.loadVocalsFromPath(state, path, charKey))
         {
           // Tell the user the load was successful.
           #if !mac
@@ -740,7 +695,7 @@ class ChartEditorDialogHandler
               #else
               vocalsEntryLabel.text = 'Vocals for $charName (click to browse)\n${selectedFile.name}';
               #end
-              state.loadVocalsFromBytes(selectedFile.bytes, charKey);
+              ChartEditorAudioHandler.loadVocalsFromBytes(state, selectedFile.bytes, charKey);
               dialogNoVocals.hidden = true;
               removeDropHandler(onDropFile);
             }
@@ -793,7 +748,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 +835,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 +900,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 +965,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 +1032,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 +1159,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 +1197,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 +1255,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..9ac903e38
--- /dev/null
+++ b/source/funkin/ui/debug/charting/ChartEditorImportExportHandler.hx
@@ -0,0 +1,195 @@
+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 == '') ? 'default' : 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();
+
+    ChartEditorAudioHandler.loadInstrumentalFromAsset(state, Paths.inst(songId));
+
+    var diff:Null<SongDifficulty> = song.getDifficulty(state.selectedDifficulty);
+    var voiceList:Array<String> = diff != null ? diff.buildVoiceList() : [];
+    if (voiceList.length == 2)
+    {
+      ChartEditorAudioHandler.loadVocalsFromAsset(state, voiceList[0], BF);
+      ChartEditorAudioHandler.loadVocalsFromAsset(state, voiceList[1], DAD);
+    }
+    else
+    {
+      for (voicePath in voiceList)
+      {
+        ChartEditorAudioHandler.loadVocalsFromAsset(state, voicePath);
+      }
+    }
+
+    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.
+   * @param tmp If true, save to the temporary directory instead of the local `backup` directory.
+   */
+  public static function exportAllSongData(state:ChartEditorState, force:Bool = false, tmp:Bool = false):Void
+  {
+    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', SerializerUtil.toJSON(variationMetadata)));
+        var variationChart:Null<SongChartData> = state.songChartData.get(variation);
+        if (variationChart != null) zipEntries.push(FileUtil.makeZIPEntry('${state.currentSongId}-chart.json', SerializerUtil.toJSON(variationChart)));
+      }
+      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.push(FileUtil.makeZIPEntryFromBytes('Inst.ogg', state.audioInstTrackData));
+    for (charId in state.audioVocalTrackData.keys())
+    {
+      var entryData = state.audioVocalTrackData.get(charId);
+      if (entryData == null) continue;
+      zipEntries.push(FileUtil.makeZIPEntryFromBytes('Vocals-$charId.ogg', entryData));
+    }
+
+    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..b94041afd 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');
 
@@ -751,6 +756,11 @@ 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.
    * `null` until an instrumental track is loaded.
@@ -950,19 +960,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,57 +1035,28 @@ 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;
+    return currentSongMetadata.playData.characters.player;
   }
 
   function set_currentSongCharacterPlayer(value:String):String
   {
-    if (!currentSongPlayableCharacters.contains(value))
-    {
-      trace('Invalid character selected: ' + value);
-      return value;
-    }
-
-    return selectedCharacter = value;
+    return currentSongMetadata.playData.characters.player = 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;
+    return currentSongMetadata.playData.characters.opponent;
   }
 
   function set_currentSongCharacterOpponent(value:String):String
   {
-    var playableCharData:SongPlayableChar = currentSongMetadata.playData.playableChars.get(selectedCharacter);
-    return playableCharData.opponent = value;
+    return currentSongMetadata.playData.characters.opponent = value;
   }
 
   /**
@@ -1249,6 +1230,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 +1257,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;
@@ -1602,7 +1606,7 @@ 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));
 
@@ -1738,18 +1742,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',
@@ -1795,7 +1795,7 @@ class ChartEditorState extends HaxeUIState
     // Auto-save to local storage.
     #else
     // Auto-save to temp file.
-    exportAllSongData(true, true);
+    ChartEditorImportExportHandler.exportAllSongData(this, true, true);
     #end
   }
 
@@ -1806,7 +1806,7 @@ class ChartEditorState extends HaxeUIState
 
     if (saveDataDirty)
     {
-      exportAllSongData(true);
+      ChartEditorImportExportHandler.exportAllSongData(this, true);
     }
   }
 
@@ -2407,13 +2407,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)
         {
@@ -3016,6 +3023,12 @@ 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)
     {
@@ -3167,7 +3180,7 @@ class ChartEditorState extends HaxeUIState
         selectedDifficulty = prevDifficulty;
 
         refreshDifficultyTreeSelection();
-        refreshSongMetadataToolbox();
+        refreshMetadataToolbox();
       }
       else
       {
@@ -3176,7 +3189,7 @@ class ChartEditorState extends HaxeUIState
         selectedDifficulty = prevDifficulty;
 
         refreshDifficultyTreeSelection();
-        refreshSongMetadataToolbox();
+        refreshMetadataToolbox();
       }
     }
     else
@@ -3195,7 +3208,7 @@ class ChartEditorState extends HaxeUIState
         selectedDifficulty = nextDifficulty;
 
         refreshDifficultyTreeSelection();
-        refreshSongMetadataToolbox();
+        refreshMetadataToolbox();
       }
       else
       {
@@ -3204,7 +3217,7 @@ class ChartEditorState extends HaxeUIState
         selectedDifficulty = nextDifficulty;
 
         refreshDifficultyTreeSelection();
-        refreshSongMetadataToolbox();
+        refreshMetadataToolbox();
       }
     }
 
@@ -3296,6 +3309,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)
@@ -3469,7 +3504,7 @@ class ChartEditorState extends HaxeUIState
           selectedVariation = variation;
           selectedDifficulty = difficulty;
           // refreshDifficultyTreeSelection(treeView);
-          refreshSongMetadataToolbox();
+          refreshMetadataToolbox();
         }
       // case 'song':
       // case 'variation':
@@ -3478,14 +3513,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 +3534,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 +3550,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 +3783,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('funnyNoise/funnyNoise-09'));
         case 1: // Opponent
-          if (hitsoundsEnabledOpponent) playSound(Paths.sound('funnyNoise/funnyNoise-010'));
+          if (hitsoundsEnabledOpponent) ChartEditorAudioHandler.playSound(Paths.sound('funnyNoise/funnyNoise-010'));
       }
     }
   }
@@ -3913,77 +3986,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)
@@ -4014,23 +4016,6 @@ class ChartEditorState extends HaxeUIState
     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 +4024,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 +4141,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 +4154,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 +4174,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/util/Constants.hx b/source/funkin/util/Constants.hx
index b454ca429..efabf10c3 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;
 
 class Constants
 {
@@ -22,6 +23,16 @@ 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.
@@ -140,7 +151,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.
@@ -288,16 +324,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
 {