From 7e1c11bb25b0c9c5329351e4f262607076967b5d Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Fri, 8 Sep 2023 17:45:47 -0400
Subject: [PATCH] New song data parser

---
 source/funkin/data/BaseRegistry.hx            |  95 ++-
 source/funkin/data/DataParse.hx               |  99 +++
 source/funkin/data/DataWrite.hx               |   8 +
 source/funkin/data/IRegistryEntry.hx          |   3 +-
 source/funkin/data/README.md                  |  21 +
 source/funkin/data/event/SongEventData.hx     | 236 +++++++
 source/funkin/data/level/LevelData.hx         |   2 +
 source/funkin/data/level/LevelRegistry.hx     |  15 +-
 .../data/notestyle/NoteStyleRegistry.hx       |  17 +-
 source/funkin/data/song/SongData.hx           | 649 ++++++++++++++++++
 source/funkin/data/song/SongDataUtils.hx      | 232 +++++++
 source/funkin/data/song/SongRegistry.hx       | 262 +++++++
 12 files changed, 1618 insertions(+), 21 deletions(-)
 create mode 100644 source/funkin/data/DataParse.hx
 create mode 100644 source/funkin/data/DataWrite.hx
 create mode 100644 source/funkin/data/README.md
 create mode 100644 source/funkin/data/event/SongEventData.hx
 create mode 100644 source/funkin/data/song/SongData.hx
 create mode 100644 source/funkin/data/song/SongDataUtils.hx
 create mode 100644 source/funkin/data/song/SongRegistry.hx

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..8a78e7c97
--- /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): 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, 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/data/event/SongEventData.hx b/source/funkin/data/event/SongEventData.hx
new file mode 100644
index 000000000..831a53fbd
--- /dev/null
+++ b/source/funkin/data/event/SongEventData.hx
@@ -0,0 +1,236 @@
+package funkin.data.event;
+
+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;
+
+/**
+ * This class statically handles the parsing of internal and scripted song event handlers.
+ */
+class SongEventParser
+{
+  /**
+   * Every built-in event class must be added to this list.
+   * Thankfully, with the power of `SongEventMacro`, this is done automatically.
+   */
+  static final BUILTIN_EVENTS:List<Class<SongEvent>> = ClassMacro.listSubclassesOf(SongEvent);
+
+  /**
+   * Map of internal handlers for song events.
+   * These may be either `ScriptedSongEvents` or built-in classes extending `SongEvent`.
+   */
+  static final eventCache:Map<String, SongEvent> = new Map<String, SongEvent>();
+
+  public static function loadEventCache():Void
+  {
+    clearEventCache();
+
+    //
+    // BASE GAME EVENTS
+    //
+    registerBaseEvents();
+    registerScriptedEvents();
+  }
+
+  static function registerBaseEvents()
+  {
+    trace('Instantiating ${BUILTIN_EVENTS.length} built-in song events...');
+    for (eventCls in BUILTIN_EVENTS)
+    {
+      var eventClsName:String = Type.getClassName(eventCls);
+      if (eventClsName == 'funkin.play.event.SongEvent' || eventClsName == 'funkin.play.event.ScriptedSongEvent') continue;
+
+      var event:SongEvent = Type.createInstance(eventCls, ["UNKNOWN"]);
+
+      if (event != null)
+      {
+        trace('  Loaded built-in song event: (${event.id})');
+        eventCache.set(event.id, event);
+      }
+      else
+      {
+        trace('  Failed to load built-in song event: ${Type.getClassName(eventCls)}');
+      }
+    }
+  }
+
+  static function registerScriptedEvents()
+  {
+    var scriptedEventClassNames:Array<String> = ScriptedSongEvent.listScriptClasses();
+    if (scriptedEventClassNames == null || scriptedEventClassNames.length == 0) return;
+
+    trace('Instantiating ${scriptedEventClassNames.length} scripted song events...');
+    for (eventCls in scriptedEventClassNames)
+    {
+      var event:SongEvent = ScriptedSongEvent.init(eventCls, "UKNOWN");
+
+      if (event != null)
+      {
+        trace('  Loaded scripted song event: ${event.id}');
+        eventCache.set(event.id, event);
+      }
+      else
+      {
+        trace('  Failed to instantiate scripted song event class: ${eventCls}');
+      }
+    }
+  }
+
+  public static function listEventIds():Array<String>
+  {
+    return eventCache.keys().array();
+  }
+
+  public static function listEvents():Array<SongEvent>
+  {
+    return eventCache.values();
+  }
+
+  public static function getEvent(id:String):SongEvent
+  {
+    return eventCache.get(id);
+  }
+
+  public static function getEventSchema(id:String):SongEventSchema
+  {
+    var event:SongEvent = getEvent(id);
+    if (event == null) return null;
+
+    return event.getEventSchema();
+  }
+
+  static function clearEventCache()
+  {
+    eventCache.clear();
+  }
+
+  public static function handleEvent(data:SongEventData):Void
+  {
+    var eventType:String = data.event;
+    var eventHandler:SongEvent = eventCache.get(eventType);
+
+    if (eventHandler != null)
+    {
+      eventHandler.handleEvent(data);
+    }
+    else
+    {
+      trace('WARNING: No event handler for event with id: ${eventType}');
+    }
+
+    data.activated = true;
+  }
+
+  public static inline function handleEvents(events:Array<SongEventData>):Void
+  {
+    for (event in events)
+    {
+      handleEvent(event);
+    }
+  }
+
+  /**
+   * Given a list of song events and the current timestamp,
+   * return a list of events that should be handled.
+   */
+  public static function queryEvents(events:Array<SongEventData>, currentTime:Float):Array<SongEventData>
+  {
+    return events.filter(function(event:SongEventData):Bool {
+      // If the event is already activated, don't activate it again.
+      if (event.activated) return false;
+
+      // If the event is in the future, don't activate it.
+      if (event.time > currentTime) return false;
+
+      return true;
+    });
+  }
+
+  /**
+   * Reset activation of all the provided events.
+   */
+  public static function resetEvents(events:Array<SongEventData>):Void
+  {
+    for (event in events)
+    {
+      event.activated = false;
+      // TODO: Add an onReset() method to SongEvent?
+    }
+  }
+}
+
+enum abstract SongEventFieldType(String) from String to String
+{
+  /**
+   * The STRING type will display as a text field.
+   */
+  var STRING = "string";
+
+  /**
+   * The INTEGER type will display as a text field that only accepts numbers.
+   */
+  var INTEGER = "integer";
+
+  /**
+   * The FLOAT type will display as a text field that only accepts numbers.
+   */
+  var FLOAT = "float";
+
+  /**
+   * The BOOL type will display as a checkbox.
+   */
+  var BOOL = "bool";
+
+  /**
+   * The ENUM type will display as a dropdown.
+   * Make sure to specify the `keys` field in the schema.
+   */
+  var ENUM = "enum";
+}
+
+typedef SongEventSchemaField =
+{
+  /**
+   * The name of the property as it should be saved in the event data.
+   */
+  name:String,
+
+  /**
+   * The title of the field to display in the UI.
+   */
+  title:String,
+
+  /**
+   * The type of the field.
+   */
+  type:SongEventFieldType,
+
+  /**
+   * Used for ENUM values.
+   * The key is the display name and the value is the actual value.
+   */
+  ?keys:Map<String, Dynamic>,
+  /**
+   * Used for INTEGER and FLOAT values.
+   * The minimum value that can be entered.
+   */
+  ?min:Float,
+  /**
+   * Used for INTEGER and FLOAT values.
+   * The maximum value that can be entered.
+   */
+  ?max:Float,
+  /**
+   * Used for INTEGER and FLOAT values.
+   * The step value that will be used when incrementing/decrementing the value.
+   */
+  ?step:Float,
+  /**
+   * An optional default value for the field.
+   */
+  ?defaultValue:Dynamic,
+}
+
+typedef SongEventSchema = Array<SongEventSchemaField>;
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..2e98b9c0a
--- /dev/null
+++ b/source/funkin/data/song/SongData.hx
@@ -0,0 +1,649 @@
+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;
+  }
+}
+
+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;
+  }
+}
+
+/**
+ * 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;
+  }
+}
+
+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;
+  }
+}
+
+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;
+  }
+}
+
+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;
+  }
+}
diff --git a/source/funkin/data/song/SongDataUtils.hx b/source/funkin/data/song/SongDataUtils.hx
new file mode 100644
index 000000000..d15a2b19a
--- /dev/null
+++ b/source/funkin/data/song/SongDataUtils.hx
@@ -0,0 +1,232 @@
+package funkin.data.song;
+
+import flixel.util.FlxSort;
+import funkin.data.song.SongData.SongEventData;
+import funkin.data.song.SongData.SongNoteData;
+import funkin.util.ClipboardUtil;
+import funkin.util.SerializerUtil;
+
+using Lambda;
+
+class SongDataUtils
+{
+  /**
+   * Given an array of SongNoteData objects, return a new array of SongNoteData objects
+   * whose timestamps are shifted by the given amount.
+   * Does not mutate the original array.
+   *
+   * @param notes The notes to modify.
+   * @param offset The time difference to apply in milliseconds.
+   */
+  public static function offsetSongNoteData(notes:Array<SongNoteData>, offset:Int):Array<SongNoteData>
+  {
+    return notes.map(function(note:SongNoteData):SongNoteData {
+      return new SongNoteData(note.time + offset, note.data, note.length, note.kind);
+    });
+  }
+
+  /**
+   * Given an array of SongEventData objects, return a new array of SongEventData objects
+   * whose timestamps are shifted by the given amount.
+   * Does not mutate the original array.
+   *
+   * @param events The events to modify.
+   * @param offset The time difference to apply in milliseconds.
+   */
+  public static function offsetSongEventData(events:Array<SongEventData>, offset:Int):Array<SongEventData>
+  {
+    return events.map(function(event:SongEventData):SongEventData {
+      return new SongEventData(event.time + offset, event.event, event.value);
+    });
+  }
+
+  /**
+   * Return a new array without a certain subset of notes from an array of SongNoteData objects.
+   * Does not mutate the original array.
+   *
+   * @param notes The array of notes to be subtracted from.
+   * @param subtrahend The notes to remove from the `notes` array. Yes, subtrahend is a real word.
+   */
+  public static function subtractNotes(notes:Array<SongNoteData>, subtrahend:Array<SongNoteData>)
+  {
+    if (notes.length == 0 || subtrahend.length == 0) return notes;
+
+    var result = notes.filter(function(note:SongNoteData):Bool {
+      for (x in subtrahend)
+        // SongNoteData's == operation has been overridden so that this will work.
+        if (x == note) return false;
+
+      return true;
+    });
+
+    return result;
+  }
+
+  /**
+   * Return a new array without a certain subset of events from an array of SongEventData objects.
+   * Does not mutate the original array.
+   *
+   * @param events The array of events to be subtracted from.
+   * @param subtrahend The events to remove from the `events` array. Yes, subtrahend is a real word.
+   */
+  public static function subtractEvents(events:Array<SongEventData>, subtrahend:Array<SongEventData>)
+  {
+    if (events.length == 0 || subtrahend.length == 0) return events;
+
+    return events.filter(function(event:SongEventData):Bool {
+      // SongEventData's == operation has been overridden so that this will work.
+      return !subtrahend.has(event);
+    });
+  }
+
+  /**
+   * Create an array of notes whose note data is flipped (player becomes opponent and vice versa)
+   * Does not mutate the original array.
+   */
+  public static function flipNotes(notes:Array<SongNoteData>, ?strumlineSize:Int = 4):Array<SongNoteData>
+  {
+    return notes.map(function(note:SongNoteData):SongNoteData {
+      var newData = note.data;
+
+      if (newData < strumlineSize) newData += strumlineSize;
+      else
+        newData -= strumlineSize;
+
+      return new SongNoteData(note.time, newData, note.length, note.kind);
+    });
+  }
+
+  /**
+   * Prepare an array of notes to be used as the clipboard data.
+   *
+   * Offset the provided array of notes such that the first note is at 0 milliseconds.
+   */
+  public static function buildNoteClipboard(notes:Array<SongNoteData>, ?timeOffset:Int = null):Array<SongNoteData>
+  {
+    if (notes.length == 0) return notes;
+    if (timeOffset == null) timeOffset = -Std.int(notes[0].time);
+    return offsetSongNoteData(sortNotes(notes), timeOffset);
+  }
+
+  /**
+   * Prepare an array of events to be used as the clipboard data.
+   *
+   * Offset the provided array of events such that the first event is at 0 milliseconds.
+   */
+  public static function buildEventClipboard(events:Array<SongEventData>, ?timeOffset:Int = null):Array<SongEventData>
+  {
+    if (events.length == 0) return events;
+    if (timeOffset == null) timeOffset = -Std.int(events[0].time);
+    return offsetSongEventData(sortEvents(events), timeOffset);
+  }
+
+  /**
+   * Sort an array of notes by strum time.
+   */
+  public static function sortNotes(notes:Array<SongNoteData>, desc:Bool = false):Array<SongNoteData>
+  {
+    // TODO: Modifies the array in place. Is this okay?
+    notes.sort(function(a:SongNoteData, b:SongNoteData):Int {
+      return FlxSort.byValues(desc ? FlxSort.DESCENDING : FlxSort.ASCENDING, a.time, b.time);
+    });
+    return notes;
+  }
+
+  /**
+   * Sort an array of events by strum time.
+   */
+  public static function sortEvents(events:Array<SongEventData>, desc:Bool = false):Array<SongEventData>
+  {
+    // TODO: Modifies the array in place. Is this okay?
+    events.sort(function(a:SongEventData, b:SongEventData):Int {
+      return FlxSort.byValues(desc ? FlxSort.DESCENDING : FlxSort.ASCENDING, a.time, b.time);
+    });
+    return events;
+  }
+
+  /**
+   * Serialize note and event data and write it to the clipboard.
+   */
+  public static function writeItemsToClipboard(data:SongClipboardItems):Void
+  {
+    var dataString = SerializerUtil.toJSON(data);
+
+    ClipboardUtil.setClipboard(dataString);
+
+    trace('Wrote ' + data.notes.length + ' notes and ' + data.events.length + ' events to clipboard.');
+
+    trace(dataString);
+  }
+
+  /**
+   * Read an array of note data from the clipboard and deserialize it.
+   */
+  public static function readItemsFromClipboard():SongClipboardItems
+  {
+    var notesString = ClipboardUtil.getClipboard();
+
+    trace('Read ${notesString.length} characters from clipboard.');
+
+    var data:SongClipboardItems = notesString.parseJSON();
+
+    if (data == null)
+    {
+      trace('Failed to parse notes from clipboard.');
+      return {
+        notes: [],
+        events: []
+      };
+    }
+    else
+    {
+      trace('Parsed ' + data.notes.length + ' notes and ' + data.events.length + ' from clipboard.');
+      return data;
+    }
+  }
+
+  /**
+   * Filter a list of notes to only include notes that are within the given time range.
+   */
+  public static function getNotesInTimeRange(notes:Array<SongNoteData>, start:Float, end:Float):Array<SongNoteData>
+  {
+    return notes.filter(function(note:SongNoteData):Bool {
+      return note.time >= start && note.time <= end;
+    });
+  }
+
+  /**
+   * Filter a list of events to only include events that are within the given time range.
+   */
+  public static function getEventsInTimeRange(events:Array<SongEventData>, start:Float, end:Float):Array<SongEventData>
+  {
+    return events.filter(function(event:SongEventData):Bool {
+      return event.time >= start && event.time <= end;
+    });
+  }
+
+  /**
+   * Filter a list of notes to only include notes whose data is within the given range.
+   */
+  public static function getNotesInDataRange(notes:Array<SongNoteData>, start:Int, end:Int):Array<SongNoteData>
+  {
+    return notes.filter(function(note:SongNoteData):Bool {
+      return note.data >= start && note.data <= end;
+    });
+  }
+
+  /**
+   * Filter a list of notes to only include notes whose data is one of the given values.
+   */
+  public static function getNotesWithData(notes:Array<SongNoteData>, data:Array<Int>):Array<SongNoteData>
+  {
+    return notes.filter(function(note:SongNoteData):Bool {
+      return data.indexOf(note.data) != -1;
+    });
+  }
+}
+
+typedef SongClipboardItems =
+{
+  notes:Array<SongNoteData>,
+  events:Array<SongEventData>
+}
diff --git a/source/funkin/data/song/SongRegistry.hx b/source/funkin/data/song/SongRegistry.hx
new file mode 100644
index 000000000..e21c74a1f
--- /dev/null
+++ b/source/funkin/data/song/SongRegistry.hx
@@ -0,0 +1,262 @@
+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 = ''):BaseRegistry.JsonFile
+  {
+    var entryFilePath:String = Paths.json('$dataFilePath/$id/$id${variation == '' ? '' : '-$variation'}-metadata');
+    var rawJson:String = openfl.Assets.getText(entryFilePath).trim();
+    return {fileName: entryFilePath, contents: rawJson};
+  }
+
+  function loadMusicDataFile(id:String, variation:String = ''):BaseRegistry.JsonFile
+  {
+    var entryFilePath:String = Paths.file('music/$id/$id${variation == '' ? '' : '-$variation'}-metadata.json');
+    var rawJson:String = openfl.Assets.getText(entryFilePath).trim();
+    return {fileName: entryFilePath, contents: rawJson};
+  }
+
+  function loadEntryChartFile(id:String, variation:String = ''):BaseRegistry.JsonFile
+  {
+    var entryFilePath:String = Paths.json('$dataFilePath/$id/$id${variation == '' ? '' : '-$variation'}-chart');
+    var rawJson:String = openfl.Assets.getText(entryFilePath).trim();
+    return {fileName: entryFilePath, contents: rawJson};
+  }
+
+  public function fetchEntryMetadataVersion(id:String, variation:String = ''):Null<thx.semver.Version>
+  {
+    var entryStr: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: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;
+    });
+  }
+}