diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md
new file mode 100644
index 000000000..26958a467
--- /dev/null
+++ b/docs/troubleshooting.md
@@ -0,0 +1,6 @@
+# Troubleshooting Common Issues
+
+- Weird macro error with a very tall call stack: Restart Visual Studio Code
+- `Get Thread Context Failed`: Turn off other expensive applications while building
+- `Type not found: T1`: This is thrown by `json2object`, make sure the data type of `@:default` is correct.
+  - NOTE: `flixel.util.typeLimit.OneOfTwo` isn't supported.
diff --git a/hmm.json b/hmm.json
index f06b295e4..47460facf 100644
--- a/hmm.json
+++ b/hmm.json
@@ -95,8 +95,10 @@
     },
     {
       "name": "json2object",
-      "type": "haxelib",
-      "version": "3.11.0"
+      "type": "git",
+      "dir": null,
+      "ref": "429986134031cbb1980f09d0d3d642b4b4cbcd6a",
+      "url": "https://github.com/elnabo/json2object"
     },
     {
       "name": "lime",
@@ -158,4 +160,4 @@
       "version": "0.11.0"
     }
   ]
-}
+}
\ No newline at end of file
diff --git a/source/funkin/Conductor.hx b/source/funkin/Conductor.hx
index b0ad6c221..b79ae0fc4 100644
--- a/source/funkin/Conductor.hx
+++ b/source/funkin/Conductor.hx
@@ -4,7 +4,7 @@ import funkin.util.Constants;
 import flixel.util.FlxSignal;
 import flixel.math.FlxMath;
 import funkin.play.song.Song.SongDifficulty;
-import funkin.play.song.SongData.SongTimeChange;
+import funkin.data.song.SongData.SongTimeChange;
 
 /**
  * A core class which handles musical timing throughout the game,
@@ -290,7 +290,7 @@ class Conductor
 
     if (timeChanges.length > 0)
     {
-      trace('Done mapping time changes: ${timeChanges}' + timeChanges);
+      trace('Done mapping time changes: ${timeChanges}');
     }
 
     // Update currentStepTime
diff --git a/source/funkin/DialogueBox.hx b/source/funkin/DialogueBox.hx
index 342fcba10..68d330dbe 100644
--- a/source/funkin/DialogueBox.hx
+++ b/source/funkin/DialogueBox.hx
@@ -37,7 +37,7 @@ class DialogueBox extends FlxSpriteGroup
   {
     super();
 
-    switch (PlayState.instance.currentSong.songId.toLowerCase())
+    switch (PlayState.instance.currentSong.id.toLowerCase())
     {
       case 'senpai':
         FlxG.sound.playMusic(Paths.music('Lunchbox'), 0);
@@ -78,7 +78,7 @@ class DialogueBox extends FlxSpriteGroup
     box = new FlxSprite(-20, 45);
 
     var hasDialog:Bool = false;
-    switch (PlayState.instance.currentSong.songId.toLowerCase())
+    switch (PlayState.instance.currentSong.id.toLowerCase())
     {
       case 'senpai':
         hasDialog = true;
@@ -150,8 +150,8 @@ class DialogueBox extends FlxSpriteGroup
   override function update(elapsed:Float):Void
   {
     // HARD CODING CUZ IM STUPDI
-    if (PlayState.instance.currentSong.songId.toLowerCase() == 'roses') portraitLeft.visible = false;
-    if (PlayState.instance.currentSong.songId.toLowerCase() == 'thorns')
+    if (PlayState.instance.currentSong.id.toLowerCase() == 'roses') portraitLeft.visible = false;
+    if (PlayState.instance.currentSong.id.toLowerCase() == 'thorns')
     {
       portraitLeft.color = FlxColor.BLACK;
       swagDialogue.color = FlxColor.WHITE;
@@ -187,8 +187,8 @@ class DialogueBox extends FlxSpriteGroup
         {
           isEnding = true;
 
-          if (PlayState.instance.currentSong.songId.toLowerCase() == 'senpai'
-            || PlayState.instance.currentSong.songId.toLowerCase() == 'thorns') FlxG.sound.music.fadeOut(2.2, 0);
+          if (PlayState.instance.currentSong.id.toLowerCase() == 'senpai'
+            || PlayState.instance.currentSong.id.toLowerCase() == 'thorns') FlxG.sound.music.fadeOut(2.2, 0);
 
           new FlxTimer().start(0.2, function(tmr:FlxTimer) {
             box.alpha -= 1 / 5;
diff --git a/source/funkin/FreeplayState.hx b/source/funkin/FreeplayState.hx
index c31e8c77b..6cd353233 100644
--- a/source/funkin/FreeplayState.hx
+++ b/source/funkin/FreeplayState.hx
@@ -20,6 +20,7 @@ import flixel.text.FlxText;
 import flixel.tweens.FlxEase;
 import flixel.tweens.FlxTween;
 import flixel.util.FlxColor;
+import funkin.data.song.SongRegistry;
 import flixel.util.FlxSpriteUtil;
 import flixel.util.FlxTimer;
 import funkin.Controls.Control;
@@ -30,7 +31,6 @@ import funkin.freeplayStuff.LetterSort;
 import funkin.freeplayStuff.SongMenuItem;
 import funkin.play.HealthIcon;
 import funkin.play.PlayState;
-import funkin.play.song.SongData.SongDataParser;
 import funkin.shaderslmfao.AngleMask;
 import funkin.shaderslmfao.PureColor;
 import funkin.shaderslmfao.StrokeShader;
@@ -843,7 +843,8 @@ class FreeplayState extends MusicBeatSubState
       }*/
 
       PlayStatePlaylist.isStoryMode = false;
-      var targetSong:Song = SongDataParser.fetchSong(songs[curSelected].songName.toLowerCase());
+      var songId:String = songs[curSelected].songName.toLowerCase();
+      var targetSong:Song = SongRegistry.instance.fetchEntry(songId);
       var targetDifficulty:String = switch (curDifficulty)
       {
         case 0:
diff --git a/source/funkin/InitState.hx b/source/funkin/InitState.hx
index 82a357ae9..e7060abd7 100644
--- a/source/funkin/InitState.hx
+++ b/source/funkin/InitState.hx
@@ -17,11 +17,11 @@ import funkin.play.PlayStatePlaylist;
 import openfl.display.BitmapData;
 import funkin.data.level.LevelRegistry;
 import funkin.data.notestyle.NoteStyleRegistry;
-import funkin.play.event.SongEventData.SongEventParser;
+import funkin.data.event.SongEventData.SongEventParser;
 import funkin.play.cutscene.dialogue.ConversationDataParser;
 import funkin.play.cutscene.dialogue.DialogueBoxDataParser;
 import funkin.play.cutscene.dialogue.SpeakerDataParser;
-import funkin.play.song.SongData.SongDataParser;
+import funkin.data.song.SongRegistry;
 import funkin.play.stage.StageData.StageDataParser;
 import funkin.play.character.CharacterData.CharacterDataParser;
 import funkin.modding.module.ModuleHandler;
@@ -197,13 +197,13 @@ class InitState extends FlxState
 
     // NOTE: Registries and data parsers must be imported and not referenced with fully qualified names,
     // to ensure build macros work properly.
+    SongRegistry.instance.loadEntries();
     LevelRegistry.instance.loadEntries();
     NoteStyleRegistry.instance.loadEntries();
     SongEventParser.loadEventCache();
     ConversationDataParser.loadConversationCache();
     DialogueBoxDataParser.loadDialogueBoxCache();
     SpeakerDataParser.loadSpeakerCache();
-    SongDataParser.loadSongCache();
     StageDataParser.loadStageCache();
     CharacterDataParser.loadCharacterCache();
     ModuleHandler.buildModuleCallbacks();
@@ -276,7 +276,7 @@ class InitState extends FlxState
    */
   function startSong(songId:String, difficultyId:String = 'normal'):Void
   {
-    var songData:funkin.play.song.Song = funkin.play.song.SongData.SongDataParser.fetchSong(songId);
+    var songData:funkin.play.song.Song = funkin.data.song.SongRegistry.instance.fetchEntry(songId);
 
     if (songData == null)
     {
@@ -312,7 +312,7 @@ class InitState extends FlxState
 
     var targetSongId:String = PlayStatePlaylist.playlistSongIds.shift();
 
-    var targetSong:funkin.play.song.Song = funkin.play.song.SongData.SongDataParser.fetchSong(targetSongId);
+    var targetSong:funkin.play.song.Song = SongRegistry.instance.fetchEntry(targetSongId);
 
     LoadingState.loadAndSwitchState(new funkin.play.PlayState(
       {
diff --git a/source/funkin/LoadingState.hx b/source/funkin/LoadingState.hx
index 3ec2e1005..216d9ba74 100644
--- a/source/funkin/LoadingState.hx
+++ b/source/funkin/LoadingState.hx
@@ -159,7 +159,7 @@ class LoadingState extends MusicBeatState
 
   static function getSongPath():String
   {
-    return Paths.inst(PlayState.instance.currentSong.songId);
+    return Paths.inst(PlayState.instance.currentSong.id);
   }
 
   inline static public function loadAndSwitchState(nextState:FlxState, shouldStopMusic = false):Void
diff --git a/source/funkin/PauseSubState.hx b/source/funkin/PauseSubState.hx
index 791a4bb9a..f93e5a450 100644
--- a/source/funkin/PauseSubState.hx
+++ b/source/funkin/PauseSubState.hx
@@ -10,7 +10,7 @@ import flixel.tweens.FlxEase;
 import flixel.tweens.FlxTween;
 import flixel.util.FlxColor;
 import funkin.play.PlayState;
-import funkin.play.song.SongData.SongDataParser;
+import funkin.data.song.SongRegistry;
 
 class PauseSubState extends MusicBeatSubState
 {
@@ -197,7 +197,7 @@ class PauseSubState extends MusicBeatSubState
             regenMenu();
 
           case 'EASY' | 'NORMAL' | 'HARD' | 'ERECT':
-            PlayState.instance.currentSong = SongDataParser.fetchSong(PlayState.instance.currentSong.songId.toLowerCase());
+            PlayState.instance.currentSong = SongRegistry.instance.fetchEntry(PlayState.instance.currentSong.id.toLowerCase());
 
             PlayState.instance.currentDifficulty = daSelected.toLowerCase();
 
diff --git a/source/funkin/data/BaseRegistry.hx b/source/funkin/data/BaseRegistry.hx
index 98393fda4..24d0de476 100644
--- a/source/funkin/data/BaseRegistry.hx
+++ b/source/funkin/data/BaseRegistry.hx
@@ -4,6 +4,9 @@ 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.
@@ -135,7 +138,7 @@ abstract class BaseRegistry<T:(IRegistryEntry<J> & Constructible<EntryConstructo
 
   public function fetchEntryVersion(id:String):Null<thx.semver.Version>
   {
-    var entryStr:String = loadEntryFile(id);
+    var entryStr:String = loadEntryFile(id).contents;
     var entryVersion:thx.semver.Version = VersionUtil.getVersionFromJSON(entryStr);
     return entryVersion;
   }
@@ -145,11 +148,14 @@ abstract class BaseRegistry<T:(IRegistryEntry<J> & Constructible<EntryConstructo
     trace('[' + registryId + '] ' + message);
   }
 
-  function loadEntryFile(id:String):String
+  function loadEntryFile(id:String):JsonFile
   {
     var entryFilePath:String = Paths.json('${dataFilePath}/${id}');
     var rawJson:String = openfl.Assets.getText(entryFilePath).trim();
-    return rawJson;
+    return {
+      fileName: entryFilePath,
+      contents: rawJson
+    };
   }
 
   function clearEntries():Void
@@ -188,7 +194,7 @@ abstract class BaseRegistry<T:(IRegistryEntry<J> & Constructible<EntryConstructo
     }
     else
     {
-      throw '[${registryId}] Entry ${id} does not support migration.';
+      throw '[${registryId}] Entry ${id} does not support migration to version ${versionRule}.';
     }
 
     // Example:
@@ -219,4 +225,85 @@ abstract class BaseRegistry<T:(IRegistryEntry<J> & Constructible<EntryConstructo
    * @param clsName
    */
   abstract function createScriptedEntry(clsName:String):Null<T>;
+
+  function printErrors(errors:Array<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}');
+    }
+  }
 }
+
+typedef JsonFile =
+{
+  fileName:String,
+  contents:String
+};
diff --git a/source/funkin/data/DataParse.hx b/source/funkin/data/DataParse.hx
new file mode 100644
index 000000000..f6b5dd659
--- /dev/null
+++ b/source/funkin/data/DataParse.hx
@@ -0,0 +1,99 @@
+package funkin.data;
+
+import hxjsonast.Json;
+import hxjsonast.Json.JObjectField;
+
+/**
+ * `json2object` has an annotation `@:jcustomparse` which allows for mutation of parsed values.
+ *
+ * It also allows for validation, since throwing an error in this function will cause the issue to be properly caught.
+ * Parsing will fail and `parser.errors` will contain the thrown exception.
+ *
+ * Functions must be of the signature `(hxjsonast.Json, String) -> T`, where the String is the property name and `T` is the type of the property.
+ */
+class DataParse
+{
+  /**
+   * `@:jcustomparse(funkin.data.DataParse.stringNotEmpty)`
+   * @param json Contains the `pos` and `value` of the property.
+   * @param name The name of the property.
+   * @throws If the property is not a string or is empty.
+   */
+  public static function stringNotEmpty(json:Json, name:String):String
+  {
+    switch (json.value)
+    {
+      case JString(s):
+        if (s == "") throw 'Expected property $name to be non-empty.';
+        return s;
+      default:
+        throw 'Expected property $name to be a string, but it was ${json.value}.';
+    }
+  }
+
+  /**
+   * Parser which outputs a Dynamic value, either a object or something else.
+   * @param json
+   * @param name
+   * @return The value of the property.
+   */
+  public static function dynamicValue(json:Json, name:String):Dynamic
+  {
+    return jsonToDynamic(json);
+  }
+
+  /**
+   * Parser which outputs a Dynamic value, which must be an object with properties.
+   * @param json
+   * @param name
+   * @return Dynamic
+   */
+  public static function dynamicObject(json:Json, name:String):Dynamic
+  {
+    switch (json.value)
+    {
+      case JObject(fields):
+        return jsonFieldsToDynamicObject(fields);
+      default:
+        throw 'Expected property $name to be an object, but it was ${json.value}.';
+    }
+  }
+
+  static function jsonToDynamic(json:Json):Null<Dynamic>
+  {
+    return 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);
+    }
+  }
+
+  /**
+   * Array of JSON fields `[{key, value}, {key, value}]` to a Dynamic object `{key:value, key:value}`.
+   * @param fields
+   * @return Dynamic
+   */
+  static function jsonFieldsToDynamicObject(fields:Array<JObjectField>):Dynamic
+  {
+    var result:Dynamic = {};
+    for (field in fields)
+    {
+      Reflect.setField(result, field.name, jsonToDynamic(field.value));
+    }
+    return result;
+  }
+
+  /**
+   * Array of JSON elements `[Json, Json, Json]` to a Dynamic array `[String, Object, Int, Array]`
+   * @param jsons
+   * @return Array<Dynamic>
+   */
+  static function jsonArrayToDynamicArray(jsons:Array<Json>):Array<Null<Dynamic>>
+  {
+    return [for (json in jsons) jsonToDynamic(json)];
+  }
+}
diff --git a/source/funkin/data/DataWrite.hx b/source/funkin/data/DataWrite.hx
new file mode 100644
index 000000000..2ff7672da
--- /dev/null
+++ b/source/funkin/data/DataWrite.hx
@@ -0,0 +1,8 @@
+package funkin.data;
+
+/**
+ * `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 {}
diff --git a/source/funkin/data/IRegistryEntry.hx b/source/funkin/data/IRegistryEntry.hx
index 0fb704b7c..ff506767d 100644
--- a/source/funkin/data/IRegistryEntry.hx
+++ b/source/funkin/data/IRegistryEntry.hx
@@ -15,5 +15,6 @@ interface IRegistryEntry<T>
 
   // Can't make an interface field private I guess.
   public final _data:T;
-  public function _fetchData(id:String):Null<T>;
+  // Can't make a static field required by an interface I guess.
+  // private static function _fetchData(id:String):Null<T>;
 }
diff --git a/source/funkin/data/README.md b/source/funkin/data/README.md
new file mode 100644
index 000000000..58fa6fa59
--- /dev/null
+++ b/source/funkin/data/README.md
@@ -0,0 +1,21 @@
+# funkin.data
+
+Data structures are parsed using `json2object`, which uses macros to generate parser classes based on anonymous structures OR classes.
+
+Parsing errors will be returned in `parser.errors`. See `json2object.Error` for an enumeration of possible parsing errors. If an error occurred, `parser.value` will be null.
+
+The properties of these anonymous structures can have their behavior changed with annotations:
+
+- `@:optional`: The value is optional and will not throw a parsing error if it is not present in the JSON data.
+- `@:default("test")`: If the value is optional, this value will be used instead of `null`. Replace `"test"` with a value of the property's type.
+- `@:default(auto)`: If the value is an anonymous structure with `json2object` annotations, each field will be initialized to its default value.
+- `@:jignored`: This value will be ignored by the parser. Their presence will not be checked in the JSON data and their values will not be parsed.
+- `@:alias`: Choose the name the value will use in the JSON data to be separate from the property name. Useful if the desired name is a reserved word like `public`.
+- `@:jcustomparse`: Provide a custom function for parsing from a JSON string into a value.
+  - Functions must be of the signature `(hxjsonast.Json, String) -> T`, where the String is the property name and `T` is the type of the property.
+  - `hxjsonast.Json` contains a `pos` and a `value`, with `value` being an enum: https://nadako.github.io/hxjsonast/hxjsonast/JsonValue.html
+  - Errors thrown in this function will cause a parsing error (`CustomFunctionException`) along with a position!
+  - Make sure to provide the FULLY QUALIFIED path to the custom function.
+- `@:jcustomwrite`: Provide a custom function for serializing the property into a string for storage as JSON.
+  - Functions must be of the signature `(T) -> String`, where `T` is the type of the property.
+
diff --git a/source/funkin/play/event/SongEventData.hx b/source/funkin/data/event/SongEventData.hx
similarity index 97%
rename from source/funkin/play/event/SongEventData.hx
rename to source/funkin/data/event/SongEventData.hx
index 8c157b52a..831a53fbd 100644
--- a/source/funkin/play/event/SongEventData.hx
+++ b/source/funkin/data/event/SongEventData.hx
@@ -1,7 +1,8 @@
-package funkin.play.event;
+package funkin.data.event;
 
-import funkin.play.event.SongEventData.SongEventSchema;
-import funkin.play.song.SongData.SongEventData;
+import funkin.play.event.SongEvent;
+import funkin.data.event.SongEventData.SongEventSchema;
+import funkin.data.song.SongData.SongEventData;
 import funkin.util.macro.ClassMacro;
 import funkin.play.event.ScriptedSongEvent;
 
diff --git a/source/funkin/data/level/LevelData.hx b/source/funkin/data/level/LevelData.hx
index 0ba26354a..843389cae 100644
--- a/source/funkin/data/level/LevelData.hx
+++ b/source/funkin/data/level/LevelData.hx
@@ -24,6 +24,7 @@ typedef LevelData =
   /**
    * The graphic for the level, as seen in the scrolling list.
    */
+  @:jcustomparse(funkin.data.DataParse.stringNotEmpty)
   var titleAsset:String;
 
   @:default([])
@@ -40,6 +41,7 @@ typedef LevelPropData =
   /**
    * The image to use for the prop. May optionally be a sprite sheet.
    */
+  // @:jcustomparse(funkin.data.DataParse.stringNotEmpty)
   var assetPath:String;
 
   /**
diff --git a/source/funkin/data/level/LevelRegistry.hx b/source/funkin/data/level/LevelRegistry.hx
index 36ce883ea..d135e1241 100644
--- a/source/funkin/data/level/LevelRegistry.hx
+++ b/source/funkin/data/level/LevelRegistry.hx
@@ -30,17 +30,18 @@ class LevelRegistry extends BaseRegistry<Level, LevelData>
     // JsonParser does not take type parameters,
     // otherwise this function would be in BaseRegistry.
     var parser = new json2object.JsonParser<LevelData>();
-    var jsonStr:String = loadEntryFile(id);
 
-    parser.fromJson(jsonStr);
+    switch (loadEntryFile(id))
+    {
+      case {fileName: fileName, contents: contents}:
+        parser.fromJson(contents, fileName);
+      default:
+        return null;
+    }
 
     if (parser.errors.length > 0)
     {
-      trace('[${registryId}] Failed to parse entry data: ${id}');
-      for (error in parser.errors)
-      {
-        trace(error);
-      }
+      printErrors(parser.errors, id);
       return null;
     }
     return parser.value;
diff --git a/source/funkin/data/notestyle/NoteStyleRegistry.hx b/source/funkin/data/notestyle/NoteStyleRegistry.hx
index 65f6f627a..bb594bca4 100644
--- a/source/funkin/data/notestyle/NoteStyleRegistry.hx
+++ b/source/funkin/data/notestyle/NoteStyleRegistry.hx
@@ -34,22 +34,21 @@ class NoteStyleRegistry extends BaseRegistry<NoteStyle, NoteStyleData>
    */
   public function parseEntryData(id:String):Null<NoteStyleData>
   {
-    if (id == null) id = DEFAULT_NOTE_STYLE_ID;
-
     // JsonParser does not take type parameters,
     // otherwise this function would be in BaseRegistry.
     var parser = new json2object.JsonParser<NoteStyleData>();
-    var jsonStr:String = loadEntryFile(id);
 
-    parser.fromJson(jsonStr);
+    switch (loadEntryFile(id))
+    {
+      case {fileName: fileName, contents: contents}:
+        parser.fromJson(contents, fileName);
+      default:
+        return null;
+    }
 
     if (parser.errors.length > 0)
     {
-      trace('[${registryId}] Failed to parse entry data: ${id}');
-      for (error in parser.errors)
-      {
-        trace(error);
-      }
+      printErrors(parser.errors, id);
       return null;
     }
     return parser.value;
diff --git a/source/funkin/data/song/SongData.hx b/source/funkin/data/song/SongData.hx
new file mode 100644
index 000000000..6dc6f55e7
--- /dev/null
+++ b/source/funkin/data/song/SongData.hx
@@ -0,0 +1,680 @@
+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;
+
+class SongMetadata
+{
+  /**
+   * A semantic versioning string for the song data format.
+   *
+   */
+  // @:default(funkin.data.song.SongRegistry.SONG_METADATA_VERSION)
+  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;
+
+  /**
+   * Data relating to the song's gameplay.
+   */
+  public var playData:SongPlayData;
+
+  // @:default(funkin.data.song.SongRegistry.DEFAULT_GENERATEDBY)
+  public var generatedBy:String;
+
+  // @:default(funkin.data.song.SongData.SongTimeFormat.MILLISECONDS)
+  public var timeFormat:SongTimeFormat;
+
+  // @:default(funkin.data.song.SongData.SongTimeChange.DEFAULT_SONGTIMECHANGES)
+  public var timeChanges:Array<SongTimeChange>;
+
+  /**
+   * Defaults to `default` or `''`. Populated later.
+   */
+  @:jignored
+  public var variation:String = 'default';
+
+  public function new(songName:String, artist:String, variation:String = 'default')
+  {
+    this.version = SongMigrator.CHART_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.generatedBy = SongRegistry.DEFAULT_GENERATEDBY;
+    // Variation ID.
+    this.variation = variation;
+  }
+
+  public function clone(?newVariation:String = null):SongMetadata
+  {
+    var result:SongMetadata = new SongMetadata(this.songName, this.artist, newVariation == null ? this.variation : newVariation);
+    result.version = this.version;
+    result.timeFormat = this.timeFormat;
+    result.divisions = this.divisions;
+    result.timeChanges = this.timeChanges;
+    result.looped = this.looped;
+    result.playData = this.playData;
+    result.generatedBy = this.generatedBy;
+
+    return result;
+  }
+
+  public function toString():String
+  {
+    return 'SongMetadata(${this.songName} by ${this.artist}, variation ${this.variation})';
+  }
+}
+
+enum abstract SongTimeFormat(String) from String to String
+{
+  var TICKS = 'ticks';
+  var FLOAT = 'float';
+  var MILLISECONDS = 'ms';
+}
+
+class SongTimeChange
+{
+  public static final DEFAULT_SONGTIMECHANGE:SongTimeChange = new SongTimeChange(0, 100);
+
+  public static final DEFAULT_SONGTIMECHANGES:Array<SongTimeChange> = [DEFAULT_SONGTIMECHANGE];
+
+  static final DEFAULT_BEAT_TUPLETS:Array<Int> = [4, 4, 4, 4];
+  static final DEFAULT_BEAT_TIME:Null<Float> = null; // Later, null gets detected and recalculated.
+
+  /**
+   * Timestamp in specified `timeFormat`.
+   */
+  @:alias("t")
+  public var timeStamp:Float;
+
+  /**
+   * Time in beats (int). The game will calculate further beat values based on this one,
+   * so it can do it in a simple linear fashion.
+   */
+  @:optional
+  @:alias("b")
+  // @:default(funkin.data.song.SongData.SongTimeChange.DEFAULT_BEAT_TIME)
+  public var beatTime:Null<Float>;
+
+  /**
+   * Quarter notes per minute (float). Cannot be empty in the first element of the list,
+   * but otherwise it's optional, and defaults to the value of the previous element.
+   */
+  @:alias("bpm")
+  public var bpm:Float;
+
+  /**
+   * Time signature numerator (int). Optional, defaults to 4.
+   */
+  @:default(4)
+  @:optional
+  @:alias("n")
+  public var timeSignatureNum:Int;
+
+  /**
+   * Time signature denominator (int). Optional, defaults to 4. Should only ever be a power of two.
+   */
+  @:default(4)
+  @:optional
+  @:alias("d")
+  public var timeSignatureDen:Int;
+
+  /**
+   * Beat tuplets (Array<int> or int). This defines how many steps each beat is divided into.
+   * It can either be an array of length `n` (see above) or a single integer number.
+   * Optional, defaults to `[4]`.
+   */
+  @:optional
+  @:alias("bt")
+  public var beatTuplets:Array<Int>;
+
+  public function new(timeStamp:Float, bpm:Float, timeSignatureNum:Int = 4, timeSignatureDen:Int = 4, ?beatTime:Float, ?beatTuplets:Array<Int>)
+  {
+    this.timeStamp = timeStamp;
+    this.bpm = bpm;
+
+    this.timeSignatureNum = timeSignatureNum;
+    this.timeSignatureDen = timeSignatureDen;
+
+    this.beatTime = beatTime == null ? DEFAULT_BEAT_TIME : beatTime;
+    this.beatTuplets = beatTuplets == null ? DEFAULT_BEAT_TUPLETS : beatTuplets;
+  }
+
+  public function toString():String
+  {
+    return 'SongTimeChange(${this.timeStamp}ms,${this.bpm}bpm)';
+  }
+}
+
+/**
+ * Metadata for a song only used for the music.
+ * For example, the menu music.
+ */
+class SongMusicData
+{
+  /**
+   * A semantic versioning string for the song data format.
+   *
+   */
+  // @:default(funkin.data.song.SongRegistry.SONG_METADATA_VERSION)
+  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;
+
+  // @:default(funkin.data.song.SongRegistry.DEFAULT_GENERATEDBY)
+  public var generatedBy:String;
+
+  // @:default(funkin.data.song.SongData.SongTimeFormat.MILLISECONDS)
+  public var timeFormat:SongTimeFormat;
+
+  // @:default(funkin.data.song.SongData.SongTimeChange.DEFAULT_SONGTIMECHANGES)
+  public var timeChanges:Array<SongTimeChange>;
+
+  /**
+   * Defaults to `default` or `''`. Populated later.
+   */
+  @:jignored
+  public var variation:String = 'default';
+
+  public function new(songName:String, artist:String, variation:String = 'default')
+  {
+    this.version = SongMigrator.CHART_VERSION;
+    this.songName = songName;
+    this.artist = artist;
+    this.timeFormat = 'ms';
+    this.divisions = null;
+    this.timeChanges = [new SongTimeChange(0, 100)];
+    this.looped = false;
+    this.generatedBy = SongRegistry.DEFAULT_GENERATEDBY;
+    // Variation ID.
+    this.variation = variation;
+  }
+
+  public function clone(?newVariation:String = null):SongMusicData
+  {
+    var result:SongMusicData = new SongMusicData(this.songName, this.artist, newVariation == null ? this.variation : newVariation);
+    result.version = this.version;
+    result.timeFormat = this.timeFormat;
+    result.divisions = this.divisions;
+    result.timeChanges = this.timeChanges;
+    result.looped = this.looped;
+    result.generatedBy = this.generatedBy;
+
+    return result;
+  }
+
+  public function toString():String
+  {
+    return 'SongMusicData(${this.songName} by ${this.artist}, variation ${this.variation})';
+  }
+}
+
+typedef SongPlayData =
+{
+  public var songVariations:Array<String>;
+  public var difficulties:Array<String>;
+
+  /**
+   * Keys are the player characters and the values give info on what opponent/GF/inst to use.
+   */
+  public var playableChars:Map<String, SongPlayableChar>;
+
+  public var stage:String;
+  public var noteSkin:String;
+}
+
+class SongPlayableChar
+{
+  @: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;
+  }
+
+  public function toString():String
+  {
+    return 'SongPlayableChar(${this.girlfriend}, ${this.opponent}, ${this.inst})';
+  }
+}
+
+class SongChartData
+{
+  @:default(funkin.data.song.SongRegistry.SONG_CHART_DATA_VERSION)
+  public var version:Version;
+
+  public var scrollSpeed:Map<String, Float>;
+  public var events:Array<SongEventData>;
+  public var notes:Map<String, Array<SongNoteData>>;
+
+  @:default(funkin.data.song.SongRegistry.DEFAULT_GENERATEDBY)
+  public var generatedBy:String;
+
+  public function new(scrollSpeed:Map<String, Float>, events:Array<SongEventData>, notes:Map<String, Array<SongNoteData>>)
+  {
+    this.version = SongRegistry.SONG_CHART_DATA_VERSION;
+
+    this.events = events;
+    this.notes = notes;
+    this.scrollSpeed = scrollSpeed;
+
+    this.generatedBy = SongRegistry.DEFAULT_GENERATEDBY;
+  }
+
+  public function getScrollSpeed(diff:String = 'default'):Float
+  {
+    var result:Float = this.scrollSpeed.get(diff);
+
+    if (result == 0.0 && diff != 'default') return getScrollSpeed('default');
+
+    return (result == 0.0) ? 1.0 : result;
+  }
+
+  public function setScrollSpeed(value:Float, diff:String = 'default'):Float
+  {
+    this.scrollSpeed.set(diff, value);
+    return value;
+  }
+
+  public function getNotes(diff:String):Array<SongNoteData>
+  {
+    var result:Array<SongNoteData> = this.notes.get(diff);
+
+    if (result == null && diff != 'normal') return getNotes('normal');
+
+    return (result == null) ? [] : result;
+  }
+
+  public function setNotes(value:Array<SongNoteData>, diff:String):Array<SongNoteData>
+  {
+    this.notes.set(diff, value);
+    return value;
+  }
+
+  public function getEvents():Array<SongEventData>
+  {
+    return this.events;
+  }
+
+  public function setEvents(value:Array<SongEventData>):Array<SongEventData>
+  {
+    return this.events = value;
+  }
+}
+
+class SongEventData
+{
+  /**
+   * The timestamp of the event. The timestamp is in the format of the song's time format.
+   */
+  @:alias("t")
+  public var time:Float;
+
+  /**
+   * The kind of the event.
+   * Examples include "FocusCamera" and "PlayAnimation"
+   * Custom events can be added by scripts with the `ScriptedSongEvent` class.
+   */
+  @:alias("e")
+  public var event:String;
+
+  /**
+   * The data for the event.
+   * This can allow the event to include information used for custom behavior.
+   * Data type depends on the event kind. It can be anything that's JSON serializable.
+   */
+  @:alias("v")
+  @:optional
+  @:jcustomparse(funkin.data.DataParse.dynamicValue)
+  public var value:Dynamic = null;
+
+  /**
+   * Whether this event has been activated.
+   * This is only used internally by the game. It should not be serialized.
+   */
+  @:jignored
+  public var activated:Bool = false;
+
+  public function new(time:Float, event:String, value:Dynamic = null)
+  {
+    this.time = time;
+    this.event = event;
+    this.value = value;
+  }
+
+  @:jignored
+  public var stepTime(get, never):Float;
+
+  function get_stepTime():Float
+  {
+    return Conductor.getTimeInSteps(this.time);
+  }
+
+  public inline function getDynamic(key:String):Null<Dynamic>
+  {
+    return value == null ? null : Reflect.field(value, key);
+  }
+
+  public inline function getBool(key:String):Null<Bool>
+  {
+    return value == null ? null : cast Reflect.field(value, key);
+  }
+
+  public inline function getInt(key:String):Null<Int>
+  {
+    return value == null ? null : cast Reflect.field(value, key);
+  }
+
+  public inline function getFloat(key:String):Null<Float>
+  {
+    return value == null ? null : cast Reflect.field(value, key);
+  }
+
+  public inline function getString(key:String):String
+  {
+    return value == null ? null : cast Reflect.field(value, key);
+  }
+
+  public inline function getArray(key:String):Array<Dynamic>
+  {
+    return value == null ? null : cast Reflect.field(value, key);
+  }
+
+  public inline function getBoolArray(key:String):Array<Bool>
+  {
+    return value == null ? null : cast Reflect.field(value, key);
+  }
+
+  @:op(A == B)
+  public function op_equals(other:SongEventData):Bool
+  {
+    return this.time == other.time && this.event == other.event && this.value == other.value;
+  }
+
+  @:op(A != B)
+  public function op_notEquals(other:SongEventData):Bool
+  {
+    return this.time != other.time || this.event != other.event || this.value != other.value;
+  }
+
+  @:op(A > B)
+  public function op_greaterThan(other:SongEventData):Bool
+  {
+    return this.time > other.time;
+  }
+
+  @:op(A < B)
+  public function op_lessThan(other:SongEventData):Bool
+  {
+    return this.time < other.time;
+  }
+
+  @:op(A >= B)
+  public function op_greaterThanOrEquals(other:SongEventData):Bool
+  {
+    return this.time >= other.time;
+  }
+
+  @:op(A <= B)
+  public function op_lessThanOrEquals(other:SongEventData):Bool
+  {
+    return this.time <= other.time;
+  }
+
+  public function toString():String
+  {
+    return 'SongEventData(${this.time}ms, ${this.event}: ${this.value})';
+  }
+}
+
+class SongNoteData
+{
+  /**
+   * The timestamp of the note. The timestamp is in the format of the song's time format.
+   */
+  @:alias("t")
+  public var time:Float;
+
+  /**
+   * Data for the note. Represents the index on the strumline.
+   * 0 = left, 1 = down, 2 = up, 3 = right
+   * `floor(direction / strumlineSize)` specifies which strumline the note is on.
+   * 0 = player, 1 = opponent, etc.
+   */
+  @:alias("d")
+  public var data:Int;
+
+  /**
+   * Length of the note, if applicable.
+   * Defaults to 0 for single notes.
+   */
+  @:alias("l")
+  @:default(0)
+  @:optional
+  public var length:Float;
+
+  /**
+   * The kind of the note.
+   * This can allow the note to include information used for custom behavior.
+   * Defaults to blank or `"normal"`.
+   */
+  @:alias("k")
+  @:default("normal")
+  @:optional
+  public var kind(get, default):String = '';
+
+  function get_kind():String
+  {
+    if (this.kind == null || this.kind == '') return 'normal';
+
+    return this.kind;
+  }
+
+  public function new(time:Float, data:Int, length:Float = 0, kind:String = '')
+  {
+    this.time = time;
+    this.data = data;
+    this.length = length;
+    this.kind = kind;
+  }
+
+  /**
+   * The timestamp of the note, in steps.
+   */
+  @:jignored
+  public var stepTime(get, never):Float;
+
+  function get_stepTime():Float
+  {
+    return Conductor.getTimeInSteps(this.time);
+  }
+
+  /**
+   * The direction of the note, if applicable.
+   * Strips the strumline index from the data.
+   *
+   * 0 = left, 1 = down, 2 = up, 3 = right
+   */
+  public inline function getDirection(strumlineSize:Int = 4):Int
+  {
+    return this.data % strumlineSize;
+  }
+
+  public function getDirectionName(strumlineSize:Int = 4):String
+  {
+    switch (this.data % strumlineSize)
+    {
+      case 0:
+        return 'Left';
+      case 1:
+        return 'Down';
+      case 2:
+        return 'Up';
+      case 3:
+        return 'Right';
+      default:
+        return 'Unknown';
+    }
+  }
+
+  /**
+   * The strumline index of the note, if applicable.
+   * Strips the direction from the data.
+   *
+   * 0 = player, 1 = opponent, etc.
+   */
+  public inline function getStrumlineIndex(strumlineSize:Int = 4):Int
+  {
+    return Math.floor(this.data / strumlineSize);
+  }
+
+  /**
+   * Returns true if the note is one that Boyfriend should try to hit (i.e. it's on his side).
+   * TODO: The name of this function is a little misleading; what about mines?
+   * @param strumlineSize Defaults to 4.
+   * @return True if it's Boyfriend's note.
+   */
+  public inline function getMustHitNote(strumlineSize:Int = 4):Bool
+  {
+    return getStrumlineIndex(strumlineSize) == 0;
+  }
+
+  /**
+   * If this is a hold note, this is the length of the hold note in steps.
+   * @default 0 (not a hold note)
+   */
+  public var stepLength(get, set):Float;
+
+  function get_stepLength():Float
+  {
+    return Conductor.getTimeInSteps(this.time + this.length) - this.stepTime;
+  }
+
+  function set_stepLength(value:Float):Float
+  {
+    return this.length = Conductor.getStepTimeInMs(value) - this.time;
+  }
+
+  @:jignored
+  public var isHoldNote(get, never):Bool;
+
+  public function get_isHoldNote():Bool
+  {
+    return this.length > 0;
+  }
+
+  @:op(A == B)
+  public function op_equals(other:SongNoteData):Bool
+  {
+    if (this.kind == '')
+    {
+      if (other.kind != '' && other.kind != 'normal') return false;
+    }
+    else
+    {
+      if (other.kind == '' || other.kind != this.kind) return false;
+    }
+
+    return this.time == other.time && this.data == other.data && this.length == other.length;
+  }
+
+  @:op(A != B)
+  public function op_notEquals(other:SongNoteData):Bool
+  {
+    if (this.kind == '')
+    {
+      if (other.kind != '' && other.kind != 'normal') return true;
+    }
+    else
+    {
+      if (other.kind == '' || other.kind != this.kind) return true;
+    }
+
+    return this.time != other.time || this.data != other.data || this.length != other.length;
+  }
+
+  @:op(A > B)
+  public function op_greaterThan(other:SongNoteData):Bool
+  {
+    return this.time > other.time;
+  }
+
+  @:op(A < B)
+  public function op_lessThan(other:SongNoteData):Bool
+  {
+    return this.time < other.time;
+  }
+
+  @:op(A >= B)
+  public function op_greaterThanOrEquals(other:SongNoteData):Bool
+  {
+    return this.time >= other.time;
+  }
+
+  @:op(A <= B)
+  public function op_lessThanOrEquals(other:SongNoteData):Bool
+  {
+    return this.time <= other.time;
+  }
+
+  public function toString():String
+  {
+    return 'SongNoteData(${this.time}ms, ' + (this.length > 0 ? '[${this.length}ms hold]' : '') + ' ${this.data}'
+      + (this.kind != '' ? ' [kind: ${this.kind}])' : ')');
+  }
+}
diff --git a/source/funkin/play/song/SongDataUtils.hx b/source/funkin/data/song/SongDataUtils.hx
similarity index 98%
rename from source/funkin/play/song/SongDataUtils.hx
rename to source/funkin/data/song/SongDataUtils.hx
index a7cbd1b6c..d15a2b19a 100644
--- a/source/funkin/play/song/SongDataUtils.hx
+++ b/source/funkin/data/song/SongDataUtils.hx
@@ -1,8 +1,8 @@
-package funkin.play.song;
+package funkin.data.song;
 
 import flixel.util.FlxSort;
-import funkin.play.song.SongData.SongEventData;
-import funkin.play.song.SongData.SongNoteData;
+import funkin.data.song.SongData.SongEventData;
+import funkin.data.song.SongData.SongNoteData;
 import funkin.util.ClipboardUtil;
 import funkin.util.SerializerUtil;
 
diff --git a/source/funkin/data/song/SongRegistry.hx b/source/funkin/data/song/SongRegistry.hx
new file mode 100644
index 000000000..9bc1278c8
--- /dev/null
+++ b/source/funkin/data/song/SongRegistry.hx
@@ -0,0 +1,271 @@
+package funkin.data.song;
+
+import funkin.data.song.SongData;
+import funkin.data.song.SongData.SongChartData;
+import funkin.data.song.SongData.SongMetadata;
+import funkin.play.song.ScriptedSong;
+import funkin.play.song.Song;
+import funkin.util.assets.DataAssets;
+import funkin.util.VersionUtil;
+
+class SongRegistry extends BaseRegistry<Song, SongMetadata>
+{
+  /**
+   * The current version string for the stage data format.
+   * 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_RULE:thx.semver.VersionRule = "2.0.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 var DEFAULT_GENERATEDBY(get, null):String;
+
+  static function get_DEFAULT_GENERATEDBY():String
+  {
+    return '${Constants.TITLE} - ${Constants.VERSION}';
+  }
+
+  public static final instance:SongRegistry = new SongRegistry();
+
+  public function new()
+  {
+    super('SONG', 'songs', SONG_METADATA_VERSION_RULE);
+  }
+
+  public override function loadEntries():Void
+  {
+    clearEntries();
+
+    //
+    // SCRIPTED ENTRIES
+    //
+    var scriptedEntryClassNames:Array<String> = getScriptedClassNames();
+    log('Registering ${scriptedEntryClassNames.length} scripted entries...');
+
+    for (entryCls in scriptedEntryClassNames)
+    {
+      var entry:Song = createScriptedEntry(entryCls);
+
+      if (entry != null)
+      {
+        log('Successfully created scripted entry (${entryCls} = ${entry.id})');
+        entries.set(entry.id, entry);
+      }
+      else
+      {
+        log('Failed to create scripted entry (${entryCls})');
+      }
+    }
+
+    //
+    // UNSCRIPTED ENTRIES
+    //
+    var entryIdList:Array<String> = DataAssets.listDataFilesInPath('songs/', '-metadata.json').map(function(songDataPath:String):String {
+      return songDataPath.split('/')[0];
+    });
+    var unscriptedEntryIds:Array<String> = entryIdList.filter(function(entryId:String):Bool {
+      return !entries.exists(entryId);
+    });
+    log('Fetching data for ${unscriptedEntryIds.length} unscripted entries...');
+    for (entryId in unscriptedEntryIds)
+    {
+      try
+      {
+        var entry:Song = createEntry(entryId);
+        if (entry != null)
+        {
+          trace('  Loaded entry data: ${entry}');
+          entries.set(entry.id, entry);
+        }
+      }
+      catch (e:Dynamic)
+      {
+        // Print the error.
+        trace('  Failed to load entry data: ${entryId}');
+        trace(e);
+        continue;
+      }
+    }
+  }
+
+  /**
+   * Read, parse, and validate the JSON data and produce the corresponding data object.
+   */
+  public function parseEntryData(id:String):Null<SongMetadata>
+  {
+    return parseEntryMetadata(id);
+  }
+
+  public function parseEntryMetadata(id:String, variation:String = ""):Null<SongMetadata>
+  {
+    // JsonParser does not take type parameters,
+    // otherwise this function would be in BaseRegistry.
+
+    var parser = new json2object.JsonParser<SongMetadata>();
+    switch (loadEntryMetadataFile(id))
+    {
+      case {fileName: fileName, contents: contents}:
+        parser.fromJson(contents, fileName);
+      default:
+        return null;
+    }
+
+    if (parser.errors.length > 0)
+    {
+      printErrors(parser.errors, id);
+      return null;
+    }
+    return parser.value;
+  }
+
+  public function parseEntryMetadataWithMigration(id:String, variation:String = '', 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 parseEntryMetadata(id);
+    }
+    else
+    {
+      throw '[${registryId}] Metadata entry ${id}:${variation == '' ? 'default' : variation} does not support migration to version ${SONG_METADATA_VERSION_RULE}.';
+    }
+  }
+
+  public function parseMusicData(id:String, variation:String = ""):Null<SongMusicData>
+  {
+    // JsonParser does not take type parameters,
+    // otherwise this function would be in BaseRegistry.
+
+    var parser = new json2object.JsonParser<SongMusicData>();
+    switch (loadMusicDataFile(id))
+    {
+      case {fileName: fileName, contents: contents}:
+        parser.fromJson(contents, fileName);
+      default:
+        return null;
+    }
+
+    if (parser.errors.length > 0)
+    {
+      printErrors(parser.errors, id);
+      return null;
+    }
+    return parser.value;
+  }
+
+  public function parseEntryChartData(id:String, variation:String = ''):Null<SongChartData>
+  {
+    // JsonParser does not take type parameters,
+    // otherwise this function would be in BaseRegistry.
+    var parser = new json2object.JsonParser<SongChartData>();
+
+    switch (loadEntryChartFile(id))
+    {
+      case {fileName: fileName, contents: contents}:
+        parser.fromJson(contents, fileName);
+      default:
+        return null;
+    }
+
+    if (parser.errors.length > 0)
+    {
+      printErrors(parser.errors, id);
+      return null;
+    }
+    return parser.value;
+  }
+
+  public function parseEntryChartDataWithMigration(id:String, variation:String = '', 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 parseEntryChartData(id, variation);
+    }
+    else
+    {
+      throw '[${registryId}] Chart entry ${id}:${variation == '' ? 'default' : variation} does not support migration to version ${SONG_CHART_DATA_VERSION_RULE}.';
+    }
+  }
+
+  function createScriptedEntry(clsName:String):Song
+  {
+    return ScriptedSong.init(clsName, "unknown");
+  }
+
+  function getScriptedClassNames():Array<String>
+  {
+    return ScriptedSong.listScriptClasses();
+  }
+
+  function loadEntryMetadataFile(id:String, variation:String = ''):Null<BaseRegistry.JsonFile>
+  {
+    var entryFilePath:String = Paths.json('$dataFilePath/$id/$id${variation == '' ? '' : '-$variation'}-metadata');
+    if (!openfl.Assets.exists(entryFilePath)) return null;
+    var rawJson:Null<String> = openfl.Assets.getText(entryFilePath);
+    if (rawJson == null) return null;
+    rawJson = rawJson.trim();
+    return {fileName: entryFilePath, contents: rawJson};
+  }
+
+  function loadMusicDataFile(id:String, variation:String = ''):Null<BaseRegistry.JsonFile>
+  {
+    var entryFilePath:String = Paths.file('music/$id/$id${variation == '' ? '' : '-$variation'}-metadata.json');
+    if (!openfl.Assets.exists(entryFilePath)) return null;
+    var rawJson:String = openfl.Assets.getText(entryFilePath);
+    if (rawJson == null) return null;
+    rawJson = rawJson.trim();
+    return {fileName: entryFilePath, contents: rawJson};
+  }
+
+  function loadEntryChartFile(id:String, variation:String = ''):Null<BaseRegistry.JsonFile>
+  {
+    var entryFilePath:String = Paths.json('$dataFilePath/$id/$id${variation == '' ? '' : '-$variation'}-chart');
+    if (!openfl.Assets.exists(entryFilePath)) return null;
+    var rawJson:String = openfl.Assets.getText(entryFilePath);
+    if (rawJson == null) return null;
+    rawJson = rawJson.trim();
+    return {fileName: entryFilePath, contents: rawJson};
+  }
+
+  public function fetchEntryMetadataVersion(id:String, variation:String = ''):Null<thx.semver.Version>
+  {
+    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>
+  {
+    var entryStr:Null<String> = loadEntryChartFile(id, variation)?.contents;
+    var entryVersion:thx.semver.Version = VersionUtil.getVersionFromJSON(entryStr);
+    return entryVersion;
+  }
+
+  /**
+   * A list of all the story weeks from the base game, in order.
+   * TODO: Should this be hardcoded?
+   */
+  public function listBaseGameSongIds():Array<String>
+  {
+    return [
+      "tutorial", "bopeebo", "fresh", "dadbattle", "spookeez", "south", "monster", "pico", "philly-nice", "blammed", "satin-panties", "high", "milf", "cocoa",
+      "eggnog", "winter-horrorland", "senpai", "roses", "thorns", "ugh", "guns", "stress", "darnell", "lit-up", "2hot", "blazin"
+    ];
+  }
+
+  /**
+   * A list of all installed story weeks that are not from the base game.
+   */
+  public function listModdedSongIds():Array<String>
+  {
+    return listEntryIds().filter(function(id:String):Bool {
+      return listBaseGameSongIds().indexOf(id) == -1;
+    });
+  }
+}
diff --git a/source/funkin/modding/PolymodHandler.hx b/source/funkin/modding/PolymodHandler.hx
index 47afb0a30..f7f69428b 100644
--- a/source/funkin/modding/PolymodHandler.hx
+++ b/source/funkin/modding/PolymodHandler.hx
@@ -3,18 +3,19 @@ package funkin.modding;
 import funkin.util.macro.ClassMacro;
 import funkin.modding.module.ModuleHandler;
 import funkin.play.character.CharacterData.CharacterDataParser;
-import funkin.play.song.SongData;
+import funkin.data.song.SongData;
 import funkin.play.stage.StageData;
 import polymod.Polymod;
 import polymod.backends.PolymodAssets.PolymodAssetType;
 import polymod.format.ParseRules.TextFileFormat;
-import funkin.play.event.SongEventData.SongEventParser;
+import funkin.data.event.SongEventData.SongEventParser;
 import funkin.util.FileUtil;
 import funkin.data.level.LevelRegistry;
 import funkin.data.notestyle.NoteStyleRegistry;
 import funkin.play.cutscene.dialogue.ConversationDataParser;
 import funkin.play.cutscene.dialogue.DialogueBoxDataParser;
 import funkin.play.cutscene.dialogue.SpeakerDataParser;
+import funkin.data.song.SongRegistry;
 
 class PolymodHandler
 {
@@ -290,13 +291,13 @@ class PolymodHandler
 
     // These MUST be imported at the top of the file and not referred to by fully qualified name,
     // to ensure build macros work properly.
+    SongRegistry.instance.loadEntries();
     LevelRegistry.instance.loadEntries();
     NoteStyleRegistry.instance.loadEntries();
     SongEventParser.loadEventCache();
     ConversationDataParser.loadConversationCache();
     DialogueBoxDataParser.loadDialogueBoxCache();
     SpeakerDataParser.loadSpeakerCache();
-    SongDataParser.loadSongCache();
     StageDataParser.loadStageCache();
     CharacterDataParser.loadCharacterCache();
     ModuleHandler.loadModuleCache();
diff --git a/source/funkin/modding/events/ScriptEvent.hx b/source/funkin/modding/events/ScriptEvent.hx
index 3f29ad833..586a6206c 100644
--- a/source/funkin/modding/events/ScriptEvent.hx
+++ b/source/funkin/modding/events/ScriptEvent.hx
@@ -1,6 +1,6 @@
 package funkin.modding.events;
 
-import funkin.play.song.SongData.SongNoteData;
+import funkin.data.song.SongData.SongNoteData;
 import flixel.FlxState;
 import flixel.FlxSubState;
 import funkin.play.notes.NoteSprite;
@@ -435,9 +435,9 @@ class SongEventScriptEvent extends ScriptEvent
    * The note associated with this event.
    * You cannot replace it, but you can edit it.
    */
-  public var event(default, null):funkin.play.song.SongData.SongEventData;
+  public var event(default, null):funkin.data.song.SongData.SongEventData;
 
-  public function new(event:funkin.play.song.SongData.SongEventData):Void
+  public function new(event:funkin.data.song.SongData.SongEventData):Void
   {
     super(ScriptEvent.SONG_EVENT, true);
     this.event = event;
diff --git a/source/funkin/play/PlayState.hx b/source/funkin/play/PlayState.hx
index 068f32f97..46938215b 100644
--- a/source/funkin/play/PlayState.hx
+++ b/source/funkin/play/PlayState.hx
@@ -35,7 +35,7 @@ import funkin.play.cutscene.dialogue.Conversation;
 import funkin.play.cutscene.dialogue.ConversationDataParser;
 import funkin.play.cutscene.VanillaCutscenes;
 import funkin.play.cutscene.VideoCutscene;
-import funkin.play.event.SongEventData.SongEventParser;
+import funkin.data.event.SongEventData.SongEventParser;
 import funkin.play.notes.NoteSprite;
 import funkin.play.notes.NoteDirection;
 import funkin.play.notes.Strumline;
@@ -43,10 +43,10 @@ import funkin.play.notes.SustainTrail;
 import funkin.play.scoring.Scoring;
 import funkin.NoteSplash;
 import funkin.play.song.Song;
-import funkin.play.song.SongData.SongDataParser;
-import funkin.play.song.SongData.SongEventData;
-import funkin.play.song.SongData.SongNoteData;
-import funkin.play.song.SongData.SongPlayableChar;
+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.play.stage.Stage;
 import funkin.play.stage.StageData.StageDataParser;
 import funkin.ui.PopUpStuff;
@@ -630,7 +630,7 @@ class PlayState extends MusicBeatSubState
     startingSong = true;
 
     // TODO: We hardcoded the transition into Winter Horrorland. Do this with a ScriptedSong instead.
-    if ((currentSong?.songId ?? '').toLowerCase() == 'winter-horrorland')
+    if ((currentSong?.id ?? '').toLowerCase() == 'winter-horrorland')
     {
       // VanillaCutscenes will call startCountdown later.
       VanillaCutscenes.playHorrorStartCutscene();
@@ -2495,9 +2495,9 @@ class PlayState extends MusicBeatSubState
     if (currentSong != null && currentSong.validScore)
     {
       // crackhead double thingie, sets whether was new highscore, AND saves the song!
-      Highscore.tallies.isNewHighscore = Highscore.saveScoreForDifficulty(currentSong.songId, songScore, currentDifficulty);
+      Highscore.tallies.isNewHighscore = Highscore.saveScoreForDifficulty(currentSong.id, songScore, currentDifficulty);
 
-      Highscore.saveCompletionForDifficulty(currentSong.songId, Highscore.tallies.totalNotesHit / Highscore.tallies.totalNotes, currentDifficulty);
+      Highscore.saveCompletionForDifficulty(currentSong.id, Highscore.tallies.totalNotesHit / Highscore.tallies.totalNotes, currentDifficulty);
     }
 
     if (PlayStatePlaylist.isStoryMode)
@@ -2549,7 +2549,7 @@ class PlayState extends MusicBeatSubState
         vocals.stop();
 
         // TODO: Softcode this cutscene.
-        if (currentSong.songId == 'eggnog')
+        if (currentSong.id == 'eggnog')
         {
           var blackShit:FlxSprite = new FlxSprite(-FlxG.width * FlxG.camera.zoom,
             -FlxG.height * FlxG.camera.zoom).makeGraphic(FlxG.width * 3, FlxG.height * 3, FlxColor.BLACK);
@@ -2560,7 +2560,7 @@ class PlayState extends MusicBeatSubState
 
           FlxG.sound.play(Paths.sound('Lights_Shut_off'), function() {
             // no camFollow so it centers on horror tree
-            var targetSong:Song = SongDataParser.fetchSong(targetSongId);
+            var targetSong:Song = SongRegistry.instance.fetchEntry(targetSongId);
             // Load and cache the song's charts.
             // TODO: Do this in the loading state.
             targetSong.cacheCharts(true);
@@ -2577,7 +2577,7 @@ class PlayState extends MusicBeatSubState
         }
         else
         {
-          var targetSong:Song = SongDataParser.fetchSong(targetSongId);
+          var targetSong:Song = SongRegistry.instance.fetchEntry(targetSongId);
           // Load and cache the song's charts.
           // TODO: Do this in the loading state.
           targetSong.cacheCharts(true);
diff --git a/source/funkin/play/ResultState.hx b/source/funkin/play/ResultState.hx
index aaa2b6d1d..0c2984719 100644
--- a/source/funkin/play/ResultState.hx
+++ b/source/funkin/play/ResultState.hx
@@ -143,7 +143,7 @@ class ResultState extends MusicBeatSubState
     }
     else
     {
-      songName.text += PlayState.instance.currentSong.songId;
+      songName.text += PlayState.instance.currentSong.id;
     }
 
     songName.letterSpacing = -15;
diff --git a/source/funkin/play/event/FocusCameraSongEvent.hx b/source/funkin/play/event/FocusCameraSongEvent.hx
index 8d677118b..5f63254b0 100644
--- a/source/funkin/play/event/FocusCameraSongEvent.hx
+++ b/source/funkin/play/event/FocusCameraSongEvent.hx
@@ -1,9 +1,12 @@
 package funkin.play.event;
 
-import funkin.play.song.SongData;
+// Data from the chart
+import funkin.data.song.SongData;
+import funkin.data.song.SongData.SongEventData;
+// Data from the event schema
 import funkin.play.event.SongEvent;
-import funkin.play.event.SongEventData.SongEventFieldType;
-import funkin.play.event.SongEventData.SongEventSchema;
+import funkin.data.event.SongEventData.SongEventSchema;
+import funkin.data.event.SongEventData.SongEventFieldType;
 
 /**
  * This class represents a handler for a type of song event.
diff --git a/source/funkin/play/event/PlayAnimationSongEvent.hx b/source/funkin/play/event/PlayAnimationSongEvent.hx
index a187ca285..6bc625517 100644
--- a/source/funkin/play/event/PlayAnimationSongEvent.hx
+++ b/source/funkin/play/event/PlayAnimationSongEvent.hx
@@ -2,10 +2,13 @@ package funkin.play.event;
 
 import flixel.FlxSprite;
 import funkin.play.character.BaseCharacter;
+// Data from the chart
+import funkin.data.song.SongData;
+import funkin.data.song.SongData.SongEventData;
+// Data from the event schema
 import funkin.play.event.SongEvent;
-import funkin.play.event.SongEventData.SongEventFieldType;
-import funkin.play.event.SongEventData.SongEventSchema;
-import funkin.play.song.SongData;
+import funkin.data.event.SongEventData.SongEventSchema;
+import funkin.data.event.SongEventData.SongEventFieldType;
 
 class PlayAnimationSongEvent extends SongEvent
 {
diff --git a/source/funkin/play/event/SetCameraBopSongEvent.hx b/source/funkin/play/event/SetCameraBopSongEvent.hx
index b17d4511c..3cdeb9a67 100644
--- a/source/funkin/play/event/SetCameraBopSongEvent.hx
+++ b/source/funkin/play/event/SetCameraBopSongEvent.hx
@@ -3,10 +3,13 @@ package funkin.play.event;
 import flixel.tweens.FlxTween;
 import flixel.FlxCamera;
 import flixel.tweens.FlxEase;
+// Data from the chart
+import funkin.data.song.SongData;
+import funkin.data.song.SongData.SongEventData;
+// Data from the event schema
 import funkin.play.event.SongEvent;
-import funkin.play.song.SongData;
-import funkin.play.event.SongEventData;
-import funkin.play.event.SongEventData.SongEventFieldType;
+import funkin.data.event.SongEventData.SongEventSchema;
+import funkin.data.event.SongEventData.SongEventFieldType;
 
 /**
  * This class represents a handler for configuring camera bop intensity and rate.
diff --git a/source/funkin/play/event/SongEvent.hx b/source/funkin/play/event/SongEvent.hx
index 6acc745ff..36a886673 100644
--- a/source/funkin/play/event/SongEvent.hx
+++ b/source/funkin/play/event/SongEvent.hx
@@ -1,7 +1,7 @@
 package funkin.play.event;
 
-import funkin.play.song.SongData.SongEventData;
-import funkin.play.event.SongEventData.SongEventSchema;
+import funkin.data.song.SongData.SongEventData;
+import funkin.data.event.SongEventData.SongEventSchema;
 
 /**
  * This class represents a handler for a type of song event.
diff --git a/source/funkin/play/event/ZoomCameraSongEvent.hx b/source/funkin/play/event/ZoomCameraSongEvent.hx
index 79425d564..1ae76039e 100644
--- a/source/funkin/play/event/ZoomCameraSongEvent.hx
+++ b/source/funkin/play/event/ZoomCameraSongEvent.hx
@@ -3,10 +3,13 @@ package funkin.play.event;
 import flixel.tweens.FlxTween;
 import flixel.FlxCamera;
 import flixel.tweens.FlxEase;
+// Data from the chart
+import funkin.data.song.SongData;
+import funkin.data.song.SongData.SongEventData;
+// Data from the event schema
 import funkin.play.event.SongEvent;
-import funkin.play.song.SongData;
-import funkin.play.event.SongEventData;
-import funkin.play.event.SongEventData.SongEventFieldType;
+import funkin.data.event.SongEventData.SongEventFieldType;
+import funkin.data.event.SongEventData.SongEventSchema;
 
 /**
  * This class represents a handler for camera zoom events.
@@ -76,8 +79,7 @@ class ZoomCameraSongEvent extends SongEvent
           return;
         }
 
-        FlxTween.tween(PlayState.instance, {defaultCameraZoom: zoom * FlxCamera.defaultZoom}, (Conductor.stepLengthMs * duration / 1000),
-          {ease: easeFunction});
+        FlxTween.tween(PlayState.instance, {defaultCameraZoom: zoom * FlxCamera.defaultZoom}, (Conductor.stepLengthMs * duration / 1000), {ease: easeFunction});
     }
   }
 
diff --git a/source/funkin/play/notes/NoteSprite.hx b/source/funkin/play/notes/NoteSprite.hx
index 25b23eee7..e6202a3a0 100644
--- a/source/funkin/play/notes/NoteSprite.hx
+++ b/source/funkin/play/notes/NoteSprite.hx
@@ -1,6 +1,6 @@
 package funkin.play.notes;
 
-import funkin.play.song.SongData.SongNoteData;
+import funkin.data.song.SongData.SongNoteData;
 import funkin.play.notes.notestyle.NoteStyle;
 import flixel.graphics.frames.FlxAtlasFrames;
 import flixel.FlxSprite;
diff --git a/source/funkin/play/notes/Strumline.hx b/source/funkin/play/notes/Strumline.hx
index 2b21e6b7e..7bd6e7ae7 100644
--- a/source/funkin/play/notes/Strumline.hx
+++ b/source/funkin/play/notes/Strumline.hx
@@ -11,7 +11,7 @@ import funkin.play.notes.NoteHoldCover;
 import funkin.play.notes.NoteSplash;
 import funkin.play.notes.NoteSprite;
 import funkin.play.notes.SustainTrail;
-import funkin.play.song.SongData.SongNoteData;
+import funkin.data.song.SongData.SongNoteData;
 import funkin.ui.PreferencesMenu;
 import funkin.util.SortUtil;
 
diff --git a/source/funkin/play/notes/SustainTrail.hx b/source/funkin/play/notes/SustainTrail.hx
index 4bcbe0528..0b9304a3d 100644
--- a/source/funkin/play/notes/SustainTrail.hx
+++ b/source/funkin/play/notes/SustainTrail.hx
@@ -2,7 +2,7 @@ package funkin.play.notes;
 
 import funkin.play.notes.notestyle.NoteStyle;
 import funkin.play.notes.NoteDirection;
-import funkin.play.song.SongData.SongNoteData;
+import funkin.data.song.SongData.SongNoteData;
 import flixel.util.FlxDirectionFlags;
 import flixel.FlxSprite;
 import flixel.graphics.FlxGraphic;
diff --git a/source/funkin/play/notes/notestyle/NoteStyle.hx b/source/funkin/play/notes/notestyle/NoteStyle.hx
index 97871b657..34c1ce9c3 100644
--- a/source/funkin/play/notes/notestyle/NoteStyle.hx
+++ b/source/funkin/play/notes/notestyle/NoteStyle.hx
@@ -104,7 +104,8 @@ class NoteStyle implements IRegistryEntry<NoteStyleData>
 
     noteFrames = Paths.getSparrowAtlas(getNoteAssetPath(), getNoteAssetLibrary());
 
-    if (noteFrames == null) {
+    if (noteFrames == null)
+    {
       throw 'Could not load note frames for note style: $id';
     }
 
@@ -139,13 +140,13 @@ class NoteStyle implements IRegistryEntry<NoteStyleData>
   function buildNoteAnimations(target:NoteSprite):Void
   {
     var leftData:AnimationData = fetchNoteAnimationData(LEFT);
-    target.animation.addByPrefix('purpleScroll', leftData.prefix);
+    target.animation.addByPrefix('purpleScroll', leftData.prefix, leftData.frameRate, leftData.looped, leftData.flipX, leftData.flipY);
     var downData:AnimationData = fetchNoteAnimationData(DOWN);
-    target.animation.addByPrefix('blueScroll', downData.prefix);
+    target.animation.addByPrefix('blueScroll', downData.prefix, downData.frameRate, downData.looped, downData.flipX, downData.flipY);
     var upData:AnimationData = fetchNoteAnimationData(UP);
-    target.animation.addByPrefix('greenScroll', upData.prefix);
+    target.animation.addByPrefix('greenScroll', upData.prefix, upData.frameRate, upData.looped, upData.flipX, upData.flipY);
     var rightData:AnimationData = fetchNoteAnimationData(RIGHT);
-    target.animation.addByPrefix('redScroll', rightData.prefix);
+    target.animation.addByPrefix('redScroll', rightData.prefix, rightData.frameRate, rightData.looped, rightData.flipX, rightData.flipY);
   }
 
   function fetchNoteAnimationData(dir:NoteDirection):AnimationData
@@ -302,7 +303,7 @@ class NoteStyle implements IRegistryEntry<NoteStyleData>
     return 'NoteStyle($id)';
   }
 
-  public function _fetchData(id:String):Null<NoteStyleData>
+  static function _fetchData(id:String):Null<NoteStyleData>
   {
     return NoteStyleRegistry.instance.parseEntryDataWithMigration(id, NoteStyleRegistry.instance.fetchEntryVersion(id));
   }
diff --git a/source/funkin/play/song/Song.hx b/source/funkin/play/song/Song.hx
index 715629a51..e32eb8186 100644
--- a/source/funkin/play/song/Song.hx
+++ b/source/funkin/play/song/Song.hx
@@ -5,14 +5,16 @@ import openfl.utils.Assets;
 import funkin.modding.events.ScriptEvent;
 import funkin.modding.IScriptedClass;
 import funkin.audio.VoicesGroup;
-import funkin.play.song.SongData.SongChartData;
-import funkin.play.song.SongData.SongDataParser;
-import funkin.play.song.SongData.SongEventData;
-import funkin.play.song.SongData.SongMetadata;
-import funkin.play.song.SongData.SongNoteData;
-import funkin.play.song.SongData.SongPlayableChar;
-import funkin.play.song.SongData.SongTimeChange;
-import funkin.play.song.SongData.SongTimeFormat;
+import funkin.data.song.SongRegistry;
+import funkin.data.song.SongData.SongChartData;
+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.SongTimeChange;
+import funkin.data.song.SongData.SongTimeFormat;
+import funkin.data.IRegistryEntry;
 
 /**
  * This is a data structure managing information about the current song.
@@ -23,9 +25,26 @@ import funkin.play.song.SongData.SongTimeFormat;
  * It also receives script events; scripted classes which extend this class
  * can be used to perform custom gameplay behaviors only on specific songs.
  */
-class Song implements IPlayStateScriptedClass
+@:nullSafety
+class Song implements IPlayStateScriptedClass implements IRegistryEntry<SongMetadata>
 {
-  public final songId:String;
+  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 final id:String;
+
+  /**
+   * Song metadata as parsed from the JSON file.
+   * This is the data for the `default` variation specifically,
+   * and is needed for the IRegistryEntry interface.
+   * Will only be null if the song data could not be loaded.
+   */
+  public final _data:Null<SongMetadata>;
 
   final _metadata:Array<SongMetadata>;
 
@@ -39,33 +58,56 @@ class Song implements IPlayStateScriptedClass
 
   var difficultyIds:Array<String>;
 
+  public var songName(get, never):String;
+
+  function get_songName():String
+  {
+    if (_data != null) return _data?.songName ?? DEFAULT_SONGNAME;
+    if (_metadata.length > 0) return _metadata[0]?.songName ?? DEFAULT_SONGNAME;
+    return DEFAULT_SONGNAME;
+  }
+
+  public var songArtist(get, never):String;
+
+  function get_songArtist():String
+  {
+    if (_data != null) return _data?.artist ?? DEFAULT_ARTIST;
+    if (_metadata.length > 0) return _metadata[0]?.artist ?? DEFAULT_ARTIST;
+    return DEFAULT_ARTIST;
+  }
+
   /**
    * @param id The ID of the song to load.
    * @param ignoreErrors If false, an exception will be thrown if the song data could not be loaded.
    */
-  public function new(id:String, ignoreErrors:Bool = false)
+  public function new(id:String)
   {
-    this.songId = id;
+    this.id = id;
 
     variations = [];
     difficultyIds = [];
     difficulties = new Map<String, SongDifficulty>();
 
-    try
+    _data = _fetchData(id);
+
+    _metadata = _data == null ? [] : [_data];
+
+    for (meta in fetchVariationMetadata(id))
+      _metadata.push(meta);
+
+    if (_metadata.length == 0)
     {
-      _metadata = SongDataParser.loadSongMetadata(songId);
-    }
-    catch (e)
-    {
-      _metadata = [];
+      trace('[WARN] Could not find song data for songId: $id');
+      return;
     }
 
-    if (_metadata.length == 0 && !ignoreErrors)
-    {
-      throw 'Could not find song data for songId: $songId';
-    }
-    else
+    variations.clear();
+    variations.push('default');
+    if (_data != null && _data.playData != null)
     {
+      for (vari in _data.playData.songVariations)
+        variations.push(vari);
+
       populateFromMetadata();
     }
   }
@@ -74,7 +116,7 @@ class Song implements IPlayStateScriptedClass
   public static function buildRaw(songId:String, metadata:Array<SongMetadata>, variations:Array<String>, charts:Map<String, SongChartData>,
       validScore:Bool = false):Song
   {
-    var result:Song = new Song(songId, true);
+    var result:Song = new Song(songId);
 
     result._metadata.clear();
     for (meta in metadata)
@@ -112,6 +154,8 @@ class Song implements IPlayStateScriptedClass
     // Variations may have different artist, time format, generatedBy, etc.
     for (metadata in _metadata)
     {
+      if (metadata == null || metadata.playData == null) continue;
+
       // There may be more difficulties in the chart file than in the metadata,
       // (i.e. non-playable charts like the one used for Pico on the speaker in Stress)
       // but all the difficulties in the metadata must be in the chart file.
@@ -134,15 +178,16 @@ class Song implements IPlayStateScriptedClass
         difficulty.stage = metadata.playData.stage;
         // difficulty.noteSkin = 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 = metadata.playData.playableChars.get(charId);
-
+          var char:Null<SongPlayableChar> = metadata.playData.playableChars.get(charId);
+          if (char == null) continue;
           difficulty.chars.set(charId, char);
         }
-
-        difficulties.set(diffId, difficulty);
       }
     }
   }
@@ -157,11 +202,14 @@ class Song implements IPlayStateScriptedClass
       clearCharts();
     }
 
-    trace('Caching ${variations.length} chart files for song $songId');
+    trace('Caching ${variations.length} chart files for song $id');
     for (variation in variations)
     {
-      var chartData:SongChartData = SongDataParser.parseSongChartData(songId, variation);
-      applyChartData(chartData, variation);
+      var version:Null<thx.semver.Version> = SongRegistry.instance.fetchEntryChartVersion(id, variation);
+      if (version == null) continue;
+      var chart:Null<SongChartData> = SongRegistry.instance.parseEntryChartDataWithMigration(id, variation, version);
+      if (chart == null) continue;
+      applyChartData(chart, variation);
     }
     trace('Done caching charts.');
   }
@@ -181,8 +229,8 @@ class Song implements IPlayStateScriptedClass
         difficulties.set(diffId, difficulty);
       }
       // Add the chart data to the difficulty.
-      difficulty.notes = chartData.notes.get(diffId);
-      difficulty.scrollSpeed = chartData.getScrollSpeed(diffId);
+      difficulty.notes = chartNotes.get(diffId) ?? [];
+      difficulty.scrollSpeed = chartData.getScrollSpeed(diffId) ?? 1.0;
 
       difficulty.events = chartData.events;
     }
@@ -193,7 +241,7 @@ class Song implements IPlayStateScriptedClass
    * @param diffId The difficulty ID, such as `easy` or `hard`.
    * @return The difficulty data.
    */
-  public inline function getDifficulty(diffId:String = null):SongDifficulty
+  public inline function getDifficulty(?diffId:String):Null<SongDifficulty>
   {
     if (diffId == null) diffId = difficulties.keys().array()[0];
 
@@ -223,9 +271,11 @@ class Song implements IPlayStateScriptedClass
 
   public function toString():String
   {
-    return 'Song($songId)';
+    return 'Song($id)';
   }
 
+  public function destroy():Void {}
+
   public function onPause(event:PauseScriptEvent):Void {};
 
   public function onResume(event:ScriptEvent):Void {};
@@ -265,6 +315,27 @@ class Song implements IPlayStateScriptedClass
   public function onDestroy(event:ScriptEvent):Void {};
 
   public function onUpdate(event:UpdateScriptEvent):Void {};
+
+  static function _fetchData(id:String):Null<SongMetadata>
+  {
+    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);
+  }
+
+  function fetchVariationMetadata(id:String):Array<SongMetadata>
+  {
+    var result:Array<SongMetadata> = [];
+    for (vari in variations)
+    {
+      var version:Null<thx.semver.Version> = SongRegistry.instance.fetchEntryMetadataVersion(id, vari);
+      if (version == null) continue;
+      var meta:Null<SongMetadata> = SongRegistry.instance.parseEntryMetadataWithMigration(id, vari, version);
+      if (meta != null) result.push(meta);
+    }
+    return result;
+  }
 }
 
 class SongDifficulty
@@ -299,7 +370,7 @@ class SongDifficulty
   public var timeFormat:SongTimeFormat = SongValidator.DEFAULT_TIMEFORMAT;
   public var divisions:Null<Int> = SongValidator.DEFAULT_DIVISIONS;
   public var looped:Bool = SongValidator.DEFAULT_LOOPED;
-  public var generatedBy:String = SongValidator.DEFAULT_GENERATEDBY;
+  public var generatedBy:String = SongRegistry.DEFAULT_GENERATEDBY;
 
   public var timeChanges:Array<SongTimeChange> = [];
 
@@ -351,18 +422,18 @@ class SongDifficulty
     var currentPlayer:Null<SongPlayableChar> = getPlayableChar(currentPlayerId);
     if (currentPlayer != null)
     {
-      FlxG.sound.cache(Paths.inst(this.song.songId, currentPlayer.inst));
+      FlxG.sound.cache(Paths.inst(this.song.id, currentPlayer.inst));
     }
     else
     {
-      FlxG.sound.cache(Paths.inst(this.song.songId));
+      FlxG.sound.cache(Paths.inst(this.song.id));
     }
   }
 
   public inline function playInst(volume:Float = 1.0, looped:Bool = false):Void
   {
     var suffix:String = (variation != null && variation != '' && variation != 'default') ? '-$variation' : '';
-    FlxG.sound.playMusic(Paths.inst(this.song.songId, suffix), volume, looped);
+    FlxG.sound.playMusic(Paths.inst(this.song.id, suffix), volume, looped);
   }
 
   /**
@@ -388,7 +459,7 @@ class SongDifficulty
     var playableCharData:SongPlayableChar = getPlayableChar(id);
     if (playableCharData == null)
     {
-      trace('Could not find playable char $id for song ${this.song.songId}');
+      trace('Could not find playable char $id for song ${this.song.id}');
       return [];
     }
 
@@ -398,24 +469,24 @@ class SongDifficulty
     // 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.songId, '-$id$suffix');
+    var voicePlayer:String = Paths.voices(this.song.id, '-$id$suffix');
     while (voicePlayer != null && !Assets.exists(voicePlayer))
     {
       // Remove the last suffix.
       // For example, bf-car becomes bf.
       playerId = playerId.split('-').slice(0, -1).join('-');
       // Try again.
-      voicePlayer = playerId == '' ? null : Paths.voices(this.song.songId, '-${playerId}$suffix');
+      voicePlayer = playerId == '' ? null : Paths.voices(this.song.id, '-${playerId}$suffix');
     }
 
     var opponentId:String = playableCharData.opponent;
-    var voiceOpponent:String = Paths.voices(this.song.songId, '-${opponentId}$suffix');
+    var voiceOpponent:String = Paths.voices(this.song.id, '-${opponentId}$suffix');
     while (voiceOpponent != null && !Assets.exists(voiceOpponent))
     {
       // Remove the last suffix.
       opponentId = opponentId.split('-').slice(0, -1).join('-');
       // Try again.
-      voiceOpponent = opponentId == '' ? null : Paths.voices(this.song.songId, '-${opponentId}$suffix');
+      voiceOpponent = opponentId == '' ? null : Paths.voices(this.song.id, '-${opponentId}$suffix');
     }
 
     var result:Array<String> = [];
@@ -424,7 +495,7 @@ class SongDifficulty
     if (voicePlayer == null && voiceOpponent == null)
     {
       // Try to use `Voices.ogg` if no other voices are found.
-      if (Assets.exists(Paths.voices(this.song.songId, ''))) result.push(Paths.voices(this.song.songId, '$suffix'));
+      if (Assets.exists(Paths.voices(this.song.id, ''))) result.push(Paths.voices(this.song.id, '$suffix'));
     }
     return result;
   }
@@ -442,7 +513,7 @@ class SongDifficulty
 
     if (voiceList.length == 0)
     {
-      trace('Could not find any voices for song ${this.song.songId}');
+      trace('Could not find any voices for song ${this.song.id}');
       return result;
     }
 
diff --git a/source/funkin/play/song/SongData.hx b/source/funkin/play/song/SongData.hx
deleted file mode 100644
index cef4c98f6..000000000
--- a/source/funkin/play/song/SongData.hx
+++ /dev/null
@@ -1,1021 +0,0 @@
-package funkin.play.song;
-
-import funkin.modding.events.ScriptEventDispatcher;
-import funkin.modding.events.ScriptEvent;
-import flixel.util.typeLimit.OneOfTwo;
-import funkin.modding.events.ScriptEvent;
-import funkin.modding.events.ScriptEventDispatcher;
-import funkin.play.song.ScriptedSong;
-import funkin.util.assets.DataAssets;
-import haxe.DynamicAccess;
-import haxe.Json;
-import openfl.utils.Assets;
-import thx.semver.Version;
-import funkin.util.SerializerUtil;
-
-/**
- * Contains utilities for loading and parsing stage data.
- */
-class SongDataParser
-{
-  /**
-   * A list containing all the songs available to the game.
-   */
-  static final songCache:Map<String, Song> = new Map<String, Song>();
-
-  static final DEFAULT_SONG_ID:String = 'UNKNOWN';
-  static final SONG_DATA_PATH:String = 'songs/';
-  static final MUSIC_DATA_PATH:String = 'music/';
-  static final SONG_DATA_SUFFIX:String = '-metadata.json';
-
-  /**
-   * Parses and preloads the game's song metadata and scripts when the game starts.
-   *
-   * If you want to force song metadata to be reloaded, you can just call this function again.
-   */
-  public static function loadSongCache():Void
-  {
-    clearSongCache();
-    trace('Loading song cache...');
-
-    //
-    // SCRIPTED SONGS
-    //
-    var scriptedSongClassNames:Array<String> = ScriptedSong.listScriptClasses();
-    trace('  Instantiating ${scriptedSongClassNames.length} scripted songs...');
-    for (songCls in scriptedSongClassNames)
-    {
-      var song:Song = ScriptedSong.init(songCls, DEFAULT_SONG_ID);
-      if (song != null)
-      {
-        trace('    Loaded scripted song: ${song.songId}');
-        songCache.set(song.songId, song);
-      }
-      else
-      {
-        trace('    Failed to instantiate scripted song class: ${songCls}');
-      }
-    }
-
-    //
-    // UNSCRIPTED SONGS
-    //
-    var songIdList:Array<String> = DataAssets.listDataFilesInPath(SONG_DATA_PATH, SONG_DATA_SUFFIX).map(function(songDataPath:String):String {
-      return songDataPath.split('/')[0];
-    });
-    var unscriptedSongIds:Array<String> = songIdList.filter(function(songId:String):Bool {
-      return !songCache.exists(songId);
-    });
-    trace('  Instantiating ${unscriptedSongIds.length} non-scripted songs...');
-    for (songId in unscriptedSongIds)
-    {
-      try
-      {
-        var song:Song = new Song(songId);
-        if (song != null)
-        {
-          trace('    Loaded song data: ${song.songId}');
-          songCache.set(song.songId, song);
-        }
-      }
-      catch (e)
-      {
-        trace('    An error occurred while loading song data: ${songId}');
-        trace(e);
-        // Assume error was already logged.
-        continue;
-      }
-    }
-
-    trace('  Successfully loaded ${Lambda.count(songCache)} stages.');
-  }
-
-  /**
-   * Retrieves a particular song from the cache.
-   * @param songId The ID of the song to retrieve.
-   * @return The song, or null if it was not found.
-   */
-  public static function fetchSong(songId:String):Null<Song>
-  {
-    if (songCache.exists(songId))
-    {
-      var song:Song = songCache.get(songId);
-      trace('Successfully fetch song: ${songId}');
-
-      var event:ScriptEvent = new ScriptEvent(ScriptEvent.CREATE, false);
-      ScriptEventDispatcher.callEvent(song, event);
-      return song;
-    }
-    else
-    {
-      trace('Failed to fetch song, not found in cache: ${songId}');
-      return null;
-    }
-  }
-
-  static function clearSongCache():Void
-  {
-    if (songCache != null)
-    {
-      songCache.clear();
-    }
-  }
-
-  /**
-   * A list of all the song IDs available to the game.
-   * @return The list of song IDs.
-   */
-  public static function listSongIds():Array<String>
-  {
-    return songCache.keys().array();
-  }
-
-  /**
-   * Loads the song metadata for a particular song.
-   * @param songId The ID of the song to load.
-   * @return The song metadata for each variation, or an empty array if the song was not found.
-   */
-  public static function loadSongMetadata(songId:String):Array<SongMetadata>
-  {
-    var result:Array<SongMetadata> = [];
-
-    var jsonStr:String = loadSongMetadataFile(songId);
-    var jsonData:Dynamic = SerializerUtil.fromJSON(jsonStr);
-
-    var songMetadata:SongMetadata = SongMigrator.migrateSongMetadata(jsonData, songId);
-    songMetadata = SongValidator.validateSongMetadata(songMetadata, songId);
-
-    if (songMetadata == null)
-    {
-      return result;
-    }
-
-    result.push(songMetadata);
-
-    var variations:Array<String> = songMetadata.playData.songVariations;
-
-    for (variation in variations)
-    {
-      var variationJsonStr:String = loadSongMetadataFile(songId, variation);
-      var variationJsonData:Dynamic = SerializerUtil.fromJSON(variationJsonStr);
-      var variationSongMetadata:SongMetadata = SongMigrator.migrateSongMetadata(variationJsonData, '${songId}:${variation}');
-      variationSongMetadata = SongValidator.validateSongMetadata(variationSongMetadata, '${songId}:${variation}');
-      if (variationSongMetadata != null)
-      {
-        variationSongMetadata.variation = variation;
-        result.push(variationSongMetadata);
-      }
-    }
-
-    return result;
-  }
-
-  static function loadSongMetadataFile(songPath:String, variation:String = ''):String
-  {
-    var songMetadataFilePath:String = (variation != '') ? Paths.json('$SONG_DATA_PATH$songPath/$songPath-metadata-$variation') : Paths.json('$SONG_DATA_PATH$songPath/$songPath-metadata');
-
-    var rawJson:String = Assets.getText(songMetadataFilePath).trim();
-
-    while (!rawJson.endsWith('}') && rawJson.length > 0)
-    {
-      rawJson = rawJson.substr(0, rawJson.length - 1);
-    }
-
-    return rawJson;
-  }
-
-  public static function parseMusicMetadata(musicId:String):SongMetadata
-  {
-    var rawJson:String = loadMusicMetadataFile(musicId);
-    var jsonData:Dynamic = null;
-    try
-    {
-      jsonData = Json.parse(rawJson);
-    }
-    catch (e) {}
-
-    var musicMetadata:SongMetadata = SongMigrator.migrateSongMetadata(jsonData, musicId);
-    musicMetadata = SongValidator.validateSongMetadata(musicMetadata, musicId);
-
-    return musicMetadata;
-  }
-
-  static function loadMusicMetadataFile(musicPath:String, variation:String = ''):String
-  {
-    var musicMetadataFilePath:String = (variation != '' || variation == "default") ? Paths.file('$MUSIC_DATA_PATH$musicPath/$musicPath-metadata-$variation.json') : Paths.file('$MUSIC_DATA_PATH$musicPath/$musicPath-metadata.json');
-
-    var rawJson:String = Assets.getText(musicMetadataFilePath).trim();
-
-    while (!rawJson.endsWith("}"))
-    {
-      rawJson = rawJson.substr(0, rawJson.length - 1);
-    }
-
-    return rawJson;
-  }
-
-  public static function parseSongChartData(songId:String, variation:String = ''):SongChartData
-  {
-    var rawJson:String = loadSongChartDataFile(songId, variation);
-    var jsonData:Dynamic = null;
-    try
-    {
-      jsonData = Json.parse(rawJson);
-    }
-    catch (e)
-    {
-      trace('Failed to parse song chart data: ${songId} (${variation})');
-      trace(e);
-    }
-
-    var songChartData:SongChartData = SongMigrator.migrateSongChartData(jsonData, songId);
-    songChartData = SongValidator.validateSongChartData(songChartData, songId);
-
-    if (songChartData == null)
-    {
-      trace('Failed to validate song chart data: ${songId}');
-      return null;
-    }
-
-    return songChartData;
-  }
-
-  static function loadSongChartDataFile(songPath:String, variation:String = ''):String
-  {
-    var songChartDataFilePath:String = (variation != '' && variation != 'default') ? Paths.json('$SONG_DATA_PATH$songPath/$songPath-chart-$variation') : Paths.json('$SONG_DATA_PATH$songPath/$songPath-chart');
-
-    var rawJson:String = Assets.getText(songChartDataFilePath).trim();
-
-    while (!rawJson.endsWith('}') && rawJson.length > 0)
-    {
-      rawJson = rawJson.substr(0, rawJson.length - 1);
-    }
-
-    return rawJson;
-  }
-}
-
-typedef RawSongMetadata =
-{
-  /**
-   * A semantic versioning string for the song data format.
-   *
-   */
-  var version:Version;
-
-  var songName:String;
-  var artist:String;
-  var timeFormat:SongTimeFormat;
-  var divisions:Null<Int>; // Optional field
-  var timeChanges:Array<SongTimeChange>;
-  var looped:Bool;
-  var playData:SongPlayData;
-  var generatedBy:String;
-
-  /**
-   * Defaults to `default` or `''`. Populated later.
-   */
-  var variation:String;
-};
-
-@:forward
-abstract SongMetadata(RawSongMetadata)
-{
-  public function new(songName:String, artist:String, variation:String = 'default')
-  {
-    this =
-      {
-        version: SongMigrator.CHART_VERSION,
-        songName: songName,
-        artist: artist,
-        timeFormat: 'ms',
-        divisions: null,
-        timeChanges: [new SongTimeChange(-1, 0, 100, 4, 4, [4, 4, 4, 4])],
-        looped: false,
-        playData:
-          {
-            songVariations: [],
-            difficulties: ['normal'],
-
-            playableChars:
-              {
-                bf: new SongPlayableChar('gf', 'dad'),
-              },
-
-            stage: 'mainStage',
-            noteSkin: 'Normal'
-          },
-        generatedBy: SongValidator.DEFAULT_GENERATEDBY,
-
-        // Variation ID.
-        variation: variation
-      };
-  }
-
-  public function clone(?newVariation:String = null):SongMetadata
-  {
-    var result:SongMetadata = new SongMetadata(this.songName, this.artist, newVariation == null ? this.variation : newVariation);
-    result.version = this.version;
-    result.timeFormat = this.timeFormat;
-    result.divisions = this.divisions;
-    result.timeChanges = this.timeChanges;
-    result.looped = this.looped;
-    result.playData = this.playData;
-    result.generatedBy = this.generatedBy;
-
-    return result;
-  }
-}
-
-typedef SongPlayData =
-{
-  var songVariations:Array<String>;
-  var difficulties:Array<String>;
-
-  /**
-   * Keys are the player characters and the values give info on what opponent/GF/inst to use.
-   */
-  var playableChars:DynamicAccess<SongPlayableChar>;
-
-  var stage:String;
-  var noteSkin:String;
-}
-
-typedef RawSongPlayableChar =
-{
-  var g:String;
-  var o:String;
-  var i:String;
-}
-
-typedef RawSongNoteData =
-{
-  /**
-   * The timestamp of the note. The timestamp is in the format of the song's time format.
-   */
-  var t:Float;
-
-  /**
-   * Data for the note. Represents the index on the strumline.
-   * 0 = left, 1 = down, 2 = up, 3 = right
-   * `floor(direction / strumlineSize)` specifies which strumline the note is on.
-   * 0 = player, 1 = opponent, etc.
-   */
-  var d:Int;
-
-  /**
-   * Length of the note, if applicable.
-   * Defaults to 0 for single notes.
-   */
-  var l:Float;
-
-  /**
-   * The kind of the note.
-   * This can allow the note to include information used for custom behavior.
-   * Defaults to blank or `"normal"`.
-   */
-  var k:String;
-}
-
-abstract SongNoteData(RawSongNoteData)
-{
-  public function new(time:Float, data:Int, length:Float = 0, kind:String = '')
-  {
-    this =
-      {
-        t: time,
-        d: data,
-        l: length,
-        k: kind
-      };
-  }
-
-  /**
-   * The timestamp of the note, in milliseconds.
-   */
-  public var time(get, set):Float;
-
-  function get_time():Float
-  {
-    return this.t;
-  }
-
-  function set_time(value:Float):Float
-  {
-    return this.t = value;
-  }
-
-  /**
-   * The timestamp of the note, in steps.
-   */
-  public var stepTime(get, never):Float;
-
-  function get_stepTime():Float
-  {
-    return Conductor.getTimeInSteps(abstract.time);
-  }
-
-  /**
-   * The raw data for the note.
-   */
-  public var data(get, set):Int;
-
-  function get_data():Int
-  {
-    return this.d;
-  }
-
-  function set_data(value:Int):Int
-  {
-    return this.d = value;
-  }
-
-  /**
-   * The direction of the note, if applicable.
-   * Strips the strumline index from the data.
-   *
-   * 0 = left, 1 = down, 2 = up, 3 = right
-   */
-  public inline function getDirection(strumlineSize:Int = 4):Int
-  {
-    return abstract.data % strumlineSize;
-  }
-
-  public function getDirectionName(strumlineSize:Int = 4):String
-  {
-    switch (abstract.data % strumlineSize)
-    {
-      case 0:
-        return 'Left';
-      case 1:
-        return 'Down';
-      case 2:
-        return 'Up';
-      case 3:
-        return 'Right';
-      default:
-        return 'Unknown';
-    }
-  }
-
-  /**
-   * The strumline index of the note, if applicable.
-   * Strips the direction from the data.
-   *
-   * 0 = player, 1 = opponent, etc.
-   */
-  public inline function getStrumlineIndex(strumlineSize:Int = 4):Int
-  {
-    return Math.floor(abstract.data / strumlineSize);
-  }
-
-  /**
-   * Returns true if the note is one that Boyfriend should try to hit (i.e. it's on his side).
-   * TODO: The name of this function is a little misleading; what about mines?
-   * @param strumlineSize Defaults to 4.
-   * @return True if it's Boyfriend's note.
-   */
-  public inline function getMustHitNote(strumlineSize:Int = 4):Bool
-  {
-    return getStrumlineIndex(strumlineSize) == 0;
-  }
-
-  /**
-   * If this is a hold note, this is the length of the hold note in milliseconds.
-   * @default 0 (not a hold note)
-   */
-  public var length(get, set):Float;
-
-  function get_length():Float
-  {
-    return this.l;
-  }
-
-  function set_length(value:Float):Float
-  {
-    return this.l = value;
-  }
-
-  /**
-   * If this is a hold note, this is the length of the hold note in steps.
-   * @default 0 (not a hold note)
-   */
-  public var stepLength(get, set):Float;
-
-  function get_stepLength():Float
-  {
-    return Conductor.getTimeInSteps(abstract.time + abstract.length) - abstract.stepTime;
-  }
-
-  function set_stepLength(value:Float):Float
-  {
-    return abstract.length = Conductor.getStepTimeInMs(value) - abstract.time;
-  }
-
-  public var isHoldNote(get, never):Bool;
-
-  public function get_isHoldNote():Bool
-  {
-    return this.l > 0;
-  }
-
-  public var kind(get, set):String;
-
-  function get_kind():String
-  {
-    if (this.k == null || this.k == '') return 'normal';
-
-    return this.k;
-  }
-
-  function set_kind(value:String):String
-  {
-    if (value == 'normal' || value == '') value = null;
-    return this.k = value;
-  }
-
-  @:op(A == B)
-  public function op_equals(other:SongNoteData):Bool
-  {
-    if (abstract.kind == '')
-    {
-      if (other.kind != '' && other.kind != 'normal') return false;
-    }
-    else
-    {
-      if (other.kind == '' || other.kind != abstract.kind) return false;
-    }
-
-    return abstract.time == other.time && abstract.data == other.data && abstract.length == other.length;
-  }
-
-  @:op(A != B)
-  public function op_notEquals(other:SongNoteData):Bool
-  {
-    if (abstract.kind == '')
-    {
-      if (other.kind != '' && other.kind != 'normal') return true;
-    }
-    else
-    {
-      if (other.kind == '' || other.kind != abstract.kind) return true;
-    }
-
-    return abstract.time != other.time || abstract.data != other.data || abstract.length != other.length;
-  }
-
-  @:op(A > B)
-  public function op_greaterThan(other:SongNoteData):Bool
-  {
-    return abstract.time > other.time;
-  }
-
-  @:op(A < B)
-  public function op_lessThan(other:SongNoteData):Bool
-  {
-    return this.t < other.time;
-  }
-
-  @:op(A >= B)
-  public function op_greaterThanOrEquals(other:SongNoteData):Bool
-  {
-    return this.t >= other.time;
-  }
-
-  @:op(A <= B)
-  public function op_lessThanOrEquals(other:SongNoteData):Bool
-  {
-    return this.t <= other.time;
-  }
-}
-
-typedef RawSongEventData =
-{
-  /**
-   * The timestamp of the event. The timestamp is in the format of the song's time format.
-   */
-  var t:Float;
-
-  /**
-   * The kind of the event.
-   * Examples include "FocusCamera" and "PlayAnimation"
-   * Custom events can be added by scripts with the `ScriptedSongEvent` class.
-   */
-  var e:String;
-
-  /**
-   * The data for the event.
-   * This can allow the event to include information used for custom behavior.
-   * Data type depends on the event kind. It can be anything that's JSON serializable.
-   */
-  var v:DynamicAccess<Dynamic>;
-
-  /**
-   * Whether this event has been activated.
-   * This is only used internally by the game. It should not be serialized.
-   */
-  @:optional var a:Bool;
-}
-
-abstract SongEventData(RawSongEventData)
-{
-  public function new(time:Float, event:String, value:Dynamic = null)
-  {
-    this =
-      {
-        t: time,
-        e: event,
-        v: value,
-        a: false
-      };
-  }
-
-  public var time(get, set):Float;
-
-  function get_time():Float
-  {
-    return this.t;
-  }
-
-  function set_time(value:Float):Float
-  {
-    return this.t = value;
-  }
-
-  public var stepTime(get, never):Float;
-
-  function get_stepTime():Float
-  {
-    return Conductor.getTimeInSteps(abstract.time);
-  }
-
-  public var event(get, set):String;
-
-  function get_event():String
-  {
-    return this.e;
-  }
-
-  function set_event(value:String):String
-  {
-    return this.e = value;
-  }
-
-  public var value(get, set):Dynamic;
-
-  function get_value():Dynamic
-  {
-    return this.v;
-  }
-
-  function set_value(value:Dynamic):Dynamic
-  {
-    return this.v = value;
-  }
-
-  public var activated(get, set):Bool;
-
-  function get_activated():Bool
-  {
-    return this.a;
-  }
-
-  function set_activated(value:Bool):Bool
-  {
-    return this.a = value;
-  }
-
-  public inline function getDynamic(key:String):Null<Dynamic>
-  {
-    return this.v.get(key);
-  }
-
-  public inline function getBool(key:String):Null<Bool>
-  {
-    return cast this.v.get(key);
-  }
-
-  public inline function getInt(key:String):Null<Int>
-  {
-    return cast this.v.get(key);
-  }
-
-  public inline function getFloat(key:String):Null<Float>
-  {
-    return cast this.v.get(key);
-  }
-
-  public inline function getString(key:String):String
-  {
-    return cast this.v.get(key);
-  }
-
-  public inline function getArray(key:String):Array<Dynamic>
-  {
-    return cast this.v.get(key);
-  }
-
-  public inline function getBoolArray(key:String):Array<Bool>
-  {
-    return cast this.v.get(key);
-  }
-
-  @:op(A == B)
-  public function op_equals(other:SongEventData):Bool
-  {
-    return this.t == other.time && this.e == other.event && this.v == other.value;
-  }
-
-  @:op(A != B)
-  public function op_notEquals(other:SongEventData):Bool
-  {
-    return this.t != other.time || this.e != other.event || this.v != other.value;
-  }
-
-  @:op(A > B)
-  public function op_greaterThan(other:SongEventData):Bool
-  {
-    return this.t > other.time;
-  }
-
-  @:op(A < B)
-  public function op_lessThan(other:SongEventData):Bool
-  {
-    return this.t < other.time;
-  }
-
-  @:op(A >= B)
-  public function op_greaterThanOrEquals(other:SongEventData):Bool
-  {
-    return this.t >= other.time;
-  }
-
-  @:op(A <= B)
-  public function op_lessThanOrEquals(other:SongEventData):Bool
-  {
-    return this.t <= other.time;
-  }
-}
-
-abstract SongPlayableChar(RawSongPlayableChar)
-{
-  public function new(girlfriend:String, opponent:String, inst:String = '')
-  {
-    this =
-      {
-        g: girlfriend,
-        o: opponent,
-        i: inst
-      };
-  }
-
-  public var girlfriend(get, set):String;
-
-  function get_girlfriend():String
-  {
-    return this.g;
-  }
-
-  function set_girlfriend(value:String):String
-  {
-    return this.g = value;
-  }
-
-  public var opponent(get, set):String;
-
-  function get_opponent():String
-  {
-    return this.o;
-  }
-
-  function set_opponent(value:String):String
-  {
-    return this.o = value;
-  }
-
-  public var inst(get, set):String;
-
-  function get_inst():String
-  {
-    return this.i;
-  }
-
-  function set_inst(value:String):String
-  {
-    return this.i = value;
-  }
-}
-
-typedef RawSongChartData =
-{
-  var version:Version;
-
-  var scrollSpeed:DynamicAccess<Float>;
-  var events:Array<SongEventData>;
-  var notes:DynamicAccess<Array<SongNoteData>>;
-  var generatedBy:String;
-};
-
-@:forward
-abstract SongChartData(RawSongChartData)
-{
-  public function new(scrollSpeed:Float, events:Array<SongEventData>, notes:Array<SongNoteData>)
-  {
-    this =
-      {
-        version: SongMigrator.CHART_VERSION,
-
-        events: events,
-        notes:
-          {
-            normal: notes
-          },
-        scrollSpeed:
-          {
-            normal: scrollSpeed
-          },
-        generatedBy: SongValidator.DEFAULT_GENERATEDBY
-      }
-  }
-
-  public function getScrollSpeed(diff:String = 'default'):Float
-  {
-    var result:Float = this.scrollSpeed.get(diff);
-
-    if (result == 0.0 && diff != 'default') return getScrollSpeed('default');
-
-    return (result == 0.0) ? 1.0 : result;
-  }
-
-  public function setScrollSpeed(value:Float, diff:String = 'default'):Float
-  {
-    return this.scrollSpeed.set(diff, value);
-  }
-
-  public function getNotes(diff:String):Array<SongNoteData>
-  {
-    var result:Array<SongNoteData> = this.notes.get(diff);
-
-    if (result == null && diff != 'normal') return getNotes('normal');
-
-    return (result == null) ? [] : result;
-  }
-
-  public function setNotes(value:Array<SongNoteData>, diff:String):Array<SongNoteData>
-  {
-    return this.notes.set(diff, value);
-  }
-
-  public function getEvents():Array<SongEventData>
-  {
-    return this.events;
-  }
-
-  public function setEvents(value:Array<SongEventData>):Array<SongEventData>
-  {
-    return this.events = value;
-  }
-}
-
-typedef RawSongTimeChange =
-{
-  /**
-   * Timestamp in specified `timeFormat`.
-   */
-  var t:Float;
-
-  /**
-   * Time in beats (int). The game will calculate further beat values based on this one,
-   * so it can do it in a simple linear fashion.
-   */
-  var b:Null<Float>;
-
-  /**
-   * Quarter notes per minute (float). Cannot be empty in the first element of the list,
-   * but otherwise it's optional, and defaults to the value of the previous element.
-   */
-  var bpm:Float;
-
-  /**
-   * Time signature numerator (int). Optional, defaults to 4.
-   */
-  var n:Int;
-
-  /**
-   * Time signature denominator (int). Optional, defaults to 4. Should only ever be a power of two.
-   */
-  var d:Int;
-
-  /**
-   * Beat tuplets (Array<int> or int). This defines how many steps each beat is divided into.
-   * It can either be an array of length `n` (see above) or a single integer number.
-   * Optional, defaults to `[4]`.
-   */
-  var bt:OneOfTwo<Int, Array<Int>>;
-}
-
-/**
- * Add aliases to the minimalized property names of the typedef,
- * to improve readability.
- */
-abstract SongTimeChange(RawSongTimeChange) from RawSongTimeChange
-{
-  public function new(timeStamp:Float, ?beatTime:Float, bpm:Float, timeSignatureNum:Int = 4, timeSignatureDen:Int = 4, beatTuplets:Array<Int>)
-  {
-    this =
-      {
-        t: timeStamp,
-        b: beatTime,
-        bpm: bpm,
-        n: timeSignatureNum,
-        d: timeSignatureDen,
-        bt: beatTuplets,
-      }
-  }
-
-  public var timeStamp(get, set):Float;
-
-  function get_timeStamp():Float
-  {
-    return this.t;
-  }
-
-  function set_timeStamp(value:Float):Float
-  {
-    return this.t = value;
-  }
-
-  public var beatTime(get, set):Null<Float>;
-
-  public function get_beatTime():Null<Float>
-  {
-    return this.b;
-  }
-
-  public function set_beatTime(value:Null<Float>):Null<Float>
-  {
-    return this.b = value;
-  }
-
-  public var bpm(get, set):Float;
-
-  function get_bpm():Float
-  {
-    return this.bpm;
-  }
-
-  function set_bpm(value:Float):Float
-  {
-    return this.bpm = value;
-  }
-
-  public var timeSignatureNum(get, set):Int;
-
-  function get_timeSignatureNum():Int
-  {
-    return this.n;
-  }
-
-  function set_timeSignatureNum(value:Int):Int
-  {
-    return this.n = value;
-  }
-
-  public var timeSignatureDen(get, set):Int;
-
-  function get_timeSignatureDen():Int
-  {
-    return this.d;
-  }
-
-  function set_timeSignatureDen(value:Int):Int
-  {
-    return this.d = value;
-  }
-
-  public var beatTuplets(get, set):Array<Int>;
-
-  function get_beatTuplets():Array<Int>
-  {
-    if (Std.isOfType(this.bt, Int))
-    {
-      return [this.bt];
-    }
-    else
-    {
-      return this.bt;
-    }
-  }
-
-  function set_beatTuplets(value:Array<Int>):Array<Int>
-  {
-    return this.bt = value;
-  }
-}
-
-enum abstract SongTimeFormat(String) from String to String
-{
-  var TICKS = 'ticks';
-  var FLOAT = 'float';
-  var MILLISECONDS = 'ms';
-}
diff --git a/source/funkin/play/song/SongMigrator.hx b/source/funkin/play/song/SongMigrator.hx
index f33d9bbe9..43393fa4e 100644
--- a/source/funkin/play/song/SongMigrator.hx
+++ b/source/funkin/play/song/SongMigrator.hx
@@ -1,11 +1,11 @@
 package funkin.play.song;
 
 import funkin.play.song.formats.FNFLegacy;
-import funkin.play.song.SongData.SongChartData;
-import funkin.play.song.SongData.SongEventData;
-import funkin.play.song.SongData.SongMetadata;
-import funkin.play.song.SongData.SongNoteData;
-import funkin.play.song.SongData.SongPlayableChar;
+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
@@ -176,7 +176,7 @@ class SongMigrator
     songMetadata.playData.songVariations = [];
 
     // Set the song's song variations.
-    songMetadata.playData.playableChars = {};
+    songMetadata.playData.playableChars = [];
     try
     {
       songMetadata.playData.playableChars.set(songData.song.player1, new SongPlayableChar('', songData.song.player2));
@@ -203,7 +203,7 @@ class SongMigrator
 
     var songData:FNFLegacy = cast jsonData;
 
-    var songChartData:SongChartData = new SongChartData(1.0, [], []);
+    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));
diff --git a/source/funkin/play/song/SongSerializer.hx b/source/funkin/play/song/SongSerializer.hx
index a08b722da..a0a468c5b 100644
--- a/source/funkin/play/song/SongSerializer.hx
+++ b/source/funkin/play/song/SongSerializer.hx
@@ -1,7 +1,7 @@
 package funkin.play.song;
 
-import funkin.play.song.SongData.SongChartData;
-import funkin.play.song.SongData.SongMetadata;
+import funkin.data.song.SongData.SongChartData;
+import funkin.data.song.SongData.SongMetadata;
 import funkin.util.SerializerUtil;
 import lime.utils.Bytes;
 import openfl.events.Event;
diff --git a/source/funkin/play/song/SongValidator.hx b/source/funkin/play/song/SongValidator.hx
index 11cc758b9..e33ddd87c 100644
--- a/source/funkin/play/song/SongValidator.hx
+++ b/source/funkin/play/song/SongValidator.hx
@@ -1,10 +1,11 @@
 package funkin.play.song;
 
-import funkin.play.song.SongData.SongChartData;
-import funkin.play.song.SongData.SongMetadata;
-import funkin.play.song.SongData.SongPlayData;
-import funkin.play.song.SongData.SongTimeChange;
-import funkin.play.song.SongData.SongTimeFormat;
+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,
@@ -59,7 +60,7 @@ class SongValidator
     }
     if (input.generatedBy == null)
     {
-      input.generatedBy = DEFAULT_GENERATEDBY;
+      input.generatedBy = SongRegistry.DEFAULT_GENERATEDBY;
     }
 
     input.timeChanges = validateTimeChanges(input.timeChanges, songId);
diff --git a/source/funkin/ui/debug/charting/ChartEditorCommand.hx b/source/funkin/ui/debug/charting/ChartEditorCommand.hx
index f0ecb573b..79f58a098 100644
--- a/source/funkin/ui/debug/charting/ChartEditorCommand.hx
+++ b/source/funkin/ui/debug/charting/ChartEditorCommand.hx
@@ -1,8 +1,8 @@
 package funkin.ui.debug.charting;
 
-import funkin.play.song.SongData.SongEventData;
-import funkin.play.song.SongData.SongNoteData;
-import funkin.play.song.SongDataUtils;
+import funkin.data.song.SongData.SongEventData;
+import funkin.data.song.SongData.SongNoteData;
+import funkin.data.song.SongDataUtils;
 
 using Lambda;
 
diff --git a/source/funkin/ui/debug/charting/ChartEditorDialogHandler.hx b/source/funkin/ui/debug/charting/ChartEditorDialogHandler.hx
index 59bee0d74..f24169810 100644
--- a/source/funkin/ui/debug/charting/ChartEditorDialogHandler.hx
+++ b/source/funkin/ui/debug/charting/ChartEditorDialogHandler.hx
@@ -3,8 +3,8 @@ package funkin.ui.debug.charting;
 import funkin.play.character.CharacterData;
 import funkin.util.Constants;
 import funkin.util.SerializerUtil;
-import funkin.play.song.SongData.SongChartData;
-import funkin.play.song.SongData.SongMetadata;
+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;
@@ -14,9 +14,9 @@ import funkin.play.character.CharacterData.CharacterDataParser;
 import funkin.play.song.Song;
 import funkin.play.song.SongMigrator;
 import funkin.play.song.SongValidator;
-import funkin.play.song.SongData.SongDataParser;
-import funkin.play.song.SongData.SongPlayableChar;
-import funkin.play.song.SongData.SongTimeChange;
+import funkin.data.song.SongRegistry;
+import funkin.data.song.SongData.SongPlayableChar;
+import funkin.data.song.SongData.SongTimeChange;
 import funkin.util.FileUtil;
 import haxe.io.Path;
 import haxe.ui.components.Button;
@@ -113,23 +113,17 @@ class ChartEditorDialogHandler
     var splashTemplateContainer:Null<VBox> = dialog.findComponent('splashTemplateContainer', VBox);
     if (splashTemplateContainer == null) throw 'Could not locate splashTemplateContainer in Welcome dialog';
 
-    var songList:Array<String> = SongDataParser.listSongIds();
+    var songList:Array<String> = SongRegistry.instance.listEntryIds();
     songList.sort(SortUtil.alphabetically);
 
     for (targetSongId in songList)
     {
-      var songData:Null<Song> = SongDataParser.fetchSong(targetSongId);
-
+      var songData:Null<Song> = SongRegistry.instance.fetchEntry(targetSongId);
       if (songData == null) continue;
 
-      var diffNormal:Null<SongDifficulty> = songData.getDifficulty('normal');
-      var songName:Null<String> = diffNormal?.songName;
-      if (songName == null)
-      {
-        var diffDefault:Null<SongDifficulty> = songData.getDifficulty();
-        songName = diffDefault?.songName;
-      }
-      if (songName == null)
+      var songName:Null<String> = songData.getDifficulty('normal')?.songName;
+      if (songName == null) songName = songData.getDifficulty()?.songName;
+      if (songName == null) // Still null?
       {
         trace('[WARN] Could not fetch song name for ${targetSongId}');
         continue;
@@ -509,9 +503,9 @@ class ChartEditorDialogHandler
     if (dialogNoteSkin == null) throw 'Could not locate dialogNoteSkin DropDown in Song Metadata dialog';
     dialogNoteSkin.onChange = function(event:UIEvent) {
       if (event.data.id == null) return;
-      state.currentSongMetadata.playData.noteSkin = event.data.id;
+      state.currentSongNoteSkin = event.data.id;
     };
-    state.currentSongMetadata.playData.noteSkin = 'funkin';
+    state.currentSongNoteSkin = 'funkin';
 
     var dialogBPM:Null<NumberStepper> = dialog.findComponent('dialogBPM', NumberStepper);
     if (dialogBPM == null) throw 'Could not locate dialogBPM NumberStepper in Song Metadata dialog';
@@ -521,7 +515,7 @@ class ChartEditorDialogHandler
       var timeChanges:Array<SongTimeChange> = state.currentSongMetadata.timeChanges;
       if (timeChanges == null || timeChanges.length == 0)
       {
-        timeChanges = [new SongTimeChange(-1, 0, event.value, 4, 4, [4, 4, 4, 4])];
+        timeChanges = [new SongTimeChange(0, event.value)];
       }
       else
       {
@@ -544,7 +538,7 @@ class ChartEditorDialogHandler
     };
 
     // Empty the character list.
-    state.currentSongMetadata.playData.playableChars = {};
+    state.currentSongMetadata.playData.playableChars = [];
     // Add at least one character group with no Remove button.
     dialogCharGrid.addComponent(buildCharGroup(state, 'bf'));
 
@@ -559,7 +553,8 @@ class ChartEditorDialogHandler
   {
     var groupKey:String = key;
 
-    var getCharData:Void->SongPlayableChar = function() {
+    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);
@@ -571,46 +566,53 @@ class ChartEditorDialogHandler
       return result;
     }
 
-    var moveCharGroup:String->Void = function(target:String) {
-      var charData = getCharData();
+    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() {
+    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:SongPlayableChar = getCharData();
+    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) {
+    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) {
+    charGroupOpponent.onChange = function(event:UIEvent):Void {
+      if (charData == null) return;
       charData.opponent = event.data.id;
     };
-    charGroupOpponent.value = getCharData().opponent;
+    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) {
+    charGroupGirlfriend.onChange = function(event:UIEvent):Void {
+      if (charData == null) return;
       charData.girlfriend = event.data.id;
     };
-    charGroupGirlfriend.value = getCharData().girlfriend;
+    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) {
+    charGroupRemove.onClick = function(event:UIEvent):Void {
       removeGroup();
     };
 
diff --git a/source/funkin/ui/debug/charting/ChartEditorEventSprite.hx b/source/funkin/ui/debug/charting/ChartEditorEventSprite.hx
index 4ee6eda9f..af1b605a0 100644
--- a/source/funkin/ui/debug/charting/ChartEditorEventSprite.hx
+++ b/source/funkin/ui/debug/charting/ChartEditorEventSprite.hx
@@ -1,6 +1,6 @@
 package funkin.ui.debug.charting;
 
-import funkin.play.event.SongEventData.SongEventParser;
+import funkin.data.event.SongEventData.SongEventParser;
 import flixel.graphics.frames.FlxAtlasFrames;
 import openfl.display.BitmapData;
 import openfl.utils.Assets;
@@ -10,7 +10,7 @@ import flixel.FlxSprite;
 import flixel.graphics.frames.FlxFramesCollection;
 import flixel.graphics.frames.FlxTileFrames;
 import flixel.math.FlxPoint;
-import funkin.play.song.SongData.SongEventData;
+import funkin.data.song.SongData.SongEventData;
 
 /**
  * A event sprite that can be used to display a song event in a chart.
diff --git a/source/funkin/ui/debug/charting/ChartEditorHoldNoteSprite.hx b/source/funkin/ui/debug/charting/ChartEditorHoldNoteSprite.hx
index ebf65c001..d64cc33a1 100644
--- a/source/funkin/ui/debug/charting/ChartEditorHoldNoteSprite.hx
+++ b/source/funkin/ui/debug/charting/ChartEditorHoldNoteSprite.hx
@@ -8,7 +8,7 @@ import flixel.graphics.frames.FlxFramesCollection;
 import flixel.graphics.frames.FlxTileFrames;
 import flixel.math.FlxPoint;
 import funkin.play.notes.SustainTrail;
-import funkin.play.song.SongData.SongNoteData;
+import funkin.data.song.SongData.SongNoteData;
 
 /**
  * A hold note sprite that can be used to display a note in a chart.
diff --git a/source/funkin/ui/debug/charting/ChartEditorNotePreview.hx b/source/funkin/ui/debug/charting/ChartEditorNotePreview.hx
index be45676f2..6119141cc 100644
--- a/source/funkin/ui/debug/charting/ChartEditorNotePreview.hx
+++ b/source/funkin/ui/debug/charting/ChartEditorNotePreview.hx
@@ -1,7 +1,7 @@
 package funkin.ui.debug.charting;
 
-import funkin.play.song.SongData.SongEventData;
-import funkin.play.song.SongData.SongNoteData;
+import funkin.data.song.SongData.SongEventData;
+import funkin.data.song.SongData.SongNoteData;
 import flixel.math.FlxMath;
 import flixel.FlxSprite;
 import flixel.util.FlxColor;
diff --git a/source/funkin/ui/debug/charting/ChartEditorNoteSprite.hx b/source/funkin/ui/debug/charting/ChartEditorNoteSprite.hx
index 10e0f9045..c8fe8f598 100644
--- a/source/funkin/ui/debug/charting/ChartEditorNoteSprite.hx
+++ b/source/funkin/ui/debug/charting/ChartEditorNoteSprite.hx
@@ -7,7 +7,7 @@ import flixel.graphics.frames.FlxAtlasFrames;
 import flixel.graphics.frames.FlxFrame;
 import flixel.graphics.frames.FlxTileFrames;
 import flixel.math.FlxPoint;
-import funkin.play.song.SongData.SongNoteData;
+import funkin.data.song.SongData.SongNoteData;
 
 /**
  * A note sprite that can be used to display a note in a chart.
diff --git a/source/funkin/ui/debug/charting/ChartEditorState.hx b/source/funkin/ui/debug/charting/ChartEditorState.hx
index c0cb473e2..c0d99dabc 100644
--- a/source/funkin/ui/debug/charting/ChartEditorState.hx
+++ b/source/funkin/ui/debug/charting/ChartEditorState.hx
@@ -36,13 +36,13 @@ import funkin.play.notes.NoteSprite;
 import funkin.play.notes.Strumline;
 import funkin.play.PlayState;
 import funkin.play.song.Song;
-import funkin.play.song.SongData.SongChartData;
-import funkin.play.song.SongData.SongDataParser;
-import funkin.play.song.SongData.SongEventData;
-import funkin.play.song.SongData.SongMetadata;
-import funkin.play.song.SongData.SongNoteData;
-import funkin.play.song.SongData.SongPlayableChar;
-import funkin.play.song.SongDataUtils;
+import funkin.data.song.SongData.SongChartData;
+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.SongDataUtils;
 import funkin.ui.debug.charting.ChartEditorCommand;
 import funkin.ui.debug.charting.ChartEditorCommand;
 import funkin.ui.debug.charting.ChartEditorThemeHandler.ChartEditorTheme;
@@ -865,7 +865,7 @@ class ChartEditorState extends HaxeUIState
     var result:Null<SongChartData> = songChartData.get(selectedVariation);
     if (result == null)
     {
-      result = new SongChartData(1.0, [], []);
+      result = new SongChartData(["normal" => 1.0], [], ["normal" => []]);
       songChartData.set(selectedVariation, result);
     }
     return result;
@@ -2904,6 +2904,7 @@ class ChartEditorState extends HaxeUIState
     // If this gets too big, something needs to be optimized somewhere! -Eric
     FlxG.watch.addQuick("tapNotesRendered", renderedNotes.members.length);
     FlxG.watch.addQuick("holdNotesRendered", renderedHoldNotes.members.length);
+    FlxG.watch.addQuick("eventsRendered", renderedEvents.members.length);
   }
 
   function buildSelectionSquare():FlxSprite
@@ -4037,7 +4038,7 @@ class ChartEditorState extends HaxeUIState
    */
   public function loadSongAsTemplate(songId:String):Void
   {
-    var song:Null<Song> = SongDataParser.fetchSong(songId);
+    var song:Null<Song> = SongRegistry.instance.fetchEntry(songId);
 
     if (song == null) return;
 
@@ -4052,26 +4053,14 @@ class ChartEditorState extends HaxeUIState
       var variation = (metadata.variation == null || metadata.variation == '') ? 'default' : metadata.variation;
 
       // Clone to prevent modifying the original.
-      var metadataClone = Reflect.copy(metadata);
+      var metadataClone:SongMetadata = metadata.clone(variation);
       if (metadataClone != null) songMetadata.set(variation, metadataClone);
 
-      songChartData.set(variation, SongDataParser.parseSongChartData(songId, metadata.variation));
+      songChartData.set(variation, SongRegistry.instance.parseEntryChartData(songId, metadata.variation));
     }
 
     loadSong(songMetadata, songChartData);
 
-    notePreviewDirty = true;
-    notePreviewViewportBoundsDirty = true;
-
-    if (audioInstTrack != null)
-    {
-      audioInstTrack.stop();
-      audioInstTrack = null;
-    }
-
-    Conductor.forceBPM(null); // Disable the forced BPM.
-    Conductor.mapTimeChanges(currentSongMetadata.timeChanges);
-
     sortChartData();
 
     clearVocals();
@@ -4117,6 +4106,8 @@ class ChartEditorState extends HaxeUIState
     Conductor.forceBPM(null); // Disable the forced BPM.
     Conductor.mapTimeChanges(currentSongMetadata.timeChanges);
 
+    notePreviewDirty = true;
+    notePreviewViewportBoundsDirty = true;
     difficultySelectDirty = true;
     opponentPreviewDirty = true;
     playerPreviewDirty = true;
diff --git a/source/funkin/ui/debug/charting/ChartEditorToolboxHandler.hx b/source/funkin/ui/debug/charting/ChartEditorToolboxHandler.hx
index f67a69112..7833e19fd 100644
--- a/source/funkin/ui/debug/charting/ChartEditorToolboxHandler.hx
+++ b/source/funkin/ui/debug/charting/ChartEditorToolboxHandler.hx
@@ -4,8 +4,8 @@ import haxe.ui.containers.TreeView;
 import haxe.ui.containers.TreeViewNode;
 import funkin.play.character.BaseCharacter.CharacterType;
 import funkin.play.event.SongEvent;
-import funkin.play.event.SongEventData;
-import funkin.play.song.SongData.SongTimeChange;
+import funkin.data.event.SongEventData;
+import funkin.data.song.SongData.SongTimeChange;
 import funkin.play.song.SongSerializer;
 import funkin.ui.haxeui.components.CharacterPlayer;
 import haxe.ui.components.Button;
@@ -569,9 +569,9 @@ class ChartEditorToolboxHandler
     if (inputNoteSkin == null) throw 'ChartEditorToolboxHandler.buildToolboxMetadataLayout() - Could not find inputNoteSkin component.';
     inputNoteSkin.onChange = function(event:UIEvent) {
       if ((event?.data?.id ?? null) == null) return;
-      state.currentSongMetadata.playData.noteSkin = event.data.id;
+      state.currentSongNoteSkin = event.data.id;
     };
-    inputNoteSkin.value = state.currentSongMetadata.playData.noteSkin;
+    inputNoteSkin.value = state.currentSongNoteSkin;
 
     var inputBPM:Null<NumberStepper> = toolbox.findComponent('inputBPM', NumberStepper);
     if (inputBPM == null) throw 'ChartEditorToolboxHandler.buildToolboxMetadataLayout() - Could not find inputBPM component.';
@@ -581,7 +581,7 @@ class ChartEditorToolboxHandler
       var timeChanges:Array<SongTimeChange> = state.currentSongMetadata.timeChanges;
       if (timeChanges == null || timeChanges.length == 0)
       {
-        timeChanges = [new SongTimeChange(-1, 0, event.value, 4, 4, [4, 4, 4, 4])];
+        timeChanges = [new SongTimeChange(0, event.value)];
       }
       else
       {
diff --git a/source/funkin/ui/story/Level.hx b/source/funkin/ui/story/Level.hx
index 764606bf3..fd2e3ea49 100644
--- a/source/funkin/ui/story/Level.hx
+++ b/source/funkin/ui/story/Level.hx
@@ -4,6 +4,7 @@ import flixel.FlxSprite;
 import flixel.util.FlxColor;
 import funkin.play.song.Song;
 import funkin.data.IRegistryEntry;
+import funkin.data.song.SongRegistry;
 import funkin.data.level.LevelRegistry;
 import funkin.data.level.LevelData;
 
@@ -70,17 +71,20 @@ class Level implements IRegistryEntry<LevelData>
   public function getSongDisplayNames(difficulty:String):Array<String>
   {
     var songList:Array<String> = getSongs() ?? [];
-    var songNameList:Array<String> = songList.map(function(songId) {
-      var song:Song = funkin.play.song.SongData.SongDataParser.fetchSong(songId);
-      if (song == null) return 'Unknown';
-      var songDifficulty:SongDifficulty = song.getDifficulty(difficulty);
-      if (songDifficulty == null) songDifficulty = song.getDifficulty();
-      var songName:String = songDifficulty?.songName;
-      return songName ?? 'Unknown';
+    var songNameList:Array<String> = songList.map(function(songId:String) {
+      return getSongDisplayName(songId, difficulty);
     });
     return songNameList;
   }
 
+  static function getSongDisplayName(songId:String, difficulty:String):String
+  {
+    var song:Null<Song> = SongRegistry.instance.fetchEntry(songId);
+    if (song == null) return 'Unknown';
+
+    return song.songName;
+  }
+
   /**
    * Whether this level is unlocked. If not, it will be greyed out on the menu and have a lock icon.
    * TODO: Change this behavior in a later release.
@@ -141,7 +145,7 @@ class Level implements IRegistryEntry<LevelData>
     var songList = getSongs();
 
     var firstSongId:String = songList[0];
-    var firstSong:Song = funkin.play.song.SongData.SongDataParser.fetchSong(firstSongId);
+    var firstSong:Song = SongRegistry.instance.fetchEntry(firstSongId);
 
     if (firstSong != null)
     {
@@ -155,7 +159,7 @@ class Level implements IRegistryEntry<LevelData>
     for (songIndex in 1...songList.length)
     {
       var songId:String = songList[songIndex];
-      var song:Song = funkin.play.song.SongData.SongDataParser.fetchSong(songId);
+      var song:Song = SongRegistry.instance.fetchEntry(songId);
 
       if (song == null) continue;
 
@@ -200,7 +204,7 @@ class Level implements IRegistryEntry<LevelData>
     return 'Level($id)';
   }
 
-  public function _fetchData(id:String):Null<LevelData>
+  static function _fetchData(id:String):Null<LevelData>
   {
     return LevelRegistry.instance.parseEntryDataWithMigration(id, LevelRegistry.instance.fetchEntryVersion(id));
   }
diff --git a/source/funkin/ui/story/StoryMenuState.hx b/source/funkin/ui/story/StoryMenuState.hx
index 34dd49e22..3a5a388a8 100644
--- a/source/funkin/ui/story/StoryMenuState.hx
+++ b/source/funkin/ui/story/StoryMenuState.hx
@@ -16,8 +16,8 @@ import funkin.modding.events.ScriptEventDispatcher;
 import funkin.play.PlayState;
 import funkin.play.PlayStatePlaylist;
 import funkin.play.song.Song;
-import funkin.play.song.SongData.SongMetadata;
-import funkin.play.song.SongData.SongDataParser;
+import funkin.data.song.SongData.SongMusicData;
+import funkin.data.song.SongRegistry;
 
 class StoryMenuState extends MusicBeatState
 {
@@ -209,8 +209,11 @@ class StoryMenuState extends MusicBeatState
   {
     if (FlxG.sound.music == null || !FlxG.sound.music.playing)
     {
-      var freakyMenuMetadata:SongMetadata = SongDataParser.parseMusicMetadata('freakyMenu');
-      Conductor.mapTimeChanges(freakyMenuMetadata.timeChanges);
+      var freakyMenuMetadata:Null<SongMusicData> = SongRegistry.instance.parseMusicData('freakyMenu');
+      if (freakyMenuMetadata != null)
+      {
+        Conductor.mapTimeChanges(freakyMenuMetadata.timeChanges);
+      }
 
       FlxG.sound.playMusic(Paths.music('freakyMenu/freakyMenu'), 0);
       FlxG.sound.music.fadeIn(4, 0, 0.7);
@@ -514,7 +517,7 @@ class StoryMenuState extends MusicBeatState
 
     var targetSongId:String = PlayStatePlaylist.playlistSongIds.shift();
 
-    var targetSong:Song = SongDataParser.fetchSong(targetSongId);
+    var targetSong:Song = SongRegistry.instance.fetchEntry(targetSongId);
 
     PlayStatePlaylist.campaignId = currentLevel.id;
     PlayStatePlaylist.campaignTitle = currentLevel.getTitle();
diff --git a/source/funkin/ui/title/TitleState.hx b/source/funkin/ui/title/TitleState.hx
index 8b832f789..313c578a3 100644
--- a/source/funkin/ui/title/TitleState.hx
+++ b/source/funkin/ui/title/TitleState.hx
@@ -12,8 +12,8 @@ import flixel.util.FlxTimer;
 import funkin.audiovis.SpectogramSprite;
 import funkin.shaderslmfao.ColorSwap;
 import funkin.shaderslmfao.LeftMaskShader;
-import funkin.play.song.SongData.SongDataParser;
-import funkin.play.song.SongData.SongMetadata;
+import funkin.data.song.SongRegistry;
+import funkin.data.song.SongData.SongMusicData;
 import funkin.shaderslmfao.TitleOutline;
 import funkin.ui.AtlasText;
 import openfl.Assets;
@@ -216,9 +216,11 @@ class TitleState extends MusicBeatState
   {
     if (FlxG.sound.music == null || !FlxG.sound.music.playing)
     {
-      var freakyMenuMetadata:SongMetadata = SongDataParser.parseMusicMetadata('freakyMenu');
-      Conductor.mapTimeChanges(freakyMenuMetadata.timeChanges);
-
+      var freakyMenuMetadata:Null<SongMusicData> = SongRegistry.instance.parseMusicData('freakyMenu');
+      if (freakyMenuMetadata != null)
+      {
+        Conductor.mapTimeChanges(freakyMenuMetadata.timeChanges);
+      }
       FlxG.sound.playMusic(Paths.music('freakyMenu/freakyMenu'), 0);
       FlxG.sound.music.fadeIn(4, 0, 0.7);
     }
diff --git a/source/funkin/util/SortUtil.hx b/source/funkin/util/SortUtil.hx
index df45e0717..0e96f7ec8 100644
--- a/source/funkin/util/SortUtil.hx
+++ b/source/funkin/util/SortUtil.hx
@@ -1,5 +1,6 @@
 package funkin.util;
 
+import flixel.graphics.frames.FlxFrame;
 #if !macro
 import flixel.FlxBasic;
 import flixel.util.FlxSort;
@@ -41,6 +42,16 @@ class SortUtil
     return FlxSort.byValues(order, a.noteData.time, b.noteData.time);
   }
 
+  /**
+   * Given two FlxFrames, sort their names alphabetically.
+   *
+   * @param order Either `FlxSort.ASCENDING` or `FlxSort.DESCENDING`
+   */
+  public static inline function byFrameName(a:FlxFrame, b:FlxFrame)
+  {
+    return alphabetically(a.name, b.name);
+  }
+
   /**
    * Sort predicate for sorting strings alphabetically.
    * @param a The first string to compare.
diff --git a/source/funkin/util/VersionUtil.hx b/source/funkin/util/VersionUtil.hx
index 368bb12de..1dc00473a 100644
--- a/source/funkin/util/VersionUtil.hx
+++ b/source/funkin/util/VersionUtil.hx
@@ -51,8 +51,9 @@ class VersionUtil
    * @param input The JSON string to parse.
    * @return The semantic version, or null if it could not be parsed.
    */
-  public static function getVersionFromJSON(input:String):Null<thx.semver.Version>
+  public static function getVersionFromJSON(input:Null<String>):Null<thx.semver.Version>
   {
+    if (input == null) return null;
     var parsed = SerializerUtil.fromJSON(input);
     if (parsed == null) return null;
     if (parsed.version == null) return null;
diff --git a/source/funkin/util/tools/IteratorTools.hx b/source/funkin/util/tools/IteratorTools.hx
index 9279c1227..49d9477f6 100644
--- a/source/funkin/util/tools/IteratorTools.hx
+++ b/source/funkin/util/tools/IteratorTools.hx
@@ -13,4 +13,22 @@ class IteratorTools
   {
     return [for (i in iterator) i];
   }
+
+  public static function count<T>(iterator:Iterator<T>, ?predicate:(item:T) -> Bool):Int
+  {
+    var n = 0;
+
+    if (predicate == null)
+    {
+      for (_ in iterator)
+        n++;
+    }
+    else
+    {
+      for (x in iterator)
+        if (predicate(x)) n++;
+    }
+
+    return n;
+  }
 }
diff --git a/tests/unit/assets/preload/data/notestyles/funkin.json b/tests/unit/assets/preload/data/notestyles/funkin.json
index abb039150..a36dd8c1c 100644
--- a/tests/unit/assets/preload/data/notestyles/funkin.json
+++ b/tests/unit/assets/preload/data/notestyles/funkin.json
@@ -7,7 +7,7 @@
     "note": {
       "assetPath": "shared:arrows",
       "scale": 1.0,
-      "isPixel": true,
+      "isPixel": false,
       "data": {
         "left": { "prefix": "noteLeft" },
         "down": { "prefix": "noteDown" },
@@ -19,7 +19,7 @@
       "assetPath": "shared:arrows",
       "scale": 1.0,
       "offsets": [28, 32],
-      "isPixel": true,
+      "isPixel": false,
       "data": {
         "leftStatic": { "prefix": "staticLeft0" },
         "leftPress": { "prefix": "pressedLeft0" },
diff --git a/tests/unit/assets/preload/data/songs/bopeebo-swapped/bopeebo-swapped-chart.json b/tests/unit/assets/preload/data/songs/bopeebo-swapped/bopeebo-swapped-chart.json
new file mode 100644
index 000000000..c7f7629d6
--- /dev/null
+++ b/tests/unit/assets/preload/data/songs/bopeebo-swapped/bopeebo-swapped-chart.json
@@ -0,0 +1,15 @@
+{
+  "version": "2.0.0",
+  "songName": "Bopeebo",
+  "artist": "Kawai Sprite",
+  "timeFormat": "ms",
+  "timeChanges": [{ "t": 0, "bpm": 100, "n": 4, "d": 4, "bt": [4, 4, 4, 4] }],
+  "playData": {
+    "songVariations": [],
+    "difficulties": ["easy", "normal", "hard"],
+    "playableChars": { "bf": { "g": "gf", "o": "dad" } },
+    "stage": "mainStage",
+    "noteSkin": "Normal"
+  },
+  "generatedBy": "MasterEric (by hand)"
+}
diff --git a/tests/unit/assets/preload/data/songs/bopeebo-swapped/bopeebo-swapped-metadata.json b/tests/unit/assets/preload/data/songs/bopeebo-swapped/bopeebo-swapped-metadata.json
new file mode 100644
index 000000000..53817f96a
--- /dev/null
+++ b/tests/unit/assets/preload/data/songs/bopeebo-swapped/bopeebo-swapped-metadata.json
@@ -0,0 +1,1738 @@
+{
+  "version": "2.0.0",
+  "scrollSpeed": {
+    "default": 1.0,
+    "hard": 1.3
+  },
+  "events": [
+    { "t": 0, "e": "FocusCamera", "v": 1 },
+    { "t": 2400, "e": "FocusCamera", "v": 0 },
+    {
+      "t": 4200,
+      "e": "PlayAnimation",
+      "v": {
+        "target": "bf",
+        "anim": "hey",
+        "force": true
+      }
+    },
+    { "t": 4800, "e": "FocusCamera", "v": 1 },
+    { "t": 7200, "e": "FocusCamera", "v": 0 },
+    {
+      "t": 9000,
+      "e": "PlayAnimation",
+      "v": {
+        "target": "bf",
+        "anim": "hey",
+        "force": true
+      }
+    },
+    { "t": 9600, "e": "FocusCamera", "v": 1 },
+    { "t": 12000, "e": "FocusCamera", "v": 0 },
+    {
+      "t": 13800,
+      "e": "PlayAnimation",
+      "v": {
+        "target": "bf",
+        "anim": "hey",
+        "force": true
+      }
+    },
+    { "t": 14400, "e": "FocusCamera", "v": 1 },
+    { "t": 16800, "e": "FocusCamera", "v": 0 },
+    {
+      "t": 18600,
+      "e": "PlayAnimation",
+      "v": {
+        "target": "bf",
+        "anim": "hey",
+        "force": true
+      }
+    },
+    { "t": 19200, "e": "FocusCamera", "v": 1 },
+    { "t": 21600, "e": "FocusCamera", "v": 0 },
+    {
+      "t": 23400,
+      "e": "PlayAnimation",
+      "v": {
+        "target": "bf",
+        "anim": "hey",
+        "force": true
+      }
+    },
+    { "t": 24000, "e": "FocusCamera", "v": 1 },
+    { "t": 26400, "e": "FocusCamera", "v": 0 },
+    {
+      "t": 28200,
+      "e": "PlayAnimation",
+      "v": {
+        "target": "bf",
+        "anim": "hey",
+        "force": true
+      }
+    },
+    { "t": 28800, "e": "FocusCamera", "v": 1 },
+    { "t": 31200, "e": "FocusCamera", "v": 0 },
+    {
+      "t": 33000,
+      "e": "PlayAnimation",
+      "v": {
+        "target": "bf",
+        "anim": "hey",
+        "force": true
+      }
+    },
+    { "t": 33600, "e": "FocusCamera", "v": 1 },
+    { "t": 36000, "e": "FocusCamera", "v": 0 },
+    {
+      "t": 37800,
+      "e": "PlayAnimation",
+      "v": {
+        "target": "bf",
+        "anim": "hey",
+        "force": true
+      }
+    },
+    { "t": 38400, "e": "FocusCamera", "v": 1 },
+    { "t": 40800, "e": "FocusCamera", "v": 0 },
+    {
+      "t": 42600,
+      "e": "PlayAnimation",
+      "v": {
+        "target": "bf",
+        "anim": "hey",
+        "force": true
+      }
+    },
+    { "t": 43200, "e": "FocusCamera", "v": 1 },
+    { "t": 45600, "e": "FocusCamera", "v": 0 },
+    {
+      "t": 47400,
+      "e": "PlayAnimation",
+      "v": {
+        "target": "bf",
+        "anim": "hey",
+        "force": true
+      }
+    },
+    { "t": 48000, "e": "FocusCamera", "v": 1 },
+    { "t": 50400, "e": "FocusCamera", "v": 0 },
+    {
+      "t": 52200,
+      "e": "PlayAnimation",
+      "v": {
+        "target": "bf",
+        "anim": "hey",
+        "force": true
+      }
+    },
+    { "t": 52800, "e": "FocusCamera", "v": 1 },
+    { "t": 55200, "e": "FocusCamera", "v": 0 },
+    {
+      "t": 57000,
+      "e": "PlayAnimation",
+      "v": {
+        "target": "bf",
+        "anim": "hey",
+        "force": true
+      }
+    },
+    { "t": 57600, "e": "FocusCamera", "v": 1 },
+    { "t": 60000, "e": "FocusCamera", "v": 0 },
+    {
+      "t": 61800,
+      "e": "PlayAnimation",
+      "v": {
+        "target": "bf",
+        "anim": "hey",
+        "force": true
+      }
+    },
+    { "t": 62400, "e": "FocusCamera", "v": 1 },
+    { "t": 64800, "e": "FocusCamera", "v": 0 },
+    {
+      "t": 66600,
+      "e": "PlayAnimation",
+      "v": {
+        "target": "bf",
+        "anim": "hey",
+        "force": true
+      }
+    },
+    { "t": 67200, "e": "FocusCamera", "v": 1 },
+    { "t": 69600, "e": "FocusCamera", "v": 0 },
+    {
+      "t": 71400,
+      "e": "PlayAnimation",
+      "v": {
+        "target": "bf",
+        "anim": "hey",
+        "force": true
+      }
+    },
+    { "t": 72000, "e": "FocusCamera", "v": 1 },
+    { "t": 74400, "e": "FocusCamera", "v": 0 },
+    {
+      "t": 76200,
+      "e": "PlayAnimation",
+      "v": {
+        "target": "bf",
+        "anim": "hey",
+        "force": true
+      }
+    }
+  ],
+  "notes": {
+    "easy": [
+      {
+        "t": 0,
+        "d": 6
+      },
+      {
+        "t": 600,
+        "d": 7,
+        "l": 450
+      },
+      {
+        "t": 1200,
+        "d": 7,
+        "l": 600
+      },
+      {
+        "t": 2400,
+        "d": 2
+      },
+      {
+        "t": 3000,
+        "d": 3,
+        "l": 450
+      },
+      {
+        "t": 3600,
+        "d": 3,
+        "l": 600
+      },
+      {
+        "t": 4800,
+        "d": 5,
+        "l": 300
+      },
+      {
+        "t": 5400,
+        "d": 4,
+        "l": 300
+      },
+      {
+        "t": 6000,
+        "d": 7,
+        "l": 600
+      },
+      {
+        "t": 7200,
+        "d": 1,
+        "l": 300
+      },
+      {
+        "t": 7800,
+        "d": 0,
+        "l": 300
+      },
+      {
+        "t": 8400,
+        "d": 3,
+        "l": 600
+      },
+      {
+        "t": 9600,
+        "d": 5
+      },
+      {
+        "t": 10200,
+        "d": 7
+      },
+      {
+        "t": 10500,
+        "d": 4
+      },
+      {
+        "t": 10800,
+        "d": 5,
+        "l": 600
+      },
+      {
+        "t": 12000,
+        "d": 1
+      },
+      {
+        "t": 12600,
+        "d": 3
+      },
+      {
+        "t": 12900,
+        "d": 0
+      },
+      {
+        "t": 13200,
+        "d": 1,
+        "l": 600
+      },
+      {
+        "t": 14400,
+        "d": 7
+      },
+      {
+        "t": 14700,
+        "d": 5
+      },
+      {
+        "t": 15300,
+        "d": 4
+      },
+      {
+        "t": 15600,
+        "d": 6,
+        "l": 600
+      },
+      {
+        "t": 16800,
+        "d": 3
+      },
+      {
+        "t": 17100,
+        "d": 1
+      },
+      {
+        "t": 17700,
+        "d": 0
+      },
+      {
+        "t": 18000,
+        "d": 2,
+        "l": 600
+      },
+      {
+        "t": 19200,
+        "d": 4
+      },
+      {
+        "t": 19500,
+        "d": 7
+      },
+      {
+        "t": 19800,
+        "d": 5,
+        "l": 900
+      },
+      {
+        "t": 21600,
+        "d": 0
+      },
+      {
+        "t": 21900,
+        "d": 3
+      },
+      {
+        "t": 22200,
+        "d": 1,
+        "l": 900
+      },
+      {
+        "t": 24000,
+        "d": 5
+      },
+      {
+        "t": 24300,
+        "d": 7
+      },
+      {
+        "t": 24600,
+        "d": 4,
+        "l": 900
+      },
+      {
+        "t": 26400,
+        "d": 1
+      },
+      {
+        "t": 26700,
+        "d": 3
+      },
+      {
+        "t": 27000,
+        "d": 0,
+        "l": 900
+      },
+      {
+        "t": 28800,
+        "d": 6
+      },
+      {
+        "t": 29100,
+        "d": 7
+      },
+      {
+        "t": 29400,
+        "d": 4,
+        "l": 1200
+      },
+      {
+        "t": 31200,
+        "d": 2
+      },
+      {
+        "t": 31500,
+        "d": 3
+      },
+      {
+        "t": 31800,
+        "d": 0,
+        "l": 1200
+      },
+      {
+        "t": 33600,
+        "d": 4
+      },
+      {
+        "t": 33900,
+        "d": 7
+      },
+      {
+        "t": 34500,
+        "d": 6
+      },
+      {
+        "t": 34800,
+        "d": 5,
+        "l": 600
+      },
+      {
+        "t": 36000,
+        "d": 0
+      },
+      {
+        "t": 36300,
+        "d": 3
+      },
+      {
+        "t": 36900,
+        "d": 2
+      },
+      {
+        "t": 37200,
+        "d": 1,
+        "l": 600
+      },
+      {
+        "t": 38400,
+        "d": 6,
+        "l": 450
+      },
+      {
+        "t": 39000,
+        "d": 7,
+        "l": 300
+      },
+      {
+        "t": 39600,
+        "d": 4,
+        "l": 600
+      },
+      {
+        "t": 40800,
+        "d": 2,
+        "l": 450
+      },
+      {
+        "t": 41400,
+        "d": 3,
+        "l": 300
+      },
+      {
+        "t": 42000,
+        "d": 0,
+        "l": 600
+      },
+      {
+        "t": 43200,
+        "d": 5
+      },
+      {
+        "t": 43800,
+        "d": 6
+      },
+      {
+        "t": 44400,
+        "d": 5
+      },
+      {
+        "t": 44700,
+        "d": 5
+      },
+      {
+        "t": 45000,
+        "d": 6
+      },
+      {
+        "t": 45600,
+        "d": 1
+      },
+      {
+        "t": 46200,
+        "d": 2
+      },
+      {
+        "t": 46800,
+        "d": 1
+      },
+      {
+        "t": 47100,
+        "d": 1
+      },
+      {
+        "t": 47400,
+        "d": 2
+      },
+      {
+        "t": 48000,
+        "d": 6,
+        "l": 450
+      },
+      {
+        "t": 48600,
+        "d": 7,
+        "l": 300
+      },
+      {
+        "t": 49200,
+        "d": 4,
+        "l": 450
+      },
+      {
+        "t": 50400,
+        "d": 2,
+        "l": 450
+      },
+      {
+        "t": 51000,
+        "d": 3,
+        "l": 300
+      },
+      {
+        "t": 51600,
+        "d": 0,
+        "l": 450
+      },
+      {
+        "t": 52800,
+        "d": 7,
+        "l": 1800
+      },
+      {
+        "t": 55200,
+        "d": 3,
+        "l": 1800
+      },
+      {
+        "t": 57600,
+        "d": 6
+      },
+      {
+        "t": 57900,
+        "d": 7
+      },
+      {
+        "t": 58200,
+        "d": 4,
+        "l": 1200
+      },
+      {
+        "t": 60000,
+        "d": 2
+      },
+      {
+        "t": 60300,
+        "d": 3
+      },
+      {
+        "t": 60600,
+        "d": 0,
+        "l": 1200
+      },
+      {
+        "t": 62400,
+        "d": 4
+      },
+      {
+        "t": 62700,
+        "d": 7
+      },
+      {
+        "t": 63300,
+        "d": 6
+      },
+      {
+        "t": 63600,
+        "d": 5,
+        "l": 600
+      },
+      {
+        "t": 64800,
+        "d": 0
+      },
+      {
+        "t": 65100,
+        "d": 3
+      },
+      {
+        "t": 65700,
+        "d": 2
+      },
+      {
+        "t": 66000,
+        "d": 1,
+        "l": 600
+      },
+      {
+        "t": 67200,
+        "d": 6
+      },
+      {
+        "t": 67500,
+        "d": 7
+      },
+      {
+        "t": 67800,
+        "d": 4
+      },
+      {
+        "t": 68100,
+        "d": 6
+      },
+      {
+        "t": 68400,
+        "d": 5,
+        "l": 600
+      },
+      {
+        "t": 69600,
+        "d": 2
+      },
+      {
+        "t": 69900,
+        "d": 3
+      },
+      {
+        "t": 70200,
+        "d": 0
+      },
+      {
+        "t": 70500,
+        "d": 2
+      },
+      {
+        "t": 70800,
+        "d": 1,
+        "l": 600
+      },
+      {
+        "t": 72000,
+        "d": 4
+      },
+      {
+        "t": 72300,
+        "d": 7
+      },
+      {
+        "t": 72900,
+        "d": 6
+      },
+      {
+        "t": 73200,
+        "d": 5,
+        "l": 600
+      },
+      {
+        "t": 74400,
+        "d": 0
+      },
+      {
+        "t": 74700,
+        "d": 3
+      },
+      {
+        "t": 75300,
+        "d": 2
+      },
+      {
+        "t": 75600,
+        "d": 1,
+        "l": 600
+      }
+    ],
+    "normal": [
+      {
+        "t": 0,
+        "d": 6
+      },
+      {
+        "t": 600,
+        "d": 7,
+        "l": 600
+      },
+      {
+        "t": 1200,
+        "d": 7,
+        "l": 600
+      },
+      {
+        "t": 2400,
+        "d": 2
+      },
+      {
+        "t": 3000,
+        "d": 3,
+        "l": 600
+      },
+      {
+        "t": 3600,
+        "d": 3,
+        "l": 600
+      },
+      {
+        "t": 4800,
+        "d": 5,
+        "l": 300
+      },
+      {
+        "t": 5400,
+        "d": 4,
+        "l": 300
+      },
+      {
+        "t": 6000,
+        "d": 7,
+        "l": 600
+      },
+      {
+        "t": 7200,
+        "d": 1,
+        "l": 300
+      },
+      {
+        "t": 7800,
+        "d": 0,
+        "l": 300
+      },
+      {
+        "t": 8400,
+        "d": 3,
+        "l": 600
+      },
+      {
+        "t": 9600,
+        "d": 5,
+        "l": 300
+      },
+      {
+        "t": 10200,
+        "d": 7
+      },
+      {
+        "t": 10500,
+        "d": 4
+      },
+      {
+        "t": 10800,
+        "d": 5,
+        "l": 600
+      },
+      {
+        "t": 12000,
+        "d": 1,
+        "l": 300
+      },
+      {
+        "t": 12600,
+        "d": 3
+      },
+      {
+        "t": 12900,
+        "d": 0
+      },
+      {
+        "t": 13200,
+        "d": 1,
+        "l": 600
+      },
+      {
+        "t": 14400,
+        "d": 7
+      },
+      {
+        "t": 14700,
+        "d": 5
+      },
+      {
+        "t": 15300,
+        "d": 4
+      },
+      {
+        "t": 15600,
+        "d": 6,
+        "l": 600
+      },
+      {
+        "t": 16800,
+        "d": 3
+      },
+      {
+        "t": 17100,
+        "d": 1
+      },
+      {
+        "t": 17700,
+        "d": 0
+      },
+      {
+        "t": 18000,
+        "d": 2,
+        "l": 600
+      },
+      {
+        "t": 19200,
+        "d": 4
+      },
+      {
+        "t": 19500,
+        "d": 7
+      },
+      {
+        "t": 19800,
+        "d": 5,
+        "l": 900
+      },
+      {
+        "t": 21600,
+        "d": 0
+      },
+      {
+        "t": 21900,
+        "d": 3
+      },
+      {
+        "t": 22200,
+        "d": 1,
+        "l": 900
+      },
+      {
+        "t": 24000,
+        "d": 5
+      },
+      {
+        "t": 24300,
+        "d": 7
+      },
+      {
+        "t": 24600,
+        "d": 4,
+        "l": 900
+      },
+      {
+        "t": 26400,
+        "d": 1
+      },
+      {
+        "t": 26700,
+        "d": 3
+      },
+      {
+        "t": 27000,
+        "d": 0,
+        "l": 900
+      },
+      {
+        "t": 28800,
+        "d": 6
+      },
+      {
+        "t": 29100,
+        "d": 7
+      },
+      {
+        "t": 29400,
+        "d": 4,
+        "l": 1200
+      },
+      {
+        "t": 31200,
+        "d": 2
+      },
+      {
+        "t": 31500,
+        "d": 3
+      },
+      {
+        "t": 31800,
+        "d": 0,
+        "l": 1200
+      },
+      {
+        "t": 33300,
+        "d": 6
+      },
+      {
+        "t": 33600,
+        "d": 4
+      },
+      {
+        "t": 33900,
+        "d": 7
+      },
+      {
+        "t": 34500,
+        "d": 6
+      },
+      {
+        "t": 34800,
+        "d": 5,
+        "l": 600
+      },
+      {
+        "t": 35700,
+        "d": 2
+      },
+      {
+        "t": 36000,
+        "d": 0
+      },
+      {
+        "t": 36300,
+        "d": 3
+      },
+      {
+        "t": 36900,
+        "d": 2
+      },
+      {
+        "t": 37200,
+        "d": 1,
+        "l": 600
+      },
+      {
+        "t": 38400,
+        "d": 6,
+        "l": 450
+      },
+      {
+        "t": 39000,
+        "d": 7,
+        "l": 300
+      },
+      {
+        "t": 39600,
+        "d": 4,
+        "l": 600
+      },
+      {
+        "t": 40800,
+        "d": 2,
+        "l": 450
+      },
+      {
+        "t": 41400,
+        "d": 3,
+        "l": 300
+      },
+      {
+        "t": 42000,
+        "d": 0,
+        "l": 600
+      },
+      {
+        "t": 43200,
+        "d": 5
+      },
+      {
+        "t": 43800,
+        "d": 6
+      },
+      {
+        "t": 44400,
+        "d": 5
+      },
+      {
+        "t": 44550,
+        "d": 5
+      },
+      {
+        "t": 44700,
+        "d": 5
+      },
+      {
+        "t": 45000,
+        "d": 6
+      },
+      {
+        "t": 45600,
+        "d": 1
+      },
+      {
+        "t": 46200,
+        "d": 2
+      },
+      {
+        "t": 46800,
+        "d": 1
+      },
+      {
+        "t": 46950,
+        "d": 1
+      },
+      {
+        "t": 47100,
+        "d": 1
+      },
+      {
+        "t": 47400,
+        "d": 2
+      },
+      {
+        "t": 48000,
+        "d": 6,
+        "l": 450
+      },
+      {
+        "t": 48600,
+        "d": 7,
+        "l": 300
+      },
+      {
+        "t": 49200,
+        "d": 4,
+        "l": 450
+      },
+      {
+        "t": 50400,
+        "d": 2,
+        "l": 450
+      },
+      {
+        "t": 51000,
+        "d": 3,
+        "l": 300
+      },
+      {
+        "t": 51600,
+        "d": 0,
+        "l": 450
+      },
+      {
+        "t": 52800,
+        "d": 7,
+        "l": 1800
+      },
+      {
+        "t": 55200,
+        "d": 3,
+        "l": 1800
+      },
+      {
+        "t": 57600,
+        "d": 6
+      },
+      {
+        "t": 57900,
+        "d": 7
+      },
+      {
+        "t": 58200,
+        "d": 4,
+        "l": 1200
+      },
+      {
+        "t": 60000,
+        "d": 2
+      },
+      {
+        "t": 60300,
+        "d": 3
+      },
+      {
+        "t": 60600,
+        "d": 0,
+        "l": 1200
+      },
+      {
+        "t": 62100,
+        "d": 6
+      },
+      {
+        "t": 62400,
+        "d": 4
+      },
+      {
+        "t": 62700,
+        "d": 7
+      },
+      {
+        "t": 63300,
+        "d": 6
+      },
+      {
+        "t": 63600,
+        "d": 5,
+        "l": 600
+      },
+      {
+        "t": 64500,
+        "d": 2
+      },
+      {
+        "t": 64800,
+        "d": 0
+      },
+      {
+        "t": 65100,
+        "d": 3
+      },
+      {
+        "t": 65700,
+        "d": 2
+      },
+      {
+        "t": 66000,
+        "d": 1,
+        "l": 600
+      },
+      {
+        "t": 67200,
+        "d": 6
+      },
+      {
+        "t": 67500,
+        "d": 7
+      },
+      {
+        "t": 67800,
+        "d": 4
+      },
+      {
+        "t": 68100,
+        "d": 6
+      },
+      {
+        "t": 68400,
+        "d": 5,
+        "l": 600
+      },
+      {
+        "t": 69600,
+        "d": 2
+      },
+      {
+        "t": 69900,
+        "d": 3
+      },
+      {
+        "t": 70200,
+        "d": 0
+      },
+      {
+        "t": 70500,
+        "d": 2
+      },
+      {
+        "t": 70800,
+        "d": 1,
+        "l": 600
+      },
+      {
+        "t": 71700,
+        "d": 6
+      },
+      {
+        "t": 72000,
+        "d": 4
+      },
+      {
+        "t": 72300,
+        "d": 7
+      },
+      {
+        "t": 72900,
+        "d": 6
+      },
+      {
+        "t": 73200,
+        "d": 5,
+        "l": 600
+      },
+      {
+        "t": 74100,
+        "d": 2
+      },
+      {
+        "t": 74400,
+        "d": 0
+      },
+      {
+        "t": 74700,
+        "d": 3
+      },
+      {
+        "t": 75300,
+        "d": 2
+      },
+      {
+        "t": 75600,
+        "d": 1,
+        "l": 600
+      }
+    ],
+    "hard": [
+      {
+        "t": 0,
+        "d": 6
+      },
+      {
+        "t": 600,
+        "d": 7,
+        "l": 600
+      },
+      {
+        "t": 1200,
+        "d": 7,
+        "l": 600
+      },
+      {
+        "t": 2400,
+        "d": 2
+      },
+      {
+        "t": 3000,
+        "d": 3,
+        "l": 600
+      },
+      {
+        "t": 3600,
+        "d": 3,
+        "l": 600
+      },
+      {
+        "t": 4800,
+        "d": 5,
+        "l": 300
+      },
+      {
+        "t": 5400,
+        "d": 4,
+        "l": 300
+      },
+      {
+        "t": 6000,
+        "d": 7,
+        "l": 600
+      },
+      {
+        "t": 7200,
+        "d": 1,
+        "l": 300
+      },
+      {
+        "t": 7800,
+        "d": 0,
+        "l": 300
+      },
+      {
+        "t": 8400,
+        "d": 3,
+        "l": 600
+      },
+      {
+        "t": 9600,
+        "d": 5,
+        "l": 300
+      },
+      {
+        "t": 10200,
+        "d": 7
+      },
+      {
+        "t": 10500,
+        "d": 4
+      },
+      {
+        "t": 10800,
+        "d": 5,
+        "l": 600
+      },
+      {
+        "t": 12000,
+        "d": 1,
+        "l": 300
+      },
+      {
+        "t": 12600,
+        "d": 3
+      },
+      {
+        "t": 12900,
+        "d": 0
+      },
+      {
+        "t": 13200,
+        "d": 1,
+        "l": 600
+      },
+      {
+        "t": 14400,
+        "d": 7
+      },
+      {
+        "t": 14700,
+        "d": 5
+      },
+      {
+        "t": 15300,
+        "d": 4
+      },
+      {
+        "t": 15600,
+        "d": 6,
+        "l": 600
+      },
+      {
+        "t": 16800,
+        "d": 3
+      },
+      {
+        "t": 17100,
+        "d": 1
+      },
+      {
+        "t": 17700,
+        "d": 0
+      },
+      {
+        "t": 18000,
+        "d": 2,
+        "l": 600
+      },
+      {
+        "t": 19200,
+        "d": 4
+      },
+      {
+        "t": 19500,
+        "d": 7
+      },
+      {
+        "t": 19800,
+        "d": 5,
+        "l": 900
+      },
+      {
+        "t": 21600,
+        "d": 0
+      },
+      {
+        "t": 21900,
+        "d": 3
+      },
+      {
+        "t": 22200,
+        "d": 1,
+        "l": 900
+      },
+      {
+        "t": 24000,
+        "d": 5
+      },
+      {
+        "t": 24300,
+        "d": 7
+      },
+      {
+        "t": 24600,
+        "d": 4,
+        "l": 900
+      },
+      {
+        "t": 26400,
+        "d": 1
+      },
+      {
+        "t": 26700,
+        "d": 3
+      },
+      {
+        "t": 27000,
+        "d": 0,
+        "l": 900
+      },
+      {
+        "t": 28800,
+        "d": 6
+      },
+      {
+        "t": 29100,
+        "d": 7
+      },
+      {
+        "t": 29400,
+        "d": 4,
+        "l": 1200
+      },
+      {
+        "t": 31200,
+        "d": 2
+      },
+      {
+        "t": 31500,
+        "d": 3
+      },
+      {
+        "t": 31800,
+        "d": 0,
+        "l": 1200
+      },
+      {
+        "t": 33300,
+        "d": 6
+      },
+      {
+        "t": 33600,
+        "d": 4
+      },
+      {
+        "t": 33900,
+        "d": 7
+      },
+      {
+        "t": 34500,
+        "d": 6
+      },
+      {
+        "t": 34575,
+        "d": 4
+      },
+      {
+        "t": 34800,
+        "d": 5,
+        "l": 600
+      },
+      {
+        "t": 35700,
+        "d": 2
+      },
+      {
+        "t": 36000,
+        "d": 0
+      },
+      {
+        "t": 36300,
+        "d": 3
+      },
+      {
+        "t": 36900,
+        "d": 2
+      },
+      {
+        "t": 36975,
+        "d": 0
+      },
+      {
+        "t": 37200,
+        "d": 1,
+        "l": 600
+      },
+      {
+        "t": 38400,
+        "d": 6,
+        "l": 450
+      },
+      {
+        "t": 39000,
+        "d": 7,
+        "l": 300
+      },
+      {
+        "t": 39600,
+        "d": 4,
+        "l": 600
+      },
+      {
+        "t": 40800,
+        "d": 2,
+        "l": 450
+      },
+      {
+        "t": 41400,
+        "d": 3,
+        "l": 300
+      },
+      {
+        "t": 42000,
+        "d": 0,
+        "l": 600
+      },
+      {
+        "t": 43200,
+        "d": 5
+      },
+      {
+        "t": 43800,
+        "d": 6
+      },
+      {
+        "t": 44400,
+        "d": 5
+      },
+      {
+        "t": 44550,
+        "d": 5
+      },
+      {
+        "t": 44700,
+        "d": 5
+      },
+      {
+        "t": 45000,
+        "d": 6
+      },
+      {
+        "t": 45600,
+        "d": 1
+      },
+      {
+        "t": 46200,
+        "d": 2
+      },
+      {
+        "t": 46800,
+        "d": 1
+      },
+      {
+        "t": 46950,
+        "d": 1
+      },
+      {
+        "t": 47100,
+        "d": 1
+      },
+      {
+        "t": 47400,
+        "d": 2
+      },
+      {
+        "t": 48000,
+        "d": 6,
+        "l": 450
+      },
+      {
+        "t": 48600,
+        "d": 7,
+        "l": 300
+      },
+      {
+        "t": 49200,
+        "d": 4,
+        "l": 450
+      },
+      {
+        "t": 50400,
+        "d": 2,
+        "l": 450
+      },
+      {
+        "t": 51000,
+        "d": 3,
+        "l": 300
+      },
+      {
+        "t": 51600,
+        "d": 0,
+        "l": 450
+      },
+      {
+        "t": 52800,
+        "d": 7,
+        "l": 1800
+      },
+      {
+        "t": 55200,
+        "d": 3,
+        "l": 1800
+      },
+      {
+        "t": 57600,
+        "d": 6
+      },
+      {
+        "t": 57900,
+        "d": 7
+      },
+      {
+        "t": 58200,
+        "d": 4,
+        "l": 1200
+      },
+      {
+        "t": 60000,
+        "d": 2
+      },
+      {
+        "t": 60300,
+        "d": 3
+      },
+      {
+        "t": 60600,
+        "d": 0,
+        "l": 1200
+      },
+      {
+        "t": 62100,
+        "d": 6
+      },
+      {
+        "t": 62400,
+        "d": 4
+      },
+      {
+        "t": 62700,
+        "d": 7
+      },
+      {
+        "t": 63300,
+        "d": 6
+      },
+      {
+        "t": 63375,
+        "d": 4
+      },
+      {
+        "t": 63600,
+        "d": 5,
+        "l": 600
+      },
+      {
+        "t": 64500,
+        "d": 2
+      },
+      {
+        "t": 64800,
+        "d": 0
+      },
+      {
+        "t": 65100,
+        "d": 3
+      },
+      {
+        "t": 65700,
+        "d": 2
+      },
+      {
+        "t": 65775,
+        "d": 0
+      },
+      {
+        "t": 66000,
+        "d": 1,
+        "l": 600
+      },
+      {
+        "t": 67200,
+        "d": 6
+      },
+      {
+        "t": 67500,
+        "d": 7
+      },
+      {
+        "t": 67800,
+        "d": 4
+      },
+      {
+        "t": 68100,
+        "d": 6
+      },
+      {
+        "t": 68400,
+        "d": 5,
+        "l": 600
+      },
+      {
+        "t": 69600,
+        "d": 2
+      },
+      {
+        "t": 69900,
+        "d": 3
+      },
+      {
+        "t": 70200,
+        "d": 0
+      },
+      {
+        "t": 70500,
+        "d": 2
+      },
+      {
+        "t": 70800,
+        "d": 1,
+        "l": 600
+      },
+      {
+        "t": 71700,
+        "d": 6
+      },
+      {
+        "t": 72000,
+        "d": 4
+      },
+      {
+        "t": 72300,
+        "d": 7
+      },
+      {
+        "t": 72900,
+        "d": 6
+      },
+      {
+        "t": 72975,
+        "d": 4
+      },
+      {
+        "t": 73200,
+        "d": 5,
+        "l": 600
+      },
+      {
+        "t": 74100,
+        "d": 2
+      },
+      {
+        "t": 74400,
+        "d": 0
+      },
+      {
+        "t": 74700,
+        "d": 3
+      },
+      {
+        "t": 75300,
+        "d": 2
+      },
+      {
+        "t": 75375,
+        "d": 0
+      },
+      {
+        "t": 75600,
+        "d": 1,
+        "l": 600
+      }
+    ]
+  },
+  "generatedBy": "MasterEric (by hand)"
+}
diff --git a/tests/unit/assets/preload/data/songs/bopeebo/bopeebo-chart.json b/tests/unit/assets/preload/data/songs/bopeebo/bopeebo-chart.json
new file mode 100644
index 000000000..53817f96a
--- /dev/null
+++ b/tests/unit/assets/preload/data/songs/bopeebo/bopeebo-chart.json
@@ -0,0 +1,1738 @@
+{
+  "version": "2.0.0",
+  "scrollSpeed": {
+    "default": 1.0,
+    "hard": 1.3
+  },
+  "events": [
+    { "t": 0, "e": "FocusCamera", "v": 1 },
+    { "t": 2400, "e": "FocusCamera", "v": 0 },
+    {
+      "t": 4200,
+      "e": "PlayAnimation",
+      "v": {
+        "target": "bf",
+        "anim": "hey",
+        "force": true
+      }
+    },
+    { "t": 4800, "e": "FocusCamera", "v": 1 },
+    { "t": 7200, "e": "FocusCamera", "v": 0 },
+    {
+      "t": 9000,
+      "e": "PlayAnimation",
+      "v": {
+        "target": "bf",
+        "anim": "hey",
+        "force": true
+      }
+    },
+    { "t": 9600, "e": "FocusCamera", "v": 1 },
+    { "t": 12000, "e": "FocusCamera", "v": 0 },
+    {
+      "t": 13800,
+      "e": "PlayAnimation",
+      "v": {
+        "target": "bf",
+        "anim": "hey",
+        "force": true
+      }
+    },
+    { "t": 14400, "e": "FocusCamera", "v": 1 },
+    { "t": 16800, "e": "FocusCamera", "v": 0 },
+    {
+      "t": 18600,
+      "e": "PlayAnimation",
+      "v": {
+        "target": "bf",
+        "anim": "hey",
+        "force": true
+      }
+    },
+    { "t": 19200, "e": "FocusCamera", "v": 1 },
+    { "t": 21600, "e": "FocusCamera", "v": 0 },
+    {
+      "t": 23400,
+      "e": "PlayAnimation",
+      "v": {
+        "target": "bf",
+        "anim": "hey",
+        "force": true
+      }
+    },
+    { "t": 24000, "e": "FocusCamera", "v": 1 },
+    { "t": 26400, "e": "FocusCamera", "v": 0 },
+    {
+      "t": 28200,
+      "e": "PlayAnimation",
+      "v": {
+        "target": "bf",
+        "anim": "hey",
+        "force": true
+      }
+    },
+    { "t": 28800, "e": "FocusCamera", "v": 1 },
+    { "t": 31200, "e": "FocusCamera", "v": 0 },
+    {
+      "t": 33000,
+      "e": "PlayAnimation",
+      "v": {
+        "target": "bf",
+        "anim": "hey",
+        "force": true
+      }
+    },
+    { "t": 33600, "e": "FocusCamera", "v": 1 },
+    { "t": 36000, "e": "FocusCamera", "v": 0 },
+    {
+      "t": 37800,
+      "e": "PlayAnimation",
+      "v": {
+        "target": "bf",
+        "anim": "hey",
+        "force": true
+      }
+    },
+    { "t": 38400, "e": "FocusCamera", "v": 1 },
+    { "t": 40800, "e": "FocusCamera", "v": 0 },
+    {
+      "t": 42600,
+      "e": "PlayAnimation",
+      "v": {
+        "target": "bf",
+        "anim": "hey",
+        "force": true
+      }
+    },
+    { "t": 43200, "e": "FocusCamera", "v": 1 },
+    { "t": 45600, "e": "FocusCamera", "v": 0 },
+    {
+      "t": 47400,
+      "e": "PlayAnimation",
+      "v": {
+        "target": "bf",
+        "anim": "hey",
+        "force": true
+      }
+    },
+    { "t": 48000, "e": "FocusCamera", "v": 1 },
+    { "t": 50400, "e": "FocusCamera", "v": 0 },
+    {
+      "t": 52200,
+      "e": "PlayAnimation",
+      "v": {
+        "target": "bf",
+        "anim": "hey",
+        "force": true
+      }
+    },
+    { "t": 52800, "e": "FocusCamera", "v": 1 },
+    { "t": 55200, "e": "FocusCamera", "v": 0 },
+    {
+      "t": 57000,
+      "e": "PlayAnimation",
+      "v": {
+        "target": "bf",
+        "anim": "hey",
+        "force": true
+      }
+    },
+    { "t": 57600, "e": "FocusCamera", "v": 1 },
+    { "t": 60000, "e": "FocusCamera", "v": 0 },
+    {
+      "t": 61800,
+      "e": "PlayAnimation",
+      "v": {
+        "target": "bf",
+        "anim": "hey",
+        "force": true
+      }
+    },
+    { "t": 62400, "e": "FocusCamera", "v": 1 },
+    { "t": 64800, "e": "FocusCamera", "v": 0 },
+    {
+      "t": 66600,
+      "e": "PlayAnimation",
+      "v": {
+        "target": "bf",
+        "anim": "hey",
+        "force": true
+      }
+    },
+    { "t": 67200, "e": "FocusCamera", "v": 1 },
+    { "t": 69600, "e": "FocusCamera", "v": 0 },
+    {
+      "t": 71400,
+      "e": "PlayAnimation",
+      "v": {
+        "target": "bf",
+        "anim": "hey",
+        "force": true
+      }
+    },
+    { "t": 72000, "e": "FocusCamera", "v": 1 },
+    { "t": 74400, "e": "FocusCamera", "v": 0 },
+    {
+      "t": 76200,
+      "e": "PlayAnimation",
+      "v": {
+        "target": "bf",
+        "anim": "hey",
+        "force": true
+      }
+    }
+  ],
+  "notes": {
+    "easy": [
+      {
+        "t": 0,
+        "d": 6
+      },
+      {
+        "t": 600,
+        "d": 7,
+        "l": 450
+      },
+      {
+        "t": 1200,
+        "d": 7,
+        "l": 600
+      },
+      {
+        "t": 2400,
+        "d": 2
+      },
+      {
+        "t": 3000,
+        "d": 3,
+        "l": 450
+      },
+      {
+        "t": 3600,
+        "d": 3,
+        "l": 600
+      },
+      {
+        "t": 4800,
+        "d": 5,
+        "l": 300
+      },
+      {
+        "t": 5400,
+        "d": 4,
+        "l": 300
+      },
+      {
+        "t": 6000,
+        "d": 7,
+        "l": 600
+      },
+      {
+        "t": 7200,
+        "d": 1,
+        "l": 300
+      },
+      {
+        "t": 7800,
+        "d": 0,
+        "l": 300
+      },
+      {
+        "t": 8400,
+        "d": 3,
+        "l": 600
+      },
+      {
+        "t": 9600,
+        "d": 5
+      },
+      {
+        "t": 10200,
+        "d": 7
+      },
+      {
+        "t": 10500,
+        "d": 4
+      },
+      {
+        "t": 10800,
+        "d": 5,
+        "l": 600
+      },
+      {
+        "t": 12000,
+        "d": 1
+      },
+      {
+        "t": 12600,
+        "d": 3
+      },
+      {
+        "t": 12900,
+        "d": 0
+      },
+      {
+        "t": 13200,
+        "d": 1,
+        "l": 600
+      },
+      {
+        "t": 14400,
+        "d": 7
+      },
+      {
+        "t": 14700,
+        "d": 5
+      },
+      {
+        "t": 15300,
+        "d": 4
+      },
+      {
+        "t": 15600,
+        "d": 6,
+        "l": 600
+      },
+      {
+        "t": 16800,
+        "d": 3
+      },
+      {
+        "t": 17100,
+        "d": 1
+      },
+      {
+        "t": 17700,
+        "d": 0
+      },
+      {
+        "t": 18000,
+        "d": 2,
+        "l": 600
+      },
+      {
+        "t": 19200,
+        "d": 4
+      },
+      {
+        "t": 19500,
+        "d": 7
+      },
+      {
+        "t": 19800,
+        "d": 5,
+        "l": 900
+      },
+      {
+        "t": 21600,
+        "d": 0
+      },
+      {
+        "t": 21900,
+        "d": 3
+      },
+      {
+        "t": 22200,
+        "d": 1,
+        "l": 900
+      },
+      {
+        "t": 24000,
+        "d": 5
+      },
+      {
+        "t": 24300,
+        "d": 7
+      },
+      {
+        "t": 24600,
+        "d": 4,
+        "l": 900
+      },
+      {
+        "t": 26400,
+        "d": 1
+      },
+      {
+        "t": 26700,
+        "d": 3
+      },
+      {
+        "t": 27000,
+        "d": 0,
+        "l": 900
+      },
+      {
+        "t": 28800,
+        "d": 6
+      },
+      {
+        "t": 29100,
+        "d": 7
+      },
+      {
+        "t": 29400,
+        "d": 4,
+        "l": 1200
+      },
+      {
+        "t": 31200,
+        "d": 2
+      },
+      {
+        "t": 31500,
+        "d": 3
+      },
+      {
+        "t": 31800,
+        "d": 0,
+        "l": 1200
+      },
+      {
+        "t": 33600,
+        "d": 4
+      },
+      {
+        "t": 33900,
+        "d": 7
+      },
+      {
+        "t": 34500,
+        "d": 6
+      },
+      {
+        "t": 34800,
+        "d": 5,
+        "l": 600
+      },
+      {
+        "t": 36000,
+        "d": 0
+      },
+      {
+        "t": 36300,
+        "d": 3
+      },
+      {
+        "t": 36900,
+        "d": 2
+      },
+      {
+        "t": 37200,
+        "d": 1,
+        "l": 600
+      },
+      {
+        "t": 38400,
+        "d": 6,
+        "l": 450
+      },
+      {
+        "t": 39000,
+        "d": 7,
+        "l": 300
+      },
+      {
+        "t": 39600,
+        "d": 4,
+        "l": 600
+      },
+      {
+        "t": 40800,
+        "d": 2,
+        "l": 450
+      },
+      {
+        "t": 41400,
+        "d": 3,
+        "l": 300
+      },
+      {
+        "t": 42000,
+        "d": 0,
+        "l": 600
+      },
+      {
+        "t": 43200,
+        "d": 5
+      },
+      {
+        "t": 43800,
+        "d": 6
+      },
+      {
+        "t": 44400,
+        "d": 5
+      },
+      {
+        "t": 44700,
+        "d": 5
+      },
+      {
+        "t": 45000,
+        "d": 6
+      },
+      {
+        "t": 45600,
+        "d": 1
+      },
+      {
+        "t": 46200,
+        "d": 2
+      },
+      {
+        "t": 46800,
+        "d": 1
+      },
+      {
+        "t": 47100,
+        "d": 1
+      },
+      {
+        "t": 47400,
+        "d": 2
+      },
+      {
+        "t": 48000,
+        "d": 6,
+        "l": 450
+      },
+      {
+        "t": 48600,
+        "d": 7,
+        "l": 300
+      },
+      {
+        "t": 49200,
+        "d": 4,
+        "l": 450
+      },
+      {
+        "t": 50400,
+        "d": 2,
+        "l": 450
+      },
+      {
+        "t": 51000,
+        "d": 3,
+        "l": 300
+      },
+      {
+        "t": 51600,
+        "d": 0,
+        "l": 450
+      },
+      {
+        "t": 52800,
+        "d": 7,
+        "l": 1800
+      },
+      {
+        "t": 55200,
+        "d": 3,
+        "l": 1800
+      },
+      {
+        "t": 57600,
+        "d": 6
+      },
+      {
+        "t": 57900,
+        "d": 7
+      },
+      {
+        "t": 58200,
+        "d": 4,
+        "l": 1200
+      },
+      {
+        "t": 60000,
+        "d": 2
+      },
+      {
+        "t": 60300,
+        "d": 3
+      },
+      {
+        "t": 60600,
+        "d": 0,
+        "l": 1200
+      },
+      {
+        "t": 62400,
+        "d": 4
+      },
+      {
+        "t": 62700,
+        "d": 7
+      },
+      {
+        "t": 63300,
+        "d": 6
+      },
+      {
+        "t": 63600,
+        "d": 5,
+        "l": 600
+      },
+      {
+        "t": 64800,
+        "d": 0
+      },
+      {
+        "t": 65100,
+        "d": 3
+      },
+      {
+        "t": 65700,
+        "d": 2
+      },
+      {
+        "t": 66000,
+        "d": 1,
+        "l": 600
+      },
+      {
+        "t": 67200,
+        "d": 6
+      },
+      {
+        "t": 67500,
+        "d": 7
+      },
+      {
+        "t": 67800,
+        "d": 4
+      },
+      {
+        "t": 68100,
+        "d": 6
+      },
+      {
+        "t": 68400,
+        "d": 5,
+        "l": 600
+      },
+      {
+        "t": 69600,
+        "d": 2
+      },
+      {
+        "t": 69900,
+        "d": 3
+      },
+      {
+        "t": 70200,
+        "d": 0
+      },
+      {
+        "t": 70500,
+        "d": 2
+      },
+      {
+        "t": 70800,
+        "d": 1,
+        "l": 600
+      },
+      {
+        "t": 72000,
+        "d": 4
+      },
+      {
+        "t": 72300,
+        "d": 7
+      },
+      {
+        "t": 72900,
+        "d": 6
+      },
+      {
+        "t": 73200,
+        "d": 5,
+        "l": 600
+      },
+      {
+        "t": 74400,
+        "d": 0
+      },
+      {
+        "t": 74700,
+        "d": 3
+      },
+      {
+        "t": 75300,
+        "d": 2
+      },
+      {
+        "t": 75600,
+        "d": 1,
+        "l": 600
+      }
+    ],
+    "normal": [
+      {
+        "t": 0,
+        "d": 6
+      },
+      {
+        "t": 600,
+        "d": 7,
+        "l": 600
+      },
+      {
+        "t": 1200,
+        "d": 7,
+        "l": 600
+      },
+      {
+        "t": 2400,
+        "d": 2
+      },
+      {
+        "t": 3000,
+        "d": 3,
+        "l": 600
+      },
+      {
+        "t": 3600,
+        "d": 3,
+        "l": 600
+      },
+      {
+        "t": 4800,
+        "d": 5,
+        "l": 300
+      },
+      {
+        "t": 5400,
+        "d": 4,
+        "l": 300
+      },
+      {
+        "t": 6000,
+        "d": 7,
+        "l": 600
+      },
+      {
+        "t": 7200,
+        "d": 1,
+        "l": 300
+      },
+      {
+        "t": 7800,
+        "d": 0,
+        "l": 300
+      },
+      {
+        "t": 8400,
+        "d": 3,
+        "l": 600
+      },
+      {
+        "t": 9600,
+        "d": 5,
+        "l": 300
+      },
+      {
+        "t": 10200,
+        "d": 7
+      },
+      {
+        "t": 10500,
+        "d": 4
+      },
+      {
+        "t": 10800,
+        "d": 5,
+        "l": 600
+      },
+      {
+        "t": 12000,
+        "d": 1,
+        "l": 300
+      },
+      {
+        "t": 12600,
+        "d": 3
+      },
+      {
+        "t": 12900,
+        "d": 0
+      },
+      {
+        "t": 13200,
+        "d": 1,
+        "l": 600
+      },
+      {
+        "t": 14400,
+        "d": 7
+      },
+      {
+        "t": 14700,
+        "d": 5
+      },
+      {
+        "t": 15300,
+        "d": 4
+      },
+      {
+        "t": 15600,
+        "d": 6,
+        "l": 600
+      },
+      {
+        "t": 16800,
+        "d": 3
+      },
+      {
+        "t": 17100,
+        "d": 1
+      },
+      {
+        "t": 17700,
+        "d": 0
+      },
+      {
+        "t": 18000,
+        "d": 2,
+        "l": 600
+      },
+      {
+        "t": 19200,
+        "d": 4
+      },
+      {
+        "t": 19500,
+        "d": 7
+      },
+      {
+        "t": 19800,
+        "d": 5,
+        "l": 900
+      },
+      {
+        "t": 21600,
+        "d": 0
+      },
+      {
+        "t": 21900,
+        "d": 3
+      },
+      {
+        "t": 22200,
+        "d": 1,
+        "l": 900
+      },
+      {
+        "t": 24000,
+        "d": 5
+      },
+      {
+        "t": 24300,
+        "d": 7
+      },
+      {
+        "t": 24600,
+        "d": 4,
+        "l": 900
+      },
+      {
+        "t": 26400,
+        "d": 1
+      },
+      {
+        "t": 26700,
+        "d": 3
+      },
+      {
+        "t": 27000,
+        "d": 0,
+        "l": 900
+      },
+      {
+        "t": 28800,
+        "d": 6
+      },
+      {
+        "t": 29100,
+        "d": 7
+      },
+      {
+        "t": 29400,
+        "d": 4,
+        "l": 1200
+      },
+      {
+        "t": 31200,
+        "d": 2
+      },
+      {
+        "t": 31500,
+        "d": 3
+      },
+      {
+        "t": 31800,
+        "d": 0,
+        "l": 1200
+      },
+      {
+        "t": 33300,
+        "d": 6
+      },
+      {
+        "t": 33600,
+        "d": 4
+      },
+      {
+        "t": 33900,
+        "d": 7
+      },
+      {
+        "t": 34500,
+        "d": 6
+      },
+      {
+        "t": 34800,
+        "d": 5,
+        "l": 600
+      },
+      {
+        "t": 35700,
+        "d": 2
+      },
+      {
+        "t": 36000,
+        "d": 0
+      },
+      {
+        "t": 36300,
+        "d": 3
+      },
+      {
+        "t": 36900,
+        "d": 2
+      },
+      {
+        "t": 37200,
+        "d": 1,
+        "l": 600
+      },
+      {
+        "t": 38400,
+        "d": 6,
+        "l": 450
+      },
+      {
+        "t": 39000,
+        "d": 7,
+        "l": 300
+      },
+      {
+        "t": 39600,
+        "d": 4,
+        "l": 600
+      },
+      {
+        "t": 40800,
+        "d": 2,
+        "l": 450
+      },
+      {
+        "t": 41400,
+        "d": 3,
+        "l": 300
+      },
+      {
+        "t": 42000,
+        "d": 0,
+        "l": 600
+      },
+      {
+        "t": 43200,
+        "d": 5
+      },
+      {
+        "t": 43800,
+        "d": 6
+      },
+      {
+        "t": 44400,
+        "d": 5
+      },
+      {
+        "t": 44550,
+        "d": 5
+      },
+      {
+        "t": 44700,
+        "d": 5
+      },
+      {
+        "t": 45000,
+        "d": 6
+      },
+      {
+        "t": 45600,
+        "d": 1
+      },
+      {
+        "t": 46200,
+        "d": 2
+      },
+      {
+        "t": 46800,
+        "d": 1
+      },
+      {
+        "t": 46950,
+        "d": 1
+      },
+      {
+        "t": 47100,
+        "d": 1
+      },
+      {
+        "t": 47400,
+        "d": 2
+      },
+      {
+        "t": 48000,
+        "d": 6,
+        "l": 450
+      },
+      {
+        "t": 48600,
+        "d": 7,
+        "l": 300
+      },
+      {
+        "t": 49200,
+        "d": 4,
+        "l": 450
+      },
+      {
+        "t": 50400,
+        "d": 2,
+        "l": 450
+      },
+      {
+        "t": 51000,
+        "d": 3,
+        "l": 300
+      },
+      {
+        "t": 51600,
+        "d": 0,
+        "l": 450
+      },
+      {
+        "t": 52800,
+        "d": 7,
+        "l": 1800
+      },
+      {
+        "t": 55200,
+        "d": 3,
+        "l": 1800
+      },
+      {
+        "t": 57600,
+        "d": 6
+      },
+      {
+        "t": 57900,
+        "d": 7
+      },
+      {
+        "t": 58200,
+        "d": 4,
+        "l": 1200
+      },
+      {
+        "t": 60000,
+        "d": 2
+      },
+      {
+        "t": 60300,
+        "d": 3
+      },
+      {
+        "t": 60600,
+        "d": 0,
+        "l": 1200
+      },
+      {
+        "t": 62100,
+        "d": 6
+      },
+      {
+        "t": 62400,
+        "d": 4
+      },
+      {
+        "t": 62700,
+        "d": 7
+      },
+      {
+        "t": 63300,
+        "d": 6
+      },
+      {
+        "t": 63600,
+        "d": 5,
+        "l": 600
+      },
+      {
+        "t": 64500,
+        "d": 2
+      },
+      {
+        "t": 64800,
+        "d": 0
+      },
+      {
+        "t": 65100,
+        "d": 3
+      },
+      {
+        "t": 65700,
+        "d": 2
+      },
+      {
+        "t": 66000,
+        "d": 1,
+        "l": 600
+      },
+      {
+        "t": 67200,
+        "d": 6
+      },
+      {
+        "t": 67500,
+        "d": 7
+      },
+      {
+        "t": 67800,
+        "d": 4
+      },
+      {
+        "t": 68100,
+        "d": 6
+      },
+      {
+        "t": 68400,
+        "d": 5,
+        "l": 600
+      },
+      {
+        "t": 69600,
+        "d": 2
+      },
+      {
+        "t": 69900,
+        "d": 3
+      },
+      {
+        "t": 70200,
+        "d": 0
+      },
+      {
+        "t": 70500,
+        "d": 2
+      },
+      {
+        "t": 70800,
+        "d": 1,
+        "l": 600
+      },
+      {
+        "t": 71700,
+        "d": 6
+      },
+      {
+        "t": 72000,
+        "d": 4
+      },
+      {
+        "t": 72300,
+        "d": 7
+      },
+      {
+        "t": 72900,
+        "d": 6
+      },
+      {
+        "t": 73200,
+        "d": 5,
+        "l": 600
+      },
+      {
+        "t": 74100,
+        "d": 2
+      },
+      {
+        "t": 74400,
+        "d": 0
+      },
+      {
+        "t": 74700,
+        "d": 3
+      },
+      {
+        "t": 75300,
+        "d": 2
+      },
+      {
+        "t": 75600,
+        "d": 1,
+        "l": 600
+      }
+    ],
+    "hard": [
+      {
+        "t": 0,
+        "d": 6
+      },
+      {
+        "t": 600,
+        "d": 7,
+        "l": 600
+      },
+      {
+        "t": 1200,
+        "d": 7,
+        "l": 600
+      },
+      {
+        "t": 2400,
+        "d": 2
+      },
+      {
+        "t": 3000,
+        "d": 3,
+        "l": 600
+      },
+      {
+        "t": 3600,
+        "d": 3,
+        "l": 600
+      },
+      {
+        "t": 4800,
+        "d": 5,
+        "l": 300
+      },
+      {
+        "t": 5400,
+        "d": 4,
+        "l": 300
+      },
+      {
+        "t": 6000,
+        "d": 7,
+        "l": 600
+      },
+      {
+        "t": 7200,
+        "d": 1,
+        "l": 300
+      },
+      {
+        "t": 7800,
+        "d": 0,
+        "l": 300
+      },
+      {
+        "t": 8400,
+        "d": 3,
+        "l": 600
+      },
+      {
+        "t": 9600,
+        "d": 5,
+        "l": 300
+      },
+      {
+        "t": 10200,
+        "d": 7
+      },
+      {
+        "t": 10500,
+        "d": 4
+      },
+      {
+        "t": 10800,
+        "d": 5,
+        "l": 600
+      },
+      {
+        "t": 12000,
+        "d": 1,
+        "l": 300
+      },
+      {
+        "t": 12600,
+        "d": 3
+      },
+      {
+        "t": 12900,
+        "d": 0
+      },
+      {
+        "t": 13200,
+        "d": 1,
+        "l": 600
+      },
+      {
+        "t": 14400,
+        "d": 7
+      },
+      {
+        "t": 14700,
+        "d": 5
+      },
+      {
+        "t": 15300,
+        "d": 4
+      },
+      {
+        "t": 15600,
+        "d": 6,
+        "l": 600
+      },
+      {
+        "t": 16800,
+        "d": 3
+      },
+      {
+        "t": 17100,
+        "d": 1
+      },
+      {
+        "t": 17700,
+        "d": 0
+      },
+      {
+        "t": 18000,
+        "d": 2,
+        "l": 600
+      },
+      {
+        "t": 19200,
+        "d": 4
+      },
+      {
+        "t": 19500,
+        "d": 7
+      },
+      {
+        "t": 19800,
+        "d": 5,
+        "l": 900
+      },
+      {
+        "t": 21600,
+        "d": 0
+      },
+      {
+        "t": 21900,
+        "d": 3
+      },
+      {
+        "t": 22200,
+        "d": 1,
+        "l": 900
+      },
+      {
+        "t": 24000,
+        "d": 5
+      },
+      {
+        "t": 24300,
+        "d": 7
+      },
+      {
+        "t": 24600,
+        "d": 4,
+        "l": 900
+      },
+      {
+        "t": 26400,
+        "d": 1
+      },
+      {
+        "t": 26700,
+        "d": 3
+      },
+      {
+        "t": 27000,
+        "d": 0,
+        "l": 900
+      },
+      {
+        "t": 28800,
+        "d": 6
+      },
+      {
+        "t": 29100,
+        "d": 7
+      },
+      {
+        "t": 29400,
+        "d": 4,
+        "l": 1200
+      },
+      {
+        "t": 31200,
+        "d": 2
+      },
+      {
+        "t": 31500,
+        "d": 3
+      },
+      {
+        "t": 31800,
+        "d": 0,
+        "l": 1200
+      },
+      {
+        "t": 33300,
+        "d": 6
+      },
+      {
+        "t": 33600,
+        "d": 4
+      },
+      {
+        "t": 33900,
+        "d": 7
+      },
+      {
+        "t": 34500,
+        "d": 6
+      },
+      {
+        "t": 34575,
+        "d": 4
+      },
+      {
+        "t": 34800,
+        "d": 5,
+        "l": 600
+      },
+      {
+        "t": 35700,
+        "d": 2
+      },
+      {
+        "t": 36000,
+        "d": 0
+      },
+      {
+        "t": 36300,
+        "d": 3
+      },
+      {
+        "t": 36900,
+        "d": 2
+      },
+      {
+        "t": 36975,
+        "d": 0
+      },
+      {
+        "t": 37200,
+        "d": 1,
+        "l": 600
+      },
+      {
+        "t": 38400,
+        "d": 6,
+        "l": 450
+      },
+      {
+        "t": 39000,
+        "d": 7,
+        "l": 300
+      },
+      {
+        "t": 39600,
+        "d": 4,
+        "l": 600
+      },
+      {
+        "t": 40800,
+        "d": 2,
+        "l": 450
+      },
+      {
+        "t": 41400,
+        "d": 3,
+        "l": 300
+      },
+      {
+        "t": 42000,
+        "d": 0,
+        "l": 600
+      },
+      {
+        "t": 43200,
+        "d": 5
+      },
+      {
+        "t": 43800,
+        "d": 6
+      },
+      {
+        "t": 44400,
+        "d": 5
+      },
+      {
+        "t": 44550,
+        "d": 5
+      },
+      {
+        "t": 44700,
+        "d": 5
+      },
+      {
+        "t": 45000,
+        "d": 6
+      },
+      {
+        "t": 45600,
+        "d": 1
+      },
+      {
+        "t": 46200,
+        "d": 2
+      },
+      {
+        "t": 46800,
+        "d": 1
+      },
+      {
+        "t": 46950,
+        "d": 1
+      },
+      {
+        "t": 47100,
+        "d": 1
+      },
+      {
+        "t": 47400,
+        "d": 2
+      },
+      {
+        "t": 48000,
+        "d": 6,
+        "l": 450
+      },
+      {
+        "t": 48600,
+        "d": 7,
+        "l": 300
+      },
+      {
+        "t": 49200,
+        "d": 4,
+        "l": 450
+      },
+      {
+        "t": 50400,
+        "d": 2,
+        "l": 450
+      },
+      {
+        "t": 51000,
+        "d": 3,
+        "l": 300
+      },
+      {
+        "t": 51600,
+        "d": 0,
+        "l": 450
+      },
+      {
+        "t": 52800,
+        "d": 7,
+        "l": 1800
+      },
+      {
+        "t": 55200,
+        "d": 3,
+        "l": 1800
+      },
+      {
+        "t": 57600,
+        "d": 6
+      },
+      {
+        "t": 57900,
+        "d": 7
+      },
+      {
+        "t": 58200,
+        "d": 4,
+        "l": 1200
+      },
+      {
+        "t": 60000,
+        "d": 2
+      },
+      {
+        "t": 60300,
+        "d": 3
+      },
+      {
+        "t": 60600,
+        "d": 0,
+        "l": 1200
+      },
+      {
+        "t": 62100,
+        "d": 6
+      },
+      {
+        "t": 62400,
+        "d": 4
+      },
+      {
+        "t": 62700,
+        "d": 7
+      },
+      {
+        "t": 63300,
+        "d": 6
+      },
+      {
+        "t": 63375,
+        "d": 4
+      },
+      {
+        "t": 63600,
+        "d": 5,
+        "l": 600
+      },
+      {
+        "t": 64500,
+        "d": 2
+      },
+      {
+        "t": 64800,
+        "d": 0
+      },
+      {
+        "t": 65100,
+        "d": 3
+      },
+      {
+        "t": 65700,
+        "d": 2
+      },
+      {
+        "t": 65775,
+        "d": 0
+      },
+      {
+        "t": 66000,
+        "d": 1,
+        "l": 600
+      },
+      {
+        "t": 67200,
+        "d": 6
+      },
+      {
+        "t": 67500,
+        "d": 7
+      },
+      {
+        "t": 67800,
+        "d": 4
+      },
+      {
+        "t": 68100,
+        "d": 6
+      },
+      {
+        "t": 68400,
+        "d": 5,
+        "l": 600
+      },
+      {
+        "t": 69600,
+        "d": 2
+      },
+      {
+        "t": 69900,
+        "d": 3
+      },
+      {
+        "t": 70200,
+        "d": 0
+      },
+      {
+        "t": 70500,
+        "d": 2
+      },
+      {
+        "t": 70800,
+        "d": 1,
+        "l": 600
+      },
+      {
+        "t": 71700,
+        "d": 6
+      },
+      {
+        "t": 72000,
+        "d": 4
+      },
+      {
+        "t": 72300,
+        "d": 7
+      },
+      {
+        "t": 72900,
+        "d": 6
+      },
+      {
+        "t": 72975,
+        "d": 4
+      },
+      {
+        "t": 73200,
+        "d": 5,
+        "l": 600
+      },
+      {
+        "t": 74100,
+        "d": 2
+      },
+      {
+        "t": 74400,
+        "d": 0
+      },
+      {
+        "t": 74700,
+        "d": 3
+      },
+      {
+        "t": 75300,
+        "d": 2
+      },
+      {
+        "t": 75375,
+        "d": 0
+      },
+      {
+        "t": 75600,
+        "d": 1,
+        "l": 600
+      }
+    ]
+  },
+  "generatedBy": "MasterEric (by hand)"
+}
diff --git a/tests/unit/assets/preload/data/songs/bopeebo/bopeebo-metadata.json b/tests/unit/assets/preload/data/songs/bopeebo/bopeebo-metadata.json
new file mode 100644
index 000000000..c7f7629d6
--- /dev/null
+++ b/tests/unit/assets/preload/data/songs/bopeebo/bopeebo-metadata.json
@@ -0,0 +1,15 @@
+{
+  "version": "2.0.0",
+  "songName": "Bopeebo",
+  "artist": "Kawai Sprite",
+  "timeFormat": "ms",
+  "timeChanges": [{ "t": 0, "bpm": 100, "n": 4, "d": 4, "bt": [4, 4, 4, 4] }],
+  "playData": {
+    "songVariations": [],
+    "difficulties": ["easy", "normal", "hard"],
+    "playableChars": { "bf": { "g": "gf", "o": "dad" } },
+    "stage": "mainStage",
+    "noteSkin": "Normal"
+  },
+  "generatedBy": "MasterEric (by hand)"
+}
diff --git a/tests/unit/project.xml b/tests/unit/project.xml
index 63f164607..2e505e015 100644
--- a/tests/unit/project.xml
+++ b/tests/unit/project.xml
@@ -44,11 +44,12 @@
 	<!-- Assets -->
 	<assets path="assets/preload" rename="assets" exclude="*.ogg" if="web" />
 	<assets path="assets/preload" rename="assets" exclude="*.mp3" unless="web" />
+	<assets path="assets/shared" library="shared" exclude="*.fla|*.ogg" if="web" />
+	<assets path="assets/shared" library="shared" exclude="*.fla|*.mp3" unless="web" />
+	<library name="shared" preload="true" />
 	<!--
 	<assets path="assets/songs" library="songs" exclude="*.fla|*.ogg" if="web" />
 	<assets path="assets/songs" library="songs" exclude="*.fla|*.mp3" unless="web" />
-	<assets path="assets/shared" library="shared" exclude="*.fla|*.ogg" if="web" />
-	<assets path="assets/shared" library="shared" exclude="*.fla|*.mp3" unless="web" />
 	<assets path="assets/tutorial" library="tutorial" exclude="*.fla|*.ogg" if="web" />
 	<assets path="assets/tutorial" library="tutorial" exclude="*.fla|*.mp3" unless="web" />
 	<assets path="assets/week1" library="week1" exclude="*.fla|*.ogg" if="web" />
@@ -68,7 +69,6 @@
 	<assets path="assets/weekend1" library="weekend1" exclude="*.fla|*.ogg" if="web" />
 	<assets path="assets/weekend1" library="weekend1" exclude="*.fla|*.mp3" unless="web" />
 	<library name="songs" preload="true" />
-	<library name="shared" preload="true" />
 	<library name="tutorial" preload="true" />
 	<library name="week1" preload="true" />
 	<library name="week2" preload="true" />
@@ -87,8 +87,8 @@
 	<haxedef name="FLX_RECORD" />
 
 	<!-- Clean up the output -->
-	<haxedef name="no-traces" />
 	<!--
+		<haxedef name="echo-traces" />
 	-->
 	<haxedef name="ignore-inline" />
 	<haxeflag name="-w" value="-WDeprecated" />
diff --git a/tests/unit/source/FunkinAssert.hx b/tests/unit/source/FunkinAssert.hx
index 00c3a9e00..6a0b0a9a4 100644
--- a/tests/unit/source/FunkinAssert.hx
+++ b/tests/unit/source/FunkinAssert.hx
@@ -127,4 +127,54 @@ class FunkinAssert
     };
     validateThrows(targetFunc, predicate, info);
   }
+
+  static var capturedTraces:Array<String> = [];
+
+  public static function initAssertTrace():Void
+  {
+    var oldTrace = haxe.Log.trace;
+    haxe.Log.trace = function(v:Dynamic, ?infos:haxe.PosInfos) {
+      onTrace(v, infos);
+      // oldTrace(v, infos);
+    };
+  }
+
+  public static function clearTraces():Void
+  {
+    capturedTraces = [];
+  }
+
+  @:nullSafety(Off) // Why isn't haxe.std null-safe?
+  static function onTrace(v:Dynamic, ?infos:haxe.PosInfos)
+  {
+    // var str:String = haxe.Log.formatOutput(v, infos);
+    var str:String = Std.string(v);
+    capturedTraces.push(str);
+
+    #if (sys && echo_traces)
+    Sys.println('[TESTLOG] $str');
+    #end
+  }
+
+  /**
+   * Check the first string that was traced and validate it.
+   * @param expected
+   */
+  public static inline function assertTrace(expected:String):Void
+  {
+    var actual:Null<String> = capturedTraces.shift();
+    Assert.isNotNull(actual);
+    Assert.areEqual(expected, actual);
+  }
+
+  /**
+   * Check the first string that was traced and validate it.
+   * @param expected
+   */
+  public static inline function assertLastTrace(expected:String):Void
+  {
+    var actual:Null<String> = capturedTraces.pop();
+    Assert.isNotNull(actual);
+    Assert.areEqual(expected, actual);
+  }
 }
diff --git a/tests/unit/source/MockTest.hx b/tests/unit/source/MockTest.hx
index 308dbb45a..e9345dc2d 100644
--- a/tests/unit/source/MockTest.hx
+++ b/tests/unit/source/MockTest.hx
@@ -30,8 +30,8 @@ class MockTest extends FunkinTest
   {
     // Test that mocking works.
 
-    var mockSprite = mockatoo.Mockatoo.mock(flixel.FlxSprite);
-    var mockAnim = mockatoo.Mockatoo.mock(flixel.animation.FlxAnimationController);
+    var mockSprite = Mockatoo.mock(flixel.FlxSprite);
+    var mockAnim = Mockatoo.mock(flixel.animation.FlxAnimationController);
     mockSprite.animation = mockAnim;
 
     var animData:funkin.data.animation.AnimationData =
@@ -44,12 +44,12 @@ class MockTest extends FunkinTest
 
     // Verify that the method was called once.
     // If not, a VerificationException will be thrown and the test will fail.
-    mockatoo.Mockatoo.verify(mockAnim.addByPrefix("testAnim", "blablabla", 24, false, false, false), times(1));
+    mockAnim.addByPrefix("testAnim", "blablabla", 24, false, false, false).verify(times(1));
 
     FunkinAssert.validateThrows(function() {
       // Attempt to verify the method was called.
       // This should FAIL, since we didn't call the method.
-      mockatoo.Mockatoo.verify(mockAnim.addByPrefix("testAnim", "blablabla", 24, false, false, false), times(1));
+      mockAnim.addByPrefix("testAnim", "blablabla", 24, false, false, false).verify(times(1));
     }, function(err) {
       return Std.isOfType(err, mockatoo.exception.VerificationException);
     });
diff --git a/tests/unit/source/funkin/ConductorTest.hx b/tests/unit/source/funkin/ConductorTest.hx
index 0cfbe3960..c65f3f297 100644
--- a/tests/unit/source/funkin/ConductorTest.hx
+++ b/tests/unit/source/funkin/ConductorTest.hx
@@ -3,7 +3,7 @@ package funkin;
 import flixel.FlxG;
 import flixel.FlxState;
 import funkin.Conductor;
-import funkin.play.song.SongData.SongTimeChange;
+import funkin.data.song.SongData.SongTimeChange;
 import funkin.util.Constants;
 import massive.munit.Assert;
 
@@ -16,6 +16,8 @@ class ConductorTest extends FunkinTest
   @Before
   function before()
   {
+    FunkinAssert.initAssertTrace();
+
     resetGame();
 
     // The ConductorState will advance the conductor when step() is called.
@@ -193,16 +195,7 @@ class ConductorTest extends FunkinTest
   function testSingleTimeChange():Void
   {
     // Start the song with a BPM of 120.
-    var songTimeChanges:Array<SongTimeChange> = [
-      {
-        t: 0,
-        b: 0,
-        bpm: 120,
-        n: 4,
-        d: 4,
-        bt: [4, 4, 4, 4]
-      }, // 120 bpm starting 0 sec/0 beats
-    ];
+    var songTimeChanges:Array<SongTimeChange> = [new SongTimeChange(0, 120)];
     Conductor.mapTimeChanges(songTimeChanges);
 
     // All should be at 0.
@@ -253,24 +246,7 @@ class ConductorTest extends FunkinTest
   function testDoubleTimeChange():Void
   {
     // Start the song with a BPM of 120.
-    var songTimeChanges:Array<SongTimeChange> = [
-      {
-        t: 0,
-        b: 0,
-        bpm: 120,
-        n: 4,
-        d: 4,
-        bt: [4, 4, 4, 4]
-      }, // 120 bpm starting 0 sec/0 beats
-      {
-        t: 3000,
-        b: 6,
-        bpm: 90,
-        n: 4,
-        d: 4,
-        bt: [4, 4, 4, 4]
-      } // 90 bpm starting 3 sec/6 beats
-    ];
+    var songTimeChanges:Array<SongTimeChange> = [new SongTimeChange(0, 120), new SongTimeChange(3000, 90)];
     Conductor.mapTimeChanges(songTimeChanges);
 
     // All should be at 0.
@@ -354,30 +330,9 @@ class ConductorTest extends FunkinTest
   {
     // Start the song with a BPM of 120, then move to 90, then move to 180.
     var songTimeChanges:Array<SongTimeChange> = [
-      {
-        t: 0,
-        b: null,
-        bpm: 120,
-        n: 4,
-        d: 4,
-        bt: [4, 4, 4, 4]
-      }, // 120 bpm starting 0 sec/0 beats
-      {
-        t: 3000,
-        b: null,
-        bpm: 90,
-        n: 4,
-        d: 4,
-        bt: [4, 4, 4, 4]
-      }, // 90 bpm starting 3 sec/6 beats
-      {
-        t: 6000,
-        b: null,
-        bpm: 180,
-        n: 4,
-        d: 4,
-        bt: [4, 4, 4, 4]
-      } // 90 bpm starting 3 sec/6 beats
+      new SongTimeChange(0, 120),
+      new SongTimeChange(3000, 90),
+      new SongTimeChange(6000, 180)
     ];
     Conductor.mapTimeChanges(songTimeChanges);
 
diff --git a/tests/unit/source/funkin/data/BaseRegistryTest.hx b/tests/unit/source/funkin/data/BaseRegistryTest.hx
index 31e44b6ed..0be932d35 100644
--- a/tests/unit/source/funkin/data/BaseRegistryTest.hx
+++ b/tests/unit/source/funkin/data/BaseRegistryTest.hx
@@ -17,7 +17,10 @@ class BaseRegistryTest extends FunkinTest
   }
 
   @BeforeClass
-  public function beforeClass() {}
+  public function beforeClass()
+  {
+    FunkinAssert.initAssertTrace();
+  }
 
   @AfterClass
   public function afterClass() {}
@@ -118,7 +121,7 @@ class MyType implements IRegistryEntry<MyTypeData>
     return 'MyType($id)';
   }
 
-  public function _fetchData(id:String):Null<MyTypeData>
+  static function _fetchData(id:String):Null<MyTypeData>
   {
     return MyTypeRegistry.instance.parseEntryDataWithMigration(id, MyTypeRegistry.instance.fetchEntryVersion(id));
   }
@@ -153,17 +156,18 @@ class MyTypeRegistry extends BaseRegistry<MyType, MyTypeData>
     // JsonParser does not take type parameters,
     // otherwise this function would be in BaseRegistry.
     var parser = new json2object.JsonParser<MyTypeData>();
-    var jsonStr:String = loadEntryFile(id);
 
-    parser.fromJson(jsonStr);
+    switch (loadEntryFile(id))
+    {
+      case {fileName: fileName, contents: contents}:
+        parser.fromJson(contents, fileName);
+      default:
+        return null;
+    }
 
     if (parser.errors.length > 0)
     {
-      trace('[${registryId}] Failed to parse entry data: ${id}');
-      for (error in parser.errors)
-      {
-        trace(error);
-      }
+      printErrors(parser.errors, id);
       return null;
     }
     return parser.value;
@@ -177,31 +181,33 @@ class MyTypeRegistry extends BaseRegistry<MyType, MyTypeData>
     // JsonParser does not take type parameters,
     // otherwise this function would be in BaseRegistry.
     var parser = new json2object.JsonParser<MyTypeData_v0_1_x>();
-    var jsonStr:String = loadEntryFile(id);
 
-    parser.fromJson(jsonStr);
+    switch (loadEntryFile(id))
+    {
+      case {fileName: fileName, contents: contents}:
+        parser.fromJson(contents, fileName);
+      default:
+        return null;
+    }
 
     if (parser.errors.length > 0)
     {
-      trace('[${registryId}] Failed to parse entry data: ${id}');
-      for (error in parser.errors)
-      {
-        trace(error);
-      }
+      printErrors(parser.errors, id);
       return null;
     }
 
     var oldData:MyTypeData_v0_1_x = parser.value;
+    return migrateData_v0_1_x(oldData);
+  }
 
-    var result:MyTypeData =
-      {
-        version: DATA_VERSION,
-        id: '${oldData.id}',
-        name: oldData.name,
-        data: []
-      };
-
-    return result;
+  function migrateData_v0_1_x(input:MyTypeData_v0_1_x):MyTypeData
+  {
+    return {
+      version: DATA_VERSION,
+      id: '${input.id}',
+      name: input.name,
+      data: []
+    };
   }
 
   public override function parseEntryDataWithMigration(id:String, version:thx.semver.Version):Null<MyTypeData>
diff --git a/tests/unit/source/funkin/data/level/LevelRegistryTest.hx b/tests/unit/source/funkin/data/level/LevelRegistryTest.hx
index 3d9cf5d29..691d8bedc 100644
--- a/tests/unit/source/funkin/data/level/LevelRegistryTest.hx
+++ b/tests/unit/source/funkin/data/level/LevelRegistryTest.hx
@@ -123,9 +123,12 @@ class LevelRegistryTest extends FunkinTest
   }
 
   @Test
-  @Ignore("Requires redoing validation.")
   public function testCreateEntryBlankPath():Void
   {
+    // Using @:jcustomparse, `titleAsset` has a validation function that ensures it is not blank.
+    // This test makes sure that the validation function is being called, and that the error
+    // results in the level failing to parse.
+
     FunkinAssert.validateThrows(function() {
       var result:Null<Level> = LevelRegistry.instance.createEntry("blankpathtest");
     }, function(err) {
@@ -134,7 +137,6 @@ class LevelRegistryTest extends FunkinTest
   }
 
   @Test
-  @Ignore("Requires redoing validation.")
   public function testFetchBadEntry():Void
   {
     var result:Null<Level> = LevelRegistry.instance.fetchEntry("blablabla");
diff --git a/tests/unit/source/funkin/data/notestyle/NoteStyleRegistryTest.hx b/tests/unit/source/funkin/data/notestyle/NoteStyleRegistryTest.hx
index 8ae9cb31f..447ee7831 100644
--- a/tests/unit/source/funkin/data/notestyle/NoteStyleRegistryTest.hx
+++ b/tests/unit/source/funkin/data/notestyle/NoteStyleRegistryTest.hx
@@ -86,7 +86,7 @@ class NoteStyleRegistryTest extends FunkinTest
   @Test
   public function testFetchDefault():Void
   {
-    var nsrMock:NoteStyleRegistry = mock(NoteStyleRegistry);
+    var nsrMock = Mockatoo.mock(NoteStyleRegistry);
 
     nsrMock.fetchDefault().callsRealMethod();
 
diff --git a/tests/unit/source/funkin/data/song/SongRegistryTest.hx b/tests/unit/source/funkin/data/song/SongRegistryTest.hx
new file mode 100644
index 000000000..c623306a6
--- /dev/null
+++ b/tests/unit/source/funkin/data/song/SongRegistryTest.hx
@@ -0,0 +1,133 @@
+package funkin.data.song;
+
+import funkin.data.song.SongData;
+import funkin.data.song.SongRegistry;
+import funkin.play.song.Song;
+import massive.munit.Assert;
+import massive.munit.async.AsyncFactory;
+import massive.munit.util.Timer;
+
+@:nullSafety
+@:access(funkin.play.song.Song)
+@:access(funkin.data.song.SongRegistry)
+class SongRegistryTest extends FunkinTest
+{
+  public function new()
+  {
+    super();
+  }
+
+  @BeforeClass
+  public function beforeClass():Void
+  {
+    FunkinAssert.initAssertTrace();
+    SongRegistry.instance.loadEntries();
+  }
+
+  @AfterClass
+  public function afterClass():Void {}
+
+  @Before
+  public function setup():Void {}
+
+  @After
+  public function tearDown():Void {}
+
+  @Test
+  public function testValid():Void
+  {
+    Assert.isNotNull(SongRegistry.instance);
+  }
+
+  @Test
+  public function testParseMetadata():Void
+  {
+    var result:Null<SongData.SongMetadata> = SongRegistry.instance.parseEntryMetadata("bopeebo");
+
+    Assert.isNotNull(result);
+
+    var expectedVersion:thx.semver.Version = "2.0.0";
+    Assert.areEqual(expectedVersion, result.version);
+    Assert.areEqual("Bopeebo", result.songName);
+    Assert.areEqual("Kawai Sprite", result.artist);
+    Assert.areEqual(SongData.SongTimeFormat.MILLISECONDS, result.timeFormat);
+    Assert.areEqual("MasterEric (by hand)", result.generatedBy);
+  }
+
+  @Test
+  public function testParseChartData():Void
+  {
+    var result:Null<SongData.SongChartData> = SongRegistry.instance.parseEntryChartData("bopeebo");
+
+    Assert.isNotNull(result);
+
+    var expectedVersion:thx.semver.Version = "2.0.0";
+    Assert.areEqual(expectedVersion, result.version);
+  }
+
+  /**
+   * A test validating an error is thrown when attempting to parse chart data as metadata.
+   */
+  @Test
+  public function testParseMetadataSwapped():Void
+  {
+    // Arrange
+    FunkinAssert.clearTraces();
+
+    // Act
+    var result:Null<SongData.SongMetadata> = SongRegistry.instance.parseEntryMetadata("bopeebo-swapped");
+
+    // Assert
+    Assert.isNull(result);
+    FunkinAssert.assertTrace("[SONG] Failed to parse entry data: bopeebo-swapped");
+    FunkinAssert.assertTrace("  Unknown variable \"scrollSpeed\"");
+    FunkinAssert.assertTrace("    at assets/data/songs/bopeebo-swapped/bopeebo-swapped-metadata.json:3");
+    FunkinAssert.assertTrace("  Unknown variable \"events\"");
+    FunkinAssert.assertTrace("    at assets/data/songs/bopeebo-swapped/bopeebo-swapped-metadata.json:7");
+    FunkinAssert.assertTrace("  Unknown variable \"notes\"");
+    FunkinAssert.assertTrace("    at assets/data/songs/bopeebo-swapped/bopeebo-swapped-metadata.json:185");
+    FunkinAssert.assertTrace("  Uninitialized variable \"songName\"");
+    FunkinAssert.assertTrace("    at assets/data/songs/bopeebo-swapped/bopeebo-swapped-metadata.json:1738");
+    FunkinAssert.assertTrace("  Uninitialized variable \"timeFormat\"");
+    FunkinAssert.assertTrace("    at assets/data/songs/bopeebo-swapped/bopeebo-swapped-metadata.json:1738");
+    FunkinAssert.assertTrace("  Uninitialized variable \"timeChanges\"");
+    FunkinAssert.assertTrace("    at assets/data/songs/bopeebo-swapped/bopeebo-swapped-metadata.json:1738");
+    FunkinAssert.assertTrace("  Uninitialized variable \"playData\"");
+    FunkinAssert.assertTrace("    at assets/data/songs/bopeebo-swapped/bopeebo-swapped-metadata.json:1738");
+    FunkinAssert.assertTrace("  Uninitialized variable \"artist\"");
+    FunkinAssert.assertTrace("    at assets/data/songs/bopeebo-swapped/bopeebo-swapped-metadata.json:1738");
+  }
+
+  /**
+   * A test validating an error is thrown when attempting to parse metadata as chart data.
+   */
+  @Test
+  public function testParseChartDataSwapped():Void
+  {
+    // Arrange
+    FunkinAssert.clearTraces();
+
+    // Act
+    var result:Null<SongData.SongChartData> = SongRegistry.instance.parseEntryChartData("bopeebo-swapped");
+
+    // Assert
+    Assert.isNull(result);
+    FunkinAssert.assertTrace("[SONG] Failed to parse entry data: bopeebo-swapped");
+    FunkinAssert.assertTrace("  Unknown variable \"songName\"");
+    FunkinAssert.assertTrace("    at assets/data/songs/bopeebo-swapped/bopeebo-swapped-chart.json:3");
+    FunkinAssert.assertTrace("  Unknown variable \"artist\"");
+    FunkinAssert.assertTrace("    at assets/data/songs/bopeebo-swapped/bopeebo-swapped-chart.json:4");
+    FunkinAssert.assertTrace("  Unknown variable \"timeFormat\"");
+    FunkinAssert.assertTrace("    at assets/data/songs/bopeebo-swapped/bopeebo-swapped-chart.json:5");
+    FunkinAssert.assertTrace("  Unknown variable \"timeChanges\"");
+    FunkinAssert.assertTrace("    at assets/data/songs/bopeebo-swapped/bopeebo-swapped-chart.json:6");
+    FunkinAssert.assertTrace("  Unknown variable \"playData\"");
+    FunkinAssert.assertTrace("    at assets/data/songs/bopeebo-swapped/bopeebo-swapped-chart.json:7");
+    FunkinAssert.assertTrace("  Uninitialized variable \"notes\"");
+    FunkinAssert.assertTrace("    at assets/data/songs/bopeebo-swapped/bopeebo-swapped-chart.json:15");
+    FunkinAssert.assertTrace("  Uninitialized variable \"events\"");
+    FunkinAssert.assertTrace("    at assets/data/songs/bopeebo-swapped/bopeebo-swapped-chart.json:15");
+    FunkinAssert.assertTrace("  Uninitialized variable \"scrollSpeed\"");
+    FunkinAssert.assertTrace("    at assets/data/songs/bopeebo-swapped/bopeebo-swapped-chart.json:15");
+  }
+}
diff --git a/tests/unit/source/funkin/play/notes/notestyle/NoteStyleTest.hx b/tests/unit/source/funkin/play/notes/notestyle/NoteStyleTest.hx
index 6b4b46c10..f2d3e570c 100644
--- a/tests/unit/source/funkin/play/notes/notestyle/NoteStyleTest.hx
+++ b/tests/unit/source/funkin/play/notes/notestyle/NoteStyleTest.hx
@@ -1,11 +1,18 @@
 package funkin.play.notes.notestyle;
 
+import flixel.util.FlxSort;
+import funkin.util.SortUtil;
+import flixel.graphics.FlxGraphic;
+import flixel.graphics.frames.FlxFrame;
+import flixel.graphics.frames.FlxFramesCollection;
 import massive.munit.util.Timer;
 import massive.munit.Assert;
 import massive.munit.async.AsyncFactory;
 import funkin.data.notestyle.NoteStyleRegistry;
 import funkin.play.notes.notestyle.NoteStyle;
 import flixel.animation.FlxAnimationController;
+import openfl.utils.Assets;
+import flixel.math.FlxPoint;
 
 @:access(funkin.play.notes.notestyle.NoteStyle)
 class NoteStyleTest extends FunkinTest
@@ -31,20 +38,142 @@ class NoteStyleTest extends FunkinTest
   public function tearDown() {}
 
   @Test
-  @Ignore("This test doesn't work, crashes when the project has 2 mocks of the same class???")
   public function testBuildNoteSprite()
   {
     var target:Null<NoteStyle> = NoteStyleRegistry.instance.fetchEntry("funkin");
 
     Assert.isNotNull(target);
 
-    var mockNoteSprite:NoteSprite = mock(NoteSprite);
-    // var mockAnim = mock(FlxAnimationController);
-    // mockNoteSprite.animation = mockAnim;
+    // Arrange
+    var mockNoteSprite = Mockatoo.mock(NoteSprite);
+    var mockAnim = Mockatoo.mock(FlxAnimationController);
+    var scale = new FlxPoint(1, 1); // handle sprite.scale.x on the mock
 
+    mockNoteSprite.animation = mockAnim; // Tell the mock to forward calls to the animation controller mock.
+    mockNoteSprite.scale.returns(scale); // Redirect this final variable to a local variable.
+    mockNoteSprite.antialiasing.callsRealMethod(); // Tell the mock to treat this like a normal property.
+    mockNoteSprite.frames.callsRealMethod(); // Tell the mock to treat this like a normal property.
+
+    // Act
     target.buildNoteSprite(mockNoteSprite);
 
-    Assert.areEqual(mockNoteSprite.frames, []);
+    var expectedGraphic:FlxGraphic = FlxG.bitmap.add("shared:assets/shared/images/arrows.png");
+
+    // Assert
+    Assert.isNotNull(mockNoteSprite.frames);
+    mockNoteSprite.frames.frames.sort(SortUtil.byFrameName);
+    var frameCount:Int = mockNoteSprite.frames.frames.length;
+    Assert.areEqual(24, frameCount);
+
+    // Validate each frame.
+    for (i in 0...frameCount)
+    {
+      var currentFrame:FlxFrame = mockNoteSprite.frames.frames[i];
+      switch (i)
+      {
+        case 0:
+          Assert.areEqual("confirmDown0001", currentFrame.name);
+          Assert.areEqual(expectedGraphic, currentFrame.parent);
+        case 1:
+          Assert.areEqual("confirmDown0002", currentFrame.name);
+          Assert.areEqual(expectedGraphic, currentFrame.parent);
+        case 2:
+          Assert.areEqual("confirmLeft0001", currentFrame.name);
+          Assert.areEqual(expectedGraphic, currentFrame.parent);
+        case 3:
+          Assert.areEqual("confirmLeft0002", currentFrame.name);
+          Assert.areEqual(expectedGraphic, currentFrame.parent);
+        case 4:
+          Assert.areEqual("confirmRight0001", currentFrame.name);
+          Assert.areEqual(expectedGraphic, currentFrame.parent);
+        case 5:
+          Assert.areEqual("confirmRight0002", currentFrame.name);
+          Assert.areEqual(expectedGraphic, currentFrame.parent);
+        case 6:
+          Assert.areEqual("confirmUp0001", currentFrame.name);
+          Assert.areEqual(expectedGraphic, currentFrame.parent);
+        case 7:
+          Assert.areEqual("confirmUp0002", currentFrame.name);
+          Assert.areEqual(expectedGraphic, currentFrame.parent);
+        case 8:
+          Assert.areEqual("noteDown0001", currentFrame.name);
+          Assert.areEqual(expectedGraphic, currentFrame.parent);
+        case 9:
+          Assert.areEqual("noteLeft0001", currentFrame.name);
+          Assert.areEqual(expectedGraphic, currentFrame.parent);
+        case 10:
+          Assert.areEqual("noteRight0001", currentFrame.name);
+          Assert.areEqual(expectedGraphic, currentFrame.parent);
+        case 11:
+          Assert.areEqual("noteUp0001", currentFrame.name);
+          Assert.areEqual(expectedGraphic, currentFrame.parent);
+        case 12:
+          Assert.areEqual("pressedDown0001", currentFrame.name);
+          Assert.areEqual(expectedGraphic, currentFrame.parent);
+        case 13:
+          Assert.areEqual("pressedDown0002", currentFrame.name);
+          Assert.areEqual(expectedGraphic, currentFrame.parent);
+        case 14:
+          Assert.areEqual("pressedLeft0001", currentFrame.name);
+          Assert.areEqual(expectedGraphic, currentFrame.parent);
+        case 15:
+          Assert.areEqual("pressedLeft0002", currentFrame.name);
+          Assert.areEqual(expectedGraphic, currentFrame.parent);
+        case 16:
+          Assert.areEqual("pressedRight0001", currentFrame.name);
+          Assert.areEqual(expectedGraphic, currentFrame.parent);
+        case 17:
+          Assert.areEqual("pressedRight0002", currentFrame.name);
+          Assert.areEqual(expectedGraphic, currentFrame.parent);
+        case 18:
+          Assert.areEqual("pressedUp0001", currentFrame.name);
+          Assert.areEqual(expectedGraphic, currentFrame.parent);
+        case 19:
+          Assert.areEqual("pressedUp0002", currentFrame.name);
+          Assert.areEqual(expectedGraphic, currentFrame.parent);
+        case 20:
+          Assert.areEqual("staticDown0001", currentFrame.name);
+          Assert.areEqual(expectedGraphic, currentFrame.parent);
+        case 21:
+          Assert.areEqual("staticLeft0001", currentFrame.name);
+          Assert.areEqual(expectedGraphic, currentFrame.parent);
+        case 22:
+          Assert.areEqual("staticRight0001", currentFrame.name);
+          Assert.areEqual(expectedGraphic, currentFrame.parent);
+        case 23:
+          Assert.areEqual("staticUp0001", currentFrame.name);
+          Assert.areEqual(expectedGraphic, currentFrame.parent);
+        default:
+          Assert.fail('Got unexpected frame number ${i}');
+      }
+    }
+
+    // Verify animations were applied.
+    @:privateAccess {
+      mockAnim.addByPrefix('purpleScroll', 'noteLeft', 24, false, false, false).verify(times(1));
+      mockAnim.addByPrefix('blueScroll', 'noteDown', 24, false, false, false).verify(times(1));
+      mockAnim.addByPrefix('greenScroll', 'noteUp', 24, false, false, false).verify(times(1));
+      mockAnim.addByPrefix('redScroll', 'noteRight', 24, false, false, false).verify(times(1));
+      mockAnim.destroyAnimations().verify(times(1));
+      mockAnim.set_frameIndex(0).verify(times(1));
+      // Verify there were no other functions called.
+      mockAnim.verifyZeroInteractions();
+    }
+
+    // Verify sprite was initialized.
+    @:privateAccess {
+      mockNoteSprite.set_graphic(expectedGraphic).verify(times(1));
+      mockNoteSprite.graphicLoaded().verify(times(1));
+      mockNoteSprite.set_antialiasing(true).verify(times(1));
+      mockNoteSprite.set_frames(mockNoteSprite.frames).verify(times(1));
+      mockNoteSprite.set_frame(mockNoteSprite.frames.frames[21]).verify(times(1));
+      mockNoteSprite.resetHelpers().verify(times(1));
+
+      Assert.areEqual(1, mockNoteSprite.scale.x);
+      Assert.areEqual(1, mockNoteSprite.scale.y);
+      // Verify there were no other functions called.
+      mockNoteSprite.verifyZeroInteractions();
+    }
   }
 
   @Test
diff --git a/tests/unit/source/funkin/util/SortUtilTest.hx b/tests/unit/source/funkin/util/SortUtilTest.hx
index 1a39bf655..5b868b278 100644
--- a/tests/unit/source/funkin/util/SortUtilTest.hx
+++ b/tests/unit/source/funkin/util/SortUtilTest.hx
@@ -3,7 +3,7 @@ package funkin.util;
 import flixel.FlxObject;
 import flixel.FlxSprite;
 import flixel.util.FlxSort;
-import funkin.play.song.SongData.SongNoteData;
+import funkin.data.song.SongData.SongNoteData;
 import massive.munit.util.Timer;
 import massive.munit.Assert;
 import massive.munit.async.AsyncFactory;
diff --git a/tests/unit/source/funkin/util/assets/FlxAnimationUtilTest.hx b/tests/unit/source/funkin/util/assets/FlxAnimationUtilTest.hx
index 02b03055d..8adfd3565 100644
--- a/tests/unit/source/funkin/util/assets/FlxAnimationUtilTest.hx
+++ b/tests/unit/source/funkin/util/assets/FlxAnimationUtilTest.hx
@@ -34,8 +34,8 @@ class FlxAnimationUtilTest extends FunkinTest
   public function testAddAtlasAnimation()
   {
     // Build a mock child class of FlxSprite
-    var mockSprite = mock(FlxSprite);
-    var mockAnim = mock(FlxAnimationController);
+    var mockSprite = Mockatoo.mock(FlxSprite);
+    var mockAnim = Mockatoo.mock(FlxAnimationController);
     mockSprite.animation = mockAnim;
 
     var animData:AnimationData =
@@ -85,8 +85,8 @@ class FlxAnimationUtilTest extends FunkinTest
   public function testAddAtlasAnimations()
   {
     // Build a mock child class of FlxSprite
-    var mockSprite = mock(FlxSprite);
-    var mockAnim = mock(FlxAnimationController);
+    var mockSprite = Mockatoo.mock(FlxSprite);
+    var mockAnim = Mockatoo.mock(FlxAnimationController);
     mockSprite.animation = mockAnim;
 
     var animData:Array<AnimationData> = [