diff --git a/.github/actions/setup-haxeshit/action.yml b/.github/actions/setup-haxeshit/action.yml
index cb5e68f61..9c1fae0b1 100644
--- a/.github/actions/setup-haxeshit/action.yml
+++ b/.github/actions/setup-haxeshit/action.yml
@@ -3,9 +3,9 @@ description: "sets up haxe shit, using HMM!"
 runs:
   using: "composite"
   steps:
-    - uses: krdlab/setup-haxe@v1.5.1
+    - uses: funkincrew/ci-haxe@v3
       with:
-        haxe-version: 4.3.1
+        haxe-version: 4.3.3
     - name: Config haxelib
       run: |
         haxelib config
@@ -19,7 +19,7 @@ runs:
       shell: bash
     - name: dependency install cache
       id: cache-hmm
-      uses: actions/cache@v3
+      uses: actions/cache@v4
       with:
         path: .haxelib
         key: ${{ runner.os }}-hmm-${{ hashFiles('**/hmm.json') }}
diff --git a/.github/actions/upload-itch/action.yml b/.github/actions/upload-itch/action.yml
index 7a4b45427..2f7d3027d 100644
--- a/.github/actions/upload-itch/action.yml
+++ b/.github/actions/upload-itch/action.yml
@@ -13,32 +13,32 @@ inputs:
 runs:
   using: "composite"
   steps:
-      - name: Install butler Windows
-        if: runner.os == 'Windows'
-        run: |
-          curl -L -o butler.zip https://broth.itch.ovh/butler/windows-amd64/LATEST/archive/default
-          7z x butler.zip
-          ./butler -v
-        shell: bash
-      - name: Install butler Mac
-        if: runner.os == 'macOS'
-        run: |
-          curl -L -o butler.zip https://broth.itch.ovh/butler/darwin-amd64/LATEST/archive/default
-          unzip butler.zip
-          ./butler -V
-        shell: bash
-      - name: Install butler Linux
-        if: runner.os == 'Linux'
-        run: |
-          curl -L -o butler.zip https://broth.itch.ovh/butler/linux-amd64/LATEST/archive/default
-          unzip butler.zip
-          chmod +x butler
-          ./butler -V
-        shell: bash
-      - name: Upload game to itch.io
-        env:
-          BUTLER_API_KEY: ${{inputs.butler-key}}
-        run: |
-          ./butler login
-          ./butler push ${{inputs.build-dir}} ninja-muffin24/funkin-secret:${{inputs.target}}-${GITHUB_REF_NAME}
-        shell: bash
+    - name: Install butler Windows
+      if: runner.os == 'Windows'
+      run: |
+        curl -L -o butler.zip https://broth.itch.ovh/butler/windows-amd64/LATEST/archive/default
+        7z x butler.zip
+        ./butler -v
+      shell: bash
+    - name: Install butler Mac
+      if: runner.os == 'macOS'
+      run: |
+        curl -L -o butler.zip https://broth.itch.ovh/butler/darwin-amd64/LATEST/archive/default
+        unzip butler.zip
+        ./butler -V
+      shell: bash
+    - name: Install butler Linux
+      if: runner.os == 'Linux'
+      run: |
+        curl -L -o butler.zip https://broth.itch.ovh/butler/linux-amd64/LATEST/archive/default
+        unzip butler.zip
+        chmod +x butler
+        ./butler -V
+      shell: bash
+    - name: Upload game to itch.io
+      env:
+        BUTLER_API_KEY: ${{inputs.butler-key}}
+      run: |
+        ./butler login
+        ./butler push ${{inputs.build-dir}} ninja-muffin24/funkin-secret:${{inputs.target}}-${GITHUB_REF_NAME}
+      shell: bash
diff --git a/.github/workflows/build-shit.yml b/.github/workflows/build-shit.yml
index 8a58596b9..76126d106 100644
--- a/.github/workflows/build-shit.yml
+++ b/.github/workflows/build-shit.yml
@@ -4,59 +4,33 @@ on:
   push:
 
 jobs:
-  check_date:
-    runs-on: [self-hosted, linux]
-    container: ubuntu:latest
-    name: Check latest commit
-    outputs:
-      should_run: ${{ steps.should_run.outputs.should_run }}
-    steps:
-      - name: ensure git cli is installed
-        run: apt update && apt install sudo git -y
-      - uses: actions/checkout@v4
-        with:
-          submodules: 'recursive'
-          fetch-depth: 0
-          token: ${{ secrets.GH_RO_PAT }}
-      - name: check whether submodules exist
-        run: |
-          git config --global --add safe.directory $GITHUB_WORKSPACE
-
-          # debug output
-          echo gh=${{ github.sha }}
-          echo head=$(git rev-parse HEAD)
-          echo art=$(git -C art rev-parse HEAD)
-          echo assets=$(git -C assets rev-parse HEAD)
-
-          # checks if HEAD commit hash in submodules is diff from current repo, and therefore exists
-          test $(git rev-parse HEAD) != $(git -C art rev-parse HEAD)
-          test $(git rev-parse HEAD) != $(git -C assets rev-parse HEAD)
-      - id: should_run
-        continue-on-error: true
-        name: check latest commit is less than a day
-        if: ${{ github.event_name == 'schedule' }}
-        run: test -z $(git rev-list --after="24 hours" ${{ github.sha }}) && echo "::set-output name=should_run::false"
   create-nightly-html5:
-    needs: check_date
-    if: ${{ needs.check_date.outputs.should_run != 'false'}}
     runs-on: [self-hosted, linux]
-    container: ubuntu:latest
+    container: ubuntu:23.10
     steps:
       - name: prepare container
         run: |
           apt update
           apt install sudo git curl unzip -y
-          echo $GITHUB_WORKSPACE
           git config --global --add safe.directory $GITHUB_WORKSPACE
-      - uses: actions/checkout@v4
+      - name: get token from gh app
+        uses: actions/create-github-app-token@v1
+        id: app_token
+        with:
+          app-id: ${{ vars.APP_ID }}
+          private-key: ${{ secrets.APP_PEM }}
+          owner: ${{ github.repository_owner }}
+      - name: checkout repo
+        uses: funkincrew/ci-checkout@v6
         with:
           submodules: 'recursive'
-          fetch-depth: 0
-          token: ${{ secrets.GH_RO_PAT }}
+          token: ${{ steps.app_token.outputs.token }}
       - uses: ./.github/actions/setup-haxeshit
-      - name: Build game
+      - name: gather game dependencies
         run: |
           sudo apt-get install -y libx11-dev xorg-dev libgl-dev libxi-dev libxext-dev libasound2-dev libxinerama-dev libxrandr-dev libgl1-mesa-dev
+      - name: build game
+        run: |
           haxelib run lime build html5 -release --times
           ls
       - uses: ./.github/actions/upload-itch
@@ -65,32 +39,34 @@ jobs:
           build-dir: export/release/html5/bin
           target: html5
   create-nightly-win:
-    needs: check_date
-    if: ${{ needs.check_date.outputs.should_run != 'false'}}
     runs-on: windows-latest
-    permissions:
-       contents: write
-       actions: write
     steps:
-      - uses: actions/checkout@v4
+      - name: get token from gh app
+        uses: actions/create-github-app-token@v1
+        id: app_token
+        with:
+          app-id: ${{ vars.APP_ID }}
+          private-key: ${{ secrets.APP_PEM }}
+          owner: ${{ github.repository_owner }}
+      - name: checkout repo
+        uses: funkincrew/ci-checkout@v6
         with:
           submodules: 'recursive'
-          fetch-depth: 0
-          token: ${{ secrets.GH_RO_PAT }}
+          token: ${{ steps.app_token.outputs.token }}
       - uses: ./.github/actions/setup-haxeshit
       - name: Make HXCPP cache dir
         run: |
           mkdir -p ${{ runner.temp }}\hxcpp_cache
       - name: Restore build cache
         id: cache-build-win
-        uses: actions/cache@v3
+        uses: actions/cache@v4
         with:
           path: |
             .haxelib
             export
             ${{ runner.temp }}\hxcpp_cache
           key: ${{ runner.os }}-build-win-${{ github.ref_name }}-${{ hashFiles('**/hmm.json') }}
-      - name: Build game
+      - name: build game
         run: |
           haxelib run lime build windows -release -DNO_REDIRECT_ASSETS_FOLDER
           dir
@@ -101,20 +77,81 @@ jobs:
           butler-key: ${{ secrets.BUTLER_API_KEY }}
           build-dir: export/release/windows/bin
           target: win
+  create-nightly-mac:
+    runs-on: [self-hosted, macos]
+    steps:
+      - name: prepare container
+        run: |
+          git config --global --add safe.directory $GITHUB_WORKSPACE
+      - name: get token from gh app
+        uses: actions/create-github-app-token@v1
+        id: app_token
+        with:
+          app-id: ${{ vars.APP_ID }}
+          private-key: ${{ secrets.APP_PEM }}
+          owner: ${{ github.repository_owner }}
+      - name: checkout repo
+        uses: funkincrew/ci-checkout@v6
+        with:
+          submodules: 'recursive'
+          token: ${{ steps.app_token.outputs.token }}
+      - uses: ./.github/actions/setup-haxeshit
+      - name: Make HXCPP cache dir
+        run: |
+          mkdir -p ${{ runner.temp }}/hxcpp_cache
+      - name: restore build cache
+        id: cache-build-win
+        uses: actions/cache@v4
+        with:
+          path: |
+            .haxelib
+            export
+            ${{ runner.temp }}/hxcpp_cache
+          key: ${{ runner.os }}-build-mac-${{ github.ref_name }}-${{ hashFiles('**/hmm.json') }}
+      - name: Build game
+        run: |
+          haxelib run lime build macos -release --times
+          ls
+        env:
+          HXCPP_COMPILE_CACHE: "${{ runner.temp }}/hxcpp_cache"
+      - uses: ./.github/actions/upload-itch
+        with:
+          butler-key: ${{ secrets.BUTLER_API_KEY}}
+          build-dir: export/release/macos/bin
+          target: macos
+          
 #  test-unit-win:
 #    needs: create-nightly-win
 #    runs-on: windows-latest
-#    permissions:
-#       contents: write
-#       actions: write
 #    steps:
-#      - uses: actions/checkout@v4
+#     - name: get token from gh app
+#       uses: actions/create-github-app-token@v1
+#        id: app_token
 #        with:
-#          submodules: 'recursive'
-#          fetch-depth: 0
-#          token: ${{ secrets.GH_RO_PAT }}
+#          app-id: ${{ vars.APP_ID }}
+#          private-key: ${{ secrets.APP_PEM }}
+#          owner: ${{ github.repository_owner }}
+#     - name: checkout repo
+#       uses: funkincrew/ci-checkout@v6
+#       with:
+#         submodules: 'recursive'
+#         token: ${{ steps.app_token.outputs.token }}
+#     - name: Make HXCPP cache dir
+#       run: |
+#         mkdir -p ${{ runner.temp }}\hxcpp_cache
+#     - name: Restore build cache
+#       id: cache-build-win
+#       uses: actions/cache@v4
+#       with:
+#         path: |
+#           .haxelib
+#           export
+#           ${{ runner.temp }}\hxcpp_cache
+#         key: ${{ runner.os }}-test-win-${{ github.ref_name }}-${{ hashFiles('**/hmm.json') }}
 #      - uses: ./.github/actions/setup-haxeshit
 #      - name: Run unit tests
 #        run: |
 #          cd ./tests/unit/
 #          ./start-win-native.bat
+#        env:
+#          HXCPP_COMPILE_CACHE: "${{ runner.temp }}\\hxcpp_cache"
diff --git a/.vscode/settings.json b/.vscode/settings.json
index cefbadcf6..3d1f488f7 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -110,6 +110,11 @@
       "target": "windows",
       "args": ["-debug", "-DSONG=bopeebo -DDIFFICULTY=normal"]
     },
+    {
+      "label": "Windows / Debug (Conversation Test)",
+      "target": "windows",
+      "args": ["-debug", "-DDIALOGUE"]
+    },
     {
       "label": "Windows / Debug (Straight to Chart Editor)",
       "target": "windows",
@@ -125,6 +130,11 @@
       "target": "windows",
       "args": ["-debug", "-DLATENCY"]
     },
+    {
+      "label": "Windows / Debug (Waveform Test)",
+      "target": "windows",
+      "args": ["-debug", "-DWAVEFORM"]
+    },
     {
       "label": "HTML5 / Debug",
       "target": "html5",
diff --git a/Project.xml b/Project.xml
index e0677b026..40f309e1f 100644
--- a/Project.xml
+++ b/Project.xml
@@ -52,6 +52,7 @@
 		<library name="week7" preload="false" />
 		<library name="weekend1" preload="false" />
 	</section>
+	<library name="art" preload="false" />
 	<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" />
@@ -82,14 +83,15 @@
 		If we can exclude the `mods` folder from the manifest, we can re-enable this line.
 		<assets path='example_mods' rename='mods' embed='false' exclude="*.md" />
 	-->
-	<assets path="art/readme.txt" rename="do NOT readme.txt" />
-	<assets path="CHANGELOG.md" rename="changelog.txt" />
+	<assets path="art/readme.txt" rename="do NOT readme.txt" library="art"/>
+	<assets path="CHANGELOG.md" rename="changelog.txt" library="art"/>
 	<!-- NOTE FOR FUTURE SELF SINCE FONTS ARE ALWAYS FUCKY
 		TO FIX ONE OF THEM, I CONVERTED IT TO OTF. DUNNO IF YOU NEED TO
 		THEN UHHH I USED THE NAME OF THE FONT WITH SETFORMAT() ON THE TEXT!!!
 		NOT USING A DIRECT THING TO THE ASSET!!!
 	-->
 	<assets path="assets/fonts" embed="true" />
+
 	<!-- _______________________________ Libraries ______________________________ -->
 	<haxelib name="lime" /> <!-- Game engine backend -->
 	<haxelib name="openfl" /> <!-- Game engine backend -->
@@ -108,9 +110,9 @@
 	<haxelib name="hxCodec" /> <!-- Video playback -->
 
 	<haxelib name="json2object" /> <!-- JSON parsing -->
-	<haxelib name="tink_json" /> <!-- JSON parsing (DEPRECATED) -->
 	<haxelib name="thx.semver" /> <!-- Version string handling -->
 
+	<haxelib name="hmm" /> <!-- Read library version data at compile time so it can be baked into logs -->
 	<haxelib name="hxcpp-debug-server" if="desktop debug" /> <!-- VSCode debug support -->
 
 	<!--Disable the Flixel core focus lost screen-->
@@ -127,11 +129,11 @@
 	<haxeflag name="-w" value="-WDeprecated" />
 
 	<!-- Haxe 4.3.0+: Enable pretty syntax errors and stuff. -->
-	<haxedef name="message-reporting" value="pretty" />
+	<haxedef name="message.reporting" value="pretty" />
 
 	<!-- _________________________________ Custom _______________________________ -->
-	<!-- Disable trace() calls in release builds to bump up performance. -->
-	<haxeflag name="--no-traces" unless="debug" />
+	<!-- Disable trace() calls in release builds to bump up performance.
+		<haxeflag name="- -no-traces" unless="debug" />-->
 	<!-- HScript relies heavily on Reflection, which means we can't use DCE. -->
 	<haxeflag name="-dce no" />
 	<!-- Ensure all Funkin' classes are available at runtime. -->
diff --git a/art b/art
index 1656bea53..03e7c2a23 160000
--- a/art
+++ b/art
@@ -1 +1 @@
-Subproject commit 1656bea5370c65879aaeb323e329f403c78071c5
+Subproject commit 03e7c2a2353b184e45955c96d763b7cdf1acbc34
diff --git a/assets b/assets
index 2cba31629..6825d762b 160000
--- a/assets
+++ b/assets
@@ -1 +1 @@
-Subproject commit 2cba3162990db807be054889b23cf214a1d8dd2d
+Subproject commit 6825d762bd007a50405a4e38939862f5ba70481f
diff --git a/hmm.json b/hmm.json
index 96ee75bc1..4a0895034 100644
--- a/hmm.json
+++ b/hmm.json
@@ -11,14 +11,14 @@
       "name": "flixel",
       "type": "git",
       "dir": null,
-      "ref": "9bdea914f3d0485b9b3ec158f28875b5ac95d476",
+      "ref": "07c6018008801972d12275690fc144fcc22e3de6",
       "url": "https://github.com/FunkinCrew/flixel"
     },
     {
       "name": "flixel-addons",
       "type": "git",
       "dir": null,
-      "ref": "fd3aecdeb5635fa0428dffee204fc78fc26b5885",
+      "ref": "a523c3b56622f0640933944171efed46929e360e",
       "url": "https://github.com/FunkinCrew/flixel-addons"
     },
     {
@@ -37,7 +37,7 @@
       "name": "flxanimate",
       "type": "git",
       "dir": null,
-      "ref": "d7c5621be742e2c98d523dfe5af7528835eaff1e",
+      "ref": "9bacdd6ea39f5e3a33b0f5dfb7bc583fe76060d4",
       "url": "https://github.com/FunkinCrew/flxanimate"
     },
     {
@@ -54,14 +54,14 @@
       "name": "haxeui-core",
       "type": "git",
       "dir": null,
-      "ref": "7021f1fbab928268d9196a73e7f47461ca3c3e4d",
+      "ref": "8a7846b",
       "url": "https://github.com/haxeui/haxeui-core"
     },
     {
       "name": "haxeui-flixel",
       "type": "git",
       "dir": null,
-      "ref": "26b6bb132c92dfa9b77b4a61eaeda8f9a9efda98",
+      "ref": "e9f880522e27134b29df4067f82df7d7e5237b70",
       "url": "https://github.com/haxeui/haxeui-flixel"
     },
     {
@@ -100,14 +100,14 @@
       "name": "json2object",
       "type": "git",
       "dir": null,
-      "ref": "a0a78b60c41e47bae8bfa422488a199a58b4474e",
+      "ref": "a8c26f18463c98da32f744c214fe02273e1823fa",
       "url": "https://github.com/FunkinCrew/json2object"
     },
     {
       "name": "lime",
       "type": "git",
       "dir": null,
-      "ref": "737b86f121cdc90358d59e2e527934f267c94a2c",
+      "ref": "fff39ba6fc64969cd51987ef7491d9345043dc5d",
       "url": "https://github.com/FunkinCrew/lime"
     },
     {
@@ -149,18 +149,13 @@
       "name": "polymod",
       "type": "git",
       "dir": null,
-      "ref": "80d1d309803c1b111866524f9769325e3b8b0b1b",
+      "ref": "6cec79e4f322fbb262170594ed67ab72b4714810",
       "url": "https://github.com/larsiusprime/polymod"
     },
     {
       "name": "thx.semver",
       "type": "haxelib",
       "version": "0.2.2"
-    },
-    {
-      "name": "tink_json",
-      "type": "haxelib",
-      "version": "0.11.0"
     }
   ]
 }
diff --git a/source/Main.hx b/source/Main.hx
index 5fbb6747b..86e520e69 100644
--- a/source/Main.hx
+++ b/source/Main.hx
@@ -112,5 +112,6 @@ class Main extends Sprite
     Toolkit.theme = 'dark'; // don't be cringe
     Toolkit.autoScale = false;
     funkin.input.Cursor.registerHaxeUICursors();
+    haxe.ui.tooltips.ToolTipManager.defaultDelay = 200;
   }
 }
diff --git a/source/funkin/Conductor.hx b/source/funkin/Conductor.hx
index c531678ad..05c23108f 100644
--- a/source/funkin/Conductor.hx
+++ b/source/funkin/Conductor.hx
@@ -11,6 +11,7 @@ import funkin.data.song.SongDataUtils;
  * A core class which handles musical timing throughout the game,
  * both in gameplay and in menus.
  */
+@:nullSafety
 class Conductor
 {
   // onBeatHit is called every quarter note
@@ -28,29 +29,53 @@ class Conductor
   //   60 BPM = 240 sixteenth notes per minute = 4 onStepHit per second
   // 7/8 = 3.5 beats per measure = 14 steps per measure
 
+  /**
+   * The current instance of the Conductor.
+   * If one doesn't currently exist, a new one will be created.
+   *
+   * You can also do stuff like store a reference to the Conductor and pass it around or temporarily replace it,
+   * or have a second Conductor running at the same time, or other weird stuff like that if you need to.
+   */
+  public static var instance:Conductor = new Conductor();
+
+  /**
+   * Signal fired when the current Conductor instance advances to a new measure.
+   */
+  public static var measureHit(default, null):FlxSignal = new FlxSignal();
+
+  /**
+   * Signal fired when the current Conductor instance advances to a new beat.
+   */
+  public static var beatHit(default, null):FlxSignal = new FlxSignal();
+
+  /**
+   * Signal fired when the current Conductor instance advances to a new step.
+   */
+  public static var stepHit(default, null):FlxSignal = new FlxSignal();
+
   /**
    * The list of time changes in the song.
    * There should be at least one time change (at the beginning of the song) to define the BPM.
    */
-  static var timeChanges:Array<SongTimeChange> = [];
+  var timeChanges:Array<SongTimeChange> = [];
 
   /**
    * The most recent time change for the current song position.
    */
-  public static var currentTimeChange(default, null):SongTimeChange;
+  public var currentTimeChange(default, null):Null<SongTimeChange>;
 
   /**
    * The current position in the song in milliseconds.
-   * Update this every frame based on the audio position using `Conductor.update()`.
+   * Update this every frame based on the audio position using `Conductor.instance.update()`.
    */
-  public static var songPosition(default, null):Float = 0;
+  public var songPosition(default, null):Float = 0;
 
   /**
    * Beats per minute of the current song at the current time.
    */
-  public static var bpm(get, never):Float;
+  public var bpm(get, never):Float;
 
-  static function get_bpm():Float
+  function get_bpm():Float
   {
     if (bpmOverride != null) return bpmOverride;
 
@@ -62,9 +87,9 @@ class Conductor
   /**
    * Beats per minute of the current song at the start time.
    */
-  public static var startingBPM(get, never):Float;
+  public var startingBPM(get, never):Float;
 
-  static function get_startingBPM():Float
+  function get_startingBPM():Float
   {
     if (bpmOverride != null) return bpmOverride;
 
@@ -78,14 +103,14 @@ class Conductor
    * The current value set by `forceBPM`.
    * If false, BPM is determined by time changes.
    */
-  static var bpmOverride:Null<Float> = null;
+  var bpmOverride:Null<Float> = null;
 
   /**
    * Duration of a measure in milliseconds. Calculated based on bpm.
    */
-  public static var measureLengthMs(get, never):Float;
+  public var measureLengthMs(get, never):Float;
 
-  static function get_measureLengthMs():Float
+  function get_measureLengthMs():Float
   {
     return beatLengthMs * timeSignatureNumerator;
   }
@@ -93,9 +118,9 @@ class Conductor
   /**
    * Duration of a beat (quarter note) in milliseconds. Calculated based on bpm.
    */
-  public static var beatLengthMs(get, never):Float;
+  public var beatLengthMs(get, never):Float;
 
-  static function get_beatLengthMs():Float
+  function get_beatLengthMs():Float
   {
     // Tied directly to BPM.
     return ((Constants.SECS_PER_MIN / bpm) * Constants.MS_PER_SEC);
@@ -104,25 +129,25 @@ class Conductor
   /**
    * Duration of a step (sixtennth note) in milliseconds. Calculated based on bpm.
    */
-  public static var stepLengthMs(get, never):Float;
+  public var stepLengthMs(get, never):Float;
 
-  static function get_stepLengthMs():Float
+  function get_stepLengthMs():Float
   {
     return beatLengthMs / timeSignatureNumerator;
   }
 
-  public static var timeSignatureNumerator(get, never):Int;
+  public var timeSignatureNumerator(get, never):Int;
 
-  static function get_timeSignatureNumerator():Int
+  function get_timeSignatureNumerator():Int
   {
     if (currentTimeChange == null) return Constants.DEFAULT_TIME_SIGNATURE_NUM;
 
     return currentTimeChange.timeSignatureNum;
   }
 
-  public static var timeSignatureDenominator(get, never):Int;
+  public var timeSignatureDenominator(get, never):Int;
 
-  static function get_timeSignatureDenominator():Int
+  function get_timeSignatureDenominator():Int
   {
     if (currentTimeChange == null) return Constants.DEFAULT_TIME_SIGNATURE_DEN;
 
@@ -132,44 +157,44 @@ class Conductor
   /**
    * Current position in the song, in measures.
    */
-  public static var currentMeasure(default, null):Int;
+  public var currentMeasure(default, null):Int = 0;
 
   /**
    * Current position in the song, in beats.
    */
-  public static var currentBeat(default, null):Int;
+  public var currentBeat(default, null):Int = 0;
 
   /**
    * Current position in the song, in steps.
    */
-  public static var currentStep(default, null):Int;
+  public var currentStep(default, null):Int = 0;
 
   /**
    * Current position in the song, in measures and fractions of a measure.
    */
-  public static var currentMeasureTime(default, null):Float;
+  public var currentMeasureTime(default, null):Float = 0;
 
   /**
    * Current position in the song, in beats and fractions of a measure.
    */
-  public static var currentBeatTime(default, null):Float;
+  public var currentBeatTime(default, null):Float = 0;
 
   /**
    * Current position in the song, in steps and fractions of a step.
    */
-  public static var currentStepTime(default, null):Float;
+  public var currentStepTime(default, null):Float = 0;
 
   /**
    * An offset tied to the current chart file to compensate for a delay in the instrumental.
    */
-  public static var instrumentalOffset:Float = 0;
+  public var instrumentalOffset:Float = 0;
 
   /**
    * The instrumental offset, in terms of steps.
    */
-  public static var instrumentalOffsetSteps(get, never):Float;
+  public var instrumentalOffsetSteps(get, never):Float;
 
-  static function get_instrumentalOffsetSteps():Float
+  function get_instrumentalOffsetSteps():Float
   {
     var startingStepLengthMs:Float = ((Constants.SECS_PER_MIN / startingBPM) * Constants.MS_PER_SEC) / timeSignatureNumerator;
 
@@ -179,19 +204,19 @@ class Conductor
   /**
    * An offset tied to the file format of the audio file being played.
    */
-  public static var formatOffset:Float = 0;
+  public var formatOffset:Float = 0;
 
   /**
    * An offset set by the user to compensate for input lag.
    */
-  public static var inputOffset:Float = 0;
+  public var inputOffset:Float = 0;
 
   /**
    * The number of beats in a measure. May be fractional depending on the time signature.
    */
-  public static var beatsPerMeasure(get, never):Float;
+  public var beatsPerMeasure(get, never):Float;
 
-  static function get_beatsPerMeasure():Float
+  function get_beatsPerMeasure():Float
   {
     // NOTE: Not always an integer, for example 7/8 is 3.5 beats per measure
     return stepsPerMeasure / Constants.STEPS_PER_BEAT;
@@ -201,30 +226,15 @@ class Conductor
    * The number of steps in a measure.
    * TODO: I don't think this can be fractional?
    */
-  public static var stepsPerMeasure(get, never):Int;
+  public var stepsPerMeasure(get, never):Int;
 
-  static function get_stepsPerMeasure():Int
+  function get_stepsPerMeasure():Int
   {
     // TODO: Is this always an integer?
     return Std.int(timeSignatureNumerator / timeSignatureDenominator * Constants.STEPS_PER_BEAT * Constants.STEPS_PER_BEAT);
   }
 
-  /**
-   * Signal fired when the Conductor advances to a new measure.
-   */
-  public static var measureHit(default, null):FlxSignal = new FlxSignal();
-
-  /**
-   * Signal fired when the Conductor advances to a new beat.
-   */
-  public static var beatHit(default, null):FlxSignal = new FlxSignal();
-
-  /**
-   * Signal fired when the Conductor advances to a new step.
-   */
-  public static var stepHit(default, null):FlxSignal = new FlxSignal();
-
-  function new() {}
+  public function new() {}
 
   /**
    * Forcibly defines the current BPM of the song.
@@ -235,7 +245,7 @@ class Conductor
    * WARNING: Avoid this for things like setting the BPM of the title screen music,
    * you should have a metadata file for it instead.
    */
-  public static function forceBPM(?bpm:Float = null)
+  public function forceBPM(?bpm:Float = null)
   {
     if (bpm != null)
     {
@@ -246,7 +256,7 @@ class Conductor
       // trace('[CONDUCTOR] Resetting BPM to default');
     }
 
-    Conductor.bpmOverride = bpm;
+    this.bpmOverride = bpm;
   }
 
   /**
@@ -256,29 +266,29 @@ class Conductor
    * @param	songPosition The current position in the song in milliseconds.
    *        Leave blank to use the FlxG.sound.music position.
    */
-  public static function update(?songPosition:Float)
+  public function update(?songPos:Float)
   {
-    if (songPosition == null)
+    if (songPos == null)
     {
       // Take into account instrumental and file format song offsets.
-      songPosition = (FlxG.sound.music != null) ? (FlxG.sound.music.time + instrumentalOffset + formatOffset) : 0.0;
+      songPos = (FlxG.sound.music != null) ? (FlxG.sound.music.time + instrumentalOffset + formatOffset) : 0.0;
     }
 
-    var oldMeasure = currentMeasure;
-    var oldBeat = currentBeat;
-    var oldStep = currentStep;
+    var oldMeasure = this.currentMeasure;
+    var oldBeat = this.currentBeat;
+    var oldStep = this.currentStep;
 
     // Set the song position we are at (for purposes of calculating note positions, etc).
-    Conductor.songPosition = songPosition;
+    this.songPosition = songPos;
 
     currentTimeChange = timeChanges[0];
-    if (Conductor.songPosition > 0.0)
+    if (this.songPosition > 0.0)
     {
       for (i in 0...timeChanges.length)
       {
-        if (songPosition >= timeChanges[i].timeStamp) currentTimeChange = timeChanges[i];
+        if (this.songPosition >= timeChanges[i].timeStamp) currentTimeChange = timeChanges[i];
 
-        if (songPosition < timeChanges[i].timeStamp) break;
+        if (this.songPosition < timeChanges[i].timeStamp) break;
       }
     }
 
@@ -286,45 +296,49 @@ class Conductor
     {
       trace('WARNING: Conductor is broken, timeChanges is empty.');
     }
-    else if (currentTimeChange != null && Conductor.songPosition > 0.0)
+    else if (currentTimeChange != null && this.songPosition > 0.0)
     {
       // roundDecimal prevents representing 8 as 7.9999999
-      currentStepTime = FlxMath.roundDecimal((currentTimeChange.beatTime * 4) + (songPosition - currentTimeChange.timeStamp) / stepLengthMs, 6);
-      currentBeatTime = currentStepTime / Constants.STEPS_PER_BEAT;
-      currentMeasureTime = currentStepTime / stepsPerMeasure;
-      currentStep = Math.floor(currentStepTime);
-      currentBeat = Math.floor(currentBeatTime);
-      currentMeasure = Math.floor(currentMeasureTime);
+      this.currentStepTime = FlxMath.roundDecimal((currentTimeChange.beatTime * 4) + (this.songPosition - currentTimeChange.timeStamp) / stepLengthMs, 6);
+      this.currentBeatTime = currentStepTime / Constants.STEPS_PER_BEAT;
+      this.currentMeasureTime = currentStepTime / stepsPerMeasure;
+      this.currentStep = Math.floor(currentStepTime);
+      this.currentBeat = Math.floor(currentBeatTime);
+      this.currentMeasure = Math.floor(currentMeasureTime);
     }
     else
     {
       // Assume a constant BPM equal to the forced value.
-      currentStepTime = FlxMath.roundDecimal((songPosition / stepLengthMs), 4);
-      currentBeatTime = currentStepTime / Constants.STEPS_PER_BEAT;
-      currentMeasureTime = currentStepTime / stepsPerMeasure;
-      currentStep = Math.floor(currentStepTime);
-      currentBeat = Math.floor(currentBeatTime);
-      currentMeasure = Math.floor(currentMeasureTime);
+      this.currentStepTime = FlxMath.roundDecimal((songPosition / stepLengthMs), 4);
+      this.currentBeatTime = currentStepTime / Constants.STEPS_PER_BEAT;
+      this.currentMeasureTime = currentStepTime / stepsPerMeasure;
+      this.currentStep = Math.floor(currentStepTime);
+      this.currentBeat = Math.floor(currentBeatTime);
+      this.currentMeasure = Math.floor(currentMeasureTime);
     }
 
-    // FlxSignals are really cool.
-    if (currentStep != oldStep)
+    // Only fire the signal if we are THE Conductor.
+    if (this == Conductor.instance)
     {
-      stepHit.dispatch();
-    }
+      // FlxSignals are really cool.
+      if (currentStep != oldStep)
+      {
+        Conductor.stepHit.dispatch();
+      }
 
-    if (currentBeat != oldBeat)
-    {
-      beatHit.dispatch();
-    }
+      if (currentBeat != oldBeat)
+      {
+        Conductor.beatHit.dispatch();
+      }
 
-    if (currentMeasure != oldMeasure)
-    {
-      measureHit.dispatch();
+      if (currentMeasure != oldMeasure)
+      {
+        Conductor.measureHit.dispatch();
+      }
     }
   }
 
-  public static function mapTimeChanges(songTimeChanges:Array<SongTimeChange>)
+  public function mapTimeChanges(songTimeChanges:Array<SongTimeChange>)
   {
     timeChanges = [];
 
@@ -338,24 +352,21 @@ class Conductor
       // Without any custom handling, `currentStepTime` becomes non-zero at `songPosition = 0`.
       if (currentTimeChange.timeStamp < 0.0) currentTimeChange.timeStamp = 0.0;
 
-      if (currentTimeChange.beatTime == null)
+      if (currentTimeChange.timeStamp <= 0.0)
       {
-        if (currentTimeChange.timeStamp <= 0.0)
-        {
-          currentTimeChange.beatTime = 0.0;
-        }
-        else
-        {
-          // Calculate the beat time of this timestamp.
-          currentTimeChange.beatTime = 0.0;
+        currentTimeChange.beatTime = 0.0;
+      }
+      else
+      {
+        // Calculate the beat time of this timestamp.
+        currentTimeChange.beatTime = 0.0;
 
-          if (currentTimeChange.timeStamp > 0.0 && timeChanges.length > 0)
-          {
-            var prevTimeChange:SongTimeChange = timeChanges[timeChanges.length - 1];
-            currentTimeChange.beatTime = FlxMath.roundDecimal(prevTimeChange.beatTime
-              + ((currentTimeChange.timeStamp - prevTimeChange.timeStamp) * prevTimeChange.bpm / Constants.SECS_PER_MIN / Constants.MS_PER_SEC),
-              4);
-          }
+        if (currentTimeChange.timeStamp > 0.0 && timeChanges.length > 0)
+        {
+          var prevTimeChange:SongTimeChange = timeChanges[timeChanges.length - 1];
+          currentTimeChange.beatTime = FlxMath.roundDecimal(prevTimeChange.beatTime
+            + ((currentTimeChange.timeStamp - prevTimeChange.timeStamp) * prevTimeChange.bpm / Constants.SECS_PER_MIN / Constants.MS_PER_SEC),
+            4);
         }
       }
 
@@ -368,13 +379,13 @@ class Conductor
     }
 
     // Update currentStepTime
-    Conductor.update(Conductor.songPosition);
+    this.update(Conductor.instance.songPosition);
   }
 
   /**
    * Given a time in milliseconds, return a time in steps.
    */
-  public static function getTimeInSteps(ms:Float):Float
+  public function getTimeInSteps(ms:Float):Float
   {
     if (timeChanges.length == 0)
     {
@@ -411,7 +422,7 @@ class Conductor
   /**
    * Given a time in steps and fractional steps, return a time in milliseconds.
    */
-  public static function getStepTimeInMs(stepTime:Float):Float
+  public function getStepTimeInMs(stepTime:Float):Float
   {
     if (timeChanges.length == 0)
     {
@@ -447,7 +458,7 @@ class Conductor
   /**
    * Given a time in beats and fractional beats, return a time in milliseconds.
    */
-  public static function getBeatTimeInMs(beatTime:Float):Float
+  public function getBeatTimeInMs(beatTime:Float):Float
   {
     if (timeChanges.length == 0)
     {
@@ -480,13 +491,20 @@ class Conductor
     }
   }
 
+  public static function watchQuick():Void
+  {
+    FlxG.watch.addQuick("songPosition", Conductor.instance.songPosition);
+    FlxG.watch.addQuick("bpm", Conductor.instance.bpm);
+    FlxG.watch.addQuick("currentMeasureTime", Conductor.instance.currentMeasureTime);
+    FlxG.watch.addQuick("currentBeatTime", Conductor.instance.currentBeatTime);
+    FlxG.watch.addQuick("currentStepTime", Conductor.instance.currentStepTime);
+  }
+
+  /**
+   * Reset the Conductor, replacing the current instance with a fresh one.
+   */
   public static function reset():Void
   {
-    beatHit.removeAll();
-    stepHit.removeAll();
-
-    mapTimeChanges([]);
-    forceBPM(null);
-    update(0);
+    Conductor.instance = new Conductor();
   }
 }
diff --git a/source/funkin/Highscore.hx b/source/funkin/Highscore.hx
index 2c18ffa2d..24b65832b 100644
--- a/source/funkin/Highscore.hx
+++ b/source/funkin/Highscore.hx
@@ -21,7 +21,6 @@ abstract Tallies(RawTallies)
         bad: 0,
         good: 0,
         sick: 0,
-        killer: 0,
         totalNotes: 0,
         totalNotesHit: 0,
         maxCombo: 0,
@@ -43,7 +42,6 @@ typedef RawTallies =
   var bad:Int;
   var good:Int;
   var sick:Int;
-  var killer:Int;
   var maxCombo:Int;
   var isNewHighscore:Bool;
 
diff --git a/source/funkin/InitState.hx b/source/funkin/InitState.hx
index 13bcd306e..625a33ad7 100644
--- a/source/funkin/InitState.hx
+++ b/source/funkin/InitState.hx
@@ -19,12 +19,12 @@ import funkin.play.PlayStatePlaylist;
 import openfl.display.BitmapData;
 import funkin.data.level.LevelRegistry;
 import funkin.data.notestyle.NoteStyleRegistry;
-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.data.event.SongEventRegistry;
+import funkin.data.stage.StageRegistry;
+import funkin.data.dialogue.ConversationRegistry;
+import funkin.data.dialogue.DialogueBoxRegistry;
+import funkin.data.dialogue.SpeakerRegistry;
 import funkin.data.song.SongRegistry;
-import funkin.play.stage.StageData.StageDataParser;
 import funkin.play.character.CharacterData.CharacterDataParser;
 import funkin.modding.module.ModuleHandler;
 import funkin.ui.title.TitleState;
@@ -197,25 +197,40 @@ class InitState extends FlxState
     FlxG.android.preventDefaultKeys = [flixel.input.android.FlxAndroidKey.BACK];
     #end
 
+    //
+    // FLIXEL PLUGINS
+    //
+    funkin.util.plugins.EvacuateDebugPlugin.initialize();
+    funkin.util.plugins.ReloadAssetsDebugPlugin.initialize();
+    funkin.util.plugins.WatchPlugin.initialize();
+
     //
     // GAME DATA PARSING
     //
 
-    // NOTE: Registries and data parsers must be imported and not referenced with fully qualified names,
+    // NOTE: Registries must be imported and not referenced with fully qualified names,
     // to ensure build macros work properly.
+    trace('Parsing game data...');
+    var perfStart = haxe.Timer.stamp();
+    SongEventRegistry.loadEventCache(); // SongEventRegistry is structured differently so it's not a BaseRegistry.
     SongRegistry.instance.loadEntries();
     LevelRegistry.instance.loadEntries();
     NoteStyleRegistry.instance.loadEntries();
-    SongEventParser.loadEventCache();
-    ConversationDataParser.loadConversationCache();
-    DialogueBoxDataParser.loadDialogueBoxCache();
-    SpeakerDataParser.loadSpeakerCache();
-    StageDataParser.loadStageCache();
-    CharacterDataParser.loadCharacterCache();
+    ConversationRegistry.instance.loadEntries();
+    DialogueBoxRegistry.instance.loadEntries();
+    SpeakerRegistry.instance.loadEntries();
+    StageRegistry.instance.loadEntries();
+
+    // TODO: CharacterDataParser doesn't use json2object, so it's way slower than the other parsers.
+    CharacterDataParser.loadCharacterCache(); // TODO: Migrate characters to BaseRegistry.
+
     ModuleHandler.buildModuleCallbacks();
     ModuleHandler.loadModuleCache();
-
     ModuleHandler.callOnCreate();
+
+    var perfEnd = haxe.Timer.stamp();
+
+    trace('Parsing game data took ${Math.floor((perfEnd - perfStart) * 1000)}ms.');
   }
 
   /**
@@ -233,8 +248,12 @@ class InitState extends FlxState
     startLevel(defineLevel(), defineDifficulty());
     #elseif FREEPLAY // -DFREEPLAY
     FlxG.switchState(new FreeplayState());
+    #elseif DIALOGUE // -DDIALOGUE
+    FlxG.switchState(new funkin.ui.debug.dialogue.ConversationDebugState());
     #elseif ANIMATE // -DANIMATE
     FlxG.switchState(new funkin.ui.debug.anim.FlxAnimateTest());
+    #elseif WAVEFORM // -DWAVEFORM
+    FlxG.switchState(new funkin.ui.debug.WaveformTestState());
     #elseif CHARTING // -DCHARTING
     FlxG.switchState(new funkin.ui.debug.charting.ChartEditorState());
     #elseif STAGEBUILD // -DSTAGEBUILD
diff --git a/source/funkin/audio/FunkinSound.hx b/source/funkin/audio/FunkinSound.hx
index 40293b0ce..e7ce68d08 100644
--- a/source/funkin/audio/FunkinSound.hx
+++ b/source/funkin/audio/FunkinSound.hx
@@ -7,6 +7,10 @@ import flash.utils.ByteArray;
 import flixel.sound.FlxSound;
 import flixel.group.FlxGroup.FlxTypedGroup;
 import flixel.system.FlxAssets.FlxSoundAsset;
+import funkin.util.tools.ICloneable;
+import funkin.audio.waveform.WaveformData;
+import funkin.audio.waveform.WaveformDataParser;
+import flixel.math.FlxMath;
 import openfl.Assets;
 #if (openfl >= "8.0.0")
 import openfl.utils.AssetType;
@@ -17,10 +21,38 @@ import openfl.utils.AssetType;
  * - Delayed playback via negative song position.
  */
 @:nullSafety
-class FunkinSound extends FlxSound
+class FunkinSound extends FlxSound implements ICloneable<FunkinSound>
 {
+  static final MAX_VOLUME:Float = 2.0;
+
   static var cache(default, null):FlxTypedGroup<FunkinSound> = new FlxTypedGroup<FunkinSound>();
 
+  public var muted(default, set):Bool = false;
+
+  function set_muted(value:Bool):Bool
+  {
+    if (value == muted) return value;
+    muted = value;
+    updateTransform();
+    return value;
+  }
+
+  override function set_volume(value:Float):Float
+  {
+    // Uncap the volume.
+    fixMaxVolume();
+    _volume = FlxMath.bound(value, 0.0, MAX_VOLUME);
+    updateTransform();
+    return _volume;
+  }
+
+  public var paused(get, never):Bool;
+
+  function get_paused():Bool
+  {
+    return this._paused;
+  }
+
   public var isPlaying(get, never):Bool;
 
   function get_isPlaying():Bool
@@ -28,6 +60,24 @@ class FunkinSound extends FlxSound
     return this.playing || this._shouldPlay;
   }
 
+  /**
+   * Waveform data for this sound.
+   * This is lazily loaded, so it will be built the first time it is accessed.
+   */
+  public var waveformData(get, never):WaveformData;
+
+  var _waveformData:Null<WaveformData> = null;
+
+  function get_waveformData():WaveformData
+  {
+    if (_waveformData == null)
+    {
+      _waveformData = WaveformDataParser.interpretFlxSound(this);
+      if (_waveformData == null) throw 'Could not interpret waveform data!';
+    }
+    return _waveformData;
+  }
+
   /**
    * Are we in a state where the song should play but time is negative?
    */
@@ -63,6 +113,30 @@ class FunkinSound extends FlxSound
     }
   }
 
+  public function togglePlayback():FunkinSound
+  {
+    if (playing)
+    {
+      pause();
+    }
+    else
+    {
+      resume();
+    }
+    return this;
+  }
+
+  function fixMaxVolume():Void
+  {
+    #if lime_openal
+    // This code is pretty fragile, it reaches through 5 layers of private access.
+    @:privateAccess
+    var handle = this?._channel?.__source?.__backend?.handle;
+    if (handle == null) return;
+    lime.media.openal.AL.sourcef(handle, lime.media.openal.AL.MAX_GAIN, MAX_VOLUME);
+    #end
+  }
+
   public override function play(forceRestart:Bool = false, startTime:Float = 0, ?endTime:Float):FunkinSound
   {
     if (!exists) return this;
@@ -107,6 +181,26 @@ class FunkinSound extends FlxSound
     return this;
   }
 
+  /**
+   * Called when the user clicks to focus on the window.
+   */
+  override function onFocus():Void
+  {
+    if (!_alreadyPaused && this._shouldPlay)
+    {
+      resume();
+    }
+  }
+
+  /**
+   * Called when the user tabs away from the window.
+   */
+  override function onFocusLost():Void
+  {
+    _alreadyPaused = _paused;
+    pause();
+  }
+
   public override function resume():FunkinSound
   {
     if (this._time < 0)
@@ -120,6 +214,37 @@ class FunkinSound extends FlxSound
     return this;
   }
 
+  /**
+   * Call after adjusting the volume to update the sound channel's settings.
+   */
+  @:allow(flixel.sound.FlxSoundGroup)
+  override function updateTransform():Void
+  {
+    _transform.volume = #if FLX_SOUND_SYSTEM ((FlxG.sound.muted || this.muted) ? 0 : 1) * FlxG.sound.volume * #end
+      (group != null ? group.volume : 1) * _volume * _volumeAdjust;
+
+    if (_channel != null) _channel.soundTransform = _transform;
+  }
+
+  public function clone():FunkinSound
+  {
+    var sound:FunkinSound = new FunkinSound();
+
+    // Clone the sound by creating one with the same data buffer.
+    // Reusing the `Sound` object directly causes issues with playback.
+    @:privateAccess
+    sound._sound = openfl.media.Sound.fromAudioBuffer(this._sound.__buffer);
+
+    // Call init to ensure the FlxSound is properly initialized.
+    sound.init(this.looped, this.autoDestroy, this.onComplete);
+
+    // Oh yeah, the waveform data is the same too!
+    @:privateAccess
+    sound._waveformData = this._waveformData;
+
+    return sound;
+  }
+
   /**
    * Creates a new `FunkinSound` object.
    *
diff --git a/source/funkin/audio/SoundGroup.hx b/source/funkin/audio/SoundGroup.hx
index 15c2296ca..df3a67ae1 100644
--- a/source/funkin/audio/SoundGroup.hx
+++ b/source/funkin/audio/SoundGroup.hx
@@ -3,6 +3,7 @@ package funkin.audio;
 import flixel.group.FlxGroup.FlxTypedGroup;
 import flixel.sound.FlxSound;
 import funkin.audio.FunkinSound;
+import flixel.tweens.FlxTween;
 
 /**
  * A group of FunkinSounds that are all synced together.
@@ -14,8 +15,12 @@ class SoundGroup extends FlxTypedGroup<FunkinSound>
 
   public var volume(get, set):Float;
 
+  public var muted(get, set):Bool;
+
   public var pitch(get, set):Float;
 
+  public var playing(get, never):Bool;
+
   public function new()
   {
     super();
@@ -122,6 +127,26 @@ class SoundGroup extends FlxTypedGroup<FunkinSound>
     });
   }
 
+  /**
+   * Fade in all the sounds in the group.
+   */
+  public function fadeIn(duration:Float, ?from:Float = 0.0, ?to:Float = 1.0, ?onComplete:FlxTween->Void):Void
+  {
+    forEachAlive(function(sound:FunkinSound) {
+      sound.fadeIn(duration, from, to, onComplete);
+    });
+  }
+
+  /**
+   * Fade out all the sounds in the group.
+   */
+  public function fadeOut(duration:Float, ?to:Float = 0.0, ?onComplete:FlxTween->Void):Void
+  {
+    forEachAlive(function(sound:FunkinSound) {
+      sound.fadeOut(duration, to, onComplete);
+    });
+  }
+
   /**
    * Stop all the sounds in the group.
    */
@@ -132,6 +157,12 @@ class SoundGroup extends FlxTypedGroup<FunkinSound>
     });
   }
 
+  public override function destroy()
+  {
+    stop();
+    super.destroy();
+  }
+
   /**
    * Remove all sounds from the group.
    */
@@ -159,6 +190,13 @@ class SoundGroup extends FlxTypedGroup<FunkinSound>
     return time;
   }
 
+  function get_playing():Bool
+  {
+    if (getFirstAlive != null) return getFirstAlive().playing;
+    else
+      return false;
+  }
+
   function get_volume():Float
   {
     if (getFirstAlive() != null) return getFirstAlive().volume;
@@ -176,6 +214,22 @@ class SoundGroup extends FlxTypedGroup<FunkinSound>
     return volume;
   }
 
+  function get_muted():Bool
+  {
+    if (getFirstAlive() != null) return getFirstAlive().muted;
+    else
+      return false;
+  }
+
+  function set_muted(muted:Bool):Bool
+  {
+    forEachAlive(function(snd:FunkinSound) {
+      snd.muted = muted;
+    });
+
+    return muted;
+  }
+
   function get_pitch():Float
   {
     #if FLX_PITCH
diff --git a/source/funkin/audio/VoicesGroup.hx b/source/funkin/audio/VoicesGroup.hx
index 42f31af70..5daebc89d 100644
--- a/source/funkin/audio/VoicesGroup.hx
+++ b/source/funkin/audio/VoicesGroup.hx
@@ -2,6 +2,8 @@ package funkin.audio;
 
 import funkin.audio.FunkinSound;
 import flixel.group.FlxGroup.FlxTypedGroup;
+import funkin.audio.waveform.WaveformData;
+import funkin.audio.waveform.WaveformDataParser;
 
 class VoicesGroup extends SoundGroup
 {
@@ -104,6 +106,50 @@ class VoicesGroup extends SoundGroup
     return opponentVolume = volume;
   }
 
+  public function getPlayerVoice(index:Int = 0):Null<FunkinSound>
+  {
+    return playerVoices.members[index];
+  }
+
+  public function getOpponentVoice(index:Int = 0):Null<FunkinSound>
+  {
+    return opponentVoices.members[index];
+  }
+
+  public function getPlayerVoiceWaveform():Null<WaveformData>
+  {
+    if (playerVoices.members.length == 0) return null;
+
+    return playerVoices.members[0].waveformData;
+  }
+
+  public function getOpponentVoiceWaveform():Null<WaveformData>
+  {
+    if (opponentVoices.members.length == 0) return null;
+
+    return opponentVoices.members[0].waveformData;
+  }
+
+  /**
+   * The length of the player's vocal track, in milliseconds.
+   */
+  public function getPlayerVoiceLength():Float
+  {
+    if (playerVoices.members.length == 0) return 0.0;
+
+    return playerVoices.members[0].length;
+  }
+
+  /**
+   * The length of the opponent's vocal track, in milliseconds.
+   */
+  public function getOpponentVoiceLength():Float
+  {
+    if (opponentVoices.members.length == 0) return 0.0;
+
+    return opponentVoices.members[0].length;
+  }
+
   public override function clear():Void
   {
     playerVoices.clear();
diff --git a/source/funkin/audio/visualize/ABotVis.hx b/source/funkin/audio/visualize/ABotVis.hx
index 681287808..89b004df4 100644
--- a/source/funkin/audio/visualize/ABotVis.hx
+++ b/source/funkin/audio/visualize/ABotVis.hx
@@ -64,7 +64,7 @@ class ABotVis extends FlxTypedSpriteGroup<FlxSprite>
 
         if (vis.snd.playing) remappedShit = Std.int(FlxMath.remapToRange(vis.snd.time, 0, vis.snd.length, 0, vis.numSamples));
         else
-          remappedShit = Std.int(FlxMath.remapToRange(Conductor.songPosition, 0, vis.snd.length, 0, vis.numSamples));
+          remappedShit = Std.int(FlxMath.remapToRange(Conductor.instance.songPosition, 0, vis.snd.length, 0, vis.numSamples));
 
         var fftSamples:Array<Float> = [];
 
diff --git a/source/funkin/audio/visualize/PolygonSpectogram.hx b/source/funkin/audio/visualize/PolygonSpectogram.hx
index 37a6c15d1..948027a8d 100644
--- a/source/funkin/audio/visualize/PolygonSpectogram.hx
+++ b/source/funkin/audio/visualize/PolygonSpectogram.hx
@@ -102,7 +102,7 @@ class PolygonSpectogram extends MeshRender
         coolPoint.x = (curAud.balanced * waveAmplitude);
         coolPoint.y = (i / funnyPixels * daHeight);
 
-        add_quad(prevPoint.x, prevPoint.y, prevPoint.x
+        build_quad(prevPoint.x, prevPoint.y, prevPoint.x
           + thickness, prevPoint.y, coolPoint.x, coolPoint.y, coolPoint.x
           + thickness, coolPoint.y
           + thickness);
diff --git a/source/funkin/audio/visualize/PolygonVisGroup.hx b/source/funkin/audio/visualize/PolygonVisGroup.hx
index 2903eaccd..cc68f4ae0 100644
--- a/source/funkin/audio/visualize/PolygonVisGroup.hx
+++ b/source/funkin/audio/visualize/PolygonVisGroup.hx
@@ -8,8 +8,7 @@ class PolygonVisGroup extends FlxTypedGroup<PolygonSpectogram>
 {
   public var playerVis:PolygonSpectogram;
   public var opponentVis:PolygonSpectogram;
-
-  var instVis:PolygonSpectogram;
+  public var instVis:PolygonSpectogram;
 
   public function new()
   {
@@ -51,6 +50,43 @@ class PolygonVisGroup extends FlxTypedGroup<PolygonSpectogram>
     instVis = vis;
   }
 
+  public function clearPlayerVis():Void
+  {
+    if (playerVis != null)
+    {
+      remove(playerVis);
+      playerVis.destroy();
+      playerVis = null;
+    }
+  }
+
+  public function clearOpponentVis():Void
+  {
+    if (opponentVis != null)
+    {
+      remove(opponentVis);
+      opponentVis.destroy();
+      opponentVis = null;
+    }
+  }
+
+  public function clearInstVis():Void
+  {
+    if (instVis != null)
+    {
+      remove(instVis);
+      instVis.destroy();
+      instVis = null;
+    }
+  }
+
+  public function clearAllVis():Void
+  {
+    clearPlayerVis();
+    clearOpponentVis();
+    clearInstVis();
+  }
+
   /**
    * Overrides the add function to add a visualizer to the group.
    * @param vis The visualizer to add.
diff --git a/source/funkin/audio/visualize/SpectogramSprite.hx b/source/funkin/audio/visualize/SpectogramSprite.hx
index 63d0fcd2e..b4e024a4c 100644
--- a/source/funkin/audio/visualize/SpectogramSprite.hx
+++ b/source/funkin/audio/visualize/SpectogramSprite.hx
@@ -164,7 +164,7 @@ class SpectogramSprite extends FlxTypedSpriteGroup<FlxSprite>
 
         if (vis.snd.playing) remappedShit = Std.int(FlxMath.remapToRange(vis.snd.time, 0, vis.snd.length, 0, numSamples));
         else
-          remappedShit = Std.int(FlxMath.remapToRange(Conductor.songPosition, 0, vis.snd.length, 0, numSamples));
+          remappedShit = Std.int(FlxMath.remapToRange(Conductor.instance.songPosition, 0, vis.snd.length, 0, numSamples));
 
         var fftSamples:Array<Float> = [];
         var i = remappedShit;
@@ -235,15 +235,15 @@ class SpectogramSprite extends FlxTypedSpriteGroup<FlxSprite>
         if (vis.snd.playing) remappedShit = Std.int(FlxMath.remapToRange(vis.snd.time, 0, vis.snd.length, 0, numSamples));
         else
         {
-          if (curTime == Conductor.songPosition)
+          if (curTime == Conductor.instance.songPosition)
           {
             wavOptimiz = 3;
             return; // already did shit, so finishes function early
           }
 
-          curTime = Conductor.songPosition;
+          curTime = Conductor.instance.songPosition;
 
-          remappedShit = Std.int(FlxMath.remapToRange(Conductor.songPosition, 0, vis.snd.length, 0, numSamples));
+          remappedShit = Std.int(FlxMath.remapToRange(Conductor.instance.songPosition, 0, vis.snd.length, 0, numSamples));
         }
 
         wavOptimiz = 8;
diff --git a/source/funkin/audio/waveform/WaveformData.hx b/source/funkin/audio/waveform/WaveformData.hx
new file mode 100644
index 000000000..b82d141e7
--- /dev/null
+++ b/source/funkin/audio/waveform/WaveformData.hx
@@ -0,0 +1,336 @@
+package funkin.audio.waveform;
+
+import funkin.util.MathUtil;
+
+@:nullSafety
+class WaveformData
+{
+  static final DEFAULT_VERSION:Int = 2;
+
+  /**
+   * The version of the waveform data format.
+   * @default `2` (-1 if not specified/invalid)
+   */
+  public var version(default, null):Int = -1;
+
+  /**
+   * The number of channels in the waveform.
+   */
+  public var channels(default, null):Int = 1;
+
+  @:alias('sample_rate')
+  public var sampleRate(default, null):Int = 44100;
+
+  /**
+   * Number of input audio samples per output waveform data point.
+   * At base zoom level this is number of samples per pixel.
+   * Lower values can more accurately represent the waveform when zoomed in, but take more data.
+   */
+  @:alias('samples_per_pixel')
+  public var samplesPerPoint(default, null):Int = 256;
+
+  /**
+   * Number of bits to use for each sample value. Valid values are `8` and `16`.
+   */
+  public var bits(default, null):Int = 16;
+
+  /**
+   * The length of the data array, in points.
+   */
+  public var length(default, null):Int = 0;
+
+  /**
+   * Array of Int16 values representing the waveform.
+   * TODO: Use an `openfl.Vector` for performance.
+   */
+  public var data(default, null):Array<Int> = [];
+
+  @:jignored
+  var channelData:Null<Array<WaveformDataChannel>> = null;
+
+  public function new(?version:Int, channels:Int, sampleRate:Int, samplesPerPoint:Int, bits:Int, length:Int, data:Array<Int>)
+  {
+    this.version = version ?? DEFAULT_VERSION;
+    this.channels = channels;
+    this.sampleRate = sampleRate;
+    this.samplesPerPoint = samplesPerPoint;
+    this.bits = bits;
+    this.length = length;
+    this.data = data;
+  }
+
+  function buildChannelData():Array<WaveformDataChannel>
+  {
+    channelData = [];
+    for (i in 0...channels)
+    {
+      channelData.push(new WaveformDataChannel(this, i));
+    }
+    return channelData;
+  }
+
+  public function channel(index:Int)
+  {
+    return (channelData == null) ? buildChannelData()[index] : channelData[index];
+  }
+
+  public function get(index:Int):Int
+  {
+    return data[index] ?? 0;
+  }
+
+  public function set(index:Int, value:Int)
+  {
+    data[index] = value;
+  }
+
+  /**
+   * Maximum possible value for a waveform data point.
+   * The minimum possible value is (-1 * maxSampleValue)
+   */
+  public function maxSampleValue():Int
+  {
+    if (_maxSampleValue != 0) return _maxSampleValue;
+    return _maxSampleValue = Std.int(Math.pow(2, bits));
+  }
+
+  /**
+   * Cache the value because `Math.pow` is expensive and the value gets used a lot.
+   */
+  @:jignored
+  var _maxSampleValue:Int = 0;
+
+  /**
+   * @return The length of the waveform in samples.
+   */
+  public function lenSamples():Int
+  {
+    return length * samplesPerPoint;
+  }
+
+  /**
+   * @return The length of the waveform in seconds.
+   */
+  public function lenSeconds():Float
+  {
+    return inline lenSamples() / sampleRate;
+  }
+
+  /**
+   * Given the time in seconds, return the waveform data point index.
+   */
+  public function secondsToIndex(seconds:Float):Int
+  {
+    return Std.int(seconds * inline pointsPerSecond());
+  }
+
+  /**
+   * Given a waveform data point index, return the time in seconds.
+   */
+  public function indexToSeconds(index:Int):Float
+  {
+    return index / inline pointsPerSecond();
+  }
+
+  /**
+   * The number of data points this waveform data provides per second of audio.
+   */
+  public inline function pointsPerSecond():Float
+  {
+    return sampleRate / samplesPerPoint;
+  }
+
+  /**
+   * Given the percentage progress through the waveform, return the waveform data point index.
+   */
+  public function percentToIndex(percent:Float):Int
+  {
+    return Std.int(percent * length);
+  }
+
+  /**
+   * Given a waveform data point index, return the percentage progress through the waveform.
+   */
+  public function indexToPercent(index:Int):Float
+  {
+    return index / length;
+  }
+
+  /**
+   * Resample the waveform data to create a new WaveformData object matching the desired `samplesPerPoint` value.
+   * This is useful for zooming in/out of the waveform in a performant manner.
+   *
+   * @param newSamplesPerPoint The new value for `samplesPerPoint`.
+   */
+  public function resample(newSamplesPerPoint:Int):WaveformData
+  {
+    var result = this.clone();
+
+    var ratio = newSamplesPerPoint / samplesPerPoint;
+    if (ratio == 1) return result;
+    if (ratio < 1) trace('[WARNING] Downsampling will result in a low precision.');
+
+    var inputSampleCount = this.lenSamples();
+    var outputSampleCount = Std.int(inputSampleCount * ratio);
+
+    var inputPointCount = this.length;
+    var outputPointCount = Std.int(inputPointCount / ratio);
+    var outputChannelCount = this.channels;
+
+    // TODO: Actually figure out the dumbass logic for this.
+
+    return result;
+  }
+
+  /**
+   * Create a new WaveformData whose data represents the two waveforms overlayed.
+   */
+  public function merge(that:WaveformData):WaveformData
+  {
+    var result = this.clone([]);
+
+    for (channelIndex in 0...this.channels)
+    {
+      var thisChannel = this.channel(channelIndex);
+      var thatChannel = that.channel(channelIndex);
+      var resultChannel = result.channel(channelIndex);
+
+      for (index in 0...this.length)
+      {
+        var thisMinSample = thisChannel.minSample(index);
+        var thatMinSample = thatChannel.minSample(index);
+
+        var thisMaxSample = thisChannel.maxSample(index);
+        var thatMaxSample = thatChannel.maxSample(index);
+
+        resultChannel.setMinSample(index, Std.int(Math.min(thisMinSample, thatMinSample)));
+        resultChannel.setMaxSample(index, Std.int(Math.max(thisMaxSample, thatMaxSample)));
+      }
+    }
+
+    @:privateAccess
+    result.length = this.length;
+
+    return result;
+  }
+
+  /**
+   * Create a new WaveformData whose parameters match the current object.
+   */
+  public function clone(?newData:Array<Int> = null):WaveformData
+  {
+    if (newData == null)
+    {
+      newData = this.data.clone();
+    }
+
+    var clone = new WaveformData(this.version, this.channels, this.sampleRate, this.samplesPerPoint, this.bits, newData.length, newData);
+
+    return clone;
+  }
+}
+
+@:nullSafety
+class WaveformDataChannel
+{
+  var parent:WaveformData;
+  var channelId:Int;
+
+  public function new(parent:WaveformData, channelId:Int)
+  {
+    this.parent = parent;
+    this.channelId = channelId;
+  }
+
+  /**
+   * Retrieve a given minimum point at an index.
+   */
+  public function minSample(i:Int)
+  {
+    var offset = (i * parent.channels + this.channelId) * 2;
+    return inline parent.get(offset);
+  }
+
+  /**
+   * Mapped to a value between 0 and 1.
+   */
+  public function minSampleMapped(i:Int)
+  {
+    return inline minSample(i) / inline parent.maxSampleValue();
+  }
+
+  /**
+   * Minimum value within the range of samples.
+   * NOTE: Inefficient for large ranges. Use `WaveformData.remap` instead.
+   */
+  public function minSampleRange(start:Int, end:Int)
+  {
+    var min = inline parent.maxSampleValue();
+    for (i in start...end)
+    {
+      var sample = inline minSample(i);
+      if (sample < min) min = sample;
+    }
+    return min;
+  }
+
+  /**
+   * Maximum value within the range of samples, mapped to a value between 0 and 1.
+   */
+  public function minSampleRangeMapped(start:Int, end:Int)
+  {
+    return inline minSampleRange(start, end) / inline parent.maxSampleValue();
+  }
+
+  /**
+   * Retrieve a given maximum point at an index.
+   */
+  public function maxSample(i:Int)
+  {
+    var offset = (i * parent.channels + this.channelId) * 2 + 1;
+    return inline parent.get(offset);
+  }
+
+  /**
+   * Mapped to a value between 0 and 1.
+   */
+  public function maxSampleMapped(i:Int)
+  {
+    return inline maxSample(i) / inline parent.maxSampleValue();
+  }
+
+  /**
+   * Maximum value within the range of samples.
+   * NOTE: Inefficient for large ranges. Use `WaveformData.remap` instead.
+   */
+  public function maxSampleRange(start:Int, end:Int)
+  {
+    var max = -(inline parent.maxSampleValue());
+    for (i in start...end)
+    {
+      var sample = inline maxSample(i);
+      if (sample > max) max = sample;
+    }
+    return max;
+  }
+
+  /**
+   * Maximum value within the range of samples, mapped to a value between 0 and 1.
+   */
+  public function maxSampleRangeMapped(start:Int, end:Int)
+  {
+    return inline maxSampleRange(start, end) / inline parent.maxSampleValue();
+  }
+
+  public function setMinSample(i:Int, value:Int)
+  {
+    var offset = (i * parent.channels + this.channelId) * 2;
+    inline parent.set(offset, value);
+  }
+
+  public function setMaxSample(i:Int, value:Int)
+  {
+    var offset = (i * parent.channels + this.channelId) * 2 + 1;
+    inline parent.set(offset, value);
+  }
+}
diff --git a/source/funkin/audio/waveform/WaveformDataParser.hx b/source/funkin/audio/waveform/WaveformDataParser.hx
new file mode 100644
index 000000000..54a142f6a
--- /dev/null
+++ b/source/funkin/audio/waveform/WaveformDataParser.hx
@@ -0,0 +1,145 @@
+package funkin.audio.waveform;
+
+class WaveformDataParser
+{
+  static final INT16_MAX:Int = 32767;
+  static final INT16_MIN:Int = -32768;
+
+  static final INT8_MAX:Int = 127;
+  static final INT8_MIN:Int = -128;
+
+  public static function interpretFlxSound(sound:flixel.sound.FlxSound):Null<WaveformData>
+  {
+    if (sound == null) return null;
+
+    // Method 1. This only works if the sound has been played before.
+    @:privateAccess
+    var soundBuffer:Null<lime.media.AudioBuffer> = sound?._channel?.__source?.buffer;
+
+    if (soundBuffer == null)
+    {
+      // Method 2. This works if the sound has not been played before.
+      @:privateAccess
+      soundBuffer = sound?._sound?.__buffer;
+
+      if (soundBuffer == null)
+      {
+        trace('[WAVEFORM] Failed to interpret FlxSound: ${sound}');
+        return null;
+      }
+      else
+      {
+        // trace('[WAVEFORM] Method 2 worked.');
+      }
+    }
+    else
+    {
+      // trace('[WAVEFORM] Method 1 worked.');
+    }
+
+    return interpretAudioBuffer(soundBuffer);
+  }
+
+  public static function interpretAudioBuffer(soundBuffer:lime.media.AudioBuffer):Null<WaveformData>
+  {
+    var sampleRate = soundBuffer.sampleRate;
+    var channels = soundBuffer.channels;
+    var bitsPerSample = soundBuffer.bitsPerSample;
+    var samplesPerPoint:Int = 256; // I don't think we need to configure this.
+    var pointsPerSecond:Float = sampleRate / samplesPerPoint; // 172 samples per second for most songs is plenty precise while still being performant..
+
+    // TODO: Make this work better on HTML5.
+    var soundData:lime.utils.Int16Array = cast soundBuffer.data;
+
+    var soundDataRawLength:Int = soundData.length;
+    var soundDataSampleCount:Int = Std.int(Math.ceil(soundDataRawLength / channels / (bitsPerSample == 16 ? 2 : 1)));
+    var outputPointCount:Int = Std.int(Math.ceil(soundDataSampleCount / samplesPerPoint));
+
+    // trace('Interpreting audio buffer:');
+    // trace('  sampleRate: ${sampleRate}');
+    // trace('  channels: ${channels}');
+    // trace('  bitsPerSample: ${bitsPerSample}');
+    // trace('  samplesPerPoint: ${samplesPerPoint}');
+    // trace('  pointsPerSecond: ${pointsPerSecond}');
+    // trace('  soundDataRawLength: ${soundDataRawLength}');
+    // trace('  soundDataSampleCount: ${soundDataSampleCount}');
+    // trace('  soundDataRawLength/4: ${soundDataRawLength / 4}');
+    // trace('  outputPointCount: ${outputPointCount}');
+
+    var minSampleValue:Int = bitsPerSample == 16 ? INT16_MIN : INT8_MIN;
+    var maxSampleValue:Int = bitsPerSample == 16 ? INT16_MAX : INT8_MAX;
+
+    var outputData:Array<Int> = [];
+
+    var perfStart = haxe.Timer.stamp();
+
+    for (pointIndex in 0...outputPointCount)
+    {
+      // minChannel1, maxChannel1, minChannel2, maxChannel2, ...
+      var values:Array<Int> = [];
+
+      for (i in 0...channels)
+      {
+        values.push(bitsPerSample == 16 ? INT16_MAX : INT8_MAX);
+        values.push(bitsPerSample == 16 ? INT16_MIN : INT8_MIN);
+      }
+
+      var rangeStart = pointIndex * samplesPerPoint;
+      var rangeEnd = rangeStart + samplesPerPoint;
+      if (rangeEnd > soundDataSampleCount) rangeEnd = soundDataSampleCount;
+
+      for (sampleIndex in rangeStart...rangeEnd)
+      {
+        for (channelIndex in 0...channels)
+        {
+          var sampleIndex:Int = sampleIndex * channels + channelIndex;
+          var sampleValue = soundData[sampleIndex];
+
+          if (sampleValue < values[channelIndex * 2]) values[(channelIndex * 2)] = sampleValue;
+          if (sampleValue > values[channelIndex * 2 + 1]) values[(channelIndex * 2) + 1] = sampleValue;
+        }
+      }
+
+      // We now have the min and max values for the range.
+      for (value in values)
+        outputData.push(value);
+    }
+
+    var outputDataLength:Int = Std.int(outputData.length / channels / 2);
+    var result = new WaveformData(null, channels, sampleRate, samplesPerPoint, bitsPerSample, outputPointCount, outputData);
+
+    var perfEnd = haxe.Timer.stamp();
+    trace('[WAVEFORM] Interpreted audio buffer in ${perfEnd - perfStart} seconds.');
+
+    return result;
+  }
+
+  public static function parseWaveformData(path:String):Null<WaveformData>
+  {
+    var rawJson:String = openfl.Assets.getText(path).trim();
+    return parseWaveformDataString(rawJson, path);
+  }
+
+  public static function parseWaveformDataString(contents:String, ?fileName:String):Null<WaveformData>
+  {
+    var parser = new json2object.JsonParser<WaveformData>();
+    parser.ignoreUnknownVariables = false;
+    trace('[WAVEFORM] Parsing waveform data: ${contents}');
+    parser.fromJson(contents, fileName);
+
+    if (parser.errors.length > 0)
+    {
+      printErrors(parser.errors, fileName);
+      return null;
+    }
+    return parser.value;
+  }
+
+  static function printErrors(errors:Array<json2object.Error>, id:String = ''):Void
+  {
+    trace('[WAVEFORM] Failed to parse waveform data: ${id}');
+
+    for (error in errors)
+      funkin.data.DataError.printError(error);
+  }
+}
diff --git a/source/funkin/audio/waveform/WaveformSprite.hx b/source/funkin/audio/waveform/WaveformSprite.hx
new file mode 100644
index 000000000..32ced2fbd
--- /dev/null
+++ b/source/funkin/audio/waveform/WaveformSprite.hx
@@ -0,0 +1,449 @@
+package funkin.audio.waveform;
+
+import funkin.audio.waveform.WaveformData;
+import funkin.audio.waveform.WaveformDataParser;
+import funkin.graphics.rendering.MeshRender;
+import flixel.util.FlxColor;
+
+class WaveformSprite extends MeshRender
+{
+  static final DEFAULT_COLOR:FlxColor = FlxColor.WHITE;
+  static final DEFAULT_DURATION:Float = 5.0;
+  static final DEFAULT_ORIENTATION:WaveformOrientation = HORIZONTAL;
+  static final DEFAULT_X:Float = 0.0;
+  static final DEFAULT_Y:Float = 0.0;
+  static final DEFAULT_WIDTH:Float = 100.0;
+  static final DEFAULT_HEIGHT:Float = 100.0;
+
+  /**
+   * Set this to true to tell the waveform to rebuild itself.
+   * Do this any time the data or drawable area of the waveform changes.
+   * This often (but not always) needs to be done every frame.
+   */
+  var isWaveformDirty:Bool = true;
+
+  /**
+   * If true, force the waveform to redraw every frame.
+   * Useful if the waveform's clipRect is constantly changing.
+   */
+  public var forceUpdate:Bool = false;
+
+  public var waveformData(default, set):Null<WaveformData>;
+
+  function set_waveformData(value:Null<WaveformData>):Null<WaveformData>
+  {
+    if (waveformData == value) return value;
+
+    waveformData = value;
+    isWaveformDirty = true;
+    return waveformData;
+  }
+
+  /**
+   * The color to render the waveform with.
+   */
+  public var waveformColor(default, set):FlxColor;
+
+  function set_waveformColor(value:FlxColor):FlxColor
+  {
+    if (waveformColor == value) return value;
+
+    waveformColor = value;
+    // We don't need to dirty the waveform geometry, just rebuild the texture.
+    rebuildGraphic();
+    return waveformColor;
+  }
+
+  public var orientation(default, set):WaveformOrientation;
+
+  function set_orientation(value:WaveformOrientation):WaveformOrientation
+  {
+    if (orientation == value) return value;
+
+    orientation = value;
+    isWaveformDirty = true;
+    return orientation;
+  }
+
+  /**
+   * Time, in seconds, at which the waveform starts.
+   */
+  public var time(default, set):Float;
+
+  function set_time(value:Float)
+  {
+    if (time == value) return value;
+
+    time = value;
+    isWaveformDirty = true;
+    return time;
+  }
+
+  /**
+   * The duration, in seconds, that the waveform represents.
+   * The section of waveform from `time` to `time + duration` and `width` are used to determine how many samples each pixel represents.
+   */
+  public var duration(default, set):Float;
+
+  function set_duration(value:Float)
+  {
+    if (duration == value) return value;
+
+    duration = value;
+    isWaveformDirty = true;
+    return duration;
+  }
+
+  /**
+   * Set the physical size of the waveform with `this.height = value`.
+   */
+  override function set_height(value:Float):Float
+  {
+    if (height == value) return super.set_height(value);
+
+    isWaveformDirty = true;
+    return super.set_height(value);
+  }
+
+  /**
+   * Set the physical size of the waveform with `this.width = value`.
+   */
+  override function set_width(value:Float):Float
+  {
+    if (width == value) return super.set_width(value);
+
+    isWaveformDirty = true;
+    return super.set_width(value);
+  }
+
+  /**
+   * The minimum size, in pixels, that a waveform will display with.
+   * Useful for preventing the waveform from becoming too small to see.
+   *
+   * NOTE: This is technically doubled since it's applied above and below the center of the waveform.
+   */
+  public var minWaveformSize:Int = 1;
+
+  /**
+   * A multiplier on the size of the waveform.
+   * Still capped at the width and height set for the sprite.
+   */
+  public var amplitude:Float = 1.0;
+
+  public function new(?waveformData:WaveformData, ?orientation:WaveformOrientation, ?color:FlxColor, ?duration:Float)
+  {
+    super(DEFAULT_X, DEFAULT_Y, DEFAULT_COLOR);
+    this.waveformColor = color ?? DEFAULT_COLOR;
+    this.width = DEFAULT_WIDTH;
+    this.height = DEFAULT_HEIGHT;
+
+    this.waveformData = waveformData;
+    this.orientation = orientation ?? DEFAULT_ORIENTATION;
+    this.time = 0.0;
+    this.duration = duration ?? DEFAULT_DURATION;
+
+    this.forceUpdate = false;
+  }
+
+  /**
+   * Manually tell the waveform to rebuild itself, even if none of its properties have changed.
+   */
+  public function markDirty():Void
+  {
+    isWaveformDirty = true;
+  }
+
+  public override function update(elapsed:Float)
+  {
+    super.update(elapsed);
+
+    if (forceUpdate || isWaveformDirty)
+    {
+      // Recalculate the waveform vertices.
+      drawWaveform();
+      isWaveformDirty = false;
+    }
+  }
+
+  function rebuildGraphic():Void
+  {
+    // The waveform is rendered using a single colored pixel as a texture.
+    // If you want something more elaborate, make sure to modify `build_vertex` below to use the UVs you want.
+    makeGraphic(1, 1, this.waveformColor);
+  }
+
+  /**
+   * @param offsetX Horizontal offset to draw the waveform at, in samples.
+   */
+  function drawWaveform():Void
+  {
+    // For each sample in the waveform...
+    // Add a MAX vertex and a MIN vertex.
+    //   If previous MAX/MIN is empty, store.
+    //   If previous MAX/MIN is not empty, draw a quad using current and previous MAX/MIN. Then store current MAX/MIN.
+    // Continue until end of waveform.
+
+    this.clear();
+
+    if (waveformData == null) return;
+
+    // Center point of the waveform. When horizontal this is half the height, when vertical this is half the width.
+    var waveformCenterPos:Int = orientation == HORIZONTAL ? Std.int(this.height / 2) : Std.int(this.width / 2);
+
+    var oneSecondInIndices:Int = waveformData.secondsToIndex(1);
+
+    var startTime:Float = time;
+    var endTime:Float = time + duration;
+
+    var startIndex:Int = waveformData.secondsToIndex(startTime);
+    var endIndex:Int = waveformData.secondsToIndex(endTime);
+
+    var pixelsPerIndex:Float = (orientation == HORIZONTAL ? this.width : this.height) / (endIndex - startIndex);
+    var indexesPerPixel:Float = 1 / pixelsPerIndex;
+
+    var topLeftVertexIndex:Int = -1;
+    var topRightVertexIndex:Int = -1;
+    var bottomLeftVertexIndex:Int = -1;
+    var bottomRightVertexIndex:Int = -1;
+
+    if (clipRect != null)
+    {
+      topLeftVertexIndex = this.build_vertex(clipRect.x, clipRect.y);
+      topRightVertexIndex = this.build_vertex(clipRect.x + clipRect.width, clipRect.y);
+      bottomLeftVertexIndex = this.build_vertex(clipRect.x, clipRect.y + clipRect.height);
+      bottomRightVertexIndex = this.build_vertex(clipRect.x + clipRect.width, clipRect.y + clipRect.height);
+    }
+
+    if (pixelsPerIndex >= 1.0)
+    {
+      // Each index is at least one pixel wide, so we render each index.
+      var prevVertexTopIndex:Int = -1;
+      var prevVertexBottomIndex:Int = -1;
+      for (i in startIndex...endIndex)
+      {
+        var pixelPos:Int = Std.int((i - startIndex) * pixelsPerIndex);
+
+        var isBeforeClipRect:Bool = (clipRect != null) && ((orientation == HORIZONTAL) ? pixelPos < clipRect.x : pixelPos < clipRect.y);
+
+        if (isBeforeClipRect) continue;
+
+        var isAfterClipRect:Bool = (clipRect != null)
+          && ((orientation == HORIZONTAL) ? pixelPos > (clipRect.x + clipRect.width) : pixelPos > (clipRect.y + clipRect.height));
+
+        if (isAfterClipRect)
+        {
+          break;
+        };
+
+        var sampleMax:Float = Math.min(waveformData.channel(0).maxSampleMapped(i) * amplitude, 1.0);
+        var sampleMin:Float = Math.max(waveformData.channel(0).minSampleMapped(i) * amplitude, -1.0);
+        var sampleMaxSize:Float = sampleMax * (orientation == HORIZONTAL ? this.height : this.width) / 2;
+        if (sampleMaxSize < minWaveformSize) sampleMaxSize = minWaveformSize;
+        var sampleMinSize:Float = sampleMin * (orientation == HORIZONTAL ? this.height : this.width) / 2;
+        if (sampleMinSize > -minWaveformSize) sampleMinSize = -minWaveformSize;
+        var vertexTopY:Int = Std.int(waveformCenterPos - sampleMaxSize);
+        var vertexBottomY:Int = Std.int(waveformCenterPos - sampleMinSize);
+
+        if (vertexBottomY - vertexTopY < minWaveformSize) vertexTopY = vertexBottomY - minWaveformSize;
+
+        var vertexTopIndex:Int = -1;
+        var vertexBottomIndex:Int = -1;
+
+        if (clipRect != null)
+        {
+          if (orientation == HORIZONTAL)
+          {
+            vertexTopIndex = buildClippedVertex(pixelPos, vertexTopY, topLeftVertexIndex, topRightVertexIndex, bottomLeftVertexIndex, bottomRightVertexIndex);
+            vertexBottomIndex = buildClippedVertex(pixelPos, vertexBottomY, topLeftVertexIndex, topRightVertexIndex, bottomLeftVertexIndex,
+              bottomRightVertexIndex);
+          }
+          else
+          {
+            vertexTopIndex = buildClippedVertex(vertexTopY, pixelPos, topLeftVertexIndex, topRightVertexIndex, bottomLeftVertexIndex, bottomRightVertexIndex);
+            vertexBottomIndex = buildClippedVertex(vertexBottomY, pixelPos, topLeftVertexIndex, topRightVertexIndex, bottomLeftVertexIndex,
+              bottomRightVertexIndex);
+          }
+        }
+        else
+        {
+          if (orientation == HORIZONTAL)
+          {
+            vertexTopIndex = this.build_vertex(pixelPos, vertexTopY);
+            vertexBottomIndex = this.build_vertex(pixelPos, vertexBottomY);
+          }
+          else
+          {
+            vertexTopIndex = this.build_vertex(vertexTopY, pixelPos);
+            vertexBottomIndex = this.build_vertex(vertexBottomY, pixelPos);
+          }
+        }
+
+        // Don't render if we don't have a previous different set of vertices to create a quad from.
+        if (prevVertexTopIndex != -1
+          && prevVertexBottomIndex != -1
+          && prevVertexTopIndex != vertexTopIndex
+          && prevVertexBottomIndex != vertexBottomIndex)
+        {
+          switch (orientation) // the line of code that makes you gay
+          {
+            case HORIZONTAL:
+              this.add_quad(prevVertexTopIndex, vertexTopIndex, vertexBottomIndex, prevVertexBottomIndex);
+            case VERTICAL:
+              this.add_quad(prevVertexBottomIndex, prevVertexTopIndex, vertexTopIndex, vertexBottomIndex);
+          }
+        }
+
+        prevVertexTopIndex = vertexTopIndex;
+        prevVertexBottomIndex = vertexBottomIndex;
+      }
+    }
+    else
+    {
+      // Indexes are less than one pixel wide, so for each pixel we render the maximum of the samples that fall within it.
+      var prevVertexTopIndex:Int = -1;
+      var prevVertexBottomIndex:Int = -1;
+      var waveformLengthPixels:Int = orientation == HORIZONTAL ? Std.int(this.width) : Std.int(this.height);
+      for (i in 0...waveformLengthPixels)
+      {
+        var pixelPos:Int = i;
+
+        var isBeforeClipRect:Bool = (clipRect != null) && ((orientation == HORIZONTAL) ? pixelPos < clipRect.x : pixelPos < clipRect.y);
+
+        if (isBeforeClipRect) continue;
+
+        var isAfterClipRect:Bool = (clipRect != null)
+          && ((orientation == HORIZONTAL) ? pixelPos > (clipRect.x + clipRect.width) : pixelPos > (clipRect.y + clipRect.height));
+
+        if (isAfterClipRect)
+        {
+          break;
+        };
+
+        // Wrap Std.int around the whole range calculation, not just indexesPerPixel, otherwise you get weird issues with zooming.
+        var rangeStart:Int = Std.int(i * indexesPerPixel + startIndex);
+        var rangeEnd:Int = Std.int((i + 1) * indexesPerPixel + startIndex);
+
+        var sampleMax:Float = Math.min(waveformData.channel(0).maxSampleRangeMapped(rangeStart, rangeEnd) * amplitude, 1.0);
+        var sampleMin:Float = Math.max(waveformData.channel(0).minSampleRangeMapped(rangeStart, rangeEnd) * amplitude, -1.0);
+        var sampleMaxSize:Float = sampleMax * (orientation == HORIZONTAL ? this.height : this.width) / 2;
+        if (sampleMaxSize < minWaveformSize) sampleMaxSize = minWaveformSize;
+        var sampleMinSize:Float = sampleMin * (orientation == HORIZONTAL ? this.height : this.width) / 2;
+        if (sampleMinSize > -minWaveformSize) sampleMinSize = -minWaveformSize;
+        var vertexTopY:Int = Std.int(waveformCenterPos - sampleMaxSize);
+        var vertexBottomY:Int = Std.int(waveformCenterPos - sampleMinSize);
+
+        var vertexTopIndex:Int = -1;
+        var vertexBottomIndex:Int = -1;
+
+        if (clipRect != null)
+        {
+          if (orientation == HORIZONTAL)
+          {
+            vertexTopIndex = buildClippedVertex(pixelPos, vertexTopY, topLeftVertexIndex, topRightVertexIndex, bottomLeftVertexIndex, bottomRightVertexIndex);
+            vertexBottomIndex = buildClippedVertex(pixelPos, vertexBottomY, topLeftVertexIndex, topRightVertexIndex, bottomLeftVertexIndex,
+              bottomRightVertexIndex);
+          }
+          else
+          {
+            vertexTopIndex = buildClippedVertex(vertexTopY, pixelPos, topLeftVertexIndex, topRightVertexIndex, bottomLeftVertexIndex, bottomRightVertexIndex);
+            vertexBottomIndex = buildClippedVertex(vertexBottomY, pixelPos, topLeftVertexIndex, topRightVertexIndex, bottomLeftVertexIndex,
+              bottomRightVertexIndex);
+          }
+        }
+        else
+        {
+          if (orientation == HORIZONTAL)
+          {
+            vertexTopIndex = this.build_vertex(pixelPos, vertexTopY);
+            vertexBottomIndex = this.build_vertex(pixelPos, vertexBottomY);
+          }
+          else
+          {
+            vertexTopIndex = this.build_vertex(vertexTopY, pixelPos);
+            vertexBottomIndex = this.build_vertex(vertexBottomY, pixelPos);
+          }
+        }
+
+        if (prevVertexTopIndex != -1 && prevVertexBottomIndex != -1)
+        {
+          switch (orientation)
+          {
+            case HORIZONTAL:
+              this.add_quad(prevVertexTopIndex, vertexTopIndex, vertexBottomIndex, prevVertexBottomIndex);
+            case VERTICAL:
+              this.add_quad(prevVertexBottomIndex, prevVertexTopIndex, vertexTopIndex, vertexBottomIndex);
+          }
+        }
+        prevVertexTopIndex = vertexTopIndex;
+        prevVertexBottomIndex = vertexBottomIndex;
+      }
+    }
+  }
+
+  function buildClippedVertex(x:Int, y:Int, topLeftVertexIndex:Int, topRightVertexIndex:Int, bottomLeftVertexIndex:Int, bottomRightVertexIndex:Int):Int
+  {
+    var shouldClipXLeft = x < clipRect.x;
+    var shouldClipXRight = x > (clipRect.x + clipRect.width);
+    var shouldClipYTop = y < clipRect.y;
+    var shouldClipYBottom = y > (clipRect.y + clipRect.height);
+
+    // If the vertex is fully outside the clipRect, use a pre-existing vertex.
+    // Else, if the vertex is outside the clipRect on one axis, create a new vertex constrained on that axis.
+    // Else, create a whole new vertex.
+    if (shouldClipXLeft && shouldClipYTop)
+    {
+      return topLeftVertexIndex;
+    }
+    else if (shouldClipXRight && shouldClipYTop)
+    {
+      return topRightVertexIndex;
+    }
+    else if (shouldClipXLeft && shouldClipYBottom)
+    {
+      return bottomLeftVertexIndex;
+    }
+    else if (shouldClipXRight && shouldClipYBottom)
+    {
+      return bottomRightVertexIndex;
+    }
+    else if (shouldClipXLeft)
+    {
+      return this.build_vertex(clipRect.x, y);
+    }
+    else if (shouldClipXRight)
+    {
+      return this.build_vertex(clipRect.x + clipRect.width, y);
+    }
+    else if (shouldClipYTop)
+    {
+      return this.build_vertex(x, clipRect.y);
+    }
+    else if (shouldClipYBottom)
+    {
+      return this.build_vertex(x, clipRect.y + clipRect.height);
+    }
+    else
+    {
+      return this.build_vertex(x, y);
+    }
+  }
+
+  public static function buildFromWaveformData(data:WaveformData, ?orientation:WaveformOrientation, ?color:FlxColor, ?duration:Float)
+  {
+    return new WaveformSprite(data, orientation, color, duration);
+  }
+
+  public static function buildFromFunkinSound(sound:FunkinSound, ?orientation:WaveformOrientation, ?color:FlxColor, ?duration:Float)
+  {
+    // TODO: Build waveform data from FunkinSound.
+    var data = null;
+
+    return buildFromWaveformData(data, orientation, color, duration);
+  }
+}
+
+enum WaveformOrientation
+{
+  HORIZONTAL;
+  VERTICAL;
+}
diff --git a/source/funkin/data/BaseRegistry.hx b/source/funkin/data/BaseRegistry.hx
index 70615069b..0ccbe2f18 100644
--- a/source/funkin/data/BaseRegistry.hx
+++ b/source/funkin/data/BaseRegistry.hx
@@ -46,6 +46,9 @@ abstract class BaseRegistry<T:(IRegistryEntry<J> & Constructible<EntryConstructo
     this.entries = new Map<String, T>();
   }
 
+  /**
+   * TODO: Create a `loadEntriesAsync()` function.
+   */
   public function loadEntries():Void
   {
     clearEntries();
@@ -54,7 +57,7 @@ abstract class BaseRegistry<T:(IRegistryEntry<J> & Constructible<EntryConstructo
     // SCRIPTED ENTRIES
     //
     var scriptedEntryClassNames:Array<String> = getScriptedClassNames();
-    log('Registering ${scriptedEntryClassNames.length} scripted entries...');
+    log('Parsing ${scriptedEntryClassNames.length} scripted entries...');
 
     for (entryCls in scriptedEntryClassNames)
     {
@@ -78,7 +81,7 @@ abstract class BaseRegistry<T:(IRegistryEntry<J> & Constructible<EntryConstructo
     var unscriptedEntryIds:Array<String> = entryIdList.filter(function(entryId:String):Bool {
       return !entries.exists(entryId);
     });
-    log('Fetching data for ${unscriptedEntryIds.length} unscripted entries...');
+    log('Parsing ${unscriptedEntryIds.length} unscripted entries...');
     for (entryId in unscriptedEntryIds)
     {
       try
diff --git a/source/funkin/data/DataParse.hx b/source/funkin/data/DataParse.hx
index cbd168a61..244d41132 100644
--- a/source/funkin/data/DataParse.hx
+++ b/source/funkin/data/DataParse.hx
@@ -120,6 +120,71 @@ class DataParse
     }
   }
 
+  public static function backdropData(json:Json, name:String):funkin.data.dialogue.ConversationData.BackdropData
+  {
+    switch (json.value)
+    {
+      case JObject(fields):
+        var result:Dynamic = {};
+        var backdropType:String = '';
+
+        for (field in fields)
+        {
+          switch (field.name)
+          {
+            case 'type':
+              backdropType = Tools.getValue(field.value);
+          }
+          Reflect.setField(result, field.name, Tools.getValue(field.value));
+        }
+
+        switch (backdropType)
+        {
+          case 'solid':
+            return SOLID(result);
+          default:
+            throw 'Expected Backdrop property $name to be specify a valid "type", but it was "${backdropType}".';
+        }
+
+        return null;
+      default:
+        throw 'Expected property $name to be an object, but it was ${json.value}.';
+    }
+  }
+
+  public static function outroData(json:Json, name:String):Null<funkin.data.dialogue.ConversationData.OutroData>
+  {
+    switch (json.value)
+    {
+      case JObject(fields):
+        var result:Dynamic = {};
+        var outroType:String = '';
+
+        for (field in fields)
+        {
+          switch (field.name)
+          {
+            case 'type':
+              outroType = Tools.getValue(field.value);
+          }
+          Reflect.setField(result, field.name, Tools.getValue(field.value));
+        }
+
+        switch (outroType)
+        {
+          case 'none':
+            return NONE(result);
+          case 'fade':
+            return FADE(result);
+          default:
+            throw 'Expected Outro property $name to be specify a valid "type", but it was "${outroType}".';
+        }
+        return null;
+      default:
+        throw 'Expected property $name to be an object, but it was ${json.value}.';
+    }
+  }
+
   /**
    * Parser which outputs a `Either<Float, LegacyScrollSpeeds>`.
    * Used by the FNF legacy JSON importer.
@@ -178,7 +243,31 @@ class DataParse
     switch (json.value)
     {
       case JObject(fields):
-        return cast Tools.getValue(json);
+        var result:LegacyNoteSection =
+          {
+            mustHitSection: false,
+            sectionNotes: [],
+          };
+        for (field in fields)
+        {
+          switch (field.name)
+          {
+            case 'sectionNotes':
+              result.sectionNotes = legacyNotes(field.value, field.name);
+
+            case 'mustHitSection':
+              result.mustHitSection = Tools.getValue(field.value);
+            case 'typeOfSection':
+              result.typeOfSection = Tools.getValue(field.value);
+            case 'lengthInSteps':
+              result.lengthInSteps = Tools.getValue(field.value);
+            case 'changeBPM':
+              result.changeBPM = Tools.getValue(field.value);
+            case 'bpm':
+              result.bpm = Tools.getValue(field.value);
+          }
+        }
+        return result;
       default:
         throw 'Expected property $name to be an object, but it was ${json.value}.';
     }
@@ -189,7 +278,12 @@ class DataParse
     switch (json.value)
     {
       case JObject(fields):
-        return cast Tools.getValue(json);
+        var result = {};
+        for (field in fields)
+        {
+          Reflect.setField(result, field.name, legacyNoteSectionArray(field.value, field.name));
+        }
+        return result;
       default:
         throw 'Expected property $name to be an object, but it was ${json.value}.';
     }
@@ -211,13 +305,13 @@ class DataParse
     switch (json.value)
     {
       case JArray(values):
-        // var time:Null<Float> = values[0] == null ? null : Tools.getValue(values[0]);
-        // var data:Null<Int> = values[1] == null ? null : Tools.getValue(values[1]);
-        // var length:Null<Float> = values[2] == null ? null : Tools.getValue(values[2]);
-        // var alt:Null<Bool> = values[3] == null ? null : Tools.getValue(values[3]);
+        var time:Null<Float> = values[0] == null ? null : Tools.getValue(values[0]);
+        var data:Null<Int> = values[1] == null ? null : Tools.getValue(values[1]);
+        var length:Null<Float> = values[2] == null ? null : Tools.getValue(values[2]);
+        var alt:Null<Bool> = values[3] == null ? null : Tools.getValue(values[3]);
 
-        // return new LegacyNote(time, data, length, alt);
-        return null;
+        return new LegacyNote(time, data, length, alt);
+      // return null;
       default:
         throw 'Expected property $name to be a note, but it was ${json.value}.';
     }
diff --git a/source/funkin/data/character/TODO.md b/source/funkin/data/character/TODO.md
new file mode 100644
index 000000000..e69de29bb
diff --git a/source/funkin/data/conversation/TODO.md b/source/funkin/data/conversation/TODO.md
new file mode 100644
index 000000000..e69de29bb
diff --git a/source/funkin/data/dialogue/ConversationData.hx b/source/funkin/data/dialogue/ConversationData.hx
new file mode 100644
index 000000000..795ddae9a
--- /dev/null
+++ b/source/funkin/data/dialogue/ConversationData.hx
@@ -0,0 +1,168 @@
+package funkin.data.dialogue;
+
+import funkin.data.animation.AnimationData;
+
+/**
+ * A type definition for the data for a specific conversation.
+ * It includes things like what dialogue boxes to use, what text to display, and what animations to play.
+ * @see https://lib.haxe.org/p/json2object/
+ */
+typedef ConversationData =
+{
+  /**
+   * Semantic version for conversation data.
+   */
+  public var version:String;
+
+  /**
+   * Data on the backdrop for the conversation.
+   */
+  @:jcustomparse(funkin.data.DataParse.backdropData)
+  public var backdrop:BackdropData;
+
+  /**
+   * Data on the outro for the conversation.
+   */
+  @:jcustomparse(funkin.data.DataParse.outroData)
+  @:optional
+  public var outro:Null<OutroData>;
+
+  /**
+   * Data on the music for the conversation.
+   */
+  @:optional
+  public var music:Null<MusicData>;
+
+  /**
+   * Data for each line of dialogue in the conversation.
+   */
+  public var dialogue:Array<DialogueEntryData>;
+}
+
+/**
+ * Data on the backdrop for the conversation, behind the dialogue box.
+ * A custom parser distinguishes between backdrop types based on the `type` field.
+ */
+enum BackdropData
+{
+  SOLID(data:BackdropData_Solid); // 'solid'
+}
+
+/**
+ * Data for a Solid color backdrop.
+ */
+typedef BackdropData_Solid =
+{
+  /**
+   * Used to distinguish between backdrop types. Should always be `solid` for this type.
+   */
+  var type:String;
+
+  /**
+   * The color of the backdrop.
+   */
+  var color:String;
+
+  /**
+   * Fade-in time for the backdrop.
+   * @default No fade-in
+   */
+  @:optional
+  @:default(0.0)
+  var fadeTime:Float;
+};
+
+enum OutroData
+{
+  NONE(data:OutroData_None); // 'none'
+  FADE(data:OutroData_Fade); // 'fade'
+}
+
+typedef OutroData_None =
+{
+  /**
+   * Used to distinguish between outro types. Should always be `none` for this type.
+   */
+  var type:String;
+}
+
+typedef OutroData_Fade =
+{
+  /**
+   * Used to distinguish between outro types. Should always be `fade` for this type.
+   */
+  var type:String;
+
+  /**
+   * The time to fade out the conversation.
+   * @default 1 second
+   */
+  @:optional
+  @:default(1.0)
+  var fadeTime:Float;
+}
+
+typedef MusicData =
+{
+  /**
+   * The asset to play for the music.
+   */
+  var asset:String;
+
+  /**
+   * The time to fade in the music.
+   */
+  @:optional
+  @:default(0.0)
+  var fadeTime:Float;
+
+  @:optional
+  @:default(false)
+  var looped:Bool;
+};
+
+/**
+ * Data on a single line of dialogue in a conversation.
+ */
+typedef DialogueEntryData =
+{
+  /**
+   * Which speaker is speaking.
+   * @see `SpeakerData.hx`
+   */
+  public var speaker:String;
+
+  /**
+   * The animation the speaker should play for this line of dialogue.
+   */
+  public var speakerAnimation:String;
+
+  /**
+   * Which dialogue box to use for this line of dialogue.
+   * @see `DialogueBoxData.hx`
+   */
+  public var box:String;
+
+  /**
+   * Which animation to play for the dialogue box.
+   */
+  public var boxAnimation:String;
+
+  /**
+   * The text that will display for this line of dialogue.
+   * Text will automatically wrap.
+   * When the user advances the dialogue, the next entry in the array will concatenate on.
+   * Advancing when the last entry is displayed will move to the next `DialogueEntryData`,
+   * or end the conversation if there are no more.
+   */
+  public var text:Array<String>;
+
+  /**
+   * The relative speed at which text gets "typed out".
+   * Setting `speed` to `1.5` would make it look like the character is speaking quickly,
+   * and setting `speed` to `0.5` would make it look like the character is emphasizing each word.
+   */
+  @:optional
+  @:default(1.0)
+  public var speed:Float;
+};
diff --git a/source/funkin/data/dialogue/ConversationRegistry.hx b/source/funkin/data/dialogue/ConversationRegistry.hx
new file mode 100644
index 000000000..9186ef786
--- /dev/null
+++ b/source/funkin/data/dialogue/ConversationRegistry.hx
@@ -0,0 +1,81 @@
+package funkin.data.dialogue;
+
+import funkin.play.cutscene.dialogue.Conversation;
+import funkin.data.dialogue.ConversationData;
+import funkin.play.cutscene.dialogue.ScriptedConversation;
+
+class ConversationRegistry extends BaseRegistry<Conversation, ConversationData>
+{
+  /**
+   * The current version string for the dialogue box data format.
+   * Handle breaking changes by incrementing this value
+   * and adding migration to the `migrateConversationData()` function.
+   */
+  public static final CONVERSATION_DATA_VERSION:thx.semver.Version = "1.0.0";
+
+  public static final CONVERSATION_DATA_VERSION_RULE:thx.semver.VersionRule = "1.0.x";
+
+  public static final instance:ConversationRegistry = new ConversationRegistry();
+
+  public function new()
+  {
+    super('CONVERSATION', 'dialogue/conversations', CONVERSATION_DATA_VERSION_RULE);
+  }
+
+  /**
+   * Read, parse, and validate the JSON data and produce the corresponding data object.
+   */
+  public function parseEntryData(id:String):Null<ConversationData>
+  {
+    // JsonParser does not take type parameters,
+    // otherwise this function would be in BaseRegistry.
+    var parser = new json2object.JsonParser<ConversationData>();
+    parser.ignoreUnknownVariables = false;
+
+    switch (loadEntryFile(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;
+  }
+
+  /**
+   * Parse and validate the JSON data and produce the corresponding data object.
+   *
+   * NOTE: Must be implemented on the implementation class.
+   * @param contents The JSON as a string.
+   * @param fileName An optional file name for error reporting.
+   */
+  public function parseEntryDataRaw(contents:String, ?fileName:String):Null<ConversationData>
+  {
+    var parser = new json2object.JsonParser<ConversationData>();
+    parser.ignoreUnknownVariables = false;
+    parser.fromJson(contents, fileName);
+
+    if (parser.errors.length > 0)
+    {
+      printErrors(parser.errors, fileName);
+      return null;
+    }
+    return parser.value;
+  }
+
+  function createScriptedEntry(clsName:String):Conversation
+  {
+    return ScriptedConversation.init(clsName, "unknown");
+  }
+
+  function getScriptedClassNames():Array<String>
+  {
+    return ScriptedConversation.listScriptClasses();
+  }
+}
diff --git a/source/funkin/data/dialogue/DialogueBoxData.hx b/source/funkin/data/dialogue/DialogueBoxData.hx
new file mode 100644
index 000000000..a75a5595a
--- /dev/null
+++ b/source/funkin/data/dialogue/DialogueBoxData.hx
@@ -0,0 +1,128 @@
+package funkin.data.dialogue;
+
+import funkin.data.animation.AnimationData;
+
+/**
+ * A type definition for the data for a conversation text box.
+ * It includes things like the sprite to use, and the font and color for the text.
+ * The actual text is included in the ConversationData.
+ * @see https://lib.haxe.org/p/json2object/
+ */
+typedef DialogueBoxData =
+{
+  /**
+   * Semantic version for dialogue box data.
+   */
+  public var version:String;
+
+  /**
+   * A human readable name for the dialogue box type.
+   */
+  public var name:String;
+
+  /**
+   * The asset path for the sprite to use for the dialogue box.
+   * Takes a static sprite or a sprite sheet.
+   */
+  public var assetPath:String;
+
+  /**
+   * Whether to horizontally flip the dialogue box sprite.
+   */
+  @:optional
+  @:default(false)
+  public var flipX:Bool;
+
+  /**
+   * Whether to vertically flip the dialogue box sprite.
+   */
+  @:optional
+  @:default(false)
+  public var flipY:Bool;
+
+  /**
+   * Whether to disable anti-aliasing for the dialogue box sprite.
+   */
+  @:optional
+  @:default(false)
+  public var isPixel:Bool;
+
+  /**
+   * The relative horizontal and vertical offsets for the dialogue box sprite.
+   */
+  @:optional
+  @:default([0, 0])
+  public var offsets:Array<Float>;
+
+  /**
+   * Info about how to display text in the dialogue box.
+   */
+  public var text:DialogueBoxTextData;
+
+  /**
+   * Multiply the size of the dialogue box sprite.
+   */
+  @:optional
+  @:default(1)
+  public var scale:Float;
+
+  /**
+   * If using a spritesheet for the dialogue box, the animations to use.
+   */
+  @:optional
+  @:default([])
+  public var animations:Array<AnimationData>;
+}
+
+typedef DialogueBoxTextData =
+{
+  /**
+   * The position of the text in teh box.
+   */
+  @:optional
+  @:default([0, 0])
+  var offsets:Array<Float>;
+
+  /**
+   * The width of the
+   */
+  @:optional
+  @:default(300)
+  var width:Int;
+
+  /**
+   * The font size to use for the text.
+   */
+  @:optional
+  @:default(32)
+  var size:Int;
+
+  /**
+   * The color to use for the text.
+   * Use a string that can be translated to a color, like `#FF0000` for red.
+   */
+  @:optional
+  @:default("#000000")
+  var color:String;
+
+  /**
+   * The font to use for the text.
+   * @since v1.1.0
+   * @default `Arial`, make sure to switch this!
+   */
+  @:optional
+  @:default("Arial")
+  var fontFamily:String;
+
+  /**
+   * The color to use for the shadow of the text. Use transparent to disable.
+   */
+  var shadowColor:String;
+
+  /**
+   * The width of the shadow of the text.
+   */
+  @:optional
+  @:default(0)
+  var shadowWidth:Int;
+};
diff --git a/source/funkin/data/dialogue/DialogueBoxRegistry.hx b/source/funkin/data/dialogue/DialogueBoxRegistry.hx
new file mode 100644
index 000000000..87205d96c
--- /dev/null
+++ b/source/funkin/data/dialogue/DialogueBoxRegistry.hx
@@ -0,0 +1,81 @@
+package funkin.data.dialogue;
+
+import funkin.play.cutscene.dialogue.DialogueBox;
+import funkin.data.dialogue.DialogueBoxData;
+import funkin.play.cutscene.dialogue.ScriptedDialogueBox;
+
+class DialogueBoxRegistry extends BaseRegistry<DialogueBox, DialogueBoxData>
+{
+  /**
+   * The current version string for the dialogue box data format.
+   * Handle breaking changes by incrementing this value
+   * and adding migration to the `migrateDialogueBoxData()` function.
+   */
+  public static final DIALOGUEBOX_DATA_VERSION:thx.semver.Version = "1.1.0";
+
+  public static final DIALOGUEBOX_DATA_VERSION_RULE:thx.semver.VersionRule = "1.1.x";
+
+  public static final instance:DialogueBoxRegistry = new DialogueBoxRegistry();
+
+  public function new()
+  {
+    super('DIALOGUEBOX', 'dialogue/boxes', DIALOGUEBOX_DATA_VERSION_RULE);
+  }
+
+  /**
+   * Read, parse, and validate the JSON data and produce the corresponding data object.
+   */
+  public function parseEntryData(id:String):Null<DialogueBoxData>
+  {
+    // JsonParser does not take type parameters,
+    // otherwise this function would be in BaseRegistry.
+    var parser = new json2object.JsonParser<DialogueBoxData>();
+    parser.ignoreUnknownVariables = false;
+
+    switch (loadEntryFile(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;
+  }
+
+  /**
+   * Parse and validate the JSON data and produce the corresponding data object.
+   *
+   * NOTE: Must be implemented on the implementation class.
+   * @param contents The JSON as a string.
+   * @param fileName An optional file name for error reporting.
+   */
+  public function parseEntryDataRaw(contents:String, ?fileName:String):Null<DialogueBoxData>
+  {
+    var parser = new json2object.JsonParser<DialogueBoxData>();
+    parser.ignoreUnknownVariables = false;
+    parser.fromJson(contents, fileName);
+
+    if (parser.errors.length > 0)
+    {
+      printErrors(parser.errors, fileName);
+      return null;
+    }
+    return parser.value;
+  }
+
+  function createScriptedEntry(clsName:String):DialogueBox
+  {
+    return ScriptedDialogueBox.init(clsName, "unknown");
+  }
+
+  function getScriptedClassNames():Array<String>
+  {
+    return ScriptedDialogueBox.listScriptClasses();
+  }
+}
diff --git a/source/funkin/data/dialogue/SpeakerData.hx b/source/funkin/data/dialogue/SpeakerData.hx
new file mode 100644
index 000000000..e8a2eacf0
--- /dev/null
+++ b/source/funkin/data/dialogue/SpeakerData.hx
@@ -0,0 +1,68 @@
+package funkin.data.dialogue;
+
+import funkin.data.animation.AnimationData;
+
+/**
+ * A type definition for a specific speaker in a conversation.
+ * It includes things like what sprite to use and its available animations.
+ * @see https://lib.haxe.org/p/json2object/
+ */
+typedef SpeakerData =
+{
+  /**
+   * Semantic version of the speaker data.
+   */
+  public var version:String;
+
+  /**
+   * A human-readable name for the speaker.
+   */
+  public var name:String;
+
+  /**
+   * The path to the asset to use for the speaker's sprite.
+   */
+  public var assetPath:String;
+
+  /**
+   * Whether the sprite should be flipped horizontally.
+   */
+  @:optional
+  @:default(false)
+  public var flipX:Bool;
+
+  /**
+   * Whether the sprite should be flipped vertically.
+   */
+  @:optional
+  @:default(false)
+  public var flipY:Bool;
+
+  /**
+   * Whether to disable anti-aliasing for the dialogue box sprite.
+   */
+  @:optional
+  @:default(false)
+  public var isPixel:Bool;
+
+  /**
+   * The offsets to apply to the sprite's position.
+   */
+  @:optional
+  @:default([0, 0])
+  public var offsets:Array<Float>;
+
+  /**
+   * The scale to apply to the sprite.
+   */
+  @:optional
+  @:default(1.0)
+  public var scale:Float;
+
+  /**
+   * The available animations for the speaker.
+   */
+  @:optional
+  @:default([])
+  public var animations:Array<AnimationData>;
+}
diff --git a/source/funkin/data/dialogue/SpeakerRegistry.hx b/source/funkin/data/dialogue/SpeakerRegistry.hx
new file mode 100644
index 000000000..6bd301dd7
--- /dev/null
+++ b/source/funkin/data/dialogue/SpeakerRegistry.hx
@@ -0,0 +1,81 @@
+package funkin.data.dialogue;
+
+import funkin.play.cutscene.dialogue.Speaker;
+import funkin.data.dialogue.SpeakerData;
+import funkin.play.cutscene.dialogue.ScriptedSpeaker;
+
+class SpeakerRegistry extends BaseRegistry<Speaker, SpeakerData>
+{
+  /**
+   * The current version string for the speaker data format.
+   * Handle breaking changes by incrementing this value
+   * and adding migration to the `migrateSpeakerData()` function.
+   */
+  public static final SPEAKER_DATA_VERSION:thx.semver.Version = "1.0.0";
+
+  public static final SPEAKER_DATA_VERSION_RULE:thx.semver.VersionRule = "1.0.x";
+
+  public static final instance:SpeakerRegistry = new SpeakerRegistry();
+
+  public function new()
+  {
+    super('SPEAKER', 'dialogue/speakers', SPEAKER_DATA_VERSION_RULE);
+  }
+
+  /**
+   * Read, parse, and validate the JSON data and produce the corresponding data object.
+   */
+  public function parseEntryData(id:String):Null<SpeakerData>
+  {
+    // JsonParser does not take type parameters,
+    // otherwise this function would be in BaseRegistry.
+    var parser = new json2object.JsonParser<SpeakerData>();
+    parser.ignoreUnknownVariables = false;
+
+    switch (loadEntryFile(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;
+  }
+
+  /**
+   * Parse and validate the JSON data and produce the corresponding data object.
+   *
+   * NOTE: Must be implemented on the implementation class.
+   * @param contents The JSON as a string.
+   * @param fileName An optional file name for error reporting.
+   */
+  public function parseEntryDataRaw(contents:String, ?fileName:String):Null<SpeakerData>
+  {
+    var parser = new json2object.JsonParser<SpeakerData>();
+    parser.ignoreUnknownVariables = false;
+    parser.fromJson(contents, fileName);
+
+    if (parser.errors.length > 0)
+    {
+      printErrors(parser.errors, fileName);
+      return null;
+    }
+    return parser.value;
+  }
+
+  function createScriptedEntry(clsName:String):Speaker
+  {
+    return ScriptedSpeaker.init(clsName, "unknown");
+  }
+
+  function getScriptedClassNames():Array<String>
+  {
+    return ScriptedSpeaker.listScriptClasses();
+  }
+}
diff --git a/source/funkin/data/dialogue/TODO.md b/source/funkin/data/dialogue/TODO.md
new file mode 100644
index 000000000..e69de29bb
diff --git a/source/funkin/data/event/SongEventData.hx b/source/funkin/data/event/SongEventRegistry.hx
similarity index 71%
rename from source/funkin/data/event/SongEventData.hx
rename to source/funkin/data/event/SongEventRegistry.hx
index 7a167b031..dc5589813 100644
--- a/source/funkin/data/event/SongEventData.hx
+++ b/source/funkin/data/event/SongEventRegistry.hx
@@ -1,7 +1,7 @@
 package funkin.data.event;
 
 import funkin.play.event.SongEvent;
-import funkin.data.event.SongEventData.SongEventSchema;
+import funkin.data.event.SongEventSchema;
 import funkin.data.song.SongData.SongEventData;
 import funkin.util.macro.ClassMacro;
 import funkin.play.event.ScriptedSongEvent;
@@ -9,7 +9,7 @@ import funkin.play.event.ScriptedSongEvent;
 /**
  * This class statically handles the parsing of internal and scripted song event handlers.
  */
-class SongEventParser
+class SongEventRegistry
 {
   /**
    * Every built-in event class must be added to this list.
@@ -160,84 +160,3 @@ class SongEventParser
     }
   }
 }
-
-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 only 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.
-   * @default No minimum
-   */
-  ?min:Float,
-
-  /**
-   * Used for INTEGER and FLOAT values.
-   * The maximum value that can be entered.
-   * @default No maximum
-   */
-  ?max:Float,
-
-  /**
-   * Used for INTEGER and FLOAT values.
-   * The step value that will be used when incrementing/decrementing the value.
-   * @default `0.1`
-   */
-  ?step:Float,
-
-  /**
-   * An optional default value for the field.
-   */
-  ?defaultValue:Dynamic,
-}
-
-typedef SongEventSchema = Array<SongEventSchemaField>;
diff --git a/source/funkin/data/event/SongEventSchema.hx b/source/funkin/data/event/SongEventSchema.hx
new file mode 100644
index 000000000..9591e601e
--- /dev/null
+++ b/source/funkin/data/event/SongEventSchema.hx
@@ -0,0 +1,177 @@
+package funkin.data.event;
+
+import funkin.play.event.SongEvent;
+import funkin.data.event.SongEventSchema;
+import funkin.data.song.SongData.SongEventData;
+import funkin.util.macro.ClassMacro;
+import funkin.play.event.ScriptedSongEvent;
+
+@:forward(name, title, type, keys, min, max, step, units, defaultValue, iterator)
+abstract SongEventSchema(SongEventSchemaRaw)
+{
+  /**
+   * These units look better when placed immediately next to the value, rather than after a space.
+   */
+  static final NO_SPACE_UNITS:Array<String> = ['x', '°', '%'];
+
+  public function new(?fields:Array<SongEventSchemaField>)
+  {
+    this = fields;
+  }
+
+  @:arrayAccess
+  public function getByName(name:String):SongEventSchemaField
+  {
+    for (field in this)
+    {
+      if (field.name == name) return field;
+    }
+
+    return null;
+  }
+
+  public function getFirstField():SongEventSchemaField
+  {
+    return this[0];
+  }
+
+  @:arrayAccess
+  public inline function get(key:Int)
+  {
+    return this[key];
+  }
+
+  @:arrayAccess
+  public inline function arrayWrite(k:Int, v:SongEventSchemaField):SongEventSchemaField
+  {
+    return this[k] = v;
+  }
+
+  public function stringifyFieldValue(name:String, value:Dynamic, addUnits:Bool = true):String
+  {
+    var field:SongEventSchemaField = getByName(name);
+    if (field == null) return 'Unknown';
+
+    switch (field.type)
+    {
+      case SongEventFieldType.STRING:
+        return Std.string(value);
+      case SongEventFieldType.INTEGER:
+        var returnValue:String = Std.string(value);
+        if (addUnits) return addUnitsToString(returnValue, field);
+        return returnValue;
+      case SongEventFieldType.FLOAT:
+        var returnValue:String = Std.string(value);
+        if (addUnits) return addUnitsToString(returnValue, field);
+        return returnValue;
+      case SongEventFieldType.BOOL:
+        return Std.string(value);
+      case SongEventFieldType.ENUM:
+        var valueString:String = Std.string(value);
+        for (key in field.keys.keys())
+        {
+          // Comparing these values as strings because comparing Dynamic variables is jank.
+          if (Std.string(field.keys.get(key)) == valueString) return key;
+        }
+        return valueString;
+      default:
+        return 'Unknown';
+    }
+  }
+
+  function addUnitsToString(value:String, field:SongEventSchemaField)
+  {
+    if (field.units == null || field.units == '') return value;
+
+    var unit:String = field.units;
+
+    return value + (NO_SPACE_UNITS.contains(unit) ? '' : ' ') + '${unit}';
+  }
+}
+
+typedef SongEventSchemaRaw = Array<SongEventSchemaField>;
+
+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 only 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.
+   * @default No minimum
+   */
+  ?min:Float,
+
+  /**
+   * Used for INTEGER and FLOAT values.
+   * The maximum value that can be entered.
+   * @default No maximum
+   */
+  ?max:Float,
+
+  /**
+   * Used for INTEGER and FLOAT values.
+   * The step value that will be used when incrementing/decrementing the value.
+   * @default `0.1`
+   */
+  ?step:Float,
+
+  /**
+   * Used for INTEGER and FLOAT values.
+   * The units that the value is expressed in (pixels, percent, etc).
+   */
+  ?units:String,
+
+  /**
+   * An optional default value for the field.
+   */
+  ?defaultValue:Dynamic,
+}
+
+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";
+}
diff --git a/source/funkin/data/level/LevelRegistry.hx b/source/funkin/data/level/LevelRegistry.hx
index 75b0b11f6..96712cba5 100644
--- a/source/funkin/data/level/LevelRegistry.hx
+++ b/source/funkin/data/level/LevelRegistry.hx
@@ -7,9 +7,9 @@ import funkin.ui.story.ScriptedLevel;
 class LevelRegistry extends BaseRegistry<Level, LevelData>
 {
   /**
-   * The current version string for the stage data format.
+   * The current version string for the level data format.
    * Handle breaking changes by incrementing this value
-   * and adding migration to the `migrateStageData()` function.
+   * and adding migration to the `migrateLevelData()` function.
    */
   public static final LEVEL_DATA_VERSION:thx.semver.Version = "1.0.0";
 
@@ -30,6 +30,7 @@ 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>();
+    parser.ignoreUnknownVariables = false;
 
     switch (loadEntryFile(id))
     {
@@ -57,6 +58,7 @@ class LevelRegistry extends BaseRegistry<Level, LevelData>
   public function parseEntryDataRaw(contents:String, ?fileName:String):Null<LevelData>
   {
     var parser = new json2object.JsonParser<LevelData>();
+    parser.ignoreUnknownVariables = false;
     parser.fromJson(contents, fileName);
 
     if (parser.errors.length > 0)
diff --git a/source/funkin/data/notestyle/NoteStyleRegistry.hx b/source/funkin/data/notestyle/NoteStyleRegistry.hx
index 4255a644b..ffb9bf490 100644
--- a/source/funkin/data/notestyle/NoteStyleRegistry.hx
+++ b/source/funkin/data/notestyle/NoteStyleRegistry.hx
@@ -35,6 +35,7 @@ class NoteStyleRegistry extends BaseRegistry<NoteStyle, NoteStyleData>
     // JsonParser does not take type parameters,
     // otherwise this function would be in BaseRegistry.
     var parser = new json2object.JsonParser<NoteStyleData>();
+    parser.ignoreUnknownVariables = false;
 
     switch (loadEntryFile(id))
     {
@@ -62,6 +63,7 @@ class NoteStyleRegistry extends BaseRegistry<NoteStyle, NoteStyleData>
   public function parseEntryDataRaw(contents:String, ?fileName:String):Null<NoteStyleData>
   {
     var parser = new json2object.JsonParser<NoteStyleData>();
+    parser.ignoreUnknownVariables = false;
     parser.fromJson(contents, fileName);
 
     if (parser.errors.length > 0)
diff --git a/source/funkin/data/song/SongData.hx b/source/funkin/data/song/SongData.hx
index 7886ada4f..7d5bc4e19 100644
--- a/source/funkin/data/song/SongData.hx
+++ b/source/funkin/data/song/SongData.hx
@@ -1,7 +1,11 @@
 package funkin.data.song;
 
+import funkin.data.event.SongEventRegistry;
+import funkin.play.event.SongEvent;
+import funkin.data.event.SongEventSchema;
 import funkin.data.song.SongRegistry;
 import thx.semver.Version;
+import funkin.util.tools.ICloneable;
 
 /**
  * Data containing information about a song.
@@ -9,7 +13,7 @@ import thx.semver.Version;
  * Data which is only necessary in-game should be stored in the SongChartData.
  */
 @:nullSafety
-class SongMetadata
+class SongMetadata implements ICloneable<SongMetadata>
 {
   /**
    * A semantic versioning string for the song data format.
@@ -35,10 +39,11 @@ class SongMetadata
   public var looped:Bool;
 
   /**
-   * Instrumental and vocal offsets. Optional, defaults to 0.
+   * Instrumental and vocal offsets.
+   * Defaults to an empty SongOffsets object.
    */
   @:optional
-  public var offsets:SongOffsets;
+  public var offsets:Null<SongOffsets>;
 
   /**
    * Data relating to the song's gameplay.
@@ -84,16 +89,16 @@ class SongMetadata
    * @param newVariation Set to a new variation ID to change the new metadata.
    * @return The cloned SongMetadata
    */
-  public function clone(?newVariation:String = null):SongMetadata
+  public function clone():SongMetadata
   {
-    var result:SongMetadata = new SongMetadata(this.songName, this.artist, newVariation == null ? this.variation : newVariation);
+    var result:SongMetadata = new SongMetadata(this.songName, this.artist, this.variation);
     result.version = this.version;
     result.timeFormat = this.timeFormat;
     result.divisions = this.divisions;
-    result.offsets = this.offsets;
-    result.timeChanges = this.timeChanges;
+    result.offsets = this.offsets != null ? this.offsets.clone() : new SongOffsets(); // if no song offsets found (aka null), so just create new ones
+    result.timeChanges = this.timeChanges.deepClone();
     result.looped = this.looped;
-    result.playData = this.playData;
+    result.playData = this.playData.clone();
     result.generatedBy = this.generatedBy;
 
     return result;
@@ -128,7 +133,7 @@ enum abstract SongTimeFormat(String) from String to String
   var MILLISECONDS = 'ms';
 }
 
-class SongTimeChange
+class SongTimeChange implements ICloneable<SongTimeChange>
 {
   public static final DEFAULT_SONGTIMECHANGE:SongTimeChange = new SongTimeChange(0, 100);
 
@@ -149,7 +154,7 @@ class SongTimeChange
    */
   @:optional
   @:alias("b")
-  public var beatTime:Null<Float>;
+  public var beatTime:Float;
 
   /**
    * Quarter notes per minute (float). Cannot be empty in the first element of the list,
@@ -195,6 +200,11 @@ class SongTimeChange
     this.beatTuplets = beatTuplets == null ? DEFAULT_BEAT_TUPLETS : beatTuplets;
   }
 
+  public function clone():SongTimeChange
+  {
+    return new SongTimeChange(this.timeStamp, this.bpm, this.timeSignatureNum, this.timeSignatureDen, this.beatTime, this.beatTuplets);
+  }
+
   /**
    * Produces a string representation suitable for debugging.
    */
@@ -209,7 +219,7 @@ class SongTimeChange
  * These are intended to correct for issues with the chart, or with the song's audio (for example a 10ms delay before the song starts).
  * This is independent of the offsets applied in the user's settings, which are applied after these offsets and intended to correct for the user's hardware.
  */
-class SongOffsets
+class SongOffsets implements ICloneable<SongOffsets>
 {
   /**
    * The offset, in milliseconds, to apply to the song's instrumental relative to the chart.
@@ -279,6 +289,15 @@ class SongOffsets
     return value;
   }
 
+  public function clone():SongOffsets
+  {
+    var result:SongOffsets = new SongOffsets(this.instrumental);
+    result.altInstrumentals = this.altInstrumentals.clone();
+    result.vocals = this.vocals.clone();
+
+    return result;
+  }
+
   /**
    * Produces a string representation suitable for debugging.
    */
@@ -292,7 +311,7 @@ class SongOffsets
  * Metadata for a song only used for the music.
  * For example, the menu music.
  */
-class SongMusicData
+class SongMusicData implements ICloneable<SongMusicData>
 {
   /**
    * A semantic versioning string for the song data format.
@@ -346,13 +365,13 @@ class SongMusicData
     this.variation = variation == null ? Constants.DEFAULT_VARIATION : variation;
   }
 
-  public function clone(?newVariation:String = null):SongMusicData
+  public function clone():SongMusicData
   {
-    var result:SongMusicData = new SongMusicData(this.songName, this.artist, newVariation == null ? this.variation : newVariation);
+    var result:SongMusicData = new SongMusicData(this.songName, this.artist, this.variation);
     result.version = this.version;
     result.timeFormat = this.timeFormat;
     result.divisions = this.divisions;
-    result.timeChanges = this.timeChanges;
+    result.timeChanges = this.timeChanges.clone();
     result.looped = this.looped;
     result.generatedBy = this.generatedBy;
 
@@ -368,7 +387,7 @@ class SongMusicData
   }
 }
 
-class SongPlayData
+class SongPlayData implements ICloneable<SongPlayData>
 {
   /**
    * The variations this song has. The associated metadata files should exist.
@@ -412,11 +431,46 @@ class SongPlayData
   @:optional
   public var album:Null<String>;
 
+  /**
+   * The start time for the audio preview in Freeplay.
+   * Defaults to 0 seconds in.
+   * @since `2.2.2`
+   */
+  @:optional
+  @:default(0)
+  public var previewStart:Int;
+
+  /**
+   * The end time for the audio preview in Freeplay.
+   * Defaults to 15 seconds in.
+   * @since `2.2.2`
+   */
+  @:optional
+  @:default(15000)
+  public var previewEnd:Int;
+
   public function new()
   {
     ratings = new Map<String, Int>();
   }
 
+  public function clone():SongPlayData
+  {
+    // TODO: This sucks! If you forget to update this you get weird behavior.
+    var result:SongPlayData = new SongPlayData();
+    result.songVariations = this.songVariations.clone();
+    result.difficulties = this.difficulties.clone();
+    result.characters = this.characters.clone();
+    result.stage = this.stage;
+    result.noteStyle = this.noteStyle;
+    result.ratings = this.ratings.clone();
+    result.album = this.album;
+    result.previewStart = this.previewStart;
+    result.previewEnd = this.previewEnd;
+
+    return result;
+  }
+
   /**
    * Produces a string representation suitable for debugging.
    */
@@ -430,7 +484,7 @@ class SongPlayData
  * Information about the characters used in this variation of the song.
  * Create a new variation if you want to change the characters.
  */
-class SongCharacterData
+class SongCharacterData implements ICloneable<SongCharacterData>
 {
   @:optional
   @:default('')
@@ -460,6 +514,14 @@ class SongCharacterData
     this.instrumental = instrumental;
   }
 
+  public function clone():SongCharacterData
+  {
+    var result:SongCharacterData = new SongCharacterData(this.player, this.girlfriend, this.opponent, this.instrumental);
+    result.altInstrumentals = this.altInstrumentals.clone();
+
+    return result;
+  }
+
   /**
    * Produces a string representation suitable for debugging.
    */
@@ -469,7 +531,7 @@ class SongCharacterData
   }
 }
 
-class SongChartData
+class SongChartData implements ICloneable<SongChartData>
 {
   @:default(funkin.data.song.SongRegistry.SONG_CHART_DATA_VERSION)
   @:jcustomparse(funkin.data.DataParse.semverVersion)
@@ -539,6 +601,24 @@ class SongChartData
     return writer.write(this, pretty ? '  ' : null);
   }
 
+  public function clone():SongChartData
+  {
+    // We have to manually perform the deep clone here because Map.deepClone() doesn't work.
+    var noteDataClone:Map<String, Array<SongNoteData>> = new Map<String, Array<SongNoteData>>();
+    for (key in this.notes.keys())
+    {
+      noteDataClone.set(key, this.notes.get(key).deepClone());
+    }
+    var eventDataClone:Array<SongEventData> = this.events.deepClone();
+
+    var result:SongChartData = new SongChartData(this.scrollSpeed.clone(), eventDataClone, noteDataClone);
+    result.version = this.version;
+    result.generatedBy = this.generatedBy;
+    result.variation = this.variation;
+
+    return result;
+  }
+
   /**
    * Produces a string representation suitable for debugging.
    */
@@ -548,7 +628,7 @@ class SongChartData
   }
 }
 
-class SongEventDataRaw
+class SongEventDataRaw implements ICloneable<SongEventDataRaw>
 {
   /**
    * The timestamp of the event. The timestamp is in the format of the song's time format.
@@ -602,14 +682,19 @@ class SongEventDataRaw
   {
     if (_stepTime != null && !force) return _stepTime;
 
-    return _stepTime = Conductor.getTimeInSteps(this.time);
+    return _stepTime = Conductor.instance.getTimeInSteps(this.time);
+  }
+
+  public function clone():SongEventDataRaw
+  {
+    return new SongEventDataRaw(this.time, this.event, this.value);
   }
 }
 
 /**
  * Wrap SongEventData in an abstract so we can overload operators.
  */
-@:forward(time, event, value, activated, getStepTime)
+@:forward(time, event, value, activated, getStepTime, clone)
 abstract SongEventData(SongEventDataRaw) from SongEventDataRaw to SongEventDataRaw
 {
   public function new(time:Float, event:String, value:Dynamic = null)
@@ -617,6 +702,38 @@ abstract SongEventData(SongEventDataRaw) from SongEventDataRaw to SongEventDataR
     this = new SongEventDataRaw(time, event, value);
   }
 
+  public inline function valueAsStruct(?defaultKey:String = "key"):Dynamic
+  {
+    if (this.value == null) return {};
+    if (Std.isOfType(this.value, Array))
+    {
+      var result:haxe.DynamicAccess<Dynamic> = {};
+      result.set(defaultKey, this.value);
+      return cast result;
+    }
+    else if (Reflect.isObject(this.value))
+    {
+      // We enter this case if the value is a struct.
+      return cast this.value;
+    }
+    else
+    {
+      var result:haxe.DynamicAccess<Dynamic> = {};
+      result.set(defaultKey, this.value);
+      return cast result;
+    }
+  }
+
+  public inline function getHandler():Null<SongEvent>
+  {
+    return SongEventRegistry.getEvent(this.event);
+  }
+
+  public inline function getSchema():Null<SongEventSchema>
+  {
+    return SongEventRegistry.getEventSchema(this.event);
+  }
+
   public inline function getDynamic(key:String):Null<Dynamic>
   {
     return this.value == null ? null : Reflect.field(this.value, key);
@@ -662,6 +779,34 @@ abstract SongEventData(SongEventDataRaw) from SongEventDataRaw to SongEventDataR
     return this.value == null ? null : cast Reflect.field(this.value, key);
   }
 
+  public function buildTooltip():String
+  {
+    var eventHandler = getHandler();
+    var eventSchema = getSchema();
+
+    if (eventSchema == null) return 'Unknown Event: ${this.event}';
+
+    var result = '${eventHandler.getTitle()}';
+
+    var defaultKey = eventSchema.getFirstField()?.name;
+    var valueStruct:haxe.DynamicAccess<Dynamic> = valueAsStruct(defaultKey);
+
+    for (pair in valueStruct.keyValueIterator())
+    {
+      var key = pair.key;
+      var value = pair.value;
+
+      var title = eventSchema.getByName(key)?.title ?? 'UnknownField';
+
+      // if (eventSchema.stringifyFieldValue(key, value) != null) trace(eventSchema.stringifyFieldValue(key, value));
+      var valueStr = eventSchema.stringifyFieldValue(key, value) ?? 'UnknownValue';
+
+      result += '\n- ${title}: ${valueStr}';
+    }
+
+    return result;
+  }
+
   public function clone():SongEventData
   {
     return new SongEventData(this.time, this.event, this.value);
@@ -712,7 +857,7 @@ abstract SongEventData(SongEventDataRaw) from SongEventDataRaw to SongEventDataR
   }
 }
 
-class SongNoteDataRaw
+class SongNoteDataRaw implements ICloneable<SongNoteDataRaw>
 {
   /**
    * The timestamp of the note. The timestamp is in the format of the song's time format.
@@ -742,12 +887,18 @@ class SongNoteDataRaw
   @:alias("l")
   @:default(0)
   @:optional
-  public var length:Float;
+  public var length(default, set):Float;
+
+  function set_length(value:Float):Float
+  {
+    _stepLength = null;
+    return length = value;
+  }
 
   /**
    * The kind of the note.
    * This can allow the note to include information used for custom behavior.
-   * Defaults to blank or `"normal"`.
+   * Defaults to blank or `Constants.DEFAULT_DIFFICULTY`.
    */
   @:alias("k")
   @:default("normal")
@@ -796,9 +947,14 @@ class SongNoteDataRaw
   {
     if (_stepTime != null && !force) return _stepTime;
 
-    return _stepTime = Conductor.getTimeInSteps(this.time);
+    return _stepTime = Conductor.instance.getTimeInSteps(this.time);
   }
 
+  /**
+   * The length of the note, if applicable, in steps.
+   * Calculated from the length and the BPM.
+   * Cached for performance. Set to `null` to recalculate.
+   */
   @:jignored
   var _stepLength:Null<Float> = null;
 
@@ -812,7 +968,7 @@ class SongNoteDataRaw
 
     if (_stepLength != null && !force) return _stepLength;
 
-    return _stepLength = Conductor.getTimeInSteps(this.time + this.length) - getStepTime();
+    return _stepLength = Conductor.instance.getTimeInSteps(this.time + this.length) - getStepTime();
   }
 
   public function setStepLength(value:Float):Void
@@ -823,11 +979,21 @@ class SongNoteDataRaw
     }
     else
     {
-      var lengthMs:Float = Conductor.getStepTimeInMs(value) - this.time;
+      var endStep:Float = getStepTime() + value;
+      var endMs:Float = Conductor.instance.getStepTimeInMs(endStep);
+      var lengthMs:Float = endMs - this.time;
+
       this.length = lengthMs;
     }
+
+    // Recalculate the step length next time it's requested.
     _stepLength = null;
   }
+
+  public function clone():SongNoteDataRaw
+  {
+    return new SongNoteDataRaw(this.time, this.data, this.length, this.kind);
+  }
 }
 
 /**
@@ -891,6 +1057,10 @@ abstract SongNoteData(SongNoteDataRaw) from SongNoteDataRaw to SongNoteDataRaw
   @:op(A == B)
   public function op_equals(other:SongNoteData):Bool
   {
+    // Handle the case where one value is null.
+    if (this == null) return other == null;
+    if (other == null) return false;
+
     if (this.kind == '')
     {
       if (other.kind != '' && other.kind != 'normal') return false;
@@ -906,6 +1076,10 @@ abstract SongNoteData(SongNoteDataRaw) from SongNoteDataRaw to SongNoteDataRaw
   @:op(A != B)
   public function op_notEquals(other:SongNoteData):Bool
   {
+    // Handle the case where one value is null.
+    if (this == null) return other == null;
+    if (other == null) return false;
+
     if (this.kind == '')
     {
       if (other.kind != '' && other.kind != 'normal') return true;
@@ -921,24 +1095,32 @@ abstract SongNoteData(SongNoteDataRaw) from SongNoteDataRaw to SongNoteDataRaw
   @:op(A > B)
   public function op_greaterThan(other:SongNoteData):Bool
   {
+    if (other == null) return false;
+
     return this.time > other.time;
   }
 
   @:op(A < B)
   public function op_lessThan(other:SongNoteData):Bool
   {
+    if (other == null) return false;
+
     return this.time < other.time;
   }
 
   @:op(A >= B)
   public function op_greaterThanOrEquals(other:SongNoteData):Bool
   {
+    if (other == null) return false;
+
     return this.time >= other.time;
   }
 
   @:op(A <= B)
   public function op_lessThanOrEquals(other:SongNoteData):Bool
   {
+    if (other == null) return false;
+
     return this.time <= other.time;
   }
 
diff --git a/source/funkin/data/song/SongDataUtils.hx b/source/funkin/data/song/SongDataUtils.hx
index 4ae4b1426..01ea2da32 100644
--- a/source/funkin/data/song/SongDataUtils.hx
+++ b/source/funkin/data/song/SongDataUtils.hx
@@ -153,8 +153,8 @@ class SongDataUtils
   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);
+    if (timeOffset == null) timeOffset = Std.int(notes[0].time);
+    return offsetSongNoteData(sortNotes(notes), -timeOffset);
   }
 
   /**
@@ -165,8 +165,8 @@ class SongDataUtils
   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);
+    if (timeOffset == null) timeOffset = Std.int(events[0].time);
+    return offsetSongEventData(sortEvents(events), -timeOffset);
   }
 
   /**
@@ -230,6 +230,7 @@ class SongDataUtils
     trace('Read ${notesString.length} characters from clipboard.');
 
     var parser = new json2object.JsonParser<SongClipboardItems>();
+    parser.ignoreUnknownVariables = false;
     parser.fromJson(notesString, 'clipboard');
     if (parser.errors.length > 0)
     {
@@ -272,7 +273,7 @@ class SongDataUtils
   }
 
   /**
-   * Filter a list of notes to only include notes whose data is within the given range.
+   * Filter a list of notes to only include notes whose data is within the given range, inclusive.
    */
   public static function getNotesInDataRange(notes:Array<SongNoteData>, start:Int, end:Int):Array<SongNoteData>
   {
diff --git a/source/funkin/data/song/SongRegistry.hx b/source/funkin/data/song/SongRegistry.hx
index 850654eb7..dad287e82 100644
--- a/source/funkin/data/song/SongRegistry.hx
+++ b/source/funkin/data/song/SongRegistry.hx
@@ -12,6 +12,7 @@ import funkin.util.VersionUtil;
 
 using funkin.data.song.migrator.SongDataMigrator;
 
+@:nullSafety
 class SongRegistry extends BaseRegistry<Song, SongMetadata>
 {
   /**
@@ -19,7 +20,7 @@ class SongRegistry extends BaseRegistry<Song, SongMetadata>
    * Handle breaking changes by incrementing this value
    * and adding migration to the `migrateStageData()` function.
    */
-  public static final SONG_METADATA_VERSION:thx.semver.Version = "2.2.1";
+  public static final SONG_METADATA_VERSION:thx.semver.Version = "2.2.2";
 
   public static final SONG_METADATA_VERSION_RULE:thx.semver.VersionRule = "2.2.x";
 
@@ -31,7 +32,7 @@ class SongRegistry extends BaseRegistry<Song, SongMetadata>
 
   public static final SONG_MUSIC_DATA_VERSION_RULE:thx.semver.VersionRule = "2.0.x";
 
-  public static var DEFAULT_GENERATEDBY(get, null):String;
+  public static var DEFAULT_GENERATEDBY(get, never):String;
 
   static function get_DEFAULT_GENERATEDBY():String
   {
@@ -57,7 +58,7 @@ class SongRegistry extends BaseRegistry<Song, SongMetadata>
     // SCRIPTED ENTRIES
     //
     var scriptedEntryClassNames:Array<String> = getScriptedClassNames();
-    log('Registering ${scriptedEntryClassNames.length} scripted entries...');
+    log('Parsing ${scriptedEntryClassNames.length} scripted entries...');
 
     for (entryCls in scriptedEntryClassNames)
     {
@@ -83,12 +84,12 @@ class SongRegistry extends BaseRegistry<Song, SongMetadata>
     var unscriptedEntryIds:Array<String> = entryIdList.filter(function(entryId:String):Bool {
       return !entries.exists(entryId);
     });
-    log('Fetching data for ${unscriptedEntryIds.length} unscripted entries...');
+    log('Parsing ${unscriptedEntryIds.length} unscripted entries...');
     for (entryId in unscriptedEntryIds)
     {
       try
       {
-        var entry:Song = createEntry(entryId);
+        var entry:Null<Song> = createEntry(entryId);
         if (entry != null)
         {
           trace('  Loaded entry data: ${entry}');
@@ -126,6 +127,8 @@ class SongRegistry extends BaseRegistry<Song, SongMetadata>
     variation = variation == null ? Constants.DEFAULT_VARIATION : variation;
 
     var parser = new json2object.JsonParser<SongMetadata>();
+    parser.ignoreUnknownVariables = true;
+
     switch (loadEntryMetadataFile(id, variation))
     {
       case {fileName: fileName, contents: contents}:
@@ -147,6 +150,7 @@ class SongRegistry extends BaseRegistry<Song, SongMetadata>
     variation = variation == null ? Constants.DEFAULT_VARIATION : variation;
 
     var parser = new json2object.JsonParser<SongMetadata>();
+    parser.ignoreUnknownVariables = true;
     parser.fromJson(contents, fileName);
 
     if (parser.errors.length > 0)
@@ -206,6 +210,8 @@ class SongRegistry extends BaseRegistry<Song, SongMetadata>
     variation = variation == null ? Constants.DEFAULT_VARIATION : variation;
 
     var parser = new json2object.JsonParser<SongMetadata_v2_1_0>();
+    parser.ignoreUnknownVariables = true;
+
     switch (loadEntryMetadataFile(id, variation))
     {
       case {fileName: fileName, contents: contents}:
@@ -226,6 +232,8 @@ class SongRegistry extends BaseRegistry<Song, SongMetadata>
     variation = variation == null ? Constants.DEFAULT_VARIATION : variation;
 
     var parser = new json2object.JsonParser<SongMetadata_v2_0_0>();
+    parser.ignoreUnknownVariables = true;
+
     switch (loadEntryMetadataFile(id, variation))
     {
       case {fileName: fileName, contents: contents}:
@@ -244,6 +252,7 @@ class SongRegistry extends BaseRegistry<Song, SongMetadata>
   function parseEntryMetadataRaw_v2_1_0(contents:String, ?fileName:String = 'raw'):Null<SongMetadata>
   {
     var parser = new json2object.JsonParser<SongMetadata_v2_1_0>();
+    parser.ignoreUnknownVariables = true;
     parser.fromJson(contents, fileName);
 
     if (parser.errors.length > 0)
@@ -257,6 +266,7 @@ class SongRegistry extends BaseRegistry<Song, SongMetadata>
   function parseEntryMetadataRaw_v2_0_0(contents:String, ?fileName:String = 'raw'):Null<SongMetadata>
   {
     var parser = new json2object.JsonParser<SongMetadata_v2_0_0>();
+    parser.ignoreUnknownVariables = true;
     parser.fromJson(contents, fileName);
 
     if (parser.errors.length > 0)
@@ -272,6 +282,8 @@ class SongRegistry extends BaseRegistry<Song, SongMetadata>
     variation = variation == null ? Constants.DEFAULT_VARIATION : variation;
 
     var parser = new json2object.JsonParser<SongMusicData>();
+    parser.ignoreUnknownVariables = false;
+
     switch (loadMusicDataFile(id, variation))
     {
       case {fileName: fileName, contents: contents}:
@@ -291,6 +303,7 @@ class SongRegistry extends BaseRegistry<Song, SongMetadata>
   public function parseMusicDataRaw(contents:String, ?fileName:String = 'raw'):Null<SongMusicData>
   {
     var parser = new json2object.JsonParser<SongMusicData>();
+    parser.ignoreUnknownVariables = false;
     parser.fromJson(contents, fileName);
 
     if (parser.errors.length > 0)
@@ -334,6 +347,7 @@ class SongRegistry extends BaseRegistry<Song, SongMetadata>
     variation = variation == null ? Constants.DEFAULT_VARIATION : variation;
 
     var parser = new json2object.JsonParser<SongChartData>();
+    parser.ignoreUnknownVariables = true;
 
     switch (loadEntryChartFile(id, variation))
     {
@@ -356,6 +370,7 @@ class SongRegistry extends BaseRegistry<Song, SongMetadata>
     variation = variation == null ? Constants.DEFAULT_VARIATION : variation;
 
     var parser = new json2object.JsonParser<SongChartData>();
+    parser.ignoreUnknownVariables = true;
     parser.fromJson(contents, fileName);
 
     if (parser.errors.length > 0)
@@ -441,7 +456,7 @@ class SongRegistry extends BaseRegistry<Song, SongMetadata>
   {
     variation = variation == null ? Constants.DEFAULT_VARIATION : variation;
     var entryStr:Null<String> = loadEntryMetadataFile(id, variation)?.contents;
-    var entryVersion:thx.semver.Version = VersionUtil.getVersionFromJSON(entryStr);
+    var entryVersion:Null<thx.semver.Version> = VersionUtil.getVersionFromJSON(entryStr);
     return entryVersion;
   }
 
@@ -449,7 +464,7 @@ class SongRegistry extends BaseRegistry<Song, SongMetadata>
   {
     variation = variation == null ? Constants.DEFAULT_VARIATION : variation;
     var entryStr:Null<String> = loadEntryChartFile(id, variation)?.contents;
-    var entryVersion:thx.semver.Version = VersionUtil.getVersionFromJSON(entryStr);
+    var entryVersion:Null<thx.semver.Version> = VersionUtil.getVersionFromJSON(entryStr);
     return entryVersion;
   }
 
diff --git a/source/funkin/data/song/importer/ChartManifestData.hx b/source/funkin/data/song/importer/ChartManifestData.hx
index 0c7d2f0b0..dd0d28479 100644
--- a/source/funkin/data/song/importer/ChartManifestData.hx
+++ b/source/funkin/data/song/importer/ChartManifestData.hx
@@ -68,6 +68,7 @@ class ChartManifestData
   public static function deserialize(contents:String):Null<ChartManifestData>
   {
     var parser = new json2object.JsonParser<ChartManifestData>();
+    parser.ignoreUnknownVariables = false;
     parser.fromJson(contents, 'manifest.json');
 
     if (parser.errors.length > 0)
diff --git a/source/funkin/data/song/importer/FNFLegacyData.hx b/source/funkin/data/song/importer/FNFLegacyData.hx
index 5b75368c9..52380d344 100644
--- a/source/funkin/data/song/importer/FNFLegacyData.hx
+++ b/source/funkin/data/song/importer/FNFLegacyData.hx
@@ -19,7 +19,8 @@ class LegacySongData
 
   @:jcustomparse(funkin.data.DataParse.eitherLegacyScrollSpeeds)
   public var speed:Either<Float, LegacyScrollSpeeds>;
-  public var stageDefault:String;
+  @:optional
+  public var stageDefault:Null<String>;
   public var bpm:Float;
 
   @:jcustomparse(funkin.data.DataParse.eitherLegacyNoteData)
diff --git a/source/funkin/data/song/importer/FNFLegacyImporter.hx b/source/funkin/data/song/importer/FNFLegacyImporter.hx
index ee68513dc..ab2abda8e 100644
--- a/source/funkin/data/song/importer/FNFLegacyImporter.hx
+++ b/source/funkin/data/song/importer/FNFLegacyImporter.hx
@@ -14,6 +14,7 @@ class FNFLegacyImporter
   public static function parseLegacyDataRaw(input:String, fileName:String = 'raw'):FNFLegacyData
   {
     var parser = new json2object.JsonParser<FNFLegacyData>();
+    parser.ignoreUnknownVariables = true; // Set to true to ignore extra variables that might be included in the JSON.
     parser.fromJson(input, fileName);
 
     if (parser.errors.length > 0)
@@ -185,15 +186,34 @@ class FNFLegacyImporter
     return result;
   }
 
+  static final STRUMLINE_SIZE = 4;
+
   static function migrateNoteSections(input:Array<LegacyNoteSection>):Array<SongNoteData>
   {
     var result:Array<SongNoteData> = [];
 
     for (section in input)
     {
+      var mustHitSection = section.mustHitSection ?? false;
       for (note in section.sectionNotes)
       {
-        result.push(new SongNoteData(note.time, note.data, note.length, note.getKind()));
+        // Handle the dumb logic for mustHitSection.
+        var noteData = note.data;
+
+        // Flip notes if mustHitSection is FALSE (not true lol).
+        if (!mustHitSection)
+        {
+          if (noteData >= STRUMLINE_SIZE)
+          {
+            noteData -= STRUMLINE_SIZE;
+          }
+          else
+          {
+            noteData += STRUMLINE_SIZE;
+          }
+        }
+
+        result.push(new SongNoteData(note.time, noteData, note.length, note.getKind()));
       }
     }
 
diff --git a/source/funkin/data/speaker/TODO.md b/source/funkin/data/speaker/TODO.md
new file mode 100644
index 000000000..e69de29bb
diff --git a/source/funkin/data/stage/StageData.hx b/source/funkin/data/stage/StageData.hx
new file mode 100644
index 000000000..cb914007f
--- /dev/null
+++ b/source/funkin/data/stage/StageData.hx
@@ -0,0 +1,199 @@
+package funkin.data.stage;
+
+import funkin.data.animation.AnimationData;
+
+@:nullSafety
+class StageData
+{
+  /**
+   * The sematic version number of the stage data JSON format.
+   * Supports fancy comparisons like NPM does it's neat.
+   */
+  @:default(funkin.data.stage.StageRegistry.STAGE_DATA_VERSION)
+  public var version:String;
+
+  public var name:String = 'Unknown';
+  public var props:Array<StageDataProp> = [];
+  public var characters:StageDataCharacters;
+
+  @:default(1.0)
+  @:optional
+  public var cameraZoom:Null<Float>;
+
+  public function new()
+  {
+    this.version = StageRegistry.STAGE_DATA_VERSION;
+    this.characters = makeDefaultCharacters();
+  }
+
+  function makeDefaultCharacters():StageDataCharacters
+  {
+    return {
+      bf:
+        {
+          zIndex: 0,
+          position: [0, 0],
+          cameraOffsets: [-100, -100]
+        },
+      dad:
+        {
+          zIndex: 0,
+          position: [0, 0],
+          cameraOffsets: [100, -100]
+        },
+      gf:
+        {
+          zIndex: 0,
+          position: [0, 0],
+          cameraOffsets: [0, 0]
+        }
+    };
+  }
+
+  /**
+   * Convert this StageData into a JSON string.
+   */
+  public function serialize(pretty:Bool = true):String
+  {
+    var writer = new json2object.JsonWriter<StageData>();
+    return writer.write(this, pretty ? '  ' : null);
+  }
+}
+
+typedef StageDataCharacters =
+{
+  var bf:StageDataCharacter;
+  var dad:StageDataCharacter;
+  var gf:StageDataCharacter;
+};
+
+typedef StageDataProp =
+{
+  /**
+   * The name of the prop for later lookup by scripts.
+   * Optional; if unspecified, the prop can't be referenced by scripts.
+   */
+  @:optional
+  var name:String;
+
+  /**
+   * The asset used to display the prop.
+   * NOTE: As of Stage data v1.0.1, you can also use a color here to create a rectangle, like "#ff0000".
+   * In this case, the `scale` property will be used to determine the size of the prop.
+   */
+  var assetPath:String;
+
+  /**
+   * The position of the prop as an [x, y] array of two floats.
+   */
+  var position:Array<Float>;
+
+  /**
+   * A number determining the stack order of the prop, relative to other props and the characters in the stage.
+   * Props with lower numbers render below those with higher numbers.
+   * This is just like CSS, it isn't hard.
+   * @default 0
+   */
+  @:optional
+  @:default(0)
+  var zIndex:Int;
+
+  /**
+   * If set to true, anti-aliasing will be forcibly disabled on the sprite.
+   * This prevents blurry images on pixel-art levels.
+   * @default false
+   */
+  @:optional
+  @:default(false)
+  var isPixel:Bool;
+
+  /**
+   * Either the scale of the prop as a float, or the [w, h] scale as an array of two floats.
+   * Pro tip: On pixel-art levels, save the sprite small and set this value to 6 or so to save memory.
+   */
+  @:jcustomparse(funkin.data.DataParse.eitherFloatOrFloats)
+  @:jcustomwrite(funkin.data.DataWrite.eitherFloatOrFloats)
+  @:optional
+  var scale:haxe.ds.Either<Float, Array<Float>>;
+
+  /**
+   * The alpha of the prop, as a float.
+   * @default 1.0
+   */
+  @:optional
+  @:default(1.0)
+  var alpha:Float;
+
+  /**
+   * If not zero, this prop will play an animation every X beats of the song.
+   * This requires animations to be defined. If `danceLeft` and `danceRight` are defined,
+   * they will alternated between, otherwise the `idle` animation will be used.
+   *
+   * @default 0
+   */
+  @:default(0)
+  @:optional
+  var danceEvery:Int;
+
+  /**
+   * How much the prop scrolls relative to the camera. Used to create a parallax effect.
+   * Represented as an [x, y] array of two floats.
+   * [1, 1] means the prop moves 1:1 with the camera.
+   * [0.5, 0.5] means the prop half as much as the camera.
+   * [0, 0] means the prop is not moved.
+   * @default [0, 0]
+   */
+  @:optional
+  @:default([0, 0])
+  var scroll:Array<Float>;
+
+  /**
+   * An optional array of animations which the prop can play.
+   * @default Prop has no animations.
+   */
+  @:optional
+  @:default([])
+  var animations:Array<AnimationData>;
+
+  /**
+   * If animations are used, this is the name of the animation to play first.
+   * @default Don't play an animation.
+   */
+  @:optional
+  var startingAnimation:Null<String>;
+
+  /**
+   * The animation type to use.
+   * Options: "sparrow", "packer"
+   * @default "sparrow"
+   */
+  @:default("sparrow")
+  @:optional
+  var animType:String;
+};
+
+typedef StageDataCharacter =
+{
+  /**
+   * A number determining the stack order of the character, relative to props and other characters in the stage.
+   * Again, just like CSS.
+   * @default 0
+   */
+  @:optional
+  @:default(0)
+  var zIndex:Int;
+
+  /**
+   * The position to render the character at.
+   */
+  @:optional
+  @:default([0, 0])
+  var position:Array<Float>;
+
+  /**
+   * The camera offsets to apply when focusing on the character on this stage.
+   * @default [-100, -100] for BF, [100, -100] for DAD/OPPONENT, [0, 0] for GF
+   */
+  @:optional
+  var cameraOffsets:Array<Float>;
+};
diff --git a/source/funkin/data/stage/StageRegistry.hx b/source/funkin/data/stage/StageRegistry.hx
new file mode 100644
index 000000000..b78292e5b
--- /dev/null
+++ b/source/funkin/data/stage/StageRegistry.hx
@@ -0,0 +1,103 @@
+package funkin.data.stage;
+
+import funkin.data.stage.StageData;
+import funkin.play.stage.Stage;
+import funkin.play.stage.ScriptedStage;
+
+class StageRegistry extends BaseRegistry<Stage, StageData>
+{
+  /**
+   * 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 STAGE_DATA_VERSION:thx.semver.Version = "1.0.1";
+
+  public static final STAGE_DATA_VERSION_RULE:thx.semver.VersionRule = "1.0.x";
+
+  public static final instance:StageRegistry = new StageRegistry();
+
+  public function new()
+  {
+    super('STAGE', 'stages', STAGE_DATA_VERSION_RULE);
+  }
+
+  /**
+   * Read, parse, and validate the JSON data and produce the corresponding data object.
+   */
+  public function parseEntryData(id:String):Null<StageData>
+  {
+    // JsonParser does not take type parameters,
+    // otherwise this function would be in BaseRegistry.
+    var parser = new json2object.JsonParser<StageData>();
+    parser.ignoreUnknownVariables = false;
+
+    switch (loadEntryFile(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;
+  }
+
+  /**
+   * Parse and validate the JSON data and produce the corresponding data object.
+   *
+   * NOTE: Must be implemented on the implementation class.
+   * @param contents The JSON as a string.
+   * @param fileName An optional file name for error reporting.
+   */
+  public function parseEntryDataRaw(contents:String, ?fileName:String):Null<StageData>
+  {
+    var parser = new json2object.JsonParser<StageData>();
+    parser.ignoreUnknownVariables = false;
+    parser.fromJson(contents, fileName);
+
+    if (parser.errors.length > 0)
+    {
+      printErrors(parser.errors, fileName);
+      return null;
+    }
+    return parser.value;
+  }
+
+  function createScriptedEntry(clsName:String):Stage
+  {
+    return ScriptedStage.init(clsName, "unknown");
+  }
+
+  function getScriptedClassNames():Array<String>
+  {
+    return ScriptedStage.listScriptClasses();
+  }
+
+  /**
+   * A list of all the stages from the base game, in order.
+   * TODO: Should this be hardcoded?
+   */
+  public function listBaseGameStageIds():Array<String>
+  {
+    return [
+      "mainStage", "spookyMansion", "phillyTrain", "limoRide", "mallXmas", "mallEvil", "school", "schoolEvil", "tankmanBattlefield", "phillyStreets",
+      "phillyBlazin",
+    ];
+  }
+
+  /**
+   * A list of all installed story weeks that are not from the base game.
+   */
+  public function listModdedStageIds():Array<String>
+  {
+    return listEntryIds().filter(function(id:String):Bool {
+      return listBaseGameStageIds().indexOf(id) == -1;
+    });
+  }
+}
diff --git a/source/funkin/graphics/FunkinSprite.hx b/source/funkin/graphics/FunkinSprite.hx
new file mode 100644
index 000000000..487aaac34
--- /dev/null
+++ b/source/funkin/graphics/FunkinSprite.hx
@@ -0,0 +1,53 @@
+package funkin.graphics;
+
+import flixel.FlxSprite;
+import flixel.util.FlxColor;
+import flixel.graphics.FlxGraphic;
+
+/**
+ * An FlxSprite with additional functionality.
+ */
+class FunkinSprite extends FlxSprite
+{
+  /**
+   * @param x Starting X position
+   * @param y Starting Y position
+   */
+  public function new(?x:Float = 0, ?y:Float = 0)
+  {
+    super(x, y);
+  }
+
+  /**
+   * Acts similarly to `makeGraphic`, but with improved memory usage,
+   * at the expense of not being able to paint onto the sprite.
+   *
+   * @param width The target width of the sprite.
+   * @param height The target height of the sprite.
+   * @param color The color to fill the sprite with.
+   */
+  public function makeSolidColor(width:Int, height:Int, color:FlxColor = FlxColor.WHITE):FunkinSprite
+  {
+    var graphic:FlxGraphic = FlxG.bitmap.create(2, 2, color, false, 'solid#${color.toHexString(true, false)}');
+    frames = graphic.imageFrame;
+    scale.set(width / 2, height / 2);
+    updateHitbox();
+
+    return this;
+  }
+
+  /**
+   * Ensure scale is applied when cloning a sprite.
+   * The default `clone()` method acts kinda weird TBH.
+   * @return A clone of this sprite.
+   */
+  public override function clone():FunkinSprite
+  {
+    var result = new FunkinSprite(this.x, this.y);
+    result.frames = this.frames;
+    result.scale.set(this.scale.x, this.scale.y);
+    result.updateHitbox();
+
+    return result;
+  }
+}
diff --git a/source/funkin/graphics/rendering/MeshRender.hx b/source/funkin/graphics/rendering/MeshRender.hx
index 39402808a..a06d53337 100644
--- a/source/funkin/graphics/rendering/MeshRender.hx
+++ b/source/funkin/graphics/rendering/MeshRender.hx
@@ -12,22 +12,19 @@ class MeshRender extends FlxStrip
   public var vertex_count(default, null):Int = 0;
   public var index_count(default, null):Int = 0;
 
-  var tri_offset:Int = 0;
-
   public function new(x, y, ?col:FlxColor = FlxColor.WHITE)
   {
     super(x, y);
     makeGraphic(1, 1, col);
   }
 
-  public inline function start()
+  /**
+   * Add a vertex.
+   */
+  public inline function build_vertex(x:Float, y:Float, u:Float = 0, v:Float = 0):Int
   {
-    tri_offset = vertex_count;
-  }
-
-  public inline function add_vertex(x:Float, y:Float, u:Float = 0, v:Float = 0)
-  {
-    final pos = vertex_count << 1;
+    final index = vertex_count;
+    final pos = index << 1;
 
     vertices[pos] = x;
     vertices[pos + 1] = y;
@@ -36,48 +33,72 @@ class MeshRender extends FlxStrip
     uvtData[pos + 1] = v;
 
     vertex_count++;
+    return index;
   }
 
-  public function add_tri(a:Int, b:Int, c:Int)
+  /**
+   * Build a triangle from three vertex indexes.
+   * @param a
+   * @param b
+   * @param c
+   */
+  public function add_tri(a:Int, b:Int, c:Int):Void
   {
-    indices[index_count] = a + tri_offset;
-    indices[index_count + 1] = b + tri_offset;
-    indices[index_count + 2] = c + tri_offset;
+    indices[index_count] = a;
+    indices[index_count + 1] = b;
+    indices[index_count + 2] = c;
 
     index_count += 3;
   }
 
-  /**
-   *
-   * top left - a
-   *
-   * top right - b
-   *
-   * bottom left  - c
-   *
-   * bottom right - d
-   */
-  public function add_quad(ax:Float, ay:Float, bx:Float, by:Float, cx:Float, cy:Float, dx:Float, dy:Float, au:Float = 0, av:Float = 0, bu:Float = 0,
-      bv:Float = 0, cu:Float = 0, cv:Float = 0, du:Float = 0, dv:Float = 0)
+  public function build_tri(ax:Float, ay:Float, bx:Float, by:Float, cx:Float, cy:Float, au:Float = 0, av:Float = 0, bu:Float = 0, bv:Float = 0, cu:Float = 0,
+      cv:Float = 0):Void
   {
-    start();
-    // top left
-    add_vertex(bx, by, bu, bv);
-    // top right
-    add_vertex(ax, ay, au, av);
-    // bottom left
-    add_vertex(cx, cy, cu, cv);
-    // bottom right
-    add_vertex(dx, dy, du, dv);
+    add_tri(build_vertex(ax, ay, au, av), build_vertex(bx, by, bu, bv), build_vertex(cx, cy, cu, cv));
+  }
 
-    add_tri(0, 1, 2);
-    add_tri(0, 2, 3);
+  /**
+   * @param a top left vertex
+   * @param b top right vertex
+   * @param c bottom right vertex
+   * @param d bottom left vertex
+   */
+  public function add_quad(a:Int, b:Int, c:Int, d:Int):Void
+  {
+    add_tri(a, b, c);
+    add_tri(a, c, d);
+  }
+
+  /**
+   * Build a quad from four points.
+   *
+   * top right - a
+   * top left - b
+   * bottom right  - c
+   * bottom left - d
+   */
+  public function build_quad(ax:Float, ay:Float, bx:Float, by:Float, cx:Float, cy:Float, dx:Float, dy:Float, au:Float = 0, av:Float = 0, bu:Float = 0,
+      bv:Float = 0, cu:Float = 0, cv:Float = 0, du:Float = 0, dv:Float = 0):Void
+  {
+    // top left
+    var b = build_vertex(bx, by, bu, bv);
+    // top right
+    var a = build_vertex(ax, ay, au, av);
+    // bottom left
+    var c = build_vertex(cx, cy, cu, cv);
+    // bottom right
+    var d = build_vertex(dx, dy, du, dv);
+
+    add_tri(a, b, c);
+    add_tri(a, c, d);
   }
 
   public function clear()
   {
     vertices.length = 0;
     indices.length = 0;
+    uvtData.length = 0;
+    colors.length = 0;
     vertex_count = 0;
     index_count = 0;
   }
diff --git a/source/funkin/input/Cursor.hx b/source/funkin/input/Cursor.hx
index b4bf43808..39f399465 100644
--- a/source/funkin/input/Cursor.hx
+++ b/source/funkin/input/Cursor.hx
@@ -34,6 +34,18 @@ class Cursor
     Cursor.cursorMode = null;
   }
 
+  public static inline function toggle():Void
+  {
+    if (FlxG.mouse.visible)
+    {
+      hide();
+    }
+    else
+    {
+      show();
+    }
+  }
+
   public static final CURSOR_DEFAULT_PARAMS:CursorParams =
     {
       graphic: "assets/images/cursor/cursor-default.png",
diff --git a/source/funkin/modding/PolymodHandler.hx b/source/funkin/modding/PolymodHandler.hx
index 7716f0f02..f1e82aee9 100644
--- a/source/funkin/modding/PolymodHandler.hx
+++ b/source/funkin/modding/PolymodHandler.hx
@@ -2,20 +2,21 @@ package funkin.modding;
 
 import funkin.util.macro.ClassMacro;
 import funkin.modding.module.ModuleHandler;
-import funkin.play.character.CharacterData.CharacterDataParser;
 import funkin.data.song.SongData;
-import funkin.play.stage.StageData;
+import funkin.data.stage.StageData;
 import polymod.Polymod;
 import polymod.backends.PolymodAssets.PolymodAssetType;
 import polymod.format.ParseRules.TextFileFormat;
-import funkin.data.event.SongEventData.SongEventParser;
+import funkin.data.event.SongEventRegistry;
+import funkin.data.stage.StageRegistry;
 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.data.dialogue.ConversationRegistry;
+import funkin.data.dialogue.DialogueBoxRegistry;
+import funkin.data.dialogue.SpeakerRegistry;
+import funkin.play.character.CharacterData.CharacterDataParser;
 import funkin.save.Save;
-import funkin.play.cutscene.dialogue.SpeakerDataParser;
 import funkin.data.song.SongRegistry;
 
 class PolymodHandler
@@ -207,8 +208,8 @@ class PolymodHandler
   {
     return {
       assetLibraryPaths: [
-        "default" => "preload", "shared" => "", "songs" => "songs", "tutorial" => "tutorial", "week1" => "week1", "week2" => "week2", "week3" => "week3",
-        "week4" => "week4", "week5" => "week5", "week6" => "week6", "week7" => "week7", "weekend1" => "weekend1",
+        "default" => "preload", "shared" => "shared", "songs" => "songs", "tutorial" => "tutorial", "week1" => "week1",      "week2" => "week2",
+            "week3" => "week3",   "week4" => "week4", "week5" => "week5",       "week6" => "week6", "week7" => "week7", "weekend1" => "weekend1",
       ],
       coreAssetRedirect: CORE_FOLDER,
     }
@@ -271,12 +272,12 @@ class PolymodHandler
     SongRegistry.instance.loadEntries();
     LevelRegistry.instance.loadEntries();
     NoteStyleRegistry.instance.loadEntries();
-    SongEventParser.loadEventCache();
-    ConversationDataParser.loadConversationCache();
-    DialogueBoxDataParser.loadDialogueBoxCache();
-    SpeakerDataParser.loadSpeakerCache();
-    StageDataParser.loadStageCache();
-    CharacterDataParser.loadCharacterCache();
+    SongEventRegistry.loadEventCache();
+    ConversationRegistry.instance.loadEntries();
+    DialogueBoxRegistry.instance.loadEntries();
+    SpeakerRegistry.instance.loadEntries();
+    StageRegistry.instance.loadEntries();
+    CharacterDataParser.loadCharacterCache(); // TODO: Migrate characters to BaseRegistry.
     ModuleHandler.loadModuleCache();
   }
 }
diff --git a/source/funkin/play/Countdown.hx b/source/funkin/play/Countdown.hx
index d23574ce2..5b7ce9fc2 100644
--- a/source/funkin/play/Countdown.hx
+++ b/source/funkin/play/Countdown.hx
@@ -40,7 +40,7 @@ class Countdown
     stopCountdown();
 
     PlayState.instance.isInCountdown = true;
-    Conductor.update(PlayState.instance.startTimestamp + Conductor.beatLengthMs * -5);
+    Conductor.instance.update(PlayState.instance.startTimestamp + Conductor.instance.beatLengthMs * -5);
     // Handle onBeatHit events manually
     // @:privateAccess
     // PlayState.instance.dispatchEvent(new SongTimeScriptEvent(SONG_BEAT_HIT, 0, 0));
@@ -48,7 +48,7 @@ class Countdown
     // The timer function gets called based on the beat of the song.
     countdownTimer = new FlxTimer();
 
-    countdownTimer.start(Conductor.beatLengthMs / 1000, function(tmr:FlxTimer) {
+    countdownTimer.start(Conductor.instance.beatLengthMs / 1000, function(tmr:FlxTimer) {
       if (PlayState.instance == null)
       {
         tmr.cancel();
@@ -158,7 +158,7 @@ class Countdown
   {
     stopCountdown();
     // This will trigger PlayState.startSong()
-    Conductor.update(0);
+    Conductor.instance.update(0);
     // PlayState.isInCountdown = false;
   }
 
@@ -225,7 +225,7 @@ class Countdown
     countdownSprite.screenCenter();
 
     // Fade sprite in, then out, then destroy it.
-    FlxTween.tween(countdownSprite, {y: countdownSprite.y += 100, alpha: 0}, Conductor.beatLengthMs / 1000,
+    FlxTween.tween(countdownSprite, {y: countdownSprite.y += 100, alpha: 0}, Conductor.instance.beatLengthMs / 1000,
       {
         ease: FlxEase.cubeInOut,
         onComplete: function(twn:FlxTween) {
diff --git a/source/funkin/play/GameOverSubState.hx b/source/funkin/play/GameOverSubState.hx
index 6eb53e2d5..36f72237e 100644
--- a/source/funkin/play/GameOverSubState.hx
+++ b/source/funkin/play/GameOverSubState.hx
@@ -7,6 +7,7 @@ import flixel.sound.FlxSound;
 import funkin.ui.story.StoryMenuState;
 import flixel.util.FlxColor;
 import flixel.util.FlxTimer;
+import funkin.graphics.FunkinSprite;
 import funkin.ui.MusicBeatSubState;
 import funkin.modding.events.ScriptEvent;
 import funkin.modding.events.ScriptEventDispatcher;
@@ -22,6 +23,12 @@ import funkin.play.character.BaseCharacter;
  */
 class GameOverSubState extends MusicBeatSubState
 {
+  /**
+   * The currently active GameOverSubState.
+   * There should be only one GameOverSubState in existance at a time, we can use a singleton.
+   */
+  public static var instance:GameOverSubState = null;
+
   /**
    * Which alternate animation on the character to use.
    * You can set this via script.
@@ -64,9 +71,16 @@ class GameOverSubState extends MusicBeatSubState
    */
   var isEnding:Bool = false;
 
-  public function new()
+  var isChartingMode:Bool = false;
+
+  var transparent:Bool;
+
+  public function new(params:GameOverParams)
   {
     super();
+
+    this.isChartingMode = params?.isChartingMode ?? false;
+    transparent = params.transparent;
   }
 
   /**
@@ -80,6 +94,13 @@ class GameOverSubState extends MusicBeatSubState
 
   override public function create()
   {
+    if (instance != null)
+    {
+      // TODO: Do something in this case? IDK.
+      trace('WARNING: GameOverSubState instance already exists. This should not happen.');
+    }
+    instance = this;
+
     super.create();
 
     //
@@ -87,10 +108,12 @@ class GameOverSubState extends MusicBeatSubState
     //
 
     // Add a black background to the screen.
-    // We make this transparent so that we can see the stage underneath during debugging.
-    var bg = new FlxSprite().makeGraphic(FlxG.width, FlxG.height, FlxColor.BLACK);
-    bg.alpha = 0.25;
+    var bg = new FunkinSprite().makeSolidColor(FlxG.width * 2, FlxG.height * 2, FlxColor.BLACK);
+    // We make this transparent so that we can see the stage underneath during debugging,
+    // but it's normally opaque.
+    bg.alpha = transparent ? 0.25 : 1.0;
     bg.scrollFactor.set();
+    bg.screenCenter();
     add(bg);
 
     // Pluck Boyfriend from the PlayState and place him (in the same position) in the GameOverSubState.
@@ -121,7 +144,7 @@ class GameOverSubState extends MusicBeatSubState
     gameOverMusic.stop();
 
     // The conductor now represents the BPM of the game over music.
-    Conductor.update(0);
+    Conductor.instance.update(0);
   }
 
   var hasStartedAnimation:Bool = false;
@@ -176,16 +199,27 @@ class GameOverSubState extends MusicBeatSubState
       // PlayState.seenCutscene = false; // old thing...
       gameOverMusic.stop();
 
-      if (PlayStatePlaylist.isStoryMode) FlxG.switchState(new StoryMenuState());
+      if (isChartingMode)
+      {
+        this.close();
+        if (FlxG.sound.music != null) FlxG.sound.music.pause(); // Don't reset song position!
+        PlayState.instance.close(); // This only works because PlayState is a substate!
+      }
+      else if (PlayStatePlaylist.isStoryMode)
+      {
+        FlxG.switchState(new StoryMenuState());
+      }
       else
+      {
         FlxG.switchState(new FreeplayState());
+      }
     }
 
     if (gameOverMusic.playing)
     {
       // Match the conductor to the music.
       // This enables the stepHit and beatHit events.
-      Conductor.update(gameOverMusic.time);
+      Conductor.instance.update(gameOverMusic.time);
     }
     else
     {
@@ -201,6 +235,7 @@ class GameOverSubState extends MusicBeatSubState
             playJeffQuote();
             // Start music at lower volume
             startDeathMusic(0.2, false);
+            boyfriend.playAnimation('deathLoop' + animationSuffix);
           }
         default:
           // Start music at normal volume once the initial death animation finishes.
@@ -261,15 +296,16 @@ class GameOverSubState extends MusicBeatSubState
    */
   function startDeathMusic(?startingVolume:Float = 1, force:Bool = false):Void
   {
-    var musicPath = Paths.music('gameOver' + musicSuffix);
+    var musicPath = Paths.music('gameplay/gameover/gameOver' + musicSuffix);
     if (isEnding)
     {
-      musicPath = Paths.music('gameOverEnd' + musicSuffix);
+      musicPath = Paths.music('gameplay/gameover/gameOverEnd' + musicSuffix);
     }
     if (!gameOverMusic.playing || force)
     {
       gameOverMusic.loadEmbedded(musicPath);
       gameOverMusic.volume = startingVolume;
+      gameOverMusic.looped = !isEnding;
       gameOverMusic.play();
     }
   }
@@ -283,7 +319,7 @@ class GameOverSubState extends MusicBeatSubState
   public static function playBlueBalledSFX()
   {
     blueballed = true;
-    FlxG.sound.play(Paths.sound('fnf_loss_sfx' + blueBallSuffix));
+    FlxG.sound.play(Paths.sound('gameplay/gameover/fnf_loss_sfx' + blueBallSuffix));
   }
 
   var playingJeffQuote:Bool = false;
@@ -306,4 +342,15 @@ class GameOverSubState extends MusicBeatSubState
       }
     });
   }
+
+  public override function toString():String
+  {
+    return "GameOverSubState";
+  }
+}
+
+typedef GameOverParams =
+{
+  var isChartingMode:Bool;
+  var transparent:Bool;
 }
diff --git a/source/funkin/play/PlayState.hx b/source/funkin/play/PlayState.hx
index 5f53e9388..ad5272f88 100644
--- a/source/funkin/play/PlayState.hx
+++ b/source/funkin/play/PlayState.hx
@@ -47,9 +47,11 @@ import funkin.ui.mainmenu.MainMenuState;
 import funkin.modding.events.ScriptEventDispatcher;
 import funkin.play.character.BaseCharacter;
 import funkin.play.character.CharacterData.CharacterDataParser;
+import funkin.play.cutscene.dialogue.Conversation;
+import funkin.data.dialogue.ConversationRegistry;
 import funkin.play.cutscene.VanillaCutscenes;
 import funkin.play.cutscene.VideoCutscene;
-import funkin.data.event.SongEventData.SongEventParser;
+import funkin.data.event.SongEventRegistry;
 import funkin.play.notes.NoteSprite;
 import funkin.play.notes.NoteDirection;
 import funkin.play.notes.NoteSprite;
@@ -59,11 +61,11 @@ import funkin.play.notes.notestyle.NoteStyle;
 import funkin.play.scoring.Scoring;
 import funkin.play.song.Song;
 import funkin.data.song.SongRegistry;
+import funkin.data.stage.StageRegistry;
 import funkin.data.song.SongData.SongEventData;
 import funkin.data.song.SongData.SongNoteData;
 import funkin.data.song.SongData.SongCharacterData;
 import funkin.play.stage.Stage;
-import funkin.play.stage.StageData.StageDataParser;
 import funkin.ui.transition.LoadingState;
 import funkin.play.components.PopUpStuff;
 import funkin.ui.options.PreferencesMenu;
@@ -571,15 +573,15 @@ class PlayState extends MusicBeatSubState
     }
 
     // Prepare the Conductor.
-    Conductor.forceBPM(null);
+    Conductor.instance.forceBPM(null);
 
     if (currentChart.offsets != null)
     {
-      Conductor.instrumentalOffset = currentChart.offsets.getInstrumentalOffset();
+      Conductor.instance.instrumentalOffset = currentChart.offsets.getInstrumentalOffset();
     }
 
-    Conductor.mapTimeChanges(currentChart.timeChanges);
-    Conductor.update((Conductor.beatLengthMs * -5) + startTimestamp);
+    Conductor.instance.mapTimeChanges(currentChart.timeChanges);
+    Conductor.instance.update((Conductor.instance.beatLengthMs * -5) + startTimestamp);
 
     // The song is now loaded. We can continue to initialize the play state.
     initCameras();
@@ -744,7 +746,7 @@ class PlayState extends MusicBeatSubState
 
       // Reset music properly.
 
-      FlxG.sound.music.time = Math.max(0, startTimestamp - Conductor.instrumentalOffset);
+      FlxG.sound.music.time = Math.max(0, startTimestamp - Conductor.instance.instrumentalOffset);
       FlxG.sound.music.pause();
 
       if (!overrideMusic)
@@ -795,22 +797,22 @@ class PlayState extends MusicBeatSubState
     {
       if (isInCountdown)
       {
-        Conductor.update(Conductor.songPosition + elapsed * 1000);
-        if (Conductor.songPosition >= (startTimestamp)) startSong();
+        Conductor.instance.update(Conductor.instance.songPosition + elapsed * 1000);
+        if (Conductor.instance.songPosition >= (startTimestamp)) startSong();
       }
     }
     else
     {
       if (Constants.EXT_SOUND == 'mp3')
       {
-        Conductor.formatOffset = Constants.MP3_DELAY_MS;
+        Conductor.instance.formatOffset = Constants.MP3_DELAY_MS;
       }
       else
       {
-        Conductor.formatOffset = 0.0;
+        Conductor.instance.formatOffset = 0.0;
       }
 
-      Conductor.update(); // Normal conductor update.
+      Conductor.instance.update(); // Normal conductor update.
     }
 
     var androidPause:Bool = false;
@@ -932,7 +934,11 @@ class PlayState extends MusicBeatSubState
         }
         #end
 
-        var gameOverSubState = new GameOverSubState();
+        var gameOverSubState = new GameOverSubState(
+          {
+            isChartingMode: isChartingMode,
+            transparent: persistentDraw
+          });
         FlxTransitionableSubState.skipNextTransIn = true;
         FlxTransitionableSubState.skipNextTransOut = true;
         openSubState(gameOverSubState);
@@ -948,7 +954,7 @@ class PlayState extends MusicBeatSubState
     // TODO: Check that these work even when songPosition is less than 0.
     if (songEvents != null && songEvents.length > 0)
     {
-      var songEventsToActivate:Array<SongEventData> = SongEventParser.queryEvents(songEvents, Conductor.songPosition);
+      var songEventsToActivate:Array<SongEventData> = SongEventRegistry.queryEvents(songEvents, Conductor.instance.songPosition);
 
       if (songEventsToActivate.length > 0)
       {
@@ -956,7 +962,7 @@ class PlayState extends MusicBeatSubState
         for (event in songEventsToActivate)
         {
           // If an event is trying to play, but it's over 5 seconds old, skip it.
-          if (event.time - Conductor.songPosition < -5000)
+          if (event.time - Conductor.instance.songPosition < -5000)
           {
             event.activated = true;
             continue;
@@ -967,7 +973,7 @@ class PlayState extends MusicBeatSubState
           // Calling event.cancelEvent() skips the event. Neat!
           if (!eventEvent.eventCanceled)
           {
-            SongEventParser.handleEvent(event);
+            SongEventRegistry.handleEvent(event);
           }
         }
       }
@@ -1058,7 +1064,7 @@ class PlayState extends MusicBeatSubState
       if (startTimer.finished)
       {
         DiscordClient.changePresence(detailsText, '${currentChart.songName} ($storyDifficultyText)', iconRPC, true,
-          currentSongLengthMs - Conductor.songPosition);
+          currentSongLengthMs - Conductor.instance.songPosition);
       }
       else
       {
@@ -1082,12 +1088,12 @@ class PlayState extends MusicBeatSubState
   {
     if (health > Constants.HEALTH_MIN && !paused && FlxG.autoPause)
     {
-      if (Conductor.songPosition > 0.0) DiscordClient.changePresence(detailsText, currentSong.song
+      if (Conductor.instance.songPosition > 0.0) DiscordClient.changePresence(detailsText, currentSong.song
         + ' ('
         + storyDifficultyText
         + ')', iconRPC, true,
         currentSongLengthMs
-        - Conductor.songPosition);
+        - Conductor.instance.songPosition);
       else
         DiscordClient.changePresence(detailsText, currentSong.song + ' (' + storyDifficultyText + ')', iconRPC);
     }
@@ -1160,17 +1166,17 @@ class PlayState extends MusicBeatSubState
 
     if (!startingSong
       && FlxG.sound.music != null
-      && (Math.abs(FlxG.sound.music.time - (Conductor.songPosition + Conductor.instrumentalOffset)) > 200
-        || Math.abs(vocals.checkSyncError(Conductor.songPosition + Conductor.instrumentalOffset)) > 200))
+      && (Math.abs(FlxG.sound.music.time - (Conductor.instance.songPosition + Conductor.instance.instrumentalOffset)) > 200
+        || Math.abs(vocals.checkSyncError(Conductor.instance.songPosition + Conductor.instance.instrumentalOffset)) > 200))
     {
       trace("VOCALS NEED RESYNC");
-      if (vocals != null) trace(vocals.checkSyncError(Conductor.songPosition + Conductor.instrumentalOffset));
-      trace(FlxG.sound.music.time - (Conductor.songPosition + Conductor.instrumentalOffset));
+      if (vocals != null) trace(vocals.checkSyncError(Conductor.instance.songPosition + Conductor.instance.instrumentalOffset));
+      trace(FlxG.sound.music.time - (Conductor.instance.songPosition + Conductor.instance.instrumentalOffset));
       resyncVocals();
     }
 
-    if (iconP1 != null) iconP1.onStepHit(Std.int(Conductor.currentStep));
-    if (iconP2 != null) iconP2.onStepHit(Std.int(Conductor.currentStep));
+    if (iconP1 != null) iconP1.onStepHit(Std.int(Conductor.instance.currentStep));
+    if (iconP2 != null) iconP2.onStepHit(Std.int(Conductor.instance.currentStep));
 
     return true;
   }
@@ -1191,14 +1197,14 @@ class PlayState extends MusicBeatSubState
     }
 
     // Only zoom camera if we are zoomed by less than 35%.
-    if (FlxG.camera.zoom < (1.35 * defaultCameraZoom) && cameraZoomRate > 0 && Conductor.currentBeat % cameraZoomRate == 0)
+    if (FlxG.camera.zoom < (1.35 * defaultCameraZoom) && cameraZoomRate > 0 && Conductor.instance.currentBeat % cameraZoomRate == 0)
     {
       // Zoom camera in (1.5%)
       FlxG.camera.zoom += cameraZoomIntensity * defaultCameraZoom;
       // Hud zooms double (3%)
       camHUD.zoom += hudCameraZoomIntensity * defaultHUDCameraZoom;
     }
-    // trace('Not bopping camera: ${FlxG.camera.zoom} < ${(1.35 * defaultCameraZoom)} && ${cameraZoomRate} > 0 && ${Conductor.currentBeat} % ${cameraZoomRate} == ${Conductor.currentBeat % cameraZoomRate}}');
+    // trace('Not bopping camera: ${FlxG.camera.zoom} < ${(1.35 * defaultCameraZoom)} && ${cameraZoomRate} > 0 && ${Conductor.instance.currentBeat} % ${cameraZoomRate} == ${Conductor.instance.currentBeat % cameraZoomRate}}');
 
     // That combo milestones that got spoiled that one time.
     // Comes with NEAT visual and audio effects.
@@ -1211,13 +1217,13 @@ class PlayState extends MusicBeatSubState
     // TODO: Re-enable combo text (how to do this without sections?).
     // if (currentSong != null)
     // {
-    //  shouldShowComboText = (Conductor.currentBeat % 8 == 7);
-    //  var daSection = .getSong()[Std.int(Conductor.currentBeat / 16)];
+    //  shouldShowComboText = (Conductor.instance.currentBeat % 8 == 7);
+    //  var daSection = .getSong()[Std.int(Conductor.instance.currentBeat / 16)];
     //  shouldShowComboText = shouldShowComboText && (daSection != null && daSection.mustHitSection);
     //  shouldShowComboText = shouldShowComboText && (Highscore.tallies.combo > 5);
     //
-    //  var daNextSection = .getSong()[Std.int(Conductor.currentBeat / 16) + 1];
-    //  var isEndOfSong = .getSong().length < Std.int(Conductor.currentBeat / 16);
+    //  var daNextSection = .getSong()[Std.int(Conductor.instance.currentBeat / 16) + 1];
+    //  var isEndOfSong = .getSong().length < Std.int(Conductor.instance.currentBeat / 16);
     //  shouldShowComboText = shouldShowComboText && (isEndOfSong || (daNextSection != null && !daNextSection.mustHitSection));
     // }
 
@@ -1230,7 +1236,7 @@ class PlayState extends MusicBeatSubState
 
       var frameShit:Float = (1 / 24) * 2; // equals 2 frames in the animation
 
-      new FlxTimer().start(((Conductor.beatLengthMs / 1000) * 1.25) - frameShit, function(tmr) {
+      new FlxTimer().start(((Conductor.instance.beatLengthMs / 1000) * 1.25) - frameShit, function(tmr) {
         animShit.forceFinish();
       });
     }
@@ -1267,10 +1273,10 @@ class PlayState extends MusicBeatSubState
     if (currentStage == null) return;
 
     // TODO: Add HEY! song events to Tutorial.
-    if (Conductor.currentBeat % 16 == 15
+    if (Conductor.instance.currentBeat % 16 == 15
       && currentStage.getDad().characterId == 'gf'
-      && Conductor.currentBeat > 16
-      && Conductor.currentBeat < 48)
+      && Conductor.instance.currentBeat > 16
+      && Conductor.instance.currentBeat < 48)
     {
       currentStage.getBoyfriend().playAnimation('hey', true);
       currentStage.getDad().playAnimation('cheer', true);
@@ -1359,7 +1365,8 @@ class PlayState extends MusicBeatSubState
    */
   function loadStage(id:String):Void
   {
-    currentStage = StageDataParser.fetchStage(id);
+    currentStage = StageRegistry.instance.fetchEntry(id);
+    currentStage.revive(); // Stages are killed and props destroyed when the PlayState is destroyed to save memory.
 
     if (currentStage != null)
     {
@@ -1581,7 +1588,7 @@ class PlayState extends MusicBeatSubState
       trace('Song difficulty could not be loaded.');
     }
 
-    // Conductor.forceBPM(currentChart.getStartingBPM());
+    // Conductor.instance.forceBPM(currentChart.getStartingBPM());
 
     if (!overrideMusic)
     {
@@ -1613,7 +1620,7 @@ class PlayState extends MusicBeatSubState
 
     // Reset song events.
     songEvents = currentChart.getEvents();
-    SongEventParser.resetEvents(songEvents);
+    SongEventRegistry.resetEvents(songEvents);
 
     // Reset the notes on each strumline.
     var playerNoteData:Array<SongNoteData> = [];
@@ -1667,7 +1674,7 @@ class PlayState extends MusicBeatSubState
   {
     isInCutscene = true;
 
-    currentConversation = ConversationDataParser.fetchConversation(conversationId);
+    currentConversation = ConversationRegistry.instance.fetchEntry(conversationId);
     if (currentConversation == null) return;
 
     currentConversation.completeCallback = onConversationComplete;
@@ -1712,7 +1719,7 @@ class PlayState extends MusicBeatSubState
     FlxG.sound.music.onComplete = endSong;
     // A negative instrumental offset means the song skips the first few milliseconds of the track.
     // This just gets added into the startTimestamp behavior so we don't need to do anything extra.
-    FlxG.sound.music.time = startTimestamp - Conductor.instrumentalOffset;
+    FlxG.sound.music.time = startTimestamp - Conductor.instance.instrumentalOffset;
 
     trace('Playing vocals...');
     add(vocals);
@@ -1728,7 +1735,7 @@ class PlayState extends MusicBeatSubState
 
     if (startTimestamp > 0)
     {
-      // FlxG.sound.music.time = startTimestamp - Conductor.instrumentalOffset;
+      // FlxG.sound.music.time = startTimestamp - Conductor.instance.instrumentalOffset;
       handleSkippedNotes();
     }
   }
@@ -1802,11 +1809,12 @@ class PlayState extends MusicBeatSubState
     {
       if (note == null) continue;
 
+      // TODO: Does this properly account for offsets?
       var hitWindowStart = note.strumTime - Constants.HIT_WINDOW_MS;
       var hitWindowCenter = note.strumTime;
       var hitWindowEnd = note.strumTime + Constants.HIT_WINDOW_MS;
 
-      if (Conductor.songPosition > hitWindowEnd)
+      if (Conductor.instance.songPosition > hitWindowEnd)
       {
         if (note.hasMissed) continue;
 
@@ -1816,7 +1824,7 @@ class PlayState extends MusicBeatSubState
 
         if (note.holdNoteSprite != null) note.holdNoteSprite.missedNote = true;
       }
-      else if (Conductor.songPosition > hitWindowCenter)
+      else if (Conductor.instance.songPosition > hitWindowCenter)
       {
         if (note.hasBeenHit) continue;
 
@@ -1837,7 +1845,7 @@ class PlayState extends MusicBeatSubState
           opponentStrumline.playNoteHoldCover(note.holdNoteSprite);
         }
       }
-      else if (Conductor.songPosition > hitWindowStart)
+      else if (Conductor.instance.songPosition > hitWindowStart)
       {
         if (note.hasBeenHit || note.hasMissed) continue;
 
@@ -1870,27 +1878,43 @@ class PlayState extends MusicBeatSubState
         }
       }
 
-      // TODO: Potential penalty for dropping a hold note?
-      // if (holdNote.missedNote && !holdNote.handledMiss) { holdNote.handledMiss = true; }
+      if (holdNote.missedNote && !holdNote.handledMiss)
+      {
+        // When the opponent drops a hold note.
+        holdNote.handledMiss = true;
+
+        // We dropped a hold note.
+        // Mute vocals and play miss animation, but don't penalize.
+        vocals.opponentVolume = 0;
+        currentStage.getOpponent().playSingAnimation(holdNote.noteData.getDirection(), true);
+      }
     }
 
     // Process notes on the player's side.
     for (note in playerStrumline.notes.members)
     {
-      if (note == null || note.hasBeenHit) continue;
+      if (note == null) continue;
+
+      if (note.hasBeenHit)
+      {
+        note.tooEarly = false;
+        note.mayHit = false;
+        note.hasMissed = false;
+        continue;
+      }
 
       var hitWindowStart = note.strumTime - Constants.HIT_WINDOW_MS;
       var hitWindowCenter = note.strumTime;
       var hitWindowEnd = note.strumTime + Constants.HIT_WINDOW_MS;
 
-      if (Conductor.songPosition > hitWindowEnd)
+      if (Conductor.instance.songPosition > hitWindowEnd)
       {
         note.tooEarly = false;
         note.mayHit = false;
         note.hasMissed = true;
         if (note.holdNoteSprite != null) note.holdNoteSprite.missedNote = true;
       }
-      else if (Conductor.songPosition > hitWindowStart)
+      else if (Conductor.instance.songPosition > hitWindowStart)
       {
         note.tooEarly = false;
         note.mayHit = true;
@@ -1940,8 +1964,15 @@ class PlayState extends MusicBeatSubState
         songScore += Std.int(Constants.SCORE_HOLD_BONUS_PER_SECOND * elapsed);
       }
 
-      // TODO: Potential penalty for dropping a hold note?
-      // if (holdNote.missedNote && !holdNote.handledMiss) { holdNote.handledMiss = true; }
+      if (holdNote.missedNote && !holdNote.handledMiss)
+      {
+        // The player dropped a hold note.
+        holdNote.handledMiss = true;
+
+        // Mute vocals and play miss animation, but don't penalize.
+        vocals.playerVolume = 0;
+        currentStage.getBoyfriend().playSingAnimation(holdNote.noteData.getDirection(), true);
+      }
     }
   }
 
@@ -1957,7 +1988,7 @@ class PlayState extends MusicBeatSubState
       if (note == null || note.hasBeenHit) continue;
       var hitWindowEnd = note.strumTime + Constants.HIT_WINDOW_MS;
 
-      if (Conductor.songPosition > hitWindowEnd)
+      if (Conductor.instance.songPosition > hitWindowEnd)
       {
         // We have passed this note.
         // Flag the note for deletion without actually penalizing the player.
@@ -2033,8 +2064,6 @@ class PlayState extends MusicBeatSubState
         trace('Hit note! ${targetNote.noteData}');
         goodNoteHit(targetNote, input);
 
-        targetNote.visible = false;
-        targetNote.kill();
         notesInDirection.remove(targetNote);
 
         // Play the strumline animation.
@@ -2066,15 +2095,8 @@ class PlayState extends MusicBeatSubState
     // Calling event.cancelEvent() skips all the other logic! Neat!
     if (event.eventCanceled) return;
 
-    Highscore.tallies.combo++;
-    Highscore.tallies.totalNotesHit++;
-
-    if (Highscore.tallies.combo > Highscore.tallies.maxCombo) Highscore.tallies.maxCombo = Highscore.tallies.combo;
-
     popUpScore(note, input);
 
-    playerStrumline.hitNote(note);
-
     if (note.isHoldNote && note.holdNoteSprite != null)
     {
       playerStrumline.playNoteHoldCover(note.holdNoteSprite);
@@ -2090,8 +2112,6 @@ class PlayState extends MusicBeatSubState
   function onNoteMiss(note:NoteSprite):Void
   {
     // a MISS is when you let a note scroll past you!!
-    Highscore.tallies.missed++;
-
     var event:NoteScriptEvent = new NoteScriptEvent(NOTE_MISS, note, Highscore.tallies.combo, true);
     dispatchEvent(event);
     // Calling event.cancelEvent() skips all the other logic! Neat!
@@ -2121,7 +2141,7 @@ class PlayState extends MusicBeatSubState
         {
           inputSpitter.push(
             {
-              t: Std.int(Conductor.songPosition),
+              t: Std.int(Conductor.instance.songPosition),
               d: indices[i],
               l: 20
             });
@@ -2131,7 +2151,7 @@ class PlayState extends MusicBeatSubState
       {
         inputSpitter.push(
           {
-            t: Std.int(Conductor.songPosition),
+            t: Std.int(Conductor.instance.songPosition),
             d: -1,
             l: 20
           });
@@ -2139,8 +2159,11 @@ class PlayState extends MusicBeatSubState
     }
     vocals.playerVolume = 0;
 
+    Highscore.tallies.missed++;
+
     if (Highscore.tallies.combo != 0)
     {
+      // Break the combo.
       Highscore.tallies.combo = comboPopUps.displayCombo(0);
     }
 
@@ -2192,7 +2215,7 @@ class PlayState extends MusicBeatSubState
       {
         inputSpitter.push(
           {
-            t: Std.int(Conductor.songPosition),
+            t: Std.int(Conductor.instance.songPosition),
             d: indices[i],
             l: 20
           });
@@ -2276,39 +2299,63 @@ class PlayState extends MusicBeatSubState
     vocals.playerVolume = 1;
 
     // Calculate the input latency (do this as late as possible).
-    var inputLatencyMs:Float = haxe.Int64.toInt(PreciseInputManager.getCurrentTimestamp() - input.timestamp) / 1000.0 / 1000.0;
-    trace('Input: ${daNote.noteData.getDirectionName()} pressed ${inputLatencyMs}ms ago!');
+    // trace('Compare: ${PreciseInputManager.getCurrentTimestamp()} - ${input.timestamp}');
+    var inputLatencyNs:Int64 = PreciseInputManager.getCurrentTimestamp() - input.timestamp;
+    var inputLatencyMs:Float = inputLatencyNs.toFloat() / Constants.NS_PER_MS;
+    // trace('Input: ${daNote.noteData.getDirectionName()} pressed ${inputLatencyMs}ms ago!');
 
     // Get the offset and compensate for input latency.
     // Round inward (trim remainder) for consistency.
-    var noteDiff:Int = Std.int(Conductor.songPosition - daNote.noteData.time - inputLatencyMs);
+    var noteDiff:Int = Std.int(Conductor.instance.songPosition - daNote.noteData.time - inputLatencyMs);
 
     var score = Scoring.scoreNote(noteDiff, PBOT1);
     var daRating = Scoring.judgeNote(noteDiff, PBOT1);
 
+    if (daRating == 'miss')
+    {
+      // If daRating is 'miss', that means we made a mistake and should not continue.
+      trace('[WARNING] popUpScore judged a note as a miss!');
+      // TODO: Remove this.
+      comboPopUps.displayRating('miss');
+      return;
+    }
+
+    var isComboBreak = false;
     switch (daRating)
     {
-      case 'killer':
-        Highscore.tallies.killer += 1;
-        health += Constants.HEALTH_KILLER_BONUS;
       case 'sick':
         Highscore.tallies.sick += 1;
         health += Constants.HEALTH_SICK_BONUS;
+        isComboBreak = Constants.JUDGEMENT_SICK_COMBO_BREAK;
       case 'good':
         Highscore.tallies.good += 1;
         health += Constants.HEALTH_GOOD_BONUS;
+        isComboBreak = Constants.JUDGEMENT_GOOD_COMBO_BREAK;
       case 'bad':
         Highscore.tallies.bad += 1;
         health += Constants.HEALTH_BAD_BONUS;
+        isComboBreak = Constants.JUDGEMENT_BAD_COMBO_BREAK;
       case 'shit':
         Highscore.tallies.shit += 1;
         health += Constants.HEALTH_SHIT_BONUS;
-      case 'miss':
-        Highscore.tallies.missed += 1;
-        health -= Constants.HEALTH_MISS_PENALTY;
+        isComboBreak = Constants.JUDGEMENT_SHIT_COMBO_BREAK;
     }
 
-    if (daRating == "sick" || daRating == "killer")
+    if (isComboBreak)
+    {
+      // Break the combo, but don't increment tallies.misses.
+      Highscore.tallies.combo = comboPopUps.displayCombo(0);
+    }
+    else
+    {
+      Highscore.tallies.combo++;
+      Highscore.tallies.totalNotesHit++;
+      if (Highscore.tallies.combo > Highscore.tallies.maxCombo) Highscore.tallies.maxCombo = Highscore.tallies.combo;
+    }
+
+    playerStrumline.hitNote(daNote, !isComboBreak);
+
+    if (daRating == "sick")
     {
       playerStrumline.playNoteSplash(daNote.noteData.getDirection());
     }
@@ -2336,7 +2383,7 @@ class PlayState extends MusicBeatSubState
         {
           inputSpitter.push(
             {
-              t: Std.int(Conductor.songPosition),
+              t: Std.int(Conductor.instance.songPosition),
               d: indices[i],
               l: 20
             });
@@ -2346,7 +2393,7 @@ class PlayState extends MusicBeatSubState
       {
         inputSpitter.push(
           {
-            t: Std.int(Conductor.songPosition),
+            t: Std.int(Conductor.instance.songPosition),
             d: -1,
             l: 20
           });
@@ -2444,7 +2491,6 @@ class PlayState extends MusicBeatSubState
           score: songScore,
           tallies:
             {
-              killer: Highscore.tallies.killer,
               sick: Highscore.tallies.sick,
               good: Highscore.tallies.good,
               bad: Highscore.tallies.bad,
@@ -2495,7 +2541,6 @@ class PlayState extends MusicBeatSubState
               tallies:
                 {
                   // TODO: Sum up the values for the whole level!
-                  killer: 0,
                   sick: 0,
                   good: 0,
                   bad: 0,
@@ -2745,15 +2790,15 @@ class PlayState extends MusicBeatSubState
   {
     FlxG.sound.music.pause();
 
-    var targetTimeSteps:Float = Conductor.currentStepTime + (Conductor.timeSignatureNumerator * Constants.STEPS_PER_BEAT * sections);
-    var targetTimeMs:Float = Conductor.getStepTimeInMs(targetTimeSteps);
+    var targetTimeSteps:Float = Conductor.instance.currentStepTime + (Conductor.instance.timeSignatureNumerator * Constants.STEPS_PER_BEAT * sections);
+    var targetTimeMs:Float = Conductor.instance.getStepTimeInMs(targetTimeSteps);
 
     FlxG.sound.music.time = targetTimeMs;
 
     handleSkippedNotes();
     // regenNoteData(FlxG.sound.music.time);
 
-    Conductor.update(FlxG.sound.music.time);
+    Conductor.instance.update(FlxG.sound.music.time);
 
     resyncVocals();
   }
diff --git a/source/funkin/play/character/AnimateAtlasCharacter.hx b/source/funkin/play/character/AnimateAtlasCharacter.hx
index 3523ec994..f9dc18119 100644
--- a/source/funkin/play/character/AnimateAtlasCharacter.hx
+++ b/source/funkin/play/character/AnimateAtlasCharacter.hx
@@ -9,6 +9,7 @@ import flixel.math.FlxMath;
 import flixel.math.FlxPoint.FlxCallbackPoint;
 import flixel.math.FlxPoint;
 import flixel.math.FlxRect;
+import funkin.graphics.FunkinSprite;
 import flixel.system.FlxAssets.FlxGraphicAsset;
 import flixel.util.FlxColor;
 import flixel.util.FlxDestroyUtil;
@@ -621,7 +622,7 @@ class AnimateAtlasCharacter extends BaseCharacter
    * This functionality isn't supported in SpriteGroup
    * @return this sprite group
    */
-  public override function loadGraphicFromSprite(Sprite:FlxSprite):FlxSprite
+  public override function loadGraphicFromSprite(Sprite:FlxSprite):FunkinSprite
   {
     #if FLX_DEBUG
     throw "This function is not supported in FlxSpriteGroup";
diff --git a/source/funkin/play/character/BaseCharacter.hx b/source/funkin/play/character/BaseCharacter.hx
index 7ad0892f6..390864148 100644
--- a/source/funkin/play/character/BaseCharacter.hx
+++ b/source/funkin/play/character/BaseCharacter.hx
@@ -367,7 +367,7 @@ class BaseCharacter extends Bopper
       // This lets you add frames to the end of the sing animation to ease back into the idle!
 
       holdTimer += event.elapsed;
-      var singTimeSec:Float = singTimeSec * (Conductor.beatLengthMs * 0.001); // x beats, to ms.
+      var singTimeSec:Float = singTimeSec * (Conductor.instance.beatLengthMs * 0.001); // x beats, to ms.
 
       if (getCurrentAnimation().endsWith('miss')) singTimeSec *= 2; // makes it feel more awkward when you miss
 
diff --git a/source/funkin/play/character/CharacterData.hx b/source/funkin/play/character/CharacterData.hx
index 16cc8b299..69e3ca48e 100644
--- a/source/funkin/play/character/CharacterData.hx
+++ b/source/funkin/play/character/CharacterData.hx
@@ -43,7 +43,7 @@ class CharacterDataParser
   {
     // Clear any stages that are cached if there were any.
     clearCharacterCache();
-    trace('Loading character cache...');
+    trace('[CHARACTER] Parsing all entries...');
 
     //
     // UNSCRIPTED CHARACTERS
diff --git a/source/funkin/play/character/MultiSparrowCharacter.hx b/source/funkin/play/character/MultiSparrowCharacter.hx
index 0fc07399c..48c5afb58 100644
--- a/source/funkin/play/character/MultiSparrowCharacter.hx
+++ b/source/funkin/play/character/MultiSparrowCharacter.hx
@@ -1,5 +1,6 @@
 package funkin.play.character;
 
+import flixel.graphics.frames.FlxAtlasFrames;
 import flixel.graphics.frames.FlxFramesCollection;
 import funkin.modding.events.ScriptEvent;
 import funkin.util.assets.FlxAnimationUtil;
@@ -7,35 +8,17 @@ import funkin.play.character.CharacterData.CharacterRenderType;
 
 /**
  * For some characters which use Sparrow atlases, the spritesheets need to be split
- * into multiple files. This character renderer handles by showing the appropriate sprite.
+ * into multiple files. This character renderer concatenates these together into a single sprite.
  *
  * Examples in base game include BF Holding GF (most of the sprites are in one file
  * but the death animation is in a separate file).
  * Only example I can think of in mods is Tricky (which has a separate file for each animation).
  *
- * BaseCharacter has game logic, SparrowCharacter has only rendering logic.
+ * BaseCharacter has game logic, MultiSparrowCharacter has only rendering logic.
  * KEEP THEM SEPARATE!
- *
- * TODO: Rewrite this to use a single frame collection.
- * @see https://github.com/HaxeFlixel/flixel/issues/2587#issuecomment-1179620637
  */
 class MultiSparrowCharacter extends BaseCharacter
 {
-  /**
-   * The actual group which holds all spritesheets this character uses.
-   */
-  var members:Map<String, FlxFramesCollection> = new Map<String, FlxFramesCollection>();
-
-  /**
-   * A map between animation names and what frame collection the animation should use.
-   */
-  var animAssetPath:Map<String, String> = new Map<String, String>();
-
-  /**
-   * The current frame collection being used.
-   */
-  var activeMember:String;
-
   public function new(id:String)
   {
     super(id, CharacterRenderType.MultiSparrow);
@@ -51,7 +34,7 @@ class MultiSparrowCharacter extends BaseCharacter
 
   function buildSprites():Void
   {
-    buildSpritesheets();
+    buildSpritesheet();
     buildAnimations();
 
     if (_data.isPixel)
@@ -66,95 +49,49 @@ class MultiSparrowCharacter extends BaseCharacter
     }
   }
 
-  function buildSpritesheets():Void
+  function buildSpritesheet():Void
   {
-    // TODO: This currently works by creating like 5 frame collections and switching between them.
-    // It would be better to refactor this to simply concatenate the frame collections together.
-
-    // Build the list of asset paths to use.
-    // Ignore nulls and duplicates.
-    var assetList = [_data.assetPath];
+    var assetList = [];
     for (anim in _data.animations)
     {
       if (anim.assetPath != null && !assetList.contains(anim.assetPath))
       {
         assetList.push(anim.assetPath);
       }
-      animAssetPath.set(anim.name, anim.assetPath);
     }
 
-    // Load the Sparrow atlas for each path and store them in the members map.
+    var texture:FlxAtlasFrames = Paths.getSparrowAtlas(_data.assetPath, 'shared');
+
+    if (texture == null)
+    {
+      trace('Multi-Sparrow atlas could not load PRIMARY texture: ${_data.assetPath}');
+    }
+    else
+    {
+      trace('Creating multi-sparrow atlas: ${_data.assetPath}');
+      texture.parent.destroyOnNoUse = false;
+    }
+
     for (asset in assetList)
     {
-      var texture:FlxFramesCollection = Paths.getSparrowAtlas(asset, 'shared');
+      var subTexture:FlxAtlasFrames = Paths.getSparrowAtlas(asset, 'shared');
       // If we don't do this, the unused textures will be removed as soon as they're loaded.
 
-      if (texture == null)
+      if (subTexture == null)
       {
-        trace('Multi-Sparrow atlas could not load texture: ${asset}');
+        trace('Multi-Sparrow atlas could not load subtexture: ${asset}');
       }
       else
       {
-        trace('Adding multi-sparrow atlas: ${asset}');
-        texture.parent.destroyOnNoUse = false;
-        members.set(asset, texture);
+        trace('Concatenating multi-sparrow atlas: ${asset}');
+        subTexture.parent.destroyOnNoUse = false;
       }
+
+      texture.addAtlas(subTexture);
     }
 
-    // Use the default frame collection to start.
-    loadFramesByAssetPath(_data.assetPath);
-  }
-
-  /**
-   * Replace this sprite's animation frames with the ones at this asset path.
-   */
-  function loadFramesByAssetPath(assetPath:String):Void
-  {
-    if (_data.assetPath == null)
-    {
-      trace('[ERROR] Multi-Sparrow character has no default asset path!');
-      return;
-    }
-    if (assetPath == null)
-    {
-      // trace('Asset path is null, falling back to default. This is normal!');
-      loadFramesByAssetPath(_data.assetPath);
-      return;
-    }
-
-    if (this.activeMember == assetPath)
-    {
-      // trace('Already using this asset path: ${assetPath}');
-      return;
-    }
-
-    if (members.exists(assetPath))
-    {
-      // Switch to a new set of sprites.
-      // trace('Loading frames from asset path: ${assetPath}');
-      this.frames = members.get(assetPath);
-      this.activeMember = assetPath;
-      this.setScale(_data.scale);
-    }
-    else
-    {
-      trace('[WARN] MultiSparrow character ${characterId} could not find asset path: ${assetPath}');
-    }
-  }
-
-  /**
-   * Replace this sprite's animation frames with the ones needed to play this animation.
-   */
-  function loadFramesByAnimName(animName)
-  {
-    if (animAssetPath.exists(animName))
-    {
-      loadFramesByAssetPath(animAssetPath.get(animName));
-    }
-    else
-    {
-      trace('[WARN] MultiSparrow character ${characterId} could not find animation: ${animName}');
-    }
+    this.frames = texture;
+    this.setScale(_data.scale);
   }
 
   function buildAnimations()
@@ -164,7 +101,6 @@ class MultiSparrowCharacter extends BaseCharacter
     // We need to swap to the proper frame collection before adding the animations, I think?
     for (anim in _data.animations)
     {
-      loadFramesByAnimName(anim.name);
       FlxAnimationUtil.addAtlasAnimation(this, anim);
 
       if (anim.offsets == null)
@@ -187,37 +123,6 @@ class MultiSparrowCharacter extends BaseCharacter
     // unless we're forcing a new animation.
     if (!this.canPlayOtherAnims && !ignoreOther) return;
 
-    loadFramesByAnimName(name);
     super.playAnimation(name, restart, ignoreOther, reverse);
   }
-
-  override function set_frames(value:FlxFramesCollection):FlxFramesCollection
-  {
-    // DISABLE THIS SO WE DON'T DESTROY OUR HARD WORK
-    // WE WILL MAKE SURE TO LOAD THE PROPER SPRITESHEET BEFORE PLAYING AN ANIM
-    // if (animation != null)
-    // {
-    // 	animation.destroyAnimations();
-    // }
-
-    if (value != null)
-    {
-      graphic = value.parent;
-      this.frames = value;
-      this.frame = value.getByIndex(0);
-      // this.numFrames = value.numFrames;
-      resetHelpers();
-      this.bakedRotationAngle = 0;
-      this.animation.frameIndex = 0;
-      graphicLoaded();
-    }
-    else
-    {
-      this.frames = null;
-      this.frame = null;
-      this.graphic = null;
-    }
-
-    return this.frames;
-  }
 }
diff --git a/source/funkin/play/components/ComboMilestone.hx b/source/funkin/play/components/ComboMilestone.hx
index 54d1438f1..4119e45c2 100644
--- a/source/funkin/play/components/ComboMilestone.hx
+++ b/source/funkin/play/components/ComboMilestone.hx
@@ -40,7 +40,7 @@ class ComboMilestone extends FlxTypedSpriteGroup<FlxSprite>
   {
     if (onScreenTime < 0.9)
     {
-      new FlxTimer().start((Conductor.beatLengthMs / 1000) * 0.25, function(tmr) {
+      new FlxTimer().start((Conductor.instance.beatLengthMs / 1000) * 0.25, function(tmr) {
         forceFinish();
       });
     }
diff --git a/source/funkin/play/components/PopUpStuff.hx b/source/funkin/play/components/PopUpStuff.hx
index 38a6ec15a..9553856a9 100644
--- a/source/funkin/play/components/PopUpStuff.hx
+++ b/source/funkin/play/components/PopUpStuff.hx
@@ -59,7 +59,7 @@ class PopUpStuff extends FlxTypedGroup<FlxSprite>
           remove(rating, true);
           rating.destroy();
         },
-        startDelay: Conductor.beatLengthMs * 0.001
+        startDelay: Conductor.instance.beatLengthMs * 0.001
       });
   }
 
@@ -110,7 +110,7 @@ class PopUpStuff extends FlxTypedGroup<FlxSprite>
           remove(comboSpr, true);
           comboSpr.destroy();
         },
-        startDelay: Conductor.beatLengthMs * 0.001
+        startDelay: Conductor.instance.beatLengthMs * 0.001
       });
 
     var seperatedScore:Array<Int> = [];
@@ -157,7 +157,7 @@ class PopUpStuff extends FlxTypedGroup<FlxSprite>
             remove(numScore, true);
             numScore.destroy();
           },
-          startDelay: Conductor.beatLengthMs * 0.002
+          startDelay: Conductor.instance.beatLengthMs * 0.002
         });
 
       daLoop++;
diff --git a/source/funkin/play/cutscene/dialogue/Conversation.hx b/source/funkin/play/cutscene/dialogue/Conversation.hx
index b2361c795..dc3fd8c8a 100644
--- a/source/funkin/play/cutscene/dialogue/Conversation.hx
+++ b/source/funkin/play/cutscene/dialogue/Conversation.hx
@@ -1,8 +1,10 @@
 package funkin.play.cutscene.dialogue;
 
+import funkin.data.IRegistryEntry;
 import flixel.FlxSprite;
 import flixel.group.FlxSpriteGroup;
 import flixel.util.FlxColor;
+import funkin.graphics.FunkinSprite;
 import flixel.tweens.FlxTween;
 import flixel.tweens.FlxEase;
 import flixel.sound.FlxSound;
@@ -13,27 +15,30 @@ import funkin.modding.IScriptedClass.IEventHandler;
 import funkin.play.cutscene.dialogue.DialogueBox;
 import funkin.modding.IScriptedClass.IDialogueScriptedClass;
 import funkin.modding.events.ScriptEventDispatcher;
-import funkin.play.cutscene.dialogue.ConversationData.DialogueEntryData;
 import flixel.addons.display.FlxPieDial;
+import funkin.data.dialogue.ConversationData;
+import funkin.data.dialogue.ConversationData.DialogueEntryData;
+import funkin.data.dialogue.ConversationRegistry;
+import funkin.data.dialogue.SpeakerData;
+import funkin.data.dialogue.SpeakerRegistry;
+import funkin.data.dialogue.DialogueBoxData;
+import funkin.data.dialogue.DialogueBoxRegistry;
 
 /**
  * A high-level handler for dialogue.
  *
  * This shit is great for modders but it's pretty elaborate for how much it'll actually be used, lolol. -Eric
  */
-class Conversation extends FlxSpriteGroup implements IDialogueScriptedClass
+class Conversation extends FlxSpriteGroup implements IDialogueScriptedClass implements IRegistryEntry<ConversationData>
 {
   static final CONVERSATION_SKIP_TIMER:Float = 1.5;
 
   var skipHeldTimer:Float = 0.0;
 
   /**
-   * DATA
+   * The ID of the conversation.
    */
-  /**
-   * The ID of the associated dialogue.
-   */
-  public final conversationId:String;
+  public final id:String;
 
   /**
    * The current state of the conversation.
@@ -41,9 +46,9 @@ class Conversation extends FlxSpriteGroup implements IDialogueScriptedClass
   var state:ConversationState = ConversationState.Start;
 
   /**
-   * The data for the associated dialogue.
+   * Conversation data as parsed from the JSON file.
    */
-  var conversationData:ConversationData;
+  public final _data:ConversationData;
 
   /**
    * The current entry in the dialogue.
@@ -54,7 +59,7 @@ class Conversation extends FlxSpriteGroup implements IDialogueScriptedClass
 
   function get_currentDialogueEntryCount():Int
   {
-    return conversationData.dialogue.length;
+    return _data.dialogue.length;
   }
 
   /**
@@ -73,10 +78,10 @@ class Conversation extends FlxSpriteGroup implements IDialogueScriptedClass
 
   function get_currentDialogueEntryData():DialogueEntryData
   {
-    if (conversationData == null || conversationData.dialogue == null) return null;
-    if (currentDialogueEntry < 0 || currentDialogueEntry >= conversationData.dialogue.length) return null;
+    if (_data == null || _data.dialogue == null) return null;
+    if (currentDialogueEntry < 0 || currentDialogueEntry >= _data.dialogue.length) return null;
 
-    return conversationData.dialogue[currentDialogueEntry];
+    return _data.dialogue[currentDialogueEntry];
   }
 
   var currentDialogueLineString(get, never):String;
@@ -94,7 +99,7 @@ class Conversation extends FlxSpriteGroup implements IDialogueScriptedClass
   /**
    * GRAPHICS
    */
-  var backdrop:FlxSprite;
+  var backdrop:FunkinSprite;
 
   var currentSpeaker:Speaker;
 
@@ -102,14 +107,17 @@ class Conversation extends FlxSpriteGroup implements IDialogueScriptedClass
 
   var skipTimer:FlxPieDial;
 
-  public function new(conversationId:String)
+  public function new(id:String)
   {
     super();
 
-    this.conversationId = conversationId;
-    this.conversationData = ConversationDataParser.parseConversationData(this.conversationId);
+    this.id = id;
+    this._data = _fetchData(id);
 
-    if (conversationData == null) throw 'Could not load conversation data for conversation ID "$conversationId"';
+    if (_data == null)
+    {
+      throw 'Could not parse conversation data for id: $id';
+    }
   }
 
   public function onCreate(event:ScriptEvent):Void
@@ -125,14 +133,14 @@ class Conversation extends FlxSpriteGroup implements IDialogueScriptedClass
 
   function setupMusic():Void
   {
-    if (conversationData.music == null) return;
+    if (_data.music == null) return;
 
-    music = new FlxSound().loadEmbedded(Paths.music(conversationData.music.asset), true, true);
+    music = new FlxSound().loadEmbedded(Paths.music(_data.music.asset), true, true);
     music.volume = 0;
 
-    if (conversationData.music.fadeTime > 0.0)
+    if (_data.music.fadeTime > 0.0)
     {
-      FlxTween.tween(music, {volume: 1.0}, conversationData.music.fadeTime, {ease: FlxEase.linear});
+      FlxTween.tween(music, {volume: 1.0}, _data.music.fadeTime, {ease: FlxEase.linear});
     }
     else
     {
@@ -145,19 +153,20 @@ class Conversation extends FlxSpriteGroup implements IDialogueScriptedClass
 
   function setupBackdrop():Void
   {
-    backdrop = new FlxSprite(0, 0);
+    backdrop = new FunkinSprite(0, 0);
 
-    if (conversationData.backdrop == null) return;
+    if (_data.backdrop == null) return;
 
     // Play intro
-    switch (conversationData?.backdrop.type)
+    switch (_data.backdrop)
     {
-      case SOLID:
-        backdrop.makeGraphic(Std.int(FlxG.width), Std.int(FlxG.height), FlxColor.fromString(conversationData.backdrop.data.color));
-        if (conversationData.backdrop.data.fadeTime > 0.0)
+      case SOLID(backdropData):
+        var targetColor:FlxColor = FlxColor.fromString(backdropData.color);
+        backdrop.makeSolidColor(Std.int(FlxG.width), Std.int(FlxG.height), targetColor);
+        if (backdropData.fadeTime > 0.0)
         {
           backdrop.alpha = 0.0;
-          FlxTween.tween(backdrop, {alpha: 1.0}, conversationData.backdrop.data.fadeTime, {ease: FlxEase.linear});
+          FlxTween.tween(backdrop, {alpha: 1.0}, backdropData.fadeTime, {ease: FlxEase.linear});
         }
         else
         {
@@ -190,9 +199,9 @@ class Conversation extends FlxSpriteGroup implements IDialogueScriptedClass
     var nextSpeakerId:String = currentDialogueEntryData.speaker;
 
     // Skip the next steps if the current speaker is already displayed.
-    if (currentSpeaker != null && nextSpeakerId == currentSpeaker.speakerId) return;
+    if (currentSpeaker != null && nextSpeakerId == currentSpeaker.id) return;
 
-    var nextSpeaker:Speaker = SpeakerDataParser.fetchSpeaker(nextSpeakerId);
+    var nextSpeaker:Speaker = SpeakerRegistry.instance.fetchEntry(nextSpeakerId);
 
     if (currentSpeaker != null)
     {
@@ -241,7 +250,7 @@ class Conversation extends FlxSpriteGroup implements IDialogueScriptedClass
     var nextDialogueBoxId:String = currentDialogueEntryData?.box;
 
     // Skip the next steps if the current speaker is already displayed.
-    if (currentDialogueBox != null && nextDialogueBoxId == currentDialogueBox.dialogueBoxId) return;
+    if (currentDialogueBox != null && nextDialogueBoxId == currentDialogueBox.id) return;
 
     if (currentDialogueBox != null)
     {
@@ -250,7 +259,7 @@ class Conversation extends FlxSpriteGroup implements IDialogueScriptedClass
       currentDialogueBox = null;
     }
 
-    var nextDialogueBox:DialogueBox = DialogueBoxDataParser.fetchDialogueBox(nextDialogueBoxId);
+    var nextDialogueBox:DialogueBox = DialogueBoxRegistry.instance.fetchEntry(nextDialogueBoxId);
 
     if (nextDialogueBox == null)
     {
@@ -378,20 +387,18 @@ class Conversation extends FlxSpriteGroup implements IDialogueScriptedClass
 
   public function startOutro():Void
   {
-    switch (conversationData?.outro?.type)
+    switch (_data?.outro)
     {
-      case FADE:
-        var fadeTime:Float = conversationData?.outro.data.fadeTime ?? 1.0;
-
-        outroTween = FlxTween.tween(this, {alpha: 0.0}, fadeTime,
+      case FADE(outroData):
+        outroTween = FlxTween.tween(this, {alpha: 0.0}, outroData.fadeTime,
           {
             type: ONESHOT, // holy shit like the game no way
             startDelay: 0,
             onComplete: (_) -> endOutro(),
           });
 
-        FlxTween.tween(this.music, {volume: 0.0}, fadeTime);
-      case NONE:
+        FlxTween.tween(this.music, {volume: 0.0}, outroData.fadeTime);
+      case NONE(_):
         // Immediately clean up.
         endOutro();
       default:
@@ -400,7 +407,7 @@ class Conversation extends FlxSpriteGroup implements IDialogueScriptedClass
     }
   }
 
-  public var completeCallback:Void->Void;
+  public var completeCallback:() -> Void;
 
   public function endOutro():Void
   {
@@ -596,7 +603,12 @@ class Conversation extends FlxSpriteGroup implements IDialogueScriptedClass
 
   public override function toString():String
   {
-    return 'Conversation($conversationId)';
+    return 'Conversation($id)';
+  }
+
+  static function _fetchData(id:String):Null<ConversationData>
+  {
+    return ConversationRegistry.instance.parseEntryDataWithMigration(id, ConversationRegistry.instance.fetchEntryVersion(id));
   }
 }
 
diff --git a/source/funkin/play/cutscene/dialogue/ConversationData.hx b/source/funkin/play/cutscene/dialogue/ConversationData.hx
deleted file mode 100644
index 8c4aa9684..000000000
--- a/source/funkin/play/cutscene/dialogue/ConversationData.hx
+++ /dev/null
@@ -1,240 +0,0 @@
-package funkin.play.cutscene.dialogue;
-
-import funkin.util.SerializerUtil;
-
-/**
- * Data about a conversation.
- * Includes what speakers are in the conversation, and what phrases they say.
- */
-@:jsonParse(j -> funkin.play.cutscene.dialogue.ConversationData.fromJson(j))
-@:jsonStringify(v -> v.toJson())
-class ConversationData
-{
-  public var version:String;
-  public var backdrop:BackdropData;
-  public var outro:OutroData;
-  public var music:MusicData;
-  public var dialogue:Array<DialogueEntryData>;
-
-  public function new(version:String, backdrop:BackdropData, outro:OutroData, music:MusicData, dialogue:Array<DialogueEntryData>)
-  {
-    this.version = version;
-    this.backdrop = backdrop;
-    this.outro = outro;
-    this.music = music;
-    this.dialogue = dialogue;
-  }
-
-  public static function fromString(i:String):ConversationData
-  {
-    if (i == null || i == '') return null;
-    var data:
-      {
-        version:String,
-        backdrop:Dynamic, // TODO: tink.Json doesn't like when these are typed
-        ?outro:Dynamic, // TODO: tink.Json doesn't like when these are typed
-        ?music:Dynamic, // TODO: tink.Json doesn't like when these are typed
-        dialogue:Array<Dynamic> // TODO: tink.Json doesn't like when these are typed
-      } = tink.Json.parse(i);
-    return fromJson(data);
-  }
-
-  public static function fromJson(j:Dynamic):ConversationData
-  {
-    // TODO: Check version and perform migrations if necessary.
-    if (j == null) return null;
-    return new ConversationData(j.version, BackdropData.fromJson(j.backdrop), OutroData.fromJson(j.outro), MusicData.fromJson(j.music),
-      j.dialogue.map(d -> DialogueEntryData.fromJson(d)));
-  }
-
-  public function toJson():Dynamic
-  {
-    return {
-      version: this.version,
-      backdrop: this.backdrop.toJson(),
-      dialogue: this.dialogue.map(d -> d.toJson())
-    };
-  }
-}
-
-/**
- * Data about a single dialogue entry.
- */
-@:jsonParse(j -> funkin.play.cutscene.dialogue.ConversationData.DialogueEntryData.fromJson(j))
-@:jsonStringify(v -> v.toJson())
-class DialogueEntryData
-{
-  /**
-   * The speaker who says this phrase.
-   */
-  public var speaker:String;
-
-  /**
-   * The animation the speaker will play.
-   */
-  public var speakerAnimation:String;
-
-  /**
-   * The text box that will appear.
-   */
-  public var box:String;
-
-  /**
-   * The animation the dialogue box will play.
-   */
-  public var boxAnimation:String;
-
-  /**
-   * The lines of text that will appear in the text box.
-   */
-  public var text:Array<String>;
-
-  /**
-   * The relative speed at which the text will scroll.
-   * @default 1.0
-   */
-  public var speed:Float = 1.0;
-
-  public function new(speaker:String, speakerAnimation:String, box:String, boxAnimation:String, text:Array<String>, speed:Float = null)
-  {
-    this.speaker = speaker;
-    this.speakerAnimation = speakerAnimation;
-    this.box = box;
-    this.boxAnimation = boxAnimation;
-    this.text = text;
-    if (speed != null) this.speed = speed;
-  }
-
-  public static function fromJson(j:Dynamic):DialogueEntryData
-  {
-    if (j == null) return null;
-    return new DialogueEntryData(j.speaker, j.speakerAnimation, j.box, j.boxAnimation, j.text, j.speed);
-  }
-
-  public function toJson():Dynamic
-  {
-    var result:Dynamic =
-      {
-        speaker: this.speaker,
-        speakerAnimation: this.speakerAnimation,
-        box: this.box,
-        boxAnimation: this.boxAnimation,
-        text: this.text,
-      };
-
-    if (this.speed != 1.0) result.speed = this.speed;
-
-    return result;
-  }
-}
-
-/**
- * Data about a backdrop.
- */
-@:jsonParse(j -> funkin.play.cutscene.dialogue.ConversationData.BackdropData.fromJson(j))
-@:jsonStringify(v -> v.toJson())
-class BackdropData
-{
-  public var type:BackdropType;
-  public var data:Dynamic;
-
-  public function new(typeStr:String, data:Dynamic)
-  {
-    this.type = typeStr;
-    this.data = data;
-  }
-
-  public static function fromJson(j:Dynamic):BackdropData
-  {
-    if (j == null) return null;
-    return new BackdropData(j.type, j.data);
-  }
-
-  public function toJson():Dynamic
-  {
-    return {
-      type: this.type,
-      data: this.data
-    };
-  }
-}
-
-enum abstract BackdropType(String) from String to String
-{
-  public var SOLID:BackdropType = 'solid';
-}
-
-/**
- * Data about a music track.
- */
-@:jsonParse(j -> funkin.play.cutscene.dialogue.ConversationData.MusicData.fromJson(j))
-@:jsonStringify(v -> v.toJson())
-class MusicData
-{
-  public var asset:String;
-
-  public var fadeTime:Float;
-
-  @:optional
-  @:default(false)
-  public var looped:Bool;
-
-  public function new(asset:String, looped:Bool, fadeTime:Float = 0.0)
-  {
-    this.asset = asset;
-    this.looped = looped;
-    this.fadeTime = fadeTime;
-  }
-
-  public static function fromJson(j:Dynamic):MusicData
-  {
-    if (j == null) return null;
-    return new MusicData(j.asset, j.looped, j.fadeTime);
-  }
-
-  public function toJson():Dynamic
-  {
-    return {
-      asset: this.asset,
-      looped: this.looped,
-      fadeTime: this.fadeTime
-    };
-  }
-}
-
-/**
- * Data about an outro.
- */
-@:jsonParse(j -> funkin.play.cutscene.dialogue.ConversationData.OutroData.fromJson(j))
-@:jsonStringify(v -> v.toJson())
-class OutroData
-{
-  public var type:OutroType;
-  public var data:Dynamic;
-
-  public function new(?typeStr:String, data:Dynamic)
-  {
-    this.type = typeStr ?? OutroType.NONE;
-    this.data = data;
-  }
-
-  public static function fromJson(j:Dynamic):OutroData
-  {
-    if (j == null) return null;
-    return new OutroData(j.type, j.data);
-  }
-
-  public function toJson():Dynamic
-  {
-    return {
-      type: this.type,
-      data: this.data
-    };
-  }
-}
-
-enum abstract OutroType(String) from String to String
-{
-  public var NONE:OutroType = 'none';
-  public var FADE:OutroType = 'fade';
-}
diff --git a/source/funkin/play/cutscene/dialogue/ConversationDataParser.hx b/source/funkin/play/cutscene/dialogue/ConversationDataParser.hx
deleted file mode 100644
index 9f80f8f9b..000000000
--- a/source/funkin/play/cutscene/dialogue/ConversationDataParser.hx
+++ /dev/null
@@ -1,163 +0,0 @@
-package funkin.play.cutscene.dialogue;
-
-import openfl.Assets;
-import funkin.util.assets.DataAssets;
-import funkin.play.cutscene.dialogue.ScriptedConversation;
-
-/**
- * Contains utilities for loading and parsing conversation data.
- * TODO: Refactor to use the json2object + BaseRegistry system that actually validates things for you.
- */
-class ConversationDataParser
-{
-  public static final CONVERSATION_DATA_VERSION:String = '1.0.0';
-  public static final CONVERSATION_DATA_VERSION_RULE:String = '1.0.x';
-
-  static final conversationCache:Map<String, Conversation> = new Map<String, Conversation>();
-  static final conversationScriptedClass:Map<String, String> = new Map<String, String>();
-
-  static final DEFAULT_CONVERSATION_ID:String = 'UNKNOWN';
-
-  /**
-   * Parses and preloads the game's conversation data and scripts when the game starts.
-   *
-   * If you want to force conversations to be reloaded, you can just call this function again.
-   */
-  public static function loadConversationCache():Void
-  {
-    clearConversationCache();
-    trace('Loading dialogue conversation cache...');
-
-    //
-    // SCRIPTED CONVERSATIONS
-    //
-    var scriptedConversationClassNames:Array<String> = ScriptedConversation.listScriptClasses();
-    trace('  Instantiating ${scriptedConversationClassNames.length} scripted conversations...');
-    for (conversationCls in scriptedConversationClassNames)
-    {
-      var conversation:Conversation = ScriptedConversation.init(conversationCls, DEFAULT_CONVERSATION_ID);
-      if (conversation != null)
-      {
-        trace('    Loaded scripted conversation: ${conversationCls}');
-        // Disable the rendering logic for conversation until it's loaded.
-        // Note that kill() =/= destroy()
-        conversation.kill();
-
-        // Then store it.
-        conversationCache.set(conversation.conversationId, conversation);
-      }
-      else
-      {
-        trace('    Failed to instantiate scripted conversation class: ${conversationCls}');
-      }
-    }
-
-    //
-    // UNSCRIPTED CONVERSATIONS
-    //
-    // Scripts refers to code here, not the actual dialogue.
-    var conversationIdList:Array<String> = DataAssets.listDataFilesInPath('dialogue/conversations/');
-    // Filter out conversations that are scripted.
-    var unscriptedConversationIds:Array<String> = conversationIdList.filter(function(conversationId:String):Bool {
-      return !conversationCache.exists(conversationId);
-    });
-    trace('  Fetching data for ${unscriptedConversationIds.length} conversations...');
-    for (conversationId in unscriptedConversationIds)
-    {
-      try
-      {
-        var conversation:Conversation = new Conversation(conversationId);
-        // Say something offensive to kill the conversation.
-        // We will revive it later.
-        conversation.kill();
-        if (conversation != null)
-        {
-          trace('    Loaded conversation data: ${conversation.conversationId}');
-          conversationCache.set(conversation.conversationId, conversation);
-        }
-      }
-      catch (e)
-      {
-        trace(e);
-        continue;
-      }
-    }
-  }
-
-  /**
-   * Fetches data for a conversation and returns a Conversation instance,
-   * ready to be displayed.
-   * @param conversationId The ID of the conversation to fetch.
-   * @return The conversation instance, or null if the conversation was not found.
-   */
-  public static function fetchConversation(conversationId:String):Null<Conversation>
-  {
-    if (conversationId != null && conversationId != '' && conversationCache.exists(conversationId))
-    {
-      trace('Successfully fetched conversation: ${conversationId}');
-      var conversation:Conversation = conversationCache.get(conversationId);
-      // ...ANYway...
-      conversation.revive();
-      return conversation;
-    }
-    else
-    {
-      trace('Failed to fetch conversation, not found in cache: ${conversationId}');
-      return null;
-    }
-  }
-
-  static function clearConversationCache():Void
-  {
-    if (conversationCache != null)
-    {
-      for (conversation in conversationCache)
-      {
-        conversation.destroy();
-      }
-      conversationCache.clear();
-    }
-  }
-
-  public static function listConversationIds():Array<String>
-  {
-    return conversationCache.keys().array();
-  }
-
-  /**
-   * Load a conversation's JSON file, parse its data, and return it.
-   *
-   * @param conversationId The conversation to load.
-   * @return The conversation data, or null if validation failed.
-   */
-  public static function parseConversationData(conversationId:String):Null<ConversationData>
-  {
-    trace('Parsing conversation data: ${conversationId}');
-    var rawJson:String = loadConversationFile(conversationId);
-
-    try
-    {
-      var conversationData:ConversationData = ConversationData.fromString(rawJson);
-      return conversationData;
-    }
-    catch (e)
-    {
-      trace('Failed to parse conversation ($conversationId).');
-      trace(e);
-      return null;
-    }
-  }
-
-  static function loadConversationFile(conversationPath:String):String
-  {
-    var conversationFilePath:String = Paths.json('dialogue/conversations/${conversationPath}');
-    var rawJson:String = Assets.getText(conversationFilePath).trim();
-
-    while (!rawJson.endsWith('}') && rawJson.length > 0)
-    {
-      rawJson = rawJson.substr(0, rawJson.length - 1);
-    }
-
-    return rawJson;
-  }
-}
diff --git a/source/funkin/play/cutscene/dialogue/DialogueBox.hx b/source/funkin/play/cutscene/dialogue/DialogueBox.hx
index cdac3c233..6f8a0086a 100644
--- a/source/funkin/play/cutscene/dialogue/DialogueBox.hx
+++ b/source/funkin/play/cutscene/dialogue/DialogueBox.hx
@@ -1,6 +1,7 @@
 package funkin.play.cutscene.dialogue;
 
 import flixel.FlxSprite;
+import funkin.data.IRegistryEntry;
 import flixel.group.FlxSpriteGroup;
 import flixel.graphics.frames.FlxFramesCollection;
 import flixel.text.FlxText;
@@ -9,18 +10,21 @@ import funkin.util.assets.FlxAnimationUtil;
 import funkin.modding.events.ScriptEvent;
 import funkin.modding.IScriptedClass.IDialogueScriptedClass;
 import flixel.util.FlxColor;
+import funkin.data.dialogue.DialogueBoxData;
+import funkin.data.dialogue.DialogueBoxRegistry;
 
-class DialogueBox extends FlxSpriteGroup implements IDialogueScriptedClass
+class DialogueBox extends FlxSpriteGroup implements IDialogueScriptedClass implements IRegistryEntry<DialogueBoxData>
 {
-  public final dialogueBoxId:String;
+  public final id:String;
+
   public var dialogueBoxName(get, never):String;
 
   function get_dialogueBoxName():String
   {
-    return boxData?.name ?? 'UNKNOWN';
+    return _data.name ?? 'UNKNOWN';
   }
 
-  var boxData:DialogueBoxData;
+  public final _data:DialogueBoxData;
 
   /**
    * Offset the speaker's sprite by this much when playing each animation.
@@ -88,13 +92,16 @@ class DialogueBox extends FlxSpriteGroup implements IDialogueScriptedClass
     return this.speed;
   }
 
-  public function new(dialogueBoxId:String)
+  public function new(id:String)
   {
     super();
-    this.dialogueBoxId = dialogueBoxId;
-    this.boxData = DialogueBoxDataParser.parseDialogueBoxData(this.dialogueBoxId);
+    this.id = id;
+    this._data = _fetchData(id);
 
-    if (boxData == null) throw 'Could not load dialogue box data for box ID "$dialogueBoxId"';
+    if (_data == null)
+    {
+      throw 'Could not parse dialogue box data for id: $id';
+    }
   }
 
   public function onCreate(event:ScriptEvent):Void
@@ -115,18 +122,18 @@ class DialogueBox extends FlxSpriteGroup implements IDialogueScriptedClass
 
   function loadSpritesheet():Void
   {
-    trace('[DIALOGUE BOX] Loading spritesheet ${boxData.assetPath} for ${dialogueBoxId}');
+    trace('[DIALOGUE BOX] Loading spritesheet ${_data.assetPath} for ${id}');
 
-    var tex:FlxFramesCollection = Paths.getSparrowAtlas(boxData.assetPath);
+    var tex:FlxFramesCollection = Paths.getSparrowAtlas(_data.assetPath);
     if (tex == null)
     {
-      trace('Could not load Sparrow sprite: ${boxData.assetPath}');
+      trace('Could not load Sparrow sprite: ${_data.assetPath}');
       return;
     }
 
     this.boxSprite.frames = tex;
 
-    if (boxData.isPixel)
+    if (_data.isPixel)
     {
       this.boxSprite.antialiasing = false;
     }
@@ -135,9 +142,10 @@ class DialogueBox extends FlxSpriteGroup implements IDialogueScriptedClass
       this.boxSprite.antialiasing = true;
     }
 
-    this.flipX = boxData.flipX;
-    this.globalOffsets = boxData.offsets;
-    this.setScale(boxData.scale);
+    this.flipX = _data.flipX;
+    this.flipY = _data.flipY;
+    this.globalOffsets = _data.offsets;
+    this.setScale(_data.scale);
   }
 
   public function setText(newText:String):Void
@@ -184,11 +192,11 @@ class DialogueBox extends FlxSpriteGroup implements IDialogueScriptedClass
 
   function loadAnimations():Void
   {
-    trace('[DIALOGUE BOX] Loading ${boxData.animations.length} animations for ${dialogueBoxId}');
+    trace('[DIALOGUE BOX] Loading ${_data.animations.length} animations for ${id}');
 
-    FlxAnimationUtil.addAtlasAnimations(this.boxSprite, boxData.animations);
+    FlxAnimationUtil.addAtlasAnimations(this.boxSprite, _data.animations);
 
-    for (anim in boxData.animations)
+    for (anim in _data.animations)
     {
       if (anim.offsets == null)
       {
@@ -201,7 +209,7 @@ class DialogueBox extends FlxSpriteGroup implements IDialogueScriptedClass
     }
 
     var animNames:Array<String> = this.boxSprite?.animation?.getNameList() ?? [];
-    trace('[DIALOGUE BOX] Successfully loaded ${animNames.length} animations for ${dialogueBoxId}');
+    trace('[DIALOGUE BOX] Successfully loaded ${animNames.length} animations for ${id}');
 
     boxSprite.animation.callback = this.onAnimationFrame;
     boxSprite.animation.finishCallback = this.onAnimationFinished;
@@ -234,16 +242,16 @@ class DialogueBox extends FlxSpriteGroup implements IDialogueScriptedClass
   function loadText():Void
   {
     textDisplay = new FlxTypeText(0, 0, 300, '', 32);
-    textDisplay.fieldWidth = boxData.text.width;
-    textDisplay.setFormat('Pixel Arial 11 Bold', boxData.text.size, FlxColor.fromString(boxData.text.color), LEFT, SHADOW,
-      FlxColor.fromString(boxData.text.shadowColor ?? '#00000000'), false);
-    textDisplay.borderSize = boxData.text.shadowWidth ?? 2;
+    textDisplay.fieldWidth = _data.text.width;
+    textDisplay.setFormat(_data.text.fontFamily, _data.text.size, FlxColor.fromString(_data.text.color), LEFT, SHADOW,
+      FlxColor.fromString(_data.text.shadowColor ?? '#00000000'), false);
+    textDisplay.borderSize = _data.text.shadowWidth ?? 2;
     textDisplay.sounds = [FlxG.sound.load(Paths.sound('pixelText'), 0.6)];
 
     textDisplay.completeCallback = onTypingComplete;
 
-    textDisplay.x += boxData.text.offsets[0];
-    textDisplay.y += boxData.text.offsets[1];
+    textDisplay.x += _data.text.offsets[0];
+    textDisplay.y += _data.text.offsets[1];
 
     add(textDisplay);
   }
@@ -374,4 +382,14 @@ class DialogueBox extends FlxSpriteGroup implements IDialogueScriptedClass
   }
 
   public function onScriptEvent(event:ScriptEvent):Void {}
+
+  public override function toString():String
+  {
+    return 'DialogueBox($id)';
+  }
+
+  static function _fetchData(id:String):Null<DialogueBoxData>
+  {
+    return DialogueBoxRegistry.instance.parseEntryDataWithMigration(id, DialogueBoxRegistry.instance.fetchEntryVersion(id));
+  }
 }
diff --git a/source/funkin/play/cutscene/dialogue/DialogueBoxData.hx b/source/funkin/play/cutscene/dialogue/DialogueBoxData.hx
deleted file mode 100644
index 801a01dd7..000000000
--- a/source/funkin/play/cutscene/dialogue/DialogueBoxData.hx
+++ /dev/null
@@ -1,124 +0,0 @@
-package funkin.play.cutscene.dialogue;
-
-import funkin.data.animation.AnimationData;
-import funkin.util.SerializerUtil;
-
-/**
- * Data about a text box.
- */
-@:jsonParse(j -> funkin.play.cutscene.dialogue.DialogueBoxData.fromJson(j))
-@:jsonStringify(v -> v.toJson())
-class DialogueBoxData
-{
-  public var version:String;
-  public var name:String;
-  public var assetPath:String;
-  public var flipX:Bool;
-  public var flipY:Bool;
-  public var isPixel:Bool;
-  public var offsets:Array<Float>;
-  public var text:DialogueBoxTextData;
-  public var scale:Float;
-  public var animations:Array<AnimationData>;
-
-  public function new(version:String, name:String, assetPath:String, flipX:Bool = false, flipY:Bool = false, isPixel:Bool = false, offsets:Null<Array<Float>>,
-      text:DialogueBoxTextData, scale:Float = 1.0, animations:Array<AnimationData>)
-  {
-    this.version = version;
-    this.name = name;
-    this.assetPath = assetPath;
-    this.flipX = flipX;
-    this.flipY = flipY;
-    this.isPixel = isPixel;
-    this.offsets = offsets ?? [0, 0];
-    this.text = text;
-    this.scale = scale;
-    this.animations = animations;
-  }
-
-  public static function fromString(i:String):DialogueBoxData
-  {
-    if (i == null || i == '') return null;
-    var data:
-      {
-        version:String,
-        name:String,
-        assetPath:String,
-        flipX:Bool,
-        flipY:Bool,
-        isPixel:Bool,
-        ?offsets:Array<Float>,
-        text:Dynamic,
-        scale:Float,
-        animations:Array<AnimationData>
-      } = tink.Json.parse(i);
-    return fromJson(data);
-  }
-
-  public static function fromJson(j:Dynamic):DialogueBoxData
-  {
-    // TODO: Check version and perform migrations if necessary.
-    if (j == null) return null;
-    return new DialogueBoxData(j.version, j.name, j.assetPath, j.flipX, j.flipY, j.isPixel, j.offsets, DialogueBoxTextData.fromJson(j.text), j.scale,
-      j.animations);
-  }
-
-  public function toJson():Dynamic
-  {
-    return {
-      version: this.version,
-      name: this.name,
-      assetPath: this.assetPath,
-      flipX: this.flipX,
-      flipY: this.flipY,
-      isPixel: this.isPixel,
-      offsets: this.offsets,
-      scale: this.scale,
-      animations: this.animations
-    };
-  }
-}
-
-/**
- * Data about text in a text box.
- */
-@:jsonParse(j -> funkin.play.cutscene.dialogue.DialogueBoxTextData.fromJson(j))
-@:jsonStringify(v -> v.toJson())
-class DialogueBoxTextData
-{
-  public var offsets:Array<Float>;
-  public var width:Int;
-  public var size:Int;
-  public var color:String;
-  public var shadowColor:Null<String>;
-  public var shadowWidth:Null<Int>;
-
-  public function new(offsets:Null<Array<Float>>, ?width:Int, ?size:Int, color:String, ?shadowColor:String, shadowWidth:Null<Int>)
-  {
-    this.offsets = offsets ?? [0, 0];
-    this.width = width ?? 300;
-    this.size = size ?? 32;
-    this.color = color;
-    this.shadowColor = shadowColor;
-    this.shadowWidth = shadowWidth;
-  }
-
-  public static function fromJson(j:Dynamic):DialogueBoxTextData
-  {
-    // TODO: Check version and perform migrations if necessary.
-    if (j == null) return null;
-    return new DialogueBoxTextData(j.offsets, j.width, j.size, j.color, j.shadowColor, j.shadowWidth);
-  }
-
-  public function toJson():Dynamic
-  {
-    return {
-      offsets: this.offsets,
-      width: this.width,
-      size: this.size,
-      color: this.color,
-      shadowColor: this.shadowColor,
-      shadowWidth: this.shadowWidth,
-    };
-  }
-}
diff --git a/source/funkin/play/cutscene/dialogue/DialogueBoxDataParser.hx b/source/funkin/play/cutscene/dialogue/DialogueBoxDataParser.hx
deleted file mode 100644
index cb00dd80d..000000000
--- a/source/funkin/play/cutscene/dialogue/DialogueBoxDataParser.hx
+++ /dev/null
@@ -1,159 +0,0 @@
-package funkin.play.cutscene.dialogue;
-
-import openfl.Assets;
-import funkin.util.assets.DataAssets;
-import funkin.play.cutscene.dialogue.DialogueBox;
-import funkin.play.cutscene.dialogue.ScriptedDialogueBox;
-
-/**
- * Contains utilities for loading and parsing dialogueBox data.
- */
-class DialogueBoxDataParser
-{
-  public static final DIALOGUE_BOX_DATA_VERSION:String = '1.0.0';
-  public static final DIALOGUE_BOX_DATA_VERSION_RULE:String = '1.0.x';
-
-  static final dialogueBoxCache:Map<String, DialogueBox> = new Map<String, DialogueBox>();
-
-  static final dialogueBoxScriptedClass:Map<String, String> = new Map<String, String>();
-
-  static final DEFAULT_DIALOGUE_BOX_ID:String = 'UNKNOWN';
-
-  /**
-   * Parses and preloads the game's dialogueBox data and scripts when the game starts.
-   *
-   * If you want to force dialogue boxes to be reloaded, you can just call this function again.
-   */
-  public static function loadDialogueBoxCache():Void
-  {
-    clearDialogueBoxCache();
-    trace('Loading dialogue box cache...');
-
-    //
-    // SCRIPTED CONVERSATIONS
-    //
-    var scriptedDialogueBoxClassNames:Array<String> = ScriptedDialogueBox.listScriptClasses();
-    trace('  Instantiating ${scriptedDialogueBoxClassNames.length} scripted dialogue boxes...');
-    for (dialogueBoxCls in scriptedDialogueBoxClassNames)
-    {
-      var dialogueBox:DialogueBox = ScriptedDialogueBox.init(dialogueBoxCls, DEFAULT_DIALOGUE_BOX_ID);
-      if (dialogueBox != null)
-      {
-        trace('    Loaded scripted dialogue box: ${dialogueBox.dialogueBoxName}');
-        // Disable the rendering logic for dialogueBox until it's loaded.
-        // Note that kill() =/= destroy()
-        dialogueBox.kill();
-
-        // Then store it.
-        dialogueBoxCache.set(dialogueBox.dialogueBoxId, dialogueBox);
-      }
-      else
-      {
-        trace('    Failed to instantiate scripted dialogueBox class: ${dialogueBoxCls}');
-      }
-    }
-
-    //
-    // UNSCRIPTED CONVERSATIONS
-    //
-    // Scripts refers to code here, not the actual dialogue.
-    var dialogueBoxIdList:Array<String> = DataAssets.listDataFilesInPath('dialogue/boxes/');
-    // Filter out dialogue boxes that are scripted.
-    var unscriptedDialogueBoxIds:Array<String> = dialogueBoxIdList.filter(function(dialogueBoxId:String):Bool {
-      return !dialogueBoxCache.exists(dialogueBoxId);
-    });
-    trace('  Fetching data for ${unscriptedDialogueBoxIds.length} dialogue boxes...');
-    for (dialogueBoxId in unscriptedDialogueBoxIds)
-    {
-      try
-      {
-        var dialogueBox:DialogueBox = new DialogueBox(dialogueBoxId);
-        if (dialogueBox != null)
-        {
-          trace('    Loaded dialogueBox data: ${dialogueBox.dialogueBoxName}');
-          dialogueBoxCache.set(dialogueBox.dialogueBoxId, dialogueBox);
-        }
-      }
-      catch (e)
-      {
-        trace(e);
-        continue;
-      }
-    }
-  }
-
-  /**
-   * Fetches data for a dialogueBox and returns a DialogueBox instance,
-   * ready to be displayed.
-   * @param dialogueBoxId The ID of the dialogueBox to fetch.
-   * @return The dialogueBox instance, or null if the dialogueBox was not found.
-   */
-  public static function fetchDialogueBox(dialogueBoxId:String):Null<DialogueBox>
-  {
-    if (dialogueBoxId != null && dialogueBoxId != '' && dialogueBoxCache.exists(dialogueBoxId))
-    {
-      trace('Successfully fetched dialogueBox: ${dialogueBoxId}');
-      var dialogueBox:DialogueBox = dialogueBoxCache.get(dialogueBoxId);
-      dialogueBox.revive();
-      return dialogueBox;
-    }
-    else
-    {
-      trace('Failed to fetch dialogueBox, not found in cache: ${dialogueBoxId}');
-      return null;
-    }
-  }
-
-  static function clearDialogueBoxCache():Void
-  {
-    if (dialogueBoxCache != null)
-    {
-      for (dialogueBox in dialogueBoxCache)
-      {
-        dialogueBox.destroy();
-      }
-      dialogueBoxCache.clear();
-    }
-  }
-
-  public static function listDialogueBoxIds():Array<String>
-  {
-    return dialogueBoxCache.keys().array();
-  }
-
-  /**
-   * Load a dialogueBox's JSON file, parse its data, and return it.
-   *
-   * @param dialogueBoxId The dialogueBox to load.
-   * @return The dialogueBox data, or null if validation failed.
-   */
-  public static function parseDialogueBoxData(dialogueBoxId:String):Null<DialogueBoxData>
-  {
-    var rawJson:String = loadDialogueBoxFile(dialogueBoxId);
-
-    try
-    {
-      var dialogueBoxData:DialogueBoxData = DialogueBoxData.fromString(rawJson);
-      return dialogueBoxData;
-    }
-    catch (e)
-    {
-      trace('Failed to parse dialogueBox ($dialogueBoxId).');
-      trace(e);
-      return null;
-    }
-  }
-
-  static function loadDialogueBoxFile(dialogueBoxPath:String):String
-  {
-    var dialogueBoxFilePath:String = Paths.json('dialogue/boxes/${dialogueBoxPath}');
-    var rawJson:String = Assets.getText(dialogueBoxFilePath).trim();
-
-    while (!rawJson.endsWith('}') && rawJson.length > 0)
-    {
-      rawJson = rawJson.substr(0, rawJson.length - 1);
-    }
-
-    return rawJson;
-  }
-}
diff --git a/source/funkin/play/cutscene/dialogue/ScriptedConversation.hx b/source/funkin/play/cutscene/dialogue/ScriptedConversation.hx
index 4fe383a5e..cb7344273 100644
--- a/source/funkin/play/cutscene/dialogue/ScriptedConversation.hx
+++ b/source/funkin/play/cutscene/dialogue/ScriptedConversation.hx
@@ -1,4 +1,10 @@
 package funkin.play.cutscene.dialogue;
 
+/**
+ * A script that can be tied to a Conversation.
+ * Create a scripted class that extends Conversation to use this.
+ * This allows you to customize how a specific conversation appears and behaves.
+ * Someone clever could use this to add branching dialogue I think.
+ */
 @:hscriptClass
 class ScriptedConversation extends Conversation implements polymod.hscript.HScriptedClass {}
diff --git a/source/funkin/play/cutscene/dialogue/ScriptedDialogueBox.hx b/source/funkin/play/cutscene/dialogue/ScriptedDialogueBox.hx
index a1b36c7c2..7689fc0d9 100644
--- a/source/funkin/play/cutscene/dialogue/ScriptedDialogueBox.hx
+++ b/source/funkin/play/cutscene/dialogue/ScriptedDialogueBox.hx
@@ -1,4 +1,9 @@
 package funkin.play.cutscene.dialogue;
 
+/**
+ * A script that can be tied to a DialogueBox.
+ * Create a scripted class that extends DialogueBox to use this.
+ * This allows you to customize how a specific dialogue box appears.
+ */
 @:hscriptClass
 class ScriptedDialogueBox extends DialogueBox implements polymod.hscript.HScriptedClass {}
diff --git a/source/funkin/play/cutscene/dialogue/Speaker.hx b/source/funkin/play/cutscene/dialogue/Speaker.hx
index d7ed004f1..d5bffd7b0 100644
--- a/source/funkin/play/cutscene/dialogue/Speaker.hx
+++ b/source/funkin/play/cutscene/dialogue/Speaker.hx
@@ -1,27 +1,30 @@
 package funkin.play.cutscene.dialogue;
 
 import flixel.FlxSprite;
+import funkin.data.IRegistryEntry;
 import funkin.modding.events.ScriptEvent;
 import flixel.graphics.frames.FlxFramesCollection;
 import funkin.util.assets.FlxAnimationUtil;
 import funkin.modding.IScriptedClass.IDialogueScriptedClass;
+import funkin.data.dialogue.SpeakerData;
+import funkin.data.dialogue.SpeakerRegistry;
 
 /**
  * The character sprite which displays during dialogue.
  *
  * Most conversations have two speakers, with one being flipped.
  */
-class Speaker extends FlxSprite implements IDialogueScriptedClass
+class Speaker extends FlxSprite implements IDialogueScriptedClass implements IRegistryEntry<SpeakerData>
 {
   /**
    * The internal ID for this speaker.
    */
-  public final speakerId:String;
+  public final id:String;
 
   /**
    * The full data for a speaker.
    */
-  var speakerData:SpeakerData;
+  public final _data:SpeakerData;
 
   /**
    * A readable name for this speaker.
@@ -30,7 +33,7 @@ class Speaker extends FlxSprite implements IDialogueScriptedClass
 
   function get_speakerName():String
   {
-    return speakerData.name;
+    return _data.name;
   }
 
   /**
@@ -75,14 +78,17 @@ class Speaker extends FlxSprite implements IDialogueScriptedClass
     return globalOffsets = value;
   }
 
-  public function new(speakerId:String)
+  public function new(id:String)
   {
     super();
 
-    this.speakerId = speakerId;
-    this.speakerData = SpeakerDataParser.parseSpeakerData(this.speakerId);
+    this.id = id;
+    this._data = _fetchData(id);
 
-    if (speakerData == null) throw 'Could not load speaker data for speaker ID "$speakerId"';
+    if (_data == null)
+    {
+      throw 'Could not parse speaker data for id: $id';
+    }
   }
 
   /**
@@ -102,18 +108,18 @@ class Speaker extends FlxSprite implements IDialogueScriptedClass
 
   function loadSpritesheet():Void
   {
-    trace('[SPEAKER] Loading spritesheet ${speakerData.assetPath} for ${speakerId}');
+    trace('[SPEAKER] Loading spritesheet ${_data.assetPath} for ${id}');
 
-    var tex:FlxFramesCollection = Paths.getSparrowAtlas(speakerData.assetPath);
+    var tex:FlxFramesCollection = Paths.getSparrowAtlas(_data.assetPath);
     if (tex == null)
     {
-      trace('Could not load Sparrow sprite: ${speakerData.assetPath}');
+      trace('Could not load Sparrow sprite: ${_data.assetPath}');
       return;
     }
 
     this.frames = tex;
 
-    if (speakerData.isPixel)
+    if (_data.isPixel)
     {
       this.antialiasing = false;
     }
@@ -122,9 +128,10 @@ class Speaker extends FlxSprite implements IDialogueScriptedClass
       this.antialiasing = true;
     }
 
-    this.flipX = speakerData.flipX;
-    this.globalOffsets = speakerData.offsets;
-    this.setScale(speakerData.scale);
+    this.flipX = _data.flipX;
+    this.flipY = _data.flipY;
+    this.globalOffsets = _data.offsets;
+    this.setScale(_data.scale);
   }
 
   /**
@@ -141,11 +148,11 @@ class Speaker extends FlxSprite implements IDialogueScriptedClass
 
   function loadAnimations():Void
   {
-    trace('[SPEAKER] Loading ${speakerData.animations.length} animations for ${speakerId}');
+    trace('[SPEAKER] Loading ${_data.animations.length} animations for ${id}');
 
-    FlxAnimationUtil.addAtlasAnimations(this, speakerData.animations);
+    FlxAnimationUtil.addAtlasAnimations(this, _data.animations);
 
-    for (anim in speakerData.animations)
+    for (anim in _data.animations)
     {
       if (anim.offsets == null)
       {
@@ -158,7 +165,7 @@ class Speaker extends FlxSprite implements IDialogueScriptedClass
     }
 
     var animNames:Array<String> = this.animation.getNameList();
-    trace('[SPEAKER] Successfully loaded ${animNames.length} animations for ${speakerId}');
+    trace('[SPEAKER] Successfully loaded ${animNames.length} animations for ${id}');
   }
 
   /**
@@ -271,4 +278,14 @@ class Speaker extends FlxSprite implements IDialogueScriptedClass
   }
 
   public function onScriptEvent(event:ScriptEvent):Void {}
+
+  public override function toString():String
+  {
+    return 'Speaker($id)';
+  }
+
+  static function _fetchData(id:String):Null<SpeakerData>
+  {
+    return SpeakerRegistry.instance.parseEntryDataWithMigration(id, SpeakerRegistry.instance.fetchEntryVersion(id));
+  }
 }
diff --git a/source/funkin/play/cutscene/dialogue/SpeakerData.hx b/source/funkin/play/cutscene/dialogue/SpeakerData.hx
deleted file mode 100644
index 88883ead8..000000000
--- a/source/funkin/play/cutscene/dialogue/SpeakerData.hx
+++ /dev/null
@@ -1,78 +0,0 @@
-package funkin.play.cutscene.dialogue;
-
-import funkin.data.animation.AnimationData;
-
-/**
- * Data about a conversation.
- * Includes what speakers are in the conversation, and what phrases they say.
- */
-@:jsonParse(j -> funkin.play.cutscene.dialogue.SpeakerData.fromJson(j))
-@:jsonStringify(v -> v.toJson())
-class SpeakerData
-{
-  public var version:String;
-  public var name:String;
-  public var assetPath:String;
-  public var flipX:Bool;
-  public var isPixel:Bool;
-  public var offsets:Array<Float>;
-  public var scale:Float;
-  public var animations:Array<AnimationData>;
-
-  public function new(version:String, name:String, assetPath:String, animations:Array<AnimationData>, ?offsets:Array<Float>, flipX:Bool = false,
-      isPixel:Bool = false, ?scale:Float = 1.0)
-  {
-    this.version = version;
-    this.name = name;
-    this.assetPath = assetPath;
-    this.animations = animations;
-
-    this.offsets = offsets;
-    if (this.offsets == null || this.offsets == []) this.offsets = [0, 0];
-
-    this.flipX = flipX;
-    this.isPixel = isPixel;
-    this.scale = scale;
-  }
-
-  public static function fromString(i:String):SpeakerData
-  {
-    if (i == null || i == '') return null;
-    var data:
-      {
-        version:String,
-        name:String,
-        assetPath:String,
-        animations:Array<AnimationData>,
-        ?offsets:Array<Float>,
-        ?flipX:Bool,
-        ?isPixel:Bool,
-        ?scale:Float
-      } = tink.Json.parse(i);
-    return fromJson(data);
-  }
-
-  public static function fromJson(j:Dynamic):SpeakerData
-  {
-    // TODO: Check version and perform migrations if necessary.
-    if (j == null) return null;
-    return new SpeakerData(j.version, j.name, j.assetPath, j.animations, j.offsets, j.flipX, j.isPixel, j.scale);
-  }
-
-  public function toJson():Dynamic
-  {
-    var result:Dynamic =
-      {
-        version: this.version,
-        name: this.name,
-        assetPath: this.assetPath,
-        animations: this.animations,
-        flipX: this.flipX,
-        isPixel: this.isPixel
-      };
-
-    if (this.scale != 1.0) result.scale = this.scale;
-
-    return result;
-  }
-}
diff --git a/source/funkin/play/cutscene/dialogue/SpeakerDataParser.hx b/source/funkin/play/cutscene/dialogue/SpeakerDataParser.hx
deleted file mode 100644
index f7ddb099f..000000000
--- a/source/funkin/play/cutscene/dialogue/SpeakerDataParser.hx
+++ /dev/null
@@ -1,159 +0,0 @@
-package funkin.play.cutscene.dialogue;
-
-import openfl.Assets;
-import funkin.util.assets.DataAssets;
-import funkin.play.cutscene.dialogue.Speaker;
-import funkin.play.cutscene.dialogue.ScriptedSpeaker;
-
-/**
- * Contains utilities for loading and parsing speaker data.
- */
-class SpeakerDataParser
-{
-  public static final SPEAKER_DATA_VERSION:String = '1.0.0';
-  public static final SPEAKER_DATA_VERSION_RULE:String = '1.0.x';
-
-  static final speakerCache:Map<String, Speaker> = new Map<String, Speaker>();
-
-  static final speakerScriptedClass:Map<String, String> = new Map<String, String>();
-
-  static final DEFAULT_SPEAKER_ID:String = 'UNKNOWN';
-
-  /**
-   * Parses and preloads the game's speaker data and scripts when the game starts.
-   *
-   * If you want to force speakers to be reloaded, you can just call this function again.
-   */
-  public static function loadSpeakerCache():Void
-  {
-    clearSpeakerCache();
-    trace('Loading dialogue speaker cache...');
-
-    //
-    // SCRIPTED CONVERSATIONS
-    //
-    var scriptedSpeakerClassNames:Array<String> = ScriptedSpeaker.listScriptClasses();
-    trace('  Instantiating ${scriptedSpeakerClassNames.length} scripted speakers...');
-    for (speakerCls in scriptedSpeakerClassNames)
-    {
-      var speaker:Speaker = ScriptedSpeaker.init(speakerCls, DEFAULT_SPEAKER_ID);
-      if (speaker != null)
-      {
-        trace('    Loaded scripted speaker: ${speaker.speakerName}');
-        // Disable the rendering logic for speaker until it's loaded.
-        // Note that kill() =/= destroy()
-        speaker.kill();
-
-        // Then store it.
-        speakerCache.set(speaker.speakerId, speaker);
-      }
-      else
-      {
-        trace('    Failed to instantiate scripted speaker class: ${speakerCls}');
-      }
-    }
-
-    //
-    // UNSCRIPTED CONVERSATIONS
-    //
-    // Scripts refers to code here, not the actual dialogue.
-    var speakerIdList:Array<String> = DataAssets.listDataFilesInPath('dialogue/speakers/');
-    // Filter out speakers that are scripted.
-    var unscriptedSpeakerIds:Array<String> = speakerIdList.filter(function(speakerId:String):Bool {
-      return !speakerCache.exists(speakerId);
-    });
-    trace('  Fetching data for ${unscriptedSpeakerIds.length} speakers...');
-    for (speakerId in unscriptedSpeakerIds)
-    {
-      try
-      {
-        var speaker:Speaker = new Speaker(speakerId);
-        if (speaker != null)
-        {
-          trace('    Loaded speaker data: ${speaker.speakerName}');
-          speakerCache.set(speaker.speakerId, speaker);
-        }
-      }
-      catch (e)
-      {
-        trace(e);
-        continue;
-      }
-    }
-  }
-
-  /**
-   * Fetches data for a speaker and returns a Speaker instance,
-   * ready to be displayed.
-   * @param speakerId The ID of the speaker to fetch.
-   * @return The speaker instance, or null if the speaker was not found.
-   */
-  public static function fetchSpeaker(speakerId:String):Null<Speaker>
-  {
-    if (speakerId != null && speakerId != '' && speakerCache.exists(speakerId))
-    {
-      trace('Successfully fetched speaker: ${speakerId}');
-      var speaker:Speaker = speakerCache.get(speakerId);
-      speaker.revive();
-      return speaker;
-    }
-    else
-    {
-      trace('Failed to fetch speaker, not found in cache: ${speakerId}');
-      return null;
-    }
-  }
-
-  static function clearSpeakerCache():Void
-  {
-    if (speakerCache != null)
-    {
-      for (speaker in speakerCache)
-      {
-        speaker.destroy();
-      }
-      speakerCache.clear();
-    }
-  }
-
-  public static function listSpeakerIds():Array<String>
-  {
-    return speakerCache.keys().array();
-  }
-
-  /**
-   * Load a speaker's JSON file, parse its data, and return it.
-   *
-   * @param speakerId The speaker to load.
-   * @return The speaker data, or null if validation failed.
-   */
-  public static function parseSpeakerData(speakerId:String):Null<SpeakerData>
-  {
-    var rawJson:String = loadSpeakerFile(speakerId);
-
-    try
-    {
-      var speakerData:SpeakerData = SpeakerData.fromString(rawJson);
-      return speakerData;
-    }
-    catch (e)
-    {
-      trace('Failed to parse speaker ($speakerId).');
-      trace(e);
-      return null;
-    }
-  }
-
-  static function loadSpeakerFile(speakerPath:String):String
-  {
-    var speakerFilePath:String = Paths.json('dialogue/speakers/${speakerPath}');
-    var rawJson:String = Assets.getText(speakerFilePath).trim();
-
-    while (!rawJson.endsWith('}') && rawJson.length > 0)
-    {
-      rawJson = rawJson.substr(0, rawJson.length - 1);
-    }
-
-    return rawJson;
-  }
-}
diff --git a/source/funkin/play/event/FocusCameraSongEvent.hx b/source/funkin/play/event/FocusCameraSongEvent.hx
index 5f63254b0..847df4a60 100644
--- a/source/funkin/play/event/FocusCameraSongEvent.hx
+++ b/source/funkin/play/event/FocusCameraSongEvent.hx
@@ -5,8 +5,8 @@ import funkin.data.song.SongData;
 import funkin.data.song.SongData.SongEventData;
 // Data from the event schema
 import funkin.play.event.SongEvent;
-import funkin.data.event.SongEventData.SongEventSchema;
-import funkin.data.event.SongEventData.SongEventFieldType;
+import funkin.data.event.SongEventSchema;
+import funkin.data.event.SongEventSchema.SongEventFieldType;
 
 /**
  * This class represents a handler for a type of song event.
@@ -132,13 +132,13 @@ class FocusCameraSongEvent extends SongEvent
    */
   public override function getEventSchema():SongEventSchema
   {
-    return [
+    return new SongEventSchema([
       {
         name: "char",
-        title: "Character",
+        title: "Target",
         defaultValue: 0,
         type: SongEventFieldType.ENUM,
-        keys: ["Position" => -1, "Boyfriend" => 0, "Dad" => 1, "Girlfriend" => 2]
+        keys: ["Position" => -1, "Player" => 0, "Opponent" => 1, "Girlfriend" => 2]
       },
       {
         name: "x",
@@ -146,6 +146,7 @@ class FocusCameraSongEvent extends SongEvent
         defaultValue: 0,
         step: 10.0,
         type: SongEventFieldType.FLOAT,
+        units: "px"
       },
       {
         name: "y",
@@ -153,7 +154,8 @@ class FocusCameraSongEvent extends SongEvent
         defaultValue: 0,
         step: 10.0,
         type: SongEventFieldType.FLOAT,
+        units: "px"
       }
-    ];
+    ]);
   }
 }
diff --git a/source/funkin/play/event/PlayAnimationSongEvent.hx b/source/funkin/play/event/PlayAnimationSongEvent.hx
index 6bc625517..4e6669479 100644
--- a/source/funkin/play/event/PlayAnimationSongEvent.hx
+++ b/source/funkin/play/event/PlayAnimationSongEvent.hx
@@ -7,8 +7,8 @@ import funkin.data.song.SongData;
 import funkin.data.song.SongData.SongEventData;
 // Data from the event schema
 import funkin.play.event.SongEvent;
-import funkin.data.event.SongEventData.SongEventSchema;
-import funkin.data.event.SongEventData.SongEventFieldType;
+import funkin.data.event.SongEventSchema;
+import funkin.data.event.SongEventSchema.SongEventFieldType;
 
 class PlayAnimationSongEvent extends SongEvent
 {
@@ -89,7 +89,7 @@ class PlayAnimationSongEvent extends SongEvent
    */
   public override function getEventSchema():SongEventSchema
   {
-    return [
+    return new SongEventSchema([
       {
         name: 'target',
         title: 'Target',
@@ -108,6 +108,6 @@ class PlayAnimationSongEvent extends SongEvent
         type: SongEventFieldType.BOOL,
         defaultValue: false
       }
-    ];
+    ]);
   }
 }
diff --git a/source/funkin/play/event/SetCameraBopSongEvent.hx b/source/funkin/play/event/SetCameraBopSongEvent.hx
index 3cdeb9a67..a82577a5f 100644
--- a/source/funkin/play/event/SetCameraBopSongEvent.hx
+++ b/source/funkin/play/event/SetCameraBopSongEvent.hx
@@ -8,8 +8,8 @@ import funkin.data.song.SongData;
 import funkin.data.song.SongData.SongEventData;
 // Data from the event schema
 import funkin.play.event.SongEvent;
-import funkin.data.event.SongEventData.SongEventSchema;
-import funkin.data.event.SongEventData.SongEventFieldType;
+import funkin.data.event.SongEventSchema;
+import funkin.data.event.SongEventSchema.SongEventFieldType;
 
 /**
  * This class represents a handler for configuring camera bop intensity and rate.
@@ -72,21 +72,23 @@ class SetCameraBopSongEvent extends SongEvent
    */
   public override function getEventSchema():SongEventSchema
   {
-    return [
+    return new SongEventSchema([
       {
         name: 'intensity',
         title: 'Intensity',
         defaultValue: 1.0,
         step: 0.1,
-        type: SongEventFieldType.FLOAT
+        type: SongEventFieldType.FLOAT,
+        units: 'x'
       },
       {
         name: 'rate',
-        title: 'Rate (beats/zoom)',
+        title: 'Rate',
         defaultValue: 4,
         step: 1,
         type: SongEventFieldType.INTEGER,
+        units: 'beats/zoom'
       }
-    ];
+    ]);
   }
 }
diff --git a/source/funkin/play/event/SongEvent.hx b/source/funkin/play/event/SongEvent.hx
index 36a886673..29b394c0e 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.data.song.SongData.SongEventData;
-import funkin.data.event.SongEventData.SongEventSchema;
+import funkin.data.event.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 1ae76039e..809130499 100644
--- a/source/funkin/play/event/ZoomCameraSongEvent.hx
+++ b/source/funkin/play/event/ZoomCameraSongEvent.hx
@@ -8,8 +8,8 @@ import funkin.data.song.SongData;
 import funkin.data.song.SongData.SongEventData;
 // Data from the event schema
 import funkin.play.event.SongEvent;
-import funkin.data.event.SongEventData.SongEventFieldType;
-import funkin.data.event.SongEventData.SongEventSchema;
+import funkin.data.event.SongEventSchema;
+import funkin.data.event.SongEventSchema.SongEventFieldType;
 
 /**
  * This class represents a handler for camera zoom events.
@@ -79,7 +79,8 @@ 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.instance.stepLengthMs * duration / 1000),
+          {ease: easeFunction});
     }
   }
 
@@ -99,20 +100,22 @@ class ZoomCameraSongEvent extends SongEvent
    */
   public override function getEventSchema():SongEventSchema
   {
-    return [
+    return new SongEventSchema([
       {
         name: 'zoom',
         title: 'Zoom Level',
         defaultValue: 1.0,
         step: 0.1,
-        type: SongEventFieldType.FLOAT
+        type: SongEventFieldType.FLOAT,
+        units: 'x'
       },
       {
         name: 'duration',
-        title: 'Duration (in steps)',
+        title: 'Duration',
         defaultValue: 4.0,
         step: 0.5,
         type: SongEventFieldType.FLOAT,
+        units: 'steps'
       },
       {
         name: 'ease',
@@ -145,6 +148,6 @@ class ZoomCameraSongEvent extends SongEvent
           'Elastic In/Out' => 'elasticInOut',
         ]
       }
-    ];
+    ]);
   }
 }
diff --git a/source/funkin/play/notes/NoteSprite.hx b/source/funkin/play/notes/NoteSprite.hx
index e5c4786d3..0368b18e9 100644
--- a/source/funkin/play/notes/NoteSprite.hx
+++ b/source/funkin/play/notes/NoteSprite.hx
@@ -4,6 +4,7 @@ import funkin.data.song.SongData.SongNoteData;
 import funkin.play.notes.notestyle.NoteStyle;
 import flixel.graphics.frames.FlxAtlasFrames;
 import flixel.FlxSprite;
+import funkin.graphics.shaders.HSVShader;
 
 class NoteSprite extends FlxSprite
 {
@@ -11,6 +12,8 @@ class NoteSprite extends FlxSprite
 
   public var holdNoteSprite:SustainTrail;
 
+  var hsvShader:HSVShader;
+
   /**
    * The time at which the note should be hit, in milliseconds.
    */
@@ -102,6 +105,8 @@ class NoteSprite extends FlxSprite
     this.strumTime = strumTime;
     this.direction = direction;
 
+    this.hsvShader = new HSVShader();
+
     if (this.strumTime < 0) this.strumTime = 0;
 
     setupNoteGraphic(noteStyle);
@@ -116,16 +121,57 @@ class NoteSprite extends FlxSprite
 
     setGraphicSize(Strumline.STRUMLINE_SIZE);
     updateHitbox();
+
+    this.shader = hsvShader;
+  }
+
+  #if FLX_DEBUG
+  /**
+   * Call this to override how debug bounding boxes are drawn for this sprite.
+   */
+  public override function drawDebugOnCamera(camera:flixel.FlxCamera):Void
+  {
+    if (!camera.visible || !camera.exists || !isOnScreen(camera)) return;
+
+    var gfx = beginDrawDebug(camera);
+
+    var rect = getBoundingBox(camera);
+    trace('note sprite bounding box: ' + rect.x + ', ' + rect.y + ', ' + rect.width + ', ' + rect.height);
+
+    gfx.lineStyle(2, 0xFFFF66FF, 0.5); // thickness, color, alpha
+    gfx.drawRect(rect.x, rect.y, rect.width, rect.height);
+
+    gfx.lineStyle(2, 0xFFFFFF66, 0.5); // thickness, color, alpha
+    gfx.drawRect(rect.x, rect.y + rect.height / 2, rect.width, 1);
+
+    endDrawDebug(camera);
+  }
+  #end
+
+  public function desaturate():Void
+  {
+    this.hsvShader.saturation = 0.2;
+  }
+
+  public function setHue(hue:Float):Void
+  {
+    this.hsvShader.hue = hue;
   }
 
   public override function revive():Void
   {
     super.revive();
+    this.visible = true;
+    this.alpha = 1.0;
     this.active = false;
     this.tooEarly = false;
     this.hasBeenHit = false;
     this.mayHit = false;
     this.hasMissed = false;
+
+    this.hsvShader.hue = 1.0;
+    this.hsvShader.saturation = 1.0;
+    this.hsvShader.value = 1.0;
   }
 
   public override function kill():Void
diff --git a/source/funkin/play/notes/Strumline.hx b/source/funkin/play/notes/Strumline.hx
index 0145dee3f..5fdd3945f 100644
--- a/source/funkin/play/notes/Strumline.hx
+++ b/source/funkin/play/notes/Strumline.hx
@@ -274,10 +274,12 @@ class Strumline extends FlxSpriteGroup
   static function calculateNoteYPos(strumTime:Float, vwoosh:Bool = true):Float
   {
     // Make the note move faster visually as it moves offscreen.
-    var vwoosh:Float = (strumTime < Conductor.songPosition) && vwoosh ? 2.0 : 1.0;
+    // var vwoosh:Float = (strumTime < Conductor.songPosition) && vwoosh ? 2.0 : 1.0;
+    // ^^^ commented this out... do NOT make it move faster as it moves offscreen!
+    var vwoosh:Float = 1.0;
     var scrollSpeed:Float = PlayState.instance?.currentChart?.scrollSpeed ?? 1.0;
 
-    return Constants.PIXELS_PER_MS * (Conductor.songPosition - strumTime) * scrollSpeed * vwoosh * (Preferences.downscroll ? 1 : -1);
+    return Constants.PIXELS_PER_MS * (Conductor.instance.songPosition - strumTime) * scrollSpeed * vwoosh * (Preferences.downscroll ? 1 : -1);
   }
 
   function updateNotes():Void
@@ -285,8 +287,8 @@ class Strumline extends FlxSpriteGroup
     if (noteData.length == 0) return;
 
     var songStart:Float = PlayState.instance?.startTimestamp ?? 0.0;
-    var hitWindowStart:Float = Conductor.songPosition - Constants.HIT_WINDOW_MS;
-    var renderWindowStart:Float = Conductor.songPosition + RENDER_DISTANCE_MS;
+    var hitWindowStart:Float = Conductor.instance.songPosition - Constants.HIT_WINDOW_MS;
+    var renderWindowStart:Float = Conductor.instance.songPosition + RENDER_DISTANCE_MS;
 
     for (noteIndex in nextNoteIndex...noteData.length)
     {
@@ -314,7 +316,7 @@ class Strumline extends FlxSpriteGroup
     // Update rendering of notes.
     for (note in notes.members)
     {
-      if (note == null || !note.alive || note.hasBeenHit) continue;
+      if (note == null || !note.alive) continue;
 
       var vwoosh:Bool = note.holdNoteSprite == null;
       // Set the note's position.
@@ -333,7 +335,7 @@ class Strumline extends FlxSpriteGroup
     {
       if (holdNote == null || !holdNote.alive) continue;
 
-      if (Conductor.songPosition > holdNote.strumTime && holdNote.hitNote && !holdNote.missedNote)
+      if (Conductor.instance.songPosition > holdNote.strumTime && holdNote.hitNote && !holdNote.missedNote)
       {
         if (isPlayer && !isKeyHeld(holdNote.noteDirection))
         {
@@ -341,13 +343,13 @@ class Strumline extends FlxSpriteGroup
           playStatic(holdNote.noteDirection);
           holdNote.missedNote = true;
           holdNote.visible = true;
-          holdNote.alpha = 0.0;
+          holdNote.alpha = 0.0; // Completely hide the dropped hold note.
         }
       }
 
       var renderWindowEnd = holdNote.strumTime + holdNote.fullSustainLength + Constants.HIT_WINDOW_MS + RENDER_DISTANCE_MS / 8;
 
-      if (holdNote.missedNote && Conductor.songPosition >= renderWindowEnd)
+      if (holdNote.missedNote && Conductor.instance.songPosition >= renderWindowEnd)
       {
         // Hold note is offscreen, kill it.
         holdNote.visible = false;
@@ -382,10 +384,6 @@ class Strumline extends FlxSpriteGroup
 
         var yOffset:Float = (holdNote.fullSustainLength - holdNote.sustainLength) * Constants.PIXELS_PER_MS;
 
-        trace('yOffset: ' + yOffset);
-        trace('holdNote.fullSustainLength: ' + holdNote.fullSustainLength);
-        trace('holdNote.sustainLength: ' + holdNote.sustainLength);
-
         var vwoosh:Bool = false;
 
         if (Preferences.downscroll)
@@ -397,13 +395,13 @@ class Strumline extends FlxSpriteGroup
           holdNote.y = this.y - INITIAL_OFFSET + calculateNoteYPos(holdNote.strumTime, vwoosh) + yOffset + STRUMLINE_SIZE / 2;
         }
       }
-      else if (Conductor.songPosition > holdNote.strumTime && holdNote.hitNote)
+      else if (Conductor.instance.songPosition > holdNote.strumTime && holdNote.hitNote)
       {
         // Hold note is currently being hit, clip it off.
         holdConfirm(holdNote.noteDirection);
         holdNote.visible = true;
 
-        holdNote.sustainLength = (holdNote.strumTime + holdNote.fullSustainLength) - Conductor.songPosition;
+        holdNote.sustainLength = (holdNote.strumTime + holdNote.fullSustainLength) - Conductor.instance.songPosition;
 
         if (holdNote.sustainLength <= 10)
         {
@@ -529,11 +527,24 @@ class Strumline extends FlxSpriteGroup
     this.noteData.insertionSort(compareNoteData.bind(FlxSort.ASCENDING));
   }
 
-  public function hitNote(note:NoteSprite):Void
+  /**
+   * @param note The note to hit.
+   * @param removeNote True to remove the note immediately, false to make it transparent and let it move offscreen.
+   */
+  public function hitNote(note:NoteSprite, removeNote:Bool = true):Void
   {
     playConfirm(note.direction);
     note.hasBeenHit = true;
-    killNote(note);
+
+    if (removeNote)
+    {
+      killNote(note);
+    }
+    else
+    {
+      note.alpha = 0.5;
+      note.desaturate();
+    }
 
     if (note.holdNoteSprite != null)
     {
diff --git a/source/funkin/play/notes/SustainTrail.hx b/source/funkin/play/notes/SustainTrail.hx
index ab4bf5f16..056a6a5a9 100644
--- a/source/funkin/play/notes/SustainTrail.hx
+++ b/source/funkin/play/notes/SustainTrail.hx
@@ -47,6 +47,11 @@ class SustainTrail extends FlxSprite
    */
   public var missedNote:Bool = false;
 
+  /**
+   * Set to `true` after handling additional logic for missing notes.
+   */
+  public var handledMiss:Bool = false;
+
   // maybe BlendMode.MULTIPLY if missed somehow, drawTriangles does not support!
 
   /**
@@ -82,6 +87,9 @@ class SustainTrail extends FlxSprite
 
   public var isPixel:Bool;
 
+  var graphicWidth:Float = 0;
+  var graphicHeight:Float = 0;
+
   /**
    * Normally you would take strumTime:Float, noteData:Int, sustainLength:Float, parentNote:Note (?)
    * @param NoteData
@@ -110,8 +118,8 @@ class SustainTrail extends FlxSprite
     zoom *= 0.7;
 
     // CALCULATE SIZE
-    width = graphic.width / 8 * zoom; // amount of notes * 2
-    height = sustainHeight(sustainLength, getScrollSpeed());
+    graphicWidth = graphic.width / 8 * zoom; // amount of notes * 2
+    graphicHeight = sustainHeight(sustainLength, getScrollSpeed());
     // instead of scrollSpeed, PlayState.SONG.speed
 
     flipY = Preferences.downscroll;
@@ -148,10 +156,19 @@ class SustainTrail extends FlxSprite
 
     if (sustainLength == s) return s;
 
-    height = sustainHeight(s, getScrollSpeed());
-    // updateColorTransform();
+    graphicHeight = sustainHeight(s, getScrollSpeed());
+    this.sustainLength = s;
     updateClipping();
-    return sustainLength = s;
+    updateHitbox();
+    return this.sustainLength;
+  }
+
+  public override function updateHitbox():Void
+  {
+    width = graphicWidth;
+    height = graphicHeight;
+    offset.set(0, 0);
+    origin.set(width * 0.5, height * 0.5);
   }
 
   /**
@@ -161,14 +178,16 @@ class SustainTrail extends FlxSprite
    */
   public function updateClipping(songTime:Float = 0):Void
   {
-    var clipHeight:Float = FlxMath.bound(sustainHeight(sustainLength - (songTime - strumTime), getScrollSpeed()), 0, height);
-    if (clipHeight == 0)
+    var clipHeight:Float = FlxMath.bound(sustainHeight(sustainLength - (songTime - strumTime), getScrollSpeed()), 0, graphicHeight);
+    if (clipHeight <= 0.1)
     {
       visible = false;
       return;
     }
     else
+    {
       visible = true;
+    }
 
     var bottomHeight:Float = graphic.height * zoom * endOffset;
     var partHeight:Float = clipHeight - bottomHeight;
@@ -176,10 +195,10 @@ class SustainTrail extends FlxSprite
     // ===HOLD VERTICES==
     // Top left
     vertices[0 * 2] = 0.0; // Inline with left side
-    vertices[0 * 2 + 1] = flipY ? clipHeight : height - clipHeight;
+    vertices[0 * 2 + 1] = flipY ? clipHeight : graphicHeight - clipHeight;
 
     // Top right
-    vertices[1 * 2] = width;
+    vertices[1 * 2] = graphicWidth;
     vertices[1 * 2 + 1] = vertices[0 * 2 + 1]; // Inline with top left vertex
 
     // Bottom left
@@ -195,7 +214,7 @@ class SustainTrail extends FlxSprite
     }
 
     // Bottom right
-    vertices[3 * 2] = width;
+    vertices[3 * 2] = graphicWidth;
     vertices[3 * 2 + 1] = vertices[2 * 2 + 1]; // Inline with bottom left vertex
 
     // ===HOLD UVs===
@@ -231,7 +250,7 @@ class SustainTrail extends FlxSprite
 
     // Bottom left
     vertices[6 * 2] = vertices[2 * 2]; // Inline with left side
-    vertices[6 * 2 + 1] = flipY ? (graphic.height * (-bottomClip + endOffset) * zoom) : (height + graphic.height * (bottomClip - endOffset) * zoom);
+    vertices[6 * 2 + 1] = flipY ? (graphic.height * (-bottomClip + endOffset) * zoom) : (graphicHeight + graphic.height * (bottomClip - endOffset) * zoom);
 
     // Bottom right
     vertices[7 * 2] = vertices[3 * 2]; // Inline with right side
@@ -275,6 +294,10 @@ class SustainTrail extends FlxSprite
       getScreenPosition(_point, camera).subtractPoint(offset);
       camera.drawTriangles(processedGraphic, vertices, indices, uvtData, null, _point, blend, true, antialiasing);
     }
+
+    #if FLX_DEBUG
+    if (FlxG.debugger.drawDebug) drawDebug();
+    #end
   }
 
   public override function kill():Void
@@ -303,6 +326,7 @@ class SustainTrail extends FlxSprite
 
     hitNote = false;
     missedNote = false;
+    handledMiss = false;
   }
 
   override public function destroy():Void
diff --git a/source/funkin/play/scoring/Scoring.hx b/source/funkin/play/scoring/Scoring.hx
index 75d002cb5..edfb2cae7 100644
--- a/source/funkin/play/scoring/Scoring.hx
+++ b/source/funkin/play/scoring/Scoring.hx
@@ -158,8 +158,8 @@ class Scoring
 
     return switch (absTiming)
     {
-      case(_ < PBOT1_KILLER_THRESHOLD) => true:
-        'killer';
+      // case(_ < PBOT1_KILLER_THRESHOLD) => true:
+      //   'killer';
       case(_ < PBOT1_SICK_THRESHOLD) => true:
         'sick';
       case(_ < PBOT1_GOOD_THRESHOLD) => true:
diff --git a/source/funkin/play/song/Song.hx b/source/funkin/play/song/Song.hx
index 9e5de6143..0434607f3 100644
--- a/source/funkin/play/song/Song.hx
+++ b/source/funkin/play/song/Song.hx
@@ -174,7 +174,10 @@ class Song implements IPlayStateScriptedClass implements IRegistryEntry<SongMeta
         difficulty.timeChanges = metadata.timeChanges;
         difficulty.looped = metadata.looped;
         difficulty.generatedBy = metadata.generatedBy;
-        difficulty.offsets = metadata.offsets;
+        difficulty.offsets = metadata?.offsets ?? new SongOffsets();
+
+        difficulty.difficultyRating = metadata.playData.ratings.get(diffId) ?? 0;
+        difficulty.album = metadata.playData.album;
 
         difficulty.stage = metadata.playData.stage;
         difficulty.noteStyle = metadata.playData.noteStyle;
@@ -232,6 +235,7 @@ class Song implements IPlayStateScriptedClass implements IRegistryEntry<SongMeta
           difficulty.timeChanges = metadata.timeChanges;
           difficulty.looped = metadata.looped;
           difficulty.generatedBy = metadata.generatedBy;
+          difficulty.offsets = metadata?.offsets ?? new SongOffsets();
 
           difficulty.stage = metadata.playData.stage;
           difficulty.noteStyle = metadata.playData.noteStyle;
@@ -405,6 +409,9 @@ class SongDifficulty
 
   public var scrollSpeed:Float = Constants.DEFAULT_SCROLLSPEED;
 
+  public var difficultyRating:Int = 0;
+  public var album:Null<String> = null;
+
   public function new(song:Song, diffId:String, variation:String)
   {
     this.song = song;
diff --git a/source/funkin/play/stage/Stage.hx b/source/funkin/play/stage/Stage.hx
index 639ed02da..fc23b6ace 100644
--- a/source/funkin/play/stage/Stage.hx
+++ b/source/funkin/play/stage/Stage.hx
@@ -12,13 +12,16 @@ import flixel.math.FlxPoint;
 import flixel.system.FlxAssets.FlxShader;
 import flixel.util.FlxSort;
 import openfl.display.BitmapData;
+import flixel.util.FlxColor;
 import funkin.modding.IScriptedClass;
 import funkin.modding.events.ScriptEvent;
 import funkin.modding.events.ScriptEventType;
 import funkin.modding.events.ScriptEventDispatcher;
 import funkin.play.character.BaseCharacter;
-import funkin.play.stage.StageData.StageDataCharacter;
-import funkin.play.stage.StageData.StageDataParser;
+import funkin.data.IRegistryEntry;
+import funkin.data.stage.StageData;
+import funkin.data.stage.StageData.StageDataCharacter;
+import funkin.data.stage.StageRegistry;
 import funkin.play.stage.StageProp;
 import funkin.util.SortUtil;
 import funkin.util.assets.FlxAnimationUtil;
@@ -30,14 +33,25 @@ typedef StagePropGroup = FlxTypedSpriteGroup<StageProp>;
  *
  * A Stage is comprised of one or more props, each of which is a FlxSprite.
  */
-class Stage extends FlxSpriteGroup implements IPlayStateScriptedClass
+class Stage extends FlxSpriteGroup implements IPlayStateScriptedClass implements IRegistryEntry<StageData>
 {
-  public final stageId:String;
-  public final stageName:String;
+  public final id:String;
 
-  final _data:StageData;
+  public final _data:StageData;
 
-  public var camZoom:Float = 1.0;
+  public var stageName(get, never):String;
+
+  function get_stageName():String
+  {
+    return _data?.name ?? 'Unknown';
+  }
+
+  public var camZoom(get, never):Float;
+
+  function get_camZoom():Float
+  {
+    return _data?.cameraZoom ?? 1.0;
+  }
 
   var frameBufferMan:FrameBufferManager;
 
@@ -55,21 +69,18 @@ class Stage extends FlxSpriteGroup implements IPlayStateScriptedClass
    * They're used to cache the data needed to build the stage,
    * then accessed and fleshed out when the stage needs to be built.
    *
-   * @param stageId
+   * @param id
    */
-  public function new(stageId:String)
+  public function new(id:String)
   {
     super();
 
-    this.stageId = stageId;
-    _data = StageDataParser.parseStageData(this.stageId);
+    this.id = id;
+    _data = _fetchData(id);
+
     if (_data == null)
     {
-      throw 'Could not find stage data for stageId: $stageId';
-    }
-    else
-    {
-      this.stageName = _data.name;
+      throw 'Could not find stage data for stage id: $id';
     }
   }
 
@@ -147,9 +158,7 @@ class Stage extends FlxSpriteGroup implements IPlayStateScriptedClass
    */
   function buildStage():Void
   {
-    trace('Building stage for display: ${this.stageId}');
-
-    this.camZoom = _data.cameraZoom;
+    trace('Building stage for display: ${this.id}');
 
     this.debugIconGroup = new FlxSpriteGroup();
 
@@ -157,6 +166,7 @@ class Stage extends FlxSpriteGroup implements IPlayStateScriptedClass
     {
       trace('  Placing prop: ${dataProp.name} (${dataProp.assetPath})');
 
+      var isSolidColor = dataProp.assetPath.startsWith('#');
       var isAnimated = dataProp.animations.length > 0;
 
       var propSprite:StageProp;
@@ -180,6 +190,22 @@ class Stage extends FlxSpriteGroup implements IPlayStateScriptedClass
             propSprite.frames = Paths.getSparrowAtlas(dataProp.assetPath);
         }
       }
+      else if (isSolidColor)
+      {
+        var width:Int = 1;
+        var height:Int = 1;
+        switch (dataProp.scale)
+        {
+          case Left(value):
+            width = Std.int(value);
+            height = Std.int(value);
+
+          case Right(values):
+            width = Std.int(values[0]);
+            height = Std.int(values[1]);
+        }
+        propSprite.makeSolidColor(width, height, FlxColor.fromString(dataProp.assetPath));
+      }
       else
       {
         // Initalize static sprite.
@@ -195,13 +221,16 @@ class Stage extends FlxSpriteGroup implements IPlayStateScriptedClass
         continue;
       }
 
-      switch (dataProp.scale)
+      if (!isSolidColor)
       {
-        case Left(value):
-          propSprite.scale.set(value);
+        switch (dataProp.scale)
+        {
+          case Left(value):
+            propSprite.scale.set(value);
 
-        case Right(values):
-          propSprite.scale.set(values[0], values[1]);
+          case Right(values):
+            propSprite.scale.set(values[0], values[1]);
+        }
       }
       propSprite.updateHitbox();
 
@@ -213,15 +242,8 @@ class Stage extends FlxSpriteGroup implements IPlayStateScriptedClass
       // If pixel, disable antialiasing.
       propSprite.antialiasing = !dataProp.isPixel;
 
-      switch (dataProp.scroll)
-      {
-        case Left(value):
-          propSprite.scrollFactor.x = value;
-          propSprite.scrollFactor.y = value;
-        case Right(values):
-          propSprite.scrollFactor.x = values[0];
-          propSprite.scrollFactor.y = values[1];
-      }
+      propSprite.scrollFactor.x = dataProp.scroll[0];
+      propSprite.scrollFactor.y = dataProp.scroll[1];
 
       propSprite.zIndex = dataProp.zIndex;
 
@@ -801,6 +823,11 @@ class Stage extends FlxSpriteGroup implements IPlayStateScriptedClass
     }
   }
 
+  static function _fetchData(id:String):Null<StageData>
+  {
+    return StageRegistry.instance.parseEntryDataWithMigration(id, StageRegistry.instance.fetchEntryVersion(id));
+  }
+
   public function onScriptEvent(event:ScriptEvent) {}
 
   public function onPause(event:PauseScriptEvent) {}
diff --git a/source/funkin/play/stage/StageData.hx b/source/funkin/play/stage/StageData.hx
deleted file mode 100644
index d89995ef3..000000000
--- a/source/funkin/play/stage/StageData.hx
+++ /dev/null
@@ -1,547 +0,0 @@
-package funkin.play.stage;
-
-import funkin.data.animation.AnimationData;
-import funkin.play.stage.ScriptedStage;
-import funkin.play.stage.Stage;
-import funkin.util.VersionUtil;
-import funkin.util.assets.DataAssets;
-import haxe.Json;
-import openfl.Assets;
-
-/**
- * Contains utilities for loading and parsing stage data.
- */
-class StageDataParser
-{
-  /**
-   * 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 STAGE_DATA_VERSION:String = "1.0.0";
-
-  /**
-   * The current version rule check for the stage data format.
-   */
-  public static final STAGE_DATA_VERSION_RULE:String = "1.0.x";
-
-  static final stageCache:Map<String, Stage> = new Map<String, Stage>();
-
-  static final DEFAULT_STAGE_ID = 'UNKNOWN';
-
-  /**
-   * Parses and preloads the game's stage data and scripts when the game starts.
-   *
-   * If you want to force stages to be reloaded, you can just call this function again.
-   */
-  public static function loadStageCache():Void
-  {
-    // Clear any stages that are cached if there were any.
-    clearStageCache();
-    trace("Loading stage cache...");
-
-    //
-    // SCRIPTED STAGES
-    //
-    var scriptedStageClassNames:Array<String> = ScriptedStage.listScriptClasses();
-    trace('  Instantiating ${scriptedStageClassNames.length} scripted stages...');
-    for (stageCls in scriptedStageClassNames)
-    {
-      var stage:Stage = ScriptedStage.init(stageCls, DEFAULT_STAGE_ID);
-      if (stage != null)
-      {
-        trace('    Loaded scripted stage: ${stage.stageName}');
-        // Disable the rendering logic for stage until it's loaded.
-        // Note that kill() =/= destroy()
-        stage.kill();
-
-        // Then store it.
-        stageCache.set(stage.stageId, stage);
-      }
-      else
-      {
-        trace('    Failed to instantiate scripted stage class: ${stageCls}');
-      }
-    }
-
-    //
-    // UNSCRIPTED STAGES
-    //
-    var stageIdList:Array<String> = DataAssets.listDataFilesInPath('stages/');
-    var unscriptedStageIds:Array<String> = stageIdList.filter(function(stageId:String):Bool {
-      return !stageCache.exists(stageId);
-    });
-    trace('  Instantiating ${unscriptedStageIds.length} non-scripted stages...');
-    for (stageId in unscriptedStageIds)
-    {
-      var stage:Stage;
-      try
-      {
-        stage = new Stage(stageId);
-        if (stage != null)
-        {
-          trace('    Loaded stage data: ${stage.stageName}');
-          stageCache.set(stageId, stage);
-        }
-      }
-      catch (e)
-      {
-        trace('    An error occurred while loading stage data: ${stageId}');
-        // Assume error was already logged.
-        continue;
-      }
-    }
-
-    trace('  Successfully loaded ${Lambda.count(stageCache)} stages.');
-  }
-
-  public static function fetchStage(stageId:String):Null<Stage>
-  {
-    if (stageCache.exists(stageId))
-    {
-      trace('Successfully fetch stage: ${stageId}');
-      var stage:Stage = stageCache.get(stageId);
-      stage.revive();
-      return stage;
-    }
-    else
-    {
-      trace('Failed to fetch stage, not found in cache: ${stageId}');
-      return null;
-    }
-  }
-
-  static function clearStageCache():Void
-  {
-    if (stageCache != null)
-    {
-      for (stage in stageCache)
-      {
-        stage.destroy();
-      }
-      stageCache.clear();
-    }
-  }
-
-  /**
-   * Load a stage's JSON file, parse its data, and return it.
-   *
-   * @param stageId The stage to load.
-   * @return The stage data, or null if validation failed.
-   */
-  public static function parseStageData(stageId:String):Null<StageData>
-  {
-    var rawJson:String = loadStageFile(stageId);
-
-    var stageData:StageData = migrateStageData(rawJson, stageId);
-
-    return validateStageData(stageId, stageData);
-  }
-
-  public static function listStageIds():Array<String>
-  {
-    return stageCache.keys().array();
-  }
-
-  static function loadStageFile(stagePath:String):String
-  {
-    var stageFilePath:String = Paths.json('stages/${stagePath}');
-    var rawJson = Assets.getText(stageFilePath).trim();
-
-    while (!rawJson.endsWith("}"))
-    {
-      rawJson = rawJson.substr(0, rawJson.length - 1);
-    }
-
-    return rawJson;
-  }
-
-  static function migrateStageData(rawJson:String, stageId:String):Null<StageData>
-  {
-    // If you update the stage data format in a breaking way,
-    // handle migration here by checking the `version` value.
-
-    try
-    {
-      var parser = new json2object.JsonParser<StageData>();
-      parser.fromJson(rawJson, '$stageId.json');
-
-      if (parser.errors.length > 0)
-      {
-        trace('[STAGE] Failed to parse stage data');
-
-        for (error in parser.errors)
-          funkin.data.DataError.printError(error);
-
-        return null;
-      }
-      return parser.value;
-    }
-    catch (e)
-    {
-      trace('  Error parsing data for stage: ${stageId}');
-      trace('    ${e}');
-      return null;
-    }
-  }
-
-  static final DEFAULT_ANIMTYPE:String = "sparrow";
-  static final DEFAULT_CAMERAZOOM:Float = 1.0;
-  static final DEFAULT_DANCEEVERY:Int = 0;
-  static final DEFAULT_ISPIXEL:Bool = false;
-  static final DEFAULT_NAME:String = "Untitled Stage";
-  static final DEFAULT_OFFSETS:Array<Float> = [0, 0];
-  static final DEFAULT_CAMERA_OFFSETS_BF:Array<Float> = [-100, -100];
-  static final DEFAULT_CAMERA_OFFSETS_DAD:Array<Float> = [150, -100];
-  static final DEFAULT_POSITION:Array<Float> = [0, 0];
-  static final DEFAULT_SCALE:Float = 1.0;
-  static final DEFAULT_ALPHA:Float = 1.0;
-  static final DEFAULT_SCROLL:Array<Float> = [0, 0];
-  static final DEFAULT_ZINDEX:Int = 0;
-
-  static final DEFAULT_CHARACTER_DATA:StageDataCharacter =
-    {
-      zIndex: DEFAULT_ZINDEX,
-      position: DEFAULT_POSITION,
-      cameraOffsets: DEFAULT_OFFSETS,
-    }
-
-  /**
-   * Set unspecified parameters to their defaults.
-   * If the parameter is mandatory, print an error message.
-   * @param id
-   * @param input
-   * @return The validated stage data
-   */
-  static function validateStageData(id:String, input:StageData):Null<StageData>
-  {
-    if (input == null)
-    {
-      trace('ERROR: Could not parse stage data for "${id}".');
-      return null;
-    }
-
-    if (input.version == null)
-    {
-      trace('ERROR: Could not load stage data for "$id": missing version');
-      return null;
-    }
-
-    if (!VersionUtil.validateVersionStr(input.version, STAGE_DATA_VERSION_RULE))
-    {
-      trace('ERROR: Could not load stage data for "$id": bad version (got ${input.version}, expected ${STAGE_DATA_VERSION_RULE})');
-      return null;
-    }
-
-    if (input.name == null)
-    {
-      trace('WARN: Stage data for "$id" missing name');
-      input.name = DEFAULT_NAME;
-    }
-
-    if (input.cameraZoom == null)
-    {
-      input.cameraZoom = DEFAULT_CAMERAZOOM;
-    }
-
-    if (input.props == null)
-    {
-      input.props = [];
-    }
-
-    for (inputProp in input.props)
-    {
-      // It's fine for inputProp.name to be null
-
-      if (inputProp.assetPath == null)
-      {
-        trace('ERROR: Could not load stage data for "$id": missing assetPath for prop "${inputProp.name}"');
-        return null;
-      }
-
-      if (inputProp.position == null)
-      {
-        inputProp.position = DEFAULT_POSITION;
-      }
-
-      if (inputProp.zIndex == null)
-      {
-        inputProp.zIndex = DEFAULT_ZINDEX;
-      }
-
-      if (inputProp.isPixel == null)
-      {
-        inputProp.isPixel = DEFAULT_ISPIXEL;
-      }
-
-      if (inputProp.danceEvery == null)
-      {
-        inputProp.danceEvery = DEFAULT_DANCEEVERY;
-      }
-
-      if (inputProp.animType == null)
-      {
-        inputProp.animType = DEFAULT_ANIMTYPE;
-      }
-
-      switch (inputProp.scale)
-      {
-        case null:
-          inputProp.scale = Right([DEFAULT_SCALE, DEFAULT_SCALE]);
-        case Left(value):
-          inputProp.scale = Right([value, value]);
-        case Right(_):
-          // Do nothing
-      }
-
-      switch (inputProp.scroll)
-      {
-        case null:
-          inputProp.scroll = Right(DEFAULT_SCROLL);
-        case Left(value):
-          inputProp.scroll = Right([value, value]);
-        case Right(_):
-          // Do nothing
-      }
-
-      if (inputProp.alpha == null)
-      {
-        inputProp.alpha = DEFAULT_ALPHA;
-      }
-
-      if (inputProp.animations == null)
-      {
-        inputProp.animations = [];
-      }
-
-      if (inputProp.animations.length == 0 && inputProp.startingAnimation != null)
-      {
-        trace('ERROR: Could not load stage data for "$id": missing animations for prop "${inputProp.name}"');
-        return null;
-      }
-
-      for (inputAnimation in inputProp.animations)
-      {
-        if (inputAnimation.name == null)
-        {
-          trace('ERROR: Could not load stage data for "$id": missing animation name for prop "${inputProp.name}"');
-          return null;
-        }
-
-        if (inputAnimation.frameRate == null)
-        {
-          inputAnimation.frameRate = 24;
-        }
-
-        if (inputAnimation.offsets == null)
-        {
-          inputAnimation.offsets = DEFAULT_OFFSETS;
-        }
-
-        if (inputAnimation.looped == null)
-        {
-          inputAnimation.looped = true;
-        }
-
-        if (inputAnimation.flipX == null)
-        {
-          inputAnimation.flipX = false;
-        }
-
-        if (inputAnimation.flipY == null)
-        {
-          inputAnimation.flipY = false;
-        }
-      }
-    }
-
-    if (input.characters == null)
-    {
-      trace('ERROR: Could not load stage data for "$id": missing characters');
-      return null;
-    }
-
-    if (input.characters.bf == null)
-    {
-      input.characters.bf = DEFAULT_CHARACTER_DATA;
-    }
-    if (input.characters.dad == null)
-    {
-      input.characters.dad = DEFAULT_CHARACTER_DATA;
-    }
-    if (input.characters.gf == null)
-    {
-      input.characters.gf = DEFAULT_CHARACTER_DATA;
-    }
-
-    for (inputCharacter in [input.characters.bf, input.characters.dad, input.characters.gf])
-    {
-      if (inputCharacter.position == null || inputCharacter.position.length != 2)
-      {
-        inputCharacter.position = [0, 0];
-      }
-    }
-
-    // All good!
-    return input;
-  }
-}
-
-class StageData
-{
-  /**
-   * The sematic version number of the stage data JSON format.
-   * Supports fancy comparisons like NPM does it's neat.
-   */
-  public var version:String;
-
-  public var name:String;
-  public var cameraZoom:Null<Float>;
-  public var props:Array<StageDataProp>;
-  public var characters:StageDataCharacters;
-
-  public function new()
-  {
-    this.version = StageDataParser.STAGE_DATA_VERSION;
-  }
-
-  /**
-   * Convert this StageData into a JSON string.
-   */
-  public function serialize(pretty:Bool = true):String
-  {
-    var writer = new json2object.JsonWriter<StageData>();
-    return writer.write(this, pretty ? '  ' : null);
-  }
-}
-
-typedef StageDataCharacters =
-{
-  var bf:StageDataCharacter;
-  var dad:StageDataCharacter;
-  var gf:StageDataCharacter;
-};
-
-typedef StageDataProp =
-{
-  /**
-   * The name of the prop for later lookup by scripts.
-   * Optional; if unspecified, the prop can't be referenced by scripts.
-   */
-  @:optional
-  var name:String;
-
-  /**
-   * The asset used to display the prop.
-   */
-  var assetPath:String;
-
-  /**
-   * The position of the prop as an [x, y] array of two floats.
-   */
-  var position:Array<Float>;
-
-  /**
-   * A number determining the stack order of the prop, relative to other props and the characters in the stage.
-   * Props with lower numbers render below those with higher numbers.
-   * This is just like CSS, it isn't hard.
-   * @default 0
-   */
-  @:optional
-  @:default(0)
-  var zIndex:Int;
-
-  /**
-   * If set to true, anti-aliasing will be forcibly disabled on the sprite.
-   * This prevents blurry images on pixel-art levels.
-   * @default false
-   */
-  @:optional
-  @:default(false)
-  var isPixel:Bool;
-
-  /**
-   * Either the scale of the prop as a float, or the [w, h] scale as an array of two floats.
-   * Pro tip: On pixel-art levels, save the sprite small and set this value to 6 or so to save memory.
-   */
-  @:jcustomparse(funkin.data.DataParse.eitherFloatOrFloats)
-  @:jcustomwrite(funkin.data.DataWrite.eitherFloatOrFloats)
-  @:optional
-  var scale:haxe.ds.Either<Float, Array<Float>>;
-
-  /**
-   * The alpha of the prop, as a float.
-   * @default 1.0
-   */
-  @:optional
-  @:default(1.0)
-  var alpha:Float;
-
-  /**
-   * If not zero, this prop will play an animation every X beats of the song.
-   * This requires animations to be defined. If `danceLeft` and `danceRight` are defined,
-   * they will alternated between, otherwise the `idle` animation will be used.
-   *
-   * @default 0
-   */
-  @:default(0)
-  @:optional
-  var danceEvery:Int;
-
-  /**
-   * How much the prop scrolls relative to the camera. Used to create a parallax effect.
-   * Represented as a float or as an [x, y] array of two floats.
-   * [1, 1] means the prop moves 1:1 with the camera.
-   * [0.5, 0.5] means the prop half as much as the camera.
-   * [0, 0] means the prop is not moved.
-   * @default [0, 0]
-   */
-  @:jcustomparse(funkin.data.DataParse.eitherFloatOrFloats)
-  @:jcustomwrite(funkin.data.DataWrite.eitherFloatOrFloats)
-  @:optional
-  var scroll:haxe.ds.Either<Float, Array<Float>>;
-
-  /**
-   * An optional array of animations which the prop can play.
-   * @default Prop has no animations.
-   */
-  @:optional
-  var animations:Array<AnimationData>;
-
-  /**
-   * If animations are used, this is the name of the animation to play first.
-   * @default Don't play an animation.
-   */
-  @:optional
-  var startingAnimation:Null<String>;
-
-  /**
-   * The animation type to use.
-   * Options: "sparrow", "packer"
-   * @default "sparrow"
-   */
-  @:default("sparrow")
-  @:optional
-  var animType:String;
-};
-
-typedef StageDataCharacter =
-{
-  /**
-   * A number determining the stack order of the character, relative to props and other characters in the stage.
-   * Again, just like CSS.
-   * @default 0
-   */
-  var zIndex:Int;
-
-  /**
-   * The position to render the character at.
-   */
-  var position:Array<Float>;
-
-  /**
-   * The camera offsets to apply when focusing on the character on this stage.
-   * @default [-100, -100] for BF, [100, -100] for DAD/OPPONENT, [0, 0] for GF
-   */
-  var cameraOffsets:Array<Float>;
-};
diff --git a/source/funkin/play/stage/StageProp.hx b/source/funkin/play/stage/StageProp.hx
index 4f67c5e4b..4d846162b 100644
--- a/source/funkin/play/stage/StageProp.hx
+++ b/source/funkin/play/stage/StageProp.hx
@@ -1,10 +1,10 @@
 package funkin.play.stage;
 
 import funkin.modding.events.ScriptEvent;
-import flixel.FlxSprite;
+import funkin.graphics.FunkinSprite;
 import funkin.modding.IScriptedClass.IStateStageProp;
 
-class StageProp extends FlxSprite implements IStateStageProp
+class StageProp extends FunkinSprite implements IStateStageProp
 {
   /**
    * An internal name for this prop.
diff --git a/source/funkin/save/Save.hx b/source/funkin/save/Save.hx
index 810d0fd93..6a4dd048c 100644
--- a/source/funkin/save/Save.hx
+++ b/source/funkin/save/Save.hx
@@ -14,8 +14,8 @@ import thx.semver.Version;
 @:forward(volume, mute)
 abstract Save(RawSaveData)
 {
-  // Version 2.0.1 adds attributes to `optionsChartEditor`, that should return default values if they are null.
-  public static final SAVE_DATA_VERSION:thx.semver.Version = "2.0.1";
+  // Version 2.0.2 adds attributes to `optionsChartEditor`, that should return default values if they are null.
+  public static final SAVE_DATA_VERSION:thx.semver.Version = "2.0.2";
   public static final SAVE_DATA_VERSION_RULE:thx.semver.VersionRule = "2.0.x";
 
   // We load this version's saves from a new save path, to maintain SOME level of backwards compatibility.
@@ -108,9 +108,7 @@ abstract Save(RawSaveData)
             metronomeVolume: 1.0,
             hitsoundsEnabledPlayer: true,
             hitsoundsEnabledOpponent: true,
-            instVolume: 1.0,
-            voicesVolume: 1.0,
-            playbackSpeed: 1.0,
+            themeMusic: true
           },
       };
   }
@@ -347,38 +345,21 @@ abstract Save(RawSaveData)
     return this.optionsChartEditor.hitsoundsEnabledOpponent;
   }
 
-  public var chartEditorInstVolume(get, set):Float;
+  public var chartEditorThemeMusic(get, set):Bool;
 
-  function get_chartEditorInstVolume():Float
+  function get_chartEditorThemeMusic():Bool
   {
-    if (this.optionsChartEditor.instVolume == null) this.optionsChartEditor.instVolume = 1.0;
+    if (this.optionsChartEditor.themeMusic == null) this.optionsChartEditor.themeMusic = true;
 
-    return this.optionsChartEditor.instVolume;
+    return this.optionsChartEditor.themeMusic;
   }
 
-  function set_chartEditorInstVolume(value:Float):Float
+  function set_chartEditorThemeMusic(value:Bool):Bool
   {
     // Set and apply.
-    this.optionsChartEditor.instVolume = value;
+    this.optionsChartEditor.themeMusic = value;
     flush();
-    return this.optionsChartEditor.instVolume;
-  }
-
-  public var chartEditorVoicesVolume(get, set):Float;
-
-  function get_chartEditorVoicesVolume():Float
-  {
-    if (this.optionsChartEditor.voicesVolume == null) this.optionsChartEditor.voicesVolume = 1.0;
-
-    return this.optionsChartEditor.voicesVolume;
-  }
-
-  function set_chartEditorVoicesVolume(value:Float):Float
-  {
-    // Set and apply.
-    this.optionsChartEditor.voicesVolume = value;
-    flush();
-    return this.optionsChartEditor.voicesVolume;
+    return this.optionsChartEditor.themeMusic;
   }
 
   public var chartEditorPlaybackSpeed(get, set):Float;
@@ -774,7 +755,6 @@ typedef SaveScoreData =
 
 typedef SaveScoreTallyData =
 {
-  var killer:Int;
   var sick:Int;
   var good:Int;
   var bad:Int;
@@ -1027,6 +1007,12 @@ typedef SaveDataChartEditorOptions =
    */
   var ?hitsoundsEnabledOpponent:Bool;
 
+  /**
+   * Theme music in the Chart Editor.
+   * @default `true`
+   */
+  var ?themeMusic:Bool;
+
   /**
    * Instrumental volume in the Chart Editor.
    * @default `1.0`
diff --git a/source/funkin/save/migrator/SaveDataMigrator.hx b/source/funkin/save/migrator/SaveDataMigrator.hx
index d5b23cfd9..92bee4ceb 100644
--- a/source/funkin/save/migrator/SaveDataMigrator.hx
+++ b/source/funkin/save/migrator/SaveDataMigrator.hx
@@ -120,7 +120,6 @@ class SaveDataMigrator
         accuracy: inputSaveData.songCompletion.get('${levelId}-easy') ?? 0.0,
         tallies:
           {
-            killer: 0,
             sick: 0,
             good: 0,
             bad: 0,
@@ -140,7 +139,6 @@ class SaveDataMigrator
         accuracy: inputSaveData.songCompletion.get('${levelId}') ?? 0.0,
         tallies:
           {
-            killer: 0,
             sick: 0,
             good: 0,
             bad: 0,
@@ -160,7 +158,6 @@ class SaveDataMigrator
         accuracy: inputSaveData.songCompletion.get('${levelId}-hard') ?? 0.0,
         tallies:
           {
-            killer: 0,
             sick: 0,
             good: 0,
             bad: 0,
@@ -183,7 +180,6 @@ class SaveDataMigrator
         accuracy: 0,
         tallies:
           {
-            killer: 0,
             sick: 0,
             good: 0,
             bad: 0,
@@ -209,7 +205,6 @@ class SaveDataMigrator
         accuracy: 0,
         tallies:
           {
-            killer: 0,
             sick: 0,
             good: 0,
             bad: 0,
@@ -235,7 +230,6 @@ class SaveDataMigrator
         accuracy: 0,
         tallies:
           {
-            killer: 0,
             sick: 0,
             good: 0,
             bad: 0,
diff --git a/source/funkin/ui/AtlasText.hx b/source/funkin/ui/AtlasText.hx
index fea09de54..186d87c2a 100644
--- a/source/funkin/ui/AtlasText.hx
+++ b/source/funkin/ui/AtlasText.hx
@@ -274,4 +274,5 @@ enum abstract AtlasFont(String) from String to String
 {
   var DEFAULT = "default";
   var BOLD = "bold";
+  var FREEPLAY_CLEAR = "freeplay-clear";
 }
diff --git a/source/funkin/ui/MusicBeatState.hx b/source/funkin/ui/MusicBeatState.hx
index 077e9e495..33333565f 100644
--- a/source/funkin/ui/MusicBeatState.hx
+++ b/source/funkin/ui/MusicBeatState.hx
@@ -80,25 +80,11 @@ class MusicBeatState extends FlxTransitionableState implements IEventHandler
     if (FlxG.keys.justPressed.F5) debug_refreshModules();
   }
 
-  function handleQuickWatch():Void
-  {
-    // Display Conductor info in the watch window.
-    FlxG.watch.addQuick("songPosition", Conductor.songPosition);
-    FlxG.watch.addQuick("songPositionNoOffset", Conductor.songPosition + Conductor.instrumentalOffset);
-    FlxG.watch.addQuick("musicTime", FlxG.sound.music?.time ?? 0.0);
-    FlxG.watch.addQuick("bpm", Conductor.bpm);
-    FlxG.watch.addQuick("currentMeasureTime", Conductor.currentBeatTime);
-    FlxG.watch.addQuick("currentBeatTime", Conductor.currentBeatTime);
-    FlxG.watch.addQuick("currentStepTime", Conductor.currentStepTime);
-  }
-
   override function update(elapsed:Float)
   {
     super.update(elapsed);
 
     handleControls();
-    handleFunctionControls();
-    handleQuickWatch();
 
     dispatchEvent(new UpdateScriptEvent(elapsed));
   }
@@ -139,7 +125,7 @@ class MusicBeatState extends FlxTransitionableState implements IEventHandler
 
   public function stepHit():Bool
   {
-    var event = new SongTimeScriptEvent(SONG_STEP_HIT, Conductor.currentBeat, Conductor.currentStep);
+    var event = new SongTimeScriptEvent(SONG_STEP_HIT, Conductor.instance.currentBeat, Conductor.instance.currentStep);
 
     dispatchEvent(event);
 
@@ -150,7 +136,7 @@ class MusicBeatState extends FlxTransitionableState implements IEventHandler
 
   public function beatHit():Bool
   {
-    var event = new SongTimeScriptEvent(SONG_BEAT_HIT, Conductor.currentBeat, Conductor.currentStep);
+    var event = new SongTimeScriptEvent(SONG_BEAT_HIT, Conductor.instance.currentBeat, Conductor.instance.currentStep);
 
     dispatchEvent(event);
 
diff --git a/source/funkin/ui/MusicBeatSubState.hx b/source/funkin/ui/MusicBeatSubState.hx
index 9dd755b62..0fa55c234 100644
--- a/source/funkin/ui/MusicBeatSubState.hx
+++ b/source/funkin/ui/MusicBeatSubState.hx
@@ -65,12 +65,8 @@ class MusicBeatSubState extends FlxTransitionableSubState implements IEventHandl
     if (FlxG.keys.justPressed.F5) debug_refreshModules();
 
     // Display Conductor info in the watch window.
-    FlxG.watch.addQuick("songPosition", Conductor.songPosition);
     FlxG.watch.addQuick("musicTime", FlxG.sound.music?.time ?? 0.0);
-    FlxG.watch.addQuick("bpm", Conductor.bpm);
-    FlxG.watch.addQuick("currentMeasureTime", Conductor.currentBeatTime);
-    FlxG.watch.addQuick("currentBeatTime", Conductor.currentBeatTime);
-    FlxG.watch.addQuick("currentStepTime", Conductor.currentStepTime);
+    Conductor.watchQuick();
 
     dispatchEvent(new UpdateScriptEvent(elapsed));
   }
@@ -99,7 +95,7 @@ class MusicBeatSubState extends FlxTransitionableSubState implements IEventHandl
    */
   public function stepHit():Bool
   {
-    var event:ScriptEvent = new SongTimeScriptEvent(SONG_STEP_HIT, Conductor.currentBeat, Conductor.currentStep);
+    var event:ScriptEvent = new SongTimeScriptEvent(SONG_STEP_HIT, Conductor.instance.currentBeat, Conductor.instance.currentStep);
 
     dispatchEvent(event);
 
@@ -115,7 +111,7 @@ class MusicBeatSubState extends FlxTransitionableSubState implements IEventHandl
    */
   public function beatHit():Bool
   {
-    var event:ScriptEvent = new SongTimeScriptEvent(SONG_BEAT_HIT, Conductor.currentBeat, Conductor.currentStep);
+    var event:ScriptEvent = new SongTimeScriptEvent(SONG_BEAT_HIT, Conductor.instance.currentBeat, Conductor.instance.currentStep);
 
     dispatchEvent(event);
 
diff --git a/source/funkin/ui/debug/WaveformTestState.hx b/source/funkin/ui/debug/WaveformTestState.hx
new file mode 100644
index 000000000..f3566db85
--- /dev/null
+++ b/source/funkin/ui/debug/WaveformTestState.hx
@@ -0,0 +1,191 @@
+package funkin.ui.debug;
+
+import flixel.math.FlxRect;
+import flixel.FlxSprite;
+import flixel.util.FlxColor;
+import funkin.audio.FunkinSound;
+import funkin.audio.waveform.WaveformData;
+import funkin.audio.waveform.WaveformSprite;
+import funkin.audio.waveform.WaveformDataParser;
+import funkin.graphics.rendering.MeshRender;
+
+class WaveformTestState extends MusicBeatState
+{
+  public function new()
+  {
+    super();
+  }
+
+  var waveformData:WaveformData;
+  var waveformData2:WaveformData;
+
+  var waveformAudio:FunkinSound;
+
+  // var waveformSprite:WaveformSprite;
+  // var waveformSprite2:WaveformSprite;
+  var timeMarker:FlxSprite;
+
+  var polygonSprite:MeshRender;
+  var vertexCount:Int = 3;
+
+  public override function create():Void
+  {
+    super.create();
+
+    var testSprite = new FlxSprite(0, 0);
+    testSprite.loadGraphic(Paths.image('funkay'));
+    testSprite.updateHitbox();
+    testSprite.clipRect = new FlxRect(0, 0, FlxG.width, FlxG.height);
+    // add(testSprite);
+
+    waveformAudio = FunkinSound.load(Paths.inst('bopeebo', '-erect'));
+
+    waveformData = WaveformDataParser.interpretFlxSound(waveformAudio);
+
+    polygonSprite = new MeshRender(FlxG.width / 2, FlxG.height / 2, FlxColor.WHITE);
+
+    setPolygonVertices(vertexCount);
+    add(polygonSprite);
+
+    // waveformSprite = WaveformSprite.buildFromWaveformData(waveformData, HORIZONTAL, FlxColor.fromString("#ADD8E6"));
+    // waveformSprite.duration = 5.0 * 160;
+    // waveformSprite.width = FlxG.width * 160;
+    // waveformSprite.height = FlxG.height; // / 2;
+    // waveformSprite.amplitude = 2.0;
+    // waveformSprite.minWaveformSize = 25;
+    // waveformSprite.clipRect = new FlxRect(0, 0, FlxG.width, FlxG.height);
+    // add(waveformSprite);
+    //
+    // waveformSprite2 = WaveformSprite.buildFromWaveformData(waveformData2, HORIZONTAL, FlxColor.fromString("#FF0000"), 5.0);
+    // waveformSprite2.width = FlxG.width;
+    // waveformSprite2.height = FlxG.height / 2;
+    // waveformSprite2.y = FlxG.height / 2;
+    // add(waveformSprite2);
+
+    timeMarker = new FlxSprite(0, FlxG.height * 1 / 6);
+    timeMarker.makeGraphic(1, Std.int(FlxG.height * 2 / 3), FlxColor.RED);
+    add(timeMarker);
+
+    // drawWaveform(time, duration);
+  }
+
+  public override function update(elapsed:Float):Void
+  {
+    super.update(elapsed);
+
+    if (FlxG.keys.justPressed.SPACE)
+    {
+      if (waveformAudio.isPlaying)
+      {
+        waveformAudio.stop();
+      }
+      else
+      {
+        waveformAudio.play();
+      }
+    }
+
+    if (FlxG.keys.justPressed.ENTER)
+    {
+      // if (waveformSprite.orientation == HORIZONTAL)
+      // {
+      //   // waveformSprite.orientation = VERTICAL;
+      //   // waveformSprite2.orientation = VERTICAL;
+      // }
+      // else
+      // {
+      //   // waveformSprite.orientation = HORIZONTAL;
+      //   // waveformSprite2.orientation = HORIZONTAL;
+      // }
+    }
+
+    if (waveformAudio.isPlaying)
+    {
+      // waveformSprite takes a time in fractional seconds, not milliseconds.
+      var timeSeconds = waveformAudio.time / 1000;
+      // waveformSprite.time = timeSeconds;
+      // waveformSprite2.time = timeSeconds;
+    }
+
+    if (FlxG.keys.justPressed.UP)
+    {
+      vertexCount += 1;
+      setPolygonVertices(vertexCount);
+      // waveformSprite.duration += 1.0;
+      // waveformSprite2.duration += 1.0;
+    }
+    if (FlxG.keys.justPressed.DOWN)
+    {
+      vertexCount -= 1;
+      setPolygonVertices(vertexCount);
+      // waveformSprite.duration -= 1.0;
+      // waveformSprite2.duration -= 1.0;
+    }
+    if (FlxG.keys.justPressed.LEFT)
+    {
+      // waveformSprite.time -= 1.0;
+      // waveformSprite2.time -= 1.0;
+    }
+    if (FlxG.keys.justPressed.RIGHT)
+    {
+      // waveformSprite.time += 1.0;
+      // waveformSprite2.time += 1.0;
+    }
+  }
+
+  function setPolygonVertices(count:Int)
+  {
+    polygonSprite.clear();
+
+    var size = 100.0;
+
+    // Build a polygon with count vertices.
+
+    var vertices:Array<Array<Float>> = [];
+
+    var angle = 0.0;
+
+    for (i in 0...count)
+    {
+      var x = Math.cos(angle) * size;
+      var y = Math.sin(angle) * size;
+
+      vertices.push([x, y]);
+
+      angle += 2 * Math.PI / count;
+    }
+
+    trace('vertices: ${vertices}');
+
+    var centerVertex = polygonSprite.build_vertex(0, 0);
+    var firstVertex = -1;
+    var lastVertex = -1;
+
+    for (vertex in vertices)
+    {
+      var x = vertex[0];
+      var y = vertex[1];
+
+      var newVertex = polygonSprite.build_vertex(x, y);
+
+      if (firstVertex == -1)
+      {
+        firstVertex = newVertex;
+      }
+
+      if (lastVertex != -1)
+      {
+        polygonSprite.add_tri(centerVertex, lastVertex, newVertex);
+      }
+
+      lastVertex = newVertex;
+    }
+
+    polygonSprite.add_tri(centerVertex, lastVertex, firstVertex);
+  }
+
+  public override function destroy():Void
+  {
+    super.destroy();
+  }
+}
diff --git a/source/funkin/ui/debug/charting/ChartEditorState.hx b/source/funkin/ui/debug/charting/ChartEditorState.hx
index fa55750bf..53325acb8 100644
--- a/source/funkin/ui/debug/charting/ChartEditorState.hx
+++ b/source/funkin/ui/debug/charting/ChartEditorState.hx
@@ -1,41 +1,52 @@
 package funkin.ui.debug.charting;
 
-import funkin.util.logging.CrashHandler;
-import haxe.ui.containers.HBox;
-import haxe.ui.containers.Grid;
-import haxe.ui.containers.ScrollView;
-import haxe.ui.containers.menus.MenuBar;
 import flixel.addons.display.FlxSliceSprite;
 import flixel.addons.display.FlxTiledSprite;
 import flixel.addons.transition.FlxTransitionableState;
 import flixel.FlxCamera;
 import flixel.FlxSprite;
 import flixel.FlxSubState;
+import flixel.graphics.FlxGraphic;
+import flixel.group.FlxGroup.FlxTypedGroup;
 import flixel.group.FlxSpriteGroup;
 import flixel.input.keyboard.FlxKey;
+import flixel.input.mouse.FlxMouseEvent;
 import flixel.math.FlxMath;
 import flixel.math.FlxPoint;
 import flixel.math.FlxRect;
 import flixel.sound.FlxSound;
+import flixel.system.debug.log.LogStyle;
 import flixel.system.FlxAssets.FlxSoundAsset;
+import flixel.text.FlxText;
 import flixel.tweens.FlxEase;
 import flixel.tweens.FlxTween;
 import flixel.tweens.misc.VarTween;
 import flixel.util.FlxColor;
 import flixel.util.FlxSort;
 import flixel.util.FlxTimer;
+import funkin.audio.FunkinSound;
+import funkin.audio.visualize.PolygonSpectogram;
 import funkin.audio.visualize.PolygonSpectogram;
 import funkin.audio.VoicesGroup;
-import funkin.audio.FunkinSound;
+import funkin.audio.waveform.WaveformSprite;
 import funkin.data.notestyle.NoteStyleRegistry;
 import funkin.data.song.SongData.SongCharacterData;
+import funkin.data.song.SongData.SongCharacterData;
+import funkin.data.song.SongData.SongChartData;
 import funkin.data.song.SongData.SongChartData;
 import funkin.data.song.SongData.SongEventData;
+import funkin.data.song.SongData.SongEventData;
 import funkin.data.song.SongData.SongMetadata;
+import funkin.data.song.SongData.SongMetadata;
+import funkin.data.song.SongData.SongNoteData;
 import funkin.data.song.SongData.SongNoteData;
 import funkin.data.song.SongData.SongOffsets;
 import funkin.data.song.SongDataUtils;
+import funkin.data.song.SongDataUtils;
 import funkin.data.song.SongRegistry;
+import funkin.data.song.SongRegistry;
+import funkin.data.stage.StageData;
+import funkin.graphics.FunkinSprite;
 import funkin.input.Cursor;
 import funkin.input.TurboKeyHandler;
 import funkin.modding.events.ScriptEvent;
@@ -46,20 +57,13 @@ import funkin.play.components.HealthIcon;
 import funkin.play.notes.NoteSprite;
 import funkin.play.PlayState;
 import funkin.play.song.Song;
-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.SongCharacterData;
-import funkin.data.song.SongDataUtils;
-import funkin.ui.debug.charting.commands.ChartEditorCommand;
-import funkin.play.stage.StageData;
 import funkin.save.Save;
 import funkin.ui.debug.charting.commands.AddEventsCommand;
 import funkin.ui.debug.charting.commands.AddNotesCommand;
 import funkin.ui.debug.charting.commands.ChartEditorCommand;
 import funkin.ui.debug.charting.commands.ChartEditorCommand;
+import funkin.ui.debug.charting.commands.ChartEditorCommand;
+import funkin.ui.debug.charting.commands.CopyItemsCommand;
 import funkin.ui.debug.charting.commands.CutItemsCommand;
 import funkin.ui.debug.charting.commands.DeselectAllItemsCommand;
 import funkin.ui.debug.charting.commands.DeselectItemsCommand;
@@ -78,16 +82,22 @@ import funkin.ui.debug.charting.commands.SelectItemsCommand;
 import funkin.ui.debug.charting.commands.SetItemSelectionCommand;
 import funkin.ui.debug.charting.components.ChartEditorEventSprite;
 import funkin.ui.debug.charting.components.ChartEditorHoldNoteSprite;
+import funkin.ui.debug.charting.components.ChartEditorMeasureTicks;
 import funkin.ui.debug.charting.components.ChartEditorNotePreview;
 import funkin.ui.debug.charting.components.ChartEditorNoteSprite;
 import funkin.ui.debug.charting.components.ChartEditorPlaybarHead;
 import funkin.ui.debug.charting.components.ChartEditorSelectionSquareSprite;
 import funkin.ui.debug.charting.handlers.ChartEditorShortcutHandler;
+import funkin.ui.debug.charting.toolboxes.ChartEditorBaseToolbox;
+import funkin.ui.debug.charting.toolboxes.ChartEditorDifficultyToolbox;
+import funkin.ui.debug.charting.toolboxes.ChartEditorFreeplayToolbox;
+import funkin.ui.debug.charting.toolboxes.ChartEditorOffsetsToolbox;
 import funkin.ui.haxeui.components.CharacterPlayer;
 import funkin.ui.haxeui.HaxeUIState;
 import funkin.ui.mainmenu.MainMenuState;
 import funkin.util.Constants;
 import funkin.util.FileUtil;
+import funkin.util.logging.CrashHandler;
 import funkin.util.SortUtil;
 import funkin.util.WindowUtil;
 import haxe.DynamicAccess;
@@ -95,22 +105,26 @@ import haxe.io.Bytes;
 import haxe.io.Path;
 import haxe.ui.backend.flixel.UIRuntimeState;
 import haxe.ui.backend.flixel.UIState;
-import haxe.ui.components.DropDown;
-import haxe.ui.components.Label;
 import haxe.ui.components.Button;
+import haxe.ui.components.DropDown;
+import haxe.ui.components.Image;
+import haxe.ui.components.Label;
 import haxe.ui.components.NumberStepper;
 import haxe.ui.components.Slider;
 import haxe.ui.components.TextField;
+import haxe.ui.containers.Box;
 import haxe.ui.containers.dialogs.CollapsibleDialog;
 import haxe.ui.containers.Frame;
+import haxe.ui.containers.Grid;
+import haxe.ui.containers.HBox;
 import haxe.ui.containers.menus.Menu;
 import haxe.ui.containers.menus.MenuBar;
-import haxe.ui.containers.menus.MenuItem;
+import haxe.ui.containers.menus.MenuBar;
 import haxe.ui.containers.menus.MenuCheckBox;
+import haxe.ui.containers.menus.MenuItem;
+import haxe.ui.containers.ScrollView;
 import haxe.ui.containers.TreeView;
 import haxe.ui.containers.TreeViewNode;
-import haxe.ui.components.Image;
-import funkin.ui.debug.charting.toolboxes.ChartEditorBaseToolbox;
 import haxe.ui.core.Component;
 import haxe.ui.core.Screen;
 import haxe.ui.events.DragEvent;
@@ -118,12 +132,8 @@ import haxe.ui.events.MouseEvent;
 import haxe.ui.events.UIEvent;
 import haxe.ui.events.UIEvent;
 import haxe.ui.focus.FocusManager;
+import haxe.ui.Toolkit;
 import openfl.display.BitmapData;
-import funkin.audio.visualize.PolygonSpectogram;
-import flixel.group.FlxGroup.FlxTypedGroup;
-import funkin.audio.visualize.PolygonVisGroup;
-import flixel.input.mouse.FlxMouseEvent;
-import flixel.text.FlxText;
 
 using Lambda;
 
@@ -145,17 +155,19 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
    */
   // ==============================
   // Layouts
-  public static final CHART_EDITOR_TOOLBOX_NOTEDATA_LAYOUT:String = Paths.ui('chart-editor/toolbox/notedata');
-
-  public static final CHART_EDITOR_TOOLBOX_EVENTDATA_LAYOUT:String = Paths.ui('chart-editor/toolbox/eventdata');
-  public static final CHART_EDITOR_TOOLBOX_PLAYTEST_PROPERTIES_LAYOUT:String = Paths.ui('chart-editor/toolbox/playtest-properties');
-  public static final CHART_EDITOR_TOOLBOX_METADATA_LAYOUT:String = Paths.ui('chart-editor/toolbox/metadata');
   public static final CHART_EDITOR_TOOLBOX_DIFFICULTY_LAYOUT:String = Paths.ui('chart-editor/toolbox/difficulty');
+
   public static final CHART_EDITOR_TOOLBOX_PLAYER_PREVIEW_LAYOUT:String = Paths.ui('chart-editor/toolbox/player-preview');
   public static final CHART_EDITOR_TOOLBOX_OPPONENT_PREVIEW_LAYOUT:String = Paths.ui('chart-editor/toolbox/opponent-preview');
+  public static final CHART_EDITOR_TOOLBOX_METADATA_LAYOUT:String = Paths.ui('chart-editor/toolbox/metadata');
+  public static final CHART_EDITOR_TOOLBOX_OFFSETS_LAYOUT:String = Paths.ui('chart-editor/toolbox/offsets');
+  public static final CHART_EDITOR_TOOLBOX_NOTEDATA_LAYOUT:String = Paths.ui('chart-editor/toolbox/notedata');
+  public static final CHART_EDITOR_TOOLBOX_EVENT_DATA_LAYOUT:String = Paths.ui('chart-editor/toolbox/eventdata');
+  public static final CHART_EDITOR_TOOLBOX_FREEPLAY_LAYOUT:String = Paths.ui('chart-editor/toolbox/freeplay');
+  public static final CHART_EDITOR_TOOLBOX_PLAYTEST_PROPERTIES_LAYOUT:String = Paths.ui('chart-editor/toolbox/playtest-properties');
 
   // Validation
-  public static final SUPPORTED_MUSIC_FORMATS:Array<String> = ['ogg'];
+  public static final SUPPORTED_MUSIC_FORMATS:Array<String> = #if sys ['ogg'] #else ['mp3'] #end;
 
   // Layout
 
@@ -167,7 +179,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
   /**
    * The width of the scroll area.
    */
-  public static final PLAYHEAD_SCROLL_AREA_WIDTH:Int = 12;
+  public static final PLAYHEAD_SCROLL_AREA_WIDTH:Int = Std.int(GRID_SIZE);
 
   /**
    * The height of the playhead, in pixels.
@@ -189,10 +201,40 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
    */
   public static final PLAYBAR_HEIGHT:Int = 48;
 
+  /**
+   * The height of the note selection buttons above the grid.
+   */
+  public static final NOTE_SELECT_BUTTON_HEIGHT:Int = 24;
+
   /**
    * The amount of padding between the menu bar and the chart grid when fully scrolled up.
    */
-  public static final GRID_TOP_PAD:Int = 8;
+  public static final GRID_TOP_PAD:Int = NOTE_SELECT_BUTTON_HEIGHT + 12;
+
+  /**
+   * The initial vertical position of the chart grid.
+   */
+  public static final GRID_INITIAL_Y_POS:Int = MENU_BAR_HEIGHT + GRID_TOP_PAD;
+
+  /**
+   * The X position of the note preview area.
+   */
+  public static final NOTE_PREVIEW_X_POS:Int = 320;
+
+  /**
+   * The Y position of the note preview area.
+   */
+  public static final NOTE_PREVIEW_Y_POS:Int = GRID_INITIAL_Y_POS - NOTE_SELECT_BUTTON_HEIGHT - 4;
+
+  /**
+   * The X position of the note grid.
+   */
+  public static var GRID_X_POS(get, never):Float;
+
+  static function get_GRID_X_POS():Float
+  {
+    return FlxG.width / 2 - GRID_SIZE * STRUMLINE_SIZE;
+  }
 
   // Colors
   // Background color tint.
@@ -236,6 +278,16 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
    */
   public static final BASE_QUANT_INDEX:Int = 3;
 
+  /**
+   * The duration before the welcome music starts to fade back in after the user stops playing music in the chart editor.
+   */
+  public static final WELCOME_MUSIC_FADE_IN_DELAY:Float = 30.0;
+
+  /**
+   * The duration of the welcome music fade in.
+   */
+  public static final WELCOME_MUSIC_FADE_IN_DURATION:Float = 10.0;
+
   /**
    * INSTANCE DATA
    */
@@ -273,13 +325,13 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
 
   function get_songLengthInSteps():Float
   {
-    return Conductor.getTimeInSteps(songLengthInMs);
+    return Conductor.instance.getTimeInSteps(songLengthInMs);
   }
 
   function set_songLengthInSteps(value:Float):Float
   {
-    // Getting a reasonable result from setting songLengthInSteps requires that Conductor.mapBPMChanges be called first.
-    songLengthInMs = Conductor.getStepTimeInMs(value);
+    // Getting a reasonable result from setting songLengthInSteps requires that Conductor.instance.mapBPMChanges be called first.
+    songLengthInMs = Conductor.instance.getStepTimeInMs(value);
     return value;
   }
 
@@ -333,25 +385,24 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
     this.scrollPositionInPixels = value;
 
     // Move the grid sprite to the correct position.
-    if (gridTiledSprite != null && gridPlayheadScrollArea != null)
+    if (gridTiledSprite != null && measureTicks != null)
     {
       if (isViewDownscroll)
       {
-        gridTiledSprite.y = -scrollPositionInPixels + (MENU_BAR_HEIGHT + GRID_TOP_PAD);
-        gridPlayheadScrollArea.y = gridTiledSprite.y;
+        gridTiledSprite.y = -scrollPositionInPixels + (GRID_INITIAL_Y_POS);
+        measureTicks.y = gridTiledSprite.y;
       }
       else
       {
-        gridTiledSprite.y = -scrollPositionInPixels + (MENU_BAR_HEIGHT + GRID_TOP_PAD);
-        gridPlayheadScrollArea.y = gridTiledSprite.y;
+        gridTiledSprite.y = -scrollPositionInPixels + (GRID_INITIAL_Y_POS);
+        measureTicks.y = gridTiledSprite.y;
 
-        if (audioVisGroup != null && audioVisGroup.playerVis != null)
+        for (member in audioWaveforms.members)
         {
-          audioVisGroup.playerVis.y = Math.max(gridTiledSprite.y, MENU_BAR_HEIGHT);
-        }
-        if (audioVisGroup != null && audioVisGroup.opponentVis != null)
-        {
-          audioVisGroup.opponentVis.y = Math.max(gridTiledSprite.y, MENU_BAR_HEIGHT);
+          member.time = scrollPositionInMs / Constants.MS_PER_SEC;
+
+          // Doing this desyncs the waveforms from the grid.
+          // member.y = Math.max(this.gridTiledSprite?.y ?? 0.0, ChartEditorState.GRID_INITIAL_Y_POS - ChartEditorState.GRID_TOP_PAD);
         }
       }
     }
@@ -365,6 +416,8 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
     if (selectionBoxStartPos != null) selectionBoxStartPos.y -= diff;
     // Update the note preview viewport box.
     setNotePreviewViewportBounds(calculateNotePreviewViewportBounds());
+    // Update the measure tick display.
+    if (measureTicks != null) measureTicks.y = gridTiledSprite?.y ?? 0.0;
     return this.scrollPositionInPixels;
   }
 
@@ -393,12 +446,12 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
 
   function get_scrollPositionInMs():Float
   {
-    return Conductor.getStepTimeInMs(scrollPositionInSteps);
+    return Conductor.instance.getStepTimeInMs(scrollPositionInSteps);
   }
 
   function set_scrollPositionInMs(value:Float):Float
   {
-    scrollPositionInSteps = Conductor.getTimeInSteps(value);
+    scrollPositionInSteps = Conductor.instance.getTimeInSteps(value);
     return value;
   }
 
@@ -421,7 +474,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
     this.playheadPositionInPixels = value;
 
     // Move the playhead sprite to the correct position.
-    gridPlayhead.y = this.playheadPositionInPixels + (MENU_BAR_HEIGHT + GRID_TOP_PAD);
+    gridPlayhead.y = this.playheadPositionInPixels + GRID_INITIAL_Y_POS;
 
     return this.playheadPositionInPixels;
   }
@@ -451,16 +504,13 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
 
   function get_playheadPositionInMs():Float
   {
-    if (audioVisGroup != null && audioVisGroup.playerVis != null)
-      audioVisGroup.playerVis.realtimeStartOffset = -Conductor.getStepTimeInMs(playheadPositionInSteps);
-    return Conductor.getStepTimeInMs(playheadPositionInSteps);
+    return Conductor.instance.getStepTimeInMs(playheadPositionInSteps);
   }
 
   function set_playheadPositionInMs(value:Float):Float
   {
-    playheadPositionInSteps = Conductor.getTimeInSteps(value);
+    playheadPositionInSteps = Conductor.instance.getTimeInSteps(value);
 
-    if (audioVisGroup != null && audioVisGroup.playerVis != null) audioVisGroup.playerVis.realtimeStartOffset = -value;
     return value;
   }
 
@@ -489,17 +539,17 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
   /**
    * The note kind to use for notes being placed in the chart. Defaults to `''`.
    */
-  var selectedNoteKind:String = '';
+  var noteKindToPlace:String = '';
 
   /**
    * The event type to use for events being placed in the chart. Defaults to `''`.
    */
-  var selectedEventKind:String = 'FocusCamera';
+  var eventKindToPlace:String = 'FocusCamera';
 
   /**
    * The event data to use for events being placed in the chart.
    */
-  var selectedEventData:DynamicAccess<Dynamic> = {};
+  var eventDataToPlace:DynamicAccess<Dynamic> = {};
 
   /**
    * The internal index of what note snapping value is in use.
@@ -544,6 +594,11 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
    */
   var playtestPracticeMode:Bool = false;
 
+  /**
+   * Enables or disables the "debugger" popup that appears when you run into a flixel error.
+   */
+  var enabledDebuggerPopup:Bool = true;
+
   // Visuals
 
   /**
@@ -635,6 +690,11 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
    */
   var activeToolboxes:Map<String, CollapsibleDialog> = new Map<String, CollapsibleDialog>();
 
+  /**
+   * The camera component we're using for this state.
+   */
+  var uiCamera:FlxCamera;
+
   // Audio
 
   /**
@@ -707,7 +767,14 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
    * `null` if the user isn't currently placing a note.
    * As the user drags, we will update this note's sustain length, and finalize the note when they release.
    */
-  var currentPlaceNoteData:Null<SongNoteData> = null;
+  var currentPlaceNoteData(default, set):Null<SongNoteData> = null;
+
+  function set_currentPlaceNoteData(value:Null<SongNoteData>):Null<SongNoteData>
+  {
+    noteDisplayDirty = true;
+
+    return currentPlaceNoteData = value;
+  }
 
   // Note Movement
 
@@ -753,15 +820,23 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
 
   function set_currentNoteSelection(value:Array<SongNoteData>):Array<SongNoteData>
   {
+    // This value is true if all elements of the current selection are also in the new selection.
+    var isSuperset:Bool = currentNoteSelection.isSubset(value);
+    var isEqual:Bool = currentNoteSelection.isEqualUnordered(value);
+
     currentNoteSelection = value;
 
-    if (currentNoteSelection.length > 0)
+    if (!isEqual)
     {
-      notePreview.addNotes(currentNoteSelection, Std.int(songLengthInMs), true);
-    }
-    else
-    {
-      notePreviewDirty = true;
+      if (currentNoteSelection.length > 0 && isSuperset)
+      {
+        notePreview.addSelectedNotes(currentNoteSelection, Std.int(songLengthInMs));
+      }
+      else
+      {
+        // The new selection removes elements from the old selection, so we have to redraw the note preview.
+        notePreviewDirty = true;
+      }
     }
 
     return currentNoteSelection;
@@ -860,6 +935,70 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
     return Save.get().chartEditorHasBackup = value;
   }
 
+  /**
+   * A list of previous working file paths.
+   * Also known as the "recent files" list.
+   * The first element is [null] if the current working file has not been saved anywhere yet.
+   */
+  public var previousWorkingFilePaths(default, set):Array<Null<String>> = [null];
+
+  function set_previousWorkingFilePaths(value:Array<Null<String>>):Array<Null<String>>
+  {
+    // Called only when the WHOLE LIST is overridden.
+    previousWorkingFilePaths = value;
+    applyWindowTitle();
+    populateOpenRecentMenu();
+    applyCanQuickSave();
+    return value;
+  }
+
+  /**
+   * The current file path which the chart editor is working with.
+   * If `null`, the current chart has not been saved yet.
+   */
+  public var currentWorkingFilePath(get, set):Null<String>;
+
+  function get_currentWorkingFilePath():Null<String>
+  {
+    return previousWorkingFilePaths[0];
+  }
+
+  function set_currentWorkingFilePath(value:Null<String>):Null<String>
+  {
+    if (value == previousWorkingFilePaths[0]) return value;
+
+    if (previousWorkingFilePaths.contains(null))
+    {
+      // Filter all instances of `null` from the array.
+      previousWorkingFilePaths = previousWorkingFilePaths.filter(function(x:Null<String>):Bool {
+        return x != null;
+      });
+    }
+
+    if (previousWorkingFilePaths.contains(value))
+    {
+      // Move the path to the front of the list.
+      previousWorkingFilePaths.remove(value);
+      previousWorkingFilePaths.unshift(value);
+    }
+    else
+    {
+      // Add the path to the front of the list.
+      previousWorkingFilePaths.unshift(value);
+    }
+
+    while (previousWorkingFilePaths.length > Constants.MAX_PREVIOUS_WORKING_FILES)
+    {
+      // Remove the last path in the list.
+      previousWorkingFilePaths.pop();
+    }
+
+    populateOpenRecentMenu();
+    applyWindowTitle();
+
+    return value;
+  }
+
   /**
    * Whether the difficulty tree view in the toolbox has been modified and needs to be updated.
    * This happens when we add/remove difficulties.
@@ -889,6 +1028,12 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
    */
   var commandHistoryDirty:Bool = true;
 
+  /**
+   * If true, we are currently in the process of quitting the chart editor.
+   * Skip any update functions as most of them will call a crash.
+   */
+  var criticalFailure:Bool = false;
+
   // Input
 
   /**
@@ -961,14 +1106,14 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
    * `null` until vocal track(s) are loaded.
    * When switching characters, the elements of the VoicesGroup will be swapped to match the new character.
    */
-  var audioVocalTrackGroup:Null<VoicesGroup> = null;
+  var audioVocalTrackGroup:VoicesGroup = new VoicesGroup();
 
   /**
-   * The audio vis for the inst/vocals.
+   * The audio waveform visualization for the inst/vocals.
    * `null` until vocal track(s) are loaded.
-   * When switching characters, the elements of the PolygonVisGroup will be swapped to match the new character.
+   * When switching characters, the elements will be swapped to match the new character.
    */
-  var audioVisGroup:Null<PolygonVisGroup> = null;
+  var audioWaveforms:FlxTypedSpriteGroup<WaveformSprite> = new FlxTypedSpriteGroup<WaveformSprite>();
 
   /**
    * A map of the audio tracks for each character's vocals.
@@ -1010,7 +1155,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
   function get_availableDifficulties():Array<String>
   {
     var m:Null<SongMetadata> = songMetadata.get(selectedVariation);
-    return m?.playData?.difficulties ?? [];
+    return m?.playData?.difficulties ?? [Constants.DEFAULT_DIFFICULTY];
   }
 
   /**
@@ -1069,7 +1214,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
     var result:Null<SongChartData> = songChartData.get(selectedVariation);
     if (result == null)
     {
-      result = new SongChartData(["normal" => 1.0], [], ["normal" => []]);
+      result = new SongChartData([Constants.DEFAULT_DIFFICULTY => 1.0], [], [Constants.DEFAULT_DIFFICULTY => []]);
       songChartData.set(selectedVariation, result);
     }
     return result;
@@ -1168,6 +1313,30 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
     return currentSongMetadata.playData.noteStyle = value;
   }
 
+  var currentSongFreeplayPreviewStart(get, set):Int;
+
+  function get_currentSongFreeplayPreviewStart():Int
+  {
+    return currentSongMetadata.playData.previewStart;
+  }
+
+  function set_currentSongFreeplayPreviewStart(value:Int):Int
+  {
+    return currentSongMetadata.playData.previewStart = value;
+  }
+
+  var currentSongFreeplayPreviewEnd(get, set):Int;
+
+  function get_currentSongFreeplayPreviewEnd():Int
+  {
+    return currentSongMetadata.playData.previewEnd;
+  }
+
+  function set_currentSongFreeplayPreviewEnd(value:Int):Int
+  {
+    return currentSongMetadata.playData.previewEnd = value;
+  }
+
   var currentSongStage(get, set):String;
 
   function get_currentSongStage():String
@@ -1226,6 +1395,46 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
     return currentSongMetadata.artist = value;
   }
 
+  /**
+   * Convenience property to get the player charId for the current variation.
+   */
+  var currentPlayerChar(get, set):String;
+
+  function get_currentPlayerChar():String
+  {
+    if (currentSongMetadata.playData.characters.player == null)
+    {
+      // Initialize to the default value if not set.
+      currentSongMetadata.playData.characters.player = Constants.DEFAULT_CHARACTER;
+    }
+    return currentSongMetadata.playData.characters.player;
+  }
+
+  function set_currentPlayerChar(value:String):String
+  {
+    return currentSongMetadata.playData.characters.player = value;
+  }
+
+  /**
+   * Convenience property to get the opponent charId for the current variation.
+   */
+  var currentOpponentChar(get, set):String;
+
+  function get_currentOpponentChar():String
+  {
+    if (currentSongMetadata.playData.characters.opponent == null)
+    {
+      // Initialize to the default value if not set.
+      currentSongMetadata.playData.characters.opponent = Constants.DEFAULT_CHARACTER;
+    }
+    return currentSongMetadata.playData.characters.opponent;
+  }
+
+  function set_currentOpponentChar(value:String):String
+  {
+    return currentSongMetadata.playData.characters.opponent = value;
+  }
+
   /**
    * Convenience property to get the song offset data for the current variation.
    */
@@ -1261,6 +1470,32 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
     return value;
   }
 
+  var currentVocalOffsetPlayer(get, set):Float;
+
+  function get_currentVocalOffsetPlayer():Float
+  {
+    return currentSongOffsets.getVocalOffset(currentPlayerChar);
+  }
+
+  function set_currentVocalOffsetPlayer(value:Float):Float
+  {
+    currentSongOffsets.setVocalOffset(currentPlayerChar, value);
+    return value;
+  }
+
+  var currentVocalOffsetOpponent(get, set):Float;
+
+  function get_currentVocalOffsetOpponent():Float
+  {
+    return currentSongOffsets.getVocalOffset(currentOpponentChar);
+  }
+
+  function set_currentVocalOffsetOpponent(value:Float):Float
+  {
+    currentSongOffsets.setVocalOffset(currentOpponentChar, value);
+    return value;
+  }
+
   /**
    * The variation ID for the difficulty which is currently being edited.
    */
@@ -1293,6 +1528,8 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
 
   function set_selectedDifficulty(value:String):String
   {
+    if (value == null) value = availableDifficulties[0] ?? Constants.DEFAULT_DIFFICULTY;
+
     selectedDifficulty = value;
 
     // Make sure view is updated when the difficulty changes.
@@ -1506,6 +1743,11 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
    */
   var menubarItemOpponentHitsounds:MenuCheckBox;
 
+  /**
+   * The `Audio -> Play Theme Music` menu checkbox.
+   */
+  var menubarItemThemeMusic:MenuCheckBox;
+
   /**
    * The `Audio -> Hitsound Volume` label.
    */
@@ -1527,14 +1769,24 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
   var menubarItemVolumeInstrumental:Slider;
 
   /**
-   * The `Audio -> Vocal Volume` label.
+   * The `Audio -> Player Volume` label.
    */
-  var menubarLabelVolumeVocals:Label;
+  var menubarLabelVolumeVocalsPlayer:Label;
 
   /**
-   * The `Audio -> Vocal Volume` slider.
+   * The `Audio -> Enemy Volume` label.
    */
-  var menubarItemVolumeVocals:Slider;
+  var menubarLabelVolumeVocalsOpponent:Label;
+
+  /**
+   * The `Audio -> Player Volume` slider.
+   */
+  var menubarItemVolumeVocalsPlayer:Slider;
+
+  /**
+   * The `Audio -> Enemy Volume` slider.
+   */
+  var menubarItemVolumeVocalsOpponent:Slider;
 
   /**
    * The `Audio -> Playback Speed` label.
@@ -1586,6 +1838,36 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
    */
   var playbarEnd:Button;
 
+  /**
+   * The button above the grid that selects all notes on the opponent's side.
+   * Constructed manually and added to the layout so we can control its position.
+   */
+  var buttonSelectOpponent:Button;
+
+  /**
+   * The button above the grid that selects all notes on the player's side.
+   * Constructed manually and added to the layout so we can control its position.
+   */
+  var buttonSelectPlayer:Button;
+
+  /**
+   * The button above the grid that selects all song events.
+   * Constructed manually and added to the layout so we can control its position.
+   */
+  var buttonSelectEvent:Button;
+
+  /**
+   * The slider above the grid that sets the volume of the player's sounds.
+   * Constructed manually and added to the layout so we can control its position.
+   */
+  var sliderVolumePlayer:Slider;
+
+  /**
+   * The slider above the grid that sets the volume of the opponent's sounds.
+   * Constructed manually and added to the layout so we can control its position.
+   */
+  var sliderVolumeOpponent:Slider;
+
   /**
    * RENDER OBJECTS
    */
@@ -1614,23 +1896,33 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
    */
   var notePreviewViewportBitmap:Null<BitmapData> = null;
 
+  /**
+   * The IMAGE used for the measure ticks. Updated by ChartEditorThemeHandler.
+   */
+  var measureTickBitmap:Null<BitmapData> = null;
+
+  /**
+   * The IMAGE used for the offset ticks. Updated by ChartEditorThemeHandler.
+   */
+  var offsetTickBitmap:Null<BitmapData> = null;
+
   /**
    * The tiled sprite used to display the grid.
    * The height is the length of the song, and scrolling is done by simply the sprite.
    */
   var gridTiledSprite:Null<FlxSprite> = null;
 
+  /**
+   * The measure ticks area. Includes the numbers and the background sprite.
+   */
+  var measureTicks:Null<ChartEditorMeasureTicks> = null;
+
   /**
    * The playhead representing the current position in the song.
    * Can move around on the grid independently of the view.
    */
   var gridPlayhead:FlxSpriteGroup = new FlxSpriteGroup();
 
-  /**
-   * The sprite for the scroll area under
-   */
-  var gridPlayheadScrollArea:Null<FlxSprite> = null;
-
   /**
    * A sprite used to indicate the note that will be placed on click.
    */
@@ -1717,70 +2009,6 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
    */
   var params:Null<ChartEditorParams>;
 
-  /**
-   * A list of previous working file paths.
-   * Also known as the "recent files" list.
-   * The first element is [null] if the current working file has not been saved anywhere yet.
-   */
-  public var previousWorkingFilePaths(default, set):Array<Null<String>> = [null];
-
-  function set_previousWorkingFilePaths(value:Array<Null<String>>):Array<Null<String>>
-  {
-    // Called only when the WHOLE LIST is overridden.
-    previousWorkingFilePaths = value;
-    applyWindowTitle();
-    populateOpenRecentMenu();
-    applyCanQuickSave();
-    return value;
-  }
-
-  /**
-   * The current file path which the chart editor is working with.
-   * If `null`, the current chart has not been saved yet.
-   */
-  public var currentWorkingFilePath(get, set):Null<String>;
-
-  function get_currentWorkingFilePath():Null<String>
-  {
-    return previousWorkingFilePaths[0];
-  }
-
-  function set_currentWorkingFilePath(value:Null<String>):Null<String>
-  {
-    if (value == previousWorkingFilePaths[0]) return value;
-
-    if (previousWorkingFilePaths.contains(null))
-    {
-      // Filter all instances of `null` from the array.
-      previousWorkingFilePaths = previousWorkingFilePaths.filter(function(x:Null<String>):Bool {
-        return x != null;
-      });
-    }
-
-    if (previousWorkingFilePaths.contains(value))
-    {
-      // Move the path to the front of the list.
-      previousWorkingFilePaths.remove(value);
-      previousWorkingFilePaths.unshift(value);
-    }
-    else
-    {
-      // Add the path to the front of the list.
-      previousWorkingFilePaths.unshift(value);
-    }
-
-    while (previousWorkingFilePaths.length > Constants.MAX_PREVIOUS_WORKING_FILES)
-    {
-      // Remove the last path in the list.
-      previousWorkingFilePaths.pop();
-    }
-
-    populateOpenRecentMenu();
-    applyWindowTitle();
-
-    return value;
-  }
-
   public function new(?params:ChartEditorParams)
   {
     super();
@@ -1832,19 +2060,20 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
     // Set the z-index of the HaxeUI.
     this.root.zIndex = 100;
 
-    // Show the mouse cursor.
-    Cursor.show();
-
-    loadPreferences();
-
-    fixCamera();
-
     // Get rid of any music from the previous state.
     if (FlxG.sound.music != null) FlxG.sound.music.stop();
 
     // Play the welcome music.
     setupWelcomeMusic();
 
+    // Show the mouse cursor.
+    Cursor.show();
+
+    loadPreferences();
+
+    uiCamera = new FlxCamera();
+    FlxG.cameras.reset(uiCamera);
+
     buildDefaultSongData();
 
     buildBackground();
@@ -1852,8 +2081,8 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
     this.updateTheme();
 
     buildGrid();
+    buildMeasureTicks();
     buildNotePreview();
-    buildSelectionBox();
 
     buildAdditionalUI();
     populateOpenRecentMenu();
@@ -1861,6 +2090,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
 
     // Setup the onClick listeners for the UI after it's been created.
     setupUIListeners();
+    setupContextMenu();
     setupTurboKeyHandlers();
 
     setupAutoSave();
@@ -1936,6 +2166,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
     hitsoundVolume = save.chartEditorHitsoundVolume;
     hitsoundsEnabledPlayer = save.chartEditorHitsoundsEnabledPlayer;
     hitsoundsEnabledOpponent = save.chartEditorHitsoundsEnabledOpponent;
+    this.welcomeMusic.active = save.chartEditorThemeMusic;
 
     // audioInstTrack.volume = save.chartEditorInstVolume;
     // audioInstTrack.pitch = save.chartEditorPlaybackSpeed;
@@ -1965,6 +2196,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
     save.chartEditorHitsoundVolume = hitsoundVolume;
     save.chartEditorHitsoundsEnabledPlayer = hitsoundsEnabledPlayer;
     save.chartEditorHitsoundsEnabledOpponent = hitsoundsEnabledOpponent;
+    save.chartEditorThemeMusic = this.welcomeMusic.active;
 
     // save.chartEditorInstVolume = audioInstTrack.volume;
     // save.chartEditorVoicesVolume = audioVocalTrackGroup.volume;
@@ -2025,10 +2257,19 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
 
   function fadeInWelcomeMusic(?extraWait:Float = 0, ?fadeInTime:Float = 5):Void
   {
+    if (!this.welcomeMusic.active)
+    {
+      stopWelcomeMusic();
+      return;
+    }
+
     bgMusicTimer = new FlxTimer().start(extraWait, (_) -> {
       this.welcomeMusic.volume = 0;
-      this.welcomeMusic.play();
-      this.welcomeMusic.fadeIn(fadeInTime, 0, 1.0);
+      if (this.welcomeMusic.active)
+      {
+        this.welcomeMusic.play();
+        this.welcomeMusic.fadeIn(fadeInTime, 0, 1.0);
+      }
     });
   }
 
@@ -2049,8 +2290,6 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
 
     // Initialize the song chart data.
     songChartData = new Map<String, SongChartData>();
-
-    audioVocalTrackGroup = new VoicesGroup();
   }
 
   /**
@@ -2078,8 +2317,8 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
     if (gridBitmap == null) throw 'ERROR: Tried to build grid, but gridBitmap is null! Check ChartEditorThemeHandler.updateTheme().';
 
     gridTiledSprite = new FlxTiledSprite(gridBitmap, gridBitmap.width, 1000, false, true);
-    gridTiledSprite.x = FlxG.width / 2 - GRID_SIZE * STRUMLINE_SIZE; // Center the grid.
-    gridTiledSprite.y = MENU_BAR_HEIGHT + GRID_TOP_PAD; // Push down to account for the menu bar.
+    gridTiledSprite.x = GRID_X_POS; // Center the grid.
+    gridTiledSprite.y = GRID_INITIAL_Y_POS; // Push down to account for the menu bar.
     add(gridTiledSprite);
     gridTiledSprite.zIndex = 10;
 
@@ -2097,7 +2336,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
     add(gridGhostHoldNote);
     gridGhostHoldNote.zIndex = 11;
 
-    gridGhostEvent = new ChartEditorEventSprite(this);
+    gridGhostEvent = new ChartEditorEventSprite(this, true);
     gridGhostEvent.alpha = 0.6;
     gridGhostEvent.eventData = new SongEventData(-1, '', {});
     gridGhostEvent.visible = false;
@@ -2106,23 +2345,14 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
 
     buildNoteGroup();
 
-    gridPlayheadScrollArea = new FlxSprite(0, 0);
-    gridPlayheadScrollArea.makeGraphic(10, 10, PLAYHEAD_SCROLL_AREA_COLOR); // Make it 10x10px and then scale it as needed.
-    add(gridPlayheadScrollArea);
-    gridPlayheadScrollArea.setGraphicSize(PLAYHEAD_SCROLL_AREA_WIDTH, 3000);
-    gridPlayheadScrollArea.updateHitbox();
-    gridPlayheadScrollArea.x = gridTiledSprite.x - PLAYHEAD_SCROLL_AREA_WIDTH;
-    gridPlayheadScrollArea.y = MENU_BAR_HEIGHT + GRID_TOP_PAD;
-    gridPlayheadScrollArea.zIndex = 25;
-
     // The playhead that show the current position in the song.
     add(gridPlayhead);
     gridPlayhead.zIndex = 30;
 
     var playheadWidth:Int = GRID_SIZE * (STRUMLINE_SIZE * 2 + 1) + (PLAYHEAD_SCROLL_AREA_WIDTH * 2);
-    var playheadBaseYPos:Float = MENU_BAR_HEIGHT + GRID_TOP_PAD;
-    gridPlayhead.setPosition(gridTiledSprite.x, playheadBaseYPos);
-    var playheadSprite:FlxSprite = new FlxSprite().makeGraphic(playheadWidth, PLAYHEAD_HEIGHT, PLAYHEAD_COLOR);
+    var playheadBaseYPos:Float = GRID_INITIAL_Y_POS;
+    gridPlayhead.setPosition(GRID_X_POS, playheadBaseYPos);
+    var playheadSprite:FunkinSprite = new FunkinSprite().makeSolidColor(playheadWidth, PLAYHEAD_HEIGHT, PLAYHEAD_COLOR);
     playheadSprite.x = -PLAYHEAD_SCROLL_AREA_WIDTH;
     playheadSprite.y = 0;
     gridPlayhead.add(playheadSprite);
@@ -2146,16 +2376,27 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
     add(healthIconBF);
     healthIconBF.zIndex = 30;
 
-    audioVisGroup = new PolygonVisGroup();
-    add(audioVisGroup);
+    add(audioWaveforms);
+  }
+
+  function buildMeasureTicks():Void
+  {
+    measureTicks = new ChartEditorMeasureTicks(this);
+    var measureTicksWidth = (GRID_SIZE);
+    measureTicks.x = gridTiledSprite.x - measureTicksWidth;
+    measureTicks.y = MENU_BAR_HEIGHT + GRID_TOP_PAD;
+    measureTicks.zIndex = 20;
+
+    add(measureTicks);
   }
 
   function buildNotePreview():Void
   {
-    var height:Int = FlxG.height - MENU_BAR_HEIGHT - GRID_TOP_PAD - PLAYBAR_HEIGHT - GRID_TOP_PAD - GRID_TOP_PAD;
-    notePreview = new ChartEditorNotePreview(height);
-    notePreview.x = 350;
-    notePreview.y = MENU_BAR_HEIGHT + GRID_TOP_PAD;
+    var playbarHeightWithPad = PLAYBAR_HEIGHT + 10;
+    var notePreviewHeight:Int = FlxG.height - NOTE_PREVIEW_Y_POS - playbarHeightWithPad;
+    notePreview = new ChartEditorNotePreview(notePreviewHeight);
+    notePreview.x = NOTE_PREVIEW_X_POS;
+    notePreview.y = NOTE_PREVIEW_Y_POS;
     add(notePreview);
 
     if (notePreviewViewport == null) throw 'ERROR: Tried to build note preview, but notePreviewViewport is null! Check ChartEditorThemeHandler.updateTheme().';
@@ -2167,17 +2408,6 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
     setNotePreviewViewportBounds(calculateNotePreviewViewportBounds());
   }
 
-  function buildSelectionBox():Void
-  {
-    if (selectionBoxSprite == null) throw 'ERROR: Tried to build selection box, but selectionBoxSprite is null! Check ChartEditorThemeHandler.updateTheme().';
-
-    selectionBoxSprite.scrollFactor.set(0, 0);
-    add(selectionBoxSprite);
-    selectionBoxSprite.zIndex = 30;
-
-    setSelectionBoxBounds();
-  }
-
   function setSelectionBoxBounds(bounds:FlxRect = null):Void
   {
     if (selectionBoxSprite == null)
@@ -2199,6 +2429,19 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
     }
   }
 
+  /**
+   * Automatically goes through and calls render on everything you added.
+   */
+  override public function draw():Void
+  {
+    if (selectionBoxStartPos != null)
+    {
+      trace('selectionBoxSprite: ${selectionBoxSprite.visible} ${selectionBoxSprite.exists} ${this.members.contains(selectionBoxSprite)}');
+    }
+
+    super.draw();
+  }
+
   function calculateNotePreviewViewportBounds():FlxRect
   {
     var bounds:FlxRect = new FlxRect();
@@ -2234,6 +2477,8 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
       bounds.height = MIN_HEIGHT;
     }
 
+    // trace('Note preview viewport bounds: ' + bounds.toString());
+
     return bounds;
   }
 
@@ -2339,6 +2584,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
 
     add(playbarHeadLayout);
 
+    // Little text that shows up when you copy something.
     txtCopyNotif = new FlxText(0, 0, 0, '', 24);
     txtCopyNotif.setBorderStyle(OUTLINE, 0xFF074809, 1);
     txtCopyNotif.color = 0xFF52FF77;
@@ -2363,6 +2609,77 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
         this.openCharacterDropdown(CharacterType.BF, true);
       }
     });
+
+    buttonSelectOpponent = new Button();
+    buttonSelectOpponent.allowFocus = false;
+    buttonSelectOpponent.text = "Opponent"; // Default text.
+    buttonSelectOpponent.x = GRID_X_POS;
+    buttonSelectOpponent.y = GRID_INITIAL_Y_POS - NOTE_SELECT_BUTTON_HEIGHT - 8;
+    buttonSelectOpponent.width = GRID_SIZE * 4;
+    buttonSelectOpponent.height = NOTE_SELECT_BUTTON_HEIGHT;
+    buttonSelectOpponent.tooltip = "Click to set selection to all notes on this side.\nShift-click to add all notes on this side to selection.";
+    buttonSelectOpponent.zIndex = 110;
+    add(buttonSelectOpponent);
+
+    buttonSelectOpponent.onClick = (_) -> {
+      var notesToSelect:Array<SongNoteData> = currentSongChartNoteData;
+      notesToSelect = SongDataUtils.getNotesInDataRange(notesToSelect, STRUMLINE_SIZE, STRUMLINE_SIZE * 2 - 1);
+      if (FlxG.keys.pressed.SHIFT)
+      {
+        performCommand(new SelectItemsCommand(notesToSelect, []));
+      }
+      else
+      {
+        performCommand(new SetItemSelectionCommand(notesToSelect, []));
+      }
+    }
+
+    buttonSelectPlayer = new Button();
+    buttonSelectPlayer.allowFocus = false;
+    buttonSelectPlayer.text = "Player"; // Default text.
+    buttonSelectPlayer.x = buttonSelectOpponent.x + buttonSelectOpponent.width;
+    buttonSelectPlayer.y = buttonSelectOpponent.y;
+    buttonSelectPlayer.width = GRID_SIZE * 4;
+    buttonSelectPlayer.height = NOTE_SELECT_BUTTON_HEIGHT;
+    buttonSelectPlayer.tooltip = "Click to set selection to all notes on this side.\nShift-click to add all notes on this side to selection.";
+    buttonSelectPlayer.zIndex = 110;
+    add(buttonSelectPlayer);
+
+    buttonSelectPlayer.onClick = (_) -> {
+      var notesToSelect:Array<SongNoteData> = currentSongChartNoteData;
+      notesToSelect = SongDataUtils.getNotesInDataRange(notesToSelect, 0, STRUMLINE_SIZE - 1);
+      if (FlxG.keys.pressed.SHIFT)
+      {
+        performCommand(new SelectItemsCommand(notesToSelect, []));
+      }
+      else
+      {
+        performCommand(new SetItemSelectionCommand(notesToSelect, []));
+      }
+    }
+
+    buttonSelectEvent = new Button();
+    buttonSelectEvent.allowFocus = false;
+    buttonSelectEvent.icon = Paths.image('ui/chart-editor/events/Default');
+    buttonSelectEvent.iconPosition = "top";
+    buttonSelectEvent.x = buttonSelectPlayer.x + buttonSelectPlayer.width;
+    buttonSelectEvent.y = buttonSelectPlayer.y;
+    buttonSelectEvent.width = GRID_SIZE;
+    buttonSelectEvent.height = NOTE_SELECT_BUTTON_HEIGHT;
+    buttonSelectEvent.tooltip = "Click to set selection to all events.\nShift-click to add all events to selection.";
+    buttonSelectEvent.zIndex = 110;
+    add(buttonSelectEvent);
+
+    buttonSelectEvent.onClick = (_) -> {
+      if (FlxG.keys.pressed.SHIFT)
+      {
+        performCommand(new SelectItemsCommand([], currentSongChartEventData));
+      }
+      else
+      {
+        performCommand(new SetItemSelectionCommand([], currentSongChartEventData));
+      }
+    }
   }
 
   /**
@@ -2402,16 +2719,33 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
       }
       else
       {
-        Conductor.currentTimeChange.bpm += 1;
+        Conductor.instance.currentTimeChange.bpm += 1;
         this.refreshToolbox(CHART_EDITOR_TOOLBOX_METADATA_LAYOUT);
       }
     }
 
     playbarBPM.onRightClick = _ -> {
-      Conductor.currentTimeChange.bpm -= 1;
+      Conductor.instance.currentTimeChange.bpm -= 1;
       this.refreshToolbox(CHART_EDITOR_TOOLBOX_METADATA_LAYOUT);
     }
 
+    playbarDifficulty.onClick = _ -> {
+      if (FlxG.keys.pressed.CONTROL)
+      {
+        this.setToolboxState(CHART_EDITOR_TOOLBOX_DIFFICULTY_LAYOUT, true);
+      }
+      else
+      {
+        incrementDifficulty(-1);
+        this.refreshToolbox(CHART_EDITOR_TOOLBOX_DIFFICULTY_LAYOUT);
+      }
+    }
+
+    playbarDifficulty.onRightClick = _ -> {
+      incrementDifficulty(1);
+      this.refreshToolbox(CHART_EDITOR_TOOLBOX_DIFFICULTY_LAYOUT);
+    }
+
     // Add functionality to the menu items.
 
     // File
@@ -2434,31 +2768,15 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
     menubarItemUndo.onClick = _ -> undoLastCommand();
     menubarItemRedo.onClick = _ -> redoLastCommand();
     menubarItemCopy.onClick = function(_) {
-      // Doesn't use a command because it's not undoable.
-
-      // Calculate a single time offset for all the notes and events.
-      var timeOffset:Null<Int> = currentNoteSelection.length > 0 ? Std.int(currentNoteSelection[0].time) : null;
-      if (currentEventSelection.length > 0)
-      {
-        if (timeOffset == null || currentEventSelection[0].time < timeOffset)
-        {
-          timeOffset = Std.int(currentEventSelection[0].time);
-        }
-      }
-
-      SongDataUtils.writeItemsToClipboard(
-        {
-          notes: SongDataUtils.buildNoteClipboard(currentNoteSelection, timeOffset),
-          events: SongDataUtils.buildEventClipboard(currentEventSelection, timeOffset),
-        });
+      copySelection();
     };
     menubarItemCut.onClick = _ -> performCommand(new CutItemsCommand(currentNoteSelection, currentEventSelection));
 
     menubarItemPaste.onClick = _ -> {
       var targetMs:Float = scrollPositionInMs + playheadPositionInMs;
-      var targetStep:Float = Conductor.getTimeInSteps(targetMs);
+      var targetStep:Float = Conductor.instance.getTimeInSteps(targetMs);
       var targetSnappedStep:Float = Math.floor(targetStep / noteSnapRatio) * noteSnapRatio;
-      var targetSnappedMs:Float = Conductor.getStepTimeInMs(targetSnappedStep);
+      var targetSnappedMs:Float = Conductor.instance.getStepTimeInMs(targetSnappedStep);
       performCommand(new PasteItemsCommand(targetSnappedMs));
     };
 
@@ -2488,11 +2806,13 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
 
     menubarItemFlipNotes.onClick = _ -> performCommand(new FlipNotesCommand(currentNoteSelection));
 
-    menubarItemSelectAll.onClick = _ -> performCommand(new SelectAllItemsCommand(currentNoteSelection, currentEventSelection));
+    menubarItemSelectAllNotes.onClick = _ -> performCommand(new SelectAllItemsCommand(true, false));
 
-    menubarItemSelectInverse.onClick = _ -> performCommand(new InvertSelectedItemsCommand(currentNoteSelection, currentEventSelection));
+    menubarItemSelectAllEvents.onClick = _ -> performCommand(new SelectAllItemsCommand(false, true));
 
-    menubarItemSelectNone.onClick = _ -> performCommand(new DeselectAllItemsCommand(currentNoteSelection, currentEventSelection));
+    menubarItemSelectInverse.onClick = _ -> performCommand(new InvertSelectedItemsCommand());
+
+    menubarItemSelectNone.onClick = _ -> performCommand(new DeselectAllItemsCommand());
 
     menubarItemPlaytestFull.onClick = _ -> testSongInPlayState(false);
     menubarItemPlaytestMinimal.onClick = _ -> testSongInPlayState(true);
@@ -2546,8 +2866,25 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
 
     menubarItemPlayPause.onClick = _ -> toggleAudioPlayback();
 
-    menubarItemLoadInstrumental.onClick = _ -> this.openUploadInstDialog(true);
-    menubarItemLoadVocals.onClick = _ -> this.openUploadVocalsDialog(true);
+    menubarItemLoadInstrumental.onClick = _ -> {
+      var dialog = this.openUploadInstDialog(true);
+      // Ensure instrumental and vocals are reloaded properly.
+      dialog.onDialogClosed = function(_) {
+        this.isHaxeUIDialogOpen = false;
+        this.switchToCurrentInstrumental();
+        this.postLoadInstrumental();
+      }
+    };
+
+    menubarItemLoadVocals.onClick = _ -> {
+      var dialog = this.openUploadVocalsDialog(true);
+      // Ensure instrumental and vocals are reloaded properly.
+      dialog.onDialogClosed = function(_) {
+        this.isHaxeUIDialogOpen = false;
+        this.switchToCurrentInstrumental();
+        this.postLoadInstrumental();
+      }
+    };
 
     menubarItemVolumeMetronome.onChange = event -> {
       var volume:Float = event.value.toFloat() / 100.0;
@@ -2562,6 +2899,12 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
     menubarItemOpponentHitsounds.onChange = event -> hitsoundsEnabledOpponent = event.value;
     menubarItemOpponentHitsounds.selected = hitsoundsEnabledOpponent;
 
+    menubarItemThemeMusic.onChange = event -> {
+      this.welcomeMusic.active = event.value;
+      fadeInWelcomeMusic(WELCOME_MUSIC_FADE_IN_DELAY, WELCOME_MUSIC_FADE_IN_DURATION);
+    };
+    menubarItemThemeMusic.selected = this.welcomeMusic.active;
+
     menubarItemVolumeHitsound.onChange = event -> {
       var volume:Float = event.value.toFloat() / 100.0;
       hitsoundVolume = volume;
@@ -2575,31 +2918,35 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
       menubarLabelVolumeInstrumental.text = 'Instrumental - ${Std.int(event.value)}%';
     };
 
-    menubarItemVolumeVocals.onChange = event -> {
+    menubarItemVolumeVocalsPlayer.onChange = event -> {
       var volume:Float = event.value.toFloat() / 100.0;
-      if (audioVocalTrackGroup != null) audioVocalTrackGroup.volume = volume;
-      menubarLabelVolumeVocals.text = 'Voices - ${Std.int(event.value)}%';
-    }
+      audioVocalTrackGroup.playerVolume = volume;
+      menubarLabelVolumeVocalsPlayer.text = 'Player - ${Std.int(event.value)}%';
+    };
+
+    menubarItemVolumeVocalsOpponent.onChange = event -> {
+      var volume:Float = event.value.toFloat() / 100.0;
+      audioVocalTrackGroup.opponentVolume = volume;
+      menubarLabelVolumeVocalsOpponent.text = 'Enemy - ${Std.int(event.value)}%';
+    };
 
     menubarItemPlaybackSpeed.onChange = event -> {
-      var pitch:Float = (event.value * 2.0) / 100.0;
+      var pitch:Float = (event.value.toFloat() * 2.0) / 100.0;
       pitch = Math.floor(pitch / 0.25) * 0.25; // Round to nearest 0.25.
       #if FLX_PITCH
       if (audioInstTrack != null) audioInstTrack.pitch = pitch;
-      if (audioVocalTrackGroup != null) audioVocalTrackGroup.pitch = pitch;
+      audioVocalTrackGroup.pitch = pitch;
       #end
       var pitchDisplay:Float = Std.int(pitch * 100) / 100; // Round to 2 decimal places.
       menubarLabelPlaybackSpeed.text = 'Playback Speed - ${pitchDisplay}x';
     }
 
-    playbarDifficulty.onClick = _ -> {
-      this.setToolboxState(CHART_EDITOR_TOOLBOX_DIFFICULTY_LAYOUT, true);
-    }
-
     menubarItemToggleToolboxDifficulty.onChange = event -> this.setToolboxState(CHART_EDITOR_TOOLBOX_DIFFICULTY_LAYOUT, event.value);
     menubarItemToggleToolboxMetadata.onChange = event -> this.setToolboxState(CHART_EDITOR_TOOLBOX_METADATA_LAYOUT, event.value);
+    menubarItemToggleToolboxOffsets.onChange = event -> this.setToolboxState(CHART_EDITOR_TOOLBOX_OFFSETS_LAYOUT, event.value);
     menubarItemToggleToolboxNotes.onChange = event -> this.setToolboxState(CHART_EDITOR_TOOLBOX_NOTEDATA_LAYOUT, event.value);
-    menubarItemToggleToolboxEvents.onChange = event -> this.setToolboxState(CHART_EDITOR_TOOLBOX_EVENTDATA_LAYOUT, event.value);
+    menubarItemToggleToolboxEventData.onChange = event -> this.setToolboxState(CHART_EDITOR_TOOLBOX_EVENT_DATA_LAYOUT, event.value);
+    menubarItemToggleToolboxFreeplay.onChange = event -> this.setToolboxState(CHART_EDITOR_TOOLBOX_FREEPLAY_LAYOUT, event.value);
     menubarItemToggleToolboxPlaytestProperties.onChange = event -> this.setToolboxState(CHART_EDITOR_TOOLBOX_PLAYTEST_PROPERTIES_LAYOUT, event.value);
     menubarItemToggleToolboxPlayerPreview.onChange = event -> this.setToolboxState(CHART_EDITOR_TOOLBOX_PLAYER_PREVIEW_LAYOUT, event.value);
     menubarItemToggleToolboxOpponentPreview.onChange = event -> this.setToolboxState(CHART_EDITOR_TOOLBOX_OPPONENT_PREVIEW_LAYOUT, event.value);
@@ -2608,6 +2955,42 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
     // registerContextMenu(null, Paths.ui('chart-editor/context/test'));
   }
 
+  function setupContextMenu():Void
+  {
+    Screen.instance.registerEvent(MouseEvent.RIGHT_MOUSE_UP, function(e:MouseEvent) {
+      var xPos = e.screenX;
+      var yPos = e.screenY;
+      onContextMenu(xPos, yPos);
+    });
+  }
+
+  function onContextMenu(xPos:Float, yPos:Float)
+  {
+    trace('User right clicked to open menu at (${xPos}, ${yPos})');
+    // this.openDefaultContextMenu(xPos, yPos);
+  }
+
+  function copySelection():Void
+  {
+    // Doesn't use a command because it's not undoable.
+
+    // Calculate a single time offset for all the notes and events.
+    var timeOffset:Null<Int> = currentNoteSelection.length > 0 ? Std.int(currentNoteSelection[0].time) : null;
+    if (currentEventSelection.length > 0)
+    {
+      if (timeOffset == null || currentEventSelection[0].time < timeOffset)
+      {
+        timeOffset = Std.int(currentEventSelection[0].time);
+      }
+    }
+
+    SongDataUtils.writeItemsToClipboard(
+      {
+        notes: SongDataUtils.buildNoteClipboard(currentNoteSelection, timeOffset),
+        events: SongDataUtils.buildEventClipboard(currentEventSelection, timeOffset),
+      });
+  }
+
   /**
    * Initialize TurboKeyHandlers and add them to the state (so `update()` is called)
    * We can then probe `keyHandler.activated` to see if the key combo's action should be taken.
@@ -2639,10 +3022,12 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
     saveDataDirty = false;
   }
 
+  var displayAutosavePopup:Bool = false;
+
   /**
    * UPDATE FUNCTIONS
    */
-  function autoSave():Void
+  function autoSave(?beforePlaytest:Bool = false):Void
   {
     var needsAutoSave:Bool = saveDataDirty;
 
@@ -2660,13 +3045,21 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
     if (needsAutoSave)
     {
       this.exportAllSongData(true, null);
-      var absoluteBackupsPath:String = Path.join([Sys.getCwd(), ChartEditorImportExportHandler.BACKUPS_PATH]);
-      this.infoWithActions('Auto-Save', 'Chart auto-saved to ${absoluteBackupsPath}.', [
-        {
-          text: "Take Me There",
-          callback: openBackupsFolder,
-        }
-      ]);
+      if (beforePlaytest)
+      {
+        displayAutosavePopup = true;
+      }
+      else
+      {
+        displayAutosavePopup = false;
+        var absoluteBackupsPath:String = Path.join([Sys.getCwd(), ChartEditorImportExportHandler.BACKUPS_PATH]);
+        this.infoWithActions('Auto-Save', 'Chart auto-saved to ${absoluteBackupsPath}.', [
+          {
+            text: "Take Me There",
+            callback: openBackupsFolder,
+          }
+        ]);
+      }
     }
     #end
   }
@@ -2734,7 +3127,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
   public override function update(elapsed:Float):Void
   {
     // Override F4 behavior to include the autosave.
-    if (FlxG.keys.justPressed.F4)
+    if (FlxG.keys.justPressed.F4 && !criticalFailure)
     {
       quitChartEditor();
       return;
@@ -2743,6 +3136,8 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
     // dispatchEvent gets called here.
     super.update(elapsed);
 
+    if (criticalFailure) return;
+
     // These ones happen even if the modal dialog is open.
     handleMusicPlayback(elapsed);
     handleNoteDisplay();
@@ -2782,9 +3177,13 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
 
     if (metronomeVolume > 0.0 && this.subState == null && (audioInstTrack != null && audioInstTrack.isPlaying))
     {
-      playMetronomeTick(Conductor.currentBeat % 4 == 0);
+      playMetronomeTick(Conductor.instance.currentBeat % Conductor.instance.beatsPerMeasure == 0);
     }
 
+    // Show the mouse cursor.
+    // Just throwing this somewhere convenient and infrequently called because sometimes Flixel's debug thing hides the cursor.
+    Cursor.show();
+
     return true;
   }
 
@@ -2798,8 +3197,8 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
 
     if (audioInstTrack != null && audioInstTrack.isPlaying)
     {
-      if (healthIconDad != null) healthIconDad.onStepHit(Conductor.currentStep);
-      if (healthIconBF != null) healthIconBF.onStepHit(Conductor.currentStep);
+      if (healthIconDad != null) healthIconDad.onStepHit(Conductor.instance.currentStep);
+      if (healthIconBF != null) healthIconBF.onStepHit(Conductor.instance.currentStep);
     }
 
     // Updating these every step keeps it more accurate.
@@ -2827,12 +3226,12 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
       audioInstTrack.update(elapsed);
 
       // If the song starts 50ms in, make sure we start the song there.
-      if (Conductor.instrumentalOffset < 0)
+      if (Conductor.instance.instrumentalOffset < 0)
       {
-        if (audioInstTrack.time < -Conductor.instrumentalOffset)
+        if (audioInstTrack.time < -Conductor.instance.instrumentalOffset)
         {
-          trace('Resetting instrumental time to ${- Conductor.instrumentalOffset}ms');
-          audioInstTrack.time = -Conductor.instrumentalOffset;
+          trace('Resetting instrumental time to ${- Conductor.instance.instrumentalOffset}ms');
+          audioInstTrack.time = -Conductor.instance.instrumentalOffset;
         }
       }
     }
@@ -2843,16 +3242,16 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
       {
         // If middle mouse panning during song playback, we move ONLY the playhead, without scrolling. Neat!
 
-        var oldStepTime:Float = Conductor.currentStepTime;
-        var oldSongPosition:Float = Conductor.songPosition + Conductor.instrumentalOffset;
-        Conductor.update(audioInstTrack.time);
-        handleHitsounds(oldSongPosition, Conductor.songPosition + Conductor.instrumentalOffset);
+        var oldStepTime:Float = Conductor.instance.currentStepTime;
+        var oldSongPosition:Float = Conductor.instance.songPosition + Conductor.instance.instrumentalOffset;
+        Conductor.instance.update(audioInstTrack.time);
+        handleHitsounds(oldSongPosition, Conductor.instance.songPosition + Conductor.instance.instrumentalOffset);
         // Resync vocals.
-        if (audioVocalTrackGroup != null && Math.abs(audioInstTrack.time - audioVocalTrackGroup.time) > 100)
+        if (Math.abs(audioInstTrack.time - audioVocalTrackGroup.time) > 100)
         {
           audioVocalTrackGroup.time = audioInstTrack.time;
         }
-        var diffStepTime:Float = Conductor.currentStepTime - oldStepTime;
+        var diffStepTime:Float = Conductor.instance.currentStepTime - oldStepTime;
 
         // Move the playhead.
         playheadPositionInPixels += diffStepTime * GRID_SIZE;
@@ -2862,18 +3261,18 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
       else
       {
         // Else, move the entire view.
-        var oldSongPosition:Float = Conductor.songPosition + Conductor.instrumentalOffset;
-        Conductor.update(audioInstTrack.time);
-        handleHitsounds(oldSongPosition, Conductor.songPosition + Conductor.instrumentalOffset);
+        var oldSongPosition:Float = Conductor.instance.songPosition + Conductor.instance.instrumentalOffset;
+        Conductor.instance.update(audioInstTrack.time);
+        handleHitsounds(oldSongPosition, Conductor.instance.songPosition + Conductor.instance.instrumentalOffset);
         // Resync vocals.
-        if (audioVocalTrackGroup != null && Math.abs(audioInstTrack.time - audioVocalTrackGroup.time) > 100)
+        if (Math.abs(audioInstTrack.time - audioVocalTrackGroup.time) > 100)
         {
           audioVocalTrackGroup.time = audioInstTrack.time;
         }
 
         // We need time in fractional steps here to allow the song to actually play.
         // Also account for a potentially offset playhead.
-        scrollPositionInPixels = (Conductor.currentStepTime + Conductor.instrumentalOffsetSteps) * GRID_SIZE - playheadPositionInPixels;
+        scrollPositionInPixels = (Conductor.instance.currentStepTime + Conductor.instance.instrumentalOffsetSteps) * GRID_SIZE - playheadPositionInPixels;
 
         // DO NOT move song to scroll position here specifically.
 
@@ -2943,8 +3342,16 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
       {
         if (holdNoteSprite == null || holdNoteSprite.noteData == null || !holdNoteSprite.exists || !holdNoteSprite.visible) continue;
 
-        if (!holdNoteSprite.isHoldNoteVisible(FlxG.height - MENU_BAR_HEIGHT, GRID_TOP_PAD))
+        if (holdNoteSprite.noteData == currentPlaceNoteData)
         {
+          // This hold note is for the note we are currently dragging.
+          // It will be displayed by gridGhostHoldNoteSprite instead.
+          holdNoteSprite.kill();
+        }
+        else if (!holdNoteSprite.isHoldNoteVisible(FlxG.height - MENU_BAR_HEIGHT, GRID_TOP_PAD))
+        {
+          // This hold note is off-screen.
+          // Kill the hold note sprite and recycle it.
           holdNoteSprite.kill();
         }
         else if (!currentSongChartNoteData.fastContains(holdNoteSprite.noteData) || holdNoteSprite.noteData.length == 0)
@@ -2962,7 +3369,9 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
         else
         {
           displayedHoldNoteData.push(holdNoteSprite.noteData);
-          // Update the event sprite's position.
+          // Update the event sprite's height and position.
+          // var holdNoteHeight = holdNoteSprite.noteData.getStepLength() * GRID_SIZE;
+          // holdNoteSprite.setHeightDirectly(holdNoteHeight);
           holdNoteSprite.updateHoldNotePosition(renderedNotes);
         }
       }
@@ -2979,7 +3388,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
         // Resolve an issue where dragging an event too far would cause it to be hidden.
         var isSelectedAndDragged = currentEventSelection.fastContains(eventSprite.eventData) && (dragTargetCurrentStep != 0);
 
-        if ((eventSprite.isEventVisible(FlxG.height - MENU_BAR_HEIGHT, GRID_TOP_PAD)
+        if ((eventSprite.isEventVisible(FlxG.height - PLAYBAR_HEIGHT, MENU_BAR_HEIGHT)
           && currentSongChartEventData.fastContains(eventSprite.eventData))
           || isSelectedAndDragged)
         {
@@ -2988,6 +3397,8 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
 
           // Update the event sprite's position.
           eventSprite.updateEventPosition(renderedEvents);
+          // Update the sprite's graphic. TODO: Is this inefficient?
+          eventSprite.playAnimation(eventSprite.eventData.event);
         }
         else
         {
@@ -3002,8 +3413,8 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
 
       // Let's try testing only notes within a certain range of the view area.
       // TODO: I don't think this messes up really long sustains, does it?
-      var viewAreaTopMs:Float = scrollPositionInMs - (Conductor.measureLengthMs * 2); // Is 2 measures enough?
-      var viewAreaBottomMs:Float = scrollPositionInMs + (Conductor.measureLengthMs * 2); // Is 2 measures enough?
+      var viewAreaTopMs:Float = scrollPositionInMs - (Conductor.instance.measureLengthMs * 2); // Is 2 measures enough?
+      var viewAreaBottomMs:Float = scrollPositionInMs + (Conductor.instance.measureLengthMs * 2); // Is 2 measures enough?
 
       // Add notes that are now visible.
       for (noteData in currentSongChartNoteData)
@@ -3038,7 +3449,10 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
         noteSprite.updateNotePosition(renderedNotes);
 
         // Add hold notes that are now visible (and not already displayed).
-        if (noteSprite.noteData != null && noteSprite.noteData.length > 0 && displayedHoldNoteData.indexOf(noteSprite.noteData) == -1)
+        if (noteSprite.noteData != null
+          && noteSprite.noteData.length > 0
+          && displayedHoldNoteData.indexOf(noteSprite.noteData) == -1
+          && noteSprite.noteData != currentPlaceNoteData)
         {
           var holdNoteSprite:ChartEditorHoldNoteSprite = renderedHoldNotes.recycle(() -> new ChartEditorHoldNoteSprite(this));
           // trace('Creating new HoldNote... (${renderedHoldNotes.members.length})');
@@ -3051,6 +3465,8 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
           holdNoteSprite.setHeightDirectly(noteLengthPixels);
 
           holdNoteSprite.updateHoldNotePosition(renderedHoldNotes);
+
+          trace(holdNoteSprite.x + ', ' + holdNoteSprite.y + ', ' + holdNoteSprite.width + ', ' + holdNoteSprite.height);
         }
       }
 
@@ -3081,6 +3497,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
         // Setting event data resets position relative to the grid so we fix that.
         eventSprite.x += renderedEvents.x;
         eventSprite.y += renderedEvents.y;
+        eventSprite.updateTooltipPosition();
       }
 
       // Add hold notes that have been made visible (but not their parents)
@@ -3089,6 +3506,9 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
         // Is the note a hold note?
         if (noteData == null || noteData.length <= 0) continue;
 
+        // Is the note the one we are dragging? If so, ghostHoldNoteSprite will handle it.
+        if (noteData == currentPlaceNoteData) continue;
+
         // Is the hold note rendered already?
         if (displayedHoldNoteData.indexOf(noteData) != -1) continue;
 
@@ -3178,7 +3598,9 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
           selectionSquare.x = noteSprite.x;
           selectionSquare.y = noteSprite.y;
           selectionSquare.width = GRID_SIZE;
-          selectionSquare.height = GRID_SIZE;
+
+          var stepLength = noteSprite.noteData.getStepLength();
+          selectionSquare.height = (stepLength <= 0) ? GRID_SIZE : ((stepLength + 1) * GRID_SIZE);
         }
       }
 
@@ -3290,14 +3712,14 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
     // PAGE UP = Jump up to nearest measure
     if (pageUpKeyHandler.activated)
     {
-      var measureHeight:Float = GRID_SIZE * 4 * Conductor.beatsPerMeasure;
+      var measureHeight:Float = GRID_SIZE * 4 * Conductor.instance.beatsPerMeasure;
       var playheadPos:Float = scrollPositionInPixels + playheadPositionInPixels;
       var targetScrollPosition:Float = Math.floor(playheadPos / measureHeight) * measureHeight;
       // If we would move less than one grid, instead move to the top of the previous measure.
       var targetScrollAmount = Math.abs(targetScrollPosition - playheadPos);
       if (targetScrollAmount < GRID_SIZE)
       {
-        targetScrollPosition -= GRID_SIZE * Constants.STEPS_PER_BEAT * Conductor.beatsPerMeasure;
+        targetScrollPosition -= GRID_SIZE * Constants.STEPS_PER_BEAT * Conductor.instance.beatsPerMeasure;
       }
       scrollAmount = targetScrollPosition - playheadPos;
 
@@ -3306,21 +3728,21 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
     if (playbarButtonPressed == 'playbarBack')
     {
       playbarButtonPressed = '';
-      scrollAmount = -GRID_SIZE * 4 * Conductor.beatsPerMeasure;
+      scrollAmount = -GRID_SIZE * 4 * Conductor.instance.beatsPerMeasure;
       shouldPause = true;
     }
 
     // PAGE DOWN = Jump down to nearest measure
     if (pageDownKeyHandler.activated)
     {
-      var measureHeight:Float = GRID_SIZE * 4 * Conductor.beatsPerMeasure;
+      var measureHeight:Float = GRID_SIZE * 4 * Conductor.instance.beatsPerMeasure;
       var playheadPos:Float = scrollPositionInPixels + playheadPositionInPixels;
       var targetScrollPosition:Float = Math.ceil(playheadPos / measureHeight) * measureHeight;
       // If we would move less than one grid, instead move to the top of the next measure.
       var targetScrollAmount = Math.abs(targetScrollPosition - playheadPos);
       if (targetScrollAmount < GRID_SIZE)
       {
-        targetScrollPosition += GRID_SIZE * Constants.STEPS_PER_BEAT * Conductor.beatsPerMeasure;
+        targetScrollPosition += GRID_SIZE * Constants.STEPS_PER_BEAT * Conductor.instance.beatsPerMeasure;
       }
       scrollAmount = targetScrollPosition - playheadPos;
 
@@ -3329,7 +3751,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
     if (playbarButtonPressed == 'playbarForward')
     {
       playbarButtonPressed = '';
-      scrollAmount = GRID_SIZE * 4 * Conductor.beatsPerMeasure;
+      scrollAmount = GRID_SIZE * 4 * Conductor.instance.beatsPerMeasure;
       shouldPause = true;
     }
 
@@ -3444,6 +3866,8 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
 
     // trace('shouldHandleCursor: $shouldHandleCursor');
 
+    // TODO: TBH some of this should be using FlxMouseEventManager...
+
     if (shouldHandleCursor)
     {
       // Over the course of this big conditional block,
@@ -3455,6 +3879,10 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
 
       var overlapsGrid:Bool = FlxG.mouse.overlaps(gridTiledSprite);
 
+      var overlapsRenderedNotes:Bool = FlxG.mouse.overlaps(renderedNotes);
+      var overlapsRenderedHoldNotes:Bool = FlxG.mouse.overlaps(renderedHoldNotes);
+      var overlapsRenderedEvents:Bool = FlxG.mouse.overlaps(renderedEvents);
+
       // Cursor position relative to the grid.
       var cursorX:Float = FlxG.mouse.screenX - gridTiledSprite.x;
       var cursorY:Float = FlxG.mouse.screenY - gridTiledSprite.y;
@@ -3487,7 +3915,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
         {
           scrollAnchorScreenPos = null;
         }
-        else if (gridPlayheadScrollArea != null && FlxG.mouse.overlaps(gridPlayheadScrollArea) && !isCursorOverHaxeUI)
+        else if (measureTicks != null && FlxG.mouse.overlaps(measureTicks) && !isCursorOverHaxeUI)
         {
           gridPlayheadScrollAreaPressed = true;
         }
@@ -3523,7 +3951,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
       {
         // Clicked on the playhead scroll area.
         // Move the playhead to the cursor position.
-        this.playheadPositionInPixels = FlxG.mouse.screenY - MENU_BAR_HEIGHT - GRID_TOP_PAD;
+        this.playheadPositionInPixels = FlxG.mouse.screenY - (GRID_INITIAL_Y_POS);
         moveSongToScrollPosition();
 
         // Cursor should be a grabby hand.
@@ -3532,10 +3960,10 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
 
       // The song position of the cursor, in steps.
       var cursorFractionalStep:Float = cursorY / GRID_SIZE;
-      var cursorMs:Float = Conductor.getStepTimeInMs(cursorFractionalStep);
+      var cursorMs:Float = Conductor.instance.getStepTimeInMs(cursorFractionalStep);
       // Round the cursor step to the nearest snap quant.
       var cursorSnappedStep:Float = Math.floor(cursorFractionalStep / noteSnapRatio) * noteSnapRatio;
-      var cursorSnappedMs:Float = Conductor.getStepTimeInMs(cursorSnappedStep);
+      var cursorSnappedMs:Float = Conductor.instance.getStepTimeInMs(cursorSnappedStep);
 
       // The direction value for the column at the cursor.
       var cursorGridPos:Int = Math.floor(cursorX / GRID_SIZE);
@@ -3557,7 +3985,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
             // We released the mouse. Select the notes in the box.
             var cursorFractionalStepStart:Float = cursorYStart / GRID_SIZE;
             var cursorStepStart:Int = Math.floor(cursorFractionalStepStart);
-            var cursorMsStart:Float = Conductor.getStepTimeInMs(cursorStepStart);
+            var cursorMsStart:Float = Conductor.instance.getStepTimeInMs(cursorStepStart);
             var cursorColumnBase:Int = Math.floor(cursorX / GRID_SIZE);
             var cursorColumnBaseStart:Int = Math.floor(cursorXStart / GRID_SIZE);
 
@@ -3616,7 +4044,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
                 else
                 {
                   // Set the selection.
-                  performCommand(new SetItemSelectionCommand(notesToSelect, eventsToSelect, currentNoteSelection, currentEventSelection));
+                  performCommand(new SetItemSelectionCommand(notesToSelect, eventsToSelect));
                 }
               }
               else
@@ -3629,7 +4057,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
                   var shouldDeselect:Bool = !wasCursorOverHaxeUI && (currentNoteSelection.length > 0 || currentEventSelection.length > 0);
                   if (shouldDeselect)
                   {
-                    performCommand(new DeselectAllItemsCommand(currentNoteSelection, currentEventSelection));
+                    performCommand(new DeselectAllItemsCommand());
                   }
                 }
               }
@@ -3696,12 +4124,18 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
                 return event.alive && FlxG.mouse.overlaps(event);
               });
             }
+            var highlightedHoldNote:Null<ChartEditorHoldNoteSprite> = null;
+            if (highlightedNote == null && highlightedEvent == null)
+            {
+              highlightedHoldNote = renderedHoldNotes.members.find(function(holdNote:ChartEditorHoldNoteSprite):Bool {
+                return holdNote.alive && FlxG.mouse.overlaps(holdNote);
+              });
+            }
 
             if (FlxG.keys.pressed.CONTROL)
             {
               if (highlightedNote != null && highlightedNote.noteData != null)
               {
-                // TODO: Handle the case of clicking on a sustain piece.
                 // Control click to select/deselect an individual note.
                 if (isNoteSelected(highlightedNote.noteData))
                 {
@@ -3724,6 +4158,18 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
                   performCommand(new SelectItemsCommand([], [highlightedEvent.eventData]));
                 }
               }
+              else if (highlightedHoldNote != null && highlightedHoldNote.noteData != null)
+              {
+                // Control click to select/deselect an individual note.
+                if (isNoteSelected(highlightedNote.noteData))
+                {
+                  performCommand(new DeselectItemsCommand([highlightedHoldNote.noteData], []));
+                }
+                else
+                {
+                  performCommand(new SelectItemsCommand([highlightedHoldNote.noteData], []));
+                }
+              }
               else
               {
                 // Do nothing if you control-clicked on an empty space.
@@ -3734,12 +4180,17 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
               if (highlightedNote != null && highlightedNote.noteData != null)
               {
                 // Click a note to select it.
-                performCommand(new SetItemSelectionCommand([highlightedNote.noteData], [], currentNoteSelection, currentEventSelection));
+                performCommand(new SetItemSelectionCommand([highlightedNote.noteData], []));
               }
               else if (highlightedEvent != null && highlightedEvent.eventData != null)
               {
                 // Click an event to select it.
-                performCommand(new SetItemSelectionCommand([], [highlightedEvent.eventData], currentNoteSelection, currentEventSelection));
+                performCommand(new SetItemSelectionCommand([], [highlightedEvent.eventData]));
+              }
+              else if (highlightedHoldNote != null && highlightedHoldNote.noteData != null)
+              {
+                // Click a hold note to select it.
+                performCommand(new SetItemSelectionCommand([highlightedHoldNote.noteData], []));
               }
               else
               {
@@ -3747,7 +4198,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
                 var shouldDeselect:Bool = !wasCursorOverHaxeUI && (currentNoteSelection.length > 0 || currentEventSelection.length > 0);
                 if (shouldDeselect)
                 {
-                  performCommand(new DeselectAllItemsCommand(currentNoteSelection, currentEventSelection));
+                  performCommand(new DeselectAllItemsCommand());
                 }
               }
             }
@@ -3762,7 +4213,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
               var shouldDeselect:Bool = !wasCursorOverHaxeUI && (currentNoteSelection.length > 0 || currentEventSelection.length > 0);
               if (shouldDeselect)
               {
-                performCommand(new DeselectAllItemsCommand(currentNoteSelection, currentEventSelection));
+                performCommand(new DeselectAllItemsCommand());
               }
             }
           }
@@ -3793,11 +4244,11 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
           var dragDistanceMs:Float = 0;
           if (dragTargetNote != null && dragTargetNote.noteData != null)
           {
-            dragDistanceMs = Conductor.getStepTimeInMs(dragTargetNote.noteData.getStepTime() + dragDistanceSteps) - dragTargetNote.noteData.time;
+            dragDistanceMs = Conductor.instance.getStepTimeInMs(dragTargetNote.noteData.getStepTime() + dragDistanceSteps) - dragTargetNote.noteData.time;
           }
           else if (dragTargetEvent != null && dragTargetEvent.eventData != null)
           {
-            dragDistanceMs = Conductor.getStepTimeInMs(dragTargetEvent.eventData.getStepTime() + dragDistanceSteps) - dragTargetEvent.eventData.time;
+            dragDistanceMs = Conductor.instance.getStepTimeInMs(dragTargetEvent.eventData.getStepTime() + dragDistanceSteps) - dragTargetEvent.eventData.time;
           }
           var dragDistanceColumns:Int = dragTargetCurrentColumn;
 
@@ -3857,7 +4308,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
           {
             stepTime = dragTargetEvent.eventData.getStepTime();
           }
-          var dragDistanceSteps:Float = Conductor.getTimeInSteps(cursorSnappedMs).clamp(0, songLengthInSteps - (1 * noteSnapRatio)) - stepTime;
+          var dragDistanceSteps:Float = Conductor.instance.getTimeInSteps(cursorSnappedMs).clamp(0, songLengthInSteps - (1 * noteSnapRatio)) - stepTime;
           var data:Int = 0;
           var noteGridPos:Int = 0;
           if (dragTargetNote != null && dragTargetNote.noteData != null)
@@ -3889,11 +4340,11 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
         // Handle extending the note as you drag.
 
         var stepTime:Float = inline currentPlaceNoteData.getStepTime();
-        var dragLengthSteps:Float = Conductor.getTimeInSteps(cursorSnappedMs) - stepTime;
-        var dragLengthMs:Float = dragLengthSteps * Conductor.stepLengthMs;
+        var dragLengthSteps:Float = Conductor.instance.getTimeInSteps(cursorSnappedMs) - stepTime;
+        var dragLengthMs:Float = dragLengthSteps * Conductor.instance.stepLengthMs;
         var dragLengthPixels:Float = dragLengthSteps * GRID_SIZE;
 
-        if (gridGhostNote != null && gridGhostNote.noteData != null && gridGhostHoldNote != null)
+        if (gridGhostHoldNote != null)
         {
           if (dragLengthSteps > 0)
           {
@@ -3906,8 +4357,8 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
             }
 
             gridGhostHoldNote.visible = true;
-            gridGhostHoldNote.noteData = gridGhostNote.noteData;
-            gridGhostHoldNote.noteDirection = gridGhostNote.noteData.getDirection();
+            gridGhostHoldNote.noteData = currentPlaceNoteData;
+            gridGhostHoldNote.noteDirection = currentPlaceNoteData.getDirection();
 
             gridGhostHoldNote.setHeightDirectly(dragLengthPixels, true);
 
@@ -3928,6 +4379,15 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
             // Apply the new length.
             performCommand(new ExtendNoteLengthCommand(currentPlaceNoteData, dragLengthMs));
           }
+          else
+          {
+            // Apply the new (zero) length if we are changing the length.
+            if (currentPlaceNoteData.length > 0)
+            {
+              this.playSound(Paths.sound('chartingSounds/stretchSNAP_UI'));
+              performCommand(new ExtendNoteLengthCommand(currentPlaceNoteData, 0));
+            }
+          }
 
           // Finished dragging. Release the note.
           currentPlaceNoteData = null;
@@ -3960,6 +4420,14 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
                 return event.alive && FlxG.mouse.overlaps(event);
               });
             }
+            var highlightedHoldNote:Null<ChartEditorHoldNoteSprite> = null;
+            if (highlightedNote == null && highlightedEvent == null)
+            {
+              highlightedHoldNote = renderedHoldNotes.members.find(function(holdNote:ChartEditorHoldNoteSprite):Bool {
+                // If holdNote.alive is false, the holdNote is dead and awaiting recycling.
+                return holdNote.alive && FlxG.mouse.overlaps(holdNote);
+              });
+            }
 
             if (FlxG.keys.pressed.CONTROL)
             {
@@ -3986,6 +4454,17 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
                   performCommand(new SelectItemsCommand([], [highlightedEvent.eventData]));
                 }
               }
+              else if (highlightedHoldNote != null && highlightedHoldNote.noteData != null)
+              {
+                if (isNoteSelected(highlightedNote.noteData))
+                {
+                  performCommand(new DeselectItemsCommand([highlightedHoldNote.noteData], []));
+                }
+                else
+                {
+                  performCommand(new SelectItemsCommand([highlightedHoldNote.noteData], []));
+                }
+              }
               else
               {
                 // Do nothing when control clicking nothing.
@@ -4003,7 +4482,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
                 else
                 {
                   // If you click an unselected note, and aren't holding Control, deselect everything else.
-                  performCommand(new SetItemSelectionCommand([highlightedNote.noteData], [], currentNoteSelection, currentEventSelection));
+                  performCommand(new SetItemSelectionCommand([highlightedNote.noteData], []));
                 }
               }
               else if (highlightedEvent != null && highlightedEvent.eventData != null)
@@ -4016,9 +4495,14 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
                 else
                 {
                   // If you click an unselected event, and aren't holding Control, deselect everything else.
-                  performCommand(new SetItemSelectionCommand([], [highlightedEvent.eventData], currentNoteSelection, currentEventSelection));
+                  performCommand(new SetItemSelectionCommand([], [highlightedEvent.eventData]));
                 }
               }
+              else if (highlightedHoldNote != null && highlightedHoldNote.noteData != null)
+              {
+                // Clicked a hold note, start dragging TO EXTEND NOTE LENGTH.
+                currentPlaceNoteData = highlightedHoldNote.noteData;
+              }
               else
               {
                 // Click a blank space to place a note and select it.
@@ -4027,14 +4511,14 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
                 {
                   // Create an event and place it in the chart.
                   // TODO: Figure out configuring event data.
-                  var newEventData:SongEventData = new SongEventData(cursorSnappedMs, selectedEventKind, selectedEventData.clone());
+                  var newEventData:SongEventData = new SongEventData(cursorSnappedMs, eventKindToPlace, eventDataToPlace.clone());
 
                   performCommand(new AddEventsCommand([newEventData], FlxG.keys.pressed.CONTROL));
                 }
                 else
                 {
                   // Create a note and place it in the chart.
-                  var newNoteData:SongNoteData = new SongNoteData(cursorSnappedMs, cursorColumn, 0, selectedNoteKind.clone());
+                  var newNoteData:SongNoteData = new SongNoteData(cursorSnappedMs, cursorColumn, 0, noteKindToPlace.clone());
 
                   performCommand(new AddNotesCommand([newNoteData], FlxG.keys.pressed.CONTROL));
 
@@ -4045,7 +4529,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
           }
           else
           {
-            // If we clicked and released outside the grid, do nothing.
+            // If we clicked and released outside the grid (or on HaxeUI), do nothing.
           }
         }
 
@@ -4068,17 +4552,90 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
               return event.alive && FlxG.mouse.overlaps(event);
             });
           }
+          var highlightedHoldNote:Null<ChartEditorHoldNoteSprite> = null;
+          if (highlightedNote == null && highlightedEvent == null)
+          {
+            highlightedHoldNote = renderedHoldNotes.members.find(function(holdNote:ChartEditorHoldNoteSprite):Bool {
+              // If holdNote.alive is false, the holdNote is dead and awaiting recycling.
+              return holdNote.alive && FlxG.mouse.overlaps(holdNote);
+            });
+          }
 
           if (highlightedNote != null && highlightedNote.noteData != null)
           {
             // TODO: Handle the case of clicking on a sustain piece.
-            // Remove the note.
-            performCommand(new RemoveNotesCommand([highlightedNote.noteData]));
+            if (FlxG.keys.pressed.SHIFT)
+            {
+              // Shift + Right click opens the context menu.
+              // If we are clicking a large selection, open the Selection context menu, otherwise open the Note context menu.
+              var isHighlightedNoteSelected:Bool = isNoteSelected(highlightedNote.noteData);
+              var useSingleNoteContextMenu:Bool = (!isHighlightedNoteSelected)
+                || (isHighlightedNoteSelected && currentNoteSelection.length == 1);
+              // Show the context menu connected to the note.
+              if (useSingleNoteContextMenu)
+              {
+                this.openNoteContextMenu(FlxG.mouse.screenX, FlxG.mouse.screenY, highlightedNote.noteData);
+              }
+              else
+              {
+                this.openSelectionContextMenu(FlxG.mouse.screenX, FlxG.mouse.screenY);
+              }
+            }
+            else
+            {
+              // Right click removes the note.
+              performCommand(new RemoveNotesCommand([highlightedNote.noteData]));
+            }
           }
           else if (highlightedEvent != null && highlightedEvent.eventData != null)
           {
-            // Remove the event.
-            performCommand(new RemoveEventsCommand([highlightedEvent.eventData]));
+            if (FlxG.keys.pressed.SHIFT)
+            {
+              // Shift + Right click opens the context menu.
+              // If we are clicking a large selection, open the Selection context menu, otherwise open the Event context menu.
+              var isHighlightedEventSelected:Bool = isEventSelected(highlightedEvent.eventData);
+              var useSingleEventContextMenu:Bool = (!isHighlightedEventSelected)
+                || (isHighlightedEventSelected && currentEventSelection.length == 1);
+              if (useSingleEventContextMenu)
+              {
+                this.openEventContextMenu(FlxG.mouse.screenX, FlxG.mouse.screenY, highlightedEvent.eventData);
+              }
+              else
+              {
+                this.openSelectionContextMenu(FlxG.mouse.screenX, FlxG.mouse.screenY);
+              }
+            }
+            else
+            {
+              // Right click removes the event.
+              performCommand(new RemoveEventsCommand([highlightedEvent.eventData]));
+            }
+          }
+          else if (highlightedHoldNote != null && highlightedHoldNote.noteData != null)
+          {
+            if (FlxG.keys.pressed.SHIFT)
+            {
+              // Shift + Right click opens the context menu.
+              // If we are clicking a large selection, open the Selection context menu, otherwise open the Note context menu.
+              var isHighlightedNoteSelected:Bool = isNoteSelected(highlightedHoldNote.noteData);
+              var useSingleNoteContextMenu:Bool = (!isHighlightedNoteSelected)
+                || (isHighlightedNoteSelected && currentNoteSelection.length == 1);
+              // Show the context menu connected to the note.
+              if (useSingleNoteContextMenu)
+              {
+                this.openHoldNoteContextMenu(FlxG.mouse.screenX, FlxG.mouse.screenY, highlightedHoldNote.noteData);
+              }
+              else
+              {
+                this.openSelectionContextMenu(FlxG.mouse.screenX, FlxG.mouse.screenY);
+              }
+            }
+            else
+            {
+              // Right click removes hold from the note.
+              this.playSound(Paths.sound('chartingSounds/stretchSNAP_UI'));
+              performCommand(new ExtendNoteLengthCommand(highlightedHoldNote.noteData, 0));
+            }
           }
           else
           {
@@ -4086,7 +4643,8 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
           }
         }
 
-        var isOrWillSelect = overlapsSelection || dragTargetNote != null || dragTargetEvent != null;
+        var isOrWillSelect = overlapsSelection || dragTargetNote != null || dragTargetEvent != null || overlapsRenderedNotes || overlapsRenderedHoldNotes
+          || overlapsRenderedEvents;
         // Handle grid cursor.
         if (!isCursorOverHaxeUI && overlapsGrid && !isOrWillSelect && !overlapsSelectionBorder && !gridPlayheadScrollAreaPressed)
         {
@@ -4099,11 +4657,11 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
 
             if (gridGhostEvent == null) throw "ERROR: Tried to handle cursor, but gridGhostEvent is null! Check ChartEditorState.buildGrid()";
 
-            var eventData:SongEventData = gridGhostEvent.eventData != null ? gridGhostEvent.eventData : new SongEventData(cursorMs, selectedEventKind, null);
+            var eventData:SongEventData = gridGhostEvent.eventData != null ? gridGhostEvent.eventData : new SongEventData(cursorMs, eventKindToPlace, null);
 
-            if (selectedEventKind != eventData.event)
+            if (eventKindToPlace != eventData.event)
             {
-              eventData.event = selectedEventKind;
+              eventData.event = eventKindToPlace;
             }
             eventData.time = cursorSnappedMs;
 
@@ -4119,11 +4677,11 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
 
             if (gridGhostNote == null) throw "ERROR: Tried to handle cursor, but gridGhostNote is null! Check ChartEditorState.buildGrid()";
 
-            var noteData:SongNoteData = gridGhostNote.noteData != null ? gridGhostNote.noteData : new SongNoteData(cursorMs, cursorColumn, 0, selectedNoteKind);
+            var noteData:SongNoteData = gridGhostNote.noteData != null ? gridGhostNote.noteData : new SongNoteData(cursorMs, cursorColumn, 0, noteKindToPlace);
 
-            if (cursorColumn != noteData.data || selectedNoteKind != noteData.kind)
+            if (cursorColumn != noteData.data || noteKindToPlace != noteData.kind)
             {
-              noteData.kind = selectedNoteKind;
+              noteData.kind = noteKindToPlace;
               noteData.data = cursorColumn;
               gridGhostNote.playNoteAnimation();
             }
@@ -4165,7 +4723,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
             {
               targetCursorMode = Pointer;
             }
-            else if (gridPlayheadScrollArea != null && FlxG.mouse.overlaps(gridPlayheadScrollArea))
+            else if (measureTicks != null && FlxG.mouse.overlaps(measureTicks))
             {
               targetCursorMode = Pointer;
             }
@@ -4177,6 +4735,18 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
             {
               targetCursorMode = Crosshair;
             }
+            else if (overlapsRenderedNotes)
+            {
+              targetCursorMode = Pointer;
+            }
+            else if (overlapsRenderedHoldNotes)
+            {
+              targetCursorMode = Pointer;
+            }
+            else if (overlapsRenderedEvents)
+            {
+              targetCursorMode = Pointer;
+            }
             else if (overlapsGrid)
             {
               targetCursorMode = Cell;
@@ -4215,48 +4785,10 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
     {
       difficultySelectDirty = false;
 
-      // Manage the Select Difficulty tree view.
-      var difficultyToolbox:Null<CollapsibleDialog> = this.getToolbox_OLD(CHART_EDITOR_TOOLBOX_DIFFICULTY_LAYOUT);
+      var difficultyToolbox:ChartEditorDifficultyToolbox = cast this.getToolbox(CHART_EDITOR_TOOLBOX_DIFFICULTY_LAYOUT);
       if (difficultyToolbox == null) return;
 
-      var treeView:Null<TreeView> = difficultyToolbox.findComponent('difficultyToolboxTree');
-      if (treeView == null) return;
-
-      // Clear the tree view so we can rebuild it.
-      treeView.clearNodes();
-
-      // , icon: 'haxeui-core/styles/default/haxeui_tiny.png'
-      var treeSong:TreeViewNode = treeView.addNode({id: 'stv_song', text: 'S: $currentSongName'});
-      treeSong.expanded = true;
-
-      for (curVariation in availableVariations)
-      {
-        trace('DIFFICULTY TOOLBOX: Variation ${curVariation}');
-        var variationMetadata:Null<SongMetadata> = songMetadata.get(curVariation);
-        if (variationMetadata == null) continue;
-
-        var treeVariation:TreeViewNode = treeSong.addNode(
-          {
-            id: 'stv_variation_$curVariation',
-            text: 'V: ${curVariation.toTitleCase()}'
-          });
-        treeVariation.expanded = true;
-
-        var difficultyList:Array<String> = variationMetadata.playData.difficulties;
-
-        for (difficulty in difficultyList)
-        {
-          trace('DIFFICULTY TOOLBOX: Difficulty ${curVariation}_$difficulty');
-          var _treeDifficulty:TreeViewNode = treeVariation.addNode(
-            {
-              id: 'stv_difficulty_${curVariation}_$difficulty',
-              text: 'D: ${difficulty.toTitleCase()}'
-            });
-        }
-      }
-
-      treeView.onChange = onChangeTreeDifficulty;
-      refreshDifficultyTreeSelection(treeView);
+      difficultyToolbox.updateTree();
     }
   }
 
@@ -4278,7 +4810,10 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
 
       if (currentSongMetadata.playData.characters.player != charPlayer.charId)
       {
-        if (healthIconBF != null) healthIconBF.characterId = currentSongMetadata.playData.characters.player;
+        if (healthIconBF != null)
+        {
+          healthIconBF.characterId = currentSongMetadata.playData.characters.player;
+        }
 
         charPlayer.loadCharacter(currentSongMetadata.playData.characters.player);
         charPlayer.characterType = CharacterType.BF;
@@ -4314,7 +4849,10 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
 
       if (currentSongMetadata.playData.characters.opponent != charPlayer.charId)
       {
-        if (healthIconDad != null) healthIconDad.characterId = currentSongMetadata.playData.characters.opponent;
+        if (healthIconDad != null)
+        {
+          healthIconDad.characterId = currentSongMetadata.playData.characters.opponent;
+        }
 
         charPlayer.loadCharacter(currentSongMetadata.playData.characters.opponent);
         charPlayer.characterType = CharacterType.DAD;
@@ -4332,6 +4870,15 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
     }
   }
 
+  function handleSelectionButtons():Void
+  {
+    // Make sure buttons are never nudged out of the correct spot.
+    // TODO: Why do these even move in the first place? The camera never moves, LOL.
+    buttonSelectOpponent.y = GRID_INITIAL_Y_POS - NOTE_SELECT_BUTTON_HEIGHT - 2;
+    buttonSelectPlayer.y = GRID_INITIAL_Y_POS - NOTE_SELECT_BUTTON_HEIGHT - 2;
+    buttonSelectEvent.y = GRID_INITIAL_Y_POS - NOTE_SELECT_BUTTON_HEIGHT - 2;
+  }
+
   /**
    * Handles display elements for the playbar at the bottom.
    */
@@ -4351,7 +4898,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
       if (playbarHeadLayout.playbarHead.value != songPosPercent) playbarHeadLayout.playbarHead.value = songPosPercent;
     }
 
-    var songPos:Float = Conductor.songPosition + Conductor.instrumentalOffset;
+    var songPos:Float = Conductor.instance.songPosition + Conductor.instance.instrumentalOffset;
     var songPosSeconds:String = Std.string(Math.floor((Math.abs(songPos) / 1000) % 60)).lpad('0', 2);
     var songPosMinutes:String = Std.string(Math.floor((Math.abs(songPos) / 1000) / 60)).lpad('0', 2);
     if (songPos < 0) songPosMinutes = '-' + songPosMinutes;
@@ -4367,8 +4914,8 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
     if (playbarSongRemaining.value != songRemainingString) playbarSongRemaining.value = songRemainingString;
 
     playbarNoteSnap.text = '1/${noteSnapQuant}';
-    playbarDifficulty.text = "Difficulty: " + selectedDifficulty.toTitleCase();
-    playbarBPM.text = "BPM: " + Conductor.currentTimeChange.bpm;
+    playbarDifficulty.text = '${selectedDifficulty.toTitleCase()}';
+    // playbarBPM.text = 'BPM: ${(Conductor.currentTimeChange?.bpm ?? 0.0)}';
   }
 
   function handlePlayhead():Void
@@ -4407,16 +4954,16 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
     var playheadPos:Float = scrollPositionInPixels + playheadPositionInPixels;
     var playheadPosFractionalStep:Float = playheadPos / GRID_SIZE / noteSnapRatio;
     var playheadPosStep:Int = Std.int(Math.floor(playheadPosFractionalStep));
-    var playheadPosSnappedMs:Float = playheadPosStep * Conductor.stepLengthMs * noteSnapRatio;
+    var playheadPosSnappedMs:Float = playheadPosStep * Conductor.instance.stepLengthMs * noteSnapRatio;
 
     // Look for notes within 1 step of the playhead.
     var notesAtPos:Array<SongNoteData> = SongDataUtils.getNotesInTimeRange(currentSongChartNoteData, playheadPosSnappedMs,
-      playheadPosSnappedMs + Conductor.stepLengthMs * noteSnapRatio);
+      playheadPosSnappedMs + Conductor.instance.stepLengthMs * noteSnapRatio);
     notesAtPos = SongDataUtils.getNotesWithData(notesAtPos, [column]);
 
     if (notesAtPos.length == 0)
     {
-      var newNoteData:SongNoteData = new SongNoteData(playheadPosSnappedMs, column, 0, selectedNoteKind);
+      var newNoteData:SongNoteData = new SongNoteData(playheadPosSnappedMs, column, 0, noteKindToPlace);
       performCommand(new AddNotesCommand([newNoteData], FlxG.keys.pressed.CONTROL));
     }
     else
@@ -4440,11 +4987,19 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
         healthIconBF.size *= 0.5; // Make the icon smaller in Chart Editor.
         healthIconBF.flipX = !healthIconBF.flipX; // BF faces the other way.
       }
+      if (buttonSelectPlayer != null)
+      {
+        buttonSelectPlayer.text = charDataBF?.name ?? 'Player';
+      }
       if (healthIconDad != null)
       {
         healthIconDad.configure(charDataDad?.healthIcon);
         healthIconDad.size *= 0.5; // Make the icon smaller in Chart Editor.
       }
+      if (buttonSelectOpponent != null)
+      {
+        buttonSelectOpponent.text = charDataDad?.name ?? 'Opponent';
+      }
       healthIconsDirty = false;
     }
 
@@ -4452,15 +5007,19 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
     if (healthIconBF != null)
     {
       // Base X position to the right of the grid.
-      healthIconBF.x = (gridTiledSprite == null) ? (0) : (gridTiledSprite.x + gridTiledSprite.width + 45 - (healthIconBF.width / 2));
-      healthIconBF.y = (gridTiledSprite == null) ? (0) : (MENU_BAR_HEIGHT + GRID_TOP_PAD + 30 - (healthIconBF.height / 2));
+      var xOffset = 45 - (healthIconBF.width / 2);
+      healthIconBF.x = (gridTiledSprite == null) ? (0) : (GRID_X_POS + gridTiledSprite.width + xOffset);
+      var yOffset = 30 - (healthIconBF.height / 2);
+      healthIconBF.y = (gridTiledSprite == null) ? (0) : (GRID_INITIAL_Y_POS - NOTE_SELECT_BUTTON_HEIGHT) + yOffset;
     }
 
     // Visibly center the Dad health icon.
     if (healthIconDad != null)
     {
-      healthIconDad.x = (gridTiledSprite == null) ? (0) : (gridTiledSprite.x - 45 - (healthIconDad.width / 2));
-      healthIconDad.y = (gridTiledSprite == null) ? (0) : (MENU_BAR_HEIGHT + GRID_TOP_PAD + 30 - (healthIconDad.height / 2));
+      var xOffset = 75 + (healthIconDad.width / 2);
+      healthIconDad.x = (gridTiledSprite == null) ? (0) : (GRID_X_POS - xOffset);
+      var yOffset = 30 - (healthIconDad.height / 2);
+      healthIconDad.y = (gridTiledSprite == null) ? (0) : (GRID_INITIAL_Y_POS - NOTE_SELECT_BUTTON_HEIGHT) + yOffset;
     }
   }
 
@@ -4518,6 +5077,8 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
     FlxG.switchState(new MainMenuState());
 
     resetWindowTitle();
+
+    criticalFailure = true;
   }
 
   /**
@@ -4540,54 +5101,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
     // CTRL + C = Copy
     if (FlxG.keys.pressed.CONTROL && FlxG.keys.justPressed.C)
     {
-      if (currentNoteSelection.length > 0)
-      {
-        txtCopyNotif.visible = true;
-        txtCopyNotif.text = "Copied " + currentNoteSelection.length + " notes to clipboard";
-        txtCopyNotif.x = FlxG.mouse.x - (txtCopyNotif.width / 2);
-        txtCopyNotif.y = FlxG.mouse.y - 16;
-        FlxTween.tween(txtCopyNotif, {y: txtCopyNotif.y - 32}, 0.5,
-          {
-            type: FlxTween.ONESHOT,
-            ease: FlxEase.quadOut,
-            onComplete: function(_) {
-              txtCopyNotif.visible = false;
-            }
-          });
-
-        for (note in renderedNotes.members)
-        {
-          if (isNoteSelected(note.noteData))
-          {
-            FlxTween.globalManager.cancelTweensOf(note);
-            FlxTween.globalManager.cancelTweensOf(note.scale);
-            note.playNoteAnimation();
-            var prevX:Float = note.scale.x;
-            var prevY:Float = note.scale.y;
-
-            note.scale.x *= 1.2;
-            note.scale.y *= 1.2;
-
-            note.angle = FlxG.random.bool() ? -10 : 10;
-            FlxTween.tween(note, {"angle": 0}, 0.8, {ease: FlxEase.elasticOut});
-
-            FlxTween.tween(note.scale, {"y": prevX, "x": prevY}, 0.7,
-              {
-                ease: FlxEase.elasticOut,
-                onComplete: function(_) {
-                  note.playNoteAnimation();
-                }
-              });
-          }
-        }
-      }
-
-      // We don't need a command for this since we can't undo it.
-      SongDataUtils.writeItemsToClipboard(
-        {
-          notes: SongDataUtils.buildNoteClipboard(currentNoteSelection),
-          events: SongDataUtils.buildEventClipboard(currentEventSelection),
-        });
+      performCommand(new CopyItemsCommand(currentNoteSelection, currentEventSelection));
     }
 
     // CTRL + X = Cut
@@ -4608,9 +5122,9 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
       else
       {
         var targetMs:Float = scrollPositionInMs + playheadPositionInMs;
-        var targetStep:Float = Conductor.getTimeInSteps(targetMs);
+        var targetStep:Float = Conductor.instance.getTimeInSteps(targetMs);
         var targetSnappedStep:Float = Math.floor(targetStep / noteSnapRatio) * noteSnapRatio;
-        var targetSnappedMs:Float = Conductor.getStepTimeInMs(targetSnappedStep);
+        var targetSnappedMs:Float = Conductor.instance.getStepTimeInMs(targetSnappedStep);
         targetSnappedMs;
       }
       performCommand(new PasteItemsCommand(targetMs));
@@ -4648,25 +5162,50 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
       performCommand(new FlipNotesCommand(currentNoteSelection));
     }
 
-    // CTRL + A = Select All
+    // CTRL + A = Select All Notes
     if (FlxG.keys.pressed.CONTROL && FlxG.keys.justPressed.A)
     {
       // Select all items.
-      performCommand(new SelectAllItemsCommand(currentNoteSelection, currentEventSelection));
+      if (FlxG.keys.pressed.ALT)
+      {
+        if (FlxG.keys.pressed.SHIFT)
+        {
+          // CTRL + ALT + SHIFT + A = Append All Events to Selection
+          performCommand(new SelectItemsCommand([], currentSongChartEventData));
+        }
+        else
+        {
+          // CTRL + ALT + A = Set Selection to All Events
+          performCommand(new SelectAllItemsCommand(false, true));
+        }
+      }
+      else
+      {
+        if (FlxG.keys.pressed.SHIFT)
+        {
+          // CTRL + SHIFT + A = Append All Notes to Selection
+          performCommand(new SelectItemsCommand(currentSongChartNoteData, []));
+        }
+        else
+        {
+          // CTRL + A = Set Selection to All Notes
+          performCommand(new SelectAllItemsCommand(true, false));
+        }
+      }
     }
 
     // CTRL + I = Select Inverse
     if (FlxG.keys.pressed.CONTROL && FlxG.keys.justPressed.I)
     {
       // Select unselected items and deselect selected items.
-      performCommand(new InvertSelectedItemsCommand(currentNoteSelection, currentEventSelection));
+      performCommand(new InvertSelectedItemsCommand());
     }
 
     // CTRL + D = Select None
     if (FlxG.keys.pressed.CONTROL && FlxG.keys.justPressed.D)
     {
       // Deselect all items.
-      performCommand(new DeselectAllItemsCommand(currentNoteSelection, currentEventSelection));
+      performCommand(new DeselectAllItemsCommand());
     }
   }
 
@@ -4719,20 +5258,18 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
     #end
   }
 
-  override function handleQuickWatch():Void
+  function handleQuickWatch():Void
   {
-    super.handleQuickWatch();
-
     FlxG.watch.addQuick('musicTime', audioInstTrack?.time ?? 0.0);
 
     FlxG.watch.addQuick('scrollPosInPixels', scrollPositionInPixels);
     FlxG.watch.addQuick('playheadPosInPixels', playheadPositionInPixels);
 
-    FlxG.watch.addQuick("tapNotesRendered", renderedNotes.members.length);
-    FlxG.watch.addQuick("holdNotesRendered", renderedHoldNotes.members.length);
-    FlxG.watch.addQuick("eventsRendered", renderedEvents.members.length);
-    FlxG.watch.addQuick("notesSelected", currentNoteSelection.length);
-    FlxG.watch.addQuick("eventsSelected", currentEventSelection.length);
+    FlxG.watch.addQuick("tapNotesRendered", renderedNotes?.members?.length);
+    FlxG.watch.addQuick("holdNotesRendered", renderedHoldNotes?.members?.length);
+    FlxG.watch.addQuick("eventsRendered", renderedEvents?.members?.length);
+    FlxG.watch.addQuick("notesSelected", currentNoteSelection?.length);
+    FlxG.watch.addQuick("eventsSelected", currentEventSelection?.length);
   }
 
   function handlePostUpdate():Void
@@ -4750,9 +5287,10 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
    */
   function testSongInPlayState(minimal:Bool = false):Void
   {
-    autoSave();
+    autoSave(true);
 
     stopWelcomeMusic();
+    stopAudioPlayback();
 
     var startTimestamp:Float = 0;
     if (playtestStartTime) startTimestamp = scrollPositionInMs + playheadPositionInMs;
@@ -4768,6 +5306,9 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
       return;
     }
 
+    LogStyle.WARNING.openConsole = enabledDebuggerPopup;
+    LogStyle.ERROR.openConsole = enabledDebuggerPopup;
+
     // TODO: Rework asset system so we can remove this.
     switch (currentSongStage)
     {
@@ -4789,7 +5330,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
         Paths.setCurrentLevel('weekend1');
     }
 
-    subStateClosed.add(fixCamera);
+    subStateClosed.add(reviveUICamera);
     subStateClosed.add(resetConductorAfterTest);
 
     FlxTransitionableState.skipNextTransIn = false;
@@ -4812,7 +5353,12 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
     {
       FlxG.sound.music = audioInstTrack;
     }
-    if (audioVocalTrackGroup != null) targetState.vocals = audioVocalTrackGroup;
+    targetState.vocals = audioVocalTrackGroup;
+
+    // Kill and replace the UI camera so it doesn't get destroyed during the state transition.
+    uiCamera.kill();
+    FlxG.cameras.remove(uiCamera, false);
+    FlxG.cameras.reset(new FlxCamera());
 
     this.persistentUpdate = false;
     this.persistentDraw = false;
@@ -4829,13 +5375,16 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
    * Perform (or redo) a command, then add it to the undo stack.
    *
    * @param command The command to perform.
-   * @param purgeRedoStack If true, the redo stack will be cleared.
+   * @param purgeRedoStack If `true`, the redo stack will be cleared after performing the command.
    */
   function performCommand(command:ChartEditorCommand, purgeRedoStack:Bool = true):Void
   {
     command.execute(this);
-    undoHistory.push(command);
-    commandHistoryDirty = true;
+    if (command.shouldAddToHistory(this))
+    {
+      undoHistory.push(command);
+      commandHistoryDirty = true;
+    }
     if (purgeRedoStack) redoHistory = [];
   }
 
@@ -4846,6 +5395,8 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
   function undoCommand(command:ChartEditorCommand):Void
   {
     command.undo(this);
+    // Note, if we are undoing a command, it should already be in the history,
+    // therefore we don't need to check `shouldAddToHistory(state)`
     redoHistory.push(command);
     commandHistoryDirty = true;
   }
@@ -4892,19 +5443,18 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
       throw "ERROR: Tried to build selection square, but selectionSquareBitmap is null! Check ChartEditorThemeHandler.updateSelectionSquare()";
 
     // FlxG.bitmapLog.add(selectionSquareBitmap, "selectionSquareBitmap");
-    var result = new ChartEditorSelectionSquareSprite();
+    var result = new ChartEditorSelectionSquareSprite(this);
     result.loadGraphic(selectionSquareBitmap);
     return result;
   }
 
   /**
-   * Fix a camera issue caused when closing the PlayState used when testing.
+   * Revive the UI camera and re-establish it as the main camera so UI elements depending on it don't explode.
    */
-  function fixCamera(_:FlxSubState = null):Void
+  function reviveUICamera(_:FlxSubState = null):Void
   {
-    FlxG.cameras.reset(new FlxCamera());
-    FlxG.camera.focusOn(new FlxPoint(FlxG.width / 2, FlxG.height / 2));
-    FlxG.camera.zoom = 1.0;
+    uiCamera.revive();
+    FlxG.cameras.reset(uiCamera);
 
     add(this.root);
   }
@@ -4919,7 +5469,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
     if (audioInstTrack != null)
     {
       audioInstTrack.play(false, audioInstTrack.time);
-      if (audioVocalTrackGroup != null) audioVocalTrackGroup.play(false, audioInstTrack.time);
+      audioVocalTrackGroup.play(false, audioInstTrack.time);
     }
 
     playbarPlay.text = '||'; // Pause
@@ -4942,16 +5492,18 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
 
   function onSongLengthChanged():Void
   {
-    if (gridTiledSprite != null) gridTiledSprite.height = songLengthInPixels;
-    if (gridPlayheadScrollArea != null)
+    if (gridTiledSprite != null)
     {
-      gridPlayheadScrollArea.setGraphicSize(Std.int(gridPlayheadScrollArea.width), songLengthInPixels);
-      gridPlayheadScrollArea.updateHitbox();
+      gridTiledSprite.height = songLengthInPixels;
+    }
+    if (measureTicks != null)
+    {
+      measureTicks.setHeight(songLengthInPixels);
     }
 
     // Remove any notes past the end of the song.
     var songCutoffPointSteps:Float = songLengthInSteps - 0.1;
-    var songCutoffPointMs:Float = Conductor.getStepTimeInMs(songCutoffPointSteps);
+    var songCutoffPointMs:Float = Conductor.instance.getStepTimeInMs(songCutoffPointSteps);
     currentSongChartNoteData = SongDataUtils.clampSongNoteData(currentSongChartNoteData, 0.0, songCutoffPointMs);
     currentSongChartEventData = SongDataUtils.clampSongEventData(currentSongChartEventData, 0.0, songCutoffPointMs);
 
@@ -4986,7 +5538,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
     return event != null && currentEventSelection.indexOf(event) != -1;
   }
 
-  function createDifficulty(variation:String, difficulty:String, scrollSpeed:Float = 1.0)
+  function createDifficulty(variation:String, difficulty:String, scrollSpeed:Float = 1.0):Void
   {
     var variationMetadata:Null<SongMetadata> = songMetadata.get(variation);
     if (variationMetadata == null) return;
@@ -5008,6 +5560,42 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
     difficultySelectDirty = true; // Force the Difficulty toolbox to update.
   }
 
+  function removeDifficulty(variation:String, difficulty:String):Void
+  {
+    var variationMetadata:Null<SongMetadata> = songMetadata.get(variation);
+    if (variationMetadata == null) return;
+
+    variationMetadata.playData.difficulties.remove(difficulty);
+
+    var resultChartData = songChartData.get(variation);
+    if (resultChartData != null)
+    {
+      resultChartData.scrollSpeed.remove(difficulty);
+      resultChartData.notes.remove(difficulty);
+    }
+
+    if (songMetadata.size() > 1)
+    {
+      if (variationMetadata.playData.difficulties.length == 0)
+      {
+        songMetadata.remove(variation);
+        songChartData.remove(variation);
+      }
+
+      if (variation == selectedVariation)
+      {
+        var firstVariation = songMetadata.keyValues()[0];
+        if (firstVariation != null) selectedVariation = firstVariation;
+        variationMetadata = songMetadata.get(selectedVariation);
+      }
+    }
+
+    if (selectedDifficulty == difficulty
+      || !variationMetadata.playData.difficulties.contains(selectedDifficulty)) selectedDifficulty = variationMetadata.playData.difficulties[0];
+
+    difficultySelectDirty = true; // Force the Difficulty toolbox to update.
+  }
+
   function incrementDifficulty(change:Int):Void
   {
     var currentDifficultyIndex:Int = availableDifficulties.indexOf(selectedDifficulty);
@@ -5053,10 +5641,11 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
         var prevDifficulty = availableDifficulties[availableDifficulties.length - 1];
         selectedDifficulty = prevDifficulty;
 
-        Conductor.mapTimeChanges(this.currentSongMetadata.timeChanges);
+        Conductor.instance.mapTimeChanges(this.currentSongMetadata.timeChanges);
+        updateTimeSignature();
 
-        refreshDifficultyTreeSelection();
         this.refreshToolbox(CHART_EDITOR_TOOLBOX_METADATA_LAYOUT);
+        this.refreshToolbox(CHART_EDITOR_TOOLBOX_DIFFICULTY_LAYOUT);
       }
       else
       {
@@ -5064,8 +5653,8 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
         var prevDifficulty = availableDifficulties[currentDifficultyIndex - 1];
         selectedDifficulty = prevDifficulty;
 
-        refreshDifficultyTreeSelection();
         this.refreshToolbox(CHART_EDITOR_TOOLBOX_METADATA_LAYOUT);
+        this.refreshToolbox(CHART_EDITOR_TOOLBOX_DIFFICULTY_LAYOUT);
       }
     }
     else
@@ -5083,8 +5672,8 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
         var nextDifficulty = availableDifficulties[0];
         selectedDifficulty = nextDifficulty;
 
-        refreshDifficultyTreeSelection();
         this.refreshToolbox(CHART_EDITOR_TOOLBOX_METADATA_LAYOUT);
+        this.refreshToolbox(CHART_EDITOR_TOOLBOX_DIFFICULTY_LAYOUT);
       }
       else
       {
@@ -5092,12 +5681,13 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
         var nextDifficulty = availableDifficulties[currentDifficultyIndex + 1];
         selectedDifficulty = nextDifficulty;
 
-        refreshDifficultyTreeSelection();
+        this.refreshToolbox(CHART_EDITOR_TOOLBOX_DIFFICULTY_LAYOUT);
         this.refreshToolbox(CHART_EDITOR_TOOLBOX_METADATA_LAYOUT);
       }
     }
 
-    this.success('Switch Difficulty', 'Switched difficulty to ${selectedDifficulty.toTitleCase()}');
+    // Removed this notification because you can see your difficulty in the playbar now.
+    // this.success('Switch Difficulty', 'Switched difficulty to ${selectedDifficulty.toTitleCase()}');
   }
 
   /**
@@ -5114,10 +5704,10 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
     // Update the songPosition in the audio tracks.
     if (audioInstTrack != null)
     {
-      audioInstTrack.time = scrollPositionInMs + playheadPositionInMs - Conductor.instrumentalOffset;
+      audioInstTrack.time = scrollPositionInMs + playheadPositionInMs - Conductor.instance.instrumentalOffset;
       // Update the songPosition in the Conductor.
-      Conductor.update(audioInstTrack.time);
-      if (audioVocalTrackGroup != null) audioVocalTrackGroup.time = audioInstTrack.time;
+      Conductor.instance.update(audioInstTrack.time);
+      audioVocalTrackGroup.time = audioInstTrack.time;
     }
 
     // We need to update the note sprites because we changed the scroll position.
@@ -5176,117 +5766,62 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
     this.persistentUpdate = true;
     this.persistentDraw = true;
 
+    if (displayAutosavePopup)
+    {
+      displayAutosavePopup = false;
+      #if sys
+      Toolkit.callLater(() -> {
+        var absoluteBackupsPath:String = Path.join([Sys.getCwd(), ChartEditorImportExportHandler.BACKUPS_PATH]);
+        this.infoWithActions('Auto-Save', 'Chart auto-saved to ${absoluteBackupsPath}.', [
+          {
+            text: "Take Me There",
+            callback: openBackupsFolder,
+          }
+        ]);
+      });
+      #else
+      // TODO: No auto-save on HTML5?
+      #end
+    }
+
     moveSongToScrollPosition();
 
-    fadeInWelcomeMusic(7, 10);
+    fadeInWelcomeMusic(WELCOME_MUSIC_FADE_IN_DELAY, WELCOME_MUSIC_FADE_IN_DURATION);
 
     // Reapply the volume.
     var instTargetVolume:Float = menubarItemVolumeInstrumental.value ?? 1.0;
-    var vocalTargetVolume:Float = menubarItemVolumeVocals.value ?? 1.0;
+    var vocalPlayerTargetVolume:Float = menubarItemVolumeVocalsPlayer.value ?? 1.0;
+    var vocalOpponentTargetVolume:Float = menubarItemVolumeVocalsOpponent.value ?? 1.0;
 
     if (audioInstTrack != null)
     {
       audioInstTrack.volume = instTargetVolume;
       audioInstTrack.onComplete = null;
     }
-    if (audioVocalTrackGroup != null) audioVocalTrackGroup.volume = vocalTargetVolume;
+    if (audioVocalTrackGroup != null)
+    {
+      audioVocalTrackGroup.playerVolume = vocalPlayerTargetVolume;
+      audioVocalTrackGroup.opponentVolume = vocalOpponentTargetVolume;
+    }
+  }
+
+  function updateTimeSignature():Void
+  {
+    // Redo the grid bitmap to be 4/4.
+    this.updateTheme();
+    gridTiledSprite.loadGraphic(gridBitmap);
+    measureTicks.reloadTickBitmap();
   }
 
   /**
    * HAXEUI FUNCTIONS
    */
-  // ====================
-
-  /**
-   * Set the currently selected item in the Difficulty tree view to the node representing the current difficulty.
-   * @param treeView The tree view to update. If `null`, the tree view will be found.
-   */
-  function refreshDifficultyTreeSelection(?treeView:TreeView):Void
-  {
-    if (treeView == null)
-    {
-      // Manage the Select Difficulty tree view.
-      var difficultyToolbox:Null<CollapsibleDialog> = this.getToolbox_OLD(CHART_EDITOR_TOOLBOX_DIFFICULTY_LAYOUT);
-      if (difficultyToolbox == null) return;
-
-      treeView = difficultyToolbox.findComponent('difficultyToolboxTree');
-      if (treeView == null) return;
-    }
-
-    var currentTreeDifficultyNode = getCurrentTreeDifficultyNode(treeView);
-    if (currentTreeDifficultyNode != null) treeView.selectedNode = currentTreeDifficultyNode;
-  }
-
-  /**
-   * Retrieve the node representing the current difficulty in the Difficulty tree view.
-   * @param treeView The tree view to search. If `null`, the tree view will be found.
-   * @return The node representing the current difficulty, or `null` if not found.
-   */
-  function getCurrentTreeDifficultyNode(?treeView:TreeView = null):Null<TreeViewNode>
-  {
-    if (treeView == null)
-    {
-      var difficultyToolbox:Null<CollapsibleDialog> = this.getToolbox_OLD(CHART_EDITOR_TOOLBOX_DIFFICULTY_LAYOUT);
-      if (difficultyToolbox == null) return null;
-
-      treeView = difficultyToolbox.findComponent('difficultyToolboxTree');
-      if (treeView == null) return null;
-    }
-
-    var result:TreeViewNode = treeView.findNodeByPath('stv_song/stv_variation_$selectedVariation/stv_difficulty_${selectedVariation}_$selectedDifficulty',
-      'id');
-    if (result == null) return null;
-
-    return result;
-  }
-
-  /**
-   * Called when selecting a tree element in the Difficulty toolbox.
-   * @param event The click event.
-   */
-  function onChangeTreeDifficulty(event:UIEvent):Void
-  {
-    // Get the newly selected node.
-    var treeView:TreeView = cast event.target;
-    var targetNode:TreeViewNode = treeView.selectedNode;
-
-    if (targetNode == null)
-    {
-      trace('No target node!');
-      // Reset the user's selection.
-      var currentTreeDifficultyNode = getCurrentTreeDifficultyNode(treeView);
-      if (currentTreeDifficultyNode != null) treeView.selectedNode = currentTreeDifficultyNode;
-      return;
-    }
-
-    switch (targetNode.data.id.split('_')[1])
-    {
-      case 'difficulty':
-        var variation:String = targetNode.data.id.split('_')[2];
-        var difficulty:String = targetNode.data.id.split('_')[3];
-
-        if (variation != null && difficulty != null)
-        {
-          trace('Changing difficulty to "$variation:$difficulty"');
-          selectedVariation = variation;
-          selectedDifficulty = difficulty;
-          this.refreshToolbox(CHART_EDITOR_TOOLBOX_METADATA_LAYOUT);
-        }
-      // case 'song':
-      // case 'variation':
-      default:
-        // Reset the user's selection.
-        trace('Selected wrong node type, resetting selection.');
-        var currentTreeDifficultyNode = getCurrentTreeDifficultyNode(treeView);
-        if (currentTreeDifficultyNode != null) treeView.selectedNode = currentTreeDifficultyNode;
-        this.refreshToolbox(CHART_EDITOR_TOOLBOX_METADATA_LAYOUT);
-    }
-  }
+  // ==================
 
   /**
    * STATIC FUNCTIONS
    */
-  // ====================
+  // ==================
 
   function handleNotePreview():Void
   {
@@ -5297,12 +5832,14 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
       // TODO: Only update the notes that have changed.
       notePreview.erase();
       notePreview.addNotes(currentSongChartNoteData, Std.int(songLengthInMs));
+      notePreview.addSelectedNotes(currentNoteSelection, Std.int(songLengthInMs));
       notePreview.addEvents(currentSongChartEventData, Std.int(songLengthInMs));
     }
 
     if (notePreviewViewportBoundsDirty)
     {
       setNotePreviewViewportBounds(calculateNotePreviewViewportBounds());
+      notePreviewViewportBoundsDirty = false;
     }
   }
 
@@ -5391,7 +5928,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
   function stopAudioPlayback():Void
   {
     if (audioInstTrack != null) audioInstTrack.pause();
-    if (audioVocalTrackGroup != null) audioVocalTrackGroup.pause();
+    audioVocalTrackGroup.pause();
 
     playbarPlay.text = '>';
   }
@@ -5404,7 +5941,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
     {
       // Pause
       stopAudioPlayback();
-      fadeInWelcomeMusic(7, 10);
+      fadeInWelcomeMusic(WELCOME_MUSIC_FADE_IN_DELAY, WELCOME_MUSIC_FADE_IN_DURATION);
     }
     else
     {
@@ -5426,7 +5963,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
           // Keep the track at the end.
           audioInstTrack.time = audioInstTrack.length;
         }
-        if (audioVocalTrackGroup != null) audioVocalTrackGroup.pause();
+        audioVocalTrackGroup.pause();
       };
     }
     else
@@ -5434,18 +5971,38 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
       trace('ERROR: Instrumental track is null!');
     }
 
-    this.songLengthInMs = (audioInstTrack?.length ?? 1000.0) + Conductor.instrumentalOffset;
+    this.songLengthInMs = (audioInstTrack?.length ?? 1000.0) + Conductor.instance.instrumentalOffset;
 
     // Many things get reset when song length changes.
     healthIconsDirty = true;
   }
 
+  function hardRefreshOffsetsToolbox():Void
+  {
+    var offsetsToolbox:ChartEditorOffsetsToolbox = cast this.getToolbox(CHART_EDITOR_TOOLBOX_OFFSETS_LAYOUT);
+    if (offsetsToolbox != null)
+    {
+      offsetsToolbox.refreshAudioPreview();
+      offsetsToolbox.refresh();
+    }
+  }
+
+  function hardRefreshFreeplayToolbox():Void
+  {
+    var freeplayToolbox:ChartEditorFreeplayToolbox = cast this.getToolbox(CHART_EDITOR_TOOLBOX_FREEPLAY_LAYOUT);
+    if (freeplayToolbox != null)
+    {
+      freeplayToolbox.refreshAudioPreview();
+      freeplayToolbox.refresh();
+    }
+  }
+
   /**
    * Clear the voices group.
    */
   public function clearVocals():Void
   {
-    if (audioVocalTrackGroup != null) audioVocalTrackGroup.clear();
+    audioVocalTrackGroup.clear();
   }
 
   function isNoteSelected(note:Null<SongNoteData>):Bool
@@ -5459,11 +6016,18 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
 
     cleanupAutoSave();
 
+    this.closeAllMenus();
+
     // Hide the mouse cursor on other states.
     Cursor.hide();
 
     @:privateAccess
     ChartEditorNoteSprite.noteFrameCollection = null;
+
+    // Stop the music.
+    if (welcomeMusic != null) welcomeMusic.destroy();
+    if (audioInstTrack != null) audioInstTrack.destroy();
+    audioVocalTrackGroup.destroy();
   }
 
   function applyCanQuickSave():Void
diff --git a/source/funkin/ui/debug/charting/commands/AddEventsCommand.hx b/source/funkin/ui/debug/charting/commands/AddEventsCommand.hx
index 9bf8ec3db..a878ee687 100644
--- a/source/funkin/ui/debug/charting/commands/AddEventsCommand.hx
+++ b/source/funkin/ui/debug/charting/commands/AddEventsCommand.hx
@@ -59,6 +59,12 @@ class AddEventsCommand implements ChartEditorCommand
     state.sortChartData();
   }
 
+  public function shouldAddToHistory(state:ChartEditorState):Bool
+  {
+    // This command is undoable. Add to the history if we actually performed an action.
+    return (events.length > 0);
+  }
+
   public function toString():String
   {
     var len:Int = events.length;
diff --git a/source/funkin/ui/debug/charting/commands/AddNotesCommand.hx b/source/funkin/ui/debug/charting/commands/AddNotesCommand.hx
index ce4e73ea2..ea984c82d 100644
--- a/source/funkin/ui/debug/charting/commands/AddNotesCommand.hx
+++ b/source/funkin/ui/debug/charting/commands/AddNotesCommand.hx
@@ -59,6 +59,12 @@ class AddNotesCommand implements ChartEditorCommand
     state.sortChartData();
   }
 
+  public function shouldAddToHistory(state:ChartEditorState):Bool
+  {
+    // This command is undoable. Add to the history if we actually performed an action.
+    return (notes.length > 0);
+  }
+
   public function toString():String
   {
     if (notes.length == 1)
diff --git a/source/funkin/ui/debug/charting/commands/ChangeStartingBPMCommand.hx b/source/funkin/ui/debug/charting/commands/ChangeStartingBPMCommand.hx
index 3c45c1168..bd832fab3 100644
--- a/source/funkin/ui/debug/charting/commands/ChangeStartingBPMCommand.hx
+++ b/source/funkin/ui/debug/charting/commands/ChangeStartingBPMCommand.hx
@@ -34,7 +34,12 @@ class ChangeStartingBPMCommand implements ChartEditorCommand
 
     state.currentSongMetadata.timeChanges = timeChanges;
 
-    Conductor.mapTimeChanges(state.currentSongMetadata.timeChanges);
+    state.noteDisplayDirty = true;
+    state.notePreviewDirty = true;
+    state.notePreviewViewportBoundsDirty = true;
+    state.scrollPositionInPixels = 0;
+
+    Conductor.instance.mapTimeChanges(state.currentSongMetadata.timeChanges);
   }
 
   public function undo(state:ChartEditorState):Void
@@ -51,7 +56,18 @@ class ChangeStartingBPMCommand implements ChartEditorCommand
 
     state.currentSongMetadata.timeChanges = timeChanges;
 
-    Conductor.mapTimeChanges(state.currentSongMetadata.timeChanges);
+    state.noteDisplayDirty = true;
+    state.notePreviewDirty = true;
+    state.notePreviewViewportBoundsDirty = true;
+    state.scrollPositionInPixels = 0;
+
+    Conductor.instance.mapTimeChanges(state.currentSongMetadata.timeChanges);
+  }
+
+  public function shouldAddToHistory(state:ChartEditorState):Bool
+  {
+    // This command is undoable. Add to the history if we actually performed an action.
+    return (targetBPM != previousBPM);
   }
 
   public function toString():String
diff --git a/source/funkin/ui/debug/charting/commands/ChartEditorCommand.hx b/source/funkin/ui/debug/charting/commands/ChartEditorCommand.hx
index cfa169908..1fa86ad94 100644
--- a/source/funkin/ui/debug/charting/commands/ChartEditorCommand.hx
+++ b/source/funkin/ui/debug/charting/commands/ChartEditorCommand.hx
@@ -6,6 +6,8 @@ package funkin.ui.debug.charting.commands;
  *
  * To make a functionality compatible with the undo/redo history, create a new class
  * that implements ChartEditorCommand, then call `ChartEditorState.performCommand(new Command())`
+ *
+ * NOTE: Make the constructor very simple, as it may be called without executing by the command palette.
  */
 interface ChartEditorCommand
 {
@@ -22,6 +24,15 @@ interface ChartEditorCommand
    */
   public function undo(state:ChartEditorState):Void;
 
+  /**
+   * Return whether or not this command should be appended to the in the undo/redo history.
+   * Generally this should be true, it should only be false if the command is minor and non-destructive,
+   * like copying to the clipboard.
+   *
+   * Called after `execute()` is performed.
+   */
+  public function shouldAddToHistory(state:ChartEditorState):Bool;
+
   /**
    * Get a short description of the action (for the UI).
    * For example, return `Add Left Note` to display `Undo Add Left Note` in the menu.
diff --git a/source/funkin/ui/debug/charting/commands/CopyItemsCommand.hx b/source/funkin/ui/debug/charting/commands/CopyItemsCommand.hx
new file mode 100644
index 000000000..6c5152a29
--- /dev/null
+++ b/source/funkin/ui/debug/charting/commands/CopyItemsCommand.hx
@@ -0,0 +1,170 @@
+package funkin.ui.debug.charting.commands;
+
+import funkin.data.song.SongData.SongNoteData;
+import funkin.data.song.SongData.SongEventData;
+import funkin.data.song.SongDataUtils;
+import flixel.tweens.FlxEase;
+import flixel.tweens.FlxTween;
+
+/**
+ * Command that copies a given set of notes and song events to the clipboard,
+ * without deleting them from the chart editor.
+ */
+@:nullSafety
+@:access(funkin.ui.debug.charting.ChartEditorState)
+class CopyItemsCommand implements ChartEditorCommand
+{
+  var notes:Array<SongNoteData>;
+  var events:Array<SongEventData>;
+
+  public function new(notes:Array<SongNoteData>, events:Array<SongEventData>)
+  {
+    this.notes = notes;
+    this.events = events;
+  }
+
+  public function execute(state:ChartEditorState):Void
+  {
+    // Calculate a single time offset for all the notes and events.
+    var timeOffset:Null<Int> = state.currentNoteSelection.length > 0 ? Std.int(state.currentNoteSelection[0].time) : null;
+    if (state.currentEventSelection.length > 0)
+    {
+      if (timeOffset == null || state.currentEventSelection[0].time < timeOffset)
+      {
+        timeOffset = Std.int(state.currentEventSelection[0].time);
+      }
+    }
+
+    SongDataUtils.writeItemsToClipboard(
+      {
+        notes: SongDataUtils.buildNoteClipboard(state.currentNoteSelection, timeOffset),
+        events: SongDataUtils.buildEventClipboard(state.currentEventSelection, timeOffset),
+      });
+
+    performVisuals(state);
+  }
+
+  function performVisuals(state:ChartEditorState):Void
+  {
+    var hasNotes:Bool = false;
+    var hasEvents:Bool = false;
+
+    // Wiggle copied notes.
+    if (state.currentNoteSelection.length > 0)
+    {
+      hasNotes = true;
+
+      for (note in state.renderedNotes.members)
+      {
+        if (state.isNoteSelected(note.noteData))
+        {
+          FlxTween.globalManager.cancelTweensOf(note);
+          FlxTween.globalManager.cancelTweensOf(note.scale);
+          note.playNoteAnimation();
+          var prevX:Float = note.scale.x;
+          var prevY:Float = note.scale.y;
+
+          note.scale.x *= 1.2;
+          note.scale.y *= 1.2;
+
+          note.angle = FlxG.random.bool() ? -10 : 10;
+          FlxTween.tween(note, {"angle": 0}, 0.8, {ease: FlxEase.elasticOut});
+
+          FlxTween.tween(note.scale, {"y": prevX, "x": prevY}, 0.7,
+            {
+              ease: FlxEase.elasticOut,
+              onComplete: function(_) {
+                note.playNoteAnimation();
+              }
+            });
+        }
+      }
+    }
+
+    // Wiggle copied events.
+    if (state.currentEventSelection.length > 0)
+    {
+      hasEvents = true;
+
+      for (event in state.renderedEvents.members)
+      {
+        if (state.isEventSelected(event.eventData))
+        {
+          FlxTween.globalManager.cancelTweensOf(event);
+          FlxTween.globalManager.cancelTweensOf(event.scale);
+          event.playAnimation();
+          var prevX:Float = event.scale.x;
+          var prevY:Float = event.scale.y;
+
+          event.scale.x *= 1.2;
+          event.scale.y *= 1.2;
+
+          event.angle = FlxG.random.bool() ? -10 : 10;
+          FlxTween.tween(event, {"angle": 0}, 0.8, {ease: FlxEase.elasticOut});
+
+          FlxTween.tween(event.scale, {"y": prevX, "x": prevY}, 0.7,
+            {
+              ease: FlxEase.elasticOut,
+              onComplete: function(_) {
+                event.playAnimation();
+              }
+            });
+        }
+      }
+    }
+
+    // Display the "Copied Notes" text.
+    if ((hasNotes || hasEvents) && state.txtCopyNotif != null)
+    {
+      var copiedString:String = '';
+      if (hasNotes)
+      {
+        var copiedNotes:Int = state.currentNoteSelection.length;
+        copiedString += '${copiedNotes} note';
+        if (copiedNotes > 1) copiedString += 's';
+
+        if (hasEvents) copiedString += ' and ';
+      }
+      if (hasEvents)
+      {
+        var copiedEvents:Int = state.currentEventSelection.length;
+        copiedString += '${state.currentEventSelection.length} event';
+        if (copiedEvents > 1) copiedString += 's';
+      }
+
+      state.txtCopyNotif.visible = true;
+      state.txtCopyNotif.text = 'Copied ${copiedString} to clipboard';
+      state.txtCopyNotif.x = FlxG.mouse.x - (state.txtCopyNotif.width / 2);
+      state.txtCopyNotif.y = FlxG.mouse.y - 16;
+      FlxTween.tween(state.txtCopyNotif, {y: state.txtCopyNotif.y - 32}, 0.5,
+        {
+          type: FlxTween.ONESHOT,
+          ease: FlxEase.quadOut,
+          onComplete: function(_) {
+            state.txtCopyNotif.visible = false;
+          }
+        });
+    }
+  }
+
+  public function undo(state:ChartEditorState):Void
+  {
+    // This command is not undoable. Do nothing.
+  }
+
+  public function shouldAddToHistory(state:ChartEditorState):Bool
+  {
+    // This command is not undoable. Don't add it to the history.
+    return false;
+  }
+
+  public function toString():String
+  {
+    var len:Int = notes.length + events.length;
+
+    if (notes.length == 0) return 'Copy $len Events to Clipboard';
+    else if (events.length == 0) return 'Copy $len Notes to Clipboard';
+    else
+      return 'Copy $len Items to Clipboard';
+  }
+}
diff --git a/source/funkin/ui/debug/charting/commands/CutItemsCommand.hx b/source/funkin/ui/debug/charting/commands/CutItemsCommand.hx
index d0301b1ec..6cf674f80 100644
--- a/source/funkin/ui/debug/charting/commands/CutItemsCommand.hx
+++ b/source/funkin/ui/debug/charting/commands/CutItemsCommand.hx
@@ -56,6 +56,12 @@ class CutItemsCommand implements ChartEditorCommand
     state.sortChartData();
   }
 
+  public function shouldAddToHistory(state:ChartEditorState):Bool
+  {
+    // This command is undoable. Always add it to the history.
+    return (notes.length > 0 || events.length > 0);
+  }
+
   public function toString():String
   {
     var len:Int = notes.length + events.length;
diff --git a/source/funkin/ui/debug/charting/commands/DeselectAllItemsCommand.hx b/source/funkin/ui/debug/charting/commands/DeselectAllItemsCommand.hx
index cbde0ab3d..5bfef76cc 100644
--- a/source/funkin/ui/debug/charting/commands/DeselectAllItemsCommand.hx
+++ b/source/funkin/ui/debug/charting/commands/DeselectAllItemsCommand.hx
@@ -10,17 +10,16 @@ import funkin.data.song.SongData.SongEventData;
 @:access(funkin.ui.debug.charting.ChartEditorState)
 class DeselectAllItemsCommand implements ChartEditorCommand
 {
-  var previousNoteSelection:Array<SongNoteData>;
-  var previousEventSelection:Array<SongEventData>;
+  var previousNoteSelection:Array<SongNoteData> = [];
+  var previousEventSelection:Array<SongEventData> = [];
 
-  public function new(?previousNoteSelection:Array<SongNoteData>, ?previousEventSelection:Array<SongEventData>)
-  {
-    this.previousNoteSelection = previousNoteSelection == null ? [] : previousNoteSelection;
-    this.previousEventSelection = previousEventSelection == null ? [] : previousEventSelection;
-  }
+  public function new() {}
 
   public function execute(state:ChartEditorState):Void
   {
+    this.previousNoteSelection = state.currentNoteSelection;
+    this.previousEventSelection = state.currentEventSelection;
+
     state.currentNoteSelection = [];
     state.currentEventSelection = [];
 
@@ -35,6 +34,12 @@ class DeselectAllItemsCommand implements ChartEditorCommand
     state.noteDisplayDirty = true;
   }
 
+  public function shouldAddToHistory(state:ChartEditorState):Bool
+  {
+    // This command is undoable. Add to the history if we actually performed an action.
+    return (previousNoteSelection.length > 0 || previousEventSelection.length > 0);
+  }
+
   public function toString():String
   {
     return 'Deselect All Items';
diff --git a/source/funkin/ui/debug/charting/commands/DeselectItemsCommand.hx b/source/funkin/ui/debug/charting/commands/DeselectItemsCommand.hx
index d679b5363..6a115a26a 100644
--- a/source/funkin/ui/debug/charting/commands/DeselectItemsCommand.hx
+++ b/source/funkin/ui/debug/charting/commands/DeselectItemsCommand.hx
@@ -45,16 +45,27 @@ class DeselectItemsCommand implements ChartEditorCommand
     state.notePreviewDirty = true;
   }
 
+  public function shouldAddToHistory(state:ChartEditorState):Bool
+  {
+    // This command is undoable. Add to the history if we actually performed an action.
+    return (notes.length > 0 || events.length > 0);
+  }
+
   public function toString():String
   {
-    var noteCount = notes.length + events.length;
+    var isPlural = (notes.length + events.length) > 1;
+    var notesOnly = (notes.length > 0 && events.length == 0);
+    var eventsOnly = (notes.length == 0 && events.length > 0);
 
-    if (noteCount == 1)
+    if (notesOnly)
     {
-      var dir:String = notes[0].getDirectionName();
-      return 'Deselect $dir Items';
+      return 'Deselect ${notes.length} ${isPlural ? 'Notes' : 'Note'}';
+    }
+    else if (eventsOnly)
+    {
+      return 'Deselect ${events.length} ${isPlural ? 'Events' : 'Event'}';
     }
 
-    return 'Deselect ${noteCount} Items';
+    return 'Deselect ${notes.length + events.length} Items';
   }
 }
diff --git a/source/funkin/ui/debug/charting/commands/ExtendNoteLengthCommand.hx b/source/funkin/ui/debug/charting/commands/ExtendNoteLengthCommand.hx
index 47da0dde5..3ef9f22d1 100644
--- a/source/funkin/ui/debug/charting/commands/ExtendNoteLengthCommand.hx
+++ b/source/funkin/ui/debug/charting/commands/ExtendNoteLengthCommand.hx
@@ -13,17 +13,25 @@ class ExtendNoteLengthCommand implements ChartEditorCommand
   var note:SongNoteData;
   var oldLength:Float;
   var newLength:Float;
+  var unit:Unit;
 
-  public function new(note:SongNoteData, newLength:Float)
+  public function new(note:SongNoteData, newLength:Float, unit:Unit = MILLISECONDS)
   {
     this.note = note;
     this.oldLength = note.length;
     this.newLength = newLength;
+    this.unit = unit;
   }
 
   public function execute(state:ChartEditorState):Void
   {
-    note.length = newLength;
+    switch (unit)
+    {
+      case MILLISECONDS:
+        this.note.length = newLength;
+      case STEPS:
+        this.note.setStepLength(newLength);
+    }
 
     state.saveDataDirty = true;
     state.noteDisplayDirty = true;
@@ -36,7 +44,8 @@ class ExtendNoteLengthCommand implements ChartEditorCommand
   {
     state.playSound(Paths.sound('chartingSounds/undo'));
 
-    note.length = oldLength;
+    // Always use milliseconds for undoing
+    this.note.length = oldLength;
 
     state.saveDataDirty = true;
     state.noteDisplayDirty = true;
@@ -45,8 +54,31 @@ class ExtendNoteLengthCommand implements ChartEditorCommand
     state.sortChartData();
   }
 
+  public function shouldAddToHistory(state:ChartEditorState):Bool
+  {
+    // This command is undoable. Add to the history if we actually performed an action.
+    return (oldLength != newLength);
+  }
+
   public function toString():String
   {
-    return 'Extend Note Length';
+    if (oldLength == 0)
+    {
+      return 'Add Hold to Note';
+    }
+    else if (newLength == 0)
+    {
+      return 'Remove Hold from Note';
+    }
+    else
+    {
+      return 'Extend Hold Note Length';
+    }
   }
 }
+
+enum Unit
+{
+  MILLISECONDS;
+  STEPS;
+}
diff --git a/source/funkin/ui/debug/charting/commands/FlipNotesCommand.hx b/source/funkin/ui/debug/charting/commands/FlipNotesCommand.hx
index da8ec7fbc..f54ffed15 100644
--- a/source/funkin/ui/debug/charting/commands/FlipNotesCommand.hx
+++ b/source/funkin/ui/debug/charting/commands/FlipNotesCommand.hx
@@ -51,6 +51,12 @@ class FlipNotesCommand implements ChartEditorCommand
     state.sortChartData();
   }
 
+  public function shouldAddToHistory(state:ChartEditorState):Bool
+  {
+    // This command is undoable. Add to the history if we actually performed an action.
+    return (notes.length > 0);
+  }
+
   public function toString():String
   {
     var len:Int = notes.length;
diff --git a/source/funkin/ui/debug/charting/commands/InvertSelectedItemsCommand.hx b/source/funkin/ui/debug/charting/commands/InvertSelectedItemsCommand.hx
index 6e37bcc03..d9a28f463 100644
--- a/source/funkin/ui/debug/charting/commands/InvertSelectedItemsCommand.hx
+++ b/source/funkin/ui/debug/charting/commands/InvertSelectedItemsCommand.hx
@@ -12,19 +12,19 @@ import funkin.data.song.SongDataUtils;
 @:access(funkin.ui.debug.charting.ChartEditorState)
 class InvertSelectedItemsCommand implements ChartEditorCommand
 {
-  var previousNoteSelection:Array<SongNoteData>;
-  var previousEventSelection:Array<SongEventData>;
+  var previousNoteSelection:Array<SongNoteData> = [];
+  var previousEventSelection:Array<SongEventData> = [];
 
-  public function new(?previousNoteSelection:Array<SongNoteData>, ?previousEventSelection:Array<SongEventData>)
-  {
-    this.previousNoteSelection = previousNoteSelection == null ? [] : previousNoteSelection;
-    this.previousEventSelection = previousEventSelection == null ? [] : previousEventSelection;
-  }
+  public function new() {}
 
   public function execute(state:ChartEditorState):Void
   {
+    this.previousNoteSelection = state.currentNoteSelection;
+    this.previousEventSelection = state.currentEventSelection;
+
     state.currentNoteSelection = SongDataUtils.subtractNotes(state.currentSongChartNoteData, previousNoteSelection);
     state.currentEventSelection = SongDataUtils.subtractEvents(state.currentSongChartEventData, previousEventSelection);
+
     state.noteDisplayDirty = true;
   }
 
@@ -36,6 +36,12 @@ class InvertSelectedItemsCommand implements ChartEditorCommand
     state.noteDisplayDirty = true;
   }
 
+  public function shouldAddToHistory(state:ChartEditorState):Bool
+  {
+    // This command is undoable. Add to the history if we actually performed an action.
+    return (previousNoteSelection.length > 0 || previousEventSelection.length > 0);
+  }
+
   public function toString():String
   {
     return 'Invert Selected Items';
diff --git a/source/funkin/ui/debug/charting/commands/MoveEventsCommand.hx b/source/funkin/ui/debug/charting/commands/MoveEventsCommand.hx
index efe9c25d5..ed50ad33e 100644
--- a/source/funkin/ui/debug/charting/commands/MoveEventsCommand.hx
+++ b/source/funkin/ui/debug/charting/commands/MoveEventsCommand.hx
@@ -33,7 +33,7 @@ class MoveEventsCommand implements ChartEditorCommand
     {
       // Clone the notes to prevent editing from affecting the history.
       var resultEvent = event.clone();
-      resultEvent.time = (resultEvent.time + offset).clamp(0, Conductor.getStepTimeInMs(state.songLengthInSteps - (1 * state.noteSnapRatio)));
+      resultEvent.time = (resultEvent.time + offset).clamp(0, Conductor.instance.getStepTimeInMs(state.songLengthInSteps - (1 * state.noteSnapRatio)));
 
       movedEvents.push(resultEvent);
     }
@@ -65,6 +65,12 @@ class MoveEventsCommand implements ChartEditorCommand
     state.sortChartData();
   }
 
+  public function shouldAddToHistory(state:ChartEditorState):Bool
+  {
+    // This command is undoable. Add to the history if we actually performed an action.
+    return (events.length > 0);
+  }
+
   public function toString():String
   {
     var len:Int = events.length;
diff --git a/source/funkin/ui/debug/charting/commands/MoveItemsCommand.hx b/source/funkin/ui/debug/charting/commands/MoveItemsCommand.hx
index 2eedbbf03..f44cb973a 100644
--- a/source/funkin/ui/debug/charting/commands/MoveItemsCommand.hx
+++ b/source/funkin/ui/debug/charting/commands/MoveItemsCommand.hx
@@ -21,8 +21,8 @@ class MoveItemsCommand implements ChartEditorCommand
   public function new(notes:Array<SongNoteData>, events:Array<SongEventData>, offset:Float, columns:Int)
   {
     // Clone the notes to prevent editing from affecting the history.
-    this.notes = [for (note in notes) note.clone()];
-    this.events = [for (event in events) event.clone()];
+    this.notes = notes.clone();
+    this.events = events.clone();
     this.offset = offset;
     this.columns = columns;
     this.movedNotes = [];
@@ -41,7 +41,7 @@ class MoveItemsCommand implements ChartEditorCommand
     {
       // Clone the notes to prevent editing from affecting the history.
       var resultNote = note.clone();
-      resultNote.time = (resultNote.time + offset).clamp(0, Conductor.getStepTimeInMs(state.songLengthInSteps - (1 * state.noteSnapRatio)));
+      resultNote.time = (resultNote.time + offset).clamp(0, Conductor.instance.getStepTimeInMs(state.songLengthInSteps - (1 * state.noteSnapRatio)));
       resultNote.data = ChartEditorState.gridColumnToNoteData((ChartEditorState.noteDataToGridColumn(resultNote.data) + columns).clamp(0,
         ChartEditorState.STRUMLINE_SIZE * 2 - 1));
 
@@ -52,7 +52,7 @@ class MoveItemsCommand implements ChartEditorCommand
     {
       // Clone the notes to prevent editing from affecting the history.
       var resultEvent = event.clone();
-      resultEvent.time = (resultEvent.time + offset).clamp(0, Conductor.getStepTimeInMs(state.songLengthInSteps - (1 * state.noteSnapRatio)));
+      resultEvent.time = (resultEvent.time + offset).clamp(0, Conductor.instance.getStepTimeInMs(state.songLengthInSteps - (1 * state.noteSnapRatio)));
 
       movedEvents.push(resultEvent);
     }
@@ -88,6 +88,12 @@ class MoveItemsCommand implements ChartEditorCommand
     state.sortChartData();
   }
 
+  public function shouldAddToHistory(state:ChartEditorState):Bool
+  {
+    // This command is undoable. Add to the history if we actually performed an action.
+    return (notes.length > 0 || events.length > 0);
+  }
+
   public function toString():String
   {
     var len:Int = notes.length + events.length;
diff --git a/source/funkin/ui/debug/charting/commands/MoveNotesCommand.hx b/source/funkin/ui/debug/charting/commands/MoveNotesCommand.hx
index 8bce747a1..51aeb5bbc 100644
--- a/source/funkin/ui/debug/charting/commands/MoveNotesCommand.hx
+++ b/source/funkin/ui/debug/charting/commands/MoveNotesCommand.hx
@@ -34,7 +34,7 @@ class MoveNotesCommand implements ChartEditorCommand
     {
       // Clone the notes to prevent editing from affecting the history.
       var resultNote = note.clone();
-      resultNote.time = (resultNote.time + offset).clamp(0, Conductor.getStepTimeInMs(state.songLengthInSteps - (1 * state.noteSnapRatio)));
+      resultNote.time = (resultNote.time + offset).clamp(0, Conductor.instance.getStepTimeInMs(state.songLengthInSteps - (1 * state.noteSnapRatio)));
       resultNote.data = ChartEditorState.gridColumnToNoteData((ChartEditorState.noteDataToGridColumn(resultNote.data) + columns).clamp(0,
         ChartEditorState.STRUMLINE_SIZE * 2 - 1));
 
@@ -67,6 +67,12 @@ class MoveNotesCommand implements ChartEditorCommand
     state.sortChartData();
   }
 
+  public function shouldAddToHistory(state:ChartEditorState):Bool
+  {
+    // This command is undoable. Add to the history if we actually performed an action.
+    return (notes.length > 0);
+  }
+
   public function toString():String
   {
     var len:Int = notes.length;
diff --git a/source/funkin/ui/debug/charting/commands/PasteItemsCommand.hx b/source/funkin/ui/debug/charting/commands/PasteItemsCommand.hx
index 75382da41..257db94b4 100644
--- a/source/funkin/ui/debug/charting/commands/PasteItemsCommand.hx
+++ b/source/funkin/ui/debug/charting/commands/PasteItemsCommand.hx
@@ -32,9 +32,9 @@ class PasteItemsCommand implements ChartEditorCommand
       return;
     }
 
-    var stepEndOfSong:Float = Conductor.getTimeInSteps(state.songLengthInMs);
+    var stepEndOfSong:Float = Conductor.instance.getTimeInSteps(state.songLengthInMs);
     var stepCutoff:Float = stepEndOfSong - 1.0;
-    var msCutoff:Float = Conductor.getStepTimeInMs(stepCutoff);
+    var msCutoff:Float = Conductor.instance.getStepTimeInMs(stepCutoff);
 
     addedNotes = SongDataUtils.offsetSongNoteData(currentClipboard.notes, Std.int(targetTimestamp));
     addedNotes = SongDataUtils.clampSongNoteData(addedNotes, 0.0, msCutoff);
@@ -71,6 +71,12 @@ class PasteItemsCommand implements ChartEditorCommand
     state.sortChartData();
   }
 
+  public function shouldAddToHistory(state:ChartEditorState):Bool
+  {
+    // This command is undoable. Add to the history if we actually performed an action.
+    return (addedNotes.length > 0 || addedEvents.length > 0);
+  }
+
   public function toString():String
   {
     var currentClipboard:SongClipboardItems = SongDataUtils.readItemsFromClipboard();
diff --git a/source/funkin/ui/debug/charting/commands/RemoveEventsCommand.hx b/source/funkin/ui/debug/charting/commands/RemoveEventsCommand.hx
index 7e620c210..b4d913607 100644
--- a/source/funkin/ui/debug/charting/commands/RemoveEventsCommand.hx
+++ b/source/funkin/ui/debug/charting/commands/RemoveEventsCommand.hx
@@ -48,6 +48,12 @@ class RemoveEventsCommand implements ChartEditorCommand
     state.sortChartData();
   }
 
+  public function shouldAddToHistory(state:ChartEditorState):Bool
+  {
+    // This command is undoable. Add to the history if we actually performed an action.
+    return (events.length > 0);
+  }
+
   public function toString():String
   {
     if (events.length == 1 && events[0] != null)
diff --git a/source/funkin/ui/debug/charting/commands/RemoveItemsCommand.hx b/source/funkin/ui/debug/charting/commands/RemoveItemsCommand.hx
index 77184209e..69317aff4 100644
--- a/source/funkin/ui/debug/charting/commands/RemoveItemsCommand.hx
+++ b/source/funkin/ui/debug/charting/commands/RemoveItemsCommand.hx
@@ -62,6 +62,12 @@ class RemoveItemsCommand implements ChartEditorCommand
     state.sortChartData();
   }
 
+  public function shouldAddToHistory(state:ChartEditorState):Bool
+  {
+    // This command is undoable. Add to the history if we actually performed an action.
+    return (notes.length > 0 || events.length > 0);
+  }
+
   public function toString():String
   {
     return 'Remove ${notes.length + events.length} Items';
diff --git a/source/funkin/ui/debug/charting/commands/RemoveNotesCommand.hx b/source/funkin/ui/debug/charting/commands/RemoveNotesCommand.hx
index e189be83e..4811f831d 100644
--- a/source/funkin/ui/debug/charting/commands/RemoveNotesCommand.hx
+++ b/source/funkin/ui/debug/charting/commands/RemoveNotesCommand.hx
@@ -50,6 +50,12 @@ class RemoveNotesCommand implements ChartEditorCommand
     state.sortChartData();
   }
 
+  public function shouldAddToHistory(state:ChartEditorState):Bool
+  {
+    // This command is undoable. Add to the history if we actually performed an action.
+    return (notes.length > 0);
+  }
+
   public function toString():String
   {
     if (notes.length == 1 && notes[0] != null)
diff --git a/source/funkin/ui/debug/charting/commands/SelectAllItemsCommand.hx b/source/funkin/ui/debug/charting/commands/SelectAllItemsCommand.hx
index e1a4dceaa..f550e044b 100644
--- a/source/funkin/ui/debug/charting/commands/SelectAllItemsCommand.hx
+++ b/source/funkin/ui/debug/charting/commands/SelectAllItemsCommand.hx
@@ -10,19 +10,25 @@ import funkin.data.song.SongData.SongEventData;
 @:access(funkin.ui.debug.charting.ChartEditorState)
 class SelectAllItemsCommand implements ChartEditorCommand
 {
-  var previousNoteSelection:Array<SongNoteData>;
-  var previousEventSelection:Array<SongEventData>;
+  var shouldSelectNotes:Bool;
+  var shouldSelectEvents:Bool;
 
-  public function new(?previousNoteSelection:Array<SongNoteData>, ?previousEventSelection:Array<SongEventData>)
+  var previousNoteSelection:Array<SongNoteData> = [];
+  var previousEventSelection:Array<SongEventData> = [];
+
+  public function new(shouldSelectNotes:Bool, shouldSelectEvents:Bool)
   {
-    this.previousNoteSelection = previousNoteSelection == null ? [] : previousNoteSelection;
-    this.previousEventSelection = previousEventSelection == null ? [] : previousEventSelection;
+    this.shouldSelectNotes = shouldSelectNotes;
+    this.shouldSelectEvents = shouldSelectEvents;
   }
 
   public function execute(state:ChartEditorState):Void
   {
-    state.currentNoteSelection = state.currentSongChartNoteData;
-    state.currentEventSelection = state.currentSongChartEventData;
+    this.previousNoteSelection = state.currentNoteSelection;
+    this.previousEventSelection = state.currentEventSelection;
+
+    state.currentNoteSelection = shouldSelectNotes ? state.currentSongChartNoteData : [];
+    state.currentEventSelection = shouldSelectEvents ? state.currentSongChartEventData : [];
 
     state.noteDisplayDirty = true;
   }
@@ -35,8 +41,29 @@ class SelectAllItemsCommand implements ChartEditorCommand
     state.noteDisplayDirty = true;
   }
 
+  public function shouldAddToHistory(state:ChartEditorState):Bool
+  {
+    // This command is undoable. Add to the history if we actually performed an action.
+    return (state.currentNoteSelection.length > 0 || state.currentEventSelection.length > 0);
+  }
+
   public function toString():String
   {
-    return 'Select All Items';
+    if (shouldSelectNotes && !shouldSelectEvents)
+    {
+      return 'Select All Notes';
+    }
+    else if (shouldSelectEvents && !shouldSelectNotes)
+    {
+      return 'Select All Events';
+    }
+    else if (shouldSelectNotes && shouldSelectEvents)
+    {
+      return 'Select All Notes and Events';
+    }
+    else
+    {
+      return 'Select Nothing (Huh?)';
+    }
   }
 }
diff --git a/source/funkin/ui/debug/charting/commands/SelectItemsCommand.hx b/source/funkin/ui/debug/charting/commands/SelectItemsCommand.hx
index abe8b9e35..d6c5beeac 100644
--- a/source/funkin/ui/debug/charting/commands/SelectItemsCommand.hx
+++ b/source/funkin/ui/debug/charting/commands/SelectItemsCommand.hx
@@ -15,10 +15,10 @@ class SelectItemsCommand implements ChartEditorCommand
   var notes:Array<SongNoteData>;
   var events:Array<SongEventData>;
 
-  public function new(notes:Array<SongNoteData>, events:Array<SongEventData>)
+  public function new(?notes:Array<SongNoteData>, ?events:Array<SongEventData>)
   {
-    this.notes = notes;
-    this.events = events;
+    this.notes = notes ?? [];
+    this.events = events ?? [];
   }
 
   public function execute(state:ChartEditorState):Void
@@ -33,6 +33,32 @@ class SelectItemsCommand implements ChartEditorCommand
       state.currentEventSelection.push(event);
     }
 
+    // If we just selected one or more events (and no notes), then we should make the event data toolbox display the event data for the selected event.
+    if (this.notes.length == 0 && this.events.length >= 1)
+    {
+      var eventSelected = this.events[0];
+
+      state.eventKindToPlace = eventSelected.event;
+
+      // This code is here to parse event data that's not built as a struct for some reason.
+      // TODO: Clean this up or get rid of it.
+      var eventSchema = eventSelected.getSchema();
+      var defaultKey = null;
+      if (eventSchema == null)
+      {
+        trace('[WARNING] Event schema not found for event ${eventSelected.event}.');
+      }
+      else
+      {
+        defaultKey = eventSchema.getFirstField()?.name;
+      }
+      var eventData = eventSelected.valueAsStruct(defaultKey);
+
+      state.eventDataToPlace = eventData;
+
+      state.refreshToolbox(ChartEditorState.CHART_EDITOR_TOOLBOX_EVENT_DATA_LAYOUT);
+    }
+
     state.noteDisplayDirty = true;
     state.notePreviewDirty = true;
   }
@@ -46,6 +72,12 @@ class SelectItemsCommand implements ChartEditorCommand
     state.notePreviewDirty = true;
   }
 
+  public function shouldAddToHistory(state:ChartEditorState):Bool
+  {
+    // This command is undoable. Add to the history if we actually performed an action.
+    return (notes.length > 0 || events.length > 0);
+  }
+
   public function toString():String
   {
     var len:Int = notes.length + events.length;
diff --git a/source/funkin/ui/debug/charting/commands/SetAudioOffsetCommand.hx b/source/funkin/ui/debug/charting/commands/SetAudioOffsetCommand.hx
new file mode 100644
index 000000000..ca1fda6b9
--- /dev/null
+++ b/source/funkin/ui/debug/charting/commands/SetAudioOffsetCommand.hx
@@ -0,0 +1,113 @@
+package funkin.ui.debug.charting.commands;
+
+import funkin.data.song.SongData.SongNoteData;
+import funkin.data.song.SongData.SongEventData;
+import funkin.data.song.SongDataUtils;
+import flixel.tweens.FlxEase;
+import flixel.tweens.FlxTween;
+
+/**
+ * Command that copies a given set of notes and song events to the clipboard,
+ * without deleting them from the chart editor.
+ */
+@:nullSafety
+@:access(funkin.ui.debug.charting.ChartEditorState)
+class SetAudioOffsetCommand implements ChartEditorCommand
+{
+  var type:AudioOffsetType;
+  var oldOffset:Float = 0;
+  var newOffset:Float;
+  var refreshOffsetsToolbox:Bool;
+
+  public function new(type:AudioOffsetType, newOffset:Float, refreshOffsetsToolbox:Bool = true)
+  {
+    this.type = type;
+    this.newOffset = newOffset;
+    this.refreshOffsetsToolbox = refreshOffsetsToolbox;
+  }
+
+  public function execute(state:ChartEditorState):Void
+  {
+    switch (type)
+    {
+      case INSTRUMENTAL:
+        oldOffset = state.currentInstrumentalOffset;
+        state.currentInstrumentalOffset = newOffset;
+
+        // Update rendering.
+        Conductor.instance.instrumentalOffset = state.currentInstrumentalOffset;
+        state.songLengthInMs = (state.audioInstTrack?.length ?? 1000.0) + Conductor.instance.instrumentalOffset;
+      case PLAYER:
+        oldOffset = state.currentVocalOffsetPlayer;
+        state.currentVocalOffsetPlayer = newOffset;
+
+        // Update rendering.
+        state.audioVocalTrackGroup.playerVoicesOffset = state.currentVocalOffsetPlayer;
+      case OPPONENT:
+        oldOffset = state.currentVocalOffsetOpponent;
+        state.currentVocalOffsetOpponent = newOffset;
+
+        // Update rendering.
+        state.audioVocalTrackGroup.opponentVoicesOffset = state.currentVocalOffsetOpponent;
+    }
+
+    // Update the offsets toolbox.
+    if (refreshOffsetsToolbox)
+    {
+      state.refreshToolbox(ChartEditorState.CHART_EDITOR_TOOLBOX_OFFSETS_LAYOUT);
+      state.refreshToolbox(ChartEditorState.CHART_EDITOR_TOOLBOX_FREEPLAY_LAYOUT);
+    }
+  }
+
+  public function undo(state:ChartEditorState):Void
+  {
+    switch (type)
+    {
+      case INSTRUMENTAL:
+        state.currentInstrumentalOffset = oldOffset;
+
+        // Update rendering.
+        Conductor.instance.instrumentalOffset = state.currentInstrumentalOffset;
+        state.songLengthInMs = (state.audioInstTrack?.length ?? 1000.0) + Conductor.instance.instrumentalOffset;
+      case PLAYER:
+        state.currentVocalOffsetPlayer = oldOffset;
+
+        // Update rendering.
+        state.audioVocalTrackGroup.playerVoicesOffset = state.currentVocalOffsetPlayer;
+      case OPPONENT:
+        state.currentVocalOffsetOpponent = oldOffset;
+
+        // Update rendering.
+        state.audioVocalTrackGroup.opponentVoicesOffset = state.currentVocalOffsetOpponent;
+    }
+
+    // Update the offsets toolbox.
+    state.refreshToolbox(ChartEditorState.CHART_EDITOR_TOOLBOX_OFFSETS_LAYOUT);
+  }
+
+  public function shouldAddToHistory(state:ChartEditorState):Bool
+  {
+    // This command is undoable. Add to the history if we actually performed an action.
+    return (newOffset != oldOffset);
+  }
+
+  public function toString():String
+  {
+    switch (type)
+    {
+      case INSTRUMENTAL:
+        return 'Set Inst. Audio Offset to $newOffset';
+      case PLAYER:
+        return 'Set Player Audio Offset to $newOffset';
+      case OPPONENT:
+        return 'Set Opponent Audio Offset to $newOffset';
+    }
+  }
+}
+
+enum AudioOffsetType
+{
+  INSTRUMENTAL;
+  PLAYER;
+  OPPONENT;
+}
diff --git a/source/funkin/ui/debug/charting/commands/SetFreeplayPreviewCommand.hx b/source/funkin/ui/debug/charting/commands/SetFreeplayPreviewCommand.hx
new file mode 100644
index 000000000..232768904
--- /dev/null
+++ b/source/funkin/ui/debug/charting/commands/SetFreeplayPreviewCommand.hx
@@ -0,0 +1,62 @@
+package funkin.ui.debug.charting.commands;
+
+import funkin.data.song.SongData.SongNoteData;
+import funkin.data.song.SongDataUtils;
+
+/**
+ * Command that sets the start time or end time of the Freeplay preview.
+ */
+@:nullSafety
+@:access(funkin.ui.debug.charting.ChartEditorState)
+class SetFreeplayPreviewCommand implements ChartEditorCommand
+{
+  var previousStartTime:Int = 0;
+  var previousEndTime:Int = 0;
+  var newStartTime:Null<Int> = null;
+  var newEndTime:Null<Int> = null;
+
+  public function new(newStartTime:Null<Int>, newEndTime:Null<Int>)
+  {
+    this.newStartTime = newStartTime;
+    this.newEndTime = newEndTime;
+  }
+
+  public function execute(state:ChartEditorState):Void
+  {
+    this.previousStartTime = state.currentSongFreeplayPreviewStart;
+    this.previousEndTime = state.currentSongFreeplayPreviewEnd;
+
+    if (newStartTime != null) state.currentSongFreeplayPreviewStart = newStartTime;
+    if (newEndTime != null) state.currentSongFreeplayPreviewEnd = newEndTime;
+  }
+
+  public function undo(state:ChartEditorState):Void
+  {
+    state.currentSongFreeplayPreviewStart = previousStartTime;
+    state.currentSongFreeplayPreviewEnd = previousEndTime;
+  }
+
+  public function shouldAddToHistory(state:ChartEditorState):Bool
+  {
+    return (newStartTime != null && newStartTime != previousStartTime) || (newEndTime != null && newEndTime != previousEndTime);
+  }
+
+  public function toString():String
+  {
+    var setStart = newStartTime != null && newStartTime != previousStartTime;
+    var setEnd = newEndTime != null && newEndTime != previousEndTime;
+
+    if (setStart && !setEnd)
+    {
+      return "Set Freeplay Preview Start Time";
+    }
+    else if (setEnd && !setStart)
+    {
+      return "Set Freeplay Preview End Time";
+    }
+    else
+    {
+      return "Set Freeplay Preview Start and End Times";
+    }
+  }
+}
diff --git a/source/funkin/ui/debug/charting/commands/SetItemSelectionCommand.hx b/source/funkin/ui/debug/charting/commands/SetItemSelectionCommand.hx
index a06aefabc..35a00e562 100644
--- a/source/funkin/ui/debug/charting/commands/SetItemSelectionCommand.hx
+++ b/source/funkin/ui/debug/charting/commands/SetItemSelectionCommand.hx
@@ -13,23 +13,49 @@ class SetItemSelectionCommand implements ChartEditorCommand
 {
   var notes:Array<SongNoteData>;
   var events:Array<SongEventData>;
-  var previousNoteSelection:Array<SongNoteData>;
-  var previousEventSelection:Array<SongEventData>;
+  var previousNoteSelection:Array<SongNoteData> = [];
+  var previousEventSelection:Array<SongEventData> = [];
 
-  public function new(notes:Array<SongNoteData>, events:Array<SongEventData>, previousNoteSelection:Array<SongNoteData>,
-      previousEventSelection:Array<SongEventData>)
+  public function new(notes:Array<SongNoteData>, events:Array<SongEventData>)
   {
     this.notes = notes;
     this.events = events;
-    this.previousNoteSelection = previousNoteSelection == null ? [] : previousNoteSelection;
-    this.previousEventSelection = previousEventSelection == null ? [] : previousEventSelection;
   }
 
   public function execute(state:ChartEditorState):Void
   {
+    this.previousNoteSelection = state.currentNoteSelection;
+    this.previousEventSelection = state.currentEventSelection;
+
     state.currentNoteSelection = notes;
     state.currentEventSelection = events;
 
+    // If we just selected one or more events (and no notes), then we should make the event data toolbox display the event data for the selected event.
+    if (this.notes.length == 0 && this.events.length >= 1)
+    {
+      var eventSelected = this.events[0];
+
+      state.eventKindToPlace = eventSelected.event;
+
+      // This code is here to parse event data that's not built as a struct for some reason.
+      // TODO: Clean this up or get rid of it.
+      var eventSchema = eventSelected.getSchema();
+      var defaultKey = null;
+      if (eventSchema == null)
+      {
+        trace('[WARNING] Event schema not found for event ${eventSelected.event}.');
+      }
+      else
+      {
+        defaultKey = eventSchema.getFirstField()?.name;
+      }
+      var eventData = eventSelected.valueAsStruct(defaultKey);
+
+      state.eventDataToPlace = eventData;
+
+      state.refreshToolbox(ChartEditorState.CHART_EDITOR_TOOLBOX_EVENT_DATA_LAYOUT);
+    }
+
     state.noteDisplayDirty = true;
   }
 
@@ -41,8 +67,14 @@ class SetItemSelectionCommand implements ChartEditorCommand
     state.noteDisplayDirty = true;
   }
 
+  public function shouldAddToHistory(state:ChartEditorState):Bool
+  {
+    // Add to the history if we actually performed an action.
+    return (state.currentNoteSelection != previousNoteSelection && state.currentEventSelection != previousEventSelection);
+  }
+
   public function toString():String
   {
-    return 'Select ${notes.length} Items';
+    return 'Select ${notes.length + events.length} Items';
   }
 }
diff --git a/source/funkin/ui/debug/charting/commands/SwitchDifficultyCommand.hx b/source/funkin/ui/debug/charting/commands/SwitchDifficultyCommand.hx
index 75e7e5afe..30c2edb61 100644
--- a/source/funkin/ui/debug/charting/commands/SwitchDifficultyCommand.hx
+++ b/source/funkin/ui/debug/charting/commands/SwitchDifficultyCommand.hx
@@ -38,6 +38,12 @@ class SwitchDifficultyCommand implements ChartEditorCommand
     state.notePreviewDirty = true;
   }
 
+  public function shouldAddToHistory(state:ChartEditorState):Bool
+  {
+    // Add to the history if we actually performed an action.
+    return (prevVariation != newVariation || prevDifficulty != newDifficulty);
+  }
+
   public function toString():String
   {
     return 'Switch Difficulty';
diff --git a/source/funkin/ui/debug/charting/components/ChartEditorEventSprite.hx b/source/funkin/ui/debug/charting/components/ChartEditorEventSprite.hx
index 4c9d91407..e3dae37cf 100644
--- a/source/funkin/ui/debug/charting/components/ChartEditorEventSprite.hx
+++ b/source/funkin/ui/debug/charting/components/ChartEditorEventSprite.hx
@@ -1,6 +1,6 @@
 package funkin.ui.debug.charting.components;
 
-import funkin.data.event.SongEventData.SongEventParser;
+import funkin.data.event.SongEventRegistry;
 import flixel.graphics.frames.FlxAtlasFrames;
 import openfl.display.BitmapData;
 import openfl.utils.Assets;
@@ -11,6 +11,9 @@ import flixel.graphics.frames.FlxFramesCollection;
 import flixel.graphics.frames.FlxTileFrames;
 import flixel.math.FlxPoint;
 import funkin.data.song.SongData.SongEventData;
+import haxe.ui.tooltips.ToolTipRegionOptions;
+import funkin.util.HaxeUIUtil;
+import haxe.ui.tooltips.ToolTipManager;
 
 /**
  * A sprite that can be used to display a song event in a chart.
@@ -36,6 +39,13 @@ class ChartEditorEventSprite extends FlxSprite
 
   public var overrideStepTime(default, set):Null<Float> = null;
 
+  public var tooltip:ToolTipRegionOptions;
+
+  /**
+   * Whether this sprite is a "ghost" sprite used when hovering to place a new event.
+   */
+  public var isGhost:Bool = false;
+
   function set_overrideStepTime(value:Null<Float>):Null<Float>
   {
     if (overrideStepTime == value) return overrideStepTime;
@@ -45,12 +55,14 @@ class ChartEditorEventSprite extends FlxSprite
     return overrideStepTime;
   }
 
-  public function new(parent:ChartEditorState)
+  public function new(parent:ChartEditorState, isGhost:Bool = false)
   {
     super();
 
     this.parentState = parent;
+    this.isGhost = isGhost;
 
+    this.tooltip = HaxeUIUtil.buildTooltip('N/A');
     this.frames = buildFrames();
 
     buildAnimations();
@@ -79,7 +91,7 @@ class ChartEditorEventSprite extends FlxSprite
     }
 
     // Push all the other events as frames.
-    for (eventName in SongEventParser.listEventIds())
+    for (eventName in SongEventRegistry.listEventIds())
     {
       var exists:Bool = Assets.exists(Paths.image('ui/chart-editor/events/$eventName'));
       if (!exists) continue; // No graphic for this event.
@@ -105,7 +117,7 @@ class ChartEditorEventSprite extends FlxSprite
 
   function buildAnimations():Void
   {
-    var eventNames:Array<String> = [DEFAULT_EVENT].concat(SongEventParser.listEventIds());
+    var eventNames:Array<String> = [DEFAULT_EVENT].concat(SongEventRegistry.listEventIds());
     for (eventName in eventNames)
     {
       this.animation.addByPrefix(eventName, '${eventName}0', 24, false);
@@ -119,8 +131,10 @@ class ChartEditorEventSprite extends FlxSprite
     return DEFAULT_EVENT;
   }
 
-  public function playAnimation(name:String):Void
+  public function playAnimation(?name:String):Void
   {
+    if (name == null) name = eventData?.event ?? DEFAULT_EVENT;
+
     var correctedName = correctAnimationName(name);
     this.animation.play(correctedName);
     refresh();
@@ -140,17 +154,18 @@ class ChartEditorEventSprite extends FlxSprite
       // Disown parent. MAKE SURE TO REVIVE BEFORE REUSING
       this.kill();
       this.visible = false;
+      updateTooltipPosition();
       return null;
     }
     else
     {
       this.visible = true;
-      // Only play the animation if the event type has changed.
-      // if (this.eventData == null || this.eventData.event != value.event)
       playAnimation(value.event);
       this.eventData = value;
       // Update the position to match the note data.
       updateEventPosition();
+      // Update the tooltip text.
+      this.tooltip.tipData = {text: this.eventData.buildTooltip()};
       return this.eventData;
     }
   }
@@ -169,6 +184,31 @@ class ChartEditorEventSprite extends FlxSprite
       this.x += origin.x;
       this.y += origin.y;
     }
+
+    this.updateTooltipPosition();
+  }
+
+  public function updateTooltipPosition():Void
+  {
+    // No tooltip for ghost sprites.
+    if (this.isGhost) return;
+
+    if (this.eventData == null)
+    {
+      // Disable the tooltip.
+      ToolTipManager.instance.unregisterTooltipRegion(this.tooltip);
+    }
+    else
+    {
+      // Update the position.
+      this.tooltip.left = this.x;
+      this.tooltip.top = this.y;
+      this.tooltip.width = this.width;
+      this.tooltip.height = this.height;
+
+      // Enable the tooltip.
+      ToolTipManager.instance.registerTooltipRegion(this.tooltip);
+    }
   }
 
   /**
diff --git a/source/funkin/ui/debug/charting/components/ChartEditorHoldNoteSprite.hx b/source/funkin/ui/debug/charting/components/ChartEditorHoldNoteSprite.hx
index e5971db08..c7f7747c0 100644
--- a/source/funkin/ui/debug/charting/components/ChartEditorHoldNoteSprite.hx
+++ b/source/funkin/ui/debug/charting/components/ChartEditorHoldNoteSprite.hx
@@ -39,6 +39,17 @@ class ChartEditorHoldNoteSprite extends SustainTrail
     setup();
   }
 
+  public override function updateHitbox():Void
+  {
+    // Expand the clickable hitbox to the full column width, then nudge to the left to re-center it.
+    width = ChartEditorState.GRID_SIZE;
+    height = graphicHeight;
+
+    var xOffset = (ChartEditorState.GRID_SIZE - graphicWidth) / 2;
+    offset.set(-xOffset, 0);
+    origin.set(width * 0.5, height * 0.5);
+  }
+
   /**
    * Set the height directly, to a value in pixels.
    * @param h The desired height in pixels.
@@ -52,6 +63,25 @@ class ChartEditorHoldNoteSprite extends SustainTrail
     fullSustainLength = sustainLength;
   }
 
+  #if FLX_DEBUG
+  /**
+   * Call this to override how debug bounding boxes are drawn for this sprite.
+   */
+  public override function drawDebugOnCamera(camera:flixel.FlxCamera):Void
+  {
+    if (!camera.visible || !camera.exists || !isOnScreen(camera)) return;
+
+    var rect = getBoundingBox(camera);
+    trace('hold note bounding box: ' + rect.x + ', ' + rect.y + ', ' + rect.width + ', ' + rect.height);
+
+    var gfx = beginDrawDebug(camera);
+    debugBoundingBoxColor = 0xffFF66FF;
+    gfx.lineStyle(2, color, 0.5); // thickness, color, alpha
+    gfx.drawRect(rect.x, rect.y, rect.width, rect.height);
+    endDrawDebug(camera);
+  }
+  #end
+
   function setup():Void
   {
     strumTime = 999999999;
@@ -60,7 +90,9 @@ class ChartEditorHoldNoteSprite extends SustainTrail
     active = true;
     visible = true;
     alpha = 1.0;
-    width = graphic.width / 8 * zoom; // amount of notes * 2
+    graphicWidth = graphic.width / 8 * zoom; // amount of notes * 2
+
+    updateHitbox();
   }
 
   public override function revive():Void
@@ -154,7 +186,7 @@ class ChartEditorHoldNoteSprite extends SustainTrail
     }
 
     this.x += ChartEditorState.GRID_SIZE / 2;
-    this.x -= this.width / 2;
+    this.x -= this.graphicWidth / 2;
 
     this.y += ChartEditorState.GRID_SIZE / 2;
 
@@ -163,5 +195,8 @@ class ChartEditorHoldNoteSprite extends SustainTrail
       this.x += origin.x;
       this.y += origin.y;
     }
+
+    // Account for expanded clickable hitbox.
+    this.x += this.offset.x;
   }
 }
diff --git a/source/funkin/ui/debug/charting/components/ChartEditorMeasureTicks.hx b/source/funkin/ui/debug/charting/components/ChartEditorMeasureTicks.hx
new file mode 100644
index 000000000..1a76d1e22
--- /dev/null
+++ b/source/funkin/ui/debug/charting/components/ChartEditorMeasureTicks.hx
@@ -0,0 +1,71 @@
+package funkin.ui.debug.charting.components;
+
+import flixel.FlxSprite;
+import flixel.addons.display.FlxTiledSprite;
+import flixel.group.FlxSpriteGroup.FlxTypedSpriteGroup;
+import flixel.text.FlxText;
+import flixel.util.FlxColor;
+
+@:access(funkin.ui.debug.charting.ChartEditorState)
+class ChartEditorMeasureTicks extends FlxTypedSpriteGroup<FlxSprite>
+{
+  var chartEditorState:ChartEditorState;
+
+  var tickTiledSprite:FlxTiledSprite;
+  var measureNumber:FlxText;
+
+  override function set_y(value:Float):Float
+  {
+    var result = super.set_y(value);
+
+    updateMeasureNumber();
+
+    return result;
+  }
+
+  public function new(chartEditorState:ChartEditorState)
+  {
+    super();
+
+    this.chartEditorState = chartEditorState;
+
+    tickTiledSprite = new FlxTiledSprite(chartEditorState.measureTickBitmap, chartEditorState.measureTickBitmap.width, 1000, false, true);
+    add(tickTiledSprite);
+
+    measureNumber = new FlxText(0, 0, ChartEditorState.GRID_SIZE, "1");
+    measureNumber.setFormat(Paths.font('vcr.ttf'), 20, FlxColor.WHITE);
+    measureNumber.borderStyle = FlxTextBorderStyle.OUTLINE;
+    measureNumber.borderColor = FlxColor.BLACK;
+    add(measureNumber);
+  }
+
+  public function reloadTickBitmap():Void
+  {
+    tickTiledSprite.loadGraphic(chartEditorState.measureTickBitmap);
+  }
+
+  /**
+   * At time of writing, we only have to manipulate one measure number because we can only see one measure at a time.
+   */
+  function updateMeasureNumber()
+  {
+    if (measureNumber == null) return;
+
+    var viewTopPosition = 0 - this.y;
+    var viewHeight = FlxG.height - ChartEditorState.MENU_BAR_HEIGHT - ChartEditorState.PLAYBAR_HEIGHT;
+    var viewBottomPosition = viewTopPosition + viewHeight;
+
+    var measureNumberInViewport = Math.floor(viewTopPosition / ChartEditorState.GRID_SIZE / Conductor.instance.stepsPerMeasure) + 1;
+    var measureNumberPosition = measureNumberInViewport * ChartEditorState.GRID_SIZE * Conductor.instance.stepsPerMeasure;
+
+    measureNumber.text = '${measureNumberInViewport + 1}';
+    measureNumber.y = measureNumberPosition + this.y;
+
+    // trace(measureNumber.text + ' at ' + measureNumber.y);
+  }
+
+  public function setHeight(songLengthInPixels:Float):Void
+  {
+    tickTiledSprite.height = songLengthInPixels;
+  }
+}
diff --git a/source/funkin/ui/debug/charting/components/ChartEditorNotePreview.hx b/source/funkin/ui/debug/charting/components/ChartEditorNotePreview.hx
index 09c99531d..8d9ec6743 100644
--- a/source/funkin/ui/debug/charting/components/ChartEditorNotePreview.hx
+++ b/source/funkin/ui/debug/charting/components/ChartEditorNotePreview.hx
@@ -70,9 +70,9 @@ class ChartEditorNotePreview extends FlxSprite
    * @param event The data for the event.
    * @param songLengthInMs The total length of the song in milliseconds.
    */
-  public function addEvent(event:SongEventData, songLengthInMs:Int):Void
+  public function addEvent(event:SongEventData, songLengthInMs:Int, ?isSelection:Bool = false):Void
   {
-    drawNote(-1, false, Std.int(event.time), songLengthInMs);
+    drawNote(-1, false, Std.int(event.time), songLengthInMs, isSelection);
   }
 
   /**
@@ -80,11 +80,24 @@ class ChartEditorNotePreview extends FlxSprite
    * @param notes The data for the notes.
    * @param songLengthInMs The total length of the song in milliseconds.
    */
-  public function addNotes(notes:Array<SongNoteData>, songLengthInMs:Int, ?isSelection:Bool = false):Void
+  public function addNotes(notes:Array<SongNoteData>, songLengthInMs:Int):Void
   {
     for (note in notes)
     {
-      addNote(note, songLengthInMs, isSelection);
+      addNote(note, songLengthInMs, false);
+    }
+  }
+
+  /**
+   * Add an array of selected notes to the preview.
+   * @param notes The data for the notes.
+   * @param songLengthInMs The total length of the song in milliseconds.
+   */
+  public function addSelectedNotes(notes:Array<SongNoteData>, songLengthInMs:Int):Void
+  {
+    for (note in notes)
+    {
+      addNote(note, songLengthInMs, true);
     }
   }
 
@@ -101,6 +114,19 @@ class ChartEditorNotePreview extends FlxSprite
     }
   }
 
+  /**
+   * Add an array of selected events to the preview.
+   * @param events The data for the events.
+   * @param songLengthInMs The total length of the song in milliseconds.
+   */
+  public function addSelectedEvents(events:Array<SongEventData>, songLengthInMs:Int):Void
+  {
+    for (event in events)
+    {
+      addEvent(event, songLengthInMs, true);
+    }
+  }
+
   /**
    * Draws a note on the preview.
    * @param dir Note data.
diff --git a/source/funkin/ui/debug/charting/components/ChartEditorSelectionSquareSprite.hx b/source/funkin/ui/debug/charting/components/ChartEditorSelectionSquareSprite.hx
index 8f7c4aaec..14266b71a 100644
--- a/source/funkin/ui/debug/charting/components/ChartEditorSelectionSquareSprite.hx
+++ b/source/funkin/ui/debug/charting/components/ChartEditorSelectionSquareSprite.hx
@@ -1,20 +1,33 @@
 package funkin.ui.debug.charting.components;
 
+import flixel.addons.display.FlxSliceSprite;
 import flixel.FlxSprite;
-import funkin.data.song.SongData.SongNoteData;
+import flixel.math.FlxRect;
 import funkin.data.song.SongData.SongEventData;
+import funkin.data.song.SongData.SongNoteData;
+import funkin.ui.debug.charting.handlers.ChartEditorThemeHandler;
 
 /**
  * A sprite that can be used to display a square over a selected note or event in the chart.
  * Designed to be used and reused efficiently. Has no gameplay functionality.
  */
-class ChartEditorSelectionSquareSprite extends FlxSprite
+@:nullSafety
+@:access(funkin.ui.debug.charting.ChartEditorState)
+class ChartEditorSelectionSquareSprite extends FlxSliceSprite
 {
   public var noteData:Null<SongNoteData>;
   public var eventData:Null<SongEventData>;
 
-  public function new()
+  public function new(chartEditorState:ChartEditorState)
   {
-    super();
+    super(chartEditorState.selectionSquareBitmap,
+      new FlxRect(ChartEditorThemeHandler.SELECTION_SQUARE_BORDER_WIDTH
+        + 4, ChartEditorThemeHandler.SELECTION_SQUARE_BORDER_WIDTH
+        + 4,
+        ChartEditorState.GRID_SIZE
+        - (2 * ChartEditorThemeHandler.SELECTION_SQUARE_BORDER_WIDTH + 8),
+        ChartEditorState.GRID_SIZE
+        - (2 * ChartEditorThemeHandler.SELECTION_SQUARE_BORDER_WIDTH + 8)),
+      32, 32);
   }
 }
diff --git a/source/funkin/ui/debug/charting/contextmenus/ChartEditorBaseContextMenu.hx b/source/funkin/ui/debug/charting/contextmenus/ChartEditorBaseContextMenu.hx
new file mode 100644
index 000000000..f25f3ebb3
--- /dev/null
+++ b/source/funkin/ui/debug/charting/contextmenus/ChartEditorBaseContextMenu.hx
@@ -0,0 +1,19 @@
+package funkin.ui.debug.charting.contextmenus;
+
+import haxe.ui.containers.menus.Menu;
+
+@:access(funkin.ui.debug.charting.ChartEditorState)
+class ChartEditorBaseContextMenu extends Menu
+{
+  var chartEditorState:ChartEditorState;
+
+  public function new(chartEditorState:ChartEditorState, xPos:Float = 0, yPos:Float = 0)
+  {
+    super();
+
+    this.chartEditorState = chartEditorState;
+
+    this.left = xPos;
+    this.top = yPos;
+  }
+}
diff --git a/source/funkin/ui/debug/charting/contextmenus/ChartEditorDefaultContextMenu.hx b/source/funkin/ui/debug/charting/contextmenus/ChartEditorDefaultContextMenu.hx
new file mode 100644
index 000000000..9529cc2fd
--- /dev/null
+++ b/source/funkin/ui/debug/charting/contextmenus/ChartEditorDefaultContextMenu.hx
@@ -0,0 +1,14 @@
+package funkin.ui.debug.charting.contextmenus;
+
+import haxe.ui.containers.menus.Menu;
+import haxe.ui.core.Screen;
+
+@:access(funkin.ui.debug.charting.ChartEditorState)
+@:build(haxe.ui.ComponentBuilder.build("assets/exclude/data/ui/chart-editor/context-menus/default.xml"))
+class ChartEditorDefaultContextMenu extends ChartEditorBaseContextMenu
+{
+  public function new(chartEditorState2:ChartEditorState, xPos2:Float = 0, yPos2:Float = 0)
+  {
+    super(chartEditorState2, xPos2, yPos2);
+  }
+}
diff --git a/source/funkin/ui/debug/charting/contextmenus/ChartEditorEventContextMenu.hx b/source/funkin/ui/debug/charting/contextmenus/ChartEditorEventContextMenu.hx
new file mode 100644
index 000000000..d848f1435
--- /dev/null
+++ b/source/funkin/ui/debug/charting/contextmenus/ChartEditorEventContextMenu.hx
@@ -0,0 +1,36 @@
+package funkin.ui.debug.charting.contextmenus;
+
+import haxe.ui.containers.menus.Menu;
+import haxe.ui.containers.menus.MenuItem;
+import haxe.ui.core.Screen;
+import funkin.data.song.SongData.SongEventData;
+import funkin.ui.debug.charting.commands.RemoveEventsCommand;
+
+@:access(funkin.ui.debug.charting.ChartEditorState)
+@:build(haxe.ui.ComponentBuilder.build("assets/exclude/data/ui/chart-editor/context-menus/event.xml"))
+class ChartEditorEventContextMenu extends ChartEditorBaseContextMenu
+{
+  var contextmenuEdit:MenuItem;
+  var contextmenuDelete:MenuItem;
+
+  var data:SongEventData;
+
+  public function new(chartEditorState2:ChartEditorState, xPos2:Float = 0, yPos2:Float = 0, data:SongEventData)
+  {
+    super(chartEditorState2, xPos2, yPos2);
+    this.data = data;
+
+    initialize();
+  }
+
+  function initialize()
+  {
+    contextmenuEdit.onClick = function(_) {
+      chartEditorState.showToolbox(ChartEditorState.CHART_EDITOR_TOOLBOX_EVENT_DATA_LAYOUT);
+    }
+
+    contextmenuDelete.onClick = function(_) {
+      chartEditorState.performCommand(new RemoveEventsCommand([data]));
+    }
+  }
+}
diff --git a/source/funkin/ui/debug/charting/contextmenus/ChartEditorHoldNoteContextMenu.hx b/source/funkin/ui/debug/charting/contextmenus/ChartEditorHoldNoteContextMenu.hx
new file mode 100644
index 000000000..9f58d2f03
--- /dev/null
+++ b/source/funkin/ui/debug/charting/contextmenus/ChartEditorHoldNoteContextMenu.hx
@@ -0,0 +1,43 @@
+package funkin.ui.debug.charting.contextmenus;
+
+import haxe.ui.containers.menus.Menu;
+import haxe.ui.containers.menus.MenuItem;
+import haxe.ui.core.Screen;
+import funkin.data.song.SongData.SongNoteData;
+import funkin.ui.debug.charting.commands.FlipNotesCommand;
+import funkin.ui.debug.charting.commands.RemoveNotesCommand;
+import funkin.ui.debug.charting.commands.ExtendNoteLengthCommand;
+
+@:access(funkin.ui.debug.charting.ChartEditorState)
+@:build(haxe.ui.ComponentBuilder.build("assets/exclude/data/ui/chart-editor/context-menus/hold-note.xml"))
+class ChartEditorHoldNoteContextMenu extends ChartEditorBaseContextMenu
+{
+  var contextmenuFlip:MenuItem;
+  var contextmenuDelete:MenuItem;
+
+  var data:SongNoteData;
+
+  public function new(chartEditorState2:ChartEditorState, xPos2:Float = 0, yPos2:Float = 0, data:SongNoteData)
+  {
+    super(chartEditorState2, xPos2, yPos2);
+    this.data = data;
+
+    initialize();
+  }
+
+  function initialize():Void
+  {
+    // NOTE: Remember to use commands here to ensure undo/redo works properly
+    contextmenuFlip.onClick = function(_) {
+      chartEditorState.performCommand(new FlipNotesCommand([data]));
+    }
+
+    contextmenuRemoveHold.onClick = function(_) {
+      chartEditorState.performCommand(new ExtendNoteLengthCommand(data, 0));
+    }
+
+    contextmenuDelete.onClick = function(_) {
+      chartEditorState.performCommand(new RemoveNotesCommand([data]));
+    }
+  }
+}
diff --git a/source/funkin/ui/debug/charting/contextmenus/ChartEditorNoteContextMenu.hx b/source/funkin/ui/debug/charting/contextmenus/ChartEditorNoteContextMenu.hx
new file mode 100644
index 000000000..66bf6f3ee
--- /dev/null
+++ b/source/funkin/ui/debug/charting/contextmenus/ChartEditorNoteContextMenu.hx
@@ -0,0 +1,43 @@
+package funkin.ui.debug.charting.contextmenus;
+
+import haxe.ui.containers.menus.Menu;
+import haxe.ui.containers.menus.MenuItem;
+import haxe.ui.core.Screen;
+import funkin.data.song.SongData.SongNoteData;
+import funkin.ui.debug.charting.commands.FlipNotesCommand;
+import funkin.ui.debug.charting.commands.RemoveNotesCommand;
+import funkin.ui.debug.charting.commands.ExtendNoteLengthCommand;
+
+@:access(funkin.ui.debug.charting.ChartEditorState)
+@:build(haxe.ui.ComponentBuilder.build("assets/exclude/data/ui/chart-editor/context-menus/note.xml"))
+class ChartEditorNoteContextMenu extends ChartEditorBaseContextMenu
+{
+  var contextmenuFlip:MenuItem;
+  var contextmenuDelete:MenuItem;
+
+  var data:SongNoteData;
+
+  public function new(chartEditorState2:ChartEditorState, xPos2:Float = 0, yPos2:Float = 0, data:SongNoteData)
+  {
+    super(chartEditorState2, xPos2, yPos2);
+    this.data = data;
+
+    initialize();
+  }
+
+  function initialize():Void
+  {
+    // NOTE: Remember to use commands here to ensure undo/redo works properly
+    contextmenuFlip.onClick = function(_) {
+      chartEditorState.performCommand(new FlipNotesCommand([data]));
+    }
+
+    contextmenuAddHold.onClick = function(_) {
+      chartEditorState.performCommand(new ExtendNoteLengthCommand(data, 4, STEPS));
+    }
+
+    contextmenuDelete.onClick = function(_) {
+      chartEditorState.performCommand(new RemoveNotesCommand([data]));
+    }
+  }
+}
diff --git a/source/funkin/ui/debug/charting/contextmenus/ChartEditorSelectionContextMenu.hx b/source/funkin/ui/debug/charting/contextmenus/ChartEditorSelectionContextMenu.hx
new file mode 100644
index 000000000..feed9b689
--- /dev/null
+++ b/source/funkin/ui/debug/charting/contextmenus/ChartEditorSelectionContextMenu.hx
@@ -0,0 +1,58 @@
+package funkin.ui.debug.charting.contextmenus;
+
+import haxe.ui.containers.menus.Menu;
+import haxe.ui.containers.menus.MenuItem;
+import haxe.ui.core.Screen;
+import funkin.ui.debug.charting.commands.CutItemsCommand;
+import funkin.ui.debug.charting.commands.RemoveEventsCommand;
+import funkin.ui.debug.charting.commands.RemoveItemsCommand;
+import funkin.ui.debug.charting.commands.RemoveNotesCommand;
+
+@:access(funkin.ui.debug.charting.ChartEditorState)
+@:build(haxe.ui.ComponentBuilder.build("assets/exclude/data/ui/chart-editor/context-menus/selection.xml"))
+class ChartEditorSelectionContextMenu extends ChartEditorBaseContextMenu
+{
+  var contextmenuCut:MenuItem;
+  var contextmenuCopy:MenuItem;
+  var contextmenuPaste:MenuItem;
+  var contextmenuDelete:MenuItem;
+  var contextmenuFlip:MenuItem;
+  var contextmenuSelectAll:MenuItem;
+  var contextmenuSelectInverse:MenuItem;
+  var contextmenuSelectNone:MenuItem;
+
+  public function new(chartEditorState2:ChartEditorState, xPos2:Float = 0, yPos2:Float = 0)
+  {
+    super(chartEditorState2, xPos2, yPos2);
+
+    initialize();
+  }
+
+  function initialize():Void
+  {
+    contextmenuCut.onClick = (_) -> {
+      chartEditorState.performCommand(new CutItemsCommand(chartEditorState.currentNoteSelection, chartEditorState.currentEventSelection));
+    };
+    contextmenuCopy.onClick = (_) -> {
+      chartEditorState.copySelection();
+    };
+    contextmenuFlip.onClick = (_) -> {
+      if (chartEditorState.currentNoteSelection.length > 0 && chartEditorState.currentEventSelection.length > 0)
+      {
+        chartEditorState.performCommand(new RemoveItemsCommand(chartEditorState.currentNoteSelection, chartEditorState.currentEventSelection));
+      }
+      else if (chartEditorState.currentNoteSelection.length > 0)
+      {
+        chartEditorState.performCommand(new RemoveNotesCommand(chartEditorState.currentNoteSelection));
+      }
+      else if (chartEditorState.currentEventSelection.length > 0)
+      {
+        chartEditorState.performCommand(new RemoveEventsCommand(chartEditorState.currentEventSelection));
+      }
+      else
+      {
+        // Do nothing???
+      }
+    };
+  }
+}
diff --git a/source/funkin/ui/debug/charting/dialogs/ChartEditorUploadChartDialog.hx b/source/funkin/ui/debug/charting/dialogs/ChartEditorUploadChartDialog.hx
index 5b84148c6..17f047106 100644
--- a/source/funkin/ui/debug/charting/dialogs/ChartEditorUploadChartDialog.hx
+++ b/source/funkin/ui/debug/charting/dialogs/ChartEditorUploadChartDialog.hx
@@ -13,6 +13,7 @@ import haxe.ui.notifications.NotificationType;
 
 // @:nullSafety // TODO: Fix null safety when used with HaxeUI build macros.
 @:build(haxe.ui.ComponentBuilder.build("assets/exclude/data/ui/chart-editor/dialogs/upload-chart.xml"))
+@:access(funkin.ui.debug.charting.ChartEditorState)
 class ChartEditorUploadChartDialog extends ChartEditorBaseDialog
 {
   var dropHandlers:Array<DialogDropTarget> = [];
diff --git a/source/funkin/ui/debug/charting/dialogs/ChartEditorUploadVocalsDialog.hx b/source/funkin/ui/debug/charting/dialogs/ChartEditorUploadVocalsDialog.hx
new file mode 100644
index 000000000..537c7c36e
--- /dev/null
+++ b/source/funkin/ui/debug/charting/dialogs/ChartEditorUploadVocalsDialog.hx
@@ -0,0 +1,311 @@
+package funkin.ui.debug.charting.dialogs;
+
+import funkin.input.Cursor;
+import funkin.ui.debug.charting.dialogs.ChartEditorBaseDialog.DialogDropTarget;
+import funkin.ui.debug.charting.dialogs.ChartEditorBaseDialog.DialogParams;
+import funkin.util.FileUtil;
+import funkin.play.character.CharacterData;
+import haxe.io.Path;
+import haxe.ui.components.Button;
+import haxe.ui.components.Label;
+import haxe.ui.containers.dialogs.Dialog.DialogButton;
+import haxe.ui.containers.dialogs.Dialog.DialogEvent;
+import haxe.ui.containers.Box;
+import haxe.ui.containers.dialogs.Dialogs;
+import haxe.ui.core.Component;
+import haxe.ui.notifications.NotificationManager;
+import haxe.ui.notifications.NotificationType;
+
+// @:nullSafety // TODO: Fix null safety when used with HaxeUI build macros.
+
+@:build(haxe.ui.ComponentBuilder.build("assets/exclude/data/ui/chart-editor/dialogs/upload-vocals.xml"))
+@:access(funkin.ui.debug.charting.ChartEditorState)
+class ChartEditorUploadVocalsDialog extends ChartEditorBaseDialog
+{
+  var dropHandlers:Array<DialogDropTarget> = [];
+
+  var vocalContainer:Component;
+  var dialogCancel:Button;
+  var dialogNoVocals:Button;
+  var dialogContinue:Button;
+
+  var charIds:Array<String>;
+  var instId:String;
+  var hasClearedVocals:Bool = false;
+
+  public function new(state2:ChartEditorState, charIds:Array<String>, params2:DialogParams)
+  {
+    super(state2, params2);
+
+    this.charIds = charIds;
+    this.instId = chartEditorState.currentInstrumentalId;
+
+    dialogCancel.onClick = function(_) {
+      hideDialog(DialogButton.CANCEL);
+    }
+
+    dialogNoVocals.onClick = function(_) {
+      // Dismiss
+      chartEditorState.wipeVocalData();
+      hideDialog(DialogButton.APPLY);
+    };
+
+    dialogContinue.onClick = function(_) {
+      // Dismiss
+      hideDialog(DialogButton.APPLY);
+    };
+
+    buildDropHandlers();
+  }
+
+  function buildDropHandlers():Void
+  {
+    for (charKey in charIds)
+    {
+      trace('Adding vocal upload for character ${charKey}');
+
+      var charMetadata:Null<CharacterData> = CharacterDataParser.fetchCharacterData(charKey);
+      var charName:String = charMetadata?.name ?? charKey;
+
+      var vocalsEntry = new ChartEditorUploadVocalsEntry(charName);
+
+      var dropHandler:DialogDropTarget = {component: vocalsEntry, handler: null};
+
+      var onDropFile:String->Void = function(pathStr:String) {
+        trace('Selected file: $pathStr');
+        var path:Path = new Path(pathStr);
+
+        if (chartEditorState.loadVocalsFromPath(path, charKey, this.instId, !this.hasClearedVocals))
+        {
+          this.hasClearedVocals = true;
+          // Tell the user the load was successful.
+          chartEditorState.success('Loaded Vocals', 'Loaded vocals for $charName (${path.file}.${path.ext}), variation ${chartEditorState.selectedVariation}');
+          #if FILE_DROP_SUPPORTED
+          vocalsEntry.vocalsEntryLabel.text = 'Voices for $charName (drag and drop, or click to browse)\nSelected file: ${path.file}.${path.ext}';
+          #else
+          vocalsEntry.vocalsEntryLabel.text = 'Voices for $charName (click to browse)\n${path.file}.${path.ext}';
+          #end
+
+          dialogNoVocals.hidden = true;
+          chartEditorState.removeDropHandler(dropHandler);
+        }
+        else
+        {
+          trace('Failed to load vocal track (${path.file}.${path.ext})');
+
+          chartEditorState.error('Failed to Load Vocals',
+            'Failed to load vocal track (${path.file}.${path.ext}) for variation (${chartEditorState.selectedVariation})');
+
+          #if FILE_DROP_SUPPORTED
+          vocalsEntry.vocalsEntryLabel.text = 'Drag and drop vocals for $charName here, or click to browse.';
+          #else
+          vocalsEntry.vocalsEntryLabel.text = 'Click to browse for vocals for $charName.';
+          #end
+        }
+      };
+
+      vocalsEntry.onClick = function(_event) {
+        Dialogs.openBinaryFile('Open $charName Vocals', [
+          {label: 'Audio File (.ogg)', extension: 'ogg'}], function(selectedFile) {
+            if (selectedFile != null && selectedFile.bytes != null)
+            {
+              trace('Selected file: ' + selectedFile.name);
+
+              if (chartEditorState.loadVocalsFromBytes(selectedFile.bytes, charKey, this.instId, !this.hasClearedVocals))
+              {
+                hasClearedVocals = true;
+                // Tell the user the load was successful.
+                chartEditorState.success('Loaded Vocals',
+                  'Loaded vocals for $charName (${selectedFile.name}), variation ${chartEditorState.selectedVariation}');
+
+                #if FILE_DROP_SUPPORTED
+                vocalsEntry.vocalsEntryLabel.text = 'Voices for $charName (drag and drop, or click to browse)\nSelected file: ${selectedFile.name}';
+                #else
+                vocalsEntry.vocalsEntryLabel.text = 'Voices for $charName (click to browse)\n${selectedFile.name}';
+                #end
+
+                dialogNoVocals.hidden = true;
+              }
+              else
+              {
+                trace('Failed to load vocal track (${selectedFile.fullPath})');
+
+                chartEditorState.error('Failed to Load Vocals',
+                  'Failed to load vocal track (${selectedFile.name}) for variation (${chartEditorState.selectedVariation})');
+
+                #if FILE_DROP_SUPPORTED
+                vocalsEntry.vocalsEntryLabel.text = 'Drag and drop vocals for $charName here, or click to browse.';
+                #else
+                vocalsEntry.vocalsEntryLabel.text = 'Click to browse for vocals for $charName.';
+                #end
+              }
+            }
+        });
+      }
+
+      dropHandler.handler = onDropFile;
+
+      // onDropFile
+      #if FILE_DROP_SUPPORTED
+      dropHandlers.push(dropHandler);
+      #end
+
+      vocalContainer.addComponent(vocalsEntry);
+    }
+  }
+
+  public static function build(state:ChartEditorState, charIds:Array<String>, ?closable:Bool, ?modal:Bool):ChartEditorUploadVocalsDialog
+  {
+    var dialog = new ChartEditorUploadVocalsDialog(state, charIds,
+      {
+        closable: closable ?? false,
+        modal: modal ?? true
+      });
+
+    for (dropTarget in dialog.dropHandlers)
+    {
+      state.addDropHandler(dropTarget);
+    }
+
+    dialog.showDialog(modal ?? true);
+
+    return dialog;
+  }
+
+  public override function onClose(event:DialogEvent):Void
+  {
+    super.onClose(event);
+
+    if (event.button != DialogButton.APPLY && !this.closable)
+    {
+      // User cancelled the wizard! Back to the welcome dialog.
+      chartEditorState.openWelcomeDialog(this.closable);
+    }
+
+    for (dropTarget in dropHandlers)
+    {
+      chartEditorState.removeDropHandler(dropTarget);
+    }
+  }
+
+  public override function lock():Void
+  {
+    super.lock();
+    this.dialogCancel.disabled = true;
+  }
+
+  public override function unlock():Void
+  {
+    super.unlock();
+    this.dialogCancel.disabled = false;
+  }
+
+  /**
+   * Called when clicking the Upload Chart box.
+   */
+  public function onClickChartBox():Void
+  {
+    if (this.locked) return;
+
+    this.lock();
+    // TODO / BUG: File filtering not working on mac finder dialog, so we don't use it for now
+    #if !mac
+    FileUtil.browseForBinaryFile('Open Chart', [FileUtil.FILE_EXTENSION_INFO_FNFC], onSelectFile, onCancelBrowse);
+    #else
+    FileUtil.browseForBinaryFile('Open Chart', null, onSelectFile, onCancelBrowse);
+    #end
+  }
+
+  /**
+   * Called when a file is selected by dropping a file onto the Upload Chart box.
+   */
+  function onDropFileChartBox(pathStr:String):Void
+  {
+    var path:Path = new Path(pathStr);
+    trace('Dropped file (${path})');
+
+    try
+    {
+      var result:Null<Array<String>> = ChartEditorImportExportHandler.loadFromFNFCPath(chartEditorState, path.toString());
+      if (result != null)
+      {
+        chartEditorState.success('Loaded Chart',
+          result.length == 0 ? 'Loaded chart (${path.toString()})' : 'Loaded chart (${path.toString()})\n${result.join("\n")}');
+        this.hideDialog(DialogButton.APPLY);
+      }
+      else
+      {
+        chartEditorState.failure('Failed to Load Chart', 'Failed to load chart (${path.toString()})');
+      }
+    }
+    catch (err)
+    {
+      chartEditorState.failure('Failed to Load Chart', 'Failed to load chart (${path.toString()}): ${err}');
+    }
+  }
+
+  /**
+   * Called when a file is selected by the dialog displayed when clicking the Upload Chart box.
+   */
+  function onSelectFile(selectedFile:SelectedFileInfo):Void
+  {
+    this.unlock();
+
+    if (selectedFile != null && selectedFile.bytes != null)
+    {
+      try
+      {
+        var result:Null<Array<String>> = ChartEditorImportExportHandler.loadFromFNFC(chartEditorState, selectedFile.bytes);
+        if (result != null)
+        {
+          chartEditorState.success('Loaded Chart',
+            result.length == 0 ? 'Loaded chart (${selectedFile.name})' : 'Loaded chart (${selectedFile.name})\n${result.join("\n")}');
+
+          if (selectedFile.fullPath != null) chartEditorState.currentWorkingFilePath = selectedFile.fullPath;
+          this.hideDialog(DialogButton.APPLY);
+        }
+      }
+      catch (err)
+      {
+        chartEditorState.failure('Failed to Load Chart', 'Failed to load chart (${selectedFile.name}): ${err}');
+      }
+    }
+  }
+
+  function onCancelBrowse():Void
+  {
+    this.unlock();
+  }
+}
+
+@:build(haxe.ui.ComponentBuilder.build("assets/exclude/data/ui/chart-editor/dialogs/upload-vocals-entry.xml"))
+class ChartEditorUploadVocalsEntry extends Box
+{
+  public var vocalsEntryLabel:Label;
+
+  var charName:String;
+
+  public function new(charName:String)
+  {
+    super();
+
+    this.charName = charName;
+
+    #if FILE_DROP_SUPPORTED
+    vocalsEntryLabel.text = 'Drag and drop vocals for $charName here, or click to browse.';
+    #else
+    vocalsEntryLabel.text = 'Click to browse for vocals for $charName.';
+    #end
+
+    this.onMouseOver = function(_event) {
+      // if (this.locked) return;
+      this.swapClass('upload-bg', 'upload-bg-hover');
+      Cursor.cursorMode = Pointer;
+    }
+
+    this.onMouseOut = function(_event) {
+      this.swapClass('upload-bg-hover', 'upload-bg');
+      Cursor.cursorMode = Default;
+    }
+  }
+}
diff --git a/source/funkin/ui/debug/charting/handlers/ChartEditorAudioHandler.hx b/source/funkin/ui/debug/charting/handlers/ChartEditorAudioHandler.hx
index 272291a94..76b2a388e 100644
--- a/source/funkin/ui/debug/charting/handlers/ChartEditorAudioHandler.hx
+++ b/source/funkin/ui/debug/charting/handlers/ChartEditorAudioHandler.hx
@@ -3,11 +3,14 @@ package funkin.ui.debug.charting.handlers;
 import flixel.system.FlxAssets.FlxSoundAsset;
 import flixel.system.FlxSound;
 import funkin.audio.VoicesGroup;
-import funkin.audio.visualize.PolygonVisGroup;
 import funkin.audio.FunkinSound;
 import funkin.play.character.BaseCharacter.CharacterType;
 import funkin.util.FileUtil;
 import funkin.util.assets.SoundUtil;
+import funkin.audio.waveform.WaveformData;
+import funkin.audio.waveform.WaveformDataParser;
+import funkin.audio.waveform.WaveformSprite;
+import flixel.util.FlxColor;
 import haxe.io.Bytes;
 import haxe.io.Path;
 import openfl.utils.Assets;
@@ -28,11 +31,11 @@ class ChartEditorAudioHandler
    * @param instId The instrumental this vocal track will be for.
    * @return Success or failure.
    */
-  public static function loadVocalsFromPath(state:ChartEditorState, path:Path, charId:String, instId:String = ''):Bool
+  public static function loadVocalsFromPath(state:ChartEditorState, path:Path, charId:String, instId:String = '', wipeFirst:Bool = false):Bool
   {
     #if sys
     var fileBytes:Bytes = sys.io.File.getBytes(path.toString());
-    return loadVocalsFromBytes(state, fileBytes, charId, instId);
+    return loadVocalsFromBytes(state, fileBytes, charId, instId, wipeFirst);
     #else
     trace("[WARN] This platform can't load audio from a file path, you'll need to fetch the bytes some other way.");
     return false;
@@ -47,12 +50,12 @@ class ChartEditorAudioHandler
    * @param instId The instrumental this vocal track will be for.
    * @return Success or failure.
    */
-  public static function loadVocalsFromAsset(state:ChartEditorState, path:String, charId:String, instId:String = ''):Bool
+  public static function loadVocalsFromAsset(state:ChartEditorState, path:String, charId:String, instId:String = '', wipeFirst:Bool = false):Bool
   {
     var trackData:Null<Bytes> = Assets.getBytes(path);
     if (trackData != null)
     {
-      return loadVocalsFromBytes(state, trackData, charId, instId);
+      return loadVocalsFromBytes(state, trackData, charId, instId, wipeFirst);
     }
     return false;
   }
@@ -63,10 +66,12 @@ class ChartEditorAudioHandler
    * @param bytes The audio byte data.
    * @param charId The character this vocal track will be for.
    * @param instId The instrumental this vocal track will be for.
+   * @param wipeFirst Whether to wipe the existing vocal data before loading.
    */
-  public static function loadVocalsFromBytes(state:ChartEditorState, bytes:Bytes, charId:String, instId:String = ''):Bool
+  public static function loadVocalsFromBytes(state:ChartEditorState, bytes:Bytes, charId:String, instId:String = '', wipeFirst:Bool = false):Bool
   {
     var trackId:String = '${charId}${instId == '' ? '' : '-${instId}'}';
+    if (wipeFirst) wipeVocalData(state);
     state.audioVocalTrackData.set(trackId, bytes);
     return true;
   }
@@ -78,11 +83,11 @@ class ChartEditorAudioHandler
    * @param instId The instrumental this vocal track will be for.
    * @return Success or failure.
    */
-  public static function loadInstFromPath(state:ChartEditorState, path:Path, instId:String = ''):Bool
+  public static function loadInstFromPath(state:ChartEditorState, path:Path, instId:String = '', wipeFirst:Bool = false):Bool
   {
     #if sys
     var fileBytes:Bytes = sys.io.File.getBytes(path.toString());
-    return loadInstFromBytes(state, fileBytes, instId);
+    return loadInstFromBytes(state, fileBytes, instId, wipeFirst);
     #else
     trace("[WARN] This platform can't load audio from a file path, you'll need to fetch the bytes some other way.");
     return false;
@@ -96,12 +101,12 @@ class ChartEditorAudioHandler
    * @param instId The instrumental this vocal track will be for.
    * @return Success or failure.
    */
-  public static function loadInstFromAsset(state:ChartEditorState, path:String, instId:String = ''):Bool
+  public static function loadInstFromAsset(state:ChartEditorState, path:String, instId:String = '', wipeFirst:Bool = false):Bool
   {
     var trackData:Null<Bytes> = Assets.getBytes(path);
     if (trackData != null)
     {
-      return loadInstFromBytes(state, trackData, instId);
+      return loadInstFromBytes(state, trackData, instId, wipeFirst);
     }
     return false;
   }
@@ -113,23 +118,51 @@ class ChartEditorAudioHandler
    * @param charId The character this vocal track will be for.
    * @param instId The instrumental this vocal track will be for.
    */
-  public static function loadInstFromBytes(state:ChartEditorState, bytes:Bytes, instId:String = ''):Bool
+  public static function loadInstFromBytes(state:ChartEditorState, bytes:Bytes, instId:String = '', wipeFirst:Bool = false):Bool
   {
     if (instId == '') instId = 'default';
+    if (wipeFirst) wipeInstrumentalData(state);
     state.audioInstTrackData.set(instId, bytes);
     return true;
   }
 
   public static function switchToInstrumental(state:ChartEditorState, instId:String = '', playerId:String, opponentId:String):Bool
   {
+    var perfA = haxe.Timer.stamp();
+
     var result:Bool = playInstrumental(state, instId);
     if (!result) return false;
 
+    var perfB = haxe.Timer.stamp();
+
     stopExistingVocals(state);
+
+    var perfC = haxe.Timer.stamp();
+
     result = playVocals(state, BF, playerId, instId);
-    if (!result) return false;
+
+    var perfD = haxe.Timer.stamp();
+
+    // if (!result) return false;
     result = playVocals(state, DAD, opponentId, instId);
-    if (!result) return false;
+    // if (!result) return false;
+
+    var perfE = haxe.Timer.stamp();
+
+    state.hardRefreshOffsetsToolbox();
+
+    var perfF = haxe.Timer.stamp();
+
+    state.hardRefreshFreeplayToolbox();
+
+    var perfG = haxe.Timer.stamp();
+
+    trace('Switched to instrumental in ${perfB - perfA} seconds.');
+    trace('Stopped existing vocals in ${perfC - perfB} seconds.');
+    trace('Played BF vocals in ${perfD - perfC} seconds.');
+    trace('Played DAD vocals in ${perfE - perfD} seconds.');
+    trace('Hard refreshed offsets toolbox in ${perfF - perfE} seconds.');
+    trace('Hard refreshed freeplay toolbox in ${perfG - perfF} seconds.');
 
     return true;
   }
@@ -141,7 +174,10 @@ class ChartEditorAudioHandler
   {
     if (instId == '') instId = 'default';
     var instTrackData:Null<Bytes> = state.audioInstTrackData.get(instId);
+    var perfA = haxe.Timer.stamp();
     var instTrack:Null<FunkinSound> = SoundUtil.buildSoundFromBytes(instTrackData);
+    var perfB = haxe.Timer.stamp();
+    trace('Built instrumental track in ${perfB - perfA} seconds.');
     if (instTrack == null) return false;
 
     stopExistingInstrumental(state);
@@ -169,10 +205,12 @@ class ChartEditorAudioHandler
   {
     var trackId:String = '${charId}${instId == '' ? '' : '-${instId}'}';
     var vocalTrackData:Null<Bytes> = state.audioVocalTrackData.get(trackId);
+    var perfStart = haxe.Timer.stamp();
     var vocalTrack:Null<FunkinSound> = SoundUtil.buildSoundFromBytes(vocalTrackData);
+    var perfEnd = haxe.Timer.stamp();
+    trace('Built vocal track in ${perfEnd - perfStart} seconds.');
 
     if (state.audioVocalTrackGroup == null) state.audioVocalTrackGroup = new VoicesGroup();
-    if (state.audioVisGroup == null) state.audioVisGroup = new PolygonVisGroup();
 
     if (vocalTrack != null)
     {
@@ -180,24 +218,57 @@ class ChartEditorAudioHandler
       {
         case BF:
           state.audioVocalTrackGroup.addPlayerVoice(vocalTrack);
-          state.audioVisGroup.addPlayerVis(vocalTrack);
-          state.audioVisGroup.playerVis.x = 885;
-          state.audioVisGroup.playerVis.realtimeVisLenght = Conductor.getStepTimeInMs(16) * 0.00195;
-          state.audioVisGroup.playerVis.daHeight = (ChartEditorState.GRID_SIZE) * 16;
-          state.audioVisGroup.playerVis.detail = 1;
 
-          state.audioVocalTrackGroup.playerVoicesOffset = state.currentSongOffsets.getVocalOffset(charId);
+          var perfStart = haxe.Timer.stamp();
+          var waveformData:Null<WaveformData> = vocalTrack.waveformData;
+          var perfEnd = haxe.Timer.stamp();
+          trace('Interpreted waveform data in ${perfEnd - perfStart} seconds.');
+
+          if (waveformData != null)
+          {
+            var duration:Float = Conductor.instance.getStepTimeInMs(16) * 0.001;
+            var waveformSprite:WaveformSprite = new WaveformSprite(waveformData, VERTICAL, FlxColor.WHITE);
+            waveformSprite.x = 840;
+            waveformSprite.y = Math.max(state.gridTiledSprite?.y ?? 0.0, ChartEditorState.GRID_INITIAL_Y_POS - ChartEditorState.GRID_TOP_PAD);
+            waveformSprite.height = (ChartEditorState.GRID_SIZE) * 16;
+            waveformSprite.width = (ChartEditorState.GRID_SIZE) * 2;
+            waveformSprite.time = 0;
+            waveformSprite.duration = duration;
+            state.audioWaveforms.add(waveformSprite);
+          }
+          else
+          {
+            trace('[WARN] Failed to parse waveform data for vocal track.');
+          }
+
+          state.audioVocalTrackGroup.playerVoicesOffset = state.currentVocalOffsetPlayer;
           return true;
         case DAD:
           state.audioVocalTrackGroup.addOpponentVoice(vocalTrack);
-          state.audioVisGroup.addOpponentVis(vocalTrack);
-          state.audioVisGroup.opponentVis.x = 435;
 
-          state.audioVisGroup.opponentVis.realtimeVisLenght = Conductor.getStepTimeInMs(16) * 0.00195;
-          state.audioVisGroup.opponentVis.daHeight = (ChartEditorState.GRID_SIZE) * 16;
-          state.audioVisGroup.opponentVis.detail = 1;
+          var perfStart = haxe.Timer.stamp();
+          var waveformData:Null<WaveformData> = vocalTrack.waveformData;
+          var perfEnd = haxe.Timer.stamp();
+          trace('Interpreted waveform data in ${perfEnd - perfStart} seconds.');
 
-          state.audioVocalTrackGroup.opponentVoicesOffset = state.currentSongOffsets.getVocalOffset(charId);
+          if (waveformData != null)
+          {
+            var duration:Float = Conductor.instance.getStepTimeInMs(16) * 0.001;
+            var waveformSprite:WaveformSprite = new WaveformSprite(waveformData, VERTICAL, FlxColor.WHITE);
+            waveformSprite.x = 360;
+            waveformSprite.y = Math.max(state.gridTiledSprite?.y ?? 0.0, ChartEditorState.GRID_INITIAL_Y_POS - ChartEditorState.GRID_TOP_PAD);
+            waveformSprite.height = (ChartEditorState.GRID_SIZE) * 16;
+            waveformSprite.width = (ChartEditorState.GRID_SIZE) * 2;
+            waveformSprite.time = 0;
+            waveformSprite.duration = duration;
+            state.audioWaveforms.add(waveformSprite);
+          }
+          else
+          {
+            trace('[WARN] Failed to parse waveform data for vocal track.');
+          }
+
+          state.audioVocalTrackGroup.opponentVoicesOffset = state.currentVocalOffsetOpponent;
 
           return true;
         case OTHER:
@@ -214,9 +285,10 @@ class ChartEditorAudioHandler
 
   public static function stopExistingVocals(state:ChartEditorState):Void
   {
-    if (state.audioVocalTrackGroup != null)
+    state.audioVocalTrackGroup.clear();
+    if (state.audioWaveforms != null)
     {
-      state.audioVocalTrackGroup.clear();
+      state.audioWaveforms.clear();
     }
   }
 
diff --git a/source/funkin/ui/debug/charting/handlers/ChartEditorContextMenuHandler.hx b/source/funkin/ui/debug/charting/handlers/ChartEditorContextMenuHandler.hx
new file mode 100644
index 000000000..c1eea5379
--- /dev/null
+++ b/source/funkin/ui/debug/charting/handlers/ChartEditorContextMenuHandler.hx
@@ -0,0 +1,82 @@
+package funkin.ui.debug.charting.handlers;
+
+import funkin.ui.debug.charting.contextmenus.ChartEditorDefaultContextMenu;
+import funkin.ui.debug.charting.contextmenus.ChartEditorEventContextMenu;
+import funkin.ui.debug.charting.contextmenus.ChartEditorHoldNoteContextMenu;
+import funkin.ui.debug.charting.contextmenus.ChartEditorNoteContextMenu;
+import funkin.ui.debug.charting.contextmenus.ChartEditorSelectionContextMenu;
+import haxe.ui.containers.menus.Menu;
+import haxe.ui.core.Screen;
+import funkin.data.song.SongData.SongNoteData;
+import funkin.data.song.SongData.SongEventData;
+
+/**
+ * Handles context menus (the little menus that appear when you right click on stuff) for the new Chart Editor.
+ */
+@:nullSafety
+@:access(funkin.ui.debug.charting.ChartEditorState)
+class ChartEditorContextMenuHandler
+{
+  static var existingMenus:Array<Menu> = [];
+
+  public static function openDefaultContextMenu(state:ChartEditorState, xPos:Float, yPos:Float)
+  {
+    displayMenu(state, new ChartEditorDefaultContextMenu(state, xPos, yPos));
+  }
+
+  /**
+   * Opened when shift+right-clicking a selection of multiple items.
+   */
+  public static function openSelectionContextMenu(state:ChartEditorState, xPos:Float, yPos:Float)
+  {
+    displayMenu(state, new ChartEditorSelectionContextMenu(state, xPos, yPos));
+  }
+
+  /**
+   * Opened when shift+right-clicking a single note.
+   */
+  public static function openNoteContextMenu(state:ChartEditorState, xPos:Float, yPos:Float, data:SongNoteData)
+  {
+    displayMenu(state, new ChartEditorNoteContextMenu(state, xPos, yPos, data));
+  }
+
+  /**
+   * Opened when shift+right-clicking a single hold note.
+   */
+  public static function openHoldNoteContextMenu(state:ChartEditorState, xPos:Float, yPos:Float, data:SongNoteData)
+  {
+    displayMenu(state, new ChartEditorHoldNoteContextMenu(state, xPos, yPos, data));
+  }
+
+  /**
+   * Opened when shift+right-clicking a single event.
+   */
+  public static function openEventContextMenu(state:ChartEditorState, xPos:Float, yPos:Float, data:SongEventData)
+  {
+    displayMenu(state, new ChartEditorEventContextMenu(state, xPos, yPos, data));
+  }
+
+  static function displayMenu(state:ChartEditorState, targetMenu:Menu)
+  {
+    // Close any existing menus
+    closeAllMenus(state);
+
+    // Show the new menu
+    Screen.instance.addComponent(targetMenu);
+    existingMenus.push(targetMenu);
+  }
+
+  public static function closeMenu(state:ChartEditorState, targetMenu:Menu)
+  {
+    // targetMenu.close();
+    existingMenus.remove(targetMenu);
+  }
+
+  public static function closeAllMenus(state:ChartEditorState)
+  {
+    for (existingMenu in existingMenus)
+    {
+      closeMenu(state, existingMenu);
+    }
+  }
+}
diff --git a/source/funkin/ui/debug/charting/handlers/ChartEditorDialogHandler.hx b/source/funkin/ui/debug/charting/handlers/ChartEditorDialogHandler.hx
index 666b3656c..970f021ac 100644
--- a/source/funkin/ui/debug/charting/handlers/ChartEditorDialogHandler.hx
+++ b/source/funkin/ui/debug/charting/handlers/ChartEditorDialogHandler.hx
@@ -13,12 +13,13 @@ import funkin.play.character.BaseCharacter;
 import funkin.play.character.CharacterData;
 import funkin.play.character.CharacterData.CharacterDataParser;
 import funkin.play.song.Song;
-import funkin.play.stage.StageData;
+import funkin.data.stage.StageData;
 import funkin.ui.debug.charting.dialogs.ChartEditorAboutDialog;
 import funkin.ui.debug.charting.dialogs.ChartEditorBaseDialog.DialogDropTarget;
 import funkin.ui.debug.charting.dialogs.ChartEditorCharacterIconSelectorMenu;
 import funkin.ui.debug.charting.dialogs.ChartEditorUploadChartDialog;
 import funkin.ui.debug.charting.dialogs.ChartEditorWelcomeDialog;
+import funkin.ui.debug.charting.dialogs.ChartEditorUploadVocalsDialog;
 import funkin.ui.debug.charting.util.ChartEditorDropdowns;
 import funkin.util.Constants;
 import funkin.util.DateUtil;
@@ -59,11 +60,8 @@ using Lambda;
 class ChartEditorDialogHandler
 {
   // Paths to HaxeUI layout files for each dialog.
-  static final CHART_EDITOR_DIALOG_UPLOAD_CHART_LAYOUT:String = Paths.ui('chart-editor/dialogs/upload-chart');
   static final CHART_EDITOR_DIALOG_UPLOAD_INST_LAYOUT:String = Paths.ui('chart-editor/dialogs/upload-inst');
   static final CHART_EDITOR_DIALOG_SONG_METADATA_LAYOUT:String = Paths.ui('chart-editor/dialogs/song-metadata');
-  static final CHART_EDITOR_DIALOG_UPLOAD_VOCALS_LAYOUT:String = Paths.ui('chart-editor/dialogs/upload-vocals');
-  static final CHART_EDITOR_DIALOG_UPLOAD_VOCALS_ENTRY_LAYOUT:String = Paths.ui('chart-editor/dialogs/upload-vocals-entry');
   static final CHART_EDITOR_DIALOG_OPEN_CHART_PARTS_LAYOUT:String = Paths.ui('chart-editor/dialogs/open-chart-parts');
   static final CHART_EDITOR_DIALOG_OPEN_CHART_PARTS_ENTRY_LAYOUT:String = Paths.ui('chart-editor/dialogs/open-chart-parts-entry');
   static final CHART_EDITOR_DIALOG_IMPORT_CHART_LAYOUT:String = Paths.ui('chart-editor/dialogs/import-chart');
@@ -105,6 +103,56 @@ class ChartEditorDialogHandler
     return dialog;
   }
 
+  /**
+   * Builds and opens a dialog letting the user browse for a chart file to open.
+   * @param state The current chart editor state.
+   * @param closable Whether the dialog can be closed by the user.
+   * @return The dialog that was opened.
+   */
+  public static function openBrowseFNFC(state:ChartEditorState, closable:Bool):Null<Dialog>
+  {
+    var dialog = ChartEditorUploadChartDialog.build(state, closable);
+
+    dialog.zIndex = 1000;
+    state.isHaxeUIDialogOpen = true;
+
+    return dialog;
+  }
+
+  /**
+   * Builds and opens a dialog where the user uploads vocals for the current song.
+   * @param state The current chart editor state.
+   * @param closable Whether the dialog can be closed by the user.
+   * @return The dialog that was opened.
+   */
+  public static function openUploadVocalsDialog(state:ChartEditorState, closable:Bool = true):Dialog
+  {
+    var charData:SongCharacterData = state.currentSongMetadata.playData.characters;
+
+    var hasClearedVocals:Bool = false;
+
+    var charIdsForVocals:Array<String> = [charData.player, charData.opponent];
+
+    var dialog = ChartEditorUploadVocalsDialog.build(state, charIdsForVocals, closable);
+
+    dialog.zIndex = 1000;
+    state.isHaxeUIDialogOpen = true;
+
+    return dialog;
+  }
+
+  /**
+   * Builds and opens the dialog for selecting a character.
+   */
+  public static function openCharacterDropdown(state:ChartEditorState, charType:CharacterType, lockPosition:Bool = false):Null<Menu>
+  {
+    var menu = ChartEditorCharacterIconSelectorMenu.build(state, charType, lockPosition);
+
+    menu.zIndex = 1000;
+
+    return menu;
+  }
+
   /**
    * Builds and opens a dialog letting the user know a backup is available, and prompting them to load it.
    */
@@ -186,22 +234,6 @@ class ChartEditorDialogHandler
     return dialog;
   }
 
-  /**
-   * Builds and opens a dialog letting the user browse for a chart file to open.
-   * @param state The current chart editor state.
-   * @param closable Whether the dialog can be closed by the user.
-   * @return The dialog that was opened.
-   */
-  public static function openBrowseFNFC(state:ChartEditorState, closable:Bool):Null<Dialog>
-  {
-    var dialog = ChartEditorUploadChartDialog.build(state, closable);
-
-    dialog.zIndex = 1000;
-    state.isHaxeUIDialogOpen = true;
-
-    return dialog;
-  }
-
   /**
    * Open the wizard for opening an existing chart from individual files.
    * @param state
@@ -288,15 +320,6 @@ class ChartEditorDialogHandler
     };
   }
 
-  public static function openCharacterDropdown(state:ChartEditorState, charType:CharacterType, lockPosition:Bool = false):Null<Menu>
-  {
-    var menu = ChartEditorCharacterIconSelectorMenu.build(state, charType, lockPosition);
-
-    menu.zIndex = 1000;
-
-    return menu;
-  }
-
   public static function openCreateSongWizardBasicOnly(state:ChartEditorState, closable:Bool):Void
   {
     // Step 1. Song Metadata
@@ -684,8 +707,12 @@ class ChartEditorDialogHandler
 
       state.songMetadata.set(targetVariation, newSongMetadata);
 
-      Conductor.instrumentalOffset = state.currentInstrumentalOffset; // Loads from the metadata.
-      Conductor.mapTimeChanges(state.currentSongMetadata.timeChanges);
+      Conductor.instance.instrumentalOffset = state.currentInstrumentalOffset; // Loads from the metadata.
+      Conductor.instance.mapTimeChanges(state.currentSongMetadata.timeChanges);
+      state.updateTimeSignature();
+
+      state.selectedVariation = Constants.DEFAULT_VARIATION;
+      state.selectedDifficulty = state.availableDifficulties[0];
 
       state.difficultySelectDirty = true;
 
@@ -695,158 +722,6 @@ class ChartEditorDialogHandler
     return dialog;
   }
 
-  /**
-   * Builds and opens a dialog where the user uploads vocals for the current song.
-   * @param state The current chart editor state.
-   * @param closable Whether the dialog can be closed by the user.
-   * @return The dialog that was opened.
-   */
-  public static function openUploadVocalsDialog(state:ChartEditorState, closable:Bool = true):Dialog
-  {
-    var instId:String = state.currentInstrumentalId;
-    var charIdsForVocals:Array<String> = [];
-
-    var charData:SongCharacterData = state.currentSongMetadata.playData.characters;
-
-    var hasClearedVocals:Bool = false;
-
-    charIdsForVocals.push(charData.player);
-    charIdsForVocals.push(charData.opponent);
-
-    var dialog:Null<Dialog> = openDialog(state, CHART_EDITOR_DIALOG_UPLOAD_VOCALS_LAYOUT, true, closable);
-    if (dialog == null) throw 'Could not locate Upload Vocals dialog';
-
-    var dialogContainer:Null<Component> = dialog.findComponent('vocalContainer');
-    if (dialogContainer == null) throw 'Could not locate vocalContainer in Upload Vocals dialog';
-
-    var buttonCancel:Null<Button> = dialog.findComponent('dialogCancel', Button);
-    if (buttonCancel == null) throw 'Could not locate dialogCancel button in Upload Vocals dialog';
-    buttonCancel.onClick = function(_) {
-      dialog.hideDialog(DialogButton.CANCEL);
-    }
-
-    var dialogNoVocals:Null<Button> = dialog.findComponent('dialogNoVocals', Button);
-    if (dialogNoVocals == null) throw 'Could not locate dialogNoVocals button in Upload Vocals dialog';
-    dialogNoVocals.onClick = function(_) {
-      // Dismiss
-      state.wipeVocalData();
-      dialog.hideDialog(DialogButton.APPLY);
-    };
-
-    for (charKey in charIdsForVocals)
-    {
-      trace('Adding vocal upload for character ${charKey}');
-      var charMetadata:Null<CharacterData> = CharacterDataParser.fetchCharacterData(charKey);
-      var charName:String = charMetadata != null ? charMetadata.name : charKey;
-
-      var vocalsEntry:Component = RuntimeComponentBuilder.fromAsset(CHART_EDITOR_DIALOG_UPLOAD_VOCALS_ENTRY_LAYOUT);
-
-      var vocalsEntryLabel:Null<Label> = vocalsEntry.findComponent('vocalsEntryLabel', Label);
-      if (vocalsEntryLabel == null) throw 'Could not locate vocalsEntryLabel in Upload Vocals dialog';
-      #if FILE_DROP_SUPPORTED
-      vocalsEntryLabel.text = 'Drag and drop vocals for $charName here, or click to browse.';
-      #else
-      vocalsEntryLabel.text = 'Click to browse for vocals for $charName.';
-      #end
-
-      var dropHandler:DialogDropTarget = {component: vocalsEntry, handler: null};
-
-      var onDropFile:String->Void = function(pathStr:String) {
-        trace('Selected file: $pathStr');
-        var path:Path = new Path(pathStr);
-
-        if (!hasClearedVocals)
-        {
-          hasClearedVocals = true;
-          state.stopExistingVocals();
-        }
-
-        if (state.loadVocalsFromPath(path, charKey, instId))
-        {
-          // Tell the user the load was successful.
-          state.success('Loaded Vocals', 'Loaded vocals for $charName (${path.file}.${path.ext}), variation ${state.selectedVariation}');
-          #if FILE_DROP_SUPPORTED
-          vocalsEntryLabel.text = 'Voices for $charName (drag and drop, or click to browse)\nSelected file: ${path.file}.${path.ext}';
-          #else
-          vocalsEntryLabel.text = 'Voices for $charName (click to browse)\n${path.file}.${path.ext}';
-          #end
-
-          dialogNoVocals.hidden = true;
-          state.removeDropHandler(dropHandler);
-        }
-        else
-        {
-          trace('Failed to load vocal track (${path.file}.${path.ext})');
-
-          state.error('Failed to Load Vocals', 'Failed to load vocal track (${path.file}.${path.ext}) for variation (${state.selectedVariation})');
-
-          #if FILE_DROP_SUPPORTED
-          vocalsEntryLabel.text = 'Drag and drop vocals for $charName here, or click to browse.';
-          #else
-          vocalsEntryLabel.text = 'Click to browse for vocals for $charName.';
-          #end
-        }
-      };
-
-      dropHandler.handler = onDropFile;
-
-      vocalsEntry.onClick = function(_event) {
-        Dialogs.openBinaryFile('Open $charName Vocals', [
-          {label: 'Audio File (.ogg)', extension: 'ogg'}], function(selectedFile) {
-            if (selectedFile != null && selectedFile.bytes != null)
-            {
-              trace('Selected file: ' + selectedFile.name);
-              if (!hasClearedVocals)
-              {
-                hasClearedVocals = true;
-                state.stopExistingVocals();
-              }
-              if (state.loadVocalsFromBytes(selectedFile.bytes, charKey, instId))
-              {
-                // Tell the user the load was successful.
-                state.success('Loaded Vocals', 'Loaded vocals for $charName (${selectedFile.name}), variation ${state.selectedVariation}');
-
-                #if FILE_DROP_SUPPORTED
-                vocalsEntryLabel.text = 'Voices for $charName (drag and drop, or click to browse)\nSelected file: ${selectedFile.name}';
-                #else
-                vocalsEntryLabel.text = 'Voices for $charName (click to browse)\n${selectedFile.name}';
-                #end
-
-                dialogNoVocals.hidden = true;
-              }
-              else
-              {
-                trace('Failed to load vocal track (${selectedFile.fullPath})');
-
-                state.error('Failed to Load Vocals', 'Failed to load vocal track (${selectedFile.name}) for variation (${state.selectedVariation})');
-
-                #if FILE_DROP_SUPPORTED
-                vocalsEntryLabel.text = 'Drag and drop vocals for $charName here, or click to browse.';
-                #else
-                vocalsEntryLabel.text = 'Click to browse for vocals for $charName.';
-                #end
-              }
-            }
-        });
-      }
-
-      // onDropFile
-      #if FILE_DROP_SUPPORTED
-      addDropHandler(dropHandler);
-      #end
-      dialogContainer.addComponent(vocalsEntry);
-    }
-
-    var dialogContinue:Null<Button> = dialog.findComponent('dialogContinue', Button);
-    if (dialogContinue == null) throw 'Could not locate dialogContinue button in Upload Vocals dialog';
-    dialogContinue.onClick = function(_) {
-      // Dismiss
-      dialog.hideDialog(DialogButton.APPLY);
-    };
-
-    return dialog;
-  }
-
   /**
    * Builds and opens a dialog where the user upload the JSON files for a song.
    * @param state The current chart editor state.
diff --git a/source/funkin/ui/debug/charting/handlers/ChartEditorImportExportHandler.hx b/source/funkin/ui/debug/charting/handlers/ChartEditorImportExportHandler.hx
index 267d2208a..0318bf296 100644
--- a/source/funkin/ui/debug/charting/handlers/ChartEditorImportExportHandler.hx
+++ b/source/funkin/ui/debug/charting/handlers/ChartEditorImportExportHandler.hx
@@ -28,6 +28,8 @@ class ChartEditorImportExportHandler
    */
   public static function loadSongAsTemplate(state:ChartEditorState, songId:String):Void
   {
+    trace('===============START');
+
     var song:Null<Song> = SongRegistry.instance.fetchEntry(songId);
 
     if (song == null) return;
@@ -43,7 +45,8 @@ class ChartEditorImportExportHandler
       var variation = (metadata.variation == null || metadata.variation == '') ? Constants.DEFAULT_VARIATION : metadata.variation;
 
       // Clone to prevent modifying the original.
-      var metadataClone:SongMetadata = metadata.clone(variation);
+      var metadataClone:SongMetadata = metadata.clone();
+      metadataClone.variation = variation;
       if (metadataClone != null) songMetadata.set(variation, metadataClone);
 
       var chartData:Null<SongChartData> = SongRegistry.instance.parseEntryChartData(songId, metadata.variation);
@@ -97,11 +100,14 @@ class ChartEditorImportExportHandler
     state.isHaxeUIDialogOpen = false;
     state.currentWorkingFilePath = null; // New file, so no path.
     state.switchToCurrentInstrumental();
+
     state.postLoadInstrumental();
 
     state.refreshToolbox(ChartEditorState.CHART_EDITOR_TOOLBOX_METADATA_LAYOUT);
 
     state.success('Success', 'Loaded song (${rawSongMetadata[0].songName})');
+
+    trace('===============END');
   }
 
   /**
@@ -114,9 +120,10 @@ class ChartEditorImportExportHandler
     state.songMetadata = newSongMetadata;
     state.songChartData = newSongChartData;
 
-    Conductor.forceBPM(null); // Disable the forced BPM.
-    Conductor.instrumentalOffset = state.currentInstrumentalOffset; // Loads from the metadata.
-    Conductor.mapTimeChanges(state.currentSongMetadata.timeChanges);
+    Conductor.instance.forceBPM(null); // Disable the forced BPM.
+    Conductor.instance.instrumentalOffset = state.currentInstrumentalOffset; // Loads from the metadata.
+    Conductor.instance.mapTimeChanges(state.currentSongMetadata.timeChanges);
+    state.updateTimeSignature();
 
     state.notePreviewDirty = true;
     state.notePreviewViewportBoundsDirty = true;
@@ -130,11 +137,8 @@ class ChartEditorImportExportHandler
       state.audioInstTrack.stop();
       state.audioInstTrack = null;
     }
-    if (state.audioVocalTrackGroup != null)
-    {
-      state.audioVocalTrackGroup.stop();
-      state.audioVocalTrackGroup.clear();
-    }
+    state.audioVocalTrackGroup.stop();
+    state.audioVocalTrackGroup.clear();
   }
 
   /**
@@ -415,16 +419,34 @@ class ChartEditorImportExportHandler
         ]);
         // We have to force write because the program will die before the save dialog is closed.
         trace('Force exporting to $targetPath...');
-        FileUtil.saveFilesAsZIPToPath(zipEntries, targetPath, targetMode);
-        if (onSaveCb != null) onSaveCb(targetPath);
+        try
+        {
+          FileUtil.saveFilesAsZIPToPath(zipEntries, targetPath, targetMode);
+          // On success.
+          if (onSaveCb != null) onSaveCb(targetPath);
+        }
+        catch (e)
+        {
+          // On failure.
+          if (onCancelCb != null) onCancelCb();
+        }
       }
       else
       {
         // Force write since we know what file the user wants to overwrite.
         trace('Force exporting to $targetPath...');
-        FileUtil.saveFilesAsZIPToPath(zipEntries, targetPath, targetMode);
-        state.saveDataDirty = false;
-        if (onSaveCb != null) onSaveCb(targetPath);
+        try
+        {
+          // On success.
+          FileUtil.saveFilesAsZIPToPath(zipEntries, targetPath, targetMode);
+          state.saveDataDirty = false;
+          if (onSaveCb != null) onSaveCb(targetPath);
+        }
+        catch (e)
+        {
+          // On failure.
+          if (onCancelCb != null) onCancelCb();
+        }
       }
     }
     else
diff --git a/source/funkin/ui/debug/charting/handlers/ChartEditorShortcutHandler.hx b/source/funkin/ui/debug/charting/handlers/ChartEditorShortcutHandler.hx
index f7105d2f7..62f1f4cbc 100644
--- a/source/funkin/ui/debug/charting/handlers/ChartEditorShortcutHandler.hx
+++ b/source/funkin/ui/debug/charting/handlers/ChartEditorShortcutHandler.hx
@@ -2,6 +2,10 @@ package funkin.ui.debug.charting.handlers;
 
 import funkin.util.PlatformUtil;
 
+/**
+ * Handles modifying the shortcut text of menu items based on the current platform.
+ * On MacOS, `Ctrl`, `Alt`, and `Shift` are replaced with `⌘` (or `^`), `⌥`, and `⇧`, respectively.
+ */
 @:access(funkin.ui.debug.charting.ChartEditorState)
 class ChartEditorShortcutHandler
 {
@@ -18,7 +22,8 @@ class ChartEditorShortcutHandler
     state.menubarItemCopy.shortcutText = ctrlOrCmd('C');
     state.menubarItemPaste.shortcutText = ctrlOrCmd('V');
 
-    state.menubarItemSelectAll.shortcutText = ctrlOrCmd('A');
+    state.menubarItemSelectAllNotes.shortcutText = ctrlOrCmd('A');
+    state.menubarItemSelectAllEvents.shortcutText = ctrlOrCmd(alt('A'));
     state.menubarItemSelectInverse.shortcutText = ctrlOrCmd('I');
     state.menubarItemSelectNone.shortcutText = ctrlOrCmd('D');
     state.menubarItemSelectBeforeCursor.shortcutText = shift('Home');
diff --git a/source/funkin/ui/debug/charting/handlers/ChartEditorThemeHandler.hx b/source/funkin/ui/debug/charting/handlers/ChartEditorThemeHandler.hx
index 4197ebdd3..b1af0ce4c 100644
--- a/source/funkin/ui/debug/charting/handlers/ChartEditorThemeHandler.hx
+++ b/source/funkin/ui/debug/charting/handlers/ChartEditorThemeHandler.hx
@@ -52,7 +52,7 @@ class ChartEditorThemeHandler
   // Border on the square highlighting selected notes.
   static final SELECTION_SQUARE_BORDER_COLOR_LIGHT:FlxColor = 0xFF339933;
   static final SELECTION_SQUARE_BORDER_COLOR_DARK:FlxColor = 0xFF339933;
-  static final SELECTION_SQUARE_BORDER_WIDTH:Int = 1;
+  public static final SELECTION_SQUARE_BORDER_WIDTH:Int = 1;
 
   // Fill on the square highlighting selected notes.
   // Make sure this is transparent so you can see the notes underneath.
@@ -81,6 +81,8 @@ class ChartEditorThemeHandler
   {
     updateBackground(state);
     updateGridBitmap(state);
+    updateMeasureTicks(state);
+    updateOffsetTicks(state);
     updateSelectionSquare(state);
     updateNotePreview(state);
   }
@@ -125,7 +127,7 @@ class ChartEditorThemeHandler
     // 2 * (Strumline Size) + 1 grid squares wide, by (4 * quarter notes per measure) grid squares tall.
     // This gets reused to fill the screen.
     var gridWidth:Int = Std.int(ChartEditorState.GRID_SIZE * TOTAL_COLUMN_COUNT);
-    var gridHeight:Int = Std.int(ChartEditorState.GRID_SIZE * Conductor.stepsPerMeasure);
+    var gridHeight:Int = Std.int(ChartEditorState.GRID_SIZE * Conductor.instance.stepsPerMeasure);
     state.gridBitmap = FlxGridOverlay.createGrid(ChartEditorState.GRID_SIZE, ChartEditorState.GRID_SIZE, gridWidth, gridHeight, true, gridColor1, gridColor2);
 
     // Selection borders
@@ -142,7 +144,7 @@ class ChartEditorThemeHandler
       selectionBorderColor);
 
     // Selection borders horizontally along the middle.
-    for (i in 1...(Conductor.stepsPerMeasure))
+    for (i in 1...(Conductor.instance.stepsPerMeasure))
     {
       state.gridBitmap.fillRect(new Rectangle(0, (ChartEditorState.GRID_SIZE * i) - (ChartEditorState.GRID_SELECTION_BORDER_WIDTH / 2),
         state.gridBitmap.width, ChartEditorState.GRID_SELECTION_BORDER_WIDTH),
@@ -197,9 +199,9 @@ class ChartEditorThemeHandler
     };
 
     // Selection borders horizontally in the middle.
-    for (i in 1...(Conductor.stepsPerMeasure))
+    for (i in 1...(Conductor.instance.stepsPerMeasure))
     {
-      if ((i % Conductor.beatsPerMeasure) == 0)
+      if ((i % Conductor.instance.beatsPerMeasure) == 0)
       {
         state.gridBitmap.fillRect(new Rectangle(0, (ChartEditorState.GRID_SIZE * i) - (GRID_BEAT_DIVIDER_WIDTH / 2), state.gridBitmap.width,
           GRID_BEAT_DIVIDER_WIDTH),
@@ -207,9 +209,6 @@ class ChartEditorThemeHandler
       }
     }
 
-    // Divider at top
-    state.gridBitmap.fillRect(new Rectangle(0, 0, state.gridBitmap.width, GRID_MEASURE_DIVIDER_WIDTH / 2), gridMeasureDividerColor);
-
     // Draw vertical dividers between the strumlines.
 
     var gridStrumlineDividerColor:FlxColor = switch (state.currentTheme)
@@ -233,6 +232,117 @@ class ChartEditorThemeHandler
     // Else, gridTiledSprite will be built later.
   }
 
+  /**
+   * Vertical measure ticks.
+   */
+  static function updateMeasureTicks(state:ChartEditorState):Void
+  {
+    var measureTickWidth:Int = 6;
+    var beatTickWidth:Int = 4;
+    var stepTickWidth:Int = 2;
+
+    // Draw the measure ticks.
+    var ticksWidth:Int = Std.int(ChartEditorState.GRID_SIZE); // 1 grid squares wide.
+    var ticksHeight:Int = Std.int(ChartEditorState.GRID_SIZE * Conductor.instance.stepsPerMeasure); // 1 measure tall.
+    state.measureTickBitmap = new BitmapData(ticksWidth, ticksHeight, true);
+    state.measureTickBitmap.fillRect(new Rectangle(0, 0, ticksWidth, ticksHeight), GRID_BEAT_DIVIDER_COLOR_DARK);
+
+    // Draw the measure ticks.
+    state.measureTickBitmap.fillRect(new Rectangle(0, 0, state.measureTickBitmap.width, measureTickWidth / 2), GRID_MEASURE_DIVIDER_COLOR_LIGHT);
+    var bottomTickY:Float = state.measureTickBitmap.height - (measureTickWidth / 2);
+    state.measureTickBitmap.fillRect(new Rectangle(0, bottomTickY, state.measureTickBitmap.width, measureTickWidth / 2), GRID_MEASURE_DIVIDER_COLOR_LIGHT);
+
+    // Draw the beat ticks.
+    var beatTick2Y:Float = state.measureTickBitmap.height * 1 / Conductor.instance.beatsPerMeasure - (beatTickWidth / 2);
+    var beatTick3Y:Float = state.measureTickBitmap.height * 2 / Conductor.instance.beatsPerMeasure - (beatTickWidth / 2);
+    var beatTick4Y:Float = state.measureTickBitmap.height * 3 / Conductor.instance.beatsPerMeasure - (beatTickWidth / 2);
+    var beatTickLength:Float = state.measureTickBitmap.width * 2 / 3;
+    state.measureTickBitmap.fillRect(new Rectangle(0, beatTick2Y, beatTickLength, beatTickWidth), GRID_MEASURE_DIVIDER_COLOR_LIGHT);
+    state.measureTickBitmap.fillRect(new Rectangle(0, beatTick3Y, beatTickLength, beatTickWidth), GRID_MEASURE_DIVIDER_COLOR_LIGHT);
+    state.measureTickBitmap.fillRect(new Rectangle(0, beatTick4Y, beatTickLength, beatTickWidth), GRID_MEASURE_DIVIDER_COLOR_LIGHT);
+
+    // Draw the step ticks.
+    // TODO: Make this a loop or something.
+    var stepTick2Y:Float = state.measureTickBitmap.height * 1 / Conductor.instance.stepsPerMeasure - (stepTickWidth / 2);
+    var stepTick3Y:Float = state.measureTickBitmap.height * 2 / Conductor.instance.stepsPerMeasure - (stepTickWidth / 2);
+    var stepTick4Y:Float = state.measureTickBitmap.height * 3 / Conductor.instance.stepsPerMeasure - (stepTickWidth / 2);
+    var stepTick6Y:Float = state.measureTickBitmap.height * 5 / Conductor.instance.stepsPerMeasure - (stepTickWidth / 2);
+    var stepTick7Y:Float = state.measureTickBitmap.height * 6 / Conductor.instance.stepsPerMeasure - (stepTickWidth / 2);
+    var stepTick8Y:Float = state.measureTickBitmap.height * 7 / Conductor.instance.stepsPerMeasure - (stepTickWidth / 2);
+    var stepTick10Y:Float = state.measureTickBitmap.height * 9 / Conductor.instance.stepsPerMeasure - (stepTickWidth / 2);
+    var stepTick11Y:Float = state.measureTickBitmap.height * 10 / Conductor.instance.stepsPerMeasure - (stepTickWidth / 2);
+    var stepTick12Y:Float = state.measureTickBitmap.height * 11 / Conductor.instance.stepsPerMeasure - (stepTickWidth / 2);
+    var stepTick14Y:Float = state.measureTickBitmap.height * 13 / Conductor.instance.stepsPerMeasure - (stepTickWidth / 2);
+    var stepTick15Y:Float = state.measureTickBitmap.height * 14 / Conductor.instance.stepsPerMeasure - (stepTickWidth / 2);
+    var stepTick16Y:Float = state.measureTickBitmap.height * 15 / Conductor.instance.stepsPerMeasure - (stepTickWidth / 2);
+    var stepTickLength:Float = state.measureTickBitmap.width * 1 / 3;
+    state.measureTickBitmap.fillRect(new Rectangle(0, stepTick2Y, stepTickLength, stepTickWidth), GRID_MEASURE_DIVIDER_COLOR_LIGHT);
+    state.measureTickBitmap.fillRect(new Rectangle(0, stepTick3Y, stepTickLength, stepTickWidth), GRID_MEASURE_DIVIDER_COLOR_LIGHT);
+    state.measureTickBitmap.fillRect(new Rectangle(0, stepTick4Y, stepTickLength, stepTickWidth), GRID_MEASURE_DIVIDER_COLOR_LIGHT);
+    state.measureTickBitmap.fillRect(new Rectangle(0, stepTick6Y, stepTickLength, stepTickWidth), GRID_MEASURE_DIVIDER_COLOR_LIGHT);
+    state.measureTickBitmap.fillRect(new Rectangle(0, stepTick7Y, stepTickLength, stepTickWidth), GRID_MEASURE_DIVIDER_COLOR_LIGHT);
+    state.measureTickBitmap.fillRect(new Rectangle(0, stepTick8Y, stepTickLength, stepTickWidth), GRID_MEASURE_DIVIDER_COLOR_LIGHT);
+    state.measureTickBitmap.fillRect(new Rectangle(0, stepTick10Y, stepTickLength, stepTickWidth), GRID_MEASURE_DIVIDER_COLOR_LIGHT);
+    state.measureTickBitmap.fillRect(new Rectangle(0, stepTick11Y, stepTickLength, stepTickWidth), GRID_MEASURE_DIVIDER_COLOR_LIGHT);
+    state.measureTickBitmap.fillRect(new Rectangle(0, stepTick12Y, stepTickLength, stepTickWidth), GRID_MEASURE_DIVIDER_COLOR_LIGHT);
+    state.measureTickBitmap.fillRect(new Rectangle(0, stepTick14Y, stepTickLength, stepTickWidth), GRID_MEASURE_DIVIDER_COLOR_LIGHT);
+    state.measureTickBitmap.fillRect(new Rectangle(0, stepTick15Y, stepTickLength, stepTickWidth), GRID_MEASURE_DIVIDER_COLOR_LIGHT);
+    state.measureTickBitmap.fillRect(new Rectangle(0, stepTick16Y, stepTickLength, stepTickWidth), GRID_MEASURE_DIVIDER_COLOR_LIGHT);
+  }
+
+  /**
+   * Horizontal offset ticks.
+   */
+  static function updateOffsetTicks(state:ChartEditorState):Void
+  {
+    var majorTickWidth:Int = 6;
+    var minorTickWidth:Int = 3;
+
+    var ticksWidth:Int = Std.int(ChartEditorState.GRID_SIZE * Conductor.instance.stepsPerMeasure); // 10 minor ticks wide.
+    var ticksHeight:Int = Std.int(ChartEditorState.GRID_SIZE); // 1 grid squares tall.
+    state.offsetTickBitmap = new BitmapData(ticksWidth, ticksHeight, true);
+    state.offsetTickBitmap.fillRect(new Rectangle(0, 0, ticksWidth, ticksHeight), GRID_BEAT_DIVIDER_COLOR_DARK);
+
+    // Draw the major ticks.
+    var leftTickX:Float = 0;
+    var middleTickX:Float = state.offsetTickBitmap.width / 2 - (majorTickWidth / 2);
+    var rightTickX:Float = state.offsetTickBitmap.width - (majorTickWidth / 2);
+    var majorTickLength:Float = state.offsetTickBitmap.height;
+    state.offsetTickBitmap.fillRect(new Rectangle(leftTickX, 0, majorTickWidth / 2, majorTickLength), GRID_MEASURE_DIVIDER_COLOR_LIGHT);
+    state.offsetTickBitmap.fillRect(new Rectangle(middleTickX, 0, majorTickWidth, majorTickLength), GRID_MEASURE_DIVIDER_COLOR_LIGHT);
+    state.offsetTickBitmap.fillRect(new Rectangle(rightTickX, 0, majorTickWidth / 2, majorTickLength), GRID_MEASURE_DIVIDER_COLOR_LIGHT);
+
+    // Draw the minor ticks.
+    var minorTick2X:Float = state.offsetTickBitmap.width * 1 / 10 - (minorTickWidth / 2);
+    var minorTick3X:Float = state.offsetTickBitmap.width * 2 / 10 - (minorTickWidth / 2);
+    var minorTick4X:Float = state.offsetTickBitmap.width * 3 / 10 - (minorTickWidth / 2);
+    var minorTick5X:Float = state.offsetTickBitmap.width * 4 / 10 - (minorTickWidth / 2);
+    var minorTick7X:Float = state.offsetTickBitmap.width * 6 / 10 - (minorTickWidth / 2);
+    var minorTick8X:Float = state.offsetTickBitmap.width * 7 / 10 - (minorTickWidth / 2);
+    var minorTick9X:Float = state.offsetTickBitmap.width * 8 / 10 - (minorTickWidth / 2);
+    var minorTick10X:Float = state.offsetTickBitmap.width * 9 / 10 - (minorTickWidth / 2);
+    var minorTickLength:Float = state.offsetTickBitmap.height * 1 / 3;
+    state.offsetTickBitmap.fillRect(new Rectangle(minorTick2X, 0, minorTickWidth, minorTickLength), GRID_MEASURE_DIVIDER_COLOR_LIGHT);
+    state.offsetTickBitmap.fillRect(new Rectangle(minorTick3X, 0, minorTickWidth, minorTickLength), GRID_MEASURE_DIVIDER_COLOR_LIGHT);
+    state.offsetTickBitmap.fillRect(new Rectangle(minorTick4X, 0, minorTickWidth, minorTickLength), GRID_MEASURE_DIVIDER_COLOR_LIGHT);
+    state.offsetTickBitmap.fillRect(new Rectangle(minorTick5X, 0, minorTickWidth, minorTickLength), GRID_MEASURE_DIVIDER_COLOR_LIGHT);
+    state.offsetTickBitmap.fillRect(new Rectangle(minorTick7X, 0, minorTickWidth, minorTickLength), GRID_MEASURE_DIVIDER_COLOR_LIGHT);
+    state.offsetTickBitmap.fillRect(new Rectangle(minorTick8X, 0, minorTickWidth, minorTickLength), GRID_MEASURE_DIVIDER_COLOR_LIGHT);
+    state.offsetTickBitmap.fillRect(new Rectangle(minorTick9X, 0, minorTickWidth, minorTickLength), GRID_MEASURE_DIVIDER_COLOR_LIGHT);
+    state.offsetTickBitmap.fillRect(new Rectangle(minorTick10X, 0, minorTickWidth, minorTickLength), GRID_MEASURE_DIVIDER_COLOR_LIGHT);
+
+    // Draw the offset ticks.
+    // var ticksWidth:Int = Std.int(ChartEditorState.GRID_SIZE * TOTAL_COLUMN_COUNT); // 1 grid squares wide.
+    // var ticksHeight:Int = Std.int(ChartEditorState.GRID_SIZE); // 1 measure tall.
+    // state.offsetTickBitmap = new BitmapData(ticksWidth, ticksHeight, true);
+    // state.offsetTickBitmap.fillRect(new Rectangle(0, 0, ticksWidth, ticksHeight), GRID_BEAT_DIVIDER_COLOR_DARK);
+    //
+    //// Draw the offset ticks.
+    // state.offsetTickBitmap.fillRect(new Rectangle(0, 0, offsetTickWidth / 2, state.offsetTickBitmap.height), GRID_MEASURE_DIVIDER_COLOR_LIGHT);
+    // var rightTickX:Float = state.offsetTickBitmap.width - (offsetTickWidth / 2);
+    // state.offsetTickBitmap.fillRect(new Rectangle(rightTickX, 0, offsetTickWidth / 2, state.offsetTickBitmap.height), GRID_MEASURE_DIVIDER_COLOR_LIGHT);
+  }
+
   static function updateSelectionSquare(state:ChartEditorState):Void
   {
     var selectionSquareBorderColor:FlxColor = switch (state.currentTheme)
@@ -264,6 +374,12 @@ class ChartEditorThemeHandler
         ChartEditorState.GRID_SIZE
         - (2 * SELECTION_SQUARE_BORDER_WIDTH + 8)),
       32, 32);
+
+    state.selectionBoxSprite.scrollFactor.set(0, 0);
+    state.selectionBoxSprite.zIndex = 30;
+    state.add(state.selectionBoxSprite);
+
+    state.setSelectionBoxBounds();
   }
 
   static function updateNotePreview(state:ChartEditorState):Void
@@ -289,14 +405,21 @@ class ChartEditorThemeHandler
       ChartEditorState.GRID_SIZE - (SELECTION_SQUARE_BORDER_WIDTH * 2), ChartEditorState.GRID_SIZE - (SELECTION_SQUARE_BORDER_WIDTH * 2)),
       viewportFillColor);
 
-    state.notePreviewViewport = new FlxSliceSprite(state.notePreviewViewportBitmap,
-      new FlxRect(SELECTION_SQUARE_BORDER_WIDTH
-        + 1, SELECTION_SQUARE_BORDER_WIDTH
-        + 1, ChartEditorState.GRID_SIZE
-        - (2 * SELECTION_SQUARE_BORDER_WIDTH + 2),
-        ChartEditorState.GRID_SIZE
-        - (2 * SELECTION_SQUARE_BORDER_WIDTH + 2)),
-      32, 32);
+    if (state.notePreviewViewport != null)
+    {
+      state.notePreviewViewport.loadGraphic(state.notePreviewViewportBitmap);
+    }
+    else
+    {
+      state.notePreviewViewport = new FlxSliceSprite(state.notePreviewViewportBitmap,
+        new FlxRect(SELECTION_SQUARE_BORDER_WIDTH
+          + 1, SELECTION_SQUARE_BORDER_WIDTH
+          + 1,
+          ChartEditorState.GRID_SIZE
+          - (2 * SELECTION_SQUARE_BORDER_WIDTH + 2), ChartEditorState.GRID_SIZE
+          - (2 * SELECTION_SQUARE_BORDER_WIDTH + 2)),
+        32, 32);
+    }
   }
 
   public static function buildPlayheadBlock():FlxSprite
diff --git a/source/funkin/ui/debug/charting/handlers/ChartEditorToolboxHandler.hx b/source/funkin/ui/debug/charting/handlers/ChartEditorToolboxHandler.hx
index a9a9c375d..9e22ba833 100644
--- a/source/funkin/ui/debug/charting/handlers/ChartEditorToolboxHandler.hx
+++ b/source/funkin/ui/debug/charting/handlers/ChartEditorToolboxHandler.hx
@@ -1,7 +1,6 @@
 package funkin.ui.debug.charting.handlers;
 
-import funkin.play.stage.StageData.StageDataParser;
-import funkin.play.stage.StageData;
+import funkin.data.stage.StageData;
 import funkin.play.character.CharacterData;
 import funkin.play.character.CharacterData.CharacterDataParser;
 import haxe.ui.components.HorizontalSlider;
@@ -9,20 +8,20 @@ import haxe.ui.containers.TreeView;
 import haxe.ui.containers.TreeViewNode;
 import funkin.play.character.BaseCharacter.CharacterType;
 import funkin.play.event.SongEvent;
-import funkin.data.event.SongEventData;
+import funkin.data.event.SongEventSchema;
 import funkin.data.song.SongData.SongTimeChange;
 import funkin.play.character.BaseCharacter.CharacterType;
 import funkin.play.character.CharacterData;
 import funkin.play.character.CharacterData.CharacterDataParser;
 import funkin.play.event.SongEvent;
 import funkin.play.song.SongSerializer;
-import funkin.play.stage.StageData;
-import funkin.play.stage.StageData.StageDataParser;
+import funkin.data.stage.StageData;
 import haxe.ui.RuntimeComponentBuilder;
 import funkin.ui.debug.charting.util.ChartEditorDropdowns;
 import funkin.ui.haxeui.components.CharacterPlayer;
 import funkin.util.FileUtil;
 import haxe.ui.components.Button;
+import haxe.ui.data.ArrayDataSource;
 import haxe.ui.components.CheckBox;
 import haxe.ui.components.DropDown;
 import haxe.ui.components.HorizontalSlider;
@@ -36,12 +35,15 @@ import haxe.ui.containers.dialogs.Dialog.DialogButton;
 import haxe.ui.containers.dialogs.Dialog.DialogEvent;
 import funkin.ui.debug.charting.toolboxes.ChartEditorBaseToolbox;
 import funkin.ui.debug.charting.toolboxes.ChartEditorMetadataToolbox;
+import funkin.ui.debug.charting.toolboxes.ChartEditorOffsetsToolbox;
+import funkin.ui.debug.charting.toolboxes.ChartEditorFreeplayToolbox;
+import funkin.ui.debug.charting.toolboxes.ChartEditorEventDataToolbox;
+import funkin.ui.debug.charting.toolboxes.ChartEditorDifficultyToolbox;
 import haxe.ui.containers.Frame;
 import haxe.ui.containers.Grid;
 import haxe.ui.containers.TreeView;
 import haxe.ui.containers.TreeViewNode;
 import haxe.ui.core.Component;
-import haxe.ui.data.ArrayDataSource;
 import haxe.ui.events.UIEvent;
 
 /**
@@ -79,15 +81,20 @@ class ChartEditorToolboxHandler
       {
         case ChartEditorState.CHART_EDITOR_TOOLBOX_NOTEDATA_LAYOUT:
           onShowToolboxNoteData(state, toolbox);
-        case ChartEditorState.CHART_EDITOR_TOOLBOX_EVENTDATA_LAYOUT:
-          onShowToolboxEventData(state, toolbox);
+        case ChartEditorState.CHART_EDITOR_TOOLBOX_EVENT_DATA_LAYOUT:
+          // TODO: Fix this.
+          cast(toolbox, ChartEditorBaseToolbox).refresh();
         case ChartEditorState.CHART_EDITOR_TOOLBOX_PLAYTEST_PROPERTIES_LAYOUT:
           onShowToolboxPlaytestProperties(state, toolbox);
         case ChartEditorState.CHART_EDITOR_TOOLBOX_DIFFICULTY_LAYOUT:
-          onShowToolboxDifficulty(state, toolbox);
+          cast(toolbox, ChartEditorBaseToolbox).refresh();
         case ChartEditorState.CHART_EDITOR_TOOLBOX_METADATA_LAYOUT:
           // TODO: Fix this.
           cast(toolbox, ChartEditorBaseToolbox).refresh();
+        case ChartEditorState.CHART_EDITOR_TOOLBOX_OFFSETS_LAYOUT:
+          cast(toolbox, ChartEditorBaseToolbox).refresh();
+        case ChartEditorState.CHART_EDITOR_TOOLBOX_FREEPLAY_LAYOUT:
+          cast(toolbox, ChartEditorBaseToolbox).refresh();
         case ChartEditorState.CHART_EDITOR_TOOLBOX_PLAYER_PREVIEW_LAYOUT:
           onShowToolboxPlayerPreview(state, toolbox);
         case ChartEditorState.CHART_EDITOR_TOOLBOX_OPPONENT_PREVIEW_LAYOUT:
@@ -119,14 +126,10 @@ class ChartEditorToolboxHandler
       {
         case ChartEditorState.CHART_EDITOR_TOOLBOX_NOTEDATA_LAYOUT:
           onHideToolboxNoteData(state, toolbox);
-        case ChartEditorState.CHART_EDITOR_TOOLBOX_EVENTDATA_LAYOUT:
+        case ChartEditorState.CHART_EDITOR_TOOLBOX_EVENT_DATA_LAYOUT:
           onHideToolboxEventData(state, toolbox);
         case ChartEditorState.CHART_EDITOR_TOOLBOX_PLAYTEST_PROPERTIES_LAYOUT:
           onHideToolboxPlaytestProperties(state, toolbox);
-        case ChartEditorState.CHART_EDITOR_TOOLBOX_DIFFICULTY_LAYOUT:
-          onHideToolboxDifficulty(state, toolbox);
-        case ChartEditorState.CHART_EDITOR_TOOLBOX_METADATA_LAYOUT:
-          onHideToolboxMetadata(state, toolbox);
         case ChartEditorState.CHART_EDITOR_TOOLBOX_PLAYER_PREVIEW_LAYOUT:
           onHideToolboxPlayerPreview(state, toolbox);
         case ChartEditorState.CHART_EDITOR_TOOLBOX_OPPONENT_PREVIEW_LAYOUT:
@@ -195,7 +198,7 @@ class ChartEditorToolboxHandler
     {
       case ChartEditorState.CHART_EDITOR_TOOLBOX_NOTEDATA_LAYOUT:
         toolbox = buildToolboxNoteDataLayout(state);
-      case ChartEditorState.CHART_EDITOR_TOOLBOX_EVENTDATA_LAYOUT:
+      case ChartEditorState.CHART_EDITOR_TOOLBOX_EVENT_DATA_LAYOUT:
         toolbox = buildToolboxEventDataLayout(state);
       case ChartEditorState.CHART_EDITOR_TOOLBOX_PLAYTEST_PROPERTIES_LAYOUT:
         toolbox = buildToolboxPlaytestPropertiesLayout(state);
@@ -203,6 +206,10 @@ class ChartEditorToolboxHandler
         toolbox = buildToolboxDifficultyLayout(state);
       case ChartEditorState.CHART_EDITOR_TOOLBOX_METADATA_LAYOUT:
         toolbox = buildToolboxMetadataLayout(state);
+      case ChartEditorState.CHART_EDITOR_TOOLBOX_OFFSETS_LAYOUT:
+        toolbox = buildToolboxOffsetsLayout(state);
+      case ChartEditorState.CHART_EDITOR_TOOLBOX_FREEPLAY_LAYOUT:
+        toolbox = buildToolboxFreeplayLayout(state);
       case ChartEditorState.CHART_EDITOR_TOOLBOX_PLAYER_PREVIEW_LAYOUT:
         toolbox = buildToolboxPlayerPreviewLayout(state);
       case ChartEditorState.CHART_EDITOR_TOOLBOX_OPPONENT_PREVIEW_LAYOUT:
@@ -283,19 +290,19 @@ class ChartEditorToolboxHandler
         toolboxNotesCustomKindLabel.hidden = false;
         toolboxNotesCustomKind.hidden = false;
 
-        state.selectedNoteKind = toolboxNotesCustomKind.text;
+        state.noteKindToPlace = toolboxNotesCustomKind.text;
       }
       else
       {
         toolboxNotesCustomKindLabel.hidden = true;
         toolboxNotesCustomKind.hidden = true;
 
-        state.selectedNoteKind = event.data.id;
+        state.noteKindToPlace = event.data.id;
       }
     }
 
     toolboxNotesCustomKind.onChange = function(event:UIEvent) {
-      state.selectedNoteKind = toolboxNotesCustomKind.text;
+      state.noteKindToPlace = toolboxNotesCustomKind.text;
     }
 
     return toolbox;
@@ -305,159 +312,12 @@ class ChartEditorToolboxHandler
 
   static function onHideToolboxNoteData(state:ChartEditorState, toolbox:CollapsibleDialog):Void {}
 
-  static function buildToolboxEventDataLayout(state:ChartEditorState):Null<CollapsibleDialog>
-  {
-    var toolbox:CollapsibleDialog = cast RuntimeComponentBuilder.fromAsset(ChartEditorState.CHART_EDITOR_TOOLBOX_EVENTDATA_LAYOUT);
-
-    if (toolbox == null) return null;
-
-    // Starting position.
-    toolbox.x = 100;
-    toolbox.y = 150;
-
-    toolbox.onDialogClosed = function(event:DialogEvent) {
-      state.menubarItemToggleToolboxEvents.selected = false;
-    }
-
-    var toolboxEventsEventKind:Null<DropDown> = toolbox.findComponent('toolboxEventsEventKind', DropDown);
-    if (toolboxEventsEventKind == null) throw 'ChartEditorToolboxHandler.buildToolboxEventDataLayout() - Could not find toolboxEventsEventKind component.';
-    var toolboxEventsDataGrid:Null<Grid> = toolbox.findComponent('toolboxEventsDataGrid', Grid);
-    if (toolboxEventsDataGrid == null) throw 'ChartEditorToolboxHandler.buildToolboxEventDataLayout() - Could not find toolboxEventsDataGrid component.';
-
-    toolboxEventsEventKind.dataSource = new ArrayDataSource();
-
-    var songEvents:Array<SongEvent> = SongEventParser.listEvents();
-
-    for (event in songEvents)
-    {
-      toolboxEventsEventKind.dataSource.add({text: event.getTitle(), value: event.id});
-    }
-
-    toolboxEventsEventKind.onChange = function(event:UIEvent) {
-      var eventType:String = event.data.value;
-
-      trace('ChartEditorToolboxHandler.buildToolboxEventDataLayout() - Event type changed: $eventType');
-
-      state.selectedEventKind = eventType;
-
-      var schema:SongEventSchema = SongEventParser.getEventSchema(eventType);
-
-      if (schema == null)
-      {
-        trace('ChartEditorToolboxHandler.buildToolboxEventDataLayout() - Unknown event kind: $eventType');
-        return;
-      }
-
-      buildEventDataFormFromSchema(state, toolboxEventsDataGrid, schema);
-    }
-    toolboxEventsEventKind.value = state.selectedEventKind;
-
-    return toolbox;
-  }
-
-  static function onShowToolboxEventData(state:ChartEditorState, toolbox:CollapsibleDialog):Void {}
+  static function onHideToolboxEventData(state:ChartEditorState, toolbox:CollapsibleDialog):Void {}
 
   static function onShowToolboxPlaytestProperties(state:ChartEditorState, toolbox:CollapsibleDialog):Void {}
 
-  static function onHideToolboxEventData(state:ChartEditorState, toolbox:CollapsibleDialog):Void {}
-
   static function onHideToolboxPlaytestProperties(state:ChartEditorState, toolbox:CollapsibleDialog):Void {}
 
-  static function buildEventDataFormFromSchema(state:ChartEditorState, target:Box, schema:SongEventSchema):Void
-  {
-    trace(schema);
-    // Clear the frame.
-    target.removeAllComponents();
-
-    state.selectedEventData = {};
-
-    for (field in schema)
-    {
-      if (field == null) continue;
-
-      // Add a label.
-      var label:Label = new Label();
-      label.text = field.title;
-      label.verticalAlign = "center";
-      target.addComponent(label);
-
-      var input:Component;
-      switch (field.type)
-      {
-        case INTEGER:
-          var numberStepper:NumberStepper = new NumberStepper();
-          numberStepper.id = field.name;
-          numberStepper.step = field.step ?? 1.0;
-          numberStepper.min = field.min ?? 0.0;
-          numberStepper.max = field.max ?? 10.0;
-          if (field.defaultValue != null) numberStepper.value = field.defaultValue;
-          input = numberStepper;
-        case FLOAT:
-          var numberStepper:NumberStepper = new NumberStepper();
-          numberStepper.id = field.name;
-          numberStepper.step = field.step ?? 0.1;
-          if (field.min != null) numberStepper.min = field.min;
-          if (field.max != null) numberStepper.max = field.max;
-          if (field.defaultValue != null) numberStepper.value = field.defaultValue;
-          input = numberStepper;
-        case BOOL:
-          var checkBox:CheckBox = new CheckBox();
-          checkBox.id = field.name;
-          if (field.defaultValue != null) checkBox.selected = field.defaultValue;
-          input = checkBox;
-        case ENUM:
-          var dropDown:DropDown = new DropDown();
-          dropDown.id = field.name;
-          dropDown.width = 200.0;
-          dropDown.dataSource = new ArrayDataSource();
-
-          if (field.keys == null) throw 'Field "${field.name}" is of Enum type but has no keys.';
-
-          // Add entries to the dropdown.
-
-          for (optionName in field.keys.keys())
-          {
-            var optionValue:Null<Dynamic> = field.keys.get(optionName);
-            trace('$optionName : $optionValue');
-            dropDown.dataSource.add({value: optionValue, text: optionName});
-          }
-
-          dropDown.value = field.defaultValue;
-
-          input = dropDown;
-        case STRING:
-          input = new TextField();
-          input.id = field.name;
-          if (field.defaultValue != null) input.text = field.defaultValue;
-        default:
-          // Unknown type. Display a label so we know what it is.
-          input = new Label();
-          input.id = field.name;
-          input.text = field.type;
-      }
-
-      target.addComponent(input);
-
-      input.onChange = function(event:UIEvent) {
-        var value = event.target.value;
-        if (field.type == ENUM)
-        {
-          value = event.target.value.value;
-        }
-        trace('ChartEditorToolboxHandler.buildEventDataFormFromSchema() - ${event.target.id} = ${value}');
-
-        if (value == null)
-        {
-          state.selectedEventData.remove(event.target.id);
-        }
-        else
-        {
-          state.selectedEventData.set(event.target.id, value);
-        }
-      }
-    }
-  }
-
   static function buildToolboxPlaytestPropertiesLayout(state:ChartEditorState):Null<CollapsibleDialog>
   {
     // fill with playtest properties
@@ -488,96 +348,28 @@ class ChartEditorToolboxHandler
       state.playtestStartTime = checkboxStartTime.selected;
     };
 
+    var checkboxDebugger:Null<CheckBox> = toolbox.findComponent('playtestDebuggerCheckbox', CheckBox);
+
+    if (checkboxDebugger == null) throw 'ChartEditorToolboxHandler.buildToolboxPlaytestPropertiesLayout() - Could not find playtestDebuggerCheckbox component.';
+
+    state.enabledDebuggerPopup = checkboxDebugger.selected;
+
+    checkboxDebugger.onClick = _ -> {
+      state.enabledDebuggerPopup = checkboxDebugger.selected;
+    };
+
     return toolbox;
   }
 
-  static function buildToolboxDifficultyLayout(state:ChartEditorState):Null<CollapsibleDialog>
+  static function buildToolboxDifficultyLayout(state:ChartEditorState):Null<ChartEditorBaseToolbox>
   {
-    var toolbox:CollapsibleDialog = cast RuntimeComponentBuilder.fromAsset(ChartEditorState.CHART_EDITOR_TOOLBOX_DIFFICULTY_LAYOUT);
+    var toolbox:ChartEditorBaseToolbox = ChartEditorDifficultyToolbox.build(state);
 
     if (toolbox == null) return null;
 
-    // Starting position.
-    toolbox.x = 125;
-    toolbox.y = 200;
-
-    toolbox.onDialogClosed = function(event:UIEvent) {
-      state.menubarItemToggleToolboxDifficulty.selected = false;
-    }
-
-    var difficultyToolboxAddVariation:Null<Button> = toolbox.findComponent('difficultyToolboxAddVariation', Button);
-    if (difficultyToolboxAddVariation == null)
-      throw 'ChartEditorToolboxHandler.buildToolboxDifficultyLayout() - Could not find difficultyToolboxAddVariation component.';
-    var difficultyToolboxAddDifficulty:Null<Button> = toolbox.findComponent('difficultyToolboxAddDifficulty', Button);
-    if (difficultyToolboxAddDifficulty == null)
-      throw 'ChartEditorToolboxHandler.buildToolboxDifficultyLayout() - Could not find difficultyToolboxAddDifficulty component.';
-    var difficultyToolboxSaveMetadata:Null<Button> = toolbox.findComponent('difficultyToolboxSaveMetadata', Button);
-    if (difficultyToolboxSaveMetadata == null)
-      throw 'ChartEditorToolboxHandler.buildToolboxDifficultyLayout() - Could not find difficultyToolboxSaveMetadata component.';
-    var difficultyToolboxSaveChart:Null<Button> = toolbox.findComponent('difficultyToolboxSaveChart', Button);
-    if (difficultyToolboxSaveChart == null)
-      throw 'ChartEditorToolboxHandler.buildToolboxDifficultyLayout() - Could not find difficultyToolboxSaveChart component.';
-    // var difficultyToolboxSaveAll:Null<Button> = toolbox.findComponent('difficultyToolboxSaveAll', Button);
-    // if (difficultyToolboxSaveAll == null) throw 'ChartEditorToolboxHandler.buildToolboxDifficultyLayout() - Could not find difficultyToolboxSaveAll component.';
-    var difficultyToolboxLoadMetadata:Null<Button> = toolbox.findComponent('difficultyToolboxLoadMetadata', Button);
-    if (difficultyToolboxLoadMetadata == null)
-      throw 'ChartEditorToolboxHandler.buildToolboxDifficultyLayout() - Could not find difficultyToolboxLoadMetadata component.';
-    var difficultyToolboxLoadChart:Null<Button> = toolbox.findComponent('difficultyToolboxLoadChart', Button);
-    if (difficultyToolboxLoadChart == null)
-      throw 'ChartEditorToolboxHandler.buildToolboxDifficultyLayout() - Could not find difficultyToolboxLoadChart component.';
-
-    difficultyToolboxAddVariation.onClick = function(_:UIEvent) {
-      state.openAddVariationDialog(true);
-    };
-
-    difficultyToolboxAddDifficulty.onClick = function(_:UIEvent) {
-      state.openAddDifficultyDialog(true);
-    };
-
-    difficultyToolboxSaveMetadata.onClick = function(_:UIEvent) {
-      var vari:String = state.selectedVariation != Constants.DEFAULT_VARIATION ? '-${state.selectedVariation}' : '';
-      FileUtil.writeFileReference('${state.currentSongId}$vari-metadata.json', state.currentSongMetadata.serialize());
-    };
-
-    difficultyToolboxSaveChart.onClick = function(_:UIEvent) {
-      var vari:String = state.selectedVariation != Constants.DEFAULT_VARIATION ? '-${state.selectedVariation}' : '';
-      FileUtil.writeFileReference('${state.currentSongId}$vari-chart.json', state.currentSongChartData.serialize());
-    };
-
-    difficultyToolboxLoadMetadata.onClick = function(_:UIEvent) {
-      // Replace metadata for current variation.
-      SongSerializer.importSongMetadataAsync(function(songMetadata) {
-        state.currentSongMetadata = songMetadata;
-      });
-    };
-
-    difficultyToolboxLoadChart.onClick = function(_:UIEvent) {
-      // Replace chart data for current variation.
-      SongSerializer.importSongChartDataAsync(function(songChartData) {
-        state.currentSongChartData = songChartData;
-        state.noteDisplayDirty = true;
-      });
-    };
-
-    state.difficultySelectDirty = true;
-
     return toolbox;
   }
 
-  static function onShowToolboxDifficulty(state:ChartEditorState, toolbox:CollapsibleDialog):Void
-  {
-    // Update the selected difficulty when reopening the toolbox.
-    var treeView:Null<TreeView> = toolbox.findComponent('difficultyToolboxTree');
-    if (treeView == null) return;
-
-    var current = state.getCurrentTreeDifficultyNode(treeView);
-    if (current == null) return;
-    treeView.selectedNode = current;
-    trace('selected node: ${treeView.selectedNode}');
-  }
-
-  static function onHideToolboxDifficulty(state:ChartEditorState, toolbox:CollapsibleDialog):Void {}
-
   static function buildToolboxMetadataLayout(state:ChartEditorState):Null<ChartEditorBaseToolbox>
   {
     var toolbox:ChartEditorBaseToolbox = ChartEditorMetadataToolbox.build(state);
@@ -587,7 +379,32 @@ class ChartEditorToolboxHandler
     return toolbox;
   }
 
-  static function onHideToolboxMetadata(state:ChartEditorState, toolbox:CollapsibleDialog):Void {}
+  static function buildToolboxOffsetsLayout(state:ChartEditorState):Null<ChartEditorBaseToolbox>
+  {
+    var toolbox:ChartEditorBaseToolbox = ChartEditorOffsetsToolbox.build(state);
+
+    if (toolbox == null) return null;
+
+    return toolbox;
+  }
+
+  static function buildToolboxFreeplayLayout(state:ChartEditorState):Null<ChartEditorBaseToolbox>
+  {
+    var toolbox:ChartEditorBaseToolbox = ChartEditorFreeplayToolbox.build(state);
+
+    if (toolbox == null) return null;
+
+    return toolbox;
+  }
+
+  static function buildToolboxEventDataLayout(state:ChartEditorState):Null<ChartEditorBaseToolbox>
+  {
+    var toolbox:ChartEditorBaseToolbox = ChartEditorEventDataToolbox.build(state);
+
+    if (toolbox == null) return null;
+
+    return toolbox;
+  }
 
   static function buildToolboxPlayerPreviewLayout(state:ChartEditorState):Null<CollapsibleDialog>
   {
diff --git a/source/funkin/ui/debug/charting/import.hx b/source/funkin/ui/debug/charting/import.hx
index 933eaa3a5..b0569e3bb 100644
--- a/source/funkin/ui/debug/charting/import.hx
+++ b/source/funkin/ui/debug/charting/import.hx
@@ -3,6 +3,7 @@ package funkin.ui.debug.charting;
 #if !macro
 // Apply handlers so they can be called as though they were functions in ChartEditorState
 using funkin.ui.debug.charting.handlers.ChartEditorAudioHandler;
+using funkin.ui.debug.charting.handlers.ChartEditorContextMenuHandler;
 using funkin.ui.debug.charting.handlers.ChartEditorDialogHandler;
 using funkin.ui.debug.charting.handlers.ChartEditorImportExportHandler;
 using funkin.ui.debug.charting.handlers.ChartEditorNotificationHandler;
diff --git a/source/funkin/ui/debug/charting/toolboxes/ChartEditorDifficultyToolbox.hx b/source/funkin/ui/debug/charting/toolboxes/ChartEditorDifficultyToolbox.hx
new file mode 100644
index 000000000..1163c1b96
--- /dev/null
+++ b/source/funkin/ui/debug/charting/toolboxes/ChartEditorDifficultyToolbox.hx
@@ -0,0 +1,239 @@
+package funkin.ui.debug.charting.toolboxes;
+
+import funkin.play.character.BaseCharacter.CharacterType;
+import funkin.play.character.CharacterData;
+import funkin.data.stage.StageData;
+import funkin.data.stage.StageRegistry;
+import funkin.ui.debug.charting.commands.ChangeStartingBPMCommand;
+import funkin.ui.debug.charting.util.ChartEditorDropdowns;
+import haxe.ui.components.Button;
+import haxe.ui.components.CheckBox;
+import haxe.ui.containers.dialogs.Dialogs;
+import haxe.ui.containers.dialogs.Dialog.DialogButton;
+import funkin.data.song.SongData.SongMetadata;
+import haxe.ui.components.DropDown;
+import haxe.ui.components.HorizontalSlider;
+import funkin.util.FileUtil;
+import haxe.ui.containers.dialogs.MessageBox.MessageBoxType;
+import funkin.play.song.SongSerializer;
+import haxe.ui.components.Label;
+import haxe.ui.components.NumberStepper;
+import haxe.ui.components.Slider;
+import haxe.ui.components.TextField;
+import funkin.play.stage.Stage;
+import haxe.ui.containers.Box;
+import haxe.ui.containers.TreeView;
+import haxe.ui.containers.TreeViewNode;
+import haxe.ui.containers.Frame;
+import haxe.ui.events.UIEvent;
+
+/**
+ * The toolbox which allows viewing the list of difficulties, switching to a specific one,
+ * and adding/removing variations and difficulties.
+ */
+// @:nullSafety // TODO: Fix null safety when used with HaxeUI build macros.
+@:access(funkin.ui.debug.charting.ChartEditorState)
+@:build(haxe.ui.ComponentBuilder.build("assets/exclude/data/ui/chart-editor/toolboxes/difficulty.xml"))
+class ChartEditorDifficultyToolbox extends ChartEditorBaseToolbox
+{
+  var difficultyToolboxTree:TreeView;
+  var difficultyToolboxAddVariation:Button;
+  var difficultyToolboxAddDifficulty:Button;
+  var difficultyToolboxRemoveDifficulty:Button;
+  var difficultyToolboxSaveMetadata:Button;
+  var difficultyToolboxSaveChart:Button;
+  var difficultyToolboxLoadMetadata:Button;
+  var difficultyToolboxLoadChart:Button;
+
+  public function new(chartEditorState2:ChartEditorState)
+  {
+    super(chartEditorState2);
+
+    initialize();
+
+    this.onDialogClosed = onClose;
+  }
+
+  function onClose(event:UIEvent)
+  {
+    chartEditorState.menubarItemToggleToolboxDifficulty.selected = false;
+  }
+
+  function initialize():Void
+  {
+    // Starting position.
+    // TODO: Save and load this.
+    this.x = 150;
+    this.y = 250;
+
+    difficultyToolboxAddVariation.onClick = function(_:UIEvent) {
+      chartEditorState.openAddVariationDialog(true);
+    };
+
+    difficultyToolboxAddDifficulty.onClick = function(_:UIEvent) {
+      chartEditorState.openAddDifficultyDialog(true);
+    };
+
+    difficultyToolboxRemoveDifficulty.onClick = function(_:UIEvent) {
+      var currentVariation:String = chartEditorState.selectedVariation;
+      var currentDifficulty:String = chartEditorState.selectedDifficulty;
+
+      trace('Removing difficulty "$currentVariation:$currentDifficulty"');
+
+      var callback = (button) -> {
+        switch (button)
+        {
+          case DialogButton.YES:
+            // Remove the difficulty.
+            chartEditorState.removeDifficulty(currentVariation, currentDifficulty);
+            refresh();
+          case DialogButton.NO: // Do nothing.
+          default: // Do nothing.
+        }
+      }
+
+      Dialogs.messageBox("Are you sure? This cannot be undone.", "Remove Difficulty", MessageBoxType.TYPE_YESNO, callback);
+    };
+
+    difficultyToolboxSaveMetadata.onClick = function(_:UIEvent) {
+      var vari:String = chartEditorState.selectedVariation != Constants.DEFAULT_VARIATION ? '-${chartEditorState.selectedVariation}' : '';
+      FileUtil.writeFileReference('${chartEditorState.currentSongId}$vari-metadata.json', chartEditorState.currentSongMetadata.serialize());
+    };
+
+    difficultyToolboxSaveChart.onClick = function(_:UIEvent) {
+      var vari:String = chartEditorState.selectedVariation != Constants.DEFAULT_VARIATION ? '-${chartEditorState.selectedVariation}' : '';
+      FileUtil.writeFileReference('${chartEditorState.currentSongId}$vari-chart.json', chartEditorState.currentSongChartData.serialize());
+    };
+
+    difficultyToolboxLoadMetadata.onClick = function(_:UIEvent) {
+      // Replace metadata for current variation.
+      SongSerializer.importSongMetadataAsync(function(songMetadata) {
+        chartEditorState.currentSongMetadata = songMetadata;
+      });
+    };
+
+    difficultyToolboxLoadChart.onClick = function(_:UIEvent) {
+      // Replace chart data for current variation.
+      SongSerializer.importSongChartDataAsync(function(songChartData) {
+        chartEditorState.currentSongChartData = songChartData;
+        chartEditorState.noteDisplayDirty = true;
+      });
+    };
+
+    refresh();
+  }
+
+  /**
+   * Clear the tree view and rebuild it with the current song metadata (variation and difficulty list).
+   */
+  public function updateTree():Void
+  {
+    // Clear the tree view so we can rebuild it.
+    difficultyToolboxTree.clearNodes();
+
+    // , icon: 'haxeui-core/styles/default/haxeui_tiny.png'
+    var treeSong:TreeViewNode = difficultyToolboxTree.addNode({id: 'stv_song', text: 'S: ${chartEditorState.currentSongName}'});
+    treeSong.expanded = true;
+
+    for (curVariation in chartEditorState.availableVariations)
+    {
+      var variationMetadata:Null<SongMetadata> = chartEditorState.songMetadata.get(curVariation);
+      if (variationMetadata == null) continue;
+
+      var treeVariation:TreeViewNode = treeSong.addNode(
+        {
+          id: 'stv_variation_$curVariation',
+          text: 'V: ${curVariation.toTitleCase()}'
+        });
+      treeVariation.expanded = true;
+
+      var difficultyList:Array<String> = variationMetadata.playData.difficulties;
+
+      for (difficulty in difficultyList)
+      {
+        var _treeDifficulty:TreeViewNode = treeVariation.addNode(
+          {
+            id: 'stv_difficulty_${curVariation}_$difficulty',
+            text: 'D: ${difficulty.toTitleCase()}'
+          });
+      }
+    }
+
+    difficultyToolboxTree.onChange = onTreeChange;
+    refreshTreeSelection();
+  }
+
+  /**
+   * Set the selected item in the tree to the current variation/difficulty.
+   *
+   * @param targetNode The node to select. If null, the current variation/difficulty will be used.
+   */
+  public function refreshTreeSelection():Void
+  {
+    var targetNode = getCurrentTreeNode();
+    if (targetNode != null) difficultyToolboxTree.selectedNode = targetNode;
+  }
+
+  /**
+   * Get the node in the tree representing the current variation/difficulty.
+   */
+  function getCurrentTreeNode():TreeViewNode
+  {
+    return
+      difficultyToolboxTree.findNodeByPath('stv_song/stv_variation_$chartEditorState.selectedVariation/stv_difficulty_${chartEditorState.selectedVariation}_$chartEditorState.selectedDifficulty',
+      'id');
+  }
+
+  /**
+   * Called when an item in the tree is selected. Updates the current variation/difficulty.
+   */
+  function onTreeChange(event:UIEvent):Void
+  {
+    // Get the newly selected node.
+    var treeView:TreeView = cast event.target;
+    var targetNode:TreeViewNode = difficultyToolboxTree.selectedNode;
+
+    if (targetNode == null)
+    {
+      trace('No target node!');
+      // Reset the user's selection.
+      refreshTreeSelection();
+      return;
+    }
+
+    switch (targetNode.data.id.split('_')[1])
+    {
+      case 'difficulty':
+        var variation:String = targetNode.data.id.split('_')[2];
+        var difficulty:String = targetNode.data.id.split('_')[3];
+
+        if (variation != null && difficulty != null)
+        {
+          trace('Changing difficulty to "$variation:$difficulty"');
+          chartEditorState.selectedVariation = variation;
+          chartEditorState.selectedDifficulty = difficulty;
+          chartEditorState.refreshToolbox(ChartEditorState.CHART_EDITOR_TOOLBOX_METADATA_LAYOUT);
+          refreshTreeSelection();
+        }
+      // case 'song':
+      // case 'variation':
+      default:
+        // Reset the user's selection.
+        trace('Selected wrong node type, resetting selection.');
+        refreshTreeSelection();
+        chartEditorState.refreshToolbox(ChartEditorState.CHART_EDITOR_TOOLBOX_METADATA_LAYOUT);
+    }
+  }
+
+  public override function refresh():Void
+  {
+    super.refresh();
+
+    refreshTreeSelection();
+  }
+
+  public static function build(chartEditorState:ChartEditorState):ChartEditorDifficultyToolbox
+  {
+    return new ChartEditorDifficultyToolbox(chartEditorState);
+  }
+}
diff --git a/source/funkin/ui/debug/charting/toolboxes/ChartEditorEventDataToolbox.hx b/source/funkin/ui/debug/charting/toolboxes/ChartEditorEventDataToolbox.hx
new file mode 100644
index 000000000..7b163ad3d
--- /dev/null
+++ b/source/funkin/ui/debug/charting/toolboxes/ChartEditorEventDataToolbox.hx
@@ -0,0 +1,273 @@
+package funkin.ui.debug.charting.toolboxes;
+
+import funkin.play.character.BaseCharacter.CharacterType;
+import funkin.play.character.CharacterData;
+import funkin.data.stage.StageData;
+import funkin.play.event.SongEvent;
+import funkin.data.event.SongEventSchema;
+import funkin.ui.debug.charting.commands.ChangeStartingBPMCommand;
+import funkin.ui.debug.charting.util.ChartEditorDropdowns;
+import haxe.ui.components.Button;
+import haxe.ui.components.CheckBox;
+import haxe.ui.components.DropDown;
+import haxe.ui.components.HorizontalSlider;
+import haxe.ui.components.Label;
+import haxe.ui.components.NumberStepper;
+import haxe.ui.components.Slider;
+import haxe.ui.core.Component;
+import funkin.data.event.SongEventRegistry;
+import haxe.ui.components.TextField;
+import haxe.ui.containers.Box;
+import haxe.ui.containers.HBox;
+import haxe.ui.containers.Frame;
+import haxe.ui.events.UIEvent;
+import haxe.ui.data.ArrayDataSource;
+import haxe.ui.containers.Grid;
+import haxe.ui.components.DropDown;
+import haxe.ui.containers.Frame;
+
+/**
+ * The toolbox which allows modifying information like Song Title, Scroll Speed, Characters/Stages, and starting BPM.
+ */
+// @:nullSafety // TODO: Fix null safety when used with HaxeUI build macros.
+@:access(funkin.ui.debug.charting.ChartEditorState)
+@:build(haxe.ui.ComponentBuilder.build("assets/exclude/data/ui/chart-editor/toolboxes/event-data.xml"))
+class ChartEditorEventDataToolbox extends ChartEditorBaseToolbox
+{
+  var toolboxEventsEventKind:DropDown;
+  var toolboxEventsDataFrame:Frame;
+  var toolboxEventsDataGrid:Grid;
+
+  var _initializing:Bool = true;
+
+  public function new(chartEditorState2:ChartEditorState)
+  {
+    super(chartEditorState2);
+
+    initialize();
+
+    this.onDialogClosed = onClose;
+
+    this._initializing = false;
+  }
+
+  function onClose(event:UIEvent)
+  {
+    chartEditorState.menubarItemToggleToolboxEventData.selected = false;
+  }
+
+  function initialize():Void
+  {
+    toolboxEventsEventKind.dataSource = new ArrayDataSource();
+
+    var songEvents:Array<SongEvent> = SongEventRegistry.listEvents();
+
+    for (event in songEvents)
+    {
+      toolboxEventsEventKind.dataSource.add({text: event.getTitle(), value: event.id});
+    }
+
+    toolboxEventsEventKind.onChange = function(event:UIEvent) {
+      var eventType:String = event.data.value;
+
+      trace('ChartEditorToolboxHandler.buildToolboxEventDataLayout() - Event type changed: $eventType');
+
+      // Edit the event data to place.
+      chartEditorState.eventKindToPlace = eventType;
+
+      var schema:SongEventSchema = SongEventRegistry.getEventSchema(eventType);
+
+      if (schema == null)
+      {
+        trace('ChartEditorToolboxHandler.buildToolboxEventDataLayout() - Unknown event kind: $eventType');
+        return;
+      }
+
+      buildEventDataFormFromSchema(toolboxEventsDataGrid, schema);
+
+      if (!_initializing && chartEditorState.currentEventSelection.length > 0)
+      {
+        // Edit the event data of any selected events.
+        for (event in chartEditorState.currentEventSelection)
+        {
+          event.event = chartEditorState.eventKindToPlace;
+          event.value = chartEditorState.eventDataToPlace;
+        }
+        chartEditorState.saveDataDirty = true;
+        chartEditorState.noteDisplayDirty = true;
+        chartEditorState.notePreviewDirty = true;
+      }
+    }
+    toolboxEventsEventKind.value = chartEditorState.eventKindToPlace;
+  }
+
+  public override function refresh():Void
+  {
+    super.refresh();
+
+    toolboxEventsEventKind.value = chartEditorState.eventKindToPlace;
+
+    for (pair in chartEditorState.eventDataToPlace.keyValueIterator())
+    {
+      var fieldId:String = pair.key;
+      var value:Null<Dynamic> = pair.value;
+
+      var field:Component = toolboxEventsDataGrid.findComponent(fieldId);
+
+      if (field == null)
+      {
+        throw 'ChartEditorToolboxHandler.refresh() - Field "${fieldId}" does not exist in the event data form.';
+      }
+      else
+      {
+        switch (field)
+        {
+          case Std.isOfType(_, NumberStepper) => true:
+            var numberStepper:NumberStepper = cast field;
+            numberStepper.value = value;
+          case Std.isOfType(_, CheckBox) => true:
+            var checkBox:CheckBox = cast field;
+            checkBox.selected = value;
+          case Std.isOfType(_, DropDown) => true:
+            var dropDown:DropDown = cast field;
+            dropDown.value = value;
+          case Std.isOfType(_, TextField) => true:
+            var textField:TextField = cast field;
+            textField.text = value;
+          default:
+            throw 'ChartEditorToolboxHandler.refresh() - Field "${fieldId}" is of unknown type "${Type.getClassName(Type.getClass(field))}".';
+        }
+      }
+    }
+  }
+
+  function buildEventDataFormFromSchema(target:Box, schema:SongEventSchema):Void
+  {
+    trace(schema);
+    // Clear the frame.
+    target.removeAllComponents();
+
+    chartEditorState.eventDataToPlace = {};
+
+    for (field in schema)
+    {
+      if (field == null) continue;
+
+      // Add a label for the data field.
+      var label:Label = new Label();
+      label.text = field.title;
+      label.verticalAlign = "center";
+      target.addComponent(label);
+
+      // Add an input field for the data field.
+      var input:Component;
+      switch (field.type)
+      {
+        case INTEGER:
+          var numberStepper:NumberStepper = new NumberStepper();
+          numberStepper.id = field.name;
+          numberStepper.step = field.step ?? 1.0;
+          numberStepper.min = field.min ?? 0.0;
+          numberStepper.max = field.max ?? 10.0;
+          if (field.defaultValue != null) numberStepper.value = field.defaultValue;
+          input = numberStepper;
+        case FLOAT:
+          var numberStepper:NumberStepper = new NumberStepper();
+          numberStepper.id = field.name;
+          numberStepper.step = field.step ?? 0.1;
+          if (field.min != null) numberStepper.min = field.min;
+          if (field.max != null) numberStepper.max = field.max;
+          if (field.defaultValue != null) numberStepper.value = field.defaultValue;
+          input = numberStepper;
+        case BOOL:
+          var checkBox:CheckBox = new CheckBox();
+          checkBox.id = field.name;
+          if (field.defaultValue != null) checkBox.selected = field.defaultValue;
+          input = checkBox;
+        case ENUM:
+          var dropDown:DropDown = new DropDown();
+          dropDown.id = field.name;
+          dropDown.width = 200.0;
+          dropDown.dataSource = new ArrayDataSource();
+
+          if (field.keys == null) throw 'Field "${field.name}" is of Enum type but has no keys.';
+
+          // Add entries to the dropdown.
+
+          for (optionName in field.keys.keys())
+          {
+            var optionValue:Null<Dynamic> = field.keys.get(optionName);
+            trace('$optionName : $optionValue');
+            dropDown.dataSource.add({value: optionValue, text: optionName});
+          }
+
+          dropDown.value = field.defaultValue;
+
+          input = dropDown;
+        case STRING:
+          input = new TextField();
+          input.id = field.name;
+          if (field.defaultValue != null) input.text = field.defaultValue;
+        default:
+          // Unknown type. Display a label that proclaims the type so we can debug it.
+          input = new Label();
+          input.id = field.name;
+          input.text = field.type;
+      }
+
+      // Putting in a box so we can add a unit label easily if there is one.
+      var inputBox:HBox = new HBox();
+      inputBox.addComponent(input);
+
+      // Add a unit label if applicable.
+      if (field.units != null && field.units != "")
+      {
+        var units:Label = new Label();
+        units.text = field.units;
+        units.verticalAlign = "center";
+        inputBox.addComponent(units);
+      }
+
+      target.addComponent(inputBox);
+
+      // Update the value of the event data.
+      input.onChange = function(event:UIEvent) {
+        var value = event.target.value;
+        if (field.type == ENUM)
+        {
+          value = event.target.value.value;
+        }
+
+        trace('ChartEditorToolboxHandler.buildEventDataFormFromSchema() - ${event.target.id} = ${value}');
+
+        // Edit the event data to place.
+        if (value == null)
+        {
+          chartEditorState.eventDataToPlace.remove(event.target.id);
+        }
+        else
+        {
+          chartEditorState.eventDataToPlace.set(event.target.id, value);
+        }
+
+        // Edit the event data of any existing events.
+        if (!_initializing && chartEditorState.currentEventSelection.length > 0)
+        {
+          for (event in chartEditorState.currentEventSelection)
+          {
+            event.event = chartEditorState.eventKindToPlace;
+            event.value = chartEditorState.eventDataToPlace;
+          }
+          chartEditorState.saveDataDirty = true;
+          chartEditorState.noteDisplayDirty = true;
+          chartEditorState.notePreviewDirty = true;
+        }
+      }
+    }
+  }
+
+  public static function build(chartEditorState:ChartEditorState):ChartEditorEventDataToolbox
+  {
+    return new ChartEditorEventDataToolbox(chartEditorState);
+  }
+}
diff --git a/source/funkin/ui/debug/charting/toolboxes/ChartEditorFreeplayToolbox.hx b/source/funkin/ui/debug/charting/toolboxes/ChartEditorFreeplayToolbox.hx
new file mode 100644
index 000000000..8d3554a08
--- /dev/null
+++ b/source/funkin/ui/debug/charting/toolboxes/ChartEditorFreeplayToolbox.hx
@@ -0,0 +1,693 @@
+package funkin.ui.debug.charting.toolboxes;
+
+import funkin.audio.SoundGroup;
+import haxe.ui.components.Button;
+import haxe.ui.components.HorizontalSlider;
+import haxe.ui.components.Label;
+import flixel.addons.display.FlxTiledSprite;
+import flixel.math.FlxMath;
+import haxe.ui.components.NumberStepper;
+import haxe.ui.components.Slider;
+import haxe.ui.backend.flixel.components.SpriteWrapper;
+import funkin.ui.debug.charting.commands.SetFreeplayPreviewCommand;
+import funkin.ui.haxeui.components.WaveformPlayer;
+import funkin.audio.waveform.WaveformDataParser;
+import haxe.ui.containers.VBox;
+import haxe.ui.containers.Absolute;
+import haxe.ui.containers.ScrollView;
+import funkin.ui.freeplay.FreeplayState;
+import haxe.ui.containers.Frame;
+import haxe.ui.core.Screen;
+import haxe.ui.events.DragEvent;
+import haxe.ui.events.MouseEvent;
+import haxe.ui.events.UIEvent;
+
+/**
+ * The toolbox which allows modifying information like Song Title, Scroll Speed, Characters/Stages, and starting BPM.
+ */
+// @:nullSafety // TODO: Fix null safety when used with HaxeUI build macros.
+@:access(funkin.ui.debug.charting.ChartEditorState)
+@:build(haxe.ui.ComponentBuilder.build("assets/exclude/data/ui/chart-editor/toolboxes/freeplay.xml"))
+class ChartEditorFreeplayToolbox extends ChartEditorBaseToolbox
+{
+  var waveformContainer:Absolute;
+  var waveformScrollview:ScrollView;
+  var waveformMusic:WaveformPlayer;
+  var freeplayButtonZoomIn:Button;
+  var freeplayButtonZoomOut:Button;
+  var freeplayButtonPause:Button;
+  var freeplayButtonPlay:Button;
+  var freeplayButtonStop:Button;
+  var freeplayPreviewStart:NumberStepper;
+  var freeplayPreviewEnd:NumberStepper;
+  var freeplayTicksContainer:Absolute;
+  var playheadSprite:SpriteWrapper;
+  var previewSelectionSprite:SpriteWrapper;
+
+  static final TICK_LABEL_X_OFFSET:Float = 4.0;
+
+  static final PLAYHEAD_RIGHT_PAD:Float = 10.0;
+
+  static final BASE_SCALE:Float = 64.0;
+  static final STARTING_SCALE:Float = 1024.0;
+  static final MIN_SCALE:Float = 4.0;
+  static final WAVEFORM_ZOOM_MULT:Float = 1.5;
+
+  static final MAGIC_SCALE_BASE_TIME:Float = 5.0;
+
+  var waveformScale:Float = STARTING_SCALE;
+
+  var playheadAbsolutePos(get, set):Float;
+
+  function get_playheadAbsolutePos():Float
+  {
+    return playheadSprite.left;
+  }
+
+  function set_playheadAbsolutePos(value:Float):Float
+  {
+    return playheadSprite.left = value;
+  }
+
+  var playheadRelativePos(get, set):Float;
+
+  function get_playheadRelativePos():Float
+  {
+    return playheadSprite.left - waveformScrollview.hscrollPos;
+  }
+
+  function set_playheadRelativePos(value:Float):Float
+  {
+    return playheadSprite.left = waveformScrollview.hscrollPos + value;
+  }
+
+  var previewBoxStartPosAbsolute(get, set):Float;
+
+  function get_previewBoxStartPosAbsolute():Float
+  {
+    return previewSelectionSprite.left;
+  }
+
+  function set_previewBoxStartPosAbsolute(value:Float):Float
+  {
+    return previewSelectionSprite.left = value;
+  }
+
+  var previewBoxEndPosAbsolute(get, set):Float;
+
+  function get_previewBoxEndPosAbsolute():Float
+  {
+    return previewSelectionSprite.left + previewSelectionSprite.width;
+  }
+
+  function set_previewBoxEndPosAbsolute(value:Float):Float
+  {
+    if (value < previewBoxStartPosAbsolute) return previewSelectionSprite.left = previewBoxStartPosAbsolute;
+    return previewSelectionSprite.width = value - previewBoxStartPosAbsolute;
+  }
+
+  var previewBoxStartPosRelative(get, set):Float;
+
+  function get_previewBoxStartPosRelative():Float
+  {
+    return previewSelectionSprite.left - waveformScrollview.hscrollPos;
+  }
+
+  function set_previewBoxStartPosRelative(value:Float):Float
+  {
+    return previewSelectionSprite.left = waveformScrollview.hscrollPos + value;
+  }
+
+  var previewBoxEndPosRelative(get, set):Float;
+
+  function get_previewBoxEndPosRelative():Float
+  {
+    return previewSelectionSprite.left + previewSelectionSprite.width - waveformScrollview.hscrollPos;
+  }
+
+  function set_previewBoxEndPosRelative(value:Float):Float
+  {
+    if (value < previewBoxStartPosRelative) return previewSelectionSprite.left = previewBoxStartPosRelative;
+    return previewSelectionSprite.width = value - previewBoxStartPosRelative;
+  }
+
+  /**
+   * The amount you need to multiply the zoom by such that, at the base zoom level, one tick is equal to `MAGIC_SCALE_BASE_TIME` seconds.
+   */
+  var waveformMagicFactor:Float = 1.0;
+
+  var audioPreviewTracks:SoundGroup;
+
+  var tickTiledSprite:FlxTiledSprite;
+
+  var freeplayPreviewVolume(get, null):Float;
+
+  function get_freeplayPreviewVolume():Float
+  {
+    return freeplayMusicVolume.value * 2 / 100;
+  }
+
+  var tickLabels:Array<Label> = [];
+
+  public function new(chartEditorState2:ChartEditorState)
+  {
+    super(chartEditorState2);
+
+    initialize();
+
+    this.onDialogClosed = onClose;
+  }
+
+  function onClose(event:UIEvent)
+  {
+    chartEditorState.menubarItemToggleToolboxFreeplay.selected = false;
+  }
+
+  function initialize():Void
+  {
+    // Starting position.
+    // TODO: Save and load this.
+    this.x = 150;
+    this.y = 250;
+
+    freeplayMusicVolume.onChange = (_) -> {
+      setTrackVolume(freeplayPreviewVolume);
+    };
+    freeplayMusicMute.onClick = (_) -> {
+      toggleMuteTrack();
+    };
+    freeplayButtonZoomIn.onClick = (_) -> {
+      zoomWaveformIn();
+    };
+    freeplayButtonZoomOut.onClick = (_) -> {
+      zoomWaveformOut();
+    };
+    freeplayButtonPause.onClick = (_) -> {
+      pauseAudioPreview();
+    };
+    freeplayButtonPlay.onClick = (_) -> {
+      playAudioPreview();
+    };
+    freeplayButtonStop.onClick = (_) -> {
+      stopAudioPreview();
+    };
+    testPreview.onClick = (_) -> {
+      performPreview();
+    };
+    freeplayPreviewStart.onChange = (event:UIEvent) -> {
+      if (event.value == chartEditorState.currentSongFreeplayPreviewStart) return;
+      if (waveformDragStartPos != null) return; // The values are changing because we are dragging the preview.
+
+      chartEditorState.performCommand(new SetFreeplayPreviewCommand(event.value, null));
+      refresh();
+    }
+    freeplayPreviewEnd.onChange = (event:UIEvent) -> {
+      if (event.value == chartEditorState.currentSongFreeplayPreviewEnd) return;
+      if (waveformDragStartPos != null) return; // The values are changing because we are dragging the preview.
+
+      chartEditorState.performCommand(new SetFreeplayPreviewCommand(null, event.value));
+      refresh();
+    }
+    waveformScrollview.onScroll = (_) -> {
+      if (!audioPreviewTracks.playing)
+      {
+        // Move the playhead if it would go out of view.
+        var prevPlayheadRelativePos = playheadRelativePos;
+        playheadRelativePos = FlxMath.bound(playheadRelativePos, 0, waveformScrollview.width - PLAYHEAD_RIGHT_PAD);
+        trace('newPos: ${playheadRelativePos}');
+        var diff = playheadRelativePos - prevPlayheadRelativePos;
+
+        if (diff != 0)
+        {
+          // We have to change the song time to match the playhead position when we move it.
+          var currentWaveformIndex:Int = Std.int(playheadAbsolutePos * (waveformScale / BASE_SCALE * waveformMagicFactor));
+          var targetSongTimeSeconds:Float = waveformMusic.waveform.waveformData.indexToSeconds(currentWaveformIndex);
+          audioPreviewTracks.time = targetSongTimeSeconds * Constants.MS_PER_SEC;
+        }
+
+        addOffsetsToAudioPreview();
+      }
+      else
+      {
+        // The scrollview probably changed because the song position changed.
+        // If we try to move the song now it will glitch.
+      }
+
+      // Either way, clipRect has changed, so we need to refresh the waveforms.
+      refresh();
+    };
+
+    initializeTicks();
+
+    refreshAudioPreview();
+    refresh();
+    refreshTicks();
+
+    waveformMusic.registerEvent(MouseEvent.MOUSE_DOWN, (_) -> {
+      onStartDragWaveform();
+    });
+
+    freeplayTicksContainer.registerEvent(MouseEvent.MOUSE_DOWN, (_) -> {
+      onStartDragPlayhead();
+    });
+  }
+
+  function initializeTicks():Void
+  {
+    tickTiledSprite = new FlxTiledSprite(chartEditorState.offsetTickBitmap, 100, chartEditorState.offsetTickBitmap.height, true, false);
+    freeplayTicksSprite.sprite = tickTiledSprite;
+    tickTiledSprite.width = 5000;
+  }
+
+  /**
+   * Pull the audio tracks from the chart editor state and create copies of them to play in the Offsets Toolbox.
+   * These must be DEEP CLONES or else the editor will affect the audio preview!
+   */
+  public function refreshAudioPreview():Void
+  {
+    if (audioPreviewTracks == null)
+    {
+      audioPreviewTracks = new SoundGroup();
+      // Make sure audioPreviewTracks (and all its children) receives update() calls.
+      chartEditorState.add(audioPreviewTracks);
+    }
+    else
+    {
+      audioPreviewTracks.stop();
+      audioPreviewTracks.clear();
+    }
+
+    var instTrack = chartEditorState.audioInstTrack.clone();
+    audioPreviewTracks.add(instTrack);
+
+    var playerVoice = chartEditorState.audioVocalTrackGroup.getPlayerVoice();
+    if (playerVoice != null) audioPreviewTracks.add(playerVoice.clone());
+
+    var opponentVoice = chartEditorState.audioVocalTrackGroup.getOpponentVoice();
+    if (opponentVoice != null) audioPreviewTracks.add(opponentVoice.clone());
+
+    // Build player waveform.
+    // waveformMusic.waveform.forceUpdate = true;
+    var perfStart = haxe.Timer.stamp();
+    var waveformData1 = playerVoice.waveformData;
+    var waveformData2 = opponentVoice.waveformData;
+    var waveformData3 = chartEditorState.audioInstTrack.waveformData;
+    var waveformData = waveformData1.merge(waveformData2).merge(waveformData3);
+    trace('Waveform data merging took: ${haxe.Timer.stamp() - perfStart} seconds');
+
+    waveformMusic.waveform.waveformData = waveformData;
+    // Set the width and duration to render the full waveform, with the clipRect applied we only render a segment of it.
+    waveformMusic.waveform.duration = instTrack.length / Constants.MS_PER_SEC;
+
+    addOffsetsToAudioPreview();
+  }
+
+  public function refreshTicks():Void
+  {
+    while (tickLabels.length > 0)
+    {
+      var label = tickLabels.pop();
+      freeplayTicksContainer.removeComponent(label);
+    }
+
+    var labelYPos:Float = chartEditorState.offsetTickBitmap.height / 2;
+    var labelHeight:Float = chartEditorState.offsetTickBitmap.height / 2;
+
+    var numberOfTicks:Int = Math.floor(waveformMusic.waveform.width / chartEditorState.offsetTickBitmap.width * 2) + 1;
+
+    for (index in 0...numberOfTicks)
+    {
+      var tickPos = chartEditorState.offsetTickBitmap.width / 2 * index;
+      var tickTime = tickPos * (waveformScale / BASE_SCALE * waveformMagicFactor) / waveformMusic.waveform.waveformData.pointsPerSecond();
+
+      var tickLabel:Label = new Label();
+      tickLabel.text = formatTime(tickTime);
+      tickLabel.styleNames = "offset-ticks-label";
+      tickLabel.height = labelHeight;
+      // Positioning within offsetTicksContainer is absolute (relative to the container itself).
+      tickLabel.top = labelYPos;
+      tickLabel.left = tickPos + TICK_LABEL_X_OFFSET;
+
+      freeplayTicksContainer.addComponent(tickLabel);
+      tickLabels.push(tickLabel);
+    }
+  }
+
+  function formatTime(seconds:Float):String
+  {
+    if (seconds <= 0) return "0.0";
+
+    var integerSeconds = Math.floor(seconds);
+    var decimalSeconds = Math.floor((seconds - integerSeconds) * 10);
+
+    if (integerSeconds < 60)
+    {
+      return '${integerSeconds}.${decimalSeconds}';
+    }
+    else
+    {
+      var integerMinutes = Math.floor(integerSeconds / 60);
+      var remainingSeconds = integerSeconds % 60;
+      var remainingSecondsPad:String = remainingSeconds < 10 ? '0$remainingSeconds' : '$remainingSeconds';
+
+      return '${integerMinutes}:${remainingSecondsPad}${decimalSeconds > 0 ? '.$decimalSeconds' : ''}';
+    }
+  }
+
+  function buildTickLabel():Void {}
+
+  public function onStartDragPlayhead():Void
+  {
+    Screen.instance.registerEvent(MouseEvent.MOUSE_MOVE, onDragPlayhead);
+    Screen.instance.registerEvent(MouseEvent.MOUSE_UP, onStopDragPlayhead);
+
+    movePlayheadToMouse();
+  }
+
+  public function onDragPlayhead(event:MouseEvent):Void
+  {
+    movePlayheadToMouse();
+  }
+
+  public function onStopDragPlayhead(event:MouseEvent):Void
+  {
+    // Stop dragging.
+    Screen.instance.unregisterEvent(MouseEvent.MOUSE_MOVE, onDragPlayhead);
+    Screen.instance.unregisterEvent(MouseEvent.MOUSE_UP, onStopDragPlayhead);
+  }
+
+  function movePlayheadToMouse():Void
+  {
+    // Determine the position of the mouse relative to the
+    var mouseXPos = FlxG.mouse.x;
+
+    var relativeMouseXPos = mouseXPos - waveformScrollview.screenX;
+    var targetPlayheadPos = relativeMouseXPos + waveformScrollview.hscrollPos;
+
+    // Move the playhead to the mouse position.
+    playheadAbsolutePos = targetPlayheadPos;
+
+    // Move the audio preview to the playhead position.
+    var currentWaveformIndex:Int = Std.int(playheadAbsolutePos * (waveformScale / BASE_SCALE * waveformMagicFactor));
+    var targetSongTimeSeconds:Float = waveformMusic.waveform.waveformData.indexToSeconds(currentWaveformIndex);
+    audioPreviewTracks.time = targetSongTimeSeconds * Constants.MS_PER_SEC;
+  }
+
+  var waveformDragStartPos:Null<Float> = null;
+
+  var waveformDragPreviewStartPos:Float;
+  var waveformDragPreviewEndPos:Float;
+
+  public function onStartDragWaveform():Void
+  {
+    waveformDragStartPos = FlxG.mouse.x;
+
+    Screen.instance.registerEvent(MouseEvent.MOUSE_MOVE, onDragWaveform);
+    Screen.instance.registerEvent(MouseEvent.MOUSE_UP, onStopDragWaveform);
+  }
+
+  public function onDragWaveform(event:MouseEvent):Void
+  {
+    // Set waveformDragPreviewStartPos and waveformDragPreviewEndPos to the position the drag started and the current mouse position.
+    // This only affects the visuals.
+
+    var currentAbsMousePos = FlxG.mouse.x;
+    var dragDiff = currentAbsMousePos - waveformDragStartPos;
+
+    var currentRelativeMousePos = currentAbsMousePos - waveformScrollview.screenX;
+    var relativeStartPos = waveformDragStartPos - waveformScrollview.screenX;
+
+    var isDraggingRight = dragDiff > 0;
+    var hasDraggedEnough = Math.abs(dragDiff) > 10;
+
+    if (hasDraggedEnough)
+    {
+      if (isDraggingRight)
+      {
+        waveformDragPreviewStartPos = relativeStartPos;
+        waveformDragPreviewEndPos = currentRelativeMousePos;
+      }
+      else
+      {
+        waveformDragPreviewStartPos = currentRelativeMousePos;
+        waveformDragPreviewEndPos = relativeStartPos;
+      }
+    }
+
+    refresh();
+  }
+
+  public function onStopDragWaveform(event:MouseEvent):Void
+  {
+    Screen.instance.unregisterEvent(MouseEvent.MOUSE_MOVE, onDragWaveform);
+    Screen.instance.unregisterEvent(MouseEvent.MOUSE_UP, onStopDragWaveform);
+
+    var previewStartPosAbsolute = waveformDragPreviewStartPos + waveformScrollview.hscrollPos;
+    var previewStartPosIndex:Int = Std.int(previewStartPosAbsolute * (waveformScale / BASE_SCALE * waveformMagicFactor));
+    var previewStartPosMs:Int = Std.int(waveformMusic.waveform.waveformData.indexToSeconds(previewStartPosIndex) * Constants.MS_PER_SEC);
+
+    var previewEndPosAbsolute = waveformDragPreviewEndPos + waveformScrollview.hscrollPos;
+    var previewEndPosIndex:Int = Std.int(previewEndPosAbsolute * (waveformScale / BASE_SCALE * waveformMagicFactor));
+    var previewEndPosMs:Int = Std.int(waveformMusic.waveform.waveformData.indexToSeconds(previewEndPosIndex) * Constants.MS_PER_SEC);
+
+    chartEditorState.performCommand(new SetFreeplayPreviewCommand(previewStartPosMs, previewEndPosMs));
+
+    waveformDragStartPos = null;
+    waveformDragPreviewStartPos = 0;
+    waveformDragPreviewEndPos = 0;
+
+    refresh();
+    addOffsetsToAudioPreview();
+  }
+
+  public function playAudioPreview():Void
+  {
+    if (isPerformingPreview) stopPerformingPreview();
+
+    audioPreviewTracks.volume = freeplayPreviewVolume;
+    audioPreviewTracks.play(false, audioPreviewTracks.time);
+  }
+
+  public function addOffsetsToAudioPreview():Void
+  {
+    var trackInst = audioPreviewTracks.members[0];
+    if (trackInst != null)
+    {
+      trackInst.time -= chartEditorState.currentInstrumentalOffset;
+    }
+
+    var trackPlayer = audioPreviewTracks.members[1];
+    if (trackPlayer != null)
+    {
+      trackPlayer.time -= chartEditorState.currentVocalOffsetPlayer;
+    }
+
+    var trackOpponent = audioPreviewTracks.members[2];
+    if (trackOpponent != null)
+    {
+      trackOpponent.time -= chartEditorState.currentVocalOffsetOpponent;
+    }
+  }
+
+  public function pauseAudioPreview():Void
+  {
+    if (isPerformingPreview) stopPerformingPreview();
+
+    audioPreviewTracks.pause();
+  }
+
+  public function stopAudioPreview():Void
+  {
+    if (isPerformingPreview) stopPerformingPreview();
+
+    audioPreviewTracks.stop();
+
+    audioPreviewTracks.time = 0;
+
+    waveformScrollview.hscrollPos = 0;
+    playheadAbsolutePos = 0 + playheadSprite.width;
+    refresh();
+    addOffsetsToAudioPreview();
+  }
+
+  public function zoomWaveformIn():Void
+  {
+    if (isPerformingPreview) stopPerformingPreview();
+
+    if (waveformScale > MIN_SCALE)
+    {
+      waveformScale = waveformScale / WAVEFORM_ZOOM_MULT;
+      if (waveformScale < MIN_SCALE) waveformScale = MIN_SCALE;
+
+      trace('Zooming in, scale: ${waveformScale}');
+
+      // Update the playhead too!
+      playheadAbsolutePos = playheadAbsolutePos * WAVEFORM_ZOOM_MULT;
+
+      // Recenter the scroll view on the playhead.
+      var vaguelyCenterPlayheadOffset = waveformScrollview.width / 8;
+      waveformScrollview.hscrollPos = playheadAbsolutePos - vaguelyCenterPlayheadOffset;
+
+      refresh();
+      refreshTicks();
+    }
+    else
+    {
+      waveformScale = MIN_SCALE;
+    }
+  }
+
+  public function zoomWaveformOut():Void
+  {
+    waveformScale = waveformScale * WAVEFORM_ZOOM_MULT;
+    if (waveformScale < MIN_SCALE) waveformScale = MIN_SCALE;
+
+    trace('Zooming out, scale: ${waveformScale}');
+
+    // Update the playhead too!
+    playheadAbsolutePos = playheadAbsolutePos / WAVEFORM_ZOOM_MULT;
+
+    // Recenter the scroll view on the playhead.
+    var vaguelyCenterPlayheadOffset = waveformScrollview.width / 8;
+    waveformScrollview.hscrollPos = playheadAbsolutePos - vaguelyCenterPlayheadOffset;
+
+    refresh();
+    refreshTicks();
+  }
+
+  public function setTrackVolume(volume:Float):Void
+  {
+    audioPreviewTracks.volume = volume;
+  }
+
+  public function muteTrack():Void
+  {
+    audioPreviewTracks.muted = true;
+  }
+
+  public function unmuteTrack():Void
+  {
+    audioPreviewTracks.muted = false;
+  }
+
+  public function toggleMuteTrack():Void
+  {
+    audioPreviewTracks.muted = !audioPreviewTracks.muted;
+  }
+
+  var isPerformingPreview:Bool = false;
+  var isFadingOutPreview:Bool = false;
+
+  public function performPreview():Void
+  {
+    isPerformingPreview = true;
+    isFadingOutPreview = false;
+    audioPreviewTracks.play(true, chartEditorState.currentSongFreeplayPreviewStart);
+    audioPreviewTracks.fadeIn(FreeplayState.FADE_IN_DURATION, FreeplayState.FADE_IN_START_VOLUME * freeplayPreviewVolume,
+      FreeplayState.FADE_IN_END_VOLUME * freeplayPreviewVolume, null);
+  }
+
+  public function stopPerformingPreview():Void
+  {
+    isPerformingPreview = false;
+    isFadingOutPreview = false;
+    audioPreviewTracks.volume = freeplayPreviewVolume;
+    audioPreviewTracks.pause();
+  }
+
+  public override function update(elapsed:Float)
+  {
+    super.update(elapsed);
+
+    if (isPerformingPreview && !audioPreviewTracks.playing)
+    {
+      stopPerformingPreview();
+    }
+
+    if (isPerformingPreview && audioPreviewTracks.playing)
+    {
+      var startFadeOutTime = chartEditorState.currentSongFreeplayPreviewEnd - (FreeplayState.FADE_OUT_DURATION * Constants.MS_PER_SEC);
+      trace('startFadeOutTime: ${audioPreviewTracks.time} >= ${startFadeOutTime}');
+      if (!isFadingOutPreview && audioPreviewTracks.time >= startFadeOutTime)
+      {
+        isFadingOutPreview = true;
+        audioPreviewTracks.fadeOut(FreeplayState.FADE_OUT_DURATION, FreeplayState.FADE_OUT_END_VOLUME * freeplayPreviewVolume, (_) -> {
+          trace('Stop performing preview! ${audioPreviewTracks.time}');
+          stopPerformingPreview();
+        });
+      }
+    }
+
+    if (audioPreviewTracks.playing)
+    {
+      var targetScrollPos:Float = waveformMusic.waveform.waveformData.secondsToIndex(audioPreviewTracks.time / Constants.MS_PER_SEC) / (waveformScale / BASE_SCALE * waveformMagicFactor);
+      // waveformScrollview.hscrollPos = targetScrollPos;
+      var prevPlayheadAbsolutePos = playheadAbsolutePos;
+      playheadAbsolutePos = targetScrollPos;
+      var playheadDiff = playheadAbsolutePos - prevPlayheadAbsolutePos;
+
+      // BEHAVIOR C.
+      // Copy Audacity!
+      // If the playhead is out of view, jump forward or backward by one screen width until it's in view.
+      if (playheadAbsolutePos < waveformScrollview.hscrollPos)
+      {
+        waveformScrollview.hscrollPos -= waveformScrollview.width;
+      }
+      if (playheadAbsolutePos > waveformScrollview.hscrollPos + waveformScrollview.width)
+      {
+        waveformScrollview.hscrollPos += waveformScrollview.width;
+      }
+    }
+    freeplayLabelTime.text = formatTime(audioPreviewTracks.time / Constants.MS_PER_SEC);
+    if (waveformDragStartPos != null && (waveformDragPreviewStartPos > 0 && waveformDragPreviewEndPos > 0))
+    {
+      var previewStartPosAbsolute = waveformDragPreviewStartPos + waveformScrollview.hscrollPos;
+      var previewStartPosIndex:Int = Std.int(previewStartPosAbsolute * (waveformScale / BASE_SCALE * waveformMagicFactor));
+      var previewStartPosMs:Int = Std.int(waveformMusic.waveform.waveformData.indexToSeconds(previewStartPosIndex) * Constants.MS_PER_SEC);
+
+      var previewEndPosAbsolute = waveformDragPreviewEndPos + waveformScrollview.hscrollPos;
+      var previewEndPosIndex:Int = Std.int(previewEndPosAbsolute * (waveformScale / BASE_SCALE * waveformMagicFactor));
+      var previewEndPosMs:Int = Std.int(waveformMusic.waveform.waveformData.indexToSeconds(previewEndPosIndex) * Constants.MS_PER_SEC);
+
+      // Set the values in milliseconds.
+      freeplayPreviewStart.value = previewStartPosMs;
+      freeplayPreviewEnd.value = previewEndPosMs;
+
+      previewBoxStartPosAbsolute = previewStartPosAbsolute;
+      previewBoxEndPosAbsolute = previewEndPosAbsolute;
+    }
+    else
+    {
+      previewBoxStartPosAbsolute = waveformMusic.waveform.waveformData.secondsToIndex(chartEditorState.currentSongFreeplayPreviewStart / Constants.MS_PER_SEC) / (waveformScale / BASE_SCALE * waveformMagicFactor);
+      previewBoxEndPosAbsolute = waveformMusic.waveform.waveformData.secondsToIndex(chartEditorState.currentSongFreeplayPreviewEnd / Constants.MS_PER_SEC) / (waveformScale / BASE_SCALE * waveformMagicFactor);
+
+      freeplayPreviewStart.value = chartEditorState.currentSongFreeplayPreviewStart;
+      freeplayPreviewEnd.value = chartEditorState.currentSongFreeplayPreviewEnd;
+    }
+  }
+
+  public override function refresh():Void
+  {
+    super.refresh();
+
+    waveformMagicFactor = MAGIC_SCALE_BASE_TIME / (chartEditorState.offsetTickBitmap.width / waveformMusic.waveform.waveformData.pointsPerSecond());
+
+    var currentZoomFactor = waveformScale / BASE_SCALE * waveformMagicFactor;
+
+    var maxWidth:Int = -1;
+
+    waveformMusic.waveform.time = -chartEditorState.currentInstrumentalOffset / Constants.MS_PER_SEC;
+    waveformMusic.waveform.width = (waveformMusic.waveform.waveformData?.length ?? 1000) / currentZoomFactor;
+    if (waveformMusic.waveform.width > maxWidth) maxWidth = Std.int(waveformMusic.waveform.width);
+    waveformMusic.waveform.height = 65;
+    waveformMusic.waveform.markDirty();
+
+    waveformContainer.width = maxWidth;
+    tickTiledSprite.width = maxWidth;
+  }
+
+  public static function build(chartEditorState:ChartEditorState):ChartEditorFreeplayToolbox
+  {
+    return new ChartEditorFreeplayToolbox(chartEditorState);
+  }
+}
diff --git a/source/funkin/ui/debug/charting/toolboxes/ChartEditorMetadataToolbox.hx b/source/funkin/ui/debug/charting/toolboxes/ChartEditorMetadataToolbox.hx
index 509aa5b07..5d8c25bae 100644
--- a/source/funkin/ui/debug/charting/toolboxes/ChartEditorMetadataToolbox.hx
+++ b/source/funkin/ui/debug/charting/toolboxes/ChartEditorMetadataToolbox.hx
@@ -2,7 +2,8 @@ package funkin.ui.debug.charting.toolboxes;
 
 import funkin.play.character.BaseCharacter.CharacterType;
 import funkin.play.character.CharacterData;
-import funkin.play.stage.StageData;
+import funkin.data.stage.StageData;
+import funkin.data.stage.StageRegistry;
 import funkin.ui.debug.charting.commands.ChangeStartingBPMCommand;
 import funkin.ui.debug.charting.util.ChartEditorDropdowns;
 import haxe.ui.components.Button;
@@ -13,6 +14,7 @@ import haxe.ui.components.Label;
 import haxe.ui.components.NumberStepper;
 import haxe.ui.components.Slider;
 import haxe.ui.components.TextField;
+import funkin.play.stage.Stage;
 import haxe.ui.containers.Box;
 import haxe.ui.containers.Frame;
 import haxe.ui.events.UIEvent;
@@ -33,8 +35,6 @@ class ChartEditorMetadataToolbox extends ChartEditorBaseToolbox
   var buttonCharacterGirlfriend:Button;
   var buttonCharacterOpponent:Button;
   var inputBPM:NumberStepper;
-  var inputOffsetInst:NumberStepper;
-  var inputOffsetVocal:NumberStepper;
   var labelScrollSpeed:Label;
   var inputScrollSpeed:Slider;
   var frameVariation:Frame;
@@ -116,20 +116,26 @@ class ChartEditorMetadataToolbox extends ChartEditorBaseToolbox
       }
     };
 
-    inputOffsetInst.onChange = function(event:UIEvent) {
-      if (event.value == null) return;
+    inputTimeSignature.onChange = function(event:UIEvent) {
+      var timeSignatureStr:String = event.data.text;
+      var timeSignature = timeSignatureStr.split('/');
+      if (timeSignature.length != 2) return;
 
-      chartEditorState.currentInstrumentalOffset = event.value;
-      Conductor.instrumentalOffset = event.value;
-      // Update song length.
-      chartEditorState.songLengthInMs = (chartEditorState.audioInstTrack?.length ?? 1000.0) + Conductor.instrumentalOffset;
+      var timeSignatureNum:Int = Std.parseInt(timeSignature[0]);
+      var timeSignatureDen:Int = Std.parseInt(timeSignature[1]);
+
+      var previousTimeSignatureNum:Int = chartEditorState.currentSongMetadata.timeChanges[0].timeSignatureNum;
+      var previousTimeSignatureDen:Int = chartEditorState.currentSongMetadata.timeChanges[0].timeSignatureDen;
+      if (timeSignatureNum == previousTimeSignatureNum && timeSignatureDen == previousTimeSignatureDen) return;
+
+      chartEditorState.currentSongMetadata.timeChanges[0].timeSignatureNum = timeSignatureNum;
+      chartEditorState.currentSongMetadata.timeChanges[0].timeSignatureDen = timeSignatureDen;
+
+      trace('Time signature changed to ${timeSignatureNum}/${timeSignatureDen}');
+
+      chartEditorState.updateTimeSignature();
     };
 
-    inputOffsetVocal.onChange = function(event:UIEvent) {
-      if (event.value == null) return;
-
-      chartEditorState.currentSongMetadata.offsets.setVocalOffset(chartEditorState.currentSongMetadata.playData.characters.player, event.value);
-    };
     inputScrollSpeed.onChange = function(event:UIEvent) {
       var valid:Bool = event.target.value != null && event.target.value > 0;
 
@@ -162,6 +168,8 @@ class ChartEditorMetadataToolbox extends ChartEditorBaseToolbox
 
   public override function refresh():Void
   {
+    super.refresh();
+
     inputSongName.value = chartEditorState.currentSongMetadata.songName;
     inputSongArtist.value = chartEditorState.currentSongMetadata.artist;
     inputStage.value = chartEditorState.currentSongMetadata.playData.stage;
@@ -172,28 +180,56 @@ class ChartEditorMetadataToolbox extends ChartEditorBaseToolbox
     frameVariation.text = 'Variation: ${chartEditorState.selectedVariation.toTitleCase()}';
     frameDifficulty.text = 'Difficulty: ${chartEditorState.selectedDifficulty.toTitleCase()}';
 
+    var currentTimeSignature = '${chartEditorState.currentSongMetadata.timeChanges[0].timeSignatureNum}/${chartEditorState.currentSongMetadata.timeChanges[0].timeSignatureDen}';
+    trace('Setting time signature to ${currentTimeSignature}');
+    inputTimeSignature.value = {id: currentTimeSignature, text: currentTimeSignature};
+
     var stageId:String = chartEditorState.currentSongMetadata.playData.stage;
-    var stageData:Null<StageData> = StageDataParser.parseStageData(stageId);
+    var stage:Null<Stage> = StageRegistry.instance.fetchEntry(stageId);
     if (inputStage != null)
     {
-      inputStage.value = (stageData != null) ?
-        {id: stageId, text: stageData.name} :
+      inputStage.value = (stage != null) ?
+        {id: stage.id, text: stage.stageName} :
           {id: "mainStage", text: "Main Stage"};
     }
 
     var LIMIT = 6;
 
-    var charDataOpponent:CharacterData = CharacterDataParser.fetchCharacterData(chartEditorState.currentSongMetadata.playData.characters.opponent);
-    buttonCharacterOpponent.icon = CharacterDataParser.getCharPixelIconAsset(chartEditorState.currentSongMetadata.playData.characters.opponent);
-    buttonCharacterOpponent.text = charDataOpponent.name.length > LIMIT ? '${charDataOpponent.name.substr(0, LIMIT)}.' : '${charDataOpponent.name}';
+    var charDataOpponent:Null<CharacterData> = CharacterDataParser.fetchCharacterData(chartEditorState.currentSongMetadata.playData.characters.opponent);
+    if (charDataOpponent != null)
+    {
+      buttonCharacterOpponent.icon = CharacterDataParser.getCharPixelIconAsset(chartEditorState.currentSongMetadata.playData.characters.opponent);
+      buttonCharacterOpponent.text = charDataOpponent.name.length > LIMIT ? '${charDataOpponent.name.substr(0, LIMIT)}.' : '${charDataOpponent.name}';
+    }
+    else
+    {
+      buttonCharacterOpponent.icon = null;
+      buttonCharacterOpponent.text = "None";
+    }
 
-    var charDataGirlfriend:CharacterData = CharacterDataParser.fetchCharacterData(chartEditorState.currentSongMetadata.playData.characters.girlfriend);
-    buttonCharacterGirlfriend.icon = CharacterDataParser.getCharPixelIconAsset(chartEditorState.currentSongMetadata.playData.characters.girlfriend);
-    buttonCharacterGirlfriend.text = charDataGirlfriend.name.length > LIMIT ? '${charDataGirlfriend.name.substr(0, LIMIT)}.' : '${charDataGirlfriend.name}';
+    var charDataGirlfriend:Null<CharacterData> = CharacterDataParser.fetchCharacterData(chartEditorState.currentSongMetadata.playData.characters.girlfriend);
+    if (charDataGirlfriend != null)
+    {
+      buttonCharacterGirlfriend.icon = CharacterDataParser.getCharPixelIconAsset(chartEditorState.currentSongMetadata.playData.characters.girlfriend);
+      buttonCharacterGirlfriend.text = charDataGirlfriend.name.length > LIMIT ? '${charDataGirlfriend.name.substr(0, LIMIT)}.' : '${charDataGirlfriend.name}';
+    }
+    else
+    {
+      buttonCharacterGirlfriend.icon = null;
+      buttonCharacterGirlfriend.text = "None";
+    }
 
-    var charDataPlayer:CharacterData = CharacterDataParser.fetchCharacterData(chartEditorState.currentSongMetadata.playData.characters.player);
-    buttonCharacterPlayer.icon = CharacterDataParser.getCharPixelIconAsset(chartEditorState.currentSongMetadata.playData.characters.player);
-    buttonCharacterPlayer.text = charDataPlayer.name.length > LIMIT ? '${charDataPlayer.name.substr(0, LIMIT)}.' : '${charDataPlayer.name}';
+    var charDataPlayer:Null<CharacterData> = CharacterDataParser.fetchCharacterData(chartEditorState.currentSongMetadata.playData.characters.player);
+    if (charDataPlayer != null)
+    {
+      buttonCharacterPlayer.icon = CharacterDataParser.getCharPixelIconAsset(chartEditorState.currentSongMetadata.playData.characters.player);
+      buttonCharacterPlayer.text = charDataPlayer.name.length > LIMIT ? '${charDataPlayer.name.substr(0, LIMIT)}.' : '${charDataPlayer.name}';
+    }
+    else
+    {
+      buttonCharacterPlayer.icon = null;
+      buttonCharacterPlayer.text = "None";
+    }
   }
 
   public static function build(chartEditorState:ChartEditorState):ChartEditorMetadataToolbox
diff --git a/source/funkin/ui/debug/charting/toolboxes/ChartEditorOffsetsToolbox.hx b/source/funkin/ui/debug/charting/toolboxes/ChartEditorOffsetsToolbox.hx
new file mode 100644
index 000000000..67ca82b1b
--- /dev/null
+++ b/source/funkin/ui/debug/charting/toolboxes/ChartEditorOffsetsToolbox.hx
@@ -0,0 +1,845 @@
+package funkin.ui.debug.charting.toolboxes;
+
+import funkin.audio.SoundGroup;
+import haxe.ui.components.Button;
+import haxe.ui.components.HorizontalSlider;
+import haxe.ui.components.Label;
+import flixel.addons.display.FlxTiledSprite;
+import flixel.math.FlxMath;
+import haxe.ui.components.NumberStepper;
+import haxe.ui.components.Slider;
+import haxe.ui.backend.flixel.components.SpriteWrapper;
+import funkin.ui.debug.charting.commands.SetAudioOffsetCommand;
+import funkin.ui.haxeui.components.WaveformPlayer;
+import funkin.audio.waveform.WaveformDataParser;
+import haxe.ui.containers.VBox;
+import haxe.ui.containers.Absolute;
+import haxe.ui.containers.ScrollView;
+import haxe.ui.containers.Frame;
+import haxe.ui.core.Screen;
+import haxe.ui.events.DragEvent;
+import haxe.ui.events.MouseEvent;
+import haxe.ui.events.UIEvent;
+
+/**
+ * The toolbox which allows modifying information like Song Title, Scroll Speed, Characters/Stages, and starting BPM.
+ */
+// @:nullSafety // TODO: Fix null safety when used with HaxeUI build macros.
+
+@:access(funkin.ui.debug.charting.ChartEditorState)
+@:build(haxe.ui.ComponentBuilder.build("assets/exclude/data/ui/chart-editor/toolboxes/offsets.xml"))
+class ChartEditorOffsetsToolbox extends ChartEditorBaseToolbox
+{
+  var waveformContainer:Absolute;
+  var waveformScrollview:ScrollView;
+  var waveformPlayer:WaveformPlayer;
+  var waveformOpponent:WaveformPlayer;
+  var waveformInstrumental:WaveformPlayer;
+  var offsetButtonZoomIn:Button;
+  var offsetButtonZoomOut:Button;
+  var offsetButtonPause:Button;
+  var offsetButtonPlay:Button;
+  var offsetButtonStop:Button;
+  var offsetStepperPlayer:NumberStepper;
+  var offsetStepperOpponent:NumberStepper;
+  var offsetStepperInstrumental:NumberStepper;
+  var offsetTicksContainer:Absolute;
+  var playheadSprite:SpriteWrapper;
+
+  static final TICK_LABEL_X_OFFSET:Float = 4.0;
+
+  static final PLAYHEAD_RIGHT_PAD:Float = 10.0;
+
+  static final BASE_SCALE:Float = 64.0;
+  static final MIN_SCALE:Float = 4.0;
+  static final WAVEFORM_ZOOM_MULT:Float = 1.5;
+
+  static final MAGIC_SCALE_BASE_TIME:Float = 5.0;
+
+  var waveformScale:Float = BASE_SCALE;
+
+  var playheadAbsolutePos(get, set):Float;
+
+  function get_playheadAbsolutePos():Float
+  {
+    return playheadSprite.left;
+  }
+
+  function set_playheadAbsolutePos(value:Float):Float
+  {
+    return playheadSprite.left = value;
+  }
+
+  var playheadRelativePos(get, set):Float;
+
+  function get_playheadRelativePos():Float
+  {
+    return playheadSprite.left - waveformScrollview.hscrollPos;
+  }
+
+  function set_playheadRelativePos(value:Float):Float
+  {
+    return playheadSprite.left = waveformScrollview.hscrollPos + value;
+  }
+
+  /**
+   * The amount you need to multiply the zoom by such that, at the base zoom level, one tick is equal to `MAGIC_SCALE_BASE_TIME` seconds.
+   */
+  var waveformMagicFactor:Float = 1.0;
+
+  var audioPreviewTracks:SoundGroup;
+
+  var tickTiledSprite:FlxTiledSprite;
+
+  var tickLabels:Array<Label> = [];
+
+  // Local store of the audio offsets, so we can detect when they change.
+  var audioPreviewPlayerOffset:Float = 0;
+  var audioPreviewOpponentOffset:Float = 0;
+  var audioPreviewInstrumentalOffset:Float = 0;
+
+  public function new(chartEditorState2:ChartEditorState)
+  {
+    super(chartEditorState2);
+
+    initialize();
+
+    this.onDialogClosed = onClose;
+  }
+
+  function onClose(event:UIEvent)
+  {
+    chartEditorState.menubarItemToggleToolboxOffsets.selected = false;
+  }
+
+  function initialize():Void
+  {
+    // Starting position.
+    // TODO: Save and load this.
+    this.x = 150;
+    this.y = 250;
+
+    offsetPlayerVolume.onChange = (_) -> {
+      var targetVolume = offsetPlayerVolume.value * 2 / 100;
+      setTrackVolume(PLAYER, targetVolume);
+    };
+    offsetPlayerMute.onClick = (_) -> {
+      toggleMuteTrack(PLAYER);
+    };
+    offsetPlayerSolo.onClick = (_) -> {
+      soloTrack(PLAYER);
+    };
+    offsetOpponentVolume.onChange = (_) -> {
+      var targetVolume = offsetOpponentVolume.value * 2 / 100;
+      setTrackVolume(OPPONENT, targetVolume);
+    };
+    offsetOpponentMute.onClick = (_) -> {
+      toggleMuteTrack(OPPONENT);
+    };
+    offsetOpponentSolo.onClick = (_) -> {
+      soloTrack(OPPONENT);
+    };
+    offsetInstrumentalVolume.onChange = (_) -> {
+      var targetVolume = offsetInstrumentalVolume.value * 2 / 100;
+      setTrackVolume(INSTRUMENTAL, targetVolume);
+    };
+    offsetInstrumentalMute.onClick = (_) -> {
+      toggleMuteTrack(INSTRUMENTAL);
+    };
+    offsetInstrumentalSolo.onClick = (_) -> {
+      soloTrack(INSTRUMENTAL);
+    };
+    offsetButtonZoomIn.onClick = (_) -> {
+      zoomWaveformIn();
+    };
+    offsetButtonZoomOut.onClick = (_) -> {
+      zoomWaveformOut();
+    };
+    offsetButtonPause.onClick = (_) -> {
+      pauseAudioPreview();
+    };
+    offsetButtonPlay.onClick = (_) -> {
+      playAudioPreview();
+    };
+    offsetButtonStop.onClick = (_) -> {
+      stopAudioPreview();
+    };
+    offsetStepperPlayer.onChange = (event:UIEvent) -> {
+      if (event.value == chartEditorState.currentVocalOffsetPlayer) return;
+      if (dragWaveform != null) return;
+
+      chartEditorState.performCommand(new SetAudioOffsetCommand(PLAYER, event.value));
+      refresh();
+    }
+    offsetStepperOpponent.onChange = (event:UIEvent) -> {
+      if (event.value == chartEditorState.currentVocalOffsetOpponent) return;
+      if (dragWaveform != null) return;
+
+      chartEditorState.performCommand(new SetAudioOffsetCommand(OPPONENT, event.value));
+      refresh();
+    }
+    offsetStepperInstrumental.onChange = (event:UIEvent) -> {
+      if (event.value == chartEditorState.currentInstrumentalOffset) return;
+      if (dragWaveform != null) return;
+
+      chartEditorState.performCommand(new SetAudioOffsetCommand(INSTRUMENTAL, event.value));
+      refresh();
+    }
+    waveformScrollview.onScroll = (_) -> {
+      if (!audioPreviewTracks.playing)
+      {
+        // Move the playhead if it would go out of view.
+        var prevPlayheadRelativePos = playheadRelativePos;
+        playheadRelativePos = FlxMath.bound(playheadRelativePos, 0, waveformScrollview.width - PLAYHEAD_RIGHT_PAD);
+        var diff = playheadRelativePos - prevPlayheadRelativePos;
+
+        if (diff != 0)
+        {
+          // We have to change the song time to match the playhead position when we move it.
+          var currentWaveformIndex:Int = Std.int(playheadAbsolutePos * (waveformScale / BASE_SCALE * waveformMagicFactor));
+          var targetSongTimeSeconds:Float = waveformPlayer.waveform.waveformData.indexToSeconds(currentWaveformIndex);
+          audioPreviewTracks.time = targetSongTimeSeconds * Constants.MS_PER_SEC;
+        }
+
+        addOffsetsToAudioPreview();
+      }
+      else
+      {
+        // The scrollview probably changed because the song position changed.
+        // If we try to move the song now it will glitch.
+      }
+
+      // Either way, clipRect has changed, so we need to refresh the waveforms.
+      refresh();
+    };
+
+    initializeTicks();
+
+    refreshAudioPreview();
+    refresh();
+    refreshTicks();
+
+    waveformPlayer.registerEvent(MouseEvent.MOUSE_DOWN, (_) -> {
+      onStartDragWaveform(PLAYER);
+    });
+    waveformOpponent.registerEvent(MouseEvent.MOUSE_DOWN, (_) -> {
+      onStartDragWaveform(OPPONENT);
+    });
+    waveformInstrumental.registerEvent(MouseEvent.MOUSE_DOWN, (_) -> {
+      onStartDragWaveform(INSTRUMENTAL);
+    });
+
+    offsetTicksContainer.registerEvent(MouseEvent.MOUSE_DOWN, (_) -> {
+      onStartDragPlayhead();
+    });
+  }
+
+  function initializeTicks():Void
+  {
+    tickTiledSprite = new FlxTiledSprite(chartEditorState.offsetTickBitmap, 100, chartEditorState.offsetTickBitmap.height, true, false);
+    offsetTicksSprite.sprite = tickTiledSprite;
+    tickTiledSprite.width = 5000;
+  }
+
+  /**
+   * Pull the audio tracks from the chart editor state and create copies of them to play in the Offsets Toolbox.
+   * These must be DEEP CLONES or else the editor will affect the audio preview!
+   */
+  public function refreshAudioPreview():Void
+  {
+    if (audioPreviewTracks == null)
+    {
+      audioPreviewTracks = new SoundGroup();
+      // Make sure audioPreviewTracks (and all its children) receives update() calls.
+      chartEditorState.add(audioPreviewTracks);
+    }
+    else
+    {
+      audioPreviewTracks.stop();
+      audioPreviewTracks.clear();
+    }
+
+    var instTrack = chartEditorState.audioInstTrack.clone();
+    audioPreviewTracks.add(instTrack);
+
+    var playerVoice = chartEditorState.audioVocalTrackGroup.getPlayerVoice();
+    if (playerVoice != null) audioPreviewTracks.add(playerVoice.clone());
+
+    var opponentVoice = chartEditorState.audioVocalTrackGroup.getOpponentVoice();
+    if (opponentVoice != null) audioPreviewTracks.add(opponentVoice.clone());
+
+    // Build player waveform.
+    // waveformPlayer.waveform.forceUpdate = true;
+    waveformPlayer.waveform.waveformData = playerVoice.waveformData;
+    // Set the width and duration to render the full waveform, with the clipRect applied we only render a segment of it.
+    waveformPlayer.waveform.duration = playerVoice.length / Constants.MS_PER_SEC;
+
+    // Build opponent waveform.
+    // waveformOpponent.waveform.forceUpdate = true;
+    waveformOpponent.waveform.waveformData = opponentVoice.waveformData;
+    waveformOpponent.waveform.duration = opponentVoice.length / Constants.MS_PER_SEC;
+
+    // Build instrumental waveform.
+    // waveformInstrumental.waveform.forceUpdate = true;
+    waveformInstrumental.waveform.waveformData = chartEditorState.audioInstTrack.waveformData;
+    waveformInstrumental.waveform.duration = instTrack.length / Constants.MS_PER_SEC;
+
+    addOffsetsToAudioPreview();
+  }
+
+  public function refreshTicks():Void
+  {
+    while (tickLabels.length > 0)
+    {
+      var label = tickLabels.pop();
+      offsetTicksContainer.removeComponent(label);
+    }
+
+    var labelYPos:Float = chartEditorState.offsetTickBitmap.height / 2;
+    var labelHeight:Float = chartEditorState.offsetTickBitmap.height / 2;
+
+    var numberOfTicks:Int = Math.floor(waveformInstrumental.waveform.width / chartEditorState.offsetTickBitmap.width * 2) + 1;
+
+    for (index in 0...numberOfTicks)
+    {
+      var tickPos = chartEditorState.offsetTickBitmap.width / 2 * index;
+      var tickTime = tickPos * (waveformScale / BASE_SCALE * waveformMagicFactor) / waveformInstrumental.waveform.waveformData.pointsPerSecond();
+
+      var tickLabel:Label = new Label();
+      tickLabel.text = formatTime(tickTime);
+      tickLabel.styleNames = "offset-ticks-label";
+      tickLabel.height = labelHeight;
+      // Positioning within offsetTicksContainer is absolute (relative to the container itself).
+      tickLabel.top = labelYPos;
+      tickLabel.left = tickPos + TICK_LABEL_X_OFFSET;
+
+      offsetTicksContainer.addComponent(tickLabel);
+      tickLabels.push(tickLabel);
+    }
+  }
+
+  function formatTime(seconds:Float):String
+  {
+    if (seconds <= 0) return "0.0";
+
+    var integerSeconds = Math.floor(seconds);
+    var decimalSeconds = Math.floor((seconds - integerSeconds) * 10);
+
+    if (integerSeconds < 60)
+    {
+      return '${integerSeconds}.${decimalSeconds}';
+    }
+    else
+    {
+      var integerMinutes = Math.floor(integerSeconds / 60);
+      var remainingSeconds = integerSeconds % 60;
+      var remainingSecondsPad:String = remainingSeconds < 10 ? '0$remainingSeconds' : '$remainingSeconds';
+
+      return '${integerMinutes}:${remainingSecondsPad}${decimalSeconds > 0 ? '.$decimalSeconds' : ''}';
+    }
+  }
+
+  function buildTickLabel():Void {}
+
+  public function onStartDragPlayhead():Void
+  {
+    Screen.instance.registerEvent(MouseEvent.MOUSE_MOVE, onDragPlayhead);
+    Screen.instance.registerEvent(MouseEvent.MOUSE_UP, onStopDragPlayhead);
+
+    movePlayheadToMouse();
+  }
+
+  public function onDragPlayhead(event:MouseEvent):Void
+  {
+    movePlayheadToMouse();
+  }
+
+  public function onStopDragPlayhead(event:MouseEvent):Void
+  {
+    // Stop dragging.
+    Screen.instance.unregisterEvent(MouseEvent.MOUSE_MOVE, onDragPlayhead);
+    Screen.instance.unregisterEvent(MouseEvent.MOUSE_UP, onStopDragPlayhead);
+  }
+
+  function movePlayheadToMouse():Void
+  {
+    // Determine the position of the mouse relative to the
+    var mouseXPos = FlxG.mouse.x;
+
+    var relativeMouseXPos = mouseXPos - waveformScrollview.screenX;
+    var targetPlayheadPos = relativeMouseXPos + waveformScrollview.hscrollPos;
+
+    // Move the playhead to the mouse position.
+    playheadAbsolutePos = targetPlayheadPos;
+
+    // Move the audio preview to the playhead position.
+    var currentWaveformIndex:Int = Std.int(playheadAbsolutePos * (waveformScale / BASE_SCALE * waveformMagicFactor));
+    var targetSongTimeSeconds:Float = waveformPlayer.waveform.waveformData.indexToSeconds(currentWaveformIndex);
+    audioPreviewTracks.time = targetSongTimeSeconds * Constants.MS_PER_SEC;
+  }
+
+  public function onStartDragWaveform(waveform:Waveform):Void
+  {
+    dragMousePosition = FlxG.mouse.x;
+    dragWaveform = waveform;
+
+    Screen.instance.registerEvent(MouseEvent.MOUSE_MOVE, onDragWaveform);
+    Screen.instance.registerEvent(MouseEvent.MOUSE_UP, onStopDragWaveform);
+  }
+
+  var dragMousePosition:Float = 0;
+  var dragWaveform:Waveform = null;
+  var dragOffsetMs:Float = 0;
+
+  public function onDragWaveform(event:MouseEvent):Void
+  {
+    var newDragMousePosition = FlxG.mouse.x;
+    var deltaMousePosition = newDragMousePosition - dragMousePosition;
+
+    if (deltaMousePosition == 0) return;
+
+    var deltaPixels:Float = deltaMousePosition * (waveformScale / BASE_SCALE * waveformMagicFactor);
+    var deltaMilliseconds:Float = switch (dragWaveform)
+    {
+      case PLAYER:
+        deltaPixels / waveformPlayer.waveform.waveformData.pointsPerSecond() * Constants.MS_PER_SEC;
+      case OPPONENT:
+        deltaPixels / waveformOpponent.waveform.waveformData.pointsPerSecond() * Constants.MS_PER_SEC;
+      case INSTRUMENTAL:
+        deltaPixels / waveformInstrumental.waveform.waveformData.pointsPerSecond() * Constants.MS_PER_SEC;
+    };
+
+    switch (dragWaveform)
+    {
+      case PLAYER:
+        // chartEditorState.currentVocalOffsetPlayer += deltaMilliseconds;
+        dragOffsetMs += deltaMilliseconds;
+        offsetStepperPlayer.value += deltaMilliseconds;
+      case OPPONENT:
+        // chartEditorState.currentVocalOffsetOpponent += deltaMilliseconds;
+        dragOffsetMs += deltaMilliseconds;
+        offsetStepperOpponent.value += deltaMilliseconds;
+      case INSTRUMENTAL:
+        // chartEditorState.currentInstrumentalOffset += deltaMilliseconds;
+        dragOffsetMs += deltaMilliseconds;
+        offsetStepperInstrumental.value += deltaMilliseconds;
+    }
+
+    dragMousePosition = newDragMousePosition;
+
+    refresh();
+  }
+
+  public function onStopDragWaveform(event:MouseEvent):Void
+  {
+    // Stop dragging.
+    Screen.instance.unregisterEvent(MouseEvent.MOUSE_MOVE, onDragWaveform);
+    Screen.instance.unregisterEvent(MouseEvent.MOUSE_UP, onStopDragWaveform);
+
+    // Apply the offset change after dragging happens.
+    // We only do this once per drag so we don't get 20 commands a second in the history.
+    if (dragOffsetMs != 0)
+    {
+      // false to not refresh this toolbox, we will manually do that later.
+      switch (dragWaveform)
+      {
+        case PLAYER:
+          chartEditorState.performCommand(new SetAudioOffsetCommand(PLAYER, chartEditorState.currentVocalOffsetPlayer + dragOffsetMs, false));
+        case OPPONENT:
+          chartEditorState.performCommand(new SetAudioOffsetCommand(OPPONENT, chartEditorState.currentVocalOffsetOpponent + dragOffsetMs, false));
+        case INSTRUMENTAL:
+          chartEditorState.performCommand(new SetAudioOffsetCommand(INSTRUMENTAL, chartEditorState.currentInstrumentalOffset + dragOffsetMs, false));
+      }
+    }
+
+    dragOffsetMs = 0;
+    dragMousePosition = 0;
+    dragWaveform = null;
+
+    refresh();
+    addOffsetsToAudioPreview();
+  }
+
+  public function playAudioPreview():Void
+  {
+    audioPreviewTracks.play(false, audioPreviewTracks.time);
+  }
+
+  public function addOffsetsToAudioPreview():Void
+  {
+    var trackInst = audioPreviewTracks.members[0];
+    if (trackInst != null)
+    {
+      audioPreviewInstrumentalOffset = chartEditorState.currentInstrumentalOffset;
+      trackInst.time -= audioPreviewInstrumentalOffset;
+    }
+
+    var trackPlayer = audioPreviewTracks.members[1];
+    if (trackPlayer != null)
+    {
+      audioPreviewPlayerOffset = chartEditorState.currentVocalOffsetPlayer;
+      trackPlayer.time -= audioPreviewPlayerOffset;
+    }
+
+    var trackOpponent = audioPreviewTracks.members[2];
+    if (trackOpponent != null)
+    {
+      audioPreviewOpponentOffset = chartEditorState.currentVocalOffsetOpponent;
+      trackOpponent.time -= audioPreviewOpponentOffset;
+    }
+  }
+
+  public function pauseAudioPreview():Void
+  {
+    audioPreviewTracks.pause();
+  }
+
+  public function stopAudioPreview():Void
+  {
+    audioPreviewTracks.stop();
+
+    audioPreviewTracks.time = 0;
+
+    var trackInst = audioPreviewTracks.members[0];
+    if (trackInst != null)
+    {
+      audioPreviewInstrumentalOffset = chartEditorState.currentInstrumentalOffset;
+      trackInst.time = -audioPreviewInstrumentalOffset;
+    }
+
+    var trackPlayer = audioPreviewTracks.members[1];
+    if (trackPlayer != null)
+    {
+      audioPreviewPlayerOffset = chartEditorState.currentVocalOffsetPlayer;
+      trackPlayer.time = -audioPreviewPlayerOffset;
+    }
+
+    var trackOpponent = audioPreviewTracks.members[2];
+    if (trackOpponent != null)
+    {
+      audioPreviewOpponentOffset = chartEditorState.currentVocalOffsetOpponent;
+      trackOpponent.time = -audioPreviewOpponentOffset;
+    }
+
+    waveformScrollview.hscrollPos = 0;
+    playheadAbsolutePos = 0 + playheadSprite.width;
+    refresh();
+    addOffsetsToAudioPreview();
+  }
+
+  public function zoomWaveformIn():Void
+  {
+    if (waveformScale > MIN_SCALE)
+    {
+      waveformScale = waveformScale / WAVEFORM_ZOOM_MULT;
+      if (waveformScale < MIN_SCALE) waveformScale = MIN_SCALE;
+
+      // Update the playhead too!
+      playheadAbsolutePos = playheadAbsolutePos * WAVEFORM_ZOOM_MULT;
+
+      // Recenter the scroll view on the playhead.
+      var vaguelyCenterPlayheadOffset = waveformScrollview.width / 8;
+      waveformScrollview.hscrollPos = playheadAbsolutePos - vaguelyCenterPlayheadOffset;
+
+      refresh();
+      refreshTicks();
+    }
+    else
+    {
+      waveformScale = MIN_SCALE;
+    }
+  }
+
+  public function zoomWaveformOut():Void
+  {
+    waveformScale = waveformScale * WAVEFORM_ZOOM_MULT;
+    if (waveformScale < MIN_SCALE) waveformScale = MIN_SCALE;
+
+    // Update the playhead too!
+    playheadAbsolutePos = playheadAbsolutePos / WAVEFORM_ZOOM_MULT;
+
+    // Recenter the scroll view on the playhead.
+    var vaguelyCenterPlayheadOffset = waveformScrollview.width / 8;
+    waveformScrollview.hscrollPos = playheadAbsolutePos - vaguelyCenterPlayheadOffset;
+
+    refresh();
+    refreshTicks();
+  }
+
+  public function setTrackVolume(target:Waveform, volume:Float):Void
+  {
+    switch (target)
+    {
+      case Waveform.INSTRUMENTAL:
+        var trackInst = audioPreviewTracks.members[0];
+        if (trackInst != null)
+        {
+          trackInst.volume = volume;
+        }
+      case Waveform.PLAYER:
+        var trackPlayer = audioPreviewTracks.members[1];
+        if (trackPlayer != null)
+        {
+          trackPlayer.volume = volume;
+        }
+      case Waveform.OPPONENT:
+        var trackOpponent = audioPreviewTracks.members[2];
+        if (trackOpponent != null)
+        {
+          trackOpponent.volume = volume;
+        }
+    }
+  }
+
+  public function muteTrack(target:Waveform):Void
+  {
+    switch (target)
+    {
+      case Waveform.INSTRUMENTAL:
+        var trackInst = audioPreviewTracks.members[0];
+        if (trackInst != null)
+        {
+          trackInst.muted = true;
+          offsetInstrumentalMute.text = trackInst.muted ? "Unmute" : "Mute";
+        }
+      case Waveform.PLAYER:
+        var trackPlayer = audioPreviewTracks.members[1];
+        if (trackPlayer != null)
+        {
+          trackPlayer.muted = true;
+          offsetPlayerMute.text = trackPlayer.muted ? "Unmute" : "Mute";
+        }
+      case Waveform.OPPONENT:
+        var trackOpponent = audioPreviewTracks.members[2];
+        if (trackOpponent != null)
+        {
+          trackOpponent.muted = true;
+          offsetOpponentMute.text = trackOpponent.muted ? "Unmute" : "Mute";
+        }
+    }
+  }
+
+  public function unmuteTrack(target:Waveform):Void
+  {
+    switch (target)
+    {
+      case Waveform.INSTRUMENTAL:
+        var trackInst = audioPreviewTracks.members[0];
+        if (trackInst != null)
+        {
+          trackInst.muted = false;
+          offsetInstrumentalMute.text = trackInst.muted ? "Unmute" : "Mute";
+        }
+      case Waveform.PLAYER:
+        var trackPlayer = audioPreviewTracks.members[1];
+        if (trackPlayer != null)
+        {
+          trackPlayer.muted = false;
+          offsetPlayerMute.text = trackPlayer.muted ? "Unmute" : "Mute";
+        }
+      case Waveform.OPPONENT:
+        var trackOpponent = audioPreviewTracks.members[2];
+        if (trackOpponent != null)
+        {
+          trackOpponent.muted = false;
+          offsetOpponentMute.text = trackOpponent.muted ? "Unmute" : "Mute";
+        }
+    }
+  }
+
+  public function toggleMuteTrack(target:Waveform):Void
+  {
+    switch (target)
+    {
+      case Waveform.INSTRUMENTAL:
+        var trackInst = audioPreviewTracks.members[0];
+        if (trackInst != null)
+        {
+          trackInst.muted = !trackInst.muted;
+          offsetInstrumentalMute.text = trackInst.muted ? "Unmute" : "Mute";
+        }
+      case Waveform.PLAYER:
+        var trackPlayer = audioPreviewTracks.members[1];
+        if (trackPlayer != null)
+        {
+          trackPlayer.muted = !trackPlayer.muted;
+          offsetPlayerMute.text = trackPlayer.muted ? "Unmute" : "Mute";
+        }
+      case Waveform.OPPONENT:
+        var trackOpponent = audioPreviewTracks.members[2];
+        if (trackOpponent != null)
+        {
+          trackOpponent.muted = !trackOpponent.muted;
+          offsetOpponentMute.text = trackOpponent.muted ? "Unmute" : "Mute";
+        }
+    }
+  }
+
+  /**
+   * Clicking the solo button will unmute the track and mute all other tracks.
+   * @param target
+   */
+  public function soloTrack(target:Waveform):Void
+  {
+    switch (target)
+    {
+      case Waveform.PLAYER:
+        muteTrack(Waveform.OPPONENT);
+        muteTrack(Waveform.INSTRUMENTAL);
+        unmuteTrack(Waveform.PLAYER);
+      case Waveform.OPPONENT:
+        muteTrack(Waveform.PLAYER);
+        muteTrack(Waveform.INSTRUMENTAL);
+        unmuteTrack(Waveform.OPPONENT);
+      case Waveform.INSTRUMENTAL:
+        muteTrack(Waveform.PLAYER);
+        muteTrack(Waveform.OPPONENT);
+        unmuteTrack(Waveform.INSTRUMENTAL);
+    }
+  }
+
+  public override function update(elapsed:Float)
+  {
+    super.update(elapsed);
+
+    if (audioPreviewTracks.playing)
+    {
+      trace('Playback time: ${audioPreviewTracks.time}');
+
+      var targetScrollPos:Float = waveformInstrumental.waveform.waveformData.secondsToIndex(audioPreviewTracks.time / Constants.MS_PER_SEC) / (waveformScale / BASE_SCALE * waveformMagicFactor);
+      // waveformScrollview.hscrollPos = targetScrollPos;
+      var prevPlayheadAbsolutePos = playheadAbsolutePos;
+      playheadAbsolutePos = targetScrollPos;
+      var playheadDiff = playheadAbsolutePos - prevPlayheadAbsolutePos;
+
+      // BEHAVIOR A.
+      // Just move the scroll view with the playhead, constraining it so that the playhead is always visible.
+      // waveformScrollview.hscrollPos += playheadDiff;
+      // waveformScrollview.hscrollPos = FlxMath.bound(waveformScrollview.hscrollPos, playheadAbsolutePos - playheadSprite.width, playheadAbsolutePos);
+
+      // BEHAVIOR B.
+      // Keep `playheadAbsolutePos` within the bounds of the screen.
+      // The scroll view will eventually move to where the playhead is 1/8th of the way from the left. This looks kinda nice!
+      // TODO: This causes a hard snap to scroll when the playhead is to the right of the playheadCenterPoint.
+      // var playheadCenterPoint = waveformScrollview.width / 8;
+      // waveformScrollview.hscrollPos = FlxMath.bound(waveformScrollview.hscrollPos, playheadAbsolutePos - playheadCenterPoint, playheadAbsolutePos);
+
+      // playheadRelativePos = 0;
+
+      // BEHAVIOR C.
+      // Copy Audacity!
+      // If the playhead is out of view, jump forward or backward by one screen width until it's in view.
+      if (playheadAbsolutePos < waveformScrollview.hscrollPos)
+      {
+        waveformScrollview.hscrollPos -= waveformScrollview.width;
+      }
+      if (playheadAbsolutePos > waveformScrollview.hscrollPos + waveformScrollview.width)
+      {
+        waveformScrollview.hscrollPos += waveformScrollview.width;
+      }
+    }
+
+    if (chartEditorState.currentInstrumentalOffset != audioPreviewInstrumentalOffset)
+    {
+      var track = audioPreviewTracks.members[0];
+      if (track != null)
+      {
+        track.time += audioPreviewInstrumentalOffset;
+        track.time -= chartEditorState.currentInstrumentalOffset;
+        audioPreviewInstrumentalOffset = chartEditorState.currentInstrumentalOffset;
+      }
+    }
+    if (chartEditorState.currentVocalOffsetPlayer != audioPreviewPlayerOffset)
+    {
+      var track = audioPreviewTracks.members[1];
+      if (track != null)
+      {
+        track.time += audioPreviewPlayerOffset;
+        track.time -= chartEditorState.currentVocalOffsetPlayer;
+        audioPreviewPlayerOffset = chartEditorState.currentVocalOffsetPlayer;
+      }
+    }
+    if (chartEditorState.currentVocalOffsetOpponent != audioPreviewOpponentOffset)
+    {
+      var track = audioPreviewTracks.members[2];
+      if (track != null)
+      {
+        track.time += audioPreviewOpponentOffset;
+        track.time -= chartEditorState.currentVocalOffsetOpponent;
+        audioPreviewOpponentOffset = chartEditorState.currentVocalOffsetOpponent;
+      }
+    }
+    offsetLabelTime.text = formatTime(audioPreviewTracks.time / Constants.MS_PER_SEC);
+    // Keep the playhead in view.
+    // playheadRelativePos = FlxMath.bound(playheadRelativePos, waveformScrollview.hscrollPos + 1,
+    //   Math.min(waveformScrollview.hscrollPos + waveformScrollview.width, waveformContainer.width));
+  }
+
+  public override function refresh():Void
+  {
+    super.refresh();
+
+    waveformMagicFactor = MAGIC_SCALE_BASE_TIME / (chartEditorState.offsetTickBitmap.width / waveformInstrumental.waveform.waveformData.pointsPerSecond());
+
+    var currentZoomFactor = waveformScale / BASE_SCALE * waveformMagicFactor;
+
+    var maxWidth:Int = -1;
+
+    offsetStepperPlayer.value = chartEditorState.currentVocalOffsetPlayer;
+    offsetStepperOpponent.value = chartEditorState.currentVocalOffsetOpponent;
+    offsetStepperInstrumental.value = chartEditorState.currentInstrumentalOffset;
+
+    waveformPlayer.waveform.time = -chartEditorState.currentVocalOffsetPlayer / Constants.MS_PER_SEC; // Negative offsets make the song start early.
+    waveformPlayer.waveform.width = (waveformPlayer.waveform.waveformData?.length ?? 1000) / currentZoomFactor;
+    if (waveformPlayer.waveform.width > maxWidth) maxWidth = Std.int(waveformPlayer.waveform.width);
+    waveformPlayer.waveform.height = 65;
+
+    waveformOpponent.waveform.time = -chartEditorState.currentVocalOffsetOpponent / Constants.MS_PER_SEC;
+    waveformOpponent.waveform.width = (waveformOpponent.waveform.waveformData?.length ?? 1000) / currentZoomFactor;
+    if (waveformOpponent.waveform.width > maxWidth) maxWidth = Std.int(waveformOpponent.waveform.width);
+    waveformOpponent.waveform.height = 65;
+
+    waveformInstrumental.waveform.time = -chartEditorState.currentInstrumentalOffset / Constants.MS_PER_SEC;
+    waveformInstrumental.waveform.width = (waveformInstrumental.waveform.waveformData?.length ?? 1000) / currentZoomFactor;
+    if (waveformInstrumental.waveform.width > maxWidth) maxWidth = Std.int(waveformInstrumental.waveform.width);
+    waveformInstrumental.waveform.height = 65;
+
+    // Live update the drag, but don't actually change the underlying offset until we release the mouse to finish dragging.
+    if (dragWaveform != null) switch (dragWaveform)
+    {
+      case PLAYER:
+        // chartEditorState.currentVocalOffsetPlayer += deltaMilliseconds;
+        waveformPlayer.waveform.time -= dragOffsetMs / Constants.MS_PER_SEC;
+        offsetStepperPlayer.value += dragOffsetMs;
+      case OPPONENT:
+        // chartEditorState.currentVocalOffsetOpponent += deltaMilliseconds;
+        waveformOpponent.waveform.time -= dragOffsetMs / Constants.MS_PER_SEC;
+        offsetStepperOpponent.value += dragOffsetMs;
+      case INSTRUMENTAL:
+        // chartEditorState.currentInstrumentalOffset += deltaMilliseconds;
+        waveformInstrumental.waveform.time -= dragOffsetMs / Constants.MS_PER_SEC;
+        offsetStepperInstrumental.value += dragOffsetMs;
+      default:
+        // No drag, no
+    }
+
+    waveformPlayer.waveform.markDirty();
+    waveformOpponent.waveform.markDirty();
+    waveformInstrumental.waveform.markDirty();
+
+    waveformContainer.width = maxWidth;
+    tickTiledSprite.width = maxWidth;
+  }
+
+  public static function build(chartEditorState:ChartEditorState):ChartEditorOffsetsToolbox
+  {
+    return new ChartEditorOffsetsToolbox(chartEditorState);
+  }
+}
+
+enum Waveform
+{
+  PLAYER;
+  OPPONENT;
+  INSTRUMENTAL;
+}
diff --git a/source/funkin/ui/debug/charting/util/ChartEditorDropdowns.hx b/source/funkin/ui/debug/charting/util/ChartEditorDropdowns.hx
index dfa0408d3..14c07440b 100644
--- a/source/funkin/ui/debug/charting/util/ChartEditorDropdowns.hx
+++ b/source/funkin/ui/debug/charting/util/ChartEditorDropdowns.hx
@@ -2,10 +2,11 @@ package funkin.ui.debug.charting.util;
 
 import funkin.data.notestyle.NoteStyleRegistry;
 import funkin.play.notes.notestyle.NoteStyle;
-import funkin.play.stage.StageData;
-import funkin.play.stage.StageData.StageDataParser;
+import funkin.data.stage.StageData;
+import funkin.data.stage.StageRegistry;
 import funkin.play.character.CharacterData;
 import haxe.ui.components.DropDown;
+import funkin.play.stage.Stage;
 import funkin.play.character.BaseCharacter.CharacterType;
 import funkin.play.character.CharacterData.CharacterDataParser;
 
@@ -60,16 +61,16 @@ class ChartEditorDropdowns
   {
     dropDown.dataSource.clear();
 
-    var stageIds:Array<String> = StageDataParser.listStageIds();
+    var stageIds:Array<String> = StageRegistry.instance.listEntryIds();
 
     var returnValue:DropDownEntry = {id: "mainStage", text: "Main Stage"};
 
     for (stageId in stageIds)
     {
-      var stage:Null<StageData> = StageDataParser.parseStageData(stageId);
+      var stage:Null<Stage> = StageRegistry.instance.fetchEntry(stageId);
       if (stage == null) continue;
 
-      var value = {id: stageId, text: stage.name};
+      var value = {id: stage.id, text: stage.stageName};
       if (startingStageId == stageId) returnValue = value;
 
       dropDown.dataSource.add(value);
diff --git a/source/funkin/play/cutscene/dialogue/ConversationDebugState.hx b/source/funkin/ui/debug/dialogue/ConversationDebugState.hx
similarity index 64%
rename from source/funkin/play/cutscene/dialogue/ConversationDebugState.hx
rename to source/funkin/ui/debug/dialogue/ConversationDebugState.hx
index 13697b9f4..33a6f365a 100644
--- a/source/funkin/play/cutscene/dialogue/ConversationDebugState.hx
+++ b/source/funkin/ui/debug/dialogue/ConversationDebugState.hx
@@ -1,10 +1,19 @@
-package funkin.play.cutscene.dialogue;
+package funkin.ui.debug.dialogue;
 
 import flixel.FlxState;
 import funkin.modding.events.ScriptEventDispatcher;
 import funkin.modding.events.ScriptEvent;
 import flixel.util.FlxColor;
 import funkin.ui.MusicBeatState;
+import funkin.data.dialogue.ConversationData;
+import funkin.data.dialogue.ConversationRegistry;
+import funkin.data.dialogue.DialogueBoxData;
+import funkin.data.dialogue.DialogueBoxRegistry;
+import funkin.data.dialogue.SpeakerData;
+import funkin.data.dialogue.SpeakerRegistry;
+import funkin.play.cutscene.dialogue.Conversation;
+import funkin.play.cutscene.dialogue.DialogueBox;
+import funkin.play.cutscene.dialogue.Speaker;
 
 /**
  * A state with displays a conversation with no background.
@@ -27,7 +36,7 @@ class ConversationDebugState extends MusicBeatState
 
   public override function create():Void
   {
-    conversation = ConversationDataParser.fetchConversation(conversationId);
+    conversation = ConversationRegistry.instance.fetchEntry(conversationId);
     conversation.completeCallback = onConversationComplete;
     add(conversation);
 
@@ -40,6 +49,12 @@ class ConversationDebugState extends MusicBeatState
     conversation = null;
   }
 
+  public override function dispatchEvent(event:ScriptEvent):Void
+  {
+    // Dispatch event to conversation script.
+    ScriptEventDispatcher.callEvent(conversation, event);
+  }
+
   public override function update(elapsed:Float):Void
   {
     super.update(elapsed);
diff --git a/source/funkin/ui/debug/latency/LatencyState.hx b/source/funkin/ui/debug/latency/LatencyState.hx
index 18b0010b2..70ef97fd0 100644
--- a/source/funkin/ui/debug/latency/LatencyState.hx
+++ b/source/funkin/ui/debug/latency/LatencyState.hx
@@ -75,7 +75,7 @@ class LatencyState extends MusicBeatSubState
 
     // funnyStatsGraph.hi
 
-    Conductor.forceBPM(60);
+    Conductor.instance.forceBPM(60);
 
     noteGrp = new FlxTypedGroup<NoteSprite>();
     add(noteGrp);
@@ -91,14 +91,14 @@ class LatencyState extends MusicBeatSubState
     // // musSpec.visType = FREQUENCIES;
     // add(musSpec);
 
-    for (beat in 0...Math.floor(FlxG.sound.music.length / Conductor.beatLengthMs))
+    for (beat in 0...Math.floor(FlxG.sound.music.length / Conductor.instance.beatLengthMs))
     {
-      var beatTick:FlxSprite = new FlxSprite(songPosToX(beat * Conductor.beatLengthMs), FlxG.height - 15);
+      var beatTick:FlxSprite = new FlxSprite(songPosToX(beat * Conductor.instance.beatLengthMs), FlxG.height - 15);
       beatTick.makeGraphic(2, 15);
       beatTick.alpha = 0.3;
       add(beatTick);
 
-      var offsetTxt:FlxText = new FlxText(songPosToX(beat * Conductor.beatLengthMs), FlxG.height - 26, 0, "swag");
+      var offsetTxt:FlxText = new FlxText(songPosToX(beat * Conductor.instance.beatLengthMs), FlxG.height - 26, 0, "swag");
       offsetTxt.alpha = 0.5;
       diffGrp.add(offsetTxt);
 
@@ -130,7 +130,7 @@ class LatencyState extends MusicBeatSubState
 
     for (i in 0...32)
     {
-      var note:NoteSprite = new NoteSprite(NoteStyleRegistry.instance.fetchDefault(), Conductor.beatLengthMs * i);
+      var note:NoteSprite = new NoteSprite(NoteStyleRegistry.instance.fetchDefault(), Conductor.instance.beatLengthMs * i);
       noteGrp.add(note);
     }
 
@@ -146,9 +146,9 @@ class LatencyState extends MusicBeatSubState
 
   override function stepHit():Bool
   {
-    if (Conductor.currentStep % 4 == 2)
+    if (Conductor.instance.currentStep % 4 == 2)
     {
-      blocks.members[((Conductor.currentBeat % 8) + 1) % 8].alpha = 0.5;
+      blocks.members[((Conductor.instance.currentBeat % 8) + 1) % 8].alpha = 0.5;
     }
 
     return super.stepHit();
@@ -156,11 +156,11 @@ class LatencyState extends MusicBeatSubState
 
   override function beatHit():Bool
   {
-    if (Conductor.currentBeat % 8 == 0) blocks.forEach(blok -> {
+    if (Conductor.instance.currentBeat % 8 == 0) blocks.forEach(blok -> {
       blok.alpha = 0;
     });
 
-    blocks.members[Conductor.currentBeat % 8].alpha = 1;
+    blocks.members[Conductor.instance.currentBeat % 8].alpha = 1;
     // block.visible = !block.visible;
 
     return super.beatHit();
@@ -192,17 +192,17 @@ class LatencyState extends MusicBeatSubState
 
     if (FlxG.keys.pressed.D) FlxG.sound.music.time += 1000 * FlxG.elapsed;
 
-    Conductor.update(swagSong.getTimeWithDiff() - Conductor.inputOffset);
-    // Conductor.songPosition += (Timer.stamp() * 1000) - FlxG.sound.music.prevTimestamp;
+    Conductor.instance.update(swagSong.getTimeWithDiff() - Conductor.instance.inputOffset);
+    // Conductor.instance.songPosition += (Timer.stamp() * 1000) - FlxG.sound.music.prevTimestamp;
 
-    songPosVis.x = songPosToX(Conductor.songPosition);
-    songVisFollowAudio.x = songPosToX(Conductor.songPosition - Conductor.instrumentalOffset);
-    songVisFollowVideo.x = songPosToX(Conductor.songPosition - Conductor.inputOffset);
+    songPosVis.x = songPosToX(Conductor.instance.songPosition);
+    songVisFollowAudio.x = songPosToX(Conductor.instance.songPosition - Conductor.instance.instrumentalOffset);
+    songVisFollowVideo.x = songPosToX(Conductor.instance.songPosition - Conductor.instance.inputOffset);
 
-    offsetText.text = "INST Offset: " + Conductor.instrumentalOffset + "ms";
-    offsetText.text += "\nINPUT Offset: " + Conductor.inputOffset + "ms";
-    offsetText.text += "\ncurrentStep: " + Conductor.currentStep;
-    offsetText.text += "\ncurrentBeat: " + Conductor.currentBeat;
+    offsetText.text = "INST Offset: " + Conductor.instance.instrumentalOffset + "ms";
+    offsetText.text += "\nINPUT Offset: " + Conductor.instance.inputOffset + "ms";
+    offsetText.text += "\ncurrentStep: " + Conductor.instance.currentStep;
+    offsetText.text += "\ncurrentBeat: " + Conductor.instance.currentBeat;
 
     var avgOffsetInput:Float = 0;
 
@@ -221,24 +221,24 @@ class LatencyState extends MusicBeatSubState
     {
       if (FlxG.keys.justPressed.RIGHT)
       {
-        Conductor.instrumentalOffset += 1.0 * multiply;
+        Conductor.instance.instrumentalOffset += 1.0 * multiply;
       }
 
       if (FlxG.keys.justPressed.LEFT)
       {
-        Conductor.instrumentalOffset -= 1.0 * multiply;
+        Conductor.instance.instrumentalOffset -= 1.0 * multiply;
       }
     }
     else
     {
       if (FlxG.keys.justPressed.RIGHT)
       {
-        Conductor.inputOffset += 1.0 * multiply;
+        Conductor.instance.inputOffset += 1.0 * multiply;
       }
 
       if (FlxG.keys.justPressed.LEFT)
       {
-        Conductor.inputOffset -= 1.0 * multiply;
+        Conductor.instance.inputOffset -= 1.0 * multiply;
       }
     }
 
@@ -250,7 +250,7 @@ class LatencyState extends MusicBeatSubState
     }*/
 
     noteGrp.forEach(function(daNote:NoteSprite) {
-      daNote.y = (strumLine.y - ((Conductor.songPosition - Conductor.instrumentalOffset) - daNote.noteData.time) * 0.45);
+      daNote.y = (strumLine.y - ((Conductor.instance.songPosition - Conductor.instance.instrumentalOffset) - daNote.noteData.time) * 0.45);
       daNote.x = strumLine.x + 30;
 
       if (daNote.y < strumLine.y) daNote.alpha = 0.5;
@@ -258,7 +258,7 @@ class LatencyState extends MusicBeatSubState
       if (daNote.y < 0 - daNote.height)
       {
         daNote.alpha = 1;
-        // daNote.data.strumTime += Conductor.beatLengthMs * 8;
+        // daNote.data.strumTime += Conductor.instance.beatLengthMs * 8;
       }
     });
 
@@ -267,14 +267,14 @@ class LatencyState extends MusicBeatSubState
 
   function generateBeatStuff()
   {
-    Conductor.update(swagSong.getTimeWithDiff());
+    Conductor.instance.update(swagSong.getTimeWithDiff());
 
-    var closestBeat:Int = Math.round(Conductor.songPosition / Conductor.beatLengthMs) % diffGrp.members.length;
-    var getDiff:Float = Conductor.songPosition - (closestBeat * Conductor.beatLengthMs);
-    getDiff -= Conductor.inputOffset;
+    var closestBeat:Int = Math.round(Conductor.instance.songPosition / Conductor.instance.beatLengthMs) % diffGrp.members.length;
+    var getDiff:Float = Conductor.instance.songPosition - (closestBeat * Conductor.instance.beatLengthMs);
+    getDiff -= Conductor.instance.inputOffset;
 
     // lil fix for end of song
-    if (closestBeat == 0 && getDiff >= Conductor.beatLengthMs * 2) getDiff -= FlxG.sound.music.length;
+    if (closestBeat == 0 && getDiff >= Conductor.instance.beatLengthMs * 2) getDiff -= FlxG.sound.music.length;
 
     trace("\tDISTANCE TO CLOSEST BEAT: " + getDiff + "ms");
     trace("\tCLOSEST BEAT: " + closestBeat);
diff --git a/source/funkin/ui/debug/stage/StageOffsetSubState.hx b/source/funkin/ui/debug/stage/StageOffsetSubState.hx
index 68546f1c7..e8a5d0a23 100644
--- a/source/funkin/ui/debug/stage/StageOffsetSubState.hx
+++ b/source/funkin/ui/debug/stage/StageOffsetSubState.hx
@@ -5,15 +5,17 @@ import flixel.input.mouse.FlxMouseEvent;
 import flixel.math.FlxPoint;
 import funkin.play.character.BaseCharacter;
 import funkin.play.PlayState;
-import funkin.play.stage.StageData;
+import funkin.data.stage.StageData;
 import funkin.play.stage.StageProp;
 import funkin.graphics.shaders.StrokeShader;
 import funkin.ui.haxeui.HaxeUISubState;
 import funkin.ui.debug.stage.StageEditorCommand;
 import funkin.util.SerializerUtil;
+import funkin.data.stage.StageRegistry;
 import funkin.util.MouseUtil;
 import haxe.ui.containers.ListView;
 import haxe.ui.core.Component;
+import funkin.graphics.FunkinSprite;
 import haxe.ui.events.UIEvent;
 import haxe.ui.RuntimeComponentBuilder;
 import openfl.events.Event;
@@ -354,7 +356,13 @@ class StageOffsetSubState extends HaxeUISubState
 
   function prepStageStuff():String
   {
-    var stageLol:StageData = StageDataParser.parseStageData(PlayState.instance.currentStageId);
+    var stageLol:StageData = StageRegistry.instance.fetchEntry(PlayState.instance.currentStageId)?._data;
+
+    if (stageLol == null)
+    {
+      FlxG.log.error("Stage not found in registry!");
+      return "";
+    }
 
     for (prop in stageLol.props)
     {
@@ -378,6 +386,6 @@ class StageOffsetSubState extends HaxeUISubState
     stageLol.characters.gf.position[0] = Std.int(GF_FEET_SNIIIIIIIIIIIIIFFFF.x);
     stageLol.characters.gf.position[1] = Std.int(GF_FEET_SNIIIIIIIIIIIIIFFFF.y);
 
-    return SerializerUtil.toJSON(stageLol);
+    return stageLol.serialize();
   }
 }
diff --git a/source/funkin/ui/freeplay/FreeplayState.hx b/source/funkin/ui/freeplay/FreeplayState.hx
index 7c69804d9..3c6b52c6f 100644
--- a/source/funkin/ui/freeplay/FreeplayState.hx
+++ b/source/funkin/ui/freeplay/FreeplayState.hx
@@ -1,6 +1,5 @@
 package funkin.ui.freeplay;
 
-import funkin.input.Controls;
 import flash.text.TextField;
 import flixel.addons.display.FlxGridOverlay;
 import flixel.addons.transition.FlxTransitionableState;
@@ -23,38 +22,65 @@ import flixel.tweens.FlxTween;
 import flixel.util.FlxColor;
 import flixel.util.FlxSpriteUtil;
 import flixel.util.FlxTimer;
-import funkin.input.Controls.Control;
 import funkin.data.level.LevelRegistry;
 import funkin.data.song.SongRegistry;
 import funkin.graphics.adobeanimate.FlxAtlasSprite;
 import funkin.graphics.shaders.AngleMask;
 import funkin.graphics.shaders.HSVShader;
 import funkin.graphics.shaders.PureColor;
-import funkin.util.MathUtil;
 import funkin.graphics.shaders.StrokeShader;
+import funkin.input.Controls;
+import funkin.input.Controls.Control;
 import funkin.play.components.HealthIcon;
 import funkin.play.PlayState;
 import funkin.play.PlayStatePlaylist;
 import funkin.play.song.Song;
 import funkin.save.Save;
 import funkin.save.Save.SaveScoreData;
+import funkin.ui.AtlasText;
 import funkin.ui.freeplay.BGScrollingText;
 import funkin.ui.freeplay.DifficultyStars;
 import funkin.ui.freeplay.DJBoyfriend;
 import funkin.ui.freeplay.FreeplayScore;
 import funkin.ui.freeplay.LetterSort;
 import funkin.ui.freeplay.SongMenuItem;
+import funkin.ui.mainmenu.MainMenuState;
 import funkin.ui.MusicBeatState;
 import funkin.ui.MusicBeatSubState;
-import funkin.ui.mainmenu.MainMenuState;
 import funkin.ui.transition.LoadingState;
 import funkin.ui.transition.StickerSubState;
 import funkin.util.MathUtil;
+import funkin.util.MathUtil;
 import lime.app.Future;
 import lime.utils.Assets;
 
 class FreeplayState extends MusicBeatSubState
 {
+  /**
+   * For the audio preview, the duration of the fade-in effect.
+   */
+  public static final FADE_IN_DURATION:Float = 0.5;
+
+  /**
+   * For the audio preview, the duration of the fade-out effect.
+   */
+  public static final FADE_OUT_DURATION:Float = 0.25;
+
+  /**
+   * For the audio preview, the volume at which the fade-in starts.
+   */
+  public static final FADE_IN_START_VOLUME:Float = 0.25;
+
+  /**
+   * For the audio preview, the volume at which the fade-in ends.
+   */
+  public static final FADE_IN_END_VOLUME:Float = 1.0;
+
+  /**
+   * For the audio preview, the volume at which the fade-out starts.
+   */
+  public static final FADE_OUT_END_VOLUME:Float = 0.0;
+
   var songs:Array<Null<FreeplaySongData>> = [];
 
   var diffIdsCurrent:Array<String> = [];
@@ -64,7 +90,7 @@ class FreeplayState extends MusicBeatSubState
   var currentDifficulty:String = Constants.DEFAULT_DIFFICULTY;
 
   var fp:FreeplayScore;
-  var txtCompletion:FlxText;
+  var txtCompletion:AtlasText;
   var lerpCompletion:Float = 0;
   var intendedCompletion:Float = 0;
   var lerpScore:Float = 0;
@@ -87,6 +113,8 @@ class FreeplayState extends MusicBeatSubState
   var grpCapsules:FlxTypedGroup<SongMenuItem>;
   var curCapsule:SongMenuItem;
   var curPlaying:Bool = false;
+  var ostName:FlxText;
+  var difficultyStars:DifficultyStars;
 
   var dj:DJBoyfriend;
 
@@ -97,7 +125,7 @@ class FreeplayState extends MusicBeatSubState
   var stickerSubState:StickerSubState;
 
   //
-  static var rememberedDifficulty:Null<String> = "normal";
+  static var rememberedDifficulty:Null<String> = Constants.DEFAULT_DIFFICULTY;
   static var rememberedSongId:Null<String> = null;
 
   public function new(?stickers:StickerSubState = null)
@@ -136,9 +164,9 @@ class FreeplayState extends MusicBeatSubState
     isDebug = true;
     #end
 
-    if (FlxG.sound.music != null)
+    if (FlxG.sound.music == null || (FlxG.sound.music != null && !FlxG.sound.music.playing))
     {
-      if (!FlxG.sound.music.playing) FlxG.sound.playMusic(Paths.music('freakyMenu/freakyMenu'));
+      FlxG.sound.playMusic(Paths.music('freakyMenu/freakyMenu'));
     }
 
     // Add a null entry that represents the RANDOM option
@@ -150,15 +178,10 @@ class FreeplayState extends MusicBeatSubState
       for (songId in LevelRegistry.instance.parseEntryData(levelId).songs)
       {
         var song:Song = SongRegistry.instance.fetchEntry(songId);
-        var songBaseDifficulty:SongDifficulty = song.getDifficulty(Constants.DEFAULT_DIFFICULTY);
 
-        var songName = songBaseDifficulty.songName;
-        var songOpponent = songBaseDifficulty.characters.opponent;
-        var songDifficulties = song.listDifficulties();
+        songs.push(new FreeplaySongData(levelId, songId, song));
 
-        songs.push(new FreeplaySongData(songId, songName, levelId, songOpponent, songDifficulties));
-
-        for (difficulty in songDifficulties)
+        for (difficulty in song.listDifficulties())
         {
           diffIdsTotal.pushUnique(difficulty);
         }
@@ -334,6 +357,8 @@ class FreeplayState extends MusicBeatSubState
       if (diffSprite.difficultyId == currentDifficulty) diffSprite.visible = true;
     }
 
+    // NOTE: This is an AtlasSprite because we use an animation to bring it into view.
+    // TODO: Add the ability to select the album graphic.
     var albumArt:FlxAtlasSprite = new FlxAtlasSprite(640, 360, Paths.animateAtlas("freeplay/albumRoll"));
     albumArt.visible = false;
     add(albumArt);
@@ -347,7 +372,7 @@ class FreeplayState extends MusicBeatSubState
 
     var albumTitle:FlxSprite = new FlxSprite(947, 491).loadGraphic(Paths.image('freeplay/albumTitle-fnfvol1'));
     var albumArtist:FlxSprite = new FlxSprite(1010, 607).loadGraphic(Paths.image('freeplay/albumArtist-kawaisprite'));
-    var difficultyStars:DifficultyStars = new DifficultyStars(140, 39);
+    difficultyStars = new DifficultyStars(140, 39);
 
     difficultyStars.stars.visible = false;
     albumTitle.visible = false;
@@ -382,11 +407,16 @@ class FreeplayState extends MusicBeatSubState
     add(overhangStuff);
     FlxTween.tween(overhangStuff, {y: 0}, 0.3, {ease: FlxEase.quartOut});
 
-    var fnfFreeplay:FlxText = new FlxText(0, 12, 0, "FREEPLAY", 48);
+    var fnfFreeplay:FlxText = new FlxText(8, 8, 0, "FREEPLAY", 48);
     fnfFreeplay.font = "VCR OSD Mono";
     fnfFreeplay.visible = false;
 
-    exitMovers.set([overhangStuff, fnfFreeplay],
+    ostName = new FlxText(8, 8, FlxG.width - 8 - 8, "OFFICIAL OST", 48);
+    ostName.font = "VCR OSD Mono";
+    ostName.alignment = RIGHT;
+    ostName.visible = false;
+
+    exitMovers.set([overhangStuff, fnfFreeplay, ostName],
       {
         y: -overhangStuff.height,
         x: 0,
@@ -397,8 +427,9 @@ class FreeplayState extends MusicBeatSubState
     var sillyStroke = new StrokeShader(0xFFFFFFFF, 2, 2);
     fnfFreeplay.shader = sillyStroke;
     add(fnfFreeplay);
+    add(ostName);
 
-    var fnfHighscoreSpr:FlxSprite = new FlxSprite(890, 70);
+    var fnfHighscoreSpr:FlxSprite = new FlxSprite(860, 70);
     fnfHighscoreSpr.frames = Paths.getSparrowAtlas('freeplay/highscore');
     fnfHighscoreSpr.animation.addByPrefix("highscore", "highscore", 24, false);
     fnfHighscoreSpr.visible = false;
@@ -415,8 +446,10 @@ class FreeplayState extends MusicBeatSubState
     fp.visible = false;
     add(fp);
 
-    txtCompletion = new FlxText(1200, 77, 0, "0", 32);
-    txtCompletion.font = "VCR OSD Mono";
+    var clearBoxSprite:FlxSprite = new FlxSprite(1165, 65).loadGraphic(Paths.image('freeplay/clearBox'));
+    add(clearBoxSprite);
+
+    txtCompletion = new AtlasText(1185, 87, "69", AtlasFont.FREEPLAY_CLEAR);
     txtCompletion.visible = false;
     add(txtCompletion);
 
@@ -485,6 +518,7 @@ class FreeplayState extends MusicBeatSubState
       new FlxTimer().start(1 / 24, function(handShit) {
         fnfHighscoreSpr.visible = true;
         fnfFreeplay.visible = true;
+        ostName.visible = true;
         fp.visible = true;
         fp.updateScore(0);
 
@@ -674,9 +708,32 @@ class FreeplayState extends MusicBeatSubState
     lerpScore = MathUtil.coolLerp(lerpScore, intendedScore, 0.2);
     lerpCompletion = MathUtil.coolLerp(lerpCompletion, intendedCompletion, 0.9);
 
+    if (Math.isNaN(lerpScore))
+    {
+      lerpScore = intendedScore;
+    }
+
+    if (Math.isNaN(lerpCompletion))
+    {
+      lerpCompletion = intendedCompletion;
+    }
+
     fp.updateScore(Std.int(lerpScore));
 
-    txtCompletion.text = Math.floor(lerpCompletion * 100) + "%";
+    txtCompletion.text = '${Math.floor(lerpCompletion * 100)}';
+
+    // Right align the completion percentage
+    switch (txtCompletion.text.length)
+    {
+      case 3:
+        txtCompletion.x = 1185 - 10;
+      case 2:
+        txtCompletion.x = 1185;
+      case 1:
+        txtCompletion.x = 1185 + 24;
+      default:
+        txtCompletion.x = 1185;
+    }
 
     handleInputs(elapsed);
   }
@@ -913,6 +970,11 @@ class FreeplayState extends MusicBeatSubState
       intendedCompletion = 0.0;
     }
 
+    if (intendedCompletion == Math.POSITIVE_INFINITY || intendedCompletion == Math.NEGATIVE_INFINITY || Math.isNaN(intendedCompletion))
+    {
+      intendedCompletion = 0;
+    }
+
     grpDifficulties.group.forEach(function(diffSprite) {
       diffSprite.visible = false;
     });
@@ -938,6 +1000,27 @@ class FreeplayState extends MusicBeatSubState
         }
       }
     }
+
+    if (change != 0)
+    {
+      // Update the song capsules to reflect the new difficulty info.
+      for (songCapsule in grpCapsules.members)
+      {
+        if (songCapsule == null) continue;
+        if (songCapsule.songData != null)
+        {
+          songCapsule.songData.currentDifficulty = currentDifficulty;
+          songCapsule.init(null, null, songCapsule.songData);
+        }
+        else
+        {
+          songCapsule.init(null, null, null);
+        }
+      }
+    }
+
+    // Set the difficulty star count on the right.
+    difficultyStars.difficulty = daSong?.songRating ?? difficultyStars.difficulty; // yay haxe 4.3
   }
 
   // Clears the cache of songs, frees up memory, they' ll have to be loaded in later tho function clearDaCache(actualSongTho:String)
@@ -1046,6 +1129,10 @@ class FreeplayState extends MusicBeatSubState
     {
       currentDifficulty = rememberedDifficulty;
     }
+
+    // Set the difficulty star count on the right.
+    var daSong = songs[curSelected];
+    difficultyStars.difficulty = daSong?.songRating ?? 0;
   }
 
   function changeSelection(change:Int = 0)
@@ -1176,19 +1263,47 @@ class FreeplaySongData
 {
   public var isFav:Bool = false;
 
-  public var songId:String = "";
-  public var songName:String = "";
-  public var levelId:String = "";
-  public var songCharacter:String = "";
-  public var songDifficulties:Array<String> = [];
+  var song:Song;
 
-  public function new(songId:String, songName:String, levelId:String, songCharacter:String, songDifficulties:Array<String>)
+  public var levelId(default, null):String = "";
+  public var songId(default, null):String = "";
+
+  public var songDifficulties(default, null):Array<String> = [];
+
+  public var songName(default, null):String = "";
+  public var songCharacter(default, null):String = "";
+  public var songRating(default, null):Int = 0;
+
+  public var currentDifficulty(default, set):String = Constants.DEFAULT_DIFFICULTY;
+
+  function set_currentDifficulty(value:String):String
+  {
+    if (currentDifficulty == value) return value;
+
+    currentDifficulty = value;
+    updateValues();
+    return value;
+  }
+
+  public function new(levelId:String, songId:String, song:Song)
   {
-    this.songId = songId;
-    this.songName = songName;
     this.levelId = levelId;
-    this.songCharacter = songCharacter;
-    this.songDifficulties = songDifficulties;
+    this.songId = songId;
+    this.song = song;
+
+    updateValues();
+  }
+
+  function updateValues():Void
+  {
+    this.songDifficulties = song.listDifficulties();
+    if (!this.songDifficulties.contains(currentDifficulty)) currentDifficulty = Constants.DEFAULT_DIFFICULTY;
+
+    var songDifficulty:SongDifficulty = song.getDifficulty(currentDifficulty);
+    if (songDifficulty == null) return;
+    this.songName = songDifficulty.songName;
+    this.songCharacter = songDifficulty.characters.opponent;
+    this.songRating = songDifficulty.difficultyRating;
   }
 }
 
diff --git a/source/funkin/ui/freeplay/SongMenuItem.hx b/source/funkin/ui/freeplay/SongMenuItem.hx
index 4e0772dfe..06d113468 100644
--- a/source/funkin/ui/freeplay/SongMenuItem.hx
+++ b/source/funkin/ui/freeplay/SongMenuItem.hx
@@ -35,11 +35,6 @@ class SongMenuItem extends FlxSpriteGroup
 
   var ranks:Array<String> = ["fail", "average", "great", "excellent", "perfect"];
 
-  // lol...
-  var diffRanks:Array<String> = [
-    "00", "01", "02", "03", "04", "05", "06", "07", "08", "09", "10", "11", "12", "14", "15"
-  ];
-
   public var targetPos:FlxPoint = new FlxPoint();
   public var doLerp:Bool = false;
   public var doJumpIn:Bool = false;
@@ -47,10 +42,12 @@ class SongMenuItem extends FlxSpriteGroup
   public var doJumpOut:Bool = false;
 
   public var onConfirm:Void->Void;
-  public var diffGrayscale:Grayscale;
+  public var grayscaleShader:Grayscale;
 
   public var hsvShader(default, set):HSVShader;
 
+  var diffRatingSprite:FlxSprite;
+
   public function new(x:Float, y:Float)
   {
     super(x, y);
@@ -75,26 +72,30 @@ class SongMenuItem extends FlxSpriteGroup
     add(ranking);
     grpHide.add(ranking);
 
-    diffGrayscale = new Grayscale(1);
-
-    var diffRank = new FlxSprite(145, 90).loadGraphic(Paths.image("freeplay/diffRankings/diff" + FlxG.random.getObject(diffRanks)));
-    diffRank.shader = diffGrayscale;
-    diffRank.visible = false;
-    add(diffRank);
-    diffRank.origin.set(capsule.origin.x - diffRank.x, capsule.origin.y - diffRank.y);
-    grpHide.add(diffRank);
-
     switch (rank)
     {
       case "perfect":
         ranking.x -= 10;
     }
 
+    grayscaleShader = new Grayscale(1);
+
+    diffRatingSprite = new FlxSprite(145, 90).loadGraphic(Paths.image("freeplay/diffRatings/diff00"));
+    diffRatingSprite.shader = grayscaleShader;
+    diffRatingSprite.visible = false;
+    add(diffRatingSprite);
+    diffRatingSprite.origin.set(capsule.origin.x - diffRatingSprite.x, capsule.origin.y - diffRatingSprite.y);
+    grpHide.add(diffRatingSprite);
+
     songText = new CapsuleText(capsule.width * 0.26, 45, 'Random', Std.int(40 * realScaled));
     add(songText);
     grpHide.add(songText);
 
-    pixelIcon = new FlxSprite(155, 15);
+    // TODO: Use value from metadata instead of random.
+    updateDifficultyRating(FlxG.random.int(0, 15));
+
+    pixelIcon = new FlxSprite(160, 35);
+
     pixelIcon.makeGraphic(32, 32, 0x00000000);
     pixelIcon.antialiasing = false;
     pixelIcon.active = false;
@@ -113,6 +114,12 @@ class SongMenuItem extends FlxSpriteGroup
     setVisibleGrp(false);
   }
 
+  function updateDifficultyRating(newRating:Int)
+  {
+    var ratingPadded:String = newRating < 10 ? '0$newRating' : '$newRating';
+    diffRatingSprite.loadGraphic(Paths.image('freeplay/diffRatings/diff${ratingPadded}'));
+  }
+
   function set_hsvShader(value:HSVShader):HSVShader
   {
     this.hsvShader = value;
@@ -149,16 +156,17 @@ class SongMenuItem extends FlxSpriteGroup
     updateSelected();
   }
 
-  public function init(x:Float, y:Float, songData:Null<FreeplaySongData>)
+  public function init(?x:Float, ?y:Float, songData:Null<FreeplaySongData>)
   {
-    this.x = x;
-    this.y = y;
+    if (x != null) this.x = x;
+    if (y != null) this.y = y;
     this.songData = songData;
 
     // Update capsule text.
     songText.text = songData?.songName ?? 'Random';
     // Update capsule character.
     if (songData?.songCharacter != null) setCharacter(songData.songCharacter);
+    updateDifficultyRating(songData?.songRating ?? 0);
     // Update opacity, offsets, etc.
     updateSelected();
   }
@@ -200,7 +208,14 @@ class SongMenuItem extends FlxSpriteGroup
 
     pixelIcon.loadGraphic(Paths.image(charPath));
     pixelIcon.scale.x = pixelIcon.scale.y = 2;
-    pixelIcon.origin.x = 100;
+
+    switch (char)
+    {
+      case "parents-christmas":
+        pixelIcon.origin.x = 140;
+      default:
+        pixelIcon.origin.x = 100;
+    }
     // pixelIcon.origin.x = capsule.origin.x;
     // pixelIcon.offset.x -= pixelIcon.origin.x;
   }
@@ -336,7 +351,7 @@ class SongMenuItem extends FlxSpriteGroup
 
   function updateSelected():Void
   {
-    diffGrayscale.setAmount(this.selected ? 0 : 0.8);
+    grayscaleShader.setAmount(this.selected ? 0 : 0.8);
     songText.alpha = this.selected ? 1 : 0.6;
     songText.blurredText.visible = this.selected ? true : false;
     capsule.offset.x = this.selected ? 0 : -5;
diff --git a/source/funkin/ui/haxeui/components/CharacterPlayer.hx b/source/funkin/ui/haxeui/components/CharacterPlayer.hx
index 66b94bfa2..c7171fac7 100644
--- a/source/funkin/ui/haxeui/components/CharacterPlayer.hx
+++ b/source/funkin/ui/haxeui/components/CharacterPlayer.hx
@@ -35,7 +35,7 @@ class CharacterPlayer extends Box
   public function new(defaultToBf:Bool = true)
   {
     super();
-    _overrideSkipTransformChildren = false;
+    // _overrideSkipTransformChildren = false;
 
     if (defaultToBf)
     {
diff --git a/source/funkin/ui/haxeui/components/WaveformPlayer.hx b/source/funkin/ui/haxeui/components/WaveformPlayer.hx
new file mode 100644
index 000000000..da85fbfef
--- /dev/null
+++ b/source/funkin/ui/haxeui/components/WaveformPlayer.hx
@@ -0,0 +1,17 @@
+package funkin.ui.haxeui.components;
+
+import funkin.audio.waveform.WaveformSprite;
+import funkin.audio.waveform.WaveformData;
+import haxe.ui.backend.flixel.components.SpriteWrapper;
+
+class WaveformPlayer extends SpriteWrapper
+{
+  public var waveform(default, null):WaveformSprite;
+
+  public function new(?waveformData:WaveformData)
+  {
+    super();
+    this.waveform = new WaveformSprite(waveformData);
+    this.sprite = waveform;
+  }
+}
diff --git a/source/funkin/ui/options/ControlsMenu.hx b/source/funkin/ui/options/ControlsMenu.hx
index b83b54152..ea04e1208 100644
--- a/source/funkin/ui/options/ControlsMenu.hx
+++ b/source/funkin/ui/options/ControlsMenu.hx
@@ -8,6 +8,7 @@ import flixel.group.FlxGroup;
 import flixel.input.actions.FlxActionInput;
 import flixel.input.gamepad.FlxGamepadInputID;
 import flixel.input.keyboard.FlxKey;
+import funkin.graphics.FunkinSprite;
 import funkin.input.Controls;
 import funkin.ui.AtlasText;
 import funkin.ui.MenuList;
@@ -61,8 +62,8 @@ class ControlsMenu extends funkin.ui.options.OptionsState.Page
 
     if (FlxG.gamepads.numActiveGamepads > 0)
     {
-      var devicesBg:FlxSprite = new FlxSprite();
-      devicesBg.makeGraphic(FlxG.width, 100, 0xFFFAFD6D);
+      var devicesBg:FunkinSprite = new FunkinSprite();
+      devicesBg.makeSolidColor(FlxG.width, 100, 0xFFFAFD6D);
       add(devicesBg);
       deviceList = new TextMenuList(Horizontal, None);
       add(deviceList);
diff --git a/source/funkin/ui/story/Level.hx b/source/funkin/ui/story/Level.hx
index e86241277..1b9252fde 100644
--- a/source/funkin/ui/story/Level.hx
+++ b/source/funkin/ui/story/Level.hx
@@ -180,9 +180,9 @@ class Level implements IRegistryEntry<LevelData>
     return difficulties;
   }
 
-  public function buildProps():Array<LevelProp>
+  public function buildProps(?existingProps:Array<LevelProp>):Array<LevelProp>
   {
-    var props:Array<LevelProp> = [];
+    var props:Array<LevelProp> = existingProps == null ? [] : [for (x in existingProps) x];
 
     if (_data.props.length == 0) return props;
 
@@ -190,11 +190,22 @@ class Level implements IRegistryEntry<LevelData>
     {
       var propData = _data.props[propIndex];
 
-      var propSprite:Null<LevelProp> = LevelProp.build(propData);
-      if (propSprite == null) continue;
+      // Attempt to reuse the `LevelProp` object.
+      // This prevents animations from resetting.
+      var existingProp:Null<LevelProp> = props[propIndex];
+      if (existingProp != null)
+      {
+        existingProp.propData = propData;
+        existingProp.x = propData.offsets[0] + FlxG.width * 0.25 * propIndex;
+      }
+      else
+      {
+        var propSprite:Null<LevelProp> = LevelProp.build(propData);
+        if (propSprite == null) continue;
 
-      propSprite.x += FlxG.width * 0.25 * propIndex;
-      props.push(propSprite);
+        propSprite.x = propData.offsets[0] + FlxG.width * 0.25 * propIndex;
+        props.push(propSprite);
+      }
     }
 
     return props;
diff --git a/source/funkin/ui/story/LevelProp.hx b/source/funkin/ui/story/LevelProp.hx
index 4dce7bfb3..5af383de9 100644
--- a/source/funkin/ui/story/LevelProp.hx
+++ b/source/funkin/ui/story/LevelProp.hx
@@ -6,9 +6,26 @@ import funkin.data.level.LevelData;
 
 class LevelProp extends Bopper
 {
-  public function new(danceEvery:Int)
+  public var propData(default, set):Null<LevelPropData> = null;
+
+  function set_propData(value:LevelPropData):LevelPropData
   {
-    super(danceEvery);
+    // Only reset the prop if the asset path has changed.
+    if (propData == null || value.assetPath != this.propData.assetPath)
+    {
+      this.visible = (value != null);
+      this.propData = value;
+      danceEvery = this.propData.danceEvery;
+      applyData();
+    }
+
+    return this.propData;
+  }
+
+  public function new(propData:LevelPropData)
+  {
+    super(propData.danceEvery);
+    this.propData = propData;
   }
 
   public function playConfirm():Void
@@ -16,50 +33,51 @@ class LevelProp extends Bopper
     playAnimation('confirm', true, true);
   }
 
-  public static function build(propData:Null<LevelPropData>):Null<LevelProp>
+  function applyData():Void
   {
-    if (propData == null) return null;
-
     var isAnimated:Bool = propData.animations.length > 0;
-    var prop:LevelProp = new LevelProp(propData.danceEvery);
-
     if (isAnimated)
     {
       // Initalize sprite frames.
       // Sparrow atlas only LEL.
-      prop.frames = Paths.getSparrowAtlas(propData.assetPath);
+      this.frames = Paths.getSparrowAtlas(propData.assetPath);
     }
     else
     {
       // Initalize static sprite.
-      prop.loadGraphic(Paths.image(propData.assetPath));
+      this.loadGraphic(Paths.image(propData.assetPath));
 
       // Disables calls to update() for a performance boost.
-      prop.active = false;
+      this.active = false;
     }
 
-    if (prop.frames == null || prop.frames.numFrames == 0)
+    if (this.frames == null || this.frames.numFrames == 0)
     {
       trace('ERROR: Could not build texture for level prop (${propData.assetPath}).');
-      return null;
+      return;
     }
 
     var scale:Float = propData.scale * (propData.isPixel ? 6 : 1);
-    prop.scale.set(scale, scale);
-    prop.antialiasing = !propData.isPixel;
-    prop.alpha = propData.alpha;
-    prop.x = propData.offsets[0];
-    prop.y = propData.offsets[1];
+    this.scale.set(scale, scale);
+    this.antialiasing = !propData.isPixel;
+    this.alpha = propData.alpha;
+    this.x = propData.offsets[0];
+    this.y = propData.offsets[1];
 
-    FlxAnimationUtil.addAtlasAnimations(prop, propData.animations);
+    FlxAnimationUtil.addAtlasAnimations(this, propData.animations);
     for (propAnim in propData.animations)
     {
-      prop.setAnimationOffsets(propAnim.name, propAnim.offsets[0], propAnim.offsets[1]);
+      this.setAnimationOffsets(propAnim.name, propAnim.offsets[0], propAnim.offsets[1]);
     }
 
-    prop.dance();
-    prop.animation.paused = true;
+    this.dance();
+    this.animation.paused = true;
+  }
 
-    return prop;
+  public static function build(propData:Null<LevelPropData>):Null<LevelProp>
+  {
+    if (propData == null) return null;
+
+    return new LevelProp(propData);
   }
 }
diff --git a/source/funkin/ui/story/StoryMenuState.hx b/source/funkin/ui/story/StoryMenuState.hx
index 456988873..112817f42 100644
--- a/source/funkin/ui/story/StoryMenuState.hx
+++ b/source/funkin/ui/story/StoryMenuState.hx
@@ -10,6 +10,7 @@ import flixel.group.FlxGroup.FlxTypedGroup;
 import flixel.text.FlxText;
 import flixel.addons.transition.FlxTransitionableState;
 import flixel.tweens.FlxEase;
+import funkin.graphics.FunkinSprite;
 import funkin.ui.MusicBeatState;
 import flixel.tweens.FlxTween;
 import flixel.util.FlxColor;
@@ -106,7 +107,7 @@ class StoryMenuState extends MusicBeatState
   var stickerSubState:StickerSubState;
 
   static var rememberedLevelId:Null<String> = null;
-  static var rememberedDifficulty:Null<String> = "normal";
+  static var rememberedDifficulty:Null<String> = Constants.DEFAULT_DIFFICULTY;
 
   public function new(?stickers:StickerSubState = null)
   {
@@ -153,7 +154,7 @@ class StoryMenuState extends MusicBeatState
 
     updateBackground();
 
-    var black:FlxSprite = new FlxSprite(levelBackground.x, 0).makeGraphic(FlxG.width, Std.int(400 + levelBackground.y), FlxColor.BLACK);
+    var black:FunkinSprite = new FunkinSprite(levelBackground.x, 0).makeSolidColor(FlxG.width, Std.int(400 + levelBackground.y), FlxColor.BLACK);
     black.zIndex = levelBackground.zIndex - 1;
     add(black);
 
@@ -238,7 +239,7 @@ class StoryMenuState extends MusicBeatState
       var freakyMenuMetadata:Null<SongMusicData> = SongRegistry.instance.parseMusicData('freakyMenu');
       if (freakyMenuMetadata != null)
       {
-        Conductor.mapTimeChanges(freakyMenuMetadata.timeChanges);
+        Conductor.instance.mapTimeChanges(freakyMenuMetadata.timeChanges);
       }
 
       FlxG.sound.playMusic(Paths.music('freakyMenu/freakyMenu'), 0);
@@ -317,7 +318,7 @@ class StoryMenuState extends MusicBeatState
 
   override function update(elapsed:Float)
   {
-    Conductor.update();
+    Conductor.instance.update();
 
     highScoreLerp = Std.int(MathUtil.coolLerp(highScoreLerp, highScore, 0.5));
 
@@ -636,8 +637,7 @@ class StoryMenuState extends MusicBeatState
 
   function updateProps():Void
   {
-    levelProps.clear();
-    for (prop in currentLevel.buildProps())
+    for (prop in currentLevel.buildProps(levelProps.members))
     {
       prop.zIndex = 1000;
       levelProps.add(prop);
diff --git a/source/funkin/ui/title/OutdatedSubState.hx b/source/funkin/ui/title/OutdatedSubState.hx
index d262fc4e4..012823541 100644
--- a/source/funkin/ui/title/OutdatedSubState.hx
+++ b/source/funkin/ui/title/OutdatedSubState.hx
@@ -15,7 +15,7 @@ class OutdatedSubState extends MusicBeatState
   override function create()
   {
     super.create();
-    var bg:FlxSprite = new FlxSprite().makeGraphic(FlxG.width, FlxG.height, FlxColor.BLACK);
+    var bg:FunkinSprite = new FunkinSprite().makeSolidColor(FlxG.width, FlxG.height, FlxColor.BLACK);
     add(bg);
     var ver = "v" + Application.current.meta.get('version');
     var txt:FlxText = new FlxText(0, 0, FlxG.width,
diff --git a/source/funkin/ui/title/TitleState.hx b/source/funkin/ui/title/TitleState.hx
index 7671bb336..a5dcd6def 100644
--- a/source/funkin/ui/title/TitleState.hx
+++ b/source/funkin/ui/title/TitleState.hx
@@ -13,6 +13,7 @@ import funkin.audio.visualize.SpectogramSprite;
 import funkin.graphics.shaders.ColorSwap;
 import funkin.graphics.shaders.LeftMaskShader;
 import funkin.data.song.SongRegistry;
+import funkin.graphics.FunkinSprite;
 import funkin.ui.MusicBeatState;
 import funkin.data.song.SongData.SongMusicData;
 import funkin.graphics.shaders.TitleOutline;
@@ -118,7 +119,8 @@ class TitleState extends MusicBeatState
 
     persistentUpdate = true;
 
-    var bg:FlxSprite = new FlxSprite().makeGraphic(FlxG.width, FlxG.height, FlxColor.BLACK);
+    var bg:FunkinSprite = new FunkinSprite().makeSolidColor(FlxG.width, FlxG.height, FlxColor.BLACK);
+    bg.screenCenter();
     add(bg);
 
     logoBl = new FlxSprite(-150, -100);
@@ -221,7 +223,7 @@ class TitleState extends MusicBeatState
       var freakyMenuMetadata:Null<SongMusicData> = SongRegistry.instance.parseMusicData('freakyMenu');
       if (freakyMenuMetadata != null)
       {
-        Conductor.mapTimeChanges(freakyMenuMetadata.timeChanges);
+        Conductor.instance.mapTimeChanges(freakyMenuMetadata.timeChanges);
       }
       FlxG.sound.playMusic(Paths.music('freakyMenu/freakyMenu'), 0);
       FlxG.sound.music.fadeIn(4, 0, 0.7);
@@ -256,7 +258,7 @@ class TitleState extends MusicBeatState
     if (FlxG.keys.pressed.DOWN) FlxG.sound.music.pitch -= 0.5 * elapsed;
     #end
 
-    Conductor.update();
+    Conductor.instance.update();
 
     /* if (FlxG.onMobile)
           {
@@ -280,7 +282,7 @@ class TitleState extends MusicBeatState
       FlxTween.tween(FlxG.stage.window, {y: FlxG.stage.window.y + 100}, 0.7, {ease: FlxEase.quadInOut, type: PINGPONG});
     }
 
-    if (FlxG.sound.music != null) Conductor.update(FlxG.sound.music.time);
+    if (FlxG.sound.music != null) Conductor.instance.update(FlxG.sound.music.time);
     if (FlxG.keys.justPressed.F) FlxG.fullscreen = !FlxG.fullscreen;
 
     // do controls.PAUSE | controls.ACCEPT instead?
@@ -390,7 +392,7 @@ class TitleState extends MusicBeatState
     var spec:SpectogramSprite = new SpectogramSprite(FlxG.sound.music);
     add(spec);
 
-    Conductor.forceBPM(190);
+    Conductor.instance.forceBPM(190);
     FlxG.camera.flash(FlxColor.WHITE, 1);
     FlxG.sound.play(Paths.sound('confirmMenu'), 0.7);
   }
@@ -442,13 +444,13 @@ class TitleState extends MusicBeatState
 
     if (!skippedIntro)
     {
-      // FlxG.log.add(Conductor.currentBeat);
+      // FlxG.log.add(Conductor.instance.currentBeat);
       // if the user is draggin the window some beats will
       // be missed so this is just to compensate
-      if (Conductor.currentBeat > lastBeat)
+      if (Conductor.instance.currentBeat > lastBeat)
       {
         // TODO: Why does it perform ALL the previous steps each beat?
-        for (i in lastBeat...Conductor.currentBeat)
+        for (i in lastBeat...Conductor.instance.currentBeat)
         {
           switch (i + 1)
           {
@@ -483,11 +485,11 @@ class TitleState extends MusicBeatState
           }
         }
       }
-      lastBeat = Conductor.currentBeat;
+      lastBeat = Conductor.instance.currentBeat;
     }
     if (skippedIntro)
     {
-      if (cheatActive && Conductor.currentBeat % 2 == 0) swagShader.update(0.125);
+      if (cheatActive && Conductor.instance.currentBeat % 2 == 0) swagShader.update(0.125);
 
       if (logoBl != null && logoBl.animation != null) logoBl.animation.play('bump', true);
 
diff --git a/source/funkin/ui/transition/LoadingState.hx b/source/funkin/ui/transition/LoadingState.hx
index a223a4123..da9aeb28b 100644
--- a/source/funkin/ui/transition/LoadingState.hx
+++ b/source/funkin/ui/transition/LoadingState.hx
@@ -13,6 +13,7 @@ import funkin.play.song.Song.SongDifficulty;
 import funkin.ui.mainmenu.MainMenuState;
 import funkin.ui.MusicBeatState;
 import haxe.io.Path;
+import funkin.graphics.FunkinSprite;
 import lime.app.Future;
 import lime.app.Promise;
 import lime.utils.AssetLibrary;
@@ -42,7 +43,7 @@ class LoadingState extends MusicBeatState
 
   override function create():Void
   {
-    var bg:FlxSprite = new FlxSprite().makeGraphic(FlxG.width, FlxG.height, 0xFFcaff4d);
+    var bg:FlxSprite = new FunkinSprite().makeSolidColor(FlxG.width, FlxG.height, 0xFFcaff4d);
     add(bg);
 
     funkay = new FlxSprite();
@@ -53,7 +54,7 @@ class LoadingState extends MusicBeatState
     funkay.scrollFactor.set();
     funkay.screenCenter();
 
-    loadBar = new FlxSprite(0, FlxG.height - 20).makeGraphic(FlxG.width, 10, 0xFFff16d2);
+    loadBar = new FunkinSprite(0, FlxG.height - 20).makeSolidColor(FlxG.width, 10, 0xFFff16d2);
     loadBar.screenCenter(X);
     add(loadBar);
 
diff --git a/source/funkin/util/Constants.hx b/source/funkin/util/Constants.hx
index f8749567b..1005b312e 100644
--- a/source/funkin/util/Constants.hx
+++ b/source/funkin/util/Constants.hx
@@ -70,7 +70,7 @@ class Constants
   public static final URL_KICKSTARTER:String = 'https://www.kickstarter.com/projects/funkin/friday-night-funkin-the-full-ass-game/';
 
   /**
-   * GIT REPO DATA
+   * REPOSITORY DATA
    */
   // ==============================
 
@@ -86,6 +86,11 @@ class Constants
   public static final GIT_HASH:String = funkin.util.macro.GitCommit.getGitCommitHash();
   #end
 
+  /**
+   * The current library versions, as provided by hmm.
+   */
+  public static final LIBRARY_VERSIONS:Array<String> = funkin.util.macro.HaxelibVersions.getLibraryVersions();
+
   /**
    * COLORS
    */
@@ -123,6 +128,7 @@ class Constants
 
   /**
    * Default list of difficulties for charts.
+   * Assumes no Erect mode, etc.
    */
   public static final DEFAULT_DIFFICULTY_LIST:Array<String> = ['easy', 'normal', 'hard'];
 
@@ -357,6 +363,12 @@ class Constants
    */
   public static final SCORE_HOLD_BONUS_PER_SECOND:Float = 250.0;
 
+  public static final JUDGEMENT_KILLER_COMBO_BREAK:Bool = false;
+  public static final JUDGEMENT_SICK_COMBO_BREAK:Bool = false;
+  public static final JUDGEMENT_GOOD_COMBO_BREAK:Bool = false;
+  public static final JUDGEMENT_BAD_COMBO_BREAK:Bool = true;
+  public static final JUDGEMENT_SHIT_COMBO_BREAK:Bool = true;
+
   /**
    * FILE EXTENSIONS
    */
diff --git a/source/funkin/util/HaxeUIUtil.hx b/source/funkin/util/HaxeUIUtil.hx
new file mode 100644
index 000000000..1ffd9cd40
--- /dev/null
+++ b/source/funkin/util/HaxeUIUtil.hx
@@ -0,0 +1,17 @@
+package funkin.util;
+
+import haxe.ui.tooltips.ToolTipRegionOptions;
+
+class HaxeUIUtil
+{
+  public static function buildTooltip(text:String, ?left:Float, ?top:Float, ?width:Float, ?height:Float):ToolTipRegionOptions
+  {
+    return {
+      tipData: {text: text},
+      left: left ?? 0.0,
+      top: top ?? 0.0,
+      width: width ?? 0.0,
+      height: height ?? 0.0
+    }
+  }
+}
diff --git a/source/funkin/util/assets/DataAssets.hx b/source/funkin/util/assets/DataAssets.hx
index ee75dd207..b38c993fe 100644
--- a/source/funkin/util/assets/DataAssets.hx
+++ b/source/funkin/util/assets/DataAssets.hx
@@ -9,7 +9,8 @@ class DataAssets
 
   public static function listDataFilesInPath(path:String, ?suffix:String = '.json'):Array<String>
   {
-    var textAssets = openfl.utils.Assets.list();
+    var textAssets = openfl.utils.Assets.list(TEXT);
+
     var queryPath = buildDataPath(path);
 
     var results:Array<String> = [];
diff --git a/source/funkin/util/logging/CrashHandler.hx b/source/funkin/util/logging/CrashHandler.hx
index a21732048..ad5983e52 100644
--- a/source/funkin/util/logging/CrashHandler.hx
+++ b/source/funkin/util/logging/CrashHandler.hx
@@ -3,6 +3,7 @@ package funkin.util.logging;
 import openfl.Lib;
 import openfl.events.UncaughtErrorEvent;
 import flixel.util.FlxSignal.FlxTypedSignal;
+import flixel.FlxG.FlxRenderMethod;
 
 /**
  * A custom crash handler that writes to a log file and displays a message box.
@@ -118,6 +119,18 @@ class CrashHandler
     var driverInfo = FlxG?.stage?.context3D?.driverInfo ?? 'N/A';
     fullContents += 'Driver info: ${driverInfo}\n';
     fullContents += 'Platform: ${Sys.systemName()}\n';
+    fullContents += 'Render method: ${renderMethod()}\n';
+
+    fullContents += '\n';
+
+    fullContents += '=====================\n';
+
+    fullContents += 'Haxelibs: \n';
+
+    for (lib in Constants.LIBRARY_VERSIONS)
+    {
+      fullContents += '- ${lib}\n';
+    }
 
     fullContents += '\n';
 
@@ -185,4 +198,32 @@ class CrashHandler
   {
     throw "This is an example of an uncaught exception.";
   }
+
+  public static function induceNullObjectReference():Void
+  {
+    var obj:Dynamic = null;
+    var value = obj.test;
+  }
+
+  public static function induceNullObjectReference2():Void
+  {
+    var obj:Dynamic = null;
+    var value = obj.test();
+  }
+
+  public static function induceNullObjectReference3():Void
+  {
+    var obj:Dynamic = null;
+    var value = obj();
+  }
+
+  static function renderMethod():String
+  {
+    return switch (FlxG.renderMethod)
+    {
+      case FlxRenderMethod.DRAW_TILES: 'DRAW_TILES';
+      case FlxRenderMethod.BLITTING: 'BLITTING';
+      default: 'UNKNOWN';
+    }
+  }
 }
diff --git a/source/funkin/util/macro/HaxelibVersions.hx b/source/funkin/util/macro/HaxelibVersions.hx
new file mode 100644
index 000000000..1a4699bba
--- /dev/null
+++ b/source/funkin/util/macro/HaxelibVersions.hx
@@ -0,0 +1,67 @@
+package funkin.util.macro;
+
+import haxe.io.Path;
+
+class HaxelibVersions
+{
+  public static macro function getLibraryVersions():haxe.macro.Expr.ExprOf<Array<String>>
+  {
+    #if !display
+    return macro $v{formatHmmData(readHmmData())};
+    #else
+    // `#if display` is used for code completion. In this case returning an
+    // empty string is good enough; We don't want to call functions on every hint.
+    var commitHash:Array<String> = [];
+    return macro $v{commitHash};
+    #end
+  }
+
+  #if (macro)
+  static function readHmmData():hmm.HmmConfig
+  {
+    return hmm.HmmConfig.HmmConfigs.readHmmJsonOrThrow();
+  }
+
+  static function formatHmmData(hmmData:hmm.HmmConfig):Array<String>
+  {
+    var result:Array<String> = [];
+
+    for (library in hmmData.dependencies)
+    {
+      switch (library)
+      {
+        case Haxelib(name, version):
+          result.push('${name} haxelib(${o(version)})');
+        case Git(name, url, ref, dir):
+          result.push('${name} git(${url}/${o(dir, '')}:${o(ref)})');
+        case Mercurial(name, url, ref, dir):
+          result.push('${name} mercurial(${url}/${o(dir, '')}:${o(ref)})');
+        case Dev(name, path):
+          result.push('${name} dev(${path})');
+      }
+    }
+
+    return result;
+  }
+
+  static function o(option:haxe.ds.Option<String>, defaultValue:String = 'None'):String
+  {
+    switch (option)
+    {
+      case Some(value):
+        return value;
+      case None:
+        return defaultValue;
+    }
+  }
+
+  static function readLibraryCurrentVersion(libraryName:String):String
+  {
+    var path = Path.join([Path.addTrailingSlash(Sys.getCwd()), '.haxelib', libraryName, '.current']);
+    // This is compile time so we should always have Sys available.
+    var result = sys.io.File.getContent(path);
+
+    return result;
+  }
+  #end
+}
diff --git a/source/funkin/util/plugins/EvacuateDebugPlugin.hx b/source/funkin/util/plugins/EvacuateDebugPlugin.hx
new file mode 100644
index 000000000..1803c25ba
--- /dev/null
+++ b/source/funkin/util/plugins/EvacuateDebugPlugin.hx
@@ -0,0 +1,35 @@
+package funkin.util.plugins;
+
+import flixel.FlxBasic;
+
+/**
+ * A plugin which adds functionality to press `F4` to immediately transition to the main menu.
+ * This is useful for debugging or if you get softlocked or something.
+ */
+class EvacuateDebugPlugin extends FlxBasic
+{
+  public function new()
+  {
+    super();
+  }
+
+  public static function initialize():Void
+  {
+    FlxG.plugins.addPlugin(new EvacuateDebugPlugin());
+  }
+
+  public override function update(elapsed:Float):Void
+  {
+    super.update(elapsed);
+
+    if (FlxG.keys.justPressed.F4)
+    {
+      FlxG.switchState(new funkin.ui.mainmenu.MainMenuState());
+    }
+  }
+
+  public override function destroy():Void
+  {
+    super.destroy();
+  }
+}
diff --git a/source/funkin/util/plugins/README.md b/source/funkin/util/plugins/README.md
new file mode 100644
index 000000000..fe87d36e5
--- /dev/null
+++ b/source/funkin/util/plugins/README.md
@@ -0,0 +1,5 @@
+# funkin.util.plugins
+
+Flixel plugins are objects with `update()` functions that are called from every state.
+
+See: https://github.com/HaxeFlixel/flixel/blob/dev/flixel/system/frontEnds/PluginFrontEnd.hx
diff --git a/source/funkin/util/plugins/ReloadAssetsDebugPlugin.hx b/source/funkin/util/plugins/ReloadAssetsDebugPlugin.hx
new file mode 100644
index 000000000..a43317cce
--- /dev/null
+++ b/source/funkin/util/plugins/ReloadAssetsDebugPlugin.hx
@@ -0,0 +1,38 @@
+package funkin.util.plugins;
+
+import flixel.FlxBasic;
+
+/**
+ * A plugin which adds functionality to press `F5` to reload all game assets, then reload the current state.
+ * This is useful for hot reloading assets during development.
+ */
+class ReloadAssetsDebugPlugin extends FlxBasic
+{
+  public function new()
+  {
+    super();
+  }
+
+  public static function initialize():Void
+  {
+    FlxG.plugins.addPlugin(new ReloadAssetsDebugPlugin());
+  }
+
+  public override function update(elapsed:Float):Void
+  {
+    super.update(elapsed);
+
+    if (FlxG.keys.justPressed.F5)
+    {
+      funkin.modding.PolymodHandler.forceReloadAssets();
+
+      // Create a new instance of the current state, so old data is cleared.
+      FlxG.resetState();
+    }
+  }
+
+  public override function destroy():Void
+  {
+    super.destroy();
+  }
+}
diff --git a/source/funkin/util/plugins/WatchPlugin.hx b/source/funkin/util/plugins/WatchPlugin.hx
new file mode 100644
index 000000000..17b2dd129
--- /dev/null
+++ b/source/funkin/util/plugins/WatchPlugin.hx
@@ -0,0 +1,38 @@
+package funkin.util.plugins;
+
+import flixel.FlxBasic;
+
+/**
+ * A plugin which adds functionality to display several universally important values
+ * in the Flixel variable watch window.
+ */
+class WatchPlugin extends FlxBasic
+{
+  public function new()
+  {
+    super();
+  }
+
+  public static function initialize():Void
+  {
+    FlxG.plugins.addPlugin(new WatchPlugin());
+  }
+
+  public override function update(elapsed:Float):Void
+  {
+    super.update(elapsed);
+
+    FlxG.watch.addQuick("songPosition", Conductor.instance.songPosition);
+    FlxG.watch.addQuick("songPositionNoOffset", Conductor.instance.songPosition + Conductor.instance.instrumentalOffset);
+    FlxG.watch.addQuick("musicTime", FlxG.sound?.music?.time ?? 0.0);
+    FlxG.watch.addQuick("bpm", Conductor.instance.bpm);
+    FlxG.watch.addQuick("currentMeasureTime", Conductor.instance.currentMeasureTime);
+    FlxG.watch.addQuick("currentBeatTime", Conductor.instance.currentBeatTime);
+    FlxG.watch.addQuick("currentStepTime", Conductor.instance.currentStepTime);
+  }
+
+  public override function destroy():Void
+  {
+    super.destroy();
+  }
+}
diff --git a/source/funkin/util/tools/ArrayTools.hx b/source/funkin/util/tools/ArrayTools.hx
index a88f8a861..0209cfc19 100644
--- a/source/funkin/util/tools/ArrayTools.hx
+++ b/source/funkin/util/tools/ArrayTools.hx
@@ -76,4 +76,72 @@ class ArrayTools
     while (array.length > 0)
       array.pop();
   }
+
+  /**
+   * Create a new array with all elements of the given array, to prevent modifying the original.
+   */
+  public static function clone<T>(array:Array<T>):Array<T>
+  {
+    return [for (element in array) element];
+  }
+
+  /**
+   * Create a new array with clones of all elements of the given array, to prevent modifying the original.
+   */
+  public static function deepClone<T, U:ICloneable<T>>(array:Array<U>):Array<T>
+  {
+    return [for (element in array) element.clone()];
+  }
+
+  /**
+   * Return true only if both arrays contain the same elements (possibly in a different order).
+   * @param a The first array to compare.
+   * @param b The second array to compare.
+   * @return Weather both arrays contain the same elements.
+   */
+  public static function isEqualUnordered<T>(a:Array<T>, b:Array<T>):Bool
+  {
+    if (a.length != b.length) return false;
+    for (element in a)
+    {
+      if (!b.contains(element)) return false;
+    }
+    for (element in b)
+    {
+      if (!a.contains(element)) return false;
+    }
+    return true;
+  }
+
+  /**
+   * Returns true if `superset` contains all elements of `subset`.
+   * @param superset The array to query for each element.
+   * @param subset The array containing the elements to query for.
+   * @return Weather `superset` contains all elements of `subset`.
+   */
+  public static function isSuperset<T>(superset:Array<T>, subset:Array<T>):Bool
+  {
+    // Shortcuts.
+    if (subset.length == 0) return true;
+    if (subset.length > superset.length) return false;
+
+    // Check each element.
+    for (element in subset)
+    {
+      if (!superset.contains(element)) return false;
+    }
+    return true;
+  }
+
+  /**
+   * Returns true if `superset` contains all elements of `subset`.
+   * @param subset The array containing the elements to query for.
+   * @param superset The array to query for each element.
+   * @return Weather `superset` contains all elements of `subset`.
+   */
+  public static function isSubset<T>(subset:Array<T>, superset:Array<T>):Bool
+  {
+    // Switch it around.
+    return isSuperset(superset, subset);
+  }
 }
diff --git a/source/funkin/util/tools/ICloneable.hx b/source/funkin/util/tools/ICloneable.hx
new file mode 100644
index 000000000..33f19f167
--- /dev/null
+++ b/source/funkin/util/tools/ICloneable.hx
@@ -0,0 +1,10 @@
+package funkin.util.tools;
+
+/**
+ * Implement this on a class to enable `Array<T>.deepClone()` to work on it.
+ * NOTE: T should be the type of the class that implements this interface.
+ */
+interface ICloneable<T>
+{
+  public function clone():T;
+}
diff --git a/source/funkin/util/tools/Int64Tools.hx b/source/funkin/util/tools/Int64Tools.hx
index 75448b36f..d53fa315d 100644
--- a/source/funkin/util/tools/Int64Tools.hx
+++ b/source/funkin/util/tools/Int64Tools.hx
@@ -1,32 +1,42 @@
 package funkin.util.tools;
 
+import haxe.Int64;
+
 /**
- * @see https://github.com/fponticelli/thx.core/blob/master/src/thx/Int64s.hx
+ * Why `haxe.Int64` doesn't have a built-in `toFloat` function is beyond me.
  */
 class Int64Tools
 {
-  static var min = haxe.Int64.make(0x80000000, 0);
-  static var one = haxe.Int64.make(0, 1);
-  static var two = haxe.Int64.ofInt(2);
-  static var zero = haxe.Int64.make(0, 0);
-  static var ten = haxe.Int64.ofInt(10);
+  private inline static var MAX_32_PRECISION:Float = 4294967296.0;
 
-  public static function toFloat(i:haxe.Int64):Float
+  public static function fromFloat(f:Float):Int64
   {
-    var isNegative = false;
-    if (i < 0)
+    var h = Std.int(f / MAX_32_PRECISION);
+    var l = Std.int(f);
+    return Int64.make(h, l);
+  }
+
+  public static function toFloat(i:Int64):Float
+  {
+    var f:Float = Int64.getLow(i);
+    if (f < 0) f += MAX_32_PRECISION;
+    return (Int64.getHigh(i) * MAX_32_PRECISION + f);
+  }
+
+  public static function isToIntSafe(i:Int64):Bool
+  {
+    return i.high != i.low >> 31;
+  }
+
+  public static function toIntSafe(i:Int64):Int
+  {
+    try
     {
-      if (i < min) return -9223372036854775808.0; // most -ve value can't be made +ve
-      isNegative = true;
-      i = -i;
+      return Int64.toInt(i);
     }
-    var multiplier = 1.0, ret = 0.0;
-    for (_ in 0...64)
+    catch (e:Dynamic)
     {
-      if (haxe.Int64.and(i, one) != zero) ret += multiplier;
-      multiplier *= 2.0;
-      i = haxe.Int64.shr(i, 1);
+      throw 'Could not represent value "${Int64.toStr(i)}" as an integer.';
     }
-    return (isNegative ? -1 : 1) * ret;
   }
 }
diff --git a/source/funkin/util/tools/MapTools.hx b/source/funkin/util/tools/MapTools.hx
index 739c5efdb..1399fb791 100644
--- a/source/funkin/util/tools/MapTools.hx
+++ b/source/funkin/util/tools/MapTools.hx
@@ -25,6 +25,33 @@ class MapTools
     return [for (i in map.iterator()) i];
   }
 
+  /**
+   * Create a new array with all elements of the given array, to prevent modifying the original.
+   */
+  public static function clone<K, T>(map:Map<K, T>):Map<K, T>
+  {
+    return map.copy();
+  }
+
+  /**
+   * Create a new array with clones of all elements of the given array, to prevent modifying the original.
+   */
+  public static function deepClone<K, T, U:ICloneable<T>>(map:Map<K, U>):Map<K, T>
+  {
+    // TODO: This function does NOT work.
+    throw "Not implemented";
+
+    /*
+      var newMap:Map<K, T> = [];
+      // Replace each value with a clone of itself.
+      for (key in newMap.keys())
+      {
+        newMap.set(key, newMap.get(key).clone());
+      }
+      return newMap;
+     */
+  }
+
   /**
    * Return a list of keys from the map (as an array, rather than an iterator).
    * TODO: Rename this?
diff --git a/tests/unit/project.xml b/tests/unit/project.xml
index 2e505e015..dfbf06502 100644
--- a/tests/unit/project.xml
+++ b/tests/unit/project.xml
@@ -27,7 +27,6 @@
 	<haxelib name="hxCodec" /> <!-- Video playback -->
 	<haxelib name="thx.semver" /> <!-- Semantic version handling -->
 	<haxelib name="json2object" /> <!-- JSON parsing -->
-	<haxelib name="tink_json" /> <!-- JSON parsing -->
 
 	<!-- Test dependencies -->
 	<haxelib name="munit" /> <!-- Unit test execution -->
diff --git a/tests/unit/source/funkin/ConductorTest.hx b/tests/unit/source/funkin/ConductorTest.hx
index c65f3f297..a0cfedbab 100644
--- a/tests/unit/source/funkin/ConductorTest.hx
+++ b/tests/unit/source/funkin/ConductorTest.hx
@@ -31,23 +31,23 @@ class ConductorTest extends FunkinTest
   {
     // NOTE: Expected value comes first.
 
-    Assert.areEqual([], Conductor.timeChanges);
-    Assert.areEqual(null, Conductor.currentTimeChange);
+    Assert.areEqual([], Conductor.instance.timeChanges);
+    Assert.areEqual(null, Conductor.instance.currentTimeChange);
 
-    Assert.areEqual(0, Conductor.songPosition);
-    Assert.areEqual(Constants.DEFAULT_BPM, Conductor.bpm);
-    Assert.areEqual(null, Conductor.bpmOverride);
+    Assert.areEqual(0, Conductor.instance.songPosition);
+    Assert.areEqual(Constants.DEFAULT_BPM, Conductor.instance.bpm);
+    Assert.areEqual(null, Conductor.instance.bpmOverride);
 
-    Assert.areEqual(600, Conductor.beatLengthMs);
+    Assert.areEqual(600, Conductor.instance.beatLengthMs);
 
-    Assert.areEqual(4, Conductor.timeSignatureNumerator);
-    Assert.areEqual(4, Conductor.timeSignatureDenominator);
+    Assert.areEqual(4, Conductor.instance.timeSignatureNumerator);
+    Assert.areEqual(4, Conductor.instance.timeSignatureDenominator);
 
-    Assert.areEqual(0, Conductor.currentBeat);
-    Assert.areEqual(0, Conductor.currentStep);
-    Assert.areEqual(0.0, Conductor.currentStepTime);
+    Assert.areEqual(0, Conductor.instance.currentBeat);
+    Assert.areEqual(0, Conductor.instance.currentStep);
+    Assert.areEqual(0.0, Conductor.instance.currentStepTime);
 
-    Assert.areEqual(150, Conductor.stepLengthMs);
+    Assert.areEqual(150, Conductor.instance.stepLengthMs);
   }
 
   /**
@@ -60,23 +60,23 @@ class ConductorTest extends FunkinTest
     var currentConductorState:Null<ConductorState> = conductorState;
     Assert.isNotNull(currentConductorState);
 
-    Assert.areEqual(0, Conductor.songPosition);
+    Assert.areEqual(0, Conductor.instance.songPosition);
 
     step(); // 1
 
     var BPM_100_STEP_TIME = 1 / 9;
 
-    FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 1, Conductor.songPosition);
-    Assert.areEqual(0, Conductor.currentBeat);
-    Assert.areEqual(0, Conductor.currentStep);
-    FunkinAssert.areNear(1 / 9, Conductor.currentStepTime);
+    FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 1, Conductor.instance.songPosition);
+    Assert.areEqual(0, Conductor.instance.currentBeat);
+    Assert.areEqual(0, Conductor.instance.currentStep);
+    FunkinAssert.areNear(1 / 9, Conductor.instance.currentStepTime);
 
     step(7); // 8
 
-    FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 8, Conductor.songPosition);
-    Assert.areEqual(0, Conductor.currentBeat);
-    Assert.areEqual(0, Conductor.currentStep);
-    FunkinAssert.areNear(8 / 9, Conductor.currentStepTime);
+    FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 8, Conductor.instance.songPosition);
+    Assert.areEqual(0, Conductor.instance.currentBeat);
+    Assert.areEqual(0, Conductor.instance.currentStep);
+    FunkinAssert.areNear(8 / 9, Conductor.instance.currentStepTime);
 
     Assert.areEqual(0, currentConductorState.beatsHit);
     Assert.areEqual(0, currentConductorState.stepsHit);
@@ -88,10 +88,10 @@ class ConductorTest extends FunkinTest
     currentConductorState.beatsHit = 0;
     currentConductorState.stepsHit = 0;
 
-    FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 9, Conductor.songPosition);
-    Assert.areEqual(0, Conductor.currentBeat);
-    Assert.areEqual(1, Conductor.currentStep);
-    FunkinAssert.areNear(1.0, Conductor.currentStepTime);
+    FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 9, Conductor.instance.songPosition);
+    Assert.areEqual(0, Conductor.instance.currentBeat);
+    Assert.areEqual(1, Conductor.instance.currentStep);
+    FunkinAssert.areNear(1.0, Conductor.instance.currentStepTime);
 
     step(35 - 9); // 35
 
@@ -100,10 +100,10 @@ class ConductorTest extends FunkinTest
     currentConductorState.beatsHit = 0;
     currentConductorState.stepsHit = 0;
 
-    FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 35, Conductor.songPosition);
-    Assert.areEqual(0, Conductor.currentBeat);
-    Assert.areEqual(3, Conductor.currentStep);
-    FunkinAssert.areNear(3.0 + 8 / 9, Conductor.currentStepTime);
+    FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 35, Conductor.instance.songPosition);
+    Assert.areEqual(0, Conductor.instance.currentBeat);
+    Assert.areEqual(3, Conductor.instance.currentStep);
+    FunkinAssert.areNear(3.0 + 8 / 9, Conductor.instance.currentStepTime);
 
     step(); // 36
 
@@ -112,83 +112,83 @@ class ConductorTest extends FunkinTest
     currentConductorState.beatsHit = 0;
     currentConductorState.stepsHit = 0;
 
-    FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 36, Conductor.songPosition);
-    Assert.areEqual(1, Conductor.currentBeat);
-    Assert.areEqual(4, Conductor.currentStep);
-    FunkinAssert.areNear(4.0, Conductor.currentStepTime);
+    FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 36, Conductor.instance.songPosition);
+    Assert.areEqual(1, Conductor.instance.currentBeat);
+    Assert.areEqual(4, Conductor.instance.currentStep);
+    FunkinAssert.areNear(4.0, Conductor.instance.currentStepTime);
 
     step(50 - 36); // 50
 
-    FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 50, Conductor.songPosition);
-    Assert.areEqual(1, Conductor.currentBeat);
-    Assert.areEqual(5, Conductor.currentStep);
-    FunkinAssert.areNear(5.555555, Conductor.currentStepTime);
+    FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 50, Conductor.instance.songPosition);
+    Assert.areEqual(1, Conductor.instance.currentBeat);
+    Assert.areEqual(5, Conductor.instance.currentStep);
+    FunkinAssert.areNear(5.555555, Conductor.instance.currentStepTime);
 
     step(49); // 99
 
-    FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 99, Conductor.songPosition);
-    Assert.areEqual(2, Conductor.currentBeat);
-    Assert.areEqual(11, Conductor.currentStep);
-    FunkinAssert.areNear(11.0, Conductor.currentStepTime);
+    FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 99, Conductor.instance.songPosition);
+    Assert.areEqual(2, Conductor.instance.currentBeat);
+    Assert.areEqual(11, Conductor.instance.currentStep);
+    FunkinAssert.areNear(11.0, Conductor.instance.currentStepTime);
 
     step(1); // 100
 
-    FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 100, Conductor.songPosition);
-    Assert.areEqual(2, Conductor.currentBeat);
-    Assert.areEqual(11, Conductor.currentStep);
-    FunkinAssert.areNear(11.111111, Conductor.currentStepTime);
+    FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 100, Conductor.instance.songPosition);
+    Assert.areEqual(2, Conductor.instance.currentBeat);
+    Assert.areEqual(11, Conductor.instance.currentStep);
+    FunkinAssert.areNear(11.111111, Conductor.instance.currentStepTime);
   }
 
   @Test
   function testUpdateForcedBPM():Void
   {
-    Conductor.forceBPM(60);
+    Conductor.instance.forceBPM(60);
 
-    Assert.areEqual(0, Conductor.songPosition);
+    Assert.areEqual(0, Conductor.instance.songPosition);
 
     // 60 beats per minute = 1 beat per second
     // 1 beat per second = 1/60 beats per frame = 4/60 steps per frame
     step(); // Advances time 1/60 of 1 second.
 
-    FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 1, Conductor.songPosition);
-    Assert.areEqual(0, Conductor.currentBeat);
-    Assert.areEqual(0, Conductor.currentStep);
-    FunkinAssert.areNear(4 / 60, Conductor.currentStepTime); // 1/60 of 1 beat = 4/60 of 1 step
+    FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 1, Conductor.instance.songPosition);
+    Assert.areEqual(0, Conductor.instance.currentBeat);
+    Assert.areEqual(0, Conductor.instance.currentStep);
+    FunkinAssert.areNear(4 / 60, Conductor.instance.currentStepTime); // 1/60 of 1 beat = 4/60 of 1 step
 
     step(14 - 1); // 14
 
-    FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 14, Conductor.songPosition);
-    Assert.areEqual(0, Conductor.currentBeat);
-    Assert.areEqual(0, Conductor.currentStep);
-    FunkinAssert.areNear(1.0 - 4 / 60, Conductor.currentStepTime); // 1/60 of 1 beat = 4/60 of 1 step
+    FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 14, Conductor.instance.songPosition);
+    Assert.areEqual(0, Conductor.instance.currentBeat);
+    Assert.areEqual(0, Conductor.instance.currentStep);
+    FunkinAssert.areNear(1.0 - 4 / 60, Conductor.instance.currentStepTime); // 1/60 of 1 beat = 4/60 of 1 step
 
     step(); // 15
 
-    FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 15, Conductor.songPosition);
-    Assert.areEqual(0, Conductor.currentBeat);
-    Assert.areEqual(1, Conductor.currentStep);
-    FunkinAssert.areNear(1.0, Conductor.currentStepTime); // 1/60 of 1 beat = 4/60 of 1 step
+    FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 15, Conductor.instance.songPosition);
+    Assert.areEqual(0, Conductor.instance.currentBeat);
+    Assert.areEqual(1, Conductor.instance.currentStep);
+    FunkinAssert.areNear(1.0, Conductor.instance.currentStepTime); // 1/60 of 1 beat = 4/60 of 1 step
 
     step(45 - 1); // 59
 
-    FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 59, Conductor.songPosition);
-    Assert.areEqual(0, Conductor.currentBeat);
-    Assert.areEqual(3, Conductor.currentStep);
-    FunkinAssert.areNear(4.0 - 4 / 60, Conductor.currentStepTime);
+    FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 59, Conductor.instance.songPosition);
+    Assert.areEqual(0, Conductor.instance.currentBeat);
+    Assert.areEqual(3, Conductor.instance.currentStep);
+    FunkinAssert.areNear(4.0 - 4 / 60, Conductor.instance.currentStepTime);
 
     step(); // 60
 
-    FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 60, Conductor.songPosition);
-    Assert.areEqual(1, Conductor.currentBeat);
-    Assert.areEqual(4, Conductor.currentStep);
-    FunkinAssert.areNear(4.0, Conductor.currentStepTime);
+    FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 60, Conductor.instance.songPosition);
+    Assert.areEqual(1, Conductor.instance.currentBeat);
+    Assert.areEqual(4, Conductor.instance.currentStep);
+    FunkinAssert.areNear(4.0, Conductor.instance.currentStepTime);
 
     step(); // 61
 
-    FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 61, Conductor.songPosition);
-    Assert.areEqual(1, Conductor.currentBeat);
-    Assert.areEqual(4, Conductor.currentStep);
-    FunkinAssert.areNear(4.0 + 4 / 60, Conductor.currentStepTime);
+    FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 61, Conductor.instance.songPosition);
+    Assert.areEqual(1, Conductor.instance.currentBeat);
+    Assert.areEqual(4, Conductor.instance.currentStep);
+    FunkinAssert.areNear(4.0 + 4 / 60, Conductor.instance.currentStepTime);
   }
 
   @Test
@@ -196,50 +196,50 @@ class ConductorTest extends FunkinTest
   {
     // Start the song with a BPM of 120.
     var songTimeChanges:Array<SongTimeChange> = [new SongTimeChange(0, 120)];
-    Conductor.mapTimeChanges(songTimeChanges);
+    Conductor.instance.mapTimeChanges(songTimeChanges);
 
     // All should be at 0.
-    FunkinAssert.areNear(0, Conductor.songPosition);
-    Assert.areEqual(0, Conductor.currentBeat);
-    Assert.areEqual(0, Conductor.currentStep);
-    FunkinAssert.areNear(0.0, Conductor.currentStepTime); // 2/120 of 1 beat = 8/120 of 1 step
+    FunkinAssert.areNear(0, Conductor.instance.songPosition);
+    Assert.areEqual(0, Conductor.instance.currentBeat);
+    Assert.areEqual(0, Conductor.instance.currentStep);
+    FunkinAssert.areNear(0.0, Conductor.instance.currentStepTime); // 2/120 of 1 beat = 8/120 of 1 step
 
     // 120 beats per minute = 2 beat per second
     // 2 beat per second = 2/60 beats per frame = 16/120 steps per frame
     step(); // Advances time 1/60 of 1 second.
 
-    FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 1, Conductor.songPosition);
-    Assert.areEqual(0, Conductor.currentBeat);
-    Assert.areEqual(0, Conductor.currentStep);
-    FunkinAssert.areNear(16 / 120, Conductor.currentStepTime); // 2/120 of 1 beat = 8/120 of 1 step
+    FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 1, Conductor.instance.songPosition);
+    Assert.areEqual(0, Conductor.instance.currentBeat);
+    Assert.areEqual(0, Conductor.instance.currentStep);
+    FunkinAssert.areNear(16 / 120, Conductor.instance.currentStepTime); // 2/120 of 1 beat = 8/120 of 1 step
 
     step(15 - 1); // 15
 
-    FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 15, Conductor.songPosition);
-    Assert.areEqual(0, Conductor.currentBeat);
-    Assert.areEqual(2, Conductor.currentStep);
-    FunkinAssert.areNear(2.0, Conductor.currentStepTime); // 2/60 of 1 beat = 8/60 of 1 step
+    FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 15, Conductor.instance.songPosition);
+    Assert.areEqual(0, Conductor.instance.currentBeat);
+    Assert.areEqual(2, Conductor.instance.currentStep);
+    FunkinAssert.areNear(2.0, Conductor.instance.currentStepTime); // 2/60 of 1 beat = 8/60 of 1 step
 
     step(45 - 1); // 59
 
-    FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 59, Conductor.songPosition);
-    Assert.areEqual(1, Conductor.currentBeat);
-    Assert.areEqual(7, Conductor.currentStep);
-    FunkinAssert.areNear(7.0 + 104 / 120, Conductor.currentStepTime);
+    FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 59, Conductor.instance.songPosition);
+    Assert.areEqual(1, Conductor.instance.currentBeat);
+    Assert.areEqual(7, Conductor.instance.currentStep);
+    FunkinAssert.areNear(7.0 + 104 / 120, Conductor.instance.currentStepTime);
 
     step(); // 60
 
-    FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 60, Conductor.songPosition);
-    Assert.areEqual(2, Conductor.currentBeat);
-    Assert.areEqual(8, Conductor.currentStep);
-    FunkinAssert.areNear(8.0, Conductor.currentStepTime);
+    FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 60, Conductor.instance.songPosition);
+    Assert.areEqual(2, Conductor.instance.currentBeat);
+    Assert.areEqual(8, Conductor.instance.currentStep);
+    FunkinAssert.areNear(8.0, Conductor.instance.currentStepTime);
 
     step(); // 61
 
-    FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 61, Conductor.songPosition);
-    Assert.areEqual(2, Conductor.currentBeat);
-    Assert.areEqual(8, Conductor.currentStep);
-    FunkinAssert.areNear(8.0 + 8 / 60, Conductor.currentStepTime);
+    FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 61, Conductor.instance.songPosition);
+    Assert.areEqual(2, Conductor.instance.currentBeat);
+    Assert.areEqual(8, Conductor.instance.currentStep);
+    FunkinAssert.areNear(8.0 + 8 / 60, Conductor.instance.currentStepTime);
   }
 
   @Test
@@ -247,57 +247,57 @@ class ConductorTest extends FunkinTest
   {
     // Start the song with a BPM of 120.
     var songTimeChanges:Array<SongTimeChange> = [new SongTimeChange(0, 120), new SongTimeChange(3000, 90)];
-    Conductor.mapTimeChanges(songTimeChanges);
+    Conductor.instance.mapTimeChanges(songTimeChanges);
 
     // All should be at 0.
-    FunkinAssert.areNear(0, Conductor.songPosition);
-    Assert.areEqual(0, Conductor.currentBeat);
-    Assert.areEqual(0, Conductor.currentStep);
-    FunkinAssert.areNear(0.0, Conductor.currentStepTime); // 2/120 of 1 beat = 8/120 of 1 step
+    FunkinAssert.areNear(0, Conductor.instance.songPosition);
+    Assert.areEqual(0, Conductor.instance.currentBeat);
+    Assert.areEqual(0, Conductor.instance.currentStep);
+    FunkinAssert.areNear(0.0, Conductor.instance.currentStepTime); // 2/120 of 1 beat = 8/120 of 1 step
 
     // 120 beats per minute = 2 beat per second
     // 2 beat per second = 2/60 beats per frame = 16/120 steps per frame
     step(); // Advances time 1/60 of 1 second.
 
-    FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 1, Conductor.songPosition);
-    Assert.areEqual(0, Conductor.currentBeat);
-    Assert.areEqual(0, Conductor.currentStep);
-    FunkinAssert.areNear(16 / 120, Conductor.currentStepTime); // 4/120 of 1 beat = 16/120 of 1 step
+    FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 1, Conductor.instance.songPosition);
+    Assert.areEqual(0, Conductor.instance.currentBeat);
+    Assert.areEqual(0, Conductor.instance.currentStep);
+    FunkinAssert.areNear(16 / 120, Conductor.instance.currentStepTime); // 4/120 of 1 beat = 16/120 of 1 step
 
     step(60 - 1 - 1); // 59
 
-    FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 59, Conductor.songPosition);
-    Assert.areEqual(1, Conductor.currentBeat);
-    Assert.areEqual(7, Conductor.currentStep);
-    FunkinAssert.areNear(7.0 + 104 / 120, Conductor.currentStepTime);
+    FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 59, Conductor.instance.songPosition);
+    Assert.areEqual(1, Conductor.instance.currentBeat);
+    Assert.areEqual(7, Conductor.instance.currentStep);
+    FunkinAssert.areNear(7.0 + 104 / 120, Conductor.instance.currentStepTime);
 
     step(); // 60
 
-    FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 60, Conductor.songPosition);
-    Assert.areEqual(2, Conductor.currentBeat);
-    Assert.areEqual(8, Conductor.currentStep);
-    FunkinAssert.areNear(8.0, Conductor.currentStepTime);
+    FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 60, Conductor.instance.songPosition);
+    Assert.areEqual(2, Conductor.instance.currentBeat);
+    Assert.areEqual(8, Conductor.instance.currentStep);
+    FunkinAssert.areNear(8.0, Conductor.instance.currentStepTime);
 
     step(); // 61
 
-    FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 61, Conductor.songPosition);
-    Assert.areEqual(2, Conductor.currentBeat);
-    Assert.areEqual(8, Conductor.currentStep);
-    FunkinAssert.areNear(8.0 + 8 / 60, Conductor.currentStepTime);
+    FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 61, Conductor.instance.songPosition);
+    Assert.areEqual(2, Conductor.instance.currentBeat);
+    Assert.areEqual(8, Conductor.instance.currentStep);
+    FunkinAssert.areNear(8.0 + 8 / 60, Conductor.instance.currentStepTime);
 
     step(179 - 61); // 179
 
-    FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 179, Conductor.songPosition);
-    Assert.areEqual(5, Conductor.currentBeat);
-    Assert.areEqual(23, Conductor.currentStep);
-    FunkinAssert.areNear(23.0 + 52 / 60, Conductor.currentStepTime);
+    FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 179, Conductor.instance.songPosition);
+    Assert.areEqual(5, Conductor.instance.currentBeat);
+    Assert.areEqual(23, Conductor.instance.currentStep);
+    FunkinAssert.areNear(23.0 + 52 / 60, Conductor.instance.currentStepTime);
 
     step(); // 180 (3 seconds)
 
-    FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 180, Conductor.songPosition);
-    Assert.areEqual(6, Conductor.currentBeat);
-    Assert.areEqual(24, Conductor.currentStep);
-    FunkinAssert.areNear(24.0, Conductor.currentStepTime);
+    FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 180, Conductor.instance.songPosition);
+    Assert.areEqual(6, Conductor.instance.currentBeat);
+    Assert.areEqual(24, Conductor.instance.currentStep);
+    FunkinAssert.areNear(24.0, Conductor.instance.currentStepTime);
 
     step(); // 181 (3 + 1/60 seconds)
     // BPM has switched to 90!
@@ -305,24 +305,24 @@ class ConductorTest extends FunkinTest
     // 1.5 beat per second = 1.5/60 beats per frame = 3/120 beats per frame
     // = 12/120 steps per frame
 
-    FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 181, Conductor.songPosition);
-    Assert.areEqual(6, Conductor.currentBeat);
-    Assert.areEqual(24, Conductor.currentStep);
-    FunkinAssert.areNear(24.0 + 12 / 120, Conductor.currentStepTime);
+    FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 181, Conductor.instance.songPosition);
+    Assert.areEqual(6, Conductor.instance.currentBeat);
+    Assert.areEqual(24, Conductor.instance.currentStep);
+    FunkinAssert.areNear(24.0 + 12 / 120, Conductor.instance.currentStepTime);
 
     step(59); // 240 (4 seconds)
 
-    FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 240, Conductor.songPosition);
-    Assert.areEqual(7, Conductor.currentBeat);
-    Assert.areEqual(30, Conductor.currentStep);
-    FunkinAssert.areNear(30.0, Conductor.currentStepTime);
+    FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 240, Conductor.instance.songPosition);
+    Assert.areEqual(7, Conductor.instance.currentBeat);
+    Assert.areEqual(30, Conductor.instance.currentStep);
+    FunkinAssert.areNear(30.0, Conductor.instance.currentStepTime);
 
     step(); // 241 (4 + 1/60 seconds)
 
-    FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 241, Conductor.songPosition);
-    Assert.areEqual(7, Conductor.currentBeat);
-    Assert.areEqual(30, Conductor.currentStep);
-    FunkinAssert.areNear(30.0 + 12 / 120, Conductor.currentStepTime);
+    FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 241, Conductor.instance.songPosition);
+    Assert.areEqual(7, Conductor.instance.currentBeat);
+    Assert.areEqual(30, Conductor.instance.currentStep);
+    FunkinAssert.areNear(30.0 + 12 / 120, Conductor.instance.currentStepTime);
   }
 
   @Test
@@ -334,63 +334,63 @@ class ConductorTest extends FunkinTest
       new SongTimeChange(3000, 90),
       new SongTimeChange(6000, 180)
     ];
-    Conductor.mapTimeChanges(songTimeChanges);
+    Conductor.instance.mapTimeChanges(songTimeChanges);
 
     // Verify time changes.
-    Assert.areEqual(3, Conductor.timeChanges.length);
-    FunkinAssert.areNear(0, Conductor.timeChanges[0].beatTime);
-    FunkinAssert.areNear(6, Conductor.timeChanges[1].beatTime);
-    FunkinAssert.areNear(10.5, Conductor.timeChanges[2].beatTime);
+    Assert.areEqual(3, Conductor.instance.timeChanges.length);
+    FunkinAssert.areNear(0, Conductor.instance.timeChanges[0].beatTime);
+    FunkinAssert.areNear(6, Conductor.instance.timeChanges[1].beatTime);
+    FunkinAssert.areNear(10.5, Conductor.instance.timeChanges[2].beatTime);
 
     // All should be at 0.
-    FunkinAssert.areNear(0, Conductor.songPosition);
-    Assert.areEqual(0, Conductor.currentBeat);
-    Assert.areEqual(0, Conductor.currentStep);
-    FunkinAssert.areNear(0.0, Conductor.currentStepTime); // 2/120 of 1 beat = 8/120 of 1 step
+    FunkinAssert.areNear(0, Conductor.instance.songPosition);
+    Assert.areEqual(0, Conductor.instance.currentBeat);
+    Assert.areEqual(0, Conductor.instance.currentStep);
+    FunkinAssert.areNear(0.0, Conductor.instance.currentStepTime); // 2/120 of 1 beat = 8/120 of 1 step
 
     // 120 beats per minute = 2 beat per second
     // 2 beat per second = 2/60 beats per frame = 16/120 steps per frame
     step(); // Advances time 1/60 of 1 second.
 
-    FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 1, Conductor.songPosition);
-    Assert.areEqual(0, Conductor.currentBeat);
-    Assert.areEqual(0, Conductor.currentStep);
-    FunkinAssert.areNear(16 / 120, Conductor.currentStepTime); // 4/120 of 1 beat = 16/120 of 1 step
+    FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 1, Conductor.instance.songPosition);
+    Assert.areEqual(0, Conductor.instance.currentBeat);
+    Assert.areEqual(0, Conductor.instance.currentStep);
+    FunkinAssert.areNear(16 / 120, Conductor.instance.currentStepTime); // 4/120 of 1 beat = 16/120 of 1 step
 
     step(60 - 1 - 1); // 59
 
-    FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 59, Conductor.songPosition);
-    Assert.areEqual(1, Conductor.currentBeat);
-    Assert.areEqual(7, Conductor.currentStep);
-    FunkinAssert.areNear(7 + 104 / 120, Conductor.currentStepTime);
+    FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 59, Conductor.instance.songPosition);
+    Assert.areEqual(1, Conductor.instance.currentBeat);
+    Assert.areEqual(7, Conductor.instance.currentStep);
+    FunkinAssert.areNear(7 + 104 / 120, Conductor.instance.currentStepTime);
 
     step(); // 60
 
-    FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 60, Conductor.songPosition);
-    Assert.areEqual(2, Conductor.currentBeat);
-    Assert.areEqual(8, Conductor.currentStep);
-    FunkinAssert.areNear(8.0, Conductor.currentStepTime);
+    FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 60, Conductor.instance.songPosition);
+    Assert.areEqual(2, Conductor.instance.currentBeat);
+    Assert.areEqual(8, Conductor.instance.currentStep);
+    FunkinAssert.areNear(8.0, Conductor.instance.currentStepTime);
 
     step(); // 61
 
-    FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 61, Conductor.songPosition);
-    Assert.areEqual(2, Conductor.currentBeat);
-    Assert.areEqual(8, Conductor.currentStep);
-    FunkinAssert.areNear(8.0 + 8 / 60, Conductor.currentStepTime);
+    FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 61, Conductor.instance.songPosition);
+    Assert.areEqual(2, Conductor.instance.currentBeat);
+    Assert.areEqual(8, Conductor.instance.currentStep);
+    FunkinAssert.areNear(8.0 + 8 / 60, Conductor.instance.currentStepTime);
 
     step(179 - 61); // 179
 
-    FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 179, Conductor.songPosition);
-    Assert.areEqual(5, Conductor.currentBeat);
-    Assert.areEqual(23, Conductor.currentStep);
-    FunkinAssert.areNear(23.0 + 52 / 60, Conductor.currentStepTime);
+    FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 179, Conductor.instance.songPosition);
+    Assert.areEqual(5, Conductor.instance.currentBeat);
+    Assert.areEqual(23, Conductor.instance.currentStep);
+    FunkinAssert.areNear(23.0 + 52 / 60, Conductor.instance.currentStepTime);
 
     step(); // 180 (3 seconds)
 
-    FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 180, Conductor.songPosition);
-    Assert.areEqual(6, Conductor.currentBeat);
-    Assert.areEqual(24, Conductor.currentStep); // 23.999 => 24
-    FunkinAssert.areNear(24.0, Conductor.currentStepTime);
+    FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 180, Conductor.instance.songPosition);
+    Assert.areEqual(6, Conductor.instance.currentBeat);
+    Assert.areEqual(24, Conductor.instance.currentStep); // 23.999 => 24
+    FunkinAssert.areNear(24.0, Conductor.instance.currentStepTime);
 
     step(); // 181 (3 + 1/60 seconds)
     // BPM has switched to 90!
@@ -398,45 +398,45 @@ class ConductorTest extends FunkinTest
     // 1.5 beat per second = 1.5/60 beats per frame = 3/120 beats per frame
     // = 12/120 steps per frame
 
-    FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 181, Conductor.songPosition);
-    Assert.areEqual(6, Conductor.currentBeat);
-    Assert.areEqual(24, Conductor.currentStep);
-    FunkinAssert.areNear(24.0 + 12 / 120, Conductor.currentStepTime);
+    FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 181, Conductor.instance.songPosition);
+    Assert.areEqual(6, Conductor.instance.currentBeat);
+    Assert.areEqual(24, Conductor.instance.currentStep);
+    FunkinAssert.areNear(24.0 + 12 / 120, Conductor.instance.currentStepTime);
 
     step(60 - 1 - 1); // 240 (4 seconds)
 
-    FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 239, Conductor.songPosition);
-    Assert.areEqual(7, Conductor.currentBeat);
-    Assert.areEqual(29, Conductor.currentStep);
-    FunkinAssert.areNear(29.0 + 108 / 120, Conductor.currentStepTime);
+    FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 239, Conductor.instance.songPosition);
+    Assert.areEqual(7, Conductor.instance.currentBeat);
+    Assert.areEqual(29, Conductor.instance.currentStep);
+    FunkinAssert.areNear(29.0 + 108 / 120, Conductor.instance.currentStepTime);
 
     step(); // 240 (4 seconds)
 
-    FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 240, Conductor.songPosition);
-    Assert.areEqual(7, Conductor.currentBeat);
-    Assert.areEqual(30, Conductor.currentStep);
-    FunkinAssert.areNear(30.0, Conductor.currentStepTime);
+    FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 240, Conductor.instance.songPosition);
+    Assert.areEqual(7, Conductor.instance.currentBeat);
+    Assert.areEqual(30, Conductor.instance.currentStep);
+    FunkinAssert.areNear(30.0, Conductor.instance.currentStepTime);
 
     step(); // 241 (4 + 1/60 seconds)
 
-    FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 241, Conductor.songPosition);
-    Assert.areEqual(7, Conductor.currentBeat);
-    Assert.areEqual(30, Conductor.currentStep);
-    FunkinAssert.areNear(30.0 + 12 / 120, Conductor.currentStepTime);
+    FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 241, Conductor.instance.songPosition);
+    Assert.areEqual(7, Conductor.instance.currentBeat);
+    Assert.areEqual(30, Conductor.instance.currentStep);
+    FunkinAssert.areNear(30.0 + 12 / 120, Conductor.instance.currentStepTime);
 
     step(359 - 241); // 359 (5 + 59/60 seconds)
 
-    FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 359, Conductor.songPosition);
-    Assert.areEqual(10, Conductor.currentBeat);
-    Assert.areEqual(41, Conductor.currentStep);
-    FunkinAssert.areNear(41 + 108 / 120, Conductor.currentStepTime);
+    FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 359, Conductor.instance.songPosition);
+    Assert.areEqual(10, Conductor.instance.currentBeat);
+    Assert.areEqual(41, Conductor.instance.currentStep);
+    FunkinAssert.areNear(41 + 108 / 120, Conductor.instance.currentStepTime);
 
     step(); // 360
 
-    FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 360, Conductor.songPosition);
-    Assert.areEqual(10, Conductor.currentBeat);
-    Assert.areEqual(42, Conductor.currentStep); // 41.999
-    FunkinAssert.areNear(42.0, Conductor.currentStepTime);
+    FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 360, Conductor.instance.songPosition);
+    Assert.areEqual(10, Conductor.instance.currentBeat);
+    Assert.areEqual(42, Conductor.instance.currentStep); // 41.999
+    FunkinAssert.areNear(42.0, Conductor.instance.currentStepTime);
 
     step(); // 361
     // BPM has switched to 180!
@@ -444,24 +444,24 @@ class ConductorTest extends FunkinTest
     // 3 beat per second = 3/60 beats per frame
     // = 12/60 steps per frame
 
-    FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 361, Conductor.songPosition);
-    Assert.areEqual(10, Conductor.currentBeat);
-    Assert.areEqual(42, Conductor.currentStep);
-    FunkinAssert.areNear(42.0 + 12 / 60, Conductor.currentStepTime);
+    FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 361, Conductor.instance.songPosition);
+    Assert.areEqual(10, Conductor.instance.currentBeat);
+    Assert.areEqual(42, Conductor.instance.currentStep);
+    FunkinAssert.areNear(42.0 + 12 / 60, Conductor.instance.currentStepTime);
 
     step(); // 362
 
-    FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 362, Conductor.songPosition);
-    Assert.areEqual(10, Conductor.currentBeat);
-    Assert.areEqual(42, Conductor.currentStep);
-    FunkinAssert.areNear(42.0 + 24 / 60, Conductor.currentStepTime);
+    FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 362, Conductor.instance.songPosition);
+    Assert.areEqual(10, Conductor.instance.currentBeat);
+    Assert.areEqual(42, Conductor.instance.currentStep);
+    FunkinAssert.areNear(42.0 + 24 / 60, Conductor.instance.currentStepTime);
 
     step(3); // 365
 
-    FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 365, Conductor.songPosition);
-    Assert.areEqual(10, Conductor.currentBeat);
-    Assert.areEqual(43, Conductor.currentStep); // 42.999 => 42
-    FunkinAssert.areNear(43.0, Conductor.currentStepTime);
+    FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 365, Conductor.instance.songPosition);
+    Assert.areEqual(10, Conductor.instance.currentBeat);
+    Assert.areEqual(43, Conductor.instance.currentStep); // 42.999 => 42
+    FunkinAssert.areNear(43.0, Conductor.instance.currentStepTime);
   }
 }
 
@@ -504,6 +504,6 @@ class ConductorState extends FlxState
     super.update(elapsed);
 
     // On each step, increment the Conductor as though the song was playing.
-    Conductor.update(Conductor.songPosition + elapsed * Constants.MS_PER_SEC);
+    Conductor.instance.update(Conductor.instance.songPosition + elapsed * Constants.MS_PER_SEC);
   }
 }
diff --git a/tests/unit/source/funkin/data/BaseRegistryTest.hx b/tests/unit/source/funkin/data/BaseRegistryTest.hx
index 0be932d35..5f837ba97 100644
--- a/tests/unit/source/funkin/data/BaseRegistryTest.hx
+++ b/tests/unit/source/funkin/data/BaseRegistryTest.hx
@@ -156,6 +156,7 @@ 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>();
+    parser.ignoreUnknownVariables = false;
 
     switch (loadEntryFile(id))
     {
@@ -181,6 +182,7 @@ 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>();
+    parser.ignoreUnknownVariables = false;
 
     switch (loadEntryFile(id))
     {