From fb4a3071953a8cfd1017f150efc817978b7ace59 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Thu, 19 Oct 2023 01:24:20 -0400
Subject: [PATCH 01/21] Update hmm and HaxeUI

---
 hmm.json | 10 ++++------
 1 file changed, 4 insertions(+), 6 deletions(-)

diff --git a/hmm.json b/hmm.json
index 070d96cd0..f289e96d6 100644
--- a/hmm.json
+++ b/hmm.json
@@ -49,22 +49,20 @@
       "name": "haxeui-core",
       "type": "git",
       "dir": null,
-      "ref": "e92d5cfac847943fac84696b103670d55c2c774f",
+      "ref": "a0b37242910a83e792aae1b5b785f2482ed848e0",
       "url": "https://github.com/haxeui/haxeui-core"
     },
     {
       "name": "haxeui-flixel",
       "type": "git",
       "dir": null,
-      "ref": "be0b18553189a55fd42821026618a18615b070e3",
+      "ref": "bcbcac94dc852b253fff807e05f16a4ef01fb067",
       "url": "https://github.com/haxeui/haxeui-flixel"
     },
     {
       "name": "hmm",
-      "type": "git",
-      "dir": null,
-      "ref": "d514d7786cabf18b90e60fcee38399fd44c2ddfb",
-      "url": "https://github.com/andywhite37/hmm"
+      "type": "haxelib",
+      "version": "3.1.0"
     },
     {
       "name": "hscript",

From 8042f6c5a84fb3bb7c4bbe9c2f5c8411d7775dd8 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Thu, 19 Oct 2023 15:05:54 -0400
Subject: [PATCH 02/21] Update HaxeUI again

---
 hmm.json | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/hmm.json b/hmm.json
index f289e96d6..1d9e51d69 100644
--- a/hmm.json
+++ b/hmm.json
@@ -49,14 +49,14 @@
       "name": "haxeui-core",
       "type": "git",
       "dir": null,
-      "ref": "a0b37242910a83e792aae1b5b785f2482ed848e0",
+      "ref": "db6f81191abe386d891aca5a65c27ba6f8e10598",
       "url": "https://github.com/haxeui/haxeui-core"
     },
     {
       "name": "haxeui-flixel",
       "type": "git",
       "dir": null,
-      "ref": "bcbcac94dc852b253fff807e05f16a4ef01fb067",
+      "ref": "e10f51fe33b8d8d2dd3f21a0fd1d7c4d88d5d5c0",
       "url": "https://github.com/haxeui/haxeui-flixel"
     },
     {

From ffd0a9839346cb65ab95643ba2f5fa790988c20c Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Sat, 21 Oct 2023 01:04:50 -0400
Subject: [PATCH 03/21] FNFC file rework (includes command line quicklaunch)

---
 Project.xml                                   |   5 +-
 assets                                        |   2 +-
 source/Main.hx                                |   1 +
 source/funkin/InitState.hx                    |  20 +-
 source/{ => funkin}/Preloader.hx              |   5 +-
 source/funkin/data/DataParse.hx               |  16 ++
 source/funkin/data/DataWrite.hx               |  24 +-
 source/funkin/data/animation/AnimationData.hx |   3 +
 .../data/notestyle/NoteStyleRegistry.hx       |   4 +-
 source/funkin/data/song/SongData.hx           |  32 ++-
 source/funkin/data/song/SongRegistry.hx       |  52 +++-
 .../data/song/importer/ChartManifestData.hx   |  84 +++++++
 .../data/song/migrator/SongDataMigrator.hx    |  56 ++++-
 .../data/song/migrator/SongData_v2_0_0.hx     |   9 +-
 .../data/song/migrator/SongData_v2_1_0.hx     | 108 ++++++++
 source/funkin/play/PlayState.hx               |   6 +-
 source/funkin/play/song/Song.hx               |  25 +-
 source/funkin/play/stage/Stage.hx             |  23 +-
 source/funkin/play/stage/StageData.hx         | 130 +++++++---
 .../funkin/save/migrator/SaveDataMigrator.hx  |   5 +-
 .../debug/charting/ChartEditorAudioHandler.hx |   2 +-
 .../ui/debug/charting/ChartEditorCommand.hx   |  16 +-
 .../charting/ChartEditorDialogHandler.hx      | 158 ++++++++++--
 .../ChartEditorImportExportHandler.hx         | 232 +++++++++++++++---
 .../ui/debug/charting/ChartEditorState.hx     |  93 +++++--
 source/funkin/ui/haxeui/HaxeUIState.hx        |  17 ++
 source/funkin/util/CLIUtil.hx                 | 134 ++++++++++
 source/funkin/util/FileUtil.hx                |  65 ++++-
 source/funkin/util/SerializerUtil.hx          |   2 +
 source/funkin/util/VersionUtil.hx             |  18 ++
 .../data/notestyle/NoteStyleRegistryTest.hx   |   2 +-
 31 files changed, 1162 insertions(+), 187 deletions(-)
 rename source/{ => funkin}/Preloader.hx (93%)
 create mode 100644 source/funkin/data/song/importer/ChartManifestData.hx
 create mode 100644 source/funkin/data/song/migrator/SongData_v2_1_0.hx
 create mode 100644 source/funkin/util/CLIUtil.hx

diff --git a/Project.xml b/Project.xml
index 69400d8b1..9fad26fd7 100644
--- a/Project.xml
+++ b/Project.xml
@@ -4,10 +4,7 @@
 	<app title="Friday Night Funkin'" file="Funkin" packageName="com.funkin.fnf" package="com.funkin.fnf" main="Main" version="0.3.0" company="ninjamuffin99" />
 	<!--Switch Export with Unique ApplicationID and Icon-->
 	<set name="APP_ID" value="0x0100f6c013bbc000" />
-	<!--The flixel preloader is not accurate in Chrome. You can use it regularly if you embed the swf into a html file
-		or you can set the actual size of your file manually at "FlxPreloaderBase-onUpdate-bytesTotal"-->
-	<!-- <app preloader="Preloader" resizable="true" /> -->
-	<app preloader="Preloader" />
+	<app preloader="funkin.Preloader" />
 	<!--Minimum without FLX_NO_GAMEPAD: 11.8, without FLX_NO_NATIVE_CURSOR: 11.2-->
 	<set name="SWF_VERSION" value="11.8" />
 	<!-- ____________________________ Window Settings ___________________________ -->
diff --git a/assets b/assets
index ef79a6cf1..118b62295 160000
--- a/assets
+++ b/assets
@@ -1 +1 @@
-Subproject commit ef79a6cf1ae3dcbd86a5b798f8117a6c692c0156
+Subproject commit 118b622953171aaf127cb160538e21bc468620e2
diff --git a/source/Main.hx b/source/Main.hx
index dffe666b7..726c1fdbf 100644
--- a/source/Main.hx
+++ b/source/Main.hx
@@ -11,6 +11,7 @@ import openfl.display.Sprite;
 import openfl.events.Event;
 import openfl.Lib;
 import openfl.media.Video;
+import funkin.util.CLIUtil;
 import openfl.net.NetStream;
 
 class Main extends Sprite
diff --git a/source/funkin/InitState.hx b/source/funkin/InitState.hx
index ecfa32eb3..fbde22e1b 100644
--- a/source/funkin/InitState.hx
+++ b/source/funkin/InitState.hx
@@ -1,5 +1,6 @@
 package funkin;
 
+import funkin.ui.debug.charting.ChartEditorState;
 import flixel.FlxState;
 import flixel.addons.transition.FlxTransitionableState;
 import flixel.addons.transition.FlxTransitionSprite.GraphicTransTileDiamond;
@@ -26,6 +27,8 @@ import funkin.play.stage.StageData.StageDataParser;
 import funkin.play.character.CharacterData.CharacterDataParser;
 import funkin.modding.module.ModuleHandler;
 import funkin.ui.title.TitleState;
+import funkin.util.CLIUtil;
+import funkin.util.CLIUtil.CLIParams;
 #if discord_rpc
 import Discord.DiscordClient;
 #end
@@ -247,8 +250,21 @@ class InitState extends FlxState
    */
   function startGameNormally():Void
   {
-    FlxG.sound.cache(Paths.music('freakyMenu/freakyMenu'));
-    FlxG.switchState(new TitleState());
+    var params:CLIParams = CLIUtil.processArgs();
+    trace('Command line args: ${params}');
+
+    if (params.chart.shouldLoadChart)
+    {
+      FlxG.switchState(new ChartEditorState(
+        {
+          fnfcTargetPath: params.chart.chartPath,
+        }));
+    }
+    else
+    {
+      FlxG.sound.cache(Paths.music('freakyMenu/freakyMenu'));
+      FlxG.switchState(new TitleState());
+    }
   }
 
   /**
diff --git a/source/Preloader.hx b/source/funkin/Preloader.hx
similarity index 93%
rename from source/Preloader.hx
rename to source/funkin/Preloader.hx
index 3603d1a16..24015be05 100644
--- a/source/Preloader.hx
+++ b/source/funkin/Preloader.hx
@@ -1,4 +1,4 @@
-package;
+package funkin;
 
 import flash.Lib;
 import flash.display.Bitmap;
@@ -7,6 +7,7 @@ import flash.display.BlendMode;
 import flash.display.Sprite;
 import flixel.system.FlxBasePreloader;
 import openfl.display.Sprite;
+import funkin.util.CLIUtil;
 
 @:bitmap("art/preloaderArt.png") class LogoImage extends BitmapData {}
 
@@ -15,6 +16,8 @@ class Preloader extends FlxBasePreloader
   public function new(MinDisplayTime:Float = 0, ?AllowedURLs:Array<String>)
   {
     super(MinDisplayTime, AllowedURLs);
+
+    CLIUtil.resetWorkingDir(); // Bug fix for drag-and-drop.
   }
 
   var logo:Sprite;
diff --git a/source/funkin/data/DataParse.hx b/source/funkin/data/DataParse.hx
index 64a53d2a4..cbd168a61 100644
--- a/source/funkin/data/DataParse.hx
+++ b/source/funkin/data/DataParse.hx
@@ -104,6 +104,22 @@ class DataParse
     }
   }
 
+  /**
+   * Parser which outputs a `Either<Float, Array<Float>>`.
+   */
+  public static function eitherFloatOrFloats(json:Json, name:String):Null<Either<Float, Array<Float>>>
+  {
+    switch (json.value)
+    {
+      case JNumber(f):
+        return Either.Left(Std.parseFloat(f));
+      case JArray(fields):
+        return Either.Right(fields.map((field) -> cast Tools.getValue(field)));
+      default:
+        throw 'Expected property $name to be one or multiple floats, but it was ${json.value}.';
+    }
+  }
+
   /**
    * Parser which outputs a `Either<Float, LegacyScrollSpeeds>`.
    * Used by the FNF legacy JSON importer.
diff --git a/source/funkin/data/DataWrite.hx b/source/funkin/data/DataWrite.hx
index 2f3a7632f..e277cb01c 100644
--- a/source/funkin/data/DataWrite.hx
+++ b/source/funkin/data/DataWrite.hx
@@ -3,11 +3,14 @@ package funkin.data;
 import funkin.util.SerializerUtil;
 import thx.semver.Version;
 import thx.semver.VersionRule;
+import haxe.ds.Either;
 
 /**
  * `json2object` has an annotation `@:jcustomwrite` which allows for custom serialization of values to be written to JSON.
  *
  * Functions must be of the signature `(T) -> String`, where `T` is the type of the property.
+ *
+ * NOTE: Result must include quotation marks if the value is a string! json2object will not add them for you!
  */
 class DataWrite
 {
@@ -23,11 +26,12 @@ class DataWrite
   }
 
   /**
+   *
    * `@:jcustomwrite(funkin.data.DataWrite.semverVersion)`
    */
   public static function semverVersion(value:Version):String
   {
-    return value.toString();
+    return '"${value.toString()}"';
   }
 
   /**
@@ -35,6 +39,22 @@ class DataWrite
    */
   public static function semverVersionRule(value:VersionRule):String
   {
-    return value.toString();
+    return '"${value.toString()}"';
+  }
+
+  /**
+   * `@:jcustomwrite(funkin.data.DataWrite.eitherFloatOrFloats)`
+   */
+  public static function eitherFloatOrFloats(value:Null<Either<Float, Array<Float>>>):String
+  {
+    switch (value)
+    {
+      case null:
+        return '${1.0}';
+      case Left(inner):
+        return '$inner';
+      case Right(inner):
+        return dynamicValue(inner);
+    }
   }
 }
diff --git a/source/funkin/data/animation/AnimationData.hx b/source/funkin/data/animation/AnimationData.hx
index 9765f784c..a0214096c 100644
--- a/source/funkin/data/animation/AnimationData.hx
+++ b/source/funkin/data/animation/AnimationData.hx
@@ -59,7 +59,10 @@ typedef UnnamedAnimationData =
    * The prefix for the frames of the animation as defined by the XML file.
    * This will may or may not differ from the `name` of the animation,
    * depending on how your animator organized their FLA or whatever.
+   *
+   * NOTE: For Sparrow animations, this is not optional, but for Packer animations it is.
    */
+  @:optional
   var prefix:String;
 
   /**
diff --git a/source/funkin/data/notestyle/NoteStyleRegistry.hx b/source/funkin/data/notestyle/NoteStyleRegistry.hx
index da45da5f2..4255a644b 100644
--- a/source/funkin/data/notestyle/NoteStyleRegistry.hx
+++ b/source/funkin/data/notestyle/NoteStyleRegistry.hx
@@ -15,8 +15,6 @@ class NoteStyleRegistry extends BaseRegistry<NoteStyle, NoteStyleData>
 
   public static final NOTE_STYLE_DATA_VERSION_RULE:thx.semver.VersionRule = "1.0.x";
 
-  public static final DEFAULT_NOTE_STYLE_ID:String = "funkin";
-
   public static final instance:NoteStyleRegistry = new NoteStyleRegistry();
 
   public function new()
@@ -26,7 +24,7 @@ class NoteStyleRegistry extends BaseRegistry<NoteStyle, NoteStyleData>
 
   public function fetchDefault():NoteStyle
   {
-    return fetchEntry(DEFAULT_NOTE_STYLE_ID);
+    return fetchEntry(Constants.DEFAULT_NOTE_STYLE);
   }
 
   /**
diff --git a/source/funkin/data/song/SongData.hx b/source/funkin/data/song/SongData.hx
index 88993e519..c0bd26332 100644
--- a/source/funkin/data/song/SongData.hx
+++ b/source/funkin/data/song/SongData.hx
@@ -1,9 +1,13 @@
 package funkin.data.song;
 
-import flixel.util.typeLimit.OneOfTwo;
 import funkin.data.song.SongRegistry;
 import thx.semver.Version;
 
+/**
+ * Data containing information about a song.
+ * It should contain all the data needed to display a song in the Freeplay menu, or to load the assets required to play its chart.
+ * Data which is only necessary in-game should be stored in the SongChartData.
+ */
 @:nullSafety
 class SongMetadata
 {
@@ -35,13 +39,11 @@ class SongMetadata
    */
   public var playData:SongPlayData;
 
-  // @:default(funkin.data.song.SongRegistry.DEFAULT_GENERATEDBY)
+  @:default(funkin.data.song.SongRegistry.DEFAULT_GENERATEDBY)
   public var generatedBy:String;
 
-  // @:default(funkin.data.song.SongData.SongTimeFormat.MILLISECONDS)
   public var timeFormat:SongTimeFormat;
 
-  // @:default(funkin.data.song.SongData.SongTimeChange.DEFAULT_SONGTIMECHANGES)
   public var timeChanges:Array<SongTimeChange>;
 
   /**
@@ -64,7 +66,7 @@ class SongMetadata
     this.playData.difficulties = [];
     this.playData.characters = new SongCharacterData('bf', 'gf', 'dad');
     this.playData.stage = 'mainStage';
-    this.playData.noteSkin = 'funkin';
+    this.playData.noteStyle = Constants.DEFAULT_NOTE_STYLE;
     this.generatedBy = SongRegistry.DEFAULT_GENERATEDBY;
     // Variation ID.
     this.variation = (variation == null) ? Constants.DEFAULT_VARIATION : variation;
@@ -298,23 +300,27 @@ class SongPlayData
 
   /**
    * The note style used by this song.
-   * TODO: Rename to `noteStyle`? Renaming values is a breaking change to the metadata format.
    */
-  public var noteSkin:String;
+  public var noteStyle:String;
 
   /**
-   * The difficulty rating for this song as displayed in Freeplay.
-   * TODO: Adding this is a non-breaking change to the metadata format.
+   * The difficulty ratings for this song as displayed in Freeplay.
+   * Key is a difficulty ID or `default`.
    */
-  // public var rating:Int;
+  @:default(['default' => 1])
+  public var ratings:Map<String, Int>;
 
   /**
    * The album ID for the album to display in Freeplay.
-   * TODO: Adding this is a non-breaking change to the metadata format.
+   * If `null`, display no album.
    */
-  // public var album:String;
+  @:optional
+  public var album:Null<String>;
 
-  public function new() {}
+  public function new()
+  {
+    ratings = new Map<String, Int>();
+  }
 
   /**
    * Produces a string representation suitable for debugging.
diff --git a/source/funkin/data/song/SongRegistry.hx b/source/funkin/data/song/SongRegistry.hx
index 889fca707..8e0f4577d 100644
--- a/source/funkin/data/song/SongRegistry.hx
+++ b/source/funkin/data/song/SongRegistry.hx
@@ -2,6 +2,7 @@ package funkin.data.song;
 
 import funkin.data.song.SongData;
 import funkin.data.song.migrator.SongData_v2_0_0.SongMetadata_v2_0_0;
+import funkin.data.song.migrator.SongData_v2_1_0.SongMetadata_v2_1_0;
 import funkin.data.song.SongData.SongChartData;
 import funkin.data.song.SongData.SongMetadata;
 import funkin.play.song.ScriptedSong;
@@ -18,9 +19,9 @@ 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.1.0";
+  public static final SONG_METADATA_VERSION:thx.semver.Version = "2.2.0";
 
-  public static final SONG_METADATA_VERSION_RULE:thx.semver.VersionRule = "2.1.x";
+  public static final SONG_METADATA_VERSION_RULE:thx.semver.VersionRule = "2.2.x";
 
   public static final SONG_CHART_DATA_VERSION:thx.semver.Version = "2.0.0";
 
@@ -165,6 +166,10 @@ class SongRegistry extends BaseRegistry<Song, SongMetadata>
     {
       return parseEntryMetadata(id, variation);
     }
+    else if (VersionUtil.validateVersion(version, "2.1.x"))
+    {
+      return parseEntryMetadata_v2_1_0(id, variation);
+    }
     else if (VersionUtil.validateVersion(version, "2.0.x"))
     {
       return parseEntryMetadata_v2_0_0(id, variation);
@@ -182,6 +187,10 @@ class SongRegistry extends BaseRegistry<Song, SongMetadata>
     {
       return parseEntryMetadataRaw(contents, fileName);
     }
+    else if (VersionUtil.validateVersion(version, "2.1.x"))
+    {
+      return parseEntryMetadataRaw_v2_1_0(contents, fileName);
+    }
     else if (VersionUtil.validateVersion(version, "2.0.x"))
     {
       return parseEntryMetadataRaw_v2_0_0(contents, fileName);
@@ -192,12 +201,12 @@ class SongRegistry extends BaseRegistry<Song, SongMetadata>
     }
   }
 
-  function parseEntryMetadata_v2_0_0(id:String, ?variation:String):Null<SongMetadata>
+  function parseEntryMetadata_v2_1_0(id:String, ?variation:String):Null<SongMetadata>
   {
     variation = variation == null ? Constants.DEFAULT_VARIATION : variation;
 
-    var parser = new json2object.JsonParser<SongMetadata_v2_0_0>();
-    switch (loadEntryMetadataFile(id))
+    var parser = new json2object.JsonParser<SongMetadata_v2_1_0>();
+    switch (loadEntryMetadataFile(id, variation))
     {
       case {fileName: fileName, contents: contents}:
         parser.fromJson(contents, fileName);
@@ -209,6 +218,39 @@ class SongRegistry extends BaseRegistry<Song, SongMetadata>
       printErrors(parser.errors, id);
       return null;
     }
+    return cleanMetadata(parser.value.migrate(), variation);
+  }
+
+  function parseEntryMetadata_v2_0_0(id:String, ?variation:String):Null<SongMetadata>
+  {
+    variation = variation == null ? Constants.DEFAULT_VARIATION : variation;
+
+    var parser = new json2object.JsonParser<SongMetadata_v2_0_0>();
+    switch (loadEntryMetadataFile(id, variation))
+    {
+      case {fileName: fileName, contents: contents}:
+        parser.fromJson(contents, fileName);
+      default:
+        return null;
+    }
+    if (parser.errors.length > 0)
+    {
+      printErrors(parser.errors, id);
+      return null;
+    }
+    return cleanMetadata(parser.value.migrate(), variation);
+  }
+
+  function parseEntryMetadataRaw_v2_1_0(contents:String, ?fileName:String = 'raw'):Null<SongMetadata>
+  {
+    var parser = new json2object.JsonParser<SongMetadata_v2_1_0>();
+    parser.fromJson(contents, fileName);
+
+    if (parser.errors.length > 0)
+    {
+      printErrors(parser.errors, fileName);
+      return null;
+    }
     return parser.value.migrate();
   }
 
diff --git a/source/funkin/data/song/importer/ChartManifestData.hx b/source/funkin/data/song/importer/ChartManifestData.hx
new file mode 100644
index 000000000..0c7d2f0b0
--- /dev/null
+++ b/source/funkin/data/song/importer/ChartManifestData.hx
@@ -0,0 +1,84 @@
+package funkin.data.song.importer;
+
+/**
+ * A helper JSON blob found in `.fnfc` files.
+ */
+class ChartManifestData
+{
+  /**
+   * The current semantic version of the chart manifest data.
+   */
+  public static final CHART_MANIFEST_DATA_VERSION:thx.semver.Version = "1.0.0";
+
+  @:default(funkin.data.song.importer.ChartManifestData.CHART_MANIFEST_DATA_VERSION)
+  @:jcustomparse(funkin.data.DataParse.semverVersion)
+  @:jcustomwrite(funkin.data.DataWrite.semverVersion)
+  public var version:thx.semver.Version;
+
+  /**
+   * The internal song ID for this chart.
+   * The metadata and chart data file names are derived from this.
+   */
+  public var songId:String;
+
+  public function new(songId:String)
+  {
+    this.version = CHART_MANIFEST_DATA_VERSION;
+    this.songId = songId;
+  }
+
+  public function getMetadataFileName(?variation:String):String
+  {
+    if (variation == null || variation == '') variation = Constants.DEFAULT_VARIATION;
+
+    return '$songId-metadata${variation == Constants.DEFAULT_VARIATION ? '' : '-$variation'}.${Constants.EXT_DATA}';
+  }
+
+  public function getChartDataFileName(?variation:String):String
+  {
+    if (variation == null || variation == '') variation = Constants.DEFAULT_VARIATION;
+
+    return '$songId-chart${variation == Constants.DEFAULT_VARIATION ? '' : '-$variation'}.${Constants.EXT_DATA}';
+  }
+
+  public function getInstFileName(?variation:String):String
+  {
+    if (variation == null || variation == '') variation = Constants.DEFAULT_VARIATION;
+
+    return 'Inst${variation == Constants.DEFAULT_VARIATION ? '' : '-$variation'}.${Constants.EXT_SOUND}';
+  }
+
+  public function getVocalsFileName(charId:String, ?variation:String):String
+  {
+    if (variation == null || variation == '') variation = Constants.DEFAULT_VARIATION;
+
+    return 'Voices-$charId${variation == Constants.DEFAULT_VARIATION ? '' : '-$variation'}.${Constants.EXT_SOUND}';
+  }
+
+  /**
+   * Serialize this ChartManifestData into a JSON string.
+   * @return The JSON string.
+   */
+  public function serialize(pretty:Bool = true):String
+  {
+    var writer = new json2object.JsonWriter<ChartManifestData>();
+    return writer.write(this, pretty ? '  ' : null);
+  }
+
+  public static function deserialize(contents:String):Null<ChartManifestData>
+  {
+    var parser = new json2object.JsonParser<ChartManifestData>();
+    parser.fromJson(contents, 'manifest.json');
+
+    if (parser.errors.length > 0)
+    {
+      trace('[ChartManifest] Failed to parse chart file manifest');
+
+      for (error in parser.errors)
+        DataError.printError(error);
+
+      return null;
+    }
+    return parser.value;
+  }
+}
diff --git a/source/funkin/data/song/migrator/SongDataMigrator.hx b/source/funkin/data/song/migrator/SongDataMigrator.hx
index b5e08c832..2603ab1f8 100644
--- a/source/funkin/data/song/migrator/SongDataMigrator.hx
+++ b/source/funkin/data/song/migrator/SongDataMigrator.hx
@@ -7,6 +7,8 @@ import funkin.data.song.migrator.SongData_v2_0_0.SongMetadata_v2_0_0;
 import funkin.data.song.migrator.SongData_v2_0_0.SongPlayData_v2_0_0;
 import funkin.data.song.migrator.SongData_v2_0_0.SongPlayableChar_v2_0_0;
 
+using funkin.data.song.migrator.SongDataMigrator; // Does this even work lol?
+
 /**
  * This class contains functions to migrate older data formats to the current one.
  *
@@ -15,6 +17,48 @@ import funkin.data.song.migrator.SongData_v2_0_0.SongPlayableChar_v2_0_0;
  */
 class SongDataMigrator
 {
+  public static overload extern inline function migrate(input:SongData_v2_1_0.SongMetadata_v2_1_0):SongMetadata
+  {
+    return migrate_SongMetadata_v2_1_0(input);
+  }
+
+  public static function migrate_SongMetadata_v2_1_0(input:SongData_v2_1_0.SongMetadata_v2_1_0):SongMetadata
+  {
+    var result:SongMetadata = new SongMetadata(input.songName, input.artist, input.variation);
+    result.version = SongRegistry.SONG_METADATA_VERSION;
+    result.timeFormat = input.timeFormat;
+    result.divisions = input.divisions;
+    result.timeChanges = input.timeChanges;
+    result.looped = input.looped;
+    result.playData = input.playData.migrate();
+    result.generatedBy = input.generatedBy;
+
+    return result;
+  }
+
+  public static overload extern inline function migrate(input:SongData_v2_1_0.SongPlayData_v2_1_0):SongPlayData
+  {
+    return migrate_SongPlayData_v2_1_0(input);
+  }
+
+  public static function migrate_SongPlayData_v2_1_0(input:SongData_v2_1_0.SongPlayData_v2_1_0):SongPlayData
+  {
+    var result:SongPlayData = new SongPlayData();
+    result.songVariations = input.songVariations;
+    result.difficulties = input.difficulties;
+    result.stage = input.stage;
+    result.characters = input.characters;
+
+    // Renamed
+    result.noteStyle = input.noteSkin;
+
+    // Added
+    result.ratings = ['default' => 1];
+    result.album = null;
+
+    return result;
+  }
+
   public static overload extern inline function migrate(input:SongData_v2_0_0.SongMetadata_v2_0_0):SongMetadata
   {
     return migrate_SongMetadata_v2_0_0(input);
@@ -23,12 +67,12 @@ class SongDataMigrator
   public static function migrate_SongMetadata_v2_0_0(input:SongData_v2_0_0.SongMetadata_v2_0_0):SongMetadata
   {
     var result:SongMetadata = new SongMetadata(input.songName, input.artist, input.variation);
-    result.version = input.version;
+    result.version = SongRegistry.SONG_METADATA_VERSION;
     result.timeFormat = input.timeFormat;
     result.divisions = input.divisions;
     result.timeChanges = input.timeChanges;
     result.looped = input.looped;
-    result.playData = migrate_SongPlayData_v2_0_0(input.playData);
+    result.playData = input.playData.migrate();
     result.generatedBy = input.generatedBy;
 
     return result;
@@ -45,7 +89,13 @@ class SongDataMigrator
     result.songVariations = input.songVariations;
     result.difficulties = input.difficulties;
     result.stage = input.stage;
-    result.noteSkin = input.noteSkin;
+
+    // Added
+    result.ratings = ['default' => 1];
+    result.album = null;
+
+    // Renamed
+    result.noteStyle = input.noteSkin;
 
     // Fetch the first playable character and migrate it.
     var firstCharKey:Null<String> = input.playableChars.size() == 0 ? null : input.playableChars.keys().array()[0];
diff --git a/source/funkin/data/song/migrator/SongData_v2_0_0.hx b/source/funkin/data/song/migrator/SongData_v2_0_0.hx
index eeeed2f2b..62e3faf4c 100644
--- a/source/funkin/data/song/migrator/SongData_v2_0_0.hx
+++ b/source/funkin/data/song/migrator/SongData_v2_0_0.hx
@@ -42,6 +42,7 @@ class SongMetadata_v2_0_0
   @:default(false)
   public var looped:Bool;
 
+  @:default(funkin.data.song.SongRegistry.DEFAULT_GENERATEDBY)
   public var generatedBy:String;
 
   public var timeFormat:SongData.SongTimeFormat;
@@ -70,6 +71,13 @@ class SongPlayData_v2_0_0
    */
   public var playableChars:Map<String, SongPlayableChar_v2_0_0>;
 
+  /**
+   * In metadata version `v2.2.0`, this was renamed to `noteStyle`.
+   */
+  public var noteSkin:String;
+
+  // In 2.2.0, the ratings value was added.
+  // In 2.2.0, the album value was added.
   // ==========
   // UNMODIFIED VALUES
   // ==========
@@ -77,7 +85,6 @@ class SongPlayData_v2_0_0
   public var difficulties:Array<String>;
 
   public var stage:String;
-  public var noteSkin:String;
 
   public function new() {}
 
diff --git a/source/funkin/data/song/migrator/SongData_v2_1_0.hx b/source/funkin/data/song/migrator/SongData_v2_1_0.hx
new file mode 100644
index 000000000..57e4102d9
--- /dev/null
+++ b/source/funkin/data/song/migrator/SongData_v2_1_0.hx
@@ -0,0 +1,108 @@
+package funkin.data.song.migrator;
+
+import funkin.data.song.SongData;
+import funkin.data.song.SongRegistry;
+import thx.semver.Version;
+
+@:nullSafety
+class SongMetadata_v2_1_0
+{
+  // ==========
+  // MODIFIED VALUES
+  // ===========
+
+  /**
+   * In metadata `v2.2.0`, `SongPlayData` was refactored.
+   */
+  public var playData:SongPlayData_v2_1_0;
+
+  // ==========
+  // UNMODIFIED VALUES
+  // ==========
+  @:jcustomparse(funkin.data.DataParse.semverVersion)
+  @:jcustomwrite(funkin.data.DataWrite.semverVersion)
+  public var version:Version;
+
+  @:default("Unknown")
+  public var songName:String;
+
+  @:default("Unknown")
+  public var artist:String;
+
+  @:optional
+  @:default(96)
+  public var divisions:Null<Int>; // Optional field
+
+  @:optional
+  @:default(false)
+  public var looped:Bool;
+
+  @:default(funkin.data.song.SongRegistry.DEFAULT_GENERATEDBY)
+  public var generatedBy:String;
+
+  public var timeFormat:SongData.SongTimeFormat;
+
+  public var timeChanges:Array<SongData.SongTimeChange>;
+
+  /**
+   * Defaults to `Constants.DEFAULT_VARIATION`. Populated later.
+   */
+  @:jignored
+  public var variation:String;
+
+  public function new(songName:String, artist:String, ?variation:String)
+  {
+    this.version = SongRegistry.SONG_METADATA_VERSION;
+    this.songName = songName;
+    this.artist = artist;
+    this.timeFormat = 'ms';
+    this.divisions = null;
+    this.timeChanges = [new SongTimeChange(0, 100)];
+    this.looped = false;
+    this.playData = new SongPlayData_v2_1_0();
+    this.playData.songVariations = [];
+    this.playData.difficulties = [];
+    this.playData.characters = new SongCharacterData('bf', 'gf', 'dad');
+    this.playData.stage = 'mainStage';
+    this.playData.noteSkin = 'funkin';
+    this.generatedBy = SongRegistry.DEFAULT_GENERATEDBY;
+    // Variation ID.
+    this.variation = (variation == null) ? Constants.DEFAULT_VARIATION : variation;
+  }
+
+  /**
+   * Produces a string representation suitable for debugging.
+   */
+  public function toString():String
+  {
+    return 'SongMetadata[LEGACY:v2.1.0](${this.songName} by ${this.artist}, variation ${this.variation})';
+  }
+}
+
+class SongPlayData_v2_1_0
+{
+  /**
+   * In `v2.2.0`, this value was renamed to `noteStyle`.
+   */
+  public var noteSkin:String;
+
+  // In 2.2.0, the ratings value was added.
+  // In 2.2.0, the album value was added.
+  // ==========
+  // UNMODIFIED VALUES
+  // ==========
+  public var songVariations:Array<String>;
+  public var difficulties:Array<String>;
+  public var characters:SongData.SongCharacterData;
+  public var stage:String;
+
+  public function new() {}
+
+  /**
+   * Produces a string representation suitable for debugging.
+   */
+  public function toString():String
+  {
+    return 'SongPlayData[LEGACY:v2.1.0](${this.songVariations}, ${this.difficulties})';
+  }
+}
diff --git a/source/funkin/play/PlayState.hx b/source/funkin/play/PlayState.hx
index 1d3480efe..9a126e509 100644
--- a/source/funkin/play/PlayState.hx
+++ b/source/funkin/play/PlayState.hx
@@ -1471,7 +1471,7 @@ class PlayState extends MusicBeatSubState
     {
       case 'school': 'pixel';
       case 'schoolEvil': 'pixel';
-      default: 'funkin';
+      default: Constants.DEFAULT_NOTE_STYLE;
     }
     var noteStyle:NoteStyle = NoteStyleRegistry.instance.fetchEntry(noteStyleId);
     if (noteStyle == null) noteStyle = NoteStyleRegistry.instance.fetchDefault();
@@ -2389,8 +2389,8 @@ class PlayState extends MusicBeatSubState
 
     #if sys
     // spitter for ravy, teehee!!
-
-    var output = SerializerUtil.toJSON(inputSpitter);
+    var writer = new json2object.JsonWriter<Array<ScoreInput>>();
+    var output = writer.write(inputSpitter, '  ');
     sys.io.File.saveContent("./scores.json", output);
     #end
 
diff --git a/source/funkin/play/song/Song.hx b/source/funkin/play/song/Song.hx
index 60b8b9864..90920a710 100644
--- a/source/funkin/play/song/Song.hx
+++ b/source/funkin/play/song/Song.hx
@@ -96,11 +96,13 @@ class Song implements IPlayStateScriptedClass implements IRegistryEntry<SongMeta
     if (_data != null && _data.playData != null)
     {
       for (vari in _data.playData.songVariations)
+      {
         variations.push(vari);
-    }
 
-    for (meta in fetchVariationMetadata(id))
-      _metadata.push(meta);
+        var variMeta = fetchVariationMetadata(id, vari);
+        if (variMeta != null) _metadata.push(variMeta);
+      }
+    }
 
     if (_metadata.length == 0)
     {
@@ -178,7 +180,7 @@ class Song implements IPlayStateScriptedClass implements IRegistryEntry<SongMeta
         difficulty.generatedBy = metadata.generatedBy;
 
         difficulty.stage = metadata.playData.stage;
-        difficulty.noteStyle = metadata.playData.noteSkin;
+        difficulty.noteStyle = metadata.playData.noteStyle;
 
         difficulties.set(diffId, difficulty);
 
@@ -337,17 +339,12 @@ class Song implements IPlayStateScriptedClass implements IRegistryEntry<SongMeta
     return SongRegistry.instance.parseEntryMetadataWithMigration(id, Constants.DEFAULT_VARIATION, version);
   }
 
-  function fetchVariationMetadata(id:String):Array<SongMetadata>
+  function fetchVariationMetadata(id:String, vari:String):Null<SongMetadata>
   {
-    var result:Array<SongMetadata> = [];
-    for (vari in variations)
-    {
-      var version:Null<thx.semver.Version> = SongRegistry.instance.fetchEntryMetadataVersion(id, vari);
-      if (version == null) continue;
-      var meta:Null<SongMetadata> = SongRegistry.instance.parseEntryMetadataWithMigration(id, vari, version);
-      if (meta != null) result.push(meta);
-    }
-    return result;
+    var version:Null<thx.semver.Version> = SongRegistry.instance.fetchEntryMetadataVersion(id, vari);
+    if (version == null) return null;
+    var meta:Null<SongMetadata> = SongRegistry.instance.parseEntryMetadataWithMigration(id, vari, version);
+    return meta;
   }
 }
 
diff --git a/source/funkin/play/stage/Stage.hx b/source/funkin/play/stage/Stage.hx
index d9875e456..89b85d14c 100644
--- a/source/funkin/play/stage/Stage.hx
+++ b/source/funkin/play/stage/Stage.hx
@@ -176,13 +176,13 @@ class Stage extends FlxSpriteGroup implements IPlayStateScriptedClass
         continue;
       }
 
-      if (Std.isOfType(dataProp.scale, Array))
+      switch (dataProp.scale)
       {
-        propSprite.scale.set(dataProp.scale[0], dataProp.scale[1]);
-      }
-      else
-      {
-        propSprite.scale.set(dataProp.scale);
+        case Left(value):
+          propSprite.scale.set(value);
+
+        case Right(values):
+          propSprite.scale.set(values[0], values[1]);
       }
       propSprite.updateHitbox();
 
@@ -194,8 +194,15 @@ class Stage extends FlxSpriteGroup implements IPlayStateScriptedClass
       // If pixel, disable antialiasing.
       propSprite.antialiasing = !dataProp.isPixel;
 
-      propSprite.scrollFactor.x = dataProp.scroll[0];
-      propSprite.scrollFactor.y = dataProp.scroll[1];
+      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.zIndex = dataProp.zIndex;
 
diff --git a/source/funkin/play/stage/StageData.hx b/source/funkin/play/stage/StageData.hx
index c14e05aaf..29ca03b84 100644
--- a/source/funkin/play/stage/StageData.hx
+++ b/source/funkin/play/stage/StageData.hx
@@ -1,7 +1,6 @@
 package funkin.play.stage;
 
 import funkin.data.animation.AnimationData;
-import flixel.util.typeLimit.OneOfTwo;
 import funkin.play.stage.ScriptedStage;
 import funkin.play.stage.Stage;
 import funkin.util.VersionUtil;
@@ -157,15 +156,26 @@ class StageDataParser
     return rawJson;
   }
 
-  static function migrateStageData(rawJson:String, stageId:String)
+  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 stageData:StageData = cast Json.parse(rawJson);
-      return stageData;
+      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)
     {
@@ -269,24 +279,29 @@ class StageDataParser
         inputProp.danceEvery = DEFAULT_DANCEEVERY;
       }
 
-      if (inputProp.scale == null)
-      {
-        inputProp.scale = DEFAULT_SCALE;
-      }
-
       if (inputProp.animType == null)
       {
         inputProp.animType = DEFAULT_ANIMTYPE;
       }
 
-      if (Std.isOfType(inputProp.scale, Float))
+      switch (inputProp.scale)
       {
-        inputProp.scale = [inputProp.scale, inputProp.scale];
+        case null:
+          inputProp.scale = Right([DEFAULT_SCALE, DEFAULT_SCALE]);
+        case Left(value):
+          inputProp.scale = Right([value, value]);
+        case Right(_):
+          // Do nothing
       }
 
-      if (inputProp.scroll == null)
+      switch (inputProp.scroll)
       {
-        inputProp.scroll = DEFAULT_SCROLL;
+        case null:
+          inputProp.scroll = Right(DEFAULT_SCROLL);
+        case Left(value):
+          inputProp.scroll = Right([value, value]);
+        case Right(_):
+          // Do nothing
       }
 
       if (inputProp.alpha == null)
@@ -294,11 +309,6 @@ class StageDataParser
         inputProp.alpha = DEFAULT_ALPHA;
       }
 
-      if (Std.isOfType(inputProp.scroll, Float))
-      {
-        inputProp.scroll = [inputProp.scroll, inputProp.scroll];
-      }
-
       if (inputProp.animations == null)
       {
         inputProp.animations = [];
@@ -392,23 +402,39 @@ class StageDataParser
   }
 }
 
-typedef StageData =
+class StageData
 {
   /**
    * The sematic version number of the stage data JSON format.
    * Supports fancy comparisons like NPM does it's neat.
    */
-  var version:String;
+  public var version:String;
 
-  var name:String;
-  var cameraZoom:Null<Float>;
-  var props:Array<StageDataProp>;
-  var characters:
-    {
-      bf:StageDataCharacter,
-      dad:StageDataCharacter,
-      gf:StageDataCharacter,
-    };
+  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 =
@@ -417,6 +443,7 @@ 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;
 
   /**
@@ -435,27 +462,35 @@ typedef StageDataProp =
    * This is just like CSS, it isn't hard.
    * @default 0
    */
-  var zIndex:Null<Int>;
+  @: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
    */
-  var isPixel:Null<Bool>;
+  @: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.
-   * @default 1
    */
-  var scale:OneOfTwo<Float, Array<Float>>;
+  @: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
    */
-  var alpha:Null<Float>;
+  @:optional
+  @:default(1.0)
+  var alpha:Float;
 
   /**
    * If not zero, this prop will play an animation every X beats of the song.
@@ -464,7 +499,9 @@ typedef StageDataProp =
    *
    * @default 0
    */
-  var danceEvery:Null<Int>;
+  @:default(0)
+  @:optional
+  var danceEvery:Int;
 
   /**
    * How much the prop scrolls relative to the camera. Used to create a parallax effect.
@@ -474,25 +511,32 @@ typedef StageDataProp =
    * [0, 0] means the prop is not moved.
    * @default [0, 0]
    */
-  var scroll:OneOfTwo<Float, Array<Float>>;
+  @: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.
    */
-  var startingAnimation:String;
+  @:optional
+  var startingAnimation:Null<String>;
 
   /**
    * The animation type to use.
    * Options: "sparrow", "packer"
    * @default "sparrow"
    */
+  @:default("sparrow")
+  @:optional
   var animType:String;
 };
 
@@ -503,16 +547,22 @@ typedef StageDataCharacter =
    * Again, just like CSS.
    * @default 0
    */
-  ?zIndex:Int,
+  @:optional
+  @:default(0)
+  var zIndex:Int;
 
   /**
    * The position to render the character at.
    */
-  position:Array<Float>,
+  @: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
    */
-  cameraOffsets:Array<Float>,
+  @:optional
+  @:default([0, 0])
+  var cameraOffsets:Array<Float>;
 };
diff --git a/source/funkin/save/migrator/SaveDataMigrator.hx b/source/funkin/save/migrator/SaveDataMigrator.hx
index e7b7c7583..d5b23cfd9 100644
--- a/source/funkin/save/migrator/SaveDataMigrator.hx
+++ b/source/funkin/save/migrator/SaveDataMigrator.hx
@@ -13,8 +13,7 @@ class SaveDataMigrator
    */
   public static function migrate(inputData:Dynamic):Save
   {
-    // This deserializes directly into a `Version` object, not a `String`.
-    var version:Null<Version> = inputData?.version ?? null;
+    var version:Null<thx.semver.Version> = VersionUtil.parseVersion(inputData?.version ?? null);
 
     if (version == null)
     {
@@ -24,7 +23,7 @@ class SaveDataMigrator
     }
     else
     {
-      if (VersionUtil.validateVersionStr(version, Save.SAVE_DATA_VERSION_RULE))
+      if (VersionUtil.validateVersion(version, Save.SAVE_DATA_VERSION_RULE))
       {
         // Simply cast the structured data.
         var save:Save = inputData;
diff --git a/source/funkin/ui/debug/charting/ChartEditorAudioHandler.hx b/source/funkin/ui/debug/charting/ChartEditorAudioHandler.hx
index b5a6f36be..6f390e604 100644
--- a/source/funkin/ui/debug/charting/ChartEditorAudioHandler.hx
+++ b/source/funkin/ui/debug/charting/ChartEditorAudioHandler.hx
@@ -265,7 +265,7 @@ class ChartEditorAudioHandler
     {
       var data:Null<Bytes> = state.audioVocalTrackData.get(key);
       if (data == null) continue;
-      zipEntries.push(FileUtil.makeZIPEntryFromBytes('Vocals-${key}.ogg', data));
+      zipEntries.push(FileUtil.makeZIPEntryFromBytes('Voices-${key}.ogg', data));
     }
 
     return zipEntries;
diff --git a/source/funkin/ui/debug/charting/ChartEditorCommand.hx b/source/funkin/ui/debug/charting/ChartEditorCommand.hx
index 1014e67c2..98ca810d3 100644
--- a/source/funkin/ui/debug/charting/ChartEditorCommand.hx
+++ b/source/funkin/ui/debug/charting/ChartEditorCommand.hx
@@ -182,8 +182,8 @@ class SelectItemsCommand implements ChartEditorCommand
       state.currentEventSelection.push(event);
     }
 
-    state.noteDisplayDirty = true;
-    state.notePreviewDirty = true;
+    // state.noteDisplayDirty = true;
+    // state.notePreviewDirty = true;
   }
 
   public function undo(state:ChartEditorState):Void
@@ -191,8 +191,8 @@ class SelectItemsCommand implements ChartEditorCommand
     state.currentNoteSelection = SongDataUtils.subtractNotes(state.currentNoteSelection, this.notes);
     state.currentEventSelection = SongDataUtils.subtractEvents(state.currentEventSelection, this.events);
 
-    state.noteDisplayDirty = true;
-    state.notePreviewDirty = true;
+    // state.noteDisplayDirty = true;
+    // state.notePreviewDirty = true;
   }
 
   public function toString():String
@@ -452,8 +452,8 @@ class DeselectItemsCommand implements ChartEditorCommand
     state.currentNoteSelection = SongDataUtils.subtractNotes(state.currentNoteSelection, this.notes);
     state.currentEventSelection = SongDataUtils.subtractEvents(state.currentEventSelection, this.events);
 
-    state.noteDisplayDirty = true;
-    state.notePreviewDirty = true;
+    // state.noteDisplayDirty = true;
+    // state.notePreviewDirty = true;
   }
 
   public function undo(state:ChartEditorState):Void
@@ -468,8 +468,8 @@ class DeselectItemsCommand implements ChartEditorCommand
       state.currentEventSelection.push(event);
     }
 
-    state.noteDisplayDirty = true;
-    state.notePreviewDirty = true;
+    // state.noteDisplayDirty = true;
+    // state.notePreviewDirty = true;
   }
 
   public function toString():String
diff --git a/source/funkin/ui/debug/charting/ChartEditorDialogHandler.hx b/source/funkin/ui/debug/charting/ChartEditorDialogHandler.hx
index c26f6c805..dd5ddb06c 100644
--- a/source/funkin/ui/debug/charting/ChartEditorDialogHandler.hx
+++ b/source/funkin/ui/debug/charting/ChartEditorDialogHandler.hx
@@ -51,12 +51,13 @@ class ChartEditorDialogHandler
 {
   static final CHART_EDITOR_DIALOG_ABOUT_LAYOUT:String = Paths.ui('chart-editor/dialogs/about');
   static final CHART_EDITOR_DIALOG_WELCOME_LAYOUT:String = Paths.ui('chart-editor/dialogs/welcome');
+  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_LAYOUT:String = Paths.ui('chart-editor/dialogs/open-chart');
-  static final CHART_EDITOR_DIALOG_OPEN_CHART_ENTRY_LAYOUT:String = Paths.ui('chart-editor/dialogs/open-chart-entry');
+  static final CHART_EDITOR_DIALOG_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');
   static final CHART_EDITOR_DIALOG_USER_GUIDE_LAYOUT:String = Paths.ui('chart-editor/dialogs/user-guide');
   static final CHART_EDITOR_DIALOG_ADD_VARIATION_LAYOUT:String = Paths.ui('chart-editor/dialogs/add-variation');
@@ -83,6 +84,11 @@ class ChartEditorDialogHandler
     var dialog:Null<Dialog> = openDialog(state, CHART_EDITOR_DIALOG_WELCOME_LAYOUT, true, closable);
     if (dialog == null) throw 'Could not locate Welcome dialog';
 
+    dialog.onDialogClosed = function(_event) {
+      // Called when the Welcome dialog is closed while it is closable.
+      state.stopWelcomeMusic();
+    }
+
     // Create New Song "Easy/Normal/Hard"
     var linkCreateBasic:Null<Link> = dialog.findComponent('splashCreateFromSongBasic', Link);
     if (linkCreateBasic == null) throw 'Could not locate splashCreateFromSongBasic link in Welcome dialog';
@@ -129,7 +135,7 @@ class ChartEditorDialogHandler
       state.stopWelcomeMusic();
 
       // Open the "Open Chart" dialog
-      openBrowseWizard(state, false);
+      openBrowseFNFC(state, false);
     }
 
     var splashTemplateContainer:Null<VBox> = dialog.findComponent('splashTemplateContainer', VBox);
@@ -168,6 +174,126 @@ class ChartEditorDialogHandler
     return dialog;
   }
 
+  public static function openBrowseFNFC(state:ChartEditorState, closable:Bool):Null<Dialog>
+  {
+    var dialog:Null<Dialog> = openDialog(state, CHART_EDITOR_DIALOG_UPLOAD_CHART_LAYOUT, true, closable);
+    if (dialog == null) throw 'Could not locate Upload Chart dialog';
+
+    dialog.onDialogClosed = function(_event) {
+      if (_event.button == DialogButton.APPLY)
+      {
+        // Simply let the dialog close.
+      }
+      else
+      {
+        // User cancelled the wizard! Back to the welcome dialog.
+        openWelcomeDialog(state);
+      }
+    };
+
+    var buttonCancel:Null<Button> = dialog.findComponent('dialogCancel', Button);
+    if (buttonCancel == null) throw 'Could not locate dialogCancel button in Upload Chart dialog';
+
+    buttonCancel.onClick = function(_event) {
+      dialog.hideDialog(DialogButton.CANCEL);
+    }
+
+    var chartBox:Null<Box> = dialog.findComponent('chartBox', Box);
+    if (chartBox == null) throw 'Could not locate chartBox in Upload Chart dialog';
+
+    chartBox.onMouseOver = function(_event) {
+      chartBox.swapClass('upload-bg', 'upload-bg-hover');
+      Cursor.cursorMode = Pointer;
+    }
+
+    chartBox.onMouseOut = function(_event) {
+      chartBox.swapClass('upload-bg-hover', 'upload-bg');
+      Cursor.cursorMode = Default;
+    }
+
+    var onDropFile:String->Void;
+
+    chartBox.onClick = function(_event) {
+      Dialogs.openBinaryFile('Open Chart', [
+        {label: 'Friday Night Funkin\' Chart (.fnfc)', extension: 'fnfc'}], function(selectedFile:SelectedFileInfo) {
+          if (selectedFile != null && selectedFile.bytes != null)
+          {
+            try
+            {
+              if (ChartEditorImportExportHandler.loadFromFNFC(state, selectedFile.bytes))
+              {
+                #if !mac
+                NotificationManager.instance.addNotification(
+                  {
+                    title: 'Success',
+                    body: 'Loaded chart (${selectedFile.name})',
+                    type: NotificationType.Success,
+                    expiryMs: ChartEditorState.NOTIFICATION_DISMISS_TIME
+                  });
+                #end
+
+                if (selectedFile.fullPath != null) state.currentWorkingFilePath = selectedFile.fullPath;
+                dialog.hideDialog(DialogButton.APPLY);
+                removeDropHandler(onDropFile);
+              }
+            }
+            catch (err)
+            {
+              #if !mac
+              NotificationManager.instance.addNotification(
+                {
+                  title: 'Failure',
+                  body: 'Failed to load chart (${selectedFile.name}): ${err}',
+                  type: NotificationType.Error,
+                  expiryMs: ChartEditorState.NOTIFICATION_DISMISS_TIME
+                });
+              #end
+            }
+          }
+      });
+    }
+
+    onDropFile = function(pathStr:String) {
+      var path:Path = new Path(pathStr);
+      trace('Dropped file (${path})');
+
+      try
+      {
+        if (ChartEditorImportExportHandler.loadFromFNFCPath(state, path.toString()))
+        {
+          #if !mac
+          NotificationManager.instance.addNotification(
+            {
+              title: 'Success',
+              body: 'Loaded chart (${path.toString()})',
+              type: NotificationType.Success,
+              expiryMs: ChartEditorState.NOTIFICATION_DISMISS_TIME
+            });
+          #end
+
+          dialog.hideDialog(DialogButton.APPLY);
+          removeDropHandler(onDropFile);
+        }
+      }
+      catch (err)
+      {
+        #if !mac
+        NotificationManager.instance.addNotification(
+          {
+            title: 'Failure',
+            body: 'Failed to load chart (${path.toString()}): ${err}',
+            type: NotificationType.Error,
+            expiryMs: ChartEditorState.NOTIFICATION_DISMISS_TIME
+          });
+        #end
+      }
+    };
+
+    addDropHandler(chartBox, onDropFile);
+
+    return dialog;
+  }
+
   /**
    * Open the wizard for opening an existing chart from individual files.
    * @param state
@@ -622,9 +748,9 @@ class ChartEditorDialogHandler
     if (inputNoteStyle == null) throw 'Could not locate inputNoteStyle DropDown in Song Metadata dialog';
     inputNoteStyle.onChange = function(event:UIEvent) {
       if (event.data.id == null) return;
-      newSongMetadata.playData.noteSkin = event.data.id;
+      newSongMetadata.playData.noteStyle = event.data.id;
     };
-    var startingValueNoteStyle = ChartEditorDropdowns.populateDropdownWithNoteStyles(inputNoteStyle, newSongMetadata.playData.noteSkin);
+    var startingValueNoteStyle = ChartEditorDropdowns.populateDropdownWithNoteStyles(inputNoteStyle, newSongMetadata.playData.noteStyle);
     inputNoteStyle.value = startingValueNoteStyle;
 
     var inputCharacterPlayer:Null<FunkinDropDown> = dialog.findComponent('inputCharacterPlayer', FunkinDropDown);
@@ -765,9 +891,9 @@ class ChartEditorDialogHandler
             });
           #end
           #if FILE_DROP_SUPPORTED
-          vocalsEntryLabel.text = 'Vocals for $charName (drag and drop, or click to browse)\nSelected file: ${path.file}.${path.ext}';
+          vocalsEntryLabel.text = 'Voices for $charName (drag and drop, or click to browse)\nSelected file: ${path.file}.${path.ext}';
           #else
-          vocalsEntryLabel.text = 'Vocals for $charName (click to browse)\n${path.file}.${path.ext}';
+          vocalsEntryLabel.text = 'Voices for $charName (click to browse)\n${path.file}.${path.ext}';
           #end
 
           dialogNoVocals.hidden = true;
@@ -820,9 +946,9 @@ class ChartEditorDialogHandler
                   });
                 #end
                 #if FILE_DROP_SUPPORTED
-                vocalsEntryLabel.text = 'Vocals for $charName (drag and drop, or click to browse)\nSelected file: ${selectedFile.name}';
+                vocalsEntryLabel.text = 'Voices for $charName (drag and drop, or click to browse)\nSelected file: ${selectedFile.name}';
                 #else
-                vocalsEntryLabel.text = 'Vocals for $charName (click to browse)\n${selectedFile.name}';
+                vocalsEntryLabel.text = 'Voices for $charName (click to browse)\n${selectedFile.name}';
                 #end
 
                 dialogNoVocals.hidden = true;
@@ -877,7 +1003,7 @@ class ChartEditorDialogHandler
   @:haxe.warning('-WVarInit')
   public static function openChartDialog(state:ChartEditorState, closable:Bool = true):Dialog
   {
-    var dialog:Null<Dialog> = openDialog(state, CHART_EDITOR_DIALOG_OPEN_CHART_LAYOUT, true, closable);
+    var dialog:Null<Dialog> = openDialog(state, CHART_EDITOR_DIALOG_OPEN_CHART_PARTS_LAYOUT, true, closable);
     if (dialog == null) throw 'Could not locate Open Chart dialog';
 
     var buttonCancel:Null<Button> = dialog.findComponent('dialogCancel', Button);
@@ -915,7 +1041,7 @@ class ChartEditorDialogHandler
       }
 
       // Build an entry for -chart.json.
-      var songDefaultChartDataEntry:Component = state.buildComponent(CHART_EDITOR_DIALOG_OPEN_CHART_ENTRY_LAYOUT);
+      var songDefaultChartDataEntry:Component = state.buildComponent(CHART_EDITOR_DIALOG_OPEN_CHART_PARTS_ENTRY_LAYOUT);
       var songDefaultChartDataEntryLabel:Null<Label> = songDefaultChartDataEntry.findComponent('chartEntryLabel', Label);
       if (songDefaultChartDataEntryLabel == null) throw 'Could not locate chartEntryLabel in Open Chart dialog';
       #if FILE_DROP_SUPPORTED
@@ -931,7 +1057,7 @@ class ChartEditorDialogHandler
       for (variation in variations)
       {
         // Build entries for -metadata-<variation>.json.
-        var songVariationMetadataEntry:Component = state.buildComponent(CHART_EDITOR_DIALOG_OPEN_CHART_ENTRY_LAYOUT);
+        var songVariationMetadataEntry:Component = state.buildComponent(CHART_EDITOR_DIALOG_OPEN_CHART_PARTS_ENTRY_LAYOUT);
         var songVariationMetadataEntryLabel:Null<Label> = songVariationMetadataEntry.findComponent('chartEntryLabel', Label);
         if (songVariationMetadataEntryLabel == null) throw 'Could not locate chartEntryLabel in Open Chart dialog';
         #if FILE_DROP_SUPPORTED
@@ -955,7 +1081,7 @@ class ChartEditorDialogHandler
         chartContainerB.addComponent(songVariationMetadataEntry);
 
         // Build entries for -chart-<variation>.json.
-        var songVariationChartDataEntry:Component = state.buildComponent(CHART_EDITOR_DIALOG_OPEN_CHART_ENTRY_LAYOUT);
+        var songVariationChartDataEntry:Component = state.buildComponent(CHART_EDITOR_DIALOG_OPEN_CHART_PARTS_ENTRY_LAYOUT);
         var songVariationChartDataEntryLabel:Null<Label> = songVariationChartDataEntry.findComponent('chartEntryLabel', Label);
         if (songVariationChartDataEntryLabel == null) throw 'Could not locate chartEntryLabel in Open Chart dialog';
         #if FILE_DROP_SUPPORTED
@@ -1230,7 +1356,7 @@ class ChartEditorDialogHandler
       });
     }
 
-    var metadataEntry:Component = state.buildComponent(CHART_EDITOR_DIALOG_OPEN_CHART_ENTRY_LAYOUT);
+    var metadataEntry:Component = state.buildComponent(CHART_EDITOR_DIALOG_OPEN_CHART_PARTS_ENTRY_LAYOUT);
     var metadataEntryLabel:Null<Label> = metadataEntry.findComponent('chartEntryLabel', Label);
     if (metadataEntryLabel == null) throw 'Could not locate chartEntryLabel in Open Chart dialog';
 
@@ -1447,7 +1573,7 @@ class ChartEditorDialogHandler
 
     var dialogNoteStyle:Null<DropDown> = dialog.findComponent('dialogNoteStyle', DropDown);
     if (dialogNoteStyle == null) throw 'Could not locate dialogNoteStyle DropDown in Add Variation dialog';
-    dialogNoteStyle.value = state.currentSongMetadata.playData.noteSkin;
+    dialogNoteStyle.value = state.currentSongMetadata.playData.noteStyle;
 
     var dialogCharacterPlayer:Null<DropDown> = dialog.findComponent('dialogCharacterPlayer', DropDown);
     if (dialogCharacterPlayer == null) throw 'Could not locate dialogCharacterPlayer DropDown in Add Variation dialog';
@@ -1479,7 +1605,7 @@ class ChartEditorDialogHandler
       var pendingVariation:SongMetadata = new SongMetadata(dialogSongName.text, dialogSongArtist.text, dialogVariationName.text.toLowerCase());
 
       pendingVariation.playData.stage = dialogStage.value.id;
-      pendingVariation.playData.noteSkin = dialogNoteStyle.value;
+      pendingVariation.playData.noteStyle = dialogNoteStyle.value;
       pendingVariation.timeChanges[0].bpm = dialogBPM.value;
 
       state.songMetadata.set(pendingVariation.variation, pendingVariation);
diff --git a/source/funkin/ui/debug/charting/ChartEditorImportExportHandler.hx b/source/funkin/ui/debug/charting/ChartEditorImportExportHandler.hx
index 4d8ff18cb..c5cbdd5de 100644
--- a/source/funkin/ui/debug/charting/ChartEditorImportExportHandler.hx
+++ b/source/funkin/ui/debug/charting/ChartEditorImportExportHandler.hx
@@ -1,5 +1,6 @@
 package funkin.ui.debug.charting;
 
+import funkin.util.VersionUtil;
 import haxe.ui.notifications.NotificationType;
 import funkin.util.DateUtil;
 import haxe.io.Path;
@@ -7,10 +8,12 @@ import funkin.util.SerializerUtil;
 import haxe.ui.notifications.NotificationManager;
 import funkin.util.FileUtil;
 import funkin.util.FileUtil;
+import haxe.io.Bytes;
 import funkin.play.song.Song;
 import funkin.data.song.SongData.SongChartData;
 import funkin.data.song.SongData.SongMetadata;
 import funkin.data.song.SongRegistry;
+import funkin.data.song.importer.ChartManifestData;
 
 /**
  * Contains functions for importing, loading, saving, and exporting charts.
@@ -104,7 +107,7 @@ class ChartEditorImportExportHandler
   }
 
   /**
-   * Loads song metadata and chart data into the editor.
+   * Loads a chart from parsed song metadata and chart data into the editor.
    * @param newSongMetadata The song metadata to load.
    * @param newSongChartData The song chart data to load.
    */
@@ -135,15 +138,179 @@ class ChartEditorImportExportHandler
     }
   }
 
-  /**
-   * @param force Whether to force the export without prompting the user for a file location.
-   */
-  public static function exportAllSongData(state:ChartEditorState, force:Bool = false):Void
+  public static function loadFromFNFCPath(state:ChartEditorState, path:String):Bool
+  {
+    var bytes:Null<Bytes> = FileUtil.readBytesFromPath(path);
+    if (bytes == null) return false;
+
+    trace('Loaded ${bytes.length} bytes from $path');
+
+    var result:Bool = loadFromFNFC(state, bytes);
+    if (result)
+    {
+      state.currentWorkingFilePath = path;
+    }
+
+    return result;
+  }
+
+  /**
+   * Load a chart's metadata, chart data, and audio from an FNFC archive..
+   * @param state
+   * @param bytes
+   * @param instId
+   * @return Bool
+   */
+  public static function loadFromFNFC(state:ChartEditorState, bytes:Bytes):Bool
+  {
+    var songMetadatas:Map<String, SongMetadata> = [];
+    var songChartDatas:Map<String, SongChartData> = [];
+
+    var fileEntries:Array<haxe.zip.Entry> = FileUtil.readZIPFromBytes(bytes);
+    var mappedFileEntries:Map<String, haxe.zip.Entry> = FileUtil.mapZIPEntriesByName(fileEntries);
+
+    var manifestBytes:Null<Bytes> = mappedFileEntries.get('manifest.json')?.data;
+    if (manifestBytes == null) throw 'Could not locate manifest.';
+    var manifestString = manifestBytes.toString();
+    var manifest:Null<ChartManifestData> = ChartManifestData.deserialize(manifestString);
+    if (manifest == null) throw 'Could not read manifest.';
+
+    // Get the song ID.
+    var songId:String = manifest.songId;
+
+    var baseMetadataPath:String = manifest.getMetadataFileName();
+    var baseChartDataPath:String = manifest.getChartDataFileName();
+
+    var baseMetadataBytes:Null<Bytes> = mappedFileEntries.get(baseMetadataPath)?.data;
+    if (baseMetadataBytes == null) throw 'Could not locate metadata (default).';
+    var baseMetadataString:String = baseMetadataBytes.toString();
+    var baseMetadataVersion:Null<thx.semver.Version> = VersionUtil.getVersionFromJSON(baseMetadataString);
+    if (baseMetadataVersion == null) throw 'Could not read metadata version (default).';
+
+    var baseMetadata:Null<SongMetadata> = SongRegistry.instance.parseEntryMetadataRawWithMigration(baseMetadataString, baseMetadataPath, baseMetadataVersion);
+    if (baseMetadata == null) throw 'Could not read metadata (default).';
+    songMetadatas.set(Constants.DEFAULT_VARIATION, baseMetadata);
+
+    var baseChartDataBytes:Null<Bytes> = mappedFileEntries.get(baseChartDataPath)?.data;
+    if (baseChartDataBytes == null) throw 'Could not locate chart data (default).';
+    var baseChartDataString:String = baseChartDataBytes.toString();
+    var baseChartDataVersion:Null<thx.semver.Version> = VersionUtil.getVersionFromJSON(baseChartDataString);
+    if (baseChartDataVersion == null) throw 'Could not read chart data (default) version.';
+
+    var baseChartData:Null<SongChartData> = SongRegistry.instance.parseEntryChartDataRawWithMigration(baseChartDataString, baseChartDataPath,
+      baseChartDataVersion);
+    if (baseChartData == null) throw 'Could not read chart data (default).';
+    songChartDatas.set(Constants.DEFAULT_VARIATION, baseChartData);
+
+    var variationList:Array<String> = baseMetadata.playData.songVariations;
+
+    for (variation in variationList)
+    {
+      var variMetadataPath:String = manifest.getMetadataFileName(variation);
+      var variChartDataPath:String = manifest.getChartDataFileName(variation);
+
+      var variMetadataBytes:Null<Bytes> = mappedFileEntries.get(variMetadataPath)?.data;
+      if (variMetadataBytes == null) throw 'Could not locate metadata ($variation).';
+      var variMetadataString:String = variMetadataBytes.toString();
+      var variMetadataVersion:Null<thx.semver.Version> = VersionUtil.getVersionFromJSON(variMetadataString);
+      if (variMetadataVersion == null) throw 'Could not read metadata ($variation) version.';
+
+      var variMetadata:Null<SongMetadata> = SongRegistry.instance.parseEntryMetadataRawWithMigration(baseMetadataString, variMetadataPath, variMetadataVersion);
+      if (variMetadata == null) throw 'Could not read metadata ($variation).';
+      songMetadatas.set(variation, variMetadata);
+
+      var variChartDataBytes:Null<Bytes> = mappedFileEntries.get(variChartDataPath)?.data;
+      if (variChartDataBytes == null) throw 'Could not locate chart data ($variation).';
+      var variChartDataString:String = variChartDataBytes.toString();
+      var variChartDataVersion:Null<thx.semver.Version> = VersionUtil.getVersionFromJSON(variChartDataString);
+      if (variChartDataVersion == null) throw 'Could not read chart data version ($variation).';
+
+      var variChartData:Null<SongChartData> = SongRegistry.instance.parseEntryChartDataRawWithMigration(variChartDataString, variChartDataPath,
+        variChartDataVersion);
+      if (variChartData == null) throw 'Could not read chart data ($variation).';
+      songChartDatas.set(variation, variChartData);
+    }
+
+    ChartEditorAudioHandler.stopExistingInstrumental(state);
+    ChartEditorAudioHandler.stopExistingVocals(state);
+
+    // Load instrumentals
+    for (variation in [Constants.DEFAULT_VARIATION].concat(variationList))
+    {
+      var variMetadata:Null<SongMetadata> = songMetadatas.get(variation);
+      if (variMetadata == null) continue;
+
+      var instId:String = variMetadata?.playData?.characters?.instrumental ?? '';
+      var playerCharId:String = variMetadata?.playData?.characters?.player ?? Constants.DEFAULT_CHARACTER;
+      var opponentCharId:Null<String> = variMetadata?.playData?.characters?.opponent;
+
+      var instFileName:String = manifest.getInstFileName(instId);
+      var instFileBytes:Null<Bytes> = mappedFileEntries.get(instFileName)?.data;
+      if (instFileBytes != null)
+      {
+        if (!ChartEditorAudioHandler.loadInstFromBytes(state, instFileBytes, instId))
+        {
+          throw 'Could not load instrumental ($instFileName).';
+        }
+      }
+      else
+      {
+        throw 'Could not find instrumental ($instFileName).';
+      }
+
+      var playerVocalsFileName:String = manifest.getVocalsFileName(playerCharId);
+      var playerVocalsFileBytes:Null<Bytes> = mappedFileEntries.get(playerVocalsFileName)?.data;
+      if (playerVocalsFileBytes != null)
+      {
+        if (!ChartEditorAudioHandler.loadVocalsFromBytes(state, playerVocalsFileBytes, playerCharId, instId))
+        {
+          throw 'Could not load vocals ($playerCharId).';
+        }
+      }
+      else
+      {
+        throw 'Could not find vocals ($playerVocalsFileName).';
+      }
+
+      if (opponentCharId != null)
+      {
+        var opponentVocalsFileName:String = manifest.getVocalsFileName(opponentCharId);
+        var opponentVocalsFileBytes:Null<Bytes> = mappedFileEntries.get(opponentVocalsFileName)?.data;
+        if (opponentVocalsFileBytes != null)
+        {
+          if (!ChartEditorAudioHandler.loadVocalsFromBytes(state, opponentVocalsFileBytes, opponentCharId, instId))
+          {
+            throw 'Could not load vocals ($opponentCharId).';
+          }
+        }
+        else
+        {
+          throw 'Could not load vocals ($playerCharId-$instId).';
+        }
+      }
+    }
+
+    // Apply chart data.
+    trace(songMetadatas);
+    trace(songChartDatas);
+    loadSong(state, songMetadatas, songChartDatas);
+
+    state.switchToCurrentInstrumental();
+
+    return true;
+  }
+
+  /**
+   * @param force Whether to export without prompting. `false` will prompt the user for a location.
+   * @param targetPath where to export if `force` is `true`. If `null`, will export to the `backups` folder.
+   */
+  public static function exportAllSongData(state:ChartEditorState, force:Bool = false, ?targetPath:String):Void
   {
-    var tmp = false;
     var zipEntries:Array<haxe.zip.Entry> = [];
 
-    for (variation in state.availableVariations)
+    var variations = state.availableVariations;
+
+    for (variation in variations)
     {
       var variationId:String = variation;
       if (variation == '' || variation == 'default' || variation == 'normal')
@@ -162,50 +329,51 @@ class ChartEditorImportExportHandler
       {
         var variationMetadata:Null<SongMetadata> = state.songMetadata.get(variation);
         if (variationMetadata != null) zipEntries.push(FileUtil.makeZIPEntry('${state.currentSongId}-metadata-$variationId.json',
-          SerializerUtil.toJSON(variationMetadata)));
+          variationMetadata.serialize()));
         var variationChart:Null<SongChartData> = state.songChartData.get(variation);
-        if (variationChart != null) zipEntries.push(FileUtil.makeZIPEntry('${state.currentSongId}-chart-$variationId.json',
-          SerializerUtil.toJSON(variationChart)));
+        if (variationChart != null) zipEntries.push(FileUtil.makeZIPEntry('${state.currentSongId}-chart-$variationId.json', variationChart.serialize()));
       }
     }
 
-    if (state.audioInstTrackData != null) zipEntries.concat(ChartEditorAudioHandler.makeZIPEntriesFromInstrumentals(state));
-    if (state.audioVocalTrackData != null) zipEntries.concat(ChartEditorAudioHandler.makeZIPEntriesFromVocals(state));
+    if (state.audioInstTrackData != null) zipEntries = zipEntries.concat(ChartEditorAudioHandler.makeZIPEntriesFromInstrumentals(state));
+    if (state.audioVocalTrackData != null) zipEntries = zipEntries.concat(ChartEditorAudioHandler.makeZIPEntriesFromVocals(state));
+
+    var manifest:ChartManifestData = new ChartManifestData(state.currentSongId);
+    zipEntries.push(FileUtil.makeZIPEntry('manifest.json', manifest.serialize()));
 
     trace('Exporting ${zipEntries.length} files to ZIP...');
 
     if (force)
     {
-      var targetPath:String = if (tmp)
+      if (targetPath == null)
       {
-        Path.join([
-          FileUtil.getTempDir(),
-          'chart-editor-exit-${DateUtil.generateTimestamp()}.${Constants.EXT_CHART}'
-        ]);
-      }
-      else
-      {
-        Path.join([
+        targetPath = Path.join([
           './backups/',
-          'chart-editor-exit-${DateUtil.generateTimestamp()}.${Constants.EXT_CHART}'
+          'chart-editor-${DateUtil.generateTimestamp()}.${Constants.EXT_CHART}'
         ]);
       }
 
       // We have to force write because the program will die before the save dialog is closed.
       trace('Force exporting to $targetPath...');
       FileUtil.saveFilesAsZIPToPath(zipEntries, targetPath);
-      return;
     }
+    else
+    {
+      // Prompt and save.
+      var onSave:Array<String>->Void = function(paths:Array<String>) {
+        trace('Successfully exported files.');
+      };
 
-    // Prompt and save.
-    var onSave:Array<String>->Void = function(paths:Array<String>) {
-      trace('Successfully exported files.');
-    };
+      var onCancel:Void->Void = function() {
+        trace('Export cancelled.');
+      };
 
-    var onCancel:Void->Void = function() {
-      trace('Export cancelled.');
-    };
-
-    FileUtil.saveMultipleFiles(zipEntries, onSave, onCancel, '${state.currentSongId}-chart.${Constants.EXT_CHART}');
+      trace('Exporting to user-defined location...');
+      try
+      {
+        FileUtil.saveChartAsFNFC(zipEntries, onSave, onCancel, '${state.currentSongId}.${Constants.EXT_CHART}');
+      }
+      catch (e) {}
+    }
   }
 }
diff --git a/source/funkin/ui/debug/charting/ChartEditorState.hx b/source/funkin/ui/debug/charting/ChartEditorState.hx
index 05173726f..d392c2c06 100644
--- a/source/funkin/ui/debug/charting/ChartEditorState.hx
+++ b/source/funkin/ui/debug/charting/ChartEditorState.hx
@@ -1011,17 +1011,17 @@ class ChartEditorState extends HaxeUIState
 
   function get_currentSongNoteStyle():String
   {
-    if (currentSongMetadata.playData.noteSkin == null)
+    if (currentSongMetadata.playData.noteStyle == null)
     {
       // Initialize to the default value if not set.
-      currentSongMetadata.playData.noteSkin = 'funkin';
+      currentSongMetadata.playData.noteStyle = Constants.DEFAULT_NOTE_STYLE;
     }
-    return currentSongMetadata.playData.noteSkin;
+    return currentSongMetadata.playData.noteStyle;
   }
 
   function set_currentSongNoteStyle(value:String):String
   {
-    return currentSongMetadata.playData.noteSkin = value;
+    return currentSongMetadata.playData.noteStyle = value;
   }
 
   var currentSongStage(get, set):String;
@@ -1232,10 +1232,22 @@ class ChartEditorState extends HaxeUIState
 
   var renderedSelectionSquares:FlxTypedSpriteGroup<FlxSprite> = new FlxTypedSpriteGroup<FlxSprite>();
 
-  public function new()
+  /**
+   * The params which were passed in when the Chart Editor was initialized.
+   */
+  var params:Null<ChartEditorParams>;
+
+  /**
+   * The current file path which the chart editor is working with.
+   */
+  public var currentWorkingFilePath:Null<String>;
+
+  public function new(?params:ChartEditorParams)
   {
     // Load the HaxeUI XML file.
     super(CHART_EDITOR_LAYOUT);
+
+    this.params = params;
   }
 
   override function create():Void
@@ -1251,7 +1263,7 @@ class ChartEditorState extends HaxeUIState
     fixCamera();
 
     // Get rid of any music from the previous state.
-    FlxG.sound.music.stop();
+    if (FlxG.sound.music != null) FlxG.sound.music.stop();
 
     // Play the welcome music.
     setupWelcomeMusic();
@@ -1277,7 +1289,33 @@ class ChartEditorState extends HaxeUIState
 
     refresh();
 
-    ChartEditorDialogHandler.openWelcomeDialog(this, false);
+    if (params != null && params.fnfcTargetPath != null)
+    {
+      // Chart editor was opened from the command line. Open the FNFC file now!
+      if (ChartEditorImportExportHandler.loadFromFNFCPath(this, params.fnfcTargetPath))
+      {
+        // Don't open the welcome dialog!
+
+        #if !mac
+        NotificationManager.instance.addNotification(
+          {
+            title: 'Success',
+            body: 'Loaded chart (${params.fnfcTargetPath})',
+            type: NotificationType.Success,
+            expiryMs: ChartEditorState.NOTIFICATION_DISMISS_TIME
+          });
+        #end
+      }
+      else
+      {
+        // Song failed to load, open the Welcome dialog so we aren't in a broken state.
+        ChartEditorDialogHandler.openWelcomeDialog(this, false);
+      }
+    }
+    else
+    {
+      ChartEditorDialogHandler.openWelcomeDialog(this, false);
+    }
   }
 
   function setupWelcomeMusic()
@@ -1632,11 +1670,15 @@ class ChartEditorState extends HaxeUIState
       noteSnapQuantIndex++;
       if (noteSnapQuantIndex >= SNAP_QUANTS.length) noteSnapQuantIndex = 0;
     });
+    addUIRightClickListener('playbarNoteSnap', function(_) {
+      noteSnapQuantIndex--;
+      if (noteSnapQuantIndex < 0) noteSnapQuantIndex = SNAP_QUANTS.length - 1;
+    });
 
     // Add functionality to the menu items.
 
     addUIClickListener('menubarItemNewChart', _ -> ChartEditorDialogHandler.openWelcomeDialog(this, true));
-    addUIClickListener('menubarItemOpenChart', _ -> ChartEditorDialogHandler.openBrowseWizard(this, true));
+    addUIClickListener('menubarItemOpenChart', _ -> ChartEditorDialogHandler.openBrowseFNFC(this, true));
     addUIClickListener('menubarItemSaveChartAs', _ -> ChartEditorImportExportHandler.exportAllSongData(this));
     addUIClickListener('menubarItemLoadInst', _ -> ChartEditorDialogHandler.openUploadInstDialog(this, true));
     addUIClickListener('menubarItemImportChart', _ -> ChartEditorDialogHandler.openImportChartDialog(this, 'legacy', true));
@@ -1776,7 +1818,7 @@ class ChartEditorState extends HaxeUIState
       addUIChangeListener('menubarItemVolumeVocals', function(event:UIEvent) {
         var volume:Float = (event?.value ?? 0) / 100.0;
         if (audioVocalTrackGroup != null) audioVocalTrackGroup.volume = volume;
-        vocalsVolumeLabel.text = 'Vocals - ${Std.int(event.value)}%';
+        vocalsVolumeLabel.text = 'Voices - ${Std.int(event.value)}%';
       });
     }
 
@@ -1913,6 +1955,15 @@ class ChartEditorState extends HaxeUIState
   {
     FlxG.watch.addQuick('scrollPosInPixels', scrollPositionInPixels);
     FlxG.watch.addQuick('playheadPosInPixels', playheadPositionInPixels);
+
+    // Add a debug value which displays the current size of the note pool.
+    // The pool will grow as more notes need to be rendered at once.
+    // If this gets too big, something needs to be optimized somewhere! -Eric
+    if (renderedNotes != null && renderedNotes.members != null) FlxG.watch.addQuick("tapNotesRendered", renderedNotes.members.length);
+    if (renderedHoldNotes != null && renderedHoldNotes.members != null) FlxG.watch.addQuick("holdNotesRendered", renderedHoldNotes.members.length);
+    if (renderedEvents != null && renderedEvents.members != null) FlxG.watch.addQuick("eventsRendered", renderedEvents.members.length);
+    if (currentNoteSelection != null) FlxG.watch.addQuick("notesSelected", currentNoteSelection.length);
+    if (currentEventSelection != null) FlxG.watch.addQuick("eventsSelected", currentEventSelection.length);
   }
 
   /**
@@ -3037,15 +3088,6 @@ class ChartEditorState extends HaxeUIState
       // Sort the events DESCENDING. This keeps the sustain behind the associated note.
       renderedEvents.sort(FlxSort.byY, FlxSort.DESCENDING); // TODO: .group.insertionSort()
     }
-
-    // Add a debug value which displays the current size of the note pool.
-    // The pool will grow as more notes need to be rendered at once.
-    // If this gets too big, something needs to be optimized somewhere! -Eric
-    FlxG.watch.addQuick("tapNotesRendered", renderedNotes.members.length);
-    FlxG.watch.addQuick("holdNotesRendered", renderedHoldNotes.members.length);
-    FlxG.watch.addQuick("eventsRendered", renderedEvents.members.length);
-    FlxG.watch.addQuick("notesSelected", currentNoteSelection.length);
-    FlxG.watch.addQuick("eventsSelected", currentEventSelection.length);
   }
 
   /**
@@ -3152,7 +3194,7 @@ class ChartEditorState extends HaxeUIState
     // CTRL + O = Open Chart
     if (FlxG.keys.pressed.CONTROL && FlxG.keys.justPressed.O)
     {
-      ChartEditorDialogHandler.openBrowseWizard(this, true);
+      ChartEditorDialogHandler.openBrowseFNFC(this, true);
     }
 
     // CTRL + SHIFT + S = Save As
@@ -3168,10 +3210,13 @@ class ChartEditorState extends HaxeUIState
     }
   }
 
+  @:nullSafety(Off)
   function quitChartEditor():Void
   {
     autoSave();
     stopWelcomeMusic();
+    // TODO: PR Flixel to make onComplete nullable.
+    if (audioInstTrack != null) audioInstTrack.onComplete = null;
     FlxG.switchState(new MainMenuState());
   }
 
@@ -3691,7 +3736,7 @@ class ChartEditorState extends HaxeUIState
     if (inputStage != null) inputStage.value = currentSongMetadata.playData.stage;
 
     var inputNoteStyle:Null<DropDown> = toolbox.findComponent('inputNoteStyle', DropDown);
-    if (inputNoteStyle != null) inputNoteStyle.value = currentSongMetadata.playData.noteSkin;
+    if (inputNoteStyle != null) inputNoteStyle.value = currentSongMetadata.playData.noteStyle;
 
     var inputBPM:Null<NumberStepper> = toolbox.findComponent('inputBPM', NumberStepper);
     if (inputBPM != null) inputBPM.value = currentSongMetadata.timeChanges[0].bpm;
@@ -4351,3 +4396,11 @@ enum LiveInputStyle
   NumberKeys;
   WASD;
 }
+
+typedef ChartEditorParams =
+{
+  /**
+   * If non-null, load this song immediately instead of the welcome screen.
+   */
+  var ?fnfcTargetPath:String;
+};
diff --git a/source/funkin/ui/haxeui/HaxeUIState.hx b/source/funkin/ui/haxeui/HaxeUIState.hx
index 6d432b68c..78b93c431 100644
--- a/source/funkin/ui/haxeui/HaxeUIState.hx
+++ b/source/funkin/ui/haxeui/HaxeUIState.hx
@@ -108,6 +108,23 @@ class HaxeUIState extends MusicBeatState
     }
   }
 
+  /**
+   * Add an onRightClick listener to a HaxeUI menu bar item.
+   */
+  function addUIRightClickListener(key:String, callback:MouseEvent->Void):Void
+  {
+    var target:Component = findComponent(key);
+    if (target == null)
+    {
+      // Gracefully handle the case where the item can't be located.
+      trace('WARN: Could not locate menu item: $key');
+    }
+    else
+    {
+      target.onRightClick = callback;
+    }
+  }
+
   function setComponentText(key:String, text:String):Void
   {
     var target:Component = findComponent(key);
diff --git a/source/funkin/util/CLIUtil.hx b/source/funkin/util/CLIUtil.hx
new file mode 100644
index 000000000..a085ada6d
--- /dev/null
+++ b/source/funkin/util/CLIUtil.hx
@@ -0,0 +1,134 @@
+package funkin.util;
+
+/**
+ * Utilties for interpreting command line arguments.
+ */
+@:nullSafety
+class CLIUtil
+{
+  /**
+   * If we don't do this, dragging and dropping a file onto the executable
+   * causes it to be unable to find the assets folder.
+   */
+  public static function resetWorkingDir():Void
+  {
+    #if sys
+    var exeDir:String = haxe.io.Path.directory(Sys.programPath());
+    trace('Changing working directory from ${Sys.getCwd()} to ${exeDir}');
+    Sys.setCwd(exeDir);
+    #end
+  }
+
+  public static function processArgs():CLIParams
+  {
+    #if sys
+    return interpretArgs(cleanArgs(Sys.args()));
+    #else
+    return buildDefaultParams();
+    #end
+  }
+
+  static function interpretArgs(args:Array<String>):CLIParams
+  {
+    var result = buildDefaultParams();
+
+    result.args = [for (arg in args) arg]; // Copy the array.
+
+    while (args.length > 0)
+    {
+      var arg:Null<String> = args.shift();
+      if (arg == null) continue;
+
+      if (arg.startsWith('-'))
+      {
+        switch (arg)
+        {
+          // Flags
+          case '-h' | '--help':
+            printUsage();
+          case '-v' | '--version':
+            trace(Constants.GENERATED_BY);
+          case '--chart':
+            if (args.length == 0)
+            {
+              trace('No chart path provided.');
+              printUsage();
+            }
+            else
+            {
+              result.chart.shouldLoadChart = true;
+              result.chart.chartPath = args.shift();
+            }
+        }
+      }
+      else
+      {
+        // Make an attempt to interpret the argument.
+
+        if (arg.endsWith(Constants.EXT_CHART))
+        {
+          result.chart.shouldLoadChart = true;
+          result.chart.chartPath = arg;
+        }
+        else
+        {
+          trace('Unrecognized argument: ${arg}');
+          printUsage();
+        }
+      }
+    }
+
+    return result;
+  }
+
+  static function printUsage():Void
+  {
+    trace('Usage: Funkin.exe [--chart <chart>]');
+  }
+
+  static function buildDefaultParams():CLIParams
+  {
+    return {
+      args: [],
+
+      chart:
+        {
+          shouldLoadChart: false,
+          chartPath: null
+        }
+    };
+  }
+
+  /**
+   * Clean up the arguments passed to the application before parsing them.
+   * @param args The arguments to clean up.
+   * @return The cleaned up arguments.
+   */
+  static function cleanArgs(args:Array<String>):Array<String>
+  {
+    var result:Array<String> = [];
+
+    if (args == null || args.length == 0) return result;
+
+    return args.map(function(arg:String):String {
+      if (arg == null) return '';
+
+      return arg.trim();
+    }).filter(function(arg:String):Bool {
+      return arg != null && arg != '';
+    });
+  }
+}
+
+typedef CLIParams =
+{
+  var args:Array<String>;
+
+  var chart:CLIChartParams;
+}
+
+typedef CLIChartParams =
+{
+  var shouldLoadChart:Bool;
+  var chartPath:Null<String>;
+};
diff --git a/source/funkin/util/FileUtil.hx b/source/funkin/util/FileUtil.hx
index bae3126fb..72c9c43f1 100644
--- a/source/funkin/util/FileUtil.hx
+++ b/source/funkin/util/FileUtil.hx
@@ -14,6 +14,9 @@ import openfl.events.IOErrorEvent;
  */
 class FileUtil
 {
+  public static final FILE_FILTER_FNFC:FileFilter = new FileFilter("Friday Night Funkin' Chart (.fnfc)", "*.fnfc");
+  public static final FILE_FILTER_ZIP:FileFilter = new FileFilter("ZIP Archive (.zip)", "*.zip");
+
   /**
    * Browses for a single file, then calls `onSelect(path)` when a path chosen.
    * Note that on HTML5 this will immediately fail, you should call `openFile(onOpen:Resource->Void)` instead.
@@ -173,10 +176,11 @@ class FileUtil
    *
    * @return Whether the file dialog was opened successfully.
    */
-  public static function saveFile(data:Bytes, ?onSave:String->Void, ?onCancel:Void->Void, ?defaultFileName:String, ?dialogTitle:String):Bool
+  public static function saveFile(data:Bytes, ?typeFilter:Array<FileFilter>, ?onSave:String->Void, ?onCancel:Void->Void, ?defaultFileName:String,
+      ?dialogTitle:String):Bool
   {
     #if desktop
-    var filter:String = defaultFileName != null ? Path.extension(defaultFileName) : null;
+    var filter:String = convertTypeFilter(typeFilter);
 
     var fileDialog:FileDialog = new FileDialog();
     if (onSave != null) fileDialog.onSelect.add(onSave);
@@ -231,8 +235,7 @@ class FileUtil
         }
         catch (_)
         {
-          trace('Failed to write file (probably already exists): $filePath' + filePath);
-          continue;
+          throw 'Failed to write file (probably already exists): $filePath';
         }
         paths.push(filePath);
       }
@@ -269,7 +272,26 @@ class FileUtil
     };
 
     // Prompt the user to save the ZIP file.
-    saveFile(zipBytes, onSave, onCancel, defaultPath, 'Save files as ZIP...');
+    saveFile(zipBytes, [FILE_FILTER_ZIP], onSave, onCancel, defaultPath, 'Save files as ZIP...');
+
+    return true;
+  }
+
+  /**
+   * Takes an array of file entries and prompts the user to save them as a FNFC file.
+   */
+  public static function saveChartAsFNFC(resources:Array<Entry>, ?onSave:Array<String>->Void, ?onCancel:Void->Void, ?defaultPath:String,
+      force:Bool = false):Bool
+  {
+    // Create a ZIP file.
+    var zipBytes:Bytes = createZIPFromEntries(resources);
+
+    var onSave:String->Void = function(path:String) {
+      onSave([path]);
+    };
+
+    // Prompt the user to save the ZIP file.
+    saveFile(zipBytes, [FILE_FILTER_FNFC], onSave, onCancel, defaultPath, 'Save chart as FNFC...');
 
     return true;
   }
@@ -322,7 +344,8 @@ class FileUtil
   public static function readBytesFromPath(path:String):Bytes
   {
     #if sys
-    return Bytes.ofString(sys.io.File.getContent(path));
+    if (!sys.FileSystem.exists(path)) return null;
+    return sys.io.File.getBytes(path);
     #else
     return null;
     #end
@@ -559,6 +582,36 @@ class FileUtil
     return o.getBytes();
   }
 
+  public static function readZIPFromBytes(input:Bytes):Array<Entry>
+  {
+    trace('TEST: ' + input.length);
+    trace(input.sub(0, 30).toHex());
+
+    var bytesInput = new haxe.io.BytesInput(input);
+    var zippedEntries = haxe.zip.Reader.readZip(bytesInput);
+
+    var results:Array<Entry> = [];
+    for (entry in zippedEntries)
+    {
+      if (entry.compressed)
+      {
+        entry.data = haxe.zip.Reader.unzip(entry);
+      }
+      results.push(entry);
+    }
+    return results;
+  }
+
+  public static function mapZIPEntriesByName(input:Array<Entry>):Map<String, Entry>
+  {
+    var results:Map<String, Entry> = [];
+    for (entry in input)
+    {
+      results.set(entry.fileName, entry);
+    }
+    return results;
+  }
+
   /**
    * Create a ZIP file entry from a file name and its string contents.
    *
diff --git a/source/funkin/util/SerializerUtil.hx b/source/funkin/util/SerializerUtil.hx
index 0af0fc9ea..e73dde15f 100644
--- a/source/funkin/util/SerializerUtil.hx
+++ b/source/funkin/util/SerializerUtil.hx
@@ -21,6 +21,8 @@ class SerializerUtil
 
   /**
    * Convert a Haxe object to a JSON string.
+   * NOTE: Use `json2object.JsonWriter<T>` WHEREVER POSSIBLE. Do not use this one unless you ABSOLUTELY HAVE TO it's SLOW!
+   * And don't even THINK about using `haxe.Json.stringify` without the replacer!
    */
   public static function toJSON(input:Dynamic, pretty:Bool = true):String
   {
diff --git a/source/funkin/util/VersionUtil.hx b/source/funkin/util/VersionUtil.hx
index 1dc00473a..d6f95c7c5 100644
--- a/source/funkin/util/VersionUtil.hx
+++ b/source/funkin/util/VersionUtil.hx
@@ -61,4 +61,22 @@ class VersionUtil
     var version:thx.semver.Version = versionStr; // Implicit, not explicit, cast.
     return version;
   }
+
+  public static function parseVersion(input:Dynamic):Null<thx.semver.Version>
+  {
+    if (input == null) return null;
+
+    if (Std.isOfType(input, String))
+    {
+      var inputStr:String = input;
+      var version:thx.semver.Version = inputStr;
+      return version;
+    }
+    else
+    {
+      var semVer:thx.semver.Version.SemVer = input;
+      var version:thx.semver.Version = semVer;
+      return version;
+    }
+  }
 }
diff --git a/tests/unit/source/funkin/data/notestyle/NoteStyleRegistryTest.hx b/tests/unit/source/funkin/data/notestyle/NoteStyleRegistryTest.hx
index 447ee7831..0c8cd2d71 100644
--- a/tests/unit/source/funkin/data/notestyle/NoteStyleRegistryTest.hx
+++ b/tests/unit/source/funkin/data/notestyle/NoteStyleRegistryTest.hx
@@ -95,6 +95,6 @@ class NoteStyleRegistryTest extends FunkinTest
 
     // Verify the underlying call.
 
-    nsrMock.fetchEntry(NoteStyleRegistry.DEFAULT_NOTE_STYLE_ID).verify(times(1));
+    nsrMock.fetchEntry(Constants.DEFAULT_NOTE_STYLE).verify(times(1));
   }
 }

From b2dd58b9049a7ae9beec475d2fd6944baf3d09a7 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Fri, 27 Oct 2023 01:42:05 -0400
Subject: [PATCH 04/21] Resolve several bugs related to event note placement.

---
 Project.xml                                   |  3 ++
 assets                                        |  2 +-
 source/funkin/MusicBeatState.hx               | 30 ++++++++++++---
 source/funkin/data/event/SongEventData.hx     |  9 ++++-
 source/funkin/data/song/SongData.hx           | 14 ++++++-
 source/funkin/import.hx                       |  1 +
 .../debug/charting/ChartEditorEventSprite.hx  | 24 +++++++-----
 .../ui/debug/charting/ChartEditorState.hx     | 37 ++++++++-----------
 .../charting/ChartEditorToolboxHandler.hx     | 26 ++++++++++---
 source/funkin/util/tools/DynamicTools.hx      | 14 +++++++
 10 files changed, 112 insertions(+), 48 deletions(-)
 create mode 100644 source/funkin/util/tools/DynamicTools.hx

diff --git a/Project.xml b/Project.xml
index 69400d8b1..a1772a9ef 100644
--- a/Project.xml
+++ b/Project.xml
@@ -165,7 +165,10 @@
 	<icon path="art/iconOG.png" />
 	<haxedef name="CAN_OPEN_LINKS" unless="switch" />
 	<haxedef name="CAN_CHEAT" if="switch debug" />
+	<!-- I don't -->
 	<haxedef name="haxeui_no_mouse_reset" />
+	<!-- Clicking outside a dialog should deselect the current focused component. -->
+	<haxedef name="haxeui_focus_out_on_click" />
 	<!-- Skip the Intro -->
 	<section if="debug">
 		<!-- Starts the game at the specified week, at the first song -->
diff --git a/assets b/assets
index ef79a6cf1..6b6f2462a 160000
--- a/assets
+++ b/assets
@@ -1 +1 @@
-Subproject commit ef79a6cf1ae3dcbd86a5b798f8117a6c692c0156
+Subproject commit 6b6f2462afb099a7301a782ae521a0175fb7c71b
diff --git a/source/funkin/MusicBeatState.hx b/source/funkin/MusicBeatState.hx
index 9a986a8b5..b045fdc24 100644
--- a/source/funkin/MusicBeatState.hx
+++ b/source/funkin/MusicBeatState.hx
@@ -56,27 +56,45 @@ class MusicBeatState extends FlxTransitionableState implements IEventHandler
     Conductor.stepHit.remove(this.stepHit);
   }
 
-  override function update(elapsed:Float)
+  function handleControls():Void
   {
-    super.update(elapsed);
+    var isHaxeUIFocused:Bool = haxe.ui.focus.FocusManager.instance?.focus != null;
 
-    // Rebindable volume keys.
-    if (controls.VOLUME_MUTE) FlxG.sound.toggleMuted();
-    else if (controls.VOLUME_UP) FlxG.sound.changeVolume(0.1);
-    else if (controls.VOLUME_DOWN) FlxG.sound.changeVolume(-0.1);
+    if (!isHaxeUIFocused)
+    {
+      // Rebindable volume keys.
+      if (controls.VOLUME_MUTE) FlxG.sound.toggleMuted();
+      else if (controls.VOLUME_UP) FlxG.sound.changeVolume(0.1);
+      else if (controls.VOLUME_DOWN) FlxG.sound.changeVolume(-0.1);
+    }
+  }
 
+  function handleFunctionControls():Void
+  {
     // Emergency exit button.
     if (FlxG.keys.justPressed.F4) FlxG.switchState(new MainMenuState());
 
     // This can now be used in EVERY STATE YAY!
     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("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));
   }
diff --git a/source/funkin/data/event/SongEventData.hx b/source/funkin/data/event/SongEventData.hx
index 831a53fbd..7a167b031 100644
--- a/source/funkin/data/event/SongEventData.hx
+++ b/source/funkin/data/event/SongEventData.hx
@@ -208,25 +208,32 @@ typedef SongEventSchemaField =
   type:SongEventFieldType,
 
   /**
-   * Used for ENUM values.
+   * 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.
    */
diff --git a/source/funkin/data/song/SongData.hx b/source/funkin/data/song/SongData.hx
index 88993e519..eac4a4cb5 100644
--- a/source/funkin/data/song/SongData.hx
+++ b/source/funkin/data/song/SongData.hx
@@ -516,12 +516,22 @@ class SongEventData
 
   public inline function getInt(key:String):Null<Int>
   {
-    return value == null ? null : cast Reflect.field(value, key);
+    if (value == null) return null;
+    var result = Reflect.field(value, key);
+    if (result == null) return null;
+    if (Std.isOfType(result, Int)) return result;
+    if (Std.isOfType(result, String)) return Std.parseInt(cast result);
+    return cast result;
   }
 
   public inline function getFloat(key:String):Null<Float>
   {
-    return value == null ? null : cast Reflect.field(value, key);
+    if (value == null) return null;
+    var result = Reflect.field(value, key);
+    if (result == null) return null;
+    if (Std.isOfType(result, Float)) return result;
+    if (Std.isOfType(result, String)) return Std.parseFloat(cast result);
+    return cast result;
   }
 
   public inline function getString(key:String):String
diff --git a/source/funkin/import.hx b/source/funkin/import.hx
index 1c3a0fdb4..62241c4f4 100644
--- a/source/funkin/import.hx
+++ b/source/funkin/import.hx
@@ -12,6 +12,7 @@ using Lambda;
 using StringTools;
 using funkin.util.tools.ArraySortTools;
 using funkin.util.tools.ArrayTools;
+using funkin.util.tools.DynamicTools;
 using funkin.util.tools.Int64Tools;
 using funkin.util.tools.IteratorTools;
 using funkin.util.tools.MapTools;
diff --git a/source/funkin/ui/debug/charting/ChartEditorEventSprite.hx b/source/funkin/ui/debug/charting/ChartEditorEventSprite.hx
index 021abde0f..d5d81ae29 100644
--- a/source/funkin/ui/debug/charting/ChartEditorEventSprite.hx
+++ b/source/funkin/ui/debug/charting/ChartEditorEventSprite.hx
@@ -123,21 +123,25 @@ class ChartEditorEventSprite extends FlxSprite
 
   function set_eventData(value:Null<SongEventData>):Null<SongEventData>
   {
-    this.eventData = value;
-
-    if (this.eventData == null)
+    if (value == null)
     {
+      this.eventData = null;
       // Disown parent. MAKE SURE TO REVIVE BEFORE REUSING
       this.kill();
+      this.visible = false;
+      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();
       return this.eventData;
     }
-
-    this.visible = true;
-    playAnimation(this.eventData.event);
-    // Update the position to match the note data.
-    updateEventPosition();
-
-    return this.eventData;
   }
 
   public function updateEventPosition(?origin:FlxObject)
diff --git a/source/funkin/ui/debug/charting/ChartEditorState.hx b/source/funkin/ui/debug/charting/ChartEditorState.hx
index 05173726f..c421c08f2 100644
--- a/source/funkin/ui/debug/charting/ChartEditorState.hx
+++ b/source/funkin/ui/debug/charting/ChartEditorState.hx
@@ -71,6 +71,7 @@ import haxe.ui.core.Component;
 import haxe.ui.core.Screen;
 import haxe.ui.events.DragEvent;
 import haxe.ui.events.UIEvent;
+import haxe.ui.focus.FocusManager;
 import haxe.ui.notifications.NotificationManager;
 import haxe.ui.notifications.NotificationType;
 import openfl.Assets;
@@ -492,22 +493,14 @@ class ChartEditorState extends HaxeUIState
   var hitsoundsEnabledOpponent:Bool = true;
 
   /**
-   * Whether the user's mouse cursor is hovering over a SOLID component of the HaxeUI.
-   * If so, ignore mouse events underneath.
+   * Whether the user is focused on an input in the Haxe UI, and inputs are being fed into it.
+   * If the user clicks off the input, focus will leave.
    */
-  var isCursorOverHaxeUI(get, never):Bool;
+  var isHaxeUIFocused(get, never):Bool;
 
-  function get_isCursorOverHaxeUI():Bool
+  function get_isHaxeUIFocused():Bool
   {
-    return Screen.instance.hasSolidComponentUnderPoint(FlxG.mouse.screenX, FlxG.mouse.screenY);
-  }
-
-  var isCursorOverHaxeUIButton(get, never):Bool;
-
-  function get_isCursorOverHaxeUIButton():Bool
-  {
-    return Screen.instance.hasSolidComponentUnderPoint(FlxG.mouse.screenX, FlxG.mouse.screenY, haxe.ui.components.Button)
-      || Screen.instance.hasSolidComponentUnderPoint(FlxG.mouse.screenX, FlxG.mouse.screenY, haxe.ui.components.Link);
+    return FocusManager.instance.focus != null;
   }
 
   /**
@@ -1905,11 +1898,11 @@ class ChartEditorState extends HaxeUIState
     handleHelpKeybinds();
 
     #if debug
-    handleQuickWatch();
+    handleQuickWatches();
     #end
   }
 
-  function handleQuickWatch():Void
+  function handleQuickWatches():Void
   {
     FlxG.watch.addQuick('scrollPosInPixels', scrollPositionInPixels);
     FlxG.watch.addQuick('playheadPosInPixels', playheadPositionInPixels);
@@ -1957,8 +1950,8 @@ class ChartEditorState extends HaxeUIState
   **/
   function handleScrollKeybinds():Void
   {
-    // Don't scroll when the cursor is over the UI, unless a playbar button (the << >> ones) is pressed.
-    if (isCursorOverHaxeUI && playbarButtonPressed == null) return;
+    // Don't scroll when the user is interacting with the UI, unless a playbar button (the << >> ones) is pressed.
+    if (isHaxeUIFocused && playbarButtonPressed == null) return;
 
     var scrollAmount:Float = 0; // Amount to scroll the grid.
     var playheadAmount:Float = 0; // Amount to scroll the playhead relative to the grid.
@@ -2157,7 +2150,7 @@ class ChartEditorState extends HaxeUIState
     if (FlxG.mouse.justReleased) FlxG.sound.play(Paths.sound("chartingSounds/ClickUp"));
 
     // Note: If a menu is open in HaxeUI, don't handle cursor behavior.
-    var shouldHandleCursor:Bool = !isCursorOverHaxeUI || (selectionBoxStartPos != null);
+    var shouldHandleCursor:Bool = !isHaxeUIFocused || (selectionBoxStartPos != null);
     var eventColumn:Int = (STRUMLINE_SIZE * 2 + 1) - 1;
 
     if (shouldHandleCursor)
@@ -2612,14 +2605,14 @@ class ChartEditorState extends HaxeUIState
                 {
                   // Create an event and place it in the chart.
                   // TODO: Figure out configuring event data.
-                  var newEventData:SongEventData = new SongEventData(cursorSnappedMs, selectedEventKind, selectedEventData);
+                  var newEventData:SongEventData = new SongEventData(cursorSnappedMs, selectedEventKind, selectedEventData.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);
+                  var newNoteData:SongNoteData = new SongNoteData(cursorSnappedMs, cursorColumn, 0, selectedNoteKind.clone());
 
                   performCommand(new AddNotesCommand([newNoteData], FlxG.keys.pressed.CONTROL));
 
@@ -3392,7 +3385,7 @@ class ChartEditorState extends HaxeUIState
    */
   function handleTestKeybinds():Void
   {
-    if (!isHaxeUIDialogOpen && !isCursorOverHaxeUI && FlxG.keys.justPressed.ENTER)
+    if (!isHaxeUIDialogOpen && !isHaxeUIFocused && FlxG.keys.justPressed.ENTER)
     {
       var minimal = FlxG.keys.pressed.SHIFT;
       ChartEditorToolboxHandler.hideAllToolboxes(this);
@@ -3889,7 +3882,7 @@ class ChartEditorState extends HaxeUIState
       }
     }
 
-    if (FlxG.keys.justPressed.SPACE && !isHaxeUIDialogOpen)
+    if (FlxG.keys.justPressed.SPACE && !(isHaxeUIDialogOpen || isHaxeUIFocused))
     {
       toggleAudioPlayback();
     }
diff --git a/source/funkin/ui/debug/charting/ChartEditorToolboxHandler.hx b/source/funkin/ui/debug/charting/ChartEditorToolboxHandler.hx
index 7cee1edde..fb0981bae 100644
--- a/source/funkin/ui/debug/charting/ChartEditorToolboxHandler.hx
+++ b/source/funkin/ui/debug/charting/ChartEditorToolboxHandler.hx
@@ -346,6 +346,8 @@ class ChartEditorToolboxHandler
 
       trace('ChartEditorToolboxHandler.buildToolboxEventDataLayout() - Event type changed: $eventType');
 
+      state.selectedEventKind = eventType;
+
       var schema:SongEventSchema = SongEventParser.getEventSchema(eventType);
 
       if (schema == null)
@@ -356,6 +358,7 @@ class ChartEditorToolboxHandler
 
       buildEventDataFormFromSchema(state, toolboxEventsDataGrid, schema);
     }
+    toolboxEventsEventKind.value = state.selectedEventKind;
 
     return toolbox;
   }
@@ -379,6 +382,7 @@ class ChartEditorToolboxHandler
       // Add a label.
       var label:Label = new Label();
       label.text = field.title;
+      label.verticalAlign = "center";
       target.addComponent(label);
 
       var input:Component;
@@ -396,8 +400,8 @@ class ChartEditorToolboxHandler
           var numberStepper:NumberStepper = new NumberStepper();
           numberStepper.id = field.name;
           numberStepper.step = field.step ?? 0.1;
-          numberStepper.min = field.min ?? 0.0;
-          numberStepper.max = field.max ?? 1.0;
+          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:
@@ -416,7 +420,7 @@ class ChartEditorToolboxHandler
 
           for (optionName in field.keys.keys())
           {
-            var optionValue:Null<String> = field.keys.get(optionName);
+            var optionValue:Null<Dynamic> = field.keys.get(optionName);
             trace('$optionName : $optionValue');
             dropDown.dataSource.add({value: optionValue, text: optionName});
           }
@@ -438,11 +442,21 @@ class ChartEditorToolboxHandler
       target.addComponent(input);
 
       input.onChange = function(event:UIEvent) {
-        trace('ChartEditorToolboxHandler.buildEventDataFormFromSchema() - ${event.target.id} = ${event.target.value}');
+        var value = event.target.value;
+        if (field.type == ENUM)
+        {
+          value = event.target.value.value;
+        }
+        trace('ChartEditorToolboxHandler.buildEventDataFormFromSchema() - ${event.target.id} = ${value}');
 
-        if (event.target.value == null) state.selectedEventData.remove(event.target.id);
+        if (value == null)
+        {
+          state.selectedEventData.remove(event.target.id);
+        }
         else
-          state.selectedEventData.set(event.target.id, event.target.value);
+        {
+          state.selectedEventData.set(event.target.id, value);
+        }
       }
     }
   }
diff --git a/source/funkin/util/tools/DynamicTools.hx b/source/funkin/util/tools/DynamicTools.hx
new file mode 100644
index 000000000..47501ea22
--- /dev/null
+++ b/source/funkin/util/tools/DynamicTools.hx
@@ -0,0 +1,14 @@
+package funkin.util.tools;
+
+class DynamicTools
+{
+  /**
+   * Creates a full clone of the input `Dynamic`. Only guaranteed to work on anonymous structures.
+   * @param input The `Dynamic` to clone.
+   * @return A clone of the input `Dynamic`.
+   */
+  public static function clone(input:Dynamic):Dynamic
+  {
+    return Reflect.copy(input);
+  }
+}

From 73107dbc07bcdd6f6de64b6e9b2de69694ee894b Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Tue, 31 Oct 2023 14:43:01 -0400
Subject: [PATCH 05/21] Update HaxeUI cursor handling, and remove Funkin
 components

---
 assets                                        |  2 +-
 hmm.json                                      |  4 +-
 source/Main.hx                                |  1 +
 source/funkin/Conductor.hx                    |  4 ++
 source/funkin/data/song/SongDataUtils.hx      | 13 +++++
 source/funkin/input/Cursor.hx                 | 47 ++++++++++++++-----
 .../charting/ChartEditorDialogHandler.hx      | 12 ++---
 .../charting/ChartEditorToolboxHandler.hx     | 11 ++---
 .../ui/haxeui/components/FunkinButton.hx      | 30 ------------
 .../ui/haxeui/components/FunkinClickLabel.hx  | 30 ------------
 .../ui/haxeui/components/FunkinDropDown.hx    | 30 ------------
 .../components/FunkinHorizontalSlider.hx      | 30 ------------
 .../funkin/ui/haxeui/components/FunkinLink.hx | 30 ------------
 .../ui/haxeui/components/FunkinMenuBar.hx     | 32 -------------
 .../haxeui/components/FunkinMenuCheckBox.hx   | 30 ------------
 .../ui/haxeui/components/FunkinMenuItem.hx    | 30 ------------
 .../haxeui/components/FunkinMenuOptionBox.hx  | 30 ------------
 .../haxeui/components/FunkinNumberStepper.hx  | 30 ------------
 .../ui/haxeui/components/FunkinTextField.hx   | 30 ------------
 19 files changed, 66 insertions(+), 360 deletions(-)
 delete mode 100644 source/funkin/ui/haxeui/components/FunkinButton.hx
 delete mode 100644 source/funkin/ui/haxeui/components/FunkinClickLabel.hx
 delete mode 100644 source/funkin/ui/haxeui/components/FunkinDropDown.hx
 delete mode 100644 source/funkin/ui/haxeui/components/FunkinHorizontalSlider.hx
 delete mode 100644 source/funkin/ui/haxeui/components/FunkinLink.hx
 delete mode 100644 source/funkin/ui/haxeui/components/FunkinMenuBar.hx
 delete mode 100644 source/funkin/ui/haxeui/components/FunkinMenuCheckBox.hx
 delete mode 100644 source/funkin/ui/haxeui/components/FunkinMenuItem.hx
 delete mode 100644 source/funkin/ui/haxeui/components/FunkinMenuOptionBox.hx
 delete mode 100644 source/funkin/ui/haxeui/components/FunkinNumberStepper.hx
 delete mode 100644 source/funkin/ui/haxeui/components/FunkinTextField.hx

diff --git a/assets b/assets
index 6b6f2462a..3f8299aba 160000
--- a/assets
+++ b/assets
@@ -1 +1 @@
-Subproject commit 6b6f2462afb099a7301a782ae521a0175fb7c71b
+Subproject commit 3f8299aba4e308a6c86ce8f499b697de04909ad2
diff --git a/hmm.json b/hmm.json
index 070d96cd0..69e086fd5 100644
--- a/hmm.json
+++ b/hmm.json
@@ -49,14 +49,14 @@
       "name": "haxeui-core",
       "type": "git",
       "dir": null,
-      "ref": "e92d5cfac847943fac84696b103670d55c2c774f",
+      "ref": "815e94dd5aa6cf09c5ddcef1666a54449ffde8dc",
       "url": "https://github.com/haxeui/haxeui-core"
     },
     {
       "name": "haxeui-flixel",
       "type": "git",
       "dir": null,
-      "ref": "be0b18553189a55fd42821026618a18615b070e3",
+      "ref": "bc706d67efc093cd3b1623d0e9d599b326bbd330",
       "url": "https://github.com/haxeui/haxeui-flixel"
     },
     {
diff --git a/source/Main.hx b/source/Main.hx
index dffe666b7..a9879c1a5 100644
--- a/source/Main.hx
+++ b/source/Main.hx
@@ -110,5 +110,6 @@ class Main extends Sprite
     Toolkit.init();
     Toolkit.theme = 'dark'; // don't be cringe
     Toolkit.autoScale = false;
+    funkin.input.Cursor.registerHaxeUICursors();
   }
 }
diff --git a/source/funkin/Conductor.hx b/source/funkin/Conductor.hx
index b79ae0fc4..10bf505f0 100644
--- a/source/funkin/Conductor.hx
+++ b/source/funkin/Conductor.hx
@@ -5,6 +5,7 @@ import flixel.util.FlxSignal;
 import flixel.math.FlxMath;
 import funkin.play.song.Song.SongDifficulty;
 import funkin.data.song.SongData.SongTimeChange;
+import funkin.data.song.SongDataUtils;
 
 /**
  * A core class which handles musical timing throughout the game,
@@ -257,6 +258,9 @@ class Conductor
   {
     timeChanges = [];
 
+    // Sort in place just in case it's out of order.
+    SongDataUtils.sortTimeChanges(songTimeChanges);
+
     for (currentTimeChange in songTimeChanges)
     {
       // TODO: Maybe handle this different?
diff --git a/source/funkin/data/song/SongDataUtils.hx b/source/funkin/data/song/SongDataUtils.hx
index 3ff3943c6..256c9e8f0 100644
--- a/source/funkin/data/song/SongDataUtils.hx
+++ b/source/funkin/data/song/SongDataUtils.hx
@@ -3,6 +3,7 @@ package funkin.data.song;
 import flixel.util.FlxSort;
 import funkin.data.song.SongData.SongEventData;
 import funkin.data.song.SongData.SongNoteData;
+import funkin.data.song.SongData.SongTimeChange;
 import funkin.util.ClipboardUtil;
 import funkin.util.SerializerUtil;
 
@@ -157,6 +158,18 @@ class SongDataUtils
     return events;
   }
 
+  /**
+   * Sort an array of notes by strum time.
+   */
+  public static function sortTimeChanges(timeChanges:Array<SongTimeChange>, desc:Bool = false):Array<SongTimeChange>
+  {
+    // TODO: Modifies the array in place. Is this okay?
+    timeChanges.sort(function(a:SongTimeChange, b:SongTimeChange):Int {
+      return FlxSort.byValues(desc ? FlxSort.DESCENDING : FlxSort.ASCENDING, a.timeStamp, b.timeStamp);
+    });
+    return timeChanges;
+  }
+
   /**
    * Serialize note and event data and write it to the clipboard.
    */
diff --git a/source/funkin/input/Cursor.hx b/source/funkin/input/Cursor.hx
index edd9e70f3..c609c9e30 100644
--- a/source/funkin/input/Cursor.hx
+++ b/source/funkin/input/Cursor.hx
@@ -1,5 +1,6 @@
 package funkin.input;
 
+import haxe.ui.backend.flixel.CursorHelper;
 import openfl.utils.Assets;
 import lime.app.Future;
 import openfl.display.BitmapData;
@@ -33,7 +34,7 @@ class Cursor
     Cursor.cursorMode = null;
   }
 
-  static final CURSOR_DEFAULT_PARAMS:CursorParams =
+  public static final CURSOR_DEFAULT_PARAMS:CursorParams =
     {
       graphic: "assets/images/cursor/cursor-default.png",
       scale: 1.0,
@@ -42,7 +43,7 @@ class Cursor
     };
   static var assetCursorDefault:Null<BitmapData> = null;
 
-  static final CURSOR_CROSS_PARAMS:CursorParams =
+  public static final CURSOR_CROSS_PARAMS:CursorParams =
     {
       graphic: "assets/images/cursor/cursor-cross.png",
       scale: 1.0,
@@ -51,7 +52,7 @@ class Cursor
     };
   static var assetCursorCross:Null<BitmapData> = null;
 
-  static final CURSOR_ERASER_PARAMS:CursorParams =
+  public static final CURSOR_ERASER_PARAMS:CursorParams =
     {
       graphic: "assets/images/cursor/cursor-eraser.png",
       scale: 1.0,
@@ -60,7 +61,7 @@ class Cursor
     };
   static var assetCursorEraser:Null<BitmapData> = null;
 
-  static final CURSOR_GRABBING_PARAMS:CursorParams =
+  public static final CURSOR_GRABBING_PARAMS:CursorParams =
     {
       graphic: "assets/images/cursor/cursor-grabbing.png",
       scale: 1.0,
@@ -69,7 +70,7 @@ class Cursor
     };
   static var assetCursorGrabbing:Null<BitmapData> = null;
 
-  static final CURSOR_HOURGLASS_PARAMS:CursorParams =
+  public static final CURSOR_HOURGLASS_PARAMS:CursorParams =
     {
       graphic: "assets/images/cursor/cursor-hourglass.png",
       scale: 1.0,
@@ -78,7 +79,7 @@ class Cursor
     };
   static var assetCursorHourglass:Null<BitmapData> = null;
 
-  static final CURSOR_POINTER_PARAMS:CursorParams =
+  public static final CURSOR_POINTER_PARAMS:CursorParams =
     {
       graphic: "assets/images/cursor/cursor-pointer.png",
       scale: 1.0,
@@ -87,7 +88,7 @@ class Cursor
     };
   static var assetCursorPointer:Null<BitmapData> = null;
 
-  static final CURSOR_TEXT_PARAMS:CursorParams =
+  public static final CURSOR_TEXT_PARAMS:CursorParams =
     {
       graphic: "assets/images/cursor/cursor-text.png",
       scale: 0.2,
@@ -96,7 +97,7 @@ class Cursor
     };
   static var assetCursorText:Null<BitmapData> = null;
 
-  static final CURSOR_TEXT_VERTICAL_PARAMS:CursorParams =
+  public static final CURSOR_TEXT_VERTICAL_PARAMS:CursorParams =
     {
       graphic: "assets/images/cursor/cursor-text-vertical.png",
       scale: 0.2,
@@ -105,7 +106,7 @@ class Cursor
     };
   static var assetCursorTextVertical:Null<BitmapData> = null;
 
-  static final CURSOR_ZOOM_IN_PARAMS:CursorParams =
+  public static final CURSOR_ZOOM_IN_PARAMS:CursorParams =
     {
       graphic: "assets/images/cursor/cursor-zoom-in.png",
       scale: 1.0,
@@ -114,7 +115,7 @@ class Cursor
     };
   static var assetCursorZoomIn:Null<BitmapData> = null;
 
-  static final CURSOR_ZOOM_OUT_PARAMS:CursorParams =
+  public static final CURSOR_ZOOM_OUT_PARAMS:CursorParams =
     {
       graphic: "assets/images/cursor/cursor-zoom-out.png",
       scale: 1.0,
@@ -123,7 +124,7 @@ class Cursor
     };
   static var assetCursorZoomOut:Null<BitmapData> = null;
 
-  static final CURSOR_CROSSHAIR_PARAMS:CursorParams =
+  public static final CURSOR_CROSSHAIR_PARAMS:CursorParams =
     {
       graphic: "assets/images/cursor/cursor-crosshair.png",
       scale: 1.0,
@@ -132,7 +133,7 @@ class Cursor
     };
   static var assetCursorCrosshair:Null<BitmapData> = null;
 
-  static final CURSOR_CELL_PARAMS:CursorParams =
+  public static final CURSOR_CELL_PARAMS:CursorParams =
     {
       graphic: "assets/images/cursor/cursor-cell.png",
       scale: 1.0,
@@ -500,6 +501,28 @@ class Cursor
   {
     trace("Failed to load cursor graphic for cursor mode " + cursorMode + ": " + error);
   }
+
+  public static function registerHaxeUICursors():Void
+  {
+    CursorHelper.useCustomCursors = true;
+    registerHaxeUICursor('default', CURSOR_DEFAULT_PARAMS);
+    registerHaxeUICursor('cross', CURSOR_CROSS_PARAMS);
+    registerHaxeUICursor('eraser', CURSOR_ERASER_PARAMS);
+    registerHaxeUICursor('grabbing', CURSOR_GRABBING_PARAMS);
+    registerHaxeUICursor('hourglass', CURSOR_HOURGLASS_PARAMS);
+    registerHaxeUICursor('pointer', CURSOR_POINTER_PARAMS);
+    registerHaxeUICursor('text', CURSOR_TEXT_PARAMS);
+    registerHaxeUICursor('text-vertical', CURSOR_TEXT_VERTICAL_PARAMS);
+    registerHaxeUICursor('zoom-in', CURSOR_ZOOM_IN_PARAMS);
+    registerHaxeUICursor('zoom-out', CURSOR_ZOOM_OUT_PARAMS);
+    registerHaxeUICursor('crosshair', CURSOR_CROSSHAIR_PARAMS);
+    registerHaxeUICursor('cell', CURSOR_CELL_PARAMS);
+  }
+
+  public static function registerHaxeUICursor(id:String, params:CursorParams):Void
+  {
+    CursorHelper.registerCursor(id, params.graphic, params.scale, params.offsetX, params.offsetY);
+  }
 }
 
 // https://developer.mozilla.org/en-US/docs/Web/CSS/cursor
diff --git a/source/funkin/ui/debug/charting/ChartEditorDialogHandler.hx b/source/funkin/ui/debug/charting/ChartEditorDialogHandler.hx
index c26f6c805..a98aa5e86 100644
--- a/source/funkin/ui/debug/charting/ChartEditorDialogHandler.hx
+++ b/source/funkin/ui/debug/charting/ChartEditorDialogHandler.hx
@@ -1,6 +1,5 @@
 package funkin.ui.debug.charting;
 
-import funkin.ui.haxeui.components.FunkinDropDown;
 import flixel.util.FlxTimer;
 import funkin.data.song.importer.FNFLegacyData;
 import funkin.data.song.importer.FNFLegacyImporter;
@@ -15,7 +14,6 @@ import funkin.play.character.CharacterData;
 import funkin.play.character.CharacterData.CharacterDataParser;
 import funkin.play.song.Song;
 import funkin.play.stage.StageData;
-import funkin.ui.haxeui.components.FunkinLink;
 import funkin.util.Constants;
 import funkin.util.FileUtil;
 import funkin.util.SerializerUtil;
@@ -151,7 +149,7 @@ class ChartEditorDialogHandler
         continue;
       }
 
-      var linkTemplateSong:Link = new FunkinLink();
+      var linkTemplateSong:Link = new Link();
       linkTemplateSong.text = songName;
       linkTemplateSong.onClick = function(_event) {
         dialog.hideDialog(DialogButton.CANCEL);
@@ -618,7 +616,7 @@ class ChartEditorDialogHandler
     var startingValueStage = ChartEditorDropdowns.populateDropdownWithStages(inputStage, newSongMetadata.playData.stage);
     inputStage.value = startingValueStage;
 
-    var inputNoteStyle:Null<FunkinDropDown> = dialog.findComponent('inputNoteStyle', FunkinDropDown);
+    var inputNoteStyle:Null<DropDown> = dialog.findComponent('inputNoteStyle', DropDown);
     if (inputNoteStyle == null) throw 'Could not locate inputNoteStyle DropDown in Song Metadata dialog';
     inputNoteStyle.onChange = function(event:UIEvent) {
       if (event.data.id == null) return;
@@ -627,7 +625,7 @@ class ChartEditorDialogHandler
     var startingValueNoteStyle = ChartEditorDropdowns.populateDropdownWithNoteStyles(inputNoteStyle, newSongMetadata.playData.noteSkin);
     inputNoteStyle.value = startingValueNoteStyle;
 
-    var inputCharacterPlayer:Null<FunkinDropDown> = dialog.findComponent('inputCharacterPlayer', FunkinDropDown);
+    var inputCharacterPlayer:Null<DropDown> = dialog.findComponent('inputCharacterPlayer', DropDown);
     if (inputCharacterPlayer == null) throw 'ChartEditorToolboxHandler.buildToolboxMetadataLayout() - Could not find inputCharacterPlayer component.';
     inputCharacterPlayer.onChange = function(event:UIEvent) {
       if (event.data?.id == null) return;
@@ -637,7 +635,7 @@ class ChartEditorDialogHandler
       newSongMetadata.playData.characters.player);
     inputCharacterPlayer.value = startingValuePlayer;
 
-    var inputCharacterOpponent:Null<FunkinDropDown> = dialog.findComponent('inputCharacterOpponent', FunkinDropDown);
+    var inputCharacterOpponent:Null<DropDown> = dialog.findComponent('inputCharacterOpponent', DropDown);
     if (inputCharacterOpponent == null) throw 'ChartEditorToolboxHandler.buildToolboxMetadataLayout() - Could not find inputCharacterOpponent component.';
     inputCharacterOpponent.onChange = function(event:UIEvent) {
       if (event.data?.id == null) return;
@@ -647,7 +645,7 @@ class ChartEditorDialogHandler
       newSongMetadata.playData.characters.opponent);
     inputCharacterOpponent.value = startingValueOpponent;
 
-    var inputCharacterGirlfriend:Null<FunkinDropDown> = dialog.findComponent('inputCharacterGirlfriend', FunkinDropDown);
+    var inputCharacterGirlfriend:Null<DropDown> = dialog.findComponent('inputCharacterGirlfriend', DropDown);
     if (inputCharacterGirlfriend == null) throw 'ChartEditorToolboxHandler.buildToolboxMetadataLayout() - Could not find inputCharacterGirlfriend component.';
     inputCharacterGirlfriend.onChange = function(event:UIEvent) {
       if (event.data?.id == null) return;
diff --git a/source/funkin/ui/debug/charting/ChartEditorToolboxHandler.hx b/source/funkin/ui/debug/charting/ChartEditorToolboxHandler.hx
index fb0981bae..bd4f9ed06 100644
--- a/source/funkin/ui/debug/charting/ChartEditorToolboxHandler.hx
+++ b/source/funkin/ui/debug/charting/ChartEditorToolboxHandler.hx
@@ -1,6 +1,5 @@
 package funkin.ui.debug.charting;
 
-import funkin.ui.haxeui.components.FunkinDropDown;
 import funkin.play.stage.StageData.StageDataParser;
 import funkin.play.stage.StageData;
 import funkin.play.character.CharacterData;
@@ -596,7 +595,7 @@ class ChartEditorToolboxHandler
     };
     inputSongArtist.value = state.currentSongMetadata.artist;
 
-    var inputStage:Null<FunkinDropDown> = toolbox.findComponent('inputStage', FunkinDropDown);
+    var inputStage:Null<DropDown> = toolbox.findComponent('inputStage', DropDown);
     if (inputStage == null) throw 'ChartEditorToolboxHandler.buildToolboxMetadataLayout() - Could not find inputStage component.';
     inputStage.onChange = function(event:UIEvent) {
       var valid:Bool = event.data != null && event.data.id != null;
@@ -609,7 +608,7 @@ class ChartEditorToolboxHandler
     var startingValueStage = ChartEditorDropdowns.populateDropdownWithStages(inputStage, state.currentSongMetadata.playData.stage);
     inputStage.value = startingValueStage;
 
-    var inputNoteStyle:Null<FunkinDropDown> = toolbox.findComponent('inputNoteStyle', FunkinDropDown);
+    var inputNoteStyle:Null<DropDown> = toolbox.findComponent('inputNoteStyle', DropDown);
     if (inputNoteStyle == null) throw 'ChartEditorToolboxHandler.buildToolboxMetadataLayout() - Could not find inputNoteStyle component.';
     inputNoteStyle.onChange = function(event:UIEvent) {
       if (event.data?.id == null) return;
@@ -619,7 +618,7 @@ class ChartEditorToolboxHandler
 
     // By using this flag, we prevent the dropdown value from changing while it is being populated.
 
-    var inputCharacterPlayer:Null<FunkinDropDown> = toolbox.findComponent('inputCharacterPlayer', FunkinDropDown);
+    var inputCharacterPlayer:Null<DropDown> = toolbox.findComponent('inputCharacterPlayer', DropDown);
     if (inputCharacterPlayer == null) throw 'ChartEditorToolboxHandler.buildToolboxMetadataLayout() - Could not find inputCharacterPlayer component.';
     inputCharacterPlayer.onChange = function(event:UIEvent) {
       if (event.data?.id == null) return;
@@ -629,7 +628,7 @@ class ChartEditorToolboxHandler
       state.currentSongMetadata.playData.characters.player);
     inputCharacterPlayer.value = startingValuePlayer;
 
-    var inputCharacterOpponent:Null<FunkinDropDown> = toolbox.findComponent('inputCharacterOpponent', FunkinDropDown);
+    var inputCharacterOpponent:Null<DropDown> = toolbox.findComponent('inputCharacterOpponent', DropDown);
     if (inputCharacterOpponent == null) throw 'ChartEditorToolboxHandler.buildToolboxMetadataLayout() - Could not find inputCharacterOpponent component.';
     inputCharacterOpponent.onChange = function(event:UIEvent) {
       if (event.data?.id == null) return;
@@ -639,7 +638,7 @@ class ChartEditorToolboxHandler
       state.currentSongMetadata.playData.characters.opponent);
     inputCharacterOpponent.value = startingValueOpponent;
 
-    var inputCharacterGirlfriend:Null<FunkinDropDown> = toolbox.findComponent('inputCharacterGirlfriend', FunkinDropDown);
+    var inputCharacterGirlfriend:Null<DropDown> = toolbox.findComponent('inputCharacterGirlfriend', DropDown);
     if (inputCharacterGirlfriend == null) throw 'ChartEditorToolboxHandler.buildToolboxMetadataLayout() - Could not find inputCharacterGirlfriend component.';
     inputCharacterGirlfriend.onChange = function(event:UIEvent) {
       if (event.data?.id == null) return;
diff --git a/source/funkin/ui/haxeui/components/FunkinButton.hx b/source/funkin/ui/haxeui/components/FunkinButton.hx
deleted file mode 100644
index 45987b9ec..000000000
--- a/source/funkin/ui/haxeui/components/FunkinButton.hx
+++ /dev/null
@@ -1,30 +0,0 @@
-package funkin.ui.haxeui.components;
-
-import funkin.input.Cursor;
-import haxe.ui.events.MouseEvent;
-import haxe.ui.components.Button;
-
-/**
- * A HaxeUI button which:
- * - Changes the current cursor when hovered over.
- */
-class FunkinButton extends Button
-{
-  public function new()
-  {
-    super();
-
-    this.onMouseOver = handleMouseOver;
-    this.onMouseOut = handleMouseOut;
-  }
-
-  private function handleMouseOver(event:MouseEvent)
-  {
-    Cursor.cursorMode = Pointer;
-  }
-
-  private function handleMouseOut(event:MouseEvent)
-  {
-    Cursor.cursorMode = Default;
-  }
-}
diff --git a/source/funkin/ui/haxeui/components/FunkinClickLabel.hx b/source/funkin/ui/haxeui/components/FunkinClickLabel.hx
deleted file mode 100644
index 77c9dbc0f..000000000
--- a/source/funkin/ui/haxeui/components/FunkinClickLabel.hx
+++ /dev/null
@@ -1,30 +0,0 @@
-package funkin.ui.haxeui.components;
-
-import haxe.ui.components.Label;
-import funkin.input.Cursor;
-import haxe.ui.events.MouseEvent;
-
-/**
- * A HaxeUI label which:
- * - Changes the current cursor when hovered over (assume an onClick handler will be added!).
- */
-class FunkinClickLabel extends Label
-{
-  public function new()
-  {
-    super();
-
-    this.onMouseOver = handleMouseOver;
-    this.onMouseOut = handleMouseOut;
-  }
-
-  private function handleMouseOver(event:MouseEvent)
-  {
-    Cursor.cursorMode = Pointer;
-  }
-
-  private function handleMouseOut(event:MouseEvent)
-  {
-    Cursor.cursorMode = Default;
-  }
-}
diff --git a/source/funkin/ui/haxeui/components/FunkinDropDown.hx b/source/funkin/ui/haxeui/components/FunkinDropDown.hx
deleted file mode 100644
index ad396856c..000000000
--- a/source/funkin/ui/haxeui/components/FunkinDropDown.hx
+++ /dev/null
@@ -1,30 +0,0 @@
-package funkin.ui.haxeui.components;
-
-import haxe.ui.components.DropDown;
-import funkin.input.Cursor;
-import haxe.ui.events.MouseEvent;
-
-/**
- * A HaxeUI dropdown which:
- * - Changes the current cursor when hovered over.
- */
-class FunkinDropDown extends DropDown
-{
-  public function new()
-  {
-    super();
-
-    this.onMouseOver = handleMouseOver;
-    this.onMouseOut = handleMouseOut;
-  }
-
-  private function handleMouseOver(event:MouseEvent)
-  {
-    Cursor.cursorMode = Pointer;
-  }
-
-  private function handleMouseOut(event:MouseEvent)
-  {
-    Cursor.cursorMode = Default;
-  }
-}
diff --git a/source/funkin/ui/haxeui/components/FunkinHorizontalSlider.hx b/source/funkin/ui/haxeui/components/FunkinHorizontalSlider.hx
deleted file mode 100644
index baf42aada..000000000
--- a/source/funkin/ui/haxeui/components/FunkinHorizontalSlider.hx
+++ /dev/null
@@ -1,30 +0,0 @@
-package funkin.ui.haxeui.components;
-
-import haxe.ui.components.HorizontalSlider;
-import funkin.input.Cursor;
-import haxe.ui.events.MouseEvent;
-
-/**
- * A HaxeUI horizontal slider which:
- * - Changes the current cursor when hovered over.
- */
-class FunkinHorizontalSlider extends HorizontalSlider
-{
-  public function new()
-  {
-    super();
-
-    this.onMouseOver = handleMouseOver;
-    this.onMouseOut = handleMouseOut;
-  }
-
-  private function handleMouseOver(event:MouseEvent)
-  {
-    Cursor.cursorMode = Pointer;
-  }
-
-  private function handleMouseOut(event:MouseEvent)
-  {
-    Cursor.cursorMode = Default;
-  }
-}
diff --git a/source/funkin/ui/haxeui/components/FunkinLink.hx b/source/funkin/ui/haxeui/components/FunkinLink.hx
deleted file mode 100644
index 74eb6e7c4..000000000
--- a/source/funkin/ui/haxeui/components/FunkinLink.hx
+++ /dev/null
@@ -1,30 +0,0 @@
-package funkin.ui.haxeui.components;
-
-import funkin.input.Cursor;
-import haxe.ui.events.MouseEvent;
-import haxe.ui.components.Link;
-
-/**
- * A HaxeUI link which:
- * - Changes the current cursor when hovered over.
- */
-class FunkinLink extends Link
-{
-  public function new()
-  {
-    super();
-
-    this.onMouseOver = handleMouseOver;
-    this.onMouseOut = handleMouseOut;
-  }
-
-  private function handleMouseOver(event:MouseEvent)
-  {
-    Cursor.cursorMode = Pointer;
-  }
-
-  private function handleMouseOut(event:MouseEvent)
-  {
-    Cursor.cursorMode = Default;
-  }
-}
diff --git a/source/funkin/ui/haxeui/components/FunkinMenuBar.hx b/source/funkin/ui/haxeui/components/FunkinMenuBar.hx
deleted file mode 100644
index 393372d74..000000000
--- a/source/funkin/ui/haxeui/components/FunkinMenuBar.hx
+++ /dev/null
@@ -1,32 +0,0 @@
-package funkin.ui.haxeui.components;
-
-import funkin.input.Cursor;
-import haxe.ui.events.MouseEvent;
-import haxe.ui.containers.menus.MenuBar;
-import haxe.ui.core.CompositeBuilder;
-
-/**
- * A HaxeUI menu bar which:
- * - Changes the current cursor when each button is hovered over.
- */
-class FunkinMenuBar extends MenuBar
-{
-  public function new()
-  {
-    super();
-
-    registerListeners();
-  }
-
-  private function registerListeners():Void {}
-
-  private function handleMouseOver(event:MouseEvent)
-  {
-    Cursor.cursorMode = Pointer;
-  }
-
-  private function handleMouseOut(event:MouseEvent)
-  {
-    Cursor.cursorMode = Default;
-  }
-}
diff --git a/source/funkin/ui/haxeui/components/FunkinMenuCheckBox.hx b/source/funkin/ui/haxeui/components/FunkinMenuCheckBox.hx
deleted file mode 100644
index 263277c6f..000000000
--- a/source/funkin/ui/haxeui/components/FunkinMenuCheckBox.hx
+++ /dev/null
@@ -1,30 +0,0 @@
-package funkin.ui.haxeui.components;
-
-import funkin.input.Cursor;
-import haxe.ui.events.MouseEvent;
-import haxe.ui.containers.menus.MenuCheckBox;
-
-/**
- * A HaxeUI menu checkbox which:
- * - Changes the current cursor when hovered over.
- */
-class FunkinMenuCheckBox extends MenuCheckBox
-{
-  public function new()
-  {
-    super();
-
-    this.onMouseOver = handleMouseOver;
-    this.onMouseOut = handleMouseOut;
-  }
-
-  private function handleMouseOver(event:MouseEvent)
-  {
-    Cursor.cursorMode = Pointer;
-  }
-
-  private function handleMouseOut(event:MouseEvent)
-  {
-    Cursor.cursorMode = Default;
-  }
-}
diff --git a/source/funkin/ui/haxeui/components/FunkinMenuItem.hx b/source/funkin/ui/haxeui/components/FunkinMenuItem.hx
deleted file mode 100644
index 2eb7db729..000000000
--- a/source/funkin/ui/haxeui/components/FunkinMenuItem.hx
+++ /dev/null
@@ -1,30 +0,0 @@
-package funkin.ui.haxeui.components;
-
-import funkin.input.Cursor;
-import haxe.ui.events.MouseEvent;
-import haxe.ui.containers.menus.MenuItem;
-
-/**
- * A HaxeUI menu item which:
- * - Changes the current cursor when hovered over.
- */
-class FunkinMenuItem extends MenuItem
-{
-  public function new()
-  {
-    super();
-
-    this.onMouseOver = handleMouseOver;
-    this.onMouseOut = handleMouseOut;
-  }
-
-  private function handleMouseOver(event:MouseEvent)
-  {
-    Cursor.cursorMode = Pointer;
-  }
-
-  private function handleMouseOut(event:MouseEvent)
-  {
-    Cursor.cursorMode = Default;
-  }
-}
diff --git a/source/funkin/ui/haxeui/components/FunkinMenuOptionBox.hx b/source/funkin/ui/haxeui/components/FunkinMenuOptionBox.hx
deleted file mode 100644
index d9985eede..000000000
--- a/source/funkin/ui/haxeui/components/FunkinMenuOptionBox.hx
+++ /dev/null
@@ -1,30 +0,0 @@
-package funkin.ui.haxeui.components;
-
-import haxe.ui.containers.menus.MenuOptionBox;
-import funkin.input.Cursor;
-import haxe.ui.events.MouseEvent;
-
-/**
- * A HaxeUI menu option box which:
- * - Changes the current cursor when hovered over.
- */
-class FunkinMenuOptionBox extends MenuOptionBox
-{
-  public function new()
-  {
-    super();
-
-    this.onMouseOver = handleMouseOver;
-    this.onMouseOut = handleMouseOut;
-  }
-
-  private function handleMouseOver(event:MouseEvent)
-  {
-    Cursor.cursorMode = Pointer;
-  }
-
-  private function handleMouseOut(event:MouseEvent)
-  {
-    Cursor.cursorMode = Default;
-  }
-}
diff --git a/source/funkin/ui/haxeui/components/FunkinNumberStepper.hx b/source/funkin/ui/haxeui/components/FunkinNumberStepper.hx
deleted file mode 100644
index db8d4fb7f..000000000
--- a/source/funkin/ui/haxeui/components/FunkinNumberStepper.hx
+++ /dev/null
@@ -1,30 +0,0 @@
-package funkin.ui.haxeui.components;
-
-import haxe.ui.components.NumberStepper;
-import funkin.input.Cursor;
-import haxe.ui.events.MouseEvent;
-
-/**
- * A HaxeUI number stepper which:
- * - Changes the current cursor when hovered over.
- */
-class FunkinNumberStepper extends NumberStepper
-{
-  public function new()
-  {
-    super();
-
-    this.onMouseOver = handleMouseOver;
-    this.onMouseOut = handleMouseOut;
-  }
-
-  private function handleMouseOver(event:MouseEvent)
-  {
-    Cursor.cursorMode = Pointer;
-  }
-
-  private function handleMouseOut(event:MouseEvent)
-  {
-    Cursor.cursorMode = Default;
-  }
-}
diff --git a/source/funkin/ui/haxeui/components/FunkinTextField.hx b/source/funkin/ui/haxeui/components/FunkinTextField.hx
deleted file mode 100644
index 3ecab0684..000000000
--- a/source/funkin/ui/haxeui/components/FunkinTextField.hx
+++ /dev/null
@@ -1,30 +0,0 @@
-package funkin.ui.haxeui.components;
-
-import haxe.ui.components.TextField;
-import funkin.input.Cursor;
-import haxe.ui.events.MouseEvent;
-
-/**
- * A HaxeUI text field which:
- * - Changes the current cursor when hovered over.
- */
-class FunkinTextField extends TextField
-{
-  public function new()
-  {
-    super();
-
-    this.onMouseOver = handleMouseOver;
-    this.onMouseOut = handleMouseOut;
-  }
-
-  private function handleMouseOver(event:MouseEvent)
-  {
-    Cursor.cursorMode = Text;
-  }
-
-  private function handleMouseOut(event:MouseEvent)
-  {
-    Cursor.cursorMode = Default;
-  }
-}

From 15ffbf2fe7657f8d6d73c873c8c96e09f7231fc5 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Wed, 1 Nov 2023 23:09:52 -0400
Subject: [PATCH 06/21] Fixed a MacOS-specific issue

---
 source/funkin/util/CLIUtil.hx | 19 ++++++++++++++++---
 1 file changed, 16 insertions(+), 3 deletions(-)

diff --git a/source/funkin/util/CLIUtil.hx b/source/funkin/util/CLIUtil.hx
index a085ada6d..0ca707c34 100644
--- a/source/funkin/util/CLIUtil.hx
+++ b/source/funkin/util/CLIUtil.hx
@@ -1,5 +1,7 @@
 package funkin.util;
 
+import haxe.io.Path;
+
 /**
  * Utilties for interpreting command line arguments.
  */
@@ -13,9 +15,20 @@ class CLIUtil
   public static function resetWorkingDir():Void
   {
     #if sys
-    var exeDir:String = haxe.io.Path.directory(Sys.programPath());
-    trace('Changing working directory from ${Sys.getCwd()} to ${exeDir}');
-    Sys.setCwd(exeDir);
+    var exeDir:String = Path.addTrailingSlash(Path.directory(Sys.programPath()));
+    #if mac
+    exeDir = Path.addTrailingSlash(Path.join([exeDir, '../Resources/']));
+    #end
+    var cwd:String = Path.addTrailingSlash(Sys.getCwd());
+    if (cwd == exeDir)
+    {
+      trace('Working directory is already correct.');
+    }
+    else
+    {
+      trace('Changing working directory from ${Sys.getCwd()} to ${exeDir}');
+      Sys.setCwd(exeDir);
+    }
     #end
   }
 

From 2b468ad926cdd4724e99abf57cef1d4603dedb35 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Thu, 2 Nov 2023 17:40:00 -0400
Subject: [PATCH 07/21] Fix issue where changing width of dropdown would cause
 a crash

---
 assets                                                       | 2 +-
 source/funkin/ui/debug/charting/ChartEditorToolboxHandler.hx | 1 +
 2 files changed, 2 insertions(+), 1 deletion(-)

diff --git a/assets b/assets
index 3f8299aba..9098a1df3 160000
--- a/assets
+++ b/assets
@@ -1 +1 @@
-Subproject commit 3f8299aba4e308a6c86ce8f499b697de04909ad2
+Subproject commit 9098a1df39f1320f2ea65fbac674b410ea3829dd
diff --git a/source/funkin/ui/debug/charting/ChartEditorToolboxHandler.hx b/source/funkin/ui/debug/charting/ChartEditorToolboxHandler.hx
index bd4f9ed06..4ee894f07 100644
--- a/source/funkin/ui/debug/charting/ChartEditorToolboxHandler.hx
+++ b/source/funkin/ui/debug/charting/ChartEditorToolboxHandler.hx
@@ -411,6 +411,7 @@ class ChartEditorToolboxHandler
         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.';

From a605f53e07ba54b75c5beecb4f84a11a31a45b7d Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Sun, 5 Nov 2023 00:07:49 -0400
Subject: [PATCH 08/21] Updated git references for haxeui-core and
 haxeui-flixel

---
 hmm.json | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/hmm.json b/hmm.json
index 1d9e51d69..778b85604 100644
--- a/hmm.json
+++ b/hmm.json
@@ -49,14 +49,14 @@
       "name": "haxeui-core",
       "type": "git",
       "dir": null,
-      "ref": "db6f81191abe386d891aca5a65c27ba6f8e10598",
+      "ref": "815e94dd5aa6cf09c5ddcef1666a54449ffde8dc",
       "url": "https://github.com/haxeui/haxeui-core"
     },
     {
       "name": "haxeui-flixel",
       "type": "git",
       "dir": null,
-      "ref": "e10f51fe33b8d8d2dd3f21a0fd1d7c4d88d5d5c0",
+      "ref": "95c7d66e779626eabd6f48a1cd7aa7f9a503a7f3",
       "url": "https://github.com/haxeui/haxeui-flixel"
     },
     {

From 27de7dc036dbd9b0ad22e87c65e03daea72f0231 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Sun, 5 Nov 2023 00:08:06 -0400
Subject: [PATCH 09/21] Update notification expiry time constant in
 ChartEditorState and ChartEditorDialogHandler

---
 source/funkin/ui/debug/charting/ChartEditorState.hx       | 2 +-
 .../debug/charting/handlers/ChartEditorDialogHandler.hx   | 8 ++++----
 2 files changed, 5 insertions(+), 5 deletions(-)

diff --git a/source/funkin/ui/debug/charting/ChartEditorState.hx b/source/funkin/ui/debug/charting/ChartEditorState.hx
index 3fee7ce93..8255a8c7d 100644
--- a/source/funkin/ui/debug/charting/ChartEditorState.hx
+++ b/source/funkin/ui/debug/charting/ChartEditorState.hx
@@ -1425,7 +1425,7 @@ class ChartEditorState extends HaxeUIState
             title: 'Success',
             body: 'Loaded chart (${params.fnfcTargetPath})',
             type: NotificationType.Success,
-            expiryMs: ChartEditorState.NOTIFICATION_DISMISS_TIME
+            expiryMs: Constants.NOTIFICATION_DISMISS_TIME
           });
         #end
       }
diff --git a/source/funkin/ui/debug/charting/handlers/ChartEditorDialogHandler.hx b/source/funkin/ui/debug/charting/handlers/ChartEditorDialogHandler.hx
index 0574cea4f..dba513c32 100644
--- a/source/funkin/ui/debug/charting/handlers/ChartEditorDialogHandler.hx
+++ b/source/funkin/ui/debug/charting/handlers/ChartEditorDialogHandler.hx
@@ -231,7 +231,7 @@ class ChartEditorDialogHandler
                     title: 'Success',
                     body: 'Loaded chart (${selectedFile.name})',
                     type: NotificationType.Success,
-                    expiryMs: ChartEditorState.NOTIFICATION_DISMISS_TIME
+                    expiryMs: Constants.NOTIFICATION_DISMISS_TIME
                   });
                 #end
 
@@ -248,7 +248,7 @@ class ChartEditorDialogHandler
                   title: 'Failure',
                   body: 'Failed to load chart (${selectedFile.name}): ${err}',
                   type: NotificationType.Error,
-                  expiryMs: ChartEditorState.NOTIFICATION_DISMISS_TIME
+                  expiryMs: Constants.NOTIFICATION_DISMISS_TIME
                 });
               #end
             }
@@ -270,7 +270,7 @@ class ChartEditorDialogHandler
               title: 'Success',
               body: 'Loaded chart (${path.toString()})',
               type: NotificationType.Success,
-              expiryMs: ChartEditorState.NOTIFICATION_DISMISS_TIME
+              expiryMs: Constants.NOTIFICATION_DISMISS_TIME
             });
           #end
 
@@ -286,7 +286,7 @@ class ChartEditorDialogHandler
             title: 'Failure',
             body: 'Failed to load chart (${path.toString()}): ${err}',
             type: NotificationType.Error,
-            expiryMs: ChartEditorState.NOTIFICATION_DISMISS_TIME
+            expiryMs: Constants.NOTIFICATION_DISMISS_TIME
           });
         #end
       }

From de5edb3cc208255303541e98044dfb8e70db9da9 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Sun, 5 Nov 2023 00:08:18 -0400
Subject: [PATCH 10/21] Fix variation metadata and error message in
 ChartEditorImportExportHandler

---
 source/funkin/play/song/Song.hx                             | 6 +++---
 .../charting/handlers/ChartEditorImportExportHandler.hx     | 2 +-
 2 files changed, 4 insertions(+), 4 deletions(-)

diff --git a/source/funkin/play/song/Song.hx b/source/funkin/play/song/Song.hx
index 01f0a5893..b48eda224 100644
--- a/source/funkin/play/song/Song.hx
+++ b/source/funkin/play/song/Song.hx
@@ -98,10 +98,10 @@ class Song implements IPlayStateScriptedClass implements IRegistryEntry<SongMeta
       for (vari in _data.playData.songVariations)
       {
         variations.push(vari);
-      }
 
-      for (meta in fetchVariationMetadata(id))
-        _metadata.set(meta.variation, meta);
+        var variMeta = fetchVariationMetadata(id, vari);
+        if (variMeta != null) _metadata.set(variMeta.variation, variMeta);
+      }
     }
 
     if (_metadata.size() == 0)
diff --git a/source/funkin/ui/debug/charting/handlers/ChartEditorImportExportHandler.hx b/source/funkin/ui/debug/charting/handlers/ChartEditorImportExportHandler.hx
index 001caea8b..be850e93e 100644
--- a/source/funkin/ui/debug/charting/handlers/ChartEditorImportExportHandler.hx
+++ b/source/funkin/ui/debug/charting/handlers/ChartEditorImportExportHandler.hx
@@ -285,7 +285,7 @@ class ChartEditorImportExportHandler
         }
         else
         {
-          throw 'Could not load vocals ($playerCharId-$instId).';
+          throw 'Could not load vocals ($opponentCharId).';
         }
       }
     }

From bdb3fe28bb80742dcfda156ca93974a2ae998779 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Sun, 5 Nov 2023 00:35:23 -0400
Subject: [PATCH 11/21] Update assets

---
 assets | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/assets b/assets
index 36f8134b6..d491f5d87 160000
--- a/assets
+++ b/assets
@@ -1 +1 @@
-Subproject commit 36f8134b6c0d9e4bbb7252e23a980ffdf32a3695
+Subproject commit d491f5d87a0deba0b5642beeb801c8e130754038

From 66ae60e9e48966f4c5555416560d53e635b6f02e Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Sun, 5 Nov 2023 01:19:47 -0400
Subject: [PATCH 12/21] Updated syntax after merge

---
 assets                                                    | 2 +-
 source/funkin/data/song/SongData.hx                       | 8 ++++----
 source/funkin/ui/debug/charting/ChartEditorState.hx       | 4 +++-
 .../debug/charting/handlers/ChartEditorDialogHandler.hx   | 2 --
 4 files changed, 8 insertions(+), 8 deletions(-)

diff --git a/assets b/assets
index 49901dee1..d491f5d87 160000
--- a/assets
+++ b/assets
@@ -1 +1 @@
-Subproject commit 49901dee1fae3fdfd784e9d48374255282cc2042
+Subproject commit d491f5d87a0deba0b5642beeb801c8e130754038
diff --git a/source/funkin/data/song/SongData.hx b/source/funkin/data/song/SongData.hx
index 9523f23c0..83b336606 100644
--- a/source/funkin/data/song/SongData.hx
+++ b/source/funkin/data/song/SongData.hx
@@ -528,8 +528,8 @@ abstract SongEventData(SongEventDataRaw) from SongEventDataRaw to SongEventDataR
 
   public inline function getInt(key:String):Null<Int>
   {
-    if (value == null) return null;
-    var result = Reflect.field(value, key);
+    if (this.value == null) return null;
+    var result = Reflect.field(this.value, key);
     if (result == null) return null;
     if (Std.isOfType(result, Int)) return result;
     if (Std.isOfType(result, String)) return Std.parseInt(cast result);
@@ -538,8 +538,8 @@ abstract SongEventData(SongEventDataRaw) from SongEventDataRaw to SongEventDataR
 
   public inline function getFloat(key:String):Null<Float>
   {
-    if (value == null) return null;
-    var result = Reflect.field(value, key);
+    if (this.value == null) return null;
+    var result = Reflect.field(this.value, key);
     if (result == null) return null;
     if (Std.isOfType(result, Float)) return result;
     if (Std.isOfType(result, String)) return Std.parseFloat(cast result);
diff --git a/source/funkin/ui/debug/charting/ChartEditorState.hx b/source/funkin/ui/debug/charting/ChartEditorState.hx
index 023aeda4b..71e86ad1f 100644
--- a/source/funkin/ui/debug/charting/ChartEditorState.hx
+++ b/source/funkin/ui/debug/charting/ChartEditorState.hx
@@ -3921,8 +3921,10 @@ class ChartEditorState extends HaxeUIState
     if (FlxG.keys.justPressed.F1) this.openUserGuideDialog();
   }
 
-  function handleQuickWatch():Void
+  override function handleQuickWatch():Void
   {
+    super.handleQuickWatch();
+
     FlxG.watch.addQuick('scrollPosInPixels', scrollPositionInPixels);
     FlxG.watch.addQuick('playheadPosInPixels', playheadPositionInPixels);
 
diff --git a/source/funkin/ui/debug/charting/handlers/ChartEditorDialogHandler.hx b/source/funkin/ui/debug/charting/handlers/ChartEditorDialogHandler.hx
index d773e6489..bb066a923 100644
--- a/source/funkin/ui/debug/charting/handlers/ChartEditorDialogHandler.hx
+++ b/source/funkin/ui/debug/charting/handlers/ChartEditorDialogHandler.hx
@@ -15,8 +15,6 @@ import funkin.play.character.CharacterData.CharacterDataParser;
 import funkin.play.song.Song;
 import funkin.play.stage.StageData;
 import funkin.ui.debug.charting.util.ChartEditorDropdowns;
-import funkin.ui.haxeui.components.FunkinDropDown;
-import funkin.ui.haxeui.components.FunkinLink;
 import funkin.util.Constants;
 import funkin.util.FileUtil;
 import funkin.util.SerializerUtil;

From d4fedfb99ea2a41bd0edf58f3697b6716a1793a0 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Sun, 5 Nov 2023 01:31:57 -0400
Subject: [PATCH 13/21] Update assets

---
 assets | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/assets b/assets
index d491f5d87..6ca2ae9a5 160000
--- a/assets
+++ b/assets
@@ -1 +1 @@
-Subproject commit d491f5d87a0deba0b5642beeb801c8e130754038
+Subproject commit 6ca2ae9a575b672da6c8243aa46a6cb2434eca7f

From ce97a002cb01fee2e86a9a96075665d499e671aa Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Tue, 7 Nov 2023 04:04:22 -0500
Subject: [PATCH 14/21] Reorganize a whole bunch of classes and perform syntax
 cleanup.

---
 assets                                        |   2 +-
 source/Main.hx                                |   2 +-
 source/funkin/Controls.hx                     | 177 ++++++------
 source/funkin/CoolUtil.hx                     | 129 ---------
 source/funkin/DialogueBox.hx                  | 265 ------------------
 source/funkin/FlxSwf.hx                       |  43 ---
 source/funkin/Highscore.hx                    |   3 +
 source/funkin/InitState.hx                    |  11 +-
 source/funkin/MenuCharacter.hx                |  42 ---
 source/funkin/NoteSplash.hx                   |  65 -----
 source/funkin/Options.hx                      |   6 -
 source/funkin/Paths.hx                        |   3 +
 source/funkin/PlayerSettings.hx               |   5 +-
 source/funkin/Preferences.hx                  |   2 +-
 source/funkin/TankCutscene.hx                 |  27 --
 source/funkin/{ => api/discord}/Discord.hx    |   2 +-
 source/funkin/api/newgrounds/NGUtil.hx        |  12 -
 source/funkin/{ => api/newgrounds}/NGio.hx    |   2 +-
 source/funkin/api/newgrounds/README.md        |   2 +-
 .../{audiovis => audio/visualize}/ABot.hx     |   2 +-
 .../{audiovis => audio/visualize}/ABotVis.hx  |   7 +-
 .../audio/visualize/PolygonSpectogram.hx      |   2 +-
 .../visualize}/SpectogramSprite.hx            |   6 +-
 .../{audiovis => audio/visualize}/VisShit.hx  |   7 +-
 .../visualize}/dsp/Complex.hx                 |   2 +-
 .../{audiovis => audio/visualize}/dsp/FFT.hx  |   8 +-
 .../visualize}/dsp/OffsetArray.hx             |   2 +-
 .../visualize}/dsp/Signal.hx                  |   2 +-
 .../shaders}/AngleMask.hx                     |   2 +-
 .../shaders}/BlendModeEffect.hx               |   2 +-
 .../shaders}/BlendModesShader.hx              |   2 +-
 .../shaders}/ColorSwap.hx                     |   2 +-
 .../shaders}/GaussianBlurShader.hx            |   2 +-
 .../shaders}/Grayscale.hx                     |   2 +-
 .../shaders}/HSVShader.hx                     |   2 +-
 .../shaders}/LeftMaskShader.hx                |   2 +-
 .../shaders}/MultiplyShader.hx                |   2 +-
 .../shaders}/OverlayBlend.hx                  |   2 +-
 .../shaders}/PureColor.hx                     |   2 +-
 .../shaders}/ScreenWipeShader.hx              |   2 +-
 .../shaders}/StrokeShader.hx                  |   2 +-
 .../shaders}/TitleOutline.hx                  |   2 +-
 .../shaders}/WaveShader.hx                    |   2 +-
 .../shaders}/WiggleEffectRuntime.hx           |   2 +-
 source/funkin/input/TurboKeyHandler.hx        |   3 +-
 .../modding/base/ScriptedMusicBeatState.hx    |   2 +-
 .../modding/base/ScriptedMusicBeatSubState.hx |   2 +-
 source/funkin/play/Fighter.hx                 |  68 -----
 source/funkin/play/GameOverSubState.hx        |   2 +
 source/funkin/{ => play}/GitarooPause.hx      |   4 +-
 source/funkin/{ => play}/PauseSubState.hx     |   8 +-
 source/funkin/play/PlayState.hx               |  16 +-
 source/funkin/play/ResultState.hx             |   6 +-
 source/funkin/play/character/BaseCharacter.hx |   2 +-
 .../{ => play/components}/ComboMilestone.hx   |   2 +-
 .../play/{ => components}/HealthIcon.hx       |   9 +-
 .../{ui => play/components}/PopUpStuff.hx     |   2 +-
 .../{ui => play/components}/TallyCounter.hx   |   4 +-
 .../dialogue/ConversationDebugState.hx        |   1 +
 source/funkin/play/notes/Strumline.hx         |   2 +-
 source/funkin/play/notes/SustainTrail.hx      |   2 +-
 source/funkin/play/stage/Bopper.hx            |   2 +-
 source/funkin/{ => ui}/Alphabet.hx            |   8 +-
 source/funkin/ui/AtlasMenuList.hx             |   2 +-
 source/funkin/{ => ui}/MenuItem.hx            |   5 +-
 source/funkin/ui/MenuList.hx                  |   6 +-
 source/funkin/{ => ui}/MusicBeatState.hx      |   3 +-
 source/funkin/{ => ui}/MusicBeatSubState.hx   |   3 +-
 source/funkin/{ => ui}/SwagCamera.hx          |  11 +-
 source/funkin/ui/debug/DebugMenuSubState.hx   |   7 +-
 source/funkin/{ => ui/debug}/MemoryCounter.hx |   2 +-
 .../anim}/DebugBoundingState.hx               |   9 +-
 .../anim}/FlxAnimateTest.hx                   |   3 +-
 .../ui/debug/charting/ChartEditorState.hx     |   3 +-
 .../ui/{ => debug/latency}/CoolStatsGraph.hx  |   2 +-
 .../{ => ui/debug/latency}/LatencyState.hx    |   5 +-
 .../stage}/CharStage.hx                       |   2 +-
 .../stage}/SprStage.hx                        |   2 +-
 .../stage}/StageBuilderState.hx               |   8 +-
 .../stage}/StageEditorCommand.hx              |   4 +-
 .../stage}/StageOffsetSubState.hx             |  13 +-
 .../stage}/StagetoolBar.hx                    |  10 +-
 .../freeplay}/BGScrollingText.hx              |   2 +-
 .../freeplay}/CapsuleText.hx                  |   4 +-
 .../freeplay}/DJBoyfriend.hx                  |   2 +-
 .../freeplay}/DifficultyStars.hx              |   4 +-
 .../freeplay}/FreeplayFlames.hx               |   2 +-
 .../freeplay}/FreeplayScore.hx                |   2 +-
 .../funkin/{ => ui/freeplay}/FreeplayState.hx |  38 +--
 .../freeplay}/LetterSort.hx                   |   2 +-
 .../freeplay}/SongMenuItem.hx                 |  15 +-
 source/funkin/ui/haxeui/HaxeUIState.hx        |   1 +
 source/funkin/ui/haxeui/HaxeUISubState.hx     |   3 +
 source/funkin/ui/haxeui/components/README.md  |   2 +-
 .../funkin/{ => ui/mainmenu}/MainMenuState.hx |   8 +-
 .../{ => ui/options}/ButtonRemapSubstate.hx   |   2 +-
 source/funkin/ui/{ => options}/ColorsMenu.hx  |   4 +-
 .../funkin/ui/{ => options}/ControlsMenu.hx   |   7 +-
 source/funkin/ui/{ => options}/ModMenu.hx     |   4 +-
 .../funkin/ui/{ => options}/OptionsState.hx   |   4 +-
 .../ui/{ => options}/PreferencesMenu.hx       |   4 +-
 source/funkin/ui/story/LevelTitle.hx          |   4 +-
 source/funkin/ui/story/StoryMenuState.hx      |   8 +-
 source/funkin/ui/title/AttractState.hx        |   1 +
 source/funkin/ui/title/FlxSpriteOverlay.hx    |   2 +-
 .../funkin/{ => ui/title}/OutdatedSubState.hx |   3 +-
 source/funkin/ui/title/TitleState.hx          |  17 +-
 .../{ => ui/transition}/LoadingState.hx       |  25 +-
 .../ui/{ => transition}/StickerSubState.hx    |   5 +-
 source/funkin/util/BezierUtil.hx              |   3 +
 source/funkin/util/DateUtil.hx                |   3 +
 source/funkin/util/FlxGamepadUtil.hx          |   3 +
 .../{InputFormatter.hx => util/InputUtil.hx}  |  11 +-
 source/funkin/util/MathUtil.hx                |  31 ++
 source/funkin/util/MouseUtil.hx               |  45 +++
 source/funkin/util/PlatformUtil.hx            |   3 +
 source/funkin/util/SerializerUtil.hx          |   4 +-
 source/funkin/util/SortUtil.hx                |   2 +-
 source/funkin/util/VersionUtil.hx             |   2 +
 source/funkin/util/WindowUtil.hx              |   3 +
 120 files changed, 462 insertions(+), 938 deletions(-)
 delete mode 100644 source/funkin/CoolUtil.hx
 delete mode 100644 source/funkin/DialogueBox.hx
 delete mode 100644 source/funkin/FlxSwf.hx
 delete mode 100644 source/funkin/MenuCharacter.hx
 delete mode 100644 source/funkin/NoteSplash.hx
 delete mode 100644 source/funkin/Options.hx
 delete mode 100644 source/funkin/TankCutscene.hx
 rename source/funkin/{ => api/discord}/Discord.hx (98%)
 rename source/funkin/{ => api/newgrounds}/NGio.hx (99%)
 rename source/funkin/{audiovis => audio/visualize}/ABot.hx (85%)
 rename source/funkin/{audiovis => audio/visualize}/ABotVis.hx (96%)
 rename source/funkin/{audiovis => audio/visualize}/SpectogramSprite.hx (98%)
 rename source/funkin/{audiovis => audio/visualize}/VisShit.hx (93%)
 rename source/funkin/{audiovis => audio/visualize}/dsp/Complex.hx (98%)
 rename source/funkin/{audiovis => audio/visualize}/dsp/FFT.hx (97%)
 rename source/funkin/{audiovis => audio/visualize}/dsp/OffsetArray.hx (98%)
 rename source/funkin/{audiovis => audio/visualize}/dsp/Signal.hx (98%)
 rename source/funkin/{shaderslmfao => graphics/shaders}/AngleMask.hx (96%)
 rename source/funkin/{shaderslmfao => graphics/shaders}/BlendModeEffect.hx (95%)
 rename source/funkin/{shaderslmfao => graphics/shaders}/BlendModesShader.hx (92%)
 rename source/funkin/{shaderslmfao => graphics/shaders}/ColorSwap.hx (99%)
 rename source/funkin/{shaderslmfao => graphics/shaders}/GaussianBlurShader.hx (93%)
 rename source/funkin/{shaderslmfao => graphics/shaders}/Grayscale.hx (92%)
 rename source/funkin/{shaderslmfao => graphics/shaders}/HSVShader.hx (96%)
 rename source/funkin/{shaderslmfao => graphics/shaders}/LeftMaskShader.hx (97%)
 rename source/funkin/{shaderslmfao => graphics/shaders}/MultiplyShader.hx (94%)
 rename source/funkin/{shaderslmfao => graphics/shaders}/OverlayBlend.hx (97%)
 rename source/funkin/{shaderslmfao => graphics/shaders}/PureColor.hx (96%)
 rename source/funkin/{shaderslmfao => graphics/shaders}/ScreenWipeShader.hx (98%)
 rename source/funkin/{shaderslmfao => graphics/shaders}/StrokeShader.hx (98%)
 rename source/funkin/{shaderslmfao => graphics/shaders}/TitleOutline.hx (98%)
 rename source/funkin/{shaderslmfao => graphics/shaders}/WaveShader.hx (90%)
 rename source/funkin/{shaderslmfao => graphics/shaders}/WiggleEffectRuntime.hx (98%)
 delete mode 100644 source/funkin/play/Fighter.hx
 rename source/funkin/{ => play}/GitarooPause.hx (96%)
 rename source/funkin/{ => play}/PauseSubState.hx (97%)
 rename source/funkin/{ => play/components}/ComboMilestone.hx (98%)
 rename source/funkin/play/{ => components}/HealthIcon.hx (98%)
 rename source/funkin/{ui => play/components}/PopUpStuff.hx (99%)
 rename source/funkin/{ui => play/components}/TallyCounter.hx (94%)
 rename source/funkin/{ => ui}/Alphabet.hx (97%)
 rename source/funkin/{ => ui}/MenuItem.hx (93%)
 rename source/funkin/{ => ui}/MusicBeatState.hx (98%)
 rename source/funkin/{ => ui}/MusicBeatSubState.hx (98%)
 rename source/funkin/{ => ui}/SwagCamera.hx (87%)
 rename source/funkin/{ => ui/debug}/MemoryCounter.hx (97%)
 rename source/funkin/ui/{animDebugShit => debug/anim}/DebugBoundingState.hx (98%)
 rename source/funkin/ui/{animDebugShit => debug/anim}/FlxAnimateTest.hx (94%)
 rename source/funkin/ui/{ => debug/latency}/CoolStatsGraph.hx (99%)
 rename source/funkin/{ => ui/debug/latency}/LatencyState.hx (98%)
 rename source/funkin/ui/{stageBuildShit => debug/stage}/CharStage.hx (81%)
 rename source/funkin/ui/{stageBuildShit => debug/stage}/SprStage.hx (97%)
 rename source/funkin/ui/{stageBuildShit => debug/stage}/StageBuilderState.hx (98%)
 rename source/funkin/ui/{stageBuildShit => debug/stage}/StageEditorCommand.hx (95%)
 rename source/funkin/ui/{stageBuildShit => debug/stage}/StageOffsetSubState.hx (97%)
 rename source/funkin/ui/{stageBuildShit => debug/stage}/StagetoolBar.hx (67%)
 rename source/funkin/{freeplayStuff => ui/freeplay}/BGScrollingText.hx (98%)
 rename source/funkin/{freeplayStuff => ui/freeplay}/CapsuleText.hx (93%)
 rename source/funkin/{freeplayStuff => ui/freeplay}/DJBoyfriend.hx (99%)
 rename source/funkin/{freeplayStuff => ui/freeplay}/DifficultyStars.hx (96%)
 rename source/funkin/{freeplayStuff => ui/freeplay}/FreeplayFlames.hx (98%)
 rename source/funkin/{freeplayStuff => ui/freeplay}/FreeplayScore.hx (98%)
 rename source/funkin/{ => ui/freeplay}/FreeplayState.hx (97%)
 rename source/funkin/{freeplayStuff => ui/freeplay}/LetterSort.hx (99%)
 rename source/funkin/{freeplayStuff => ui/freeplay}/SongMenuItem.hx (96%)
 rename source/funkin/{ => ui/mainmenu}/MainMenuState.hx (97%)
 rename source/funkin/{ => ui/options}/ButtonRemapSubstate.hx (82%)
 rename source/funkin/ui/{ => options}/ColorsMenu.hx (96%)
 rename source/funkin/ui/{ => options}/ControlsMenu.hx (98%)
 rename source/funkin/ui/{ => options}/ModMenu.hx (97%)
 rename source/funkin/ui/{ => options}/OptionsState.hx (98%)
 rename source/funkin/ui/{ => options}/PreferencesMenu.hx (98%)
 rename source/funkin/{ => ui/title}/OutdatedSubState.hx (95%)
 rename source/funkin/{ => ui/transition}/LoadingState.hx (89%)
 rename source/funkin/ui/{ => transition}/StickerSubState.hx (98%)
 rename source/funkin/{InputFormatter.hx => util/InputUtil.hx} (96%)
 create mode 100644 source/funkin/util/MathUtil.hx
 create mode 100644 source/funkin/util/MouseUtil.hx

diff --git a/assets b/assets
index e634c8f50..53f63f549 160000
--- a/assets
+++ b/assets
@@ -1 +1 @@
-Subproject commit e634c8f50c34845097283e0f411e1f89409e1498
+Subproject commit 53f63f549f34b9c03d0e2d7149dde551a61acb26
diff --git a/source/Main.hx b/source/Main.hx
index dffe666b7..ffc3edd76 100644
--- a/source/Main.hx
+++ b/source/Main.hx
@@ -3,7 +3,7 @@ package;
 import flixel.FlxGame;
 import flixel.FlxState;
 import funkin.util.logging.CrashHandler;
-import funkin.MemoryCounter;
+import funkin.ui.debug.MemoryCounter;
 import funkin.save.Save;
 import haxe.ui.Toolkit;
 import openfl.display.FPS;
diff --git a/source/funkin/Controls.hx b/source/funkin/Controls.hx
index 9372c4dc6..9f2dcff49 100644
--- a/source/funkin/Controls.hx
+++ b/source/funkin/Controls.hx
@@ -23,96 +23,14 @@ import flixel.util.FlxTimer;
 import lime.ui.Haptic;
 
 /**
- * Since, in many cases multiple actions should use similar keys, we don't want the
- * rebinding UI to list every action. ActionBinders are what the user percieves as
- * an input so, for instance, they can't set jump-press and jump-release to different keys.
- */
-enum Control
-{
-  // List notes in order from left to right on gameplay screen.
-  NOTE_LEFT;
-  NOTE_DOWN;
-  NOTE_UP;
-  NOTE_RIGHT;
-  UI_UP;
-  UI_LEFT;
-  UI_RIGHT;
-  UI_DOWN;
-  RESET;
-  ACCEPT;
-  BACK;
-  PAUSE;
-  CUTSCENE_ADVANCE;
-  CUTSCENE_SKIP;
-  VOLUME_UP;
-  VOLUME_DOWN;
-  VOLUME_MUTE;
-  #if CAN_CHEAT
-  CHEAT;
-  #end
-}
-
-enum
-abstract Action(String) to String from String
-{
-  var UI_UP = "ui_up";
-  var UI_LEFT = "ui_left";
-  var UI_RIGHT = "ui_right";
-  var UI_DOWN = "ui_down";
-  var UI_UP_P = "ui_up-press";
-  var UI_LEFT_P = "ui_left-press";
-  var UI_RIGHT_P = "ui_right-press";
-  var UI_DOWN_P = "ui_down-press";
-  var UI_UP_R = "ui_up-release";
-  var UI_LEFT_R = "ui_left-release";
-  var UI_RIGHT_R = "ui_right-release";
-  var UI_DOWN_R = "ui_down-release";
-  var NOTE_UP = "note_up";
-  var NOTE_LEFT = "note_left";
-  var NOTE_RIGHT = "note_right";
-  var NOTE_DOWN = "note_down";
-  var NOTE_UP_P = "note_up-press";
-  var NOTE_LEFT_P = "note_left-press";
-  var NOTE_RIGHT_P = "note_right-press";
-  var NOTE_DOWN_P = "note_down-press";
-  var NOTE_UP_R = "note_up-release";
-  var NOTE_LEFT_R = "note_left-release";
-  var NOTE_RIGHT_R = "note_right-release";
-  var NOTE_DOWN_R = "note_down-release";
-  var ACCEPT = "accept";
-  var BACK = "back";
-  var PAUSE = "pause";
-  var CUTSCENE_ADVANCE = "cutscene_advance";
-  var CUTSCENE_SKIP = "cutscene_skip";
-  var VOLUME_UP = "volume_up";
-  var VOLUME_DOWN = "volume_down";
-  var VOLUME_MUTE = "volume_mute";
-  var RESET = "reset";
-  #if CAN_CHEAT
-  var CHEAT = "cheat";
-  #end
-}
-
-enum Device
-{
-  Keys;
-  Gamepad(id:Int);
-}
-
-enum KeyboardScheme
-{
-  Solo;
-  Duo(first:Bool);
-  None;
-  Custom;
-}
-
-/**
- * A list of actions that a player would invoke via some input device.
- * Uses FlxActions to funnel various inputs to a single action.
+ * A core class which handles receiving player input and interpreting it into game actions.
  */
 class Controls extends FlxActionSet
 {
+  /**
+   * A list of actions that a player would invoke via some input device.
+   * Uses FlxActions to funnel various inputs to a single action.
+   */
   var _ui_up = new FlxActionDigital(Action.UI_UP);
   var _ui_left = new FlxActionDigital(Action.UI_LEFT);
   var _ui_right = new FlxActionDigital(Action.UI_RIGHT);
@@ -1241,3 +1159,88 @@ class FlxActionInputDigitalAndroid extends FlxActionInputDigital
   }
 }
 #end
+
+/**
+ * Since, in many cases multiple actions should use similar keys, we don't want the
+ * rebinding UI to list every action. ActionBinders are what the user percieves as
+ * an input so, for instance, they can't set jump-press and jump-release to different keys.
+ */
+enum Control
+{
+  // List notes in order from left to right on gameplay screen.
+  NOTE_LEFT;
+  NOTE_DOWN;
+  NOTE_UP;
+  NOTE_RIGHT;
+  UI_UP;
+  UI_LEFT;
+  UI_RIGHT;
+  UI_DOWN;
+  RESET;
+  ACCEPT;
+  BACK;
+  PAUSE;
+  CUTSCENE_ADVANCE;
+  CUTSCENE_SKIP;
+  VOLUME_UP;
+  VOLUME_DOWN;
+  VOLUME_MUTE;
+  #if CAN_CHEAT
+  CHEAT;
+  #end
+}
+
+enum
+abstract Action(String) to String from String
+{
+  var UI_UP = "ui_up";
+  var UI_LEFT = "ui_left";
+  var UI_RIGHT = "ui_right";
+  var UI_DOWN = "ui_down";
+  var UI_UP_P = "ui_up-press";
+  var UI_LEFT_P = "ui_left-press";
+  var UI_RIGHT_P = "ui_right-press";
+  var UI_DOWN_P = "ui_down-press";
+  var UI_UP_R = "ui_up-release";
+  var UI_LEFT_R = "ui_left-release";
+  var UI_RIGHT_R = "ui_right-release";
+  var UI_DOWN_R = "ui_down-release";
+  var NOTE_UP = "note_up";
+  var NOTE_LEFT = "note_left";
+  var NOTE_RIGHT = "note_right";
+  var NOTE_DOWN = "note_down";
+  var NOTE_UP_P = "note_up-press";
+  var NOTE_LEFT_P = "note_left-press";
+  var NOTE_RIGHT_P = "note_right-press";
+  var NOTE_DOWN_P = "note_down-press";
+  var NOTE_UP_R = "note_up-release";
+  var NOTE_LEFT_R = "note_left-release";
+  var NOTE_RIGHT_R = "note_right-release";
+  var NOTE_DOWN_R = "note_down-release";
+  var ACCEPT = "accept";
+  var BACK = "back";
+  var PAUSE = "pause";
+  var CUTSCENE_ADVANCE = "cutscene_advance";
+  var CUTSCENE_SKIP = "cutscene_skip";
+  var VOLUME_UP = "volume_up";
+  var VOLUME_DOWN = "volume_down";
+  var VOLUME_MUTE = "volume_mute";
+  var RESET = "reset";
+  #if CAN_CHEAT
+  var CHEAT = "cheat";
+  #end
+}
+
+enum Device
+{
+  Keys;
+  Gamepad(id:Int);
+}
+
+enum KeyboardScheme
+{
+  Solo;
+  Duo(first:Bool);
+  None;
+  Custom;
+}
diff --git a/source/funkin/CoolUtil.hx b/source/funkin/CoolUtil.hx
deleted file mode 100644
index d07bb4e22..000000000
--- a/source/funkin/CoolUtil.hx
+++ /dev/null
@@ -1,129 +0,0 @@
-package funkin;
-
-import flixel.FlxSprite;
-import flixel.FlxState;
-import flixel.graphics.FlxGraphic;
-import flixel.graphics.frames.FlxAtlasFrames;
-import flixel.math.FlxMath;
-import flixel.math.FlxPoint;
-import flixel.math.FlxRect;
-import flixel.system.FlxAssets.FlxGraphicAsset;
-import flixel.tweens.FlxEase;
-import flixel.tweens.FlxTween;
-import funkin.play.PlayState;
-import funkin.shaderslmfao.ScreenWipeShader;
-import haxe.format.JsonParser;
-import lime.math.Rectangle;
-import lime.utils.Assets;
-import openfl.filters.ShaderFilter;
-
-class CoolUtil
-{
-  public static function coolBaseLog(base:Float, fin:Float):Float
-  {
-    return Math.log(fin) / Math.log(base);
-  }
-
-  public static function coolTextFile(path:String):Array<String>
-  {
-    var daList:Array<String> = [];
-
-    var swagArray:Array<String> = Assets.getText(path).trim().split('\n');
-
-    for (item in swagArray)
-    {
-      // comment support in the quick lil text formats??? using //
-      if (!item.trim().startsWith('//')) daList.push(item);
-    }
-
-    for (i in 0...daList.length)
-    {
-      daList[i] = daList[i].trim();
-    }
-
-    return daList;
-  }
-
-  public static function numberArray(max:Int, ?min = 0):Array<Int>
-  {
-    var dumbArray:Array<Int> = [];
-    for (i in min...max)
-    {
-      dumbArray.push(i);
-    }
-    return dumbArray;
-  }
-
-  static var oldCamPos:FlxPoint = new FlxPoint();
-  static var oldMousePos:FlxPoint = new FlxPoint();
-
-  /**
-   * Used to be for general camera middle click dragging, now generalized for any click and drag type shit!
-   * Listen I don't make the rules here
-   * @param target what you want to be dragged, defaults to CAMERA SCROLL
-   * @param jusPres the "justPressed", should be a button of some sort
-   * @param pressed the "pressed", which should be the same button as `jusPres`
-   */
-  public static function mouseCamDrag(?target:FlxPoint, ?jusPres:Bool, ?pressed:Bool):Void
-  {
-    if (target == null) target = FlxG.camera.scroll;
-
-    if (jusPres == null) jusPres = FlxG.mouse.justPressedMiddle;
-
-    if (pressed == null) pressed = FlxG.mouse.pressedMiddle;
-
-    if (jusPres)
-    {
-      oldCamPos.set(target.x, target.y);
-      oldMousePos.set(FlxG.mouse.screenX, FlxG.mouse.screenY);
-    }
-
-    if (pressed)
-    {
-      target.x = oldCamPos.x - (FlxG.mouse.screenX - oldMousePos.x);
-      target.y = oldCamPos.y - (FlxG.mouse.screenY - oldMousePos.y);
-    }
-  }
-
-  public static function mouseWheelZoom():Void
-  {
-    if (FlxG.mouse.wheel != 0) FlxG.camera.zoom += FlxG.mouse.wheel * (0.1 * FlxG.camera.zoom);
-  }
-
-  /**
-    Lerps camera, but accountsfor framerate shit?
-    Right now it's simply for use to change the followLerp variable of a camera during update
-    TODO LATER MAYBE:
-      Actually make and modify the scroll and lerp shit in it's own function
-      instead of solely relying on changing the lerp on the fly
-   */
-  public static function camLerpShit(lerp:Float):Float
-  {
-    return lerp * (FlxG.elapsed / (1 / 60));
-  }
-
-  public static function coolSwitchState(state:FlxState, transitionTex:String = "shaderTransitionStuff/coolDots", time:Float = 2)
-  {
-    var screenShit:FlxSprite = new FlxSprite().loadGraphic(Paths.image("shaderTransitionStuff/coolDots"));
-    var screenWipeShit:ScreenWipeShader = new ScreenWipeShader();
-
-    screenWipeShit.funnyShit.input = screenShit.pixels;
-    FlxTween.tween(screenWipeShit, {daAlphaShit: 1}, time,
-      {
-        ease: FlxEase.quadInOut,
-        onComplete: function(twn) {
-          screenShit.destroy();
-          FlxG.switchState(new MainMenuState());
-        }
-      });
-    FlxG.camera.setFilters([new ShaderFilter(screenWipeShit)]);
-  }
-
-  /*
-   * frame dependant lerp kinda lol
-   */
-  public static function coolLerp(base:Float, target:Float, ratio:Float):Float
-  {
-    return base + camLerpShit(ratio) * (target - base);
-  }
-}
diff --git a/source/funkin/DialogueBox.hx b/source/funkin/DialogueBox.hx
deleted file mode 100644
index 68d330dbe..000000000
--- a/source/funkin/DialogueBox.hx
+++ /dev/null
@@ -1,265 +0,0 @@
-package funkin;
-
-import flixel.FlxSprite;
-import flixel.addons.text.FlxTypeText;
-import flixel.group.FlxSpriteGroup;
-import flixel.text.FlxText;
-import flixel.util.FlxColor;
-import flixel.util.FlxTimer;
-import funkin.play.PlayState;
-
-/**
- * Handles dialog boxes and text, like the ones in Week 6.
- */
-class DialogueBox extends FlxSpriteGroup
-{
-  var box:FlxSprite;
-
-  var curCharacter:String = '';
-
-  var dialogue:Alphabet;
-  var dialogueList:Array<String> = [];
-
-  // SECOND DIALOGUE FOR THE PIXEL SHIT INSTEAD???
-  var swagDialogue:FlxTypeText;
-
-  var dropText:FlxText;
-
-  public var finishThing:Void->Void;
-
-  var portraitLeft:FlxSprite;
-  var portraitRight:FlxSprite;
-
-  var handSelect:FlxSprite;
-  var bgFade:FlxSprite;
-
-  public function new(talkingRight:Bool = true, ?dialogueList:Array<String>)
-  {
-    super();
-
-    switch (PlayState.instance.currentSong.id.toLowerCase())
-    {
-      case 'senpai':
-        FlxG.sound.playMusic(Paths.music('Lunchbox'), 0);
-        FlxG.sound.music.fadeIn(1, 0, 0.8);
-      case 'thorns':
-        FlxG.sound.playMusic(Paths.music('LunchboxScary'), 0);
-        FlxG.sound.music.fadeIn(1, 0, 0.8);
-    }
-
-    bgFade = new FlxSprite(-200, -200).makeGraphic(Std.int(FlxG.width * 1.3), Std.int(FlxG.height * 1.3), 0xFFB3DFD8);
-    bgFade.scrollFactor.set();
-    bgFade.alpha = 0;
-    add(bgFade);
-
-    new FlxTimer().start(0.83, function(tmr:FlxTimer) {
-      bgFade.alpha += (1 / 5) * 0.7;
-      if (bgFade.alpha > 0.7) bgFade.alpha = 0.7;
-    }, 5);
-
-    portraitLeft = new FlxSprite(-20, 40);
-    portraitLeft.frames = Paths.getSparrowAtlas('weeb/senpaiPortrait');
-    portraitLeft.animation.addByPrefix('enter', 'Senpai Portrait Enter', 24, false);
-    portraitLeft.setGraphicSize(Std.int(portraitLeft.width * Constants.PIXEL_ART_SCALE * 0.9));
-    portraitLeft.updateHitbox();
-    portraitLeft.scrollFactor.set();
-    add(portraitLeft);
-    portraitLeft.visible = false;
-
-    portraitRight = new FlxSprite(0, 40);
-    portraitRight.frames = Paths.getSparrowAtlas('weeb/bfPortrait');
-    portraitRight.animation.addByPrefix('enter', 'Boyfriend portrait enter', 24, false);
-    portraitRight.setGraphicSize(Std.int(portraitRight.width * Constants.PIXEL_ART_SCALE * 0.9));
-    portraitRight.updateHitbox();
-    portraitRight.scrollFactor.set();
-    add(portraitRight);
-    portraitRight.visible = false;
-
-    box = new FlxSprite(-20, 45);
-
-    var hasDialog:Bool = false;
-    switch (PlayState.instance.currentSong.id.toLowerCase())
-    {
-      case 'senpai':
-        hasDialog = true;
-        box.frames = Paths.getSparrowAtlas('weeb/pixelUI/dialogueBox-pixel');
-        box.animation.addByPrefix('normalOpen', 'Text Box Appear', 24, false);
-        box.animation.addByIndices('normal', 'Text Box Appear', [4], '', 24);
-      case 'roses':
-        hasDialog = true;
-        FlxG.sound.play(Paths.sound('ANGRY_TEXT_BOX'));
-
-        box.frames = Paths.getSparrowAtlas('weeb/pixelUI/dialogueBox-senpaiMad');
-        box.animation.addByPrefix('normalOpen', 'SENPAI ANGRY IMPACT SPEECH', 24, false);
-        box.animation.addByIndices('normal', 'SENPAI ANGRY IMPACT SPEECH', [4], '', 24);
-
-      case 'thorns':
-        hasDialog = true;
-        box.frames = Paths.getSparrowAtlas('weeb/pixelUI/dialogueBox-evil');
-        box.animation.addByPrefix('normalOpen', 'Spirit Textbox spawn', 24, false);
-        box.animation.addByIndices('normal', 'Spirit Textbox spawn', [11], '', 24);
-
-        var face:FlxSprite = new FlxSprite(320, 170).loadGraphic(Paths.image('weeb/spiritFaceForward'));
-        face.setGraphicSize(Std.int(face.width * 6));
-        add(face);
-    }
-
-    this.dialogueList = dialogueList;
-
-    if (!hasDialog) return;
-
-    box.animation.play('normalOpen');
-    box.setGraphicSize(Std.int(box.width * Constants.PIXEL_ART_SCALE * 0.9));
-    box.updateHitbox();
-    add(box);
-
-    box.screenCenter(X);
-    portraitLeft.screenCenter(X);
-
-    handSelect = new FlxSprite(1042, 590).loadGraphic(Paths.image('weeb/pixelUI/hand_textbox'));
-    handSelect.setGraphicSize(Std.int(handSelect.width * Constants.PIXEL_ART_SCALE * 0.9));
-    handSelect.updateHitbox();
-    handSelect.visible = false;
-    add(handSelect);
-
-    if (!talkingRight)
-    {
-      // box.flipX = true;
-    }
-
-    dropText = new FlxText(242, 502, Std.int(FlxG.width * 0.6), '', 32);
-    dropText.font = 'Pixel Arial 11 Bold';
-    dropText.color = 0xFFD89494;
-    add(dropText);
-
-    swagDialogue = new FlxTypeText(240, 500, Std.int(FlxG.width * 0.6), '', 32);
-    swagDialogue.font = 'Pixel Arial 11 Bold';
-    swagDialogue.color = 0xFF3F2021;
-    swagDialogue.sounds = [FlxG.sound.load(Paths.sound('pixelText'), 0.6)];
-    add(swagDialogue);
-
-    dialogue = new Alphabet(0, 80, '', false, true);
-    // dialogue.x = 90;
-    // add(dialogue);
-  }
-
-  var dialogueOpened:Bool = false;
-  var dialogueStarted:Bool = false;
-  var dialogueEnded:Bool = false;
-
-  override function update(elapsed:Float):Void
-  {
-    // HARD CODING CUZ IM STUPDI
-    if (PlayState.instance.currentSong.id.toLowerCase() == 'roses') portraitLeft.visible = false;
-    if (PlayState.instance.currentSong.id.toLowerCase() == 'thorns')
-    {
-      portraitLeft.color = FlxColor.BLACK;
-      swagDialogue.color = FlxColor.WHITE;
-      dropText.color = FlxColor.BLACK;
-    }
-
-    dropText.text = swagDialogue.text;
-
-    if (box.animation.curAnim != null)
-    {
-      if (box.animation.curAnim.name == 'normalOpen' && box.animation.curAnim.finished)
-      {
-        box.animation.play('normal');
-        dialogueOpened = true;
-      }
-    }
-
-    if (dialogueOpened && !dialogueStarted)
-    {
-      startDialogue();
-      dialogueStarted = true;
-    }
-
-    if (FlxG.keys.justPressed.ANY && dialogueEnded)
-    {
-      remove(dialogue);
-
-      FlxG.sound.play(Paths.sound('clickText'), 0.8);
-
-      if (dialogueList[1] == null && dialogueList[0] != null)
-      {
-        if (!isEnding)
-        {
-          isEnding = true;
-
-          if (PlayState.instance.currentSong.id.toLowerCase() == 'senpai'
-            || PlayState.instance.currentSong.id.toLowerCase() == 'thorns') FlxG.sound.music.fadeOut(2.2, 0);
-
-          new FlxTimer().start(0.2, function(tmr:FlxTimer) {
-            box.alpha -= 1 / 5;
-            bgFade.alpha -= 1 / 5 * 0.7;
-            portraitLeft.visible = false;
-            portraitRight.visible = false;
-            swagDialogue.alpha -= 1 / 5;
-            handSelect.alpha -= 1 / 5;
-            dropText.alpha = swagDialogue.alpha;
-          }, 5);
-
-          new FlxTimer().start(1.2, function(tmr:FlxTimer) {
-            finishThing();
-            kill();
-          });
-        }
-      }
-      else
-      {
-        dialogueList.remove(dialogueList[0]);
-        startDialogue();
-      }
-    }
-    else if (FlxG.keys.justPressed.ANY && dialogueStarted) swagDialogue.skip();
-
-    super.update(elapsed);
-  }
-
-  var isEnding:Bool = false;
-
-  function startDialogue():Void
-  {
-    cleanDialog();
-    // var theDialog:Alphabet = new Alphabet(0, 70, dialogueList[0], false, true);
-    // dialogue = theDialog;
-    // add(theDialog);
-
-    // swagDialogue.text = ;
-    swagDialogue.resetText(dialogueList[0]);
-    swagDialogue.start(0.04);
-    swagDialogue.completeCallback = function() {
-      trace('dialogue finish');
-      handSelect.visible = true;
-      dialogueEnded = true;
-    };
-    handSelect.visible = false;
-    dialogueEnded = false;
-
-    switch (curCharacter)
-    {
-      case 'dad':
-        portraitRight.visible = false;
-        if (!portraitLeft.visible)
-        {
-          portraitLeft.visible = true;
-          portraitLeft.animation.play('enter');
-        }
-      case 'bf':
-        portraitLeft.visible = false;
-        if (!portraitRight.visible)
-        {
-          portraitRight.visible = true;
-          portraitRight.animation.play('enter');
-        }
-    }
-  }
-
-  function cleanDialog():Void
-  {
-    var splitName:Array<String> = dialogueList[0].split(':');
-    curCharacter = splitName[1];
-    dialogueList[0] = dialogueList[0].substr(splitName[1].length + 2).trim();
-  }
-}
diff --git a/source/funkin/FlxSwf.hx b/source/funkin/FlxSwf.hx
deleted file mode 100644
index d37343894..000000000
--- a/source/funkin/FlxSwf.hx
+++ /dev/null
@@ -1,43 +0,0 @@
-package funkin;
-
-import flixel.FlxCamera;
-import flixel.FlxSprite;
-import flixel.graphics.tile.FlxDrawBaseItem;
-import openfl.display.MovieClip;
-
-class FlxSwf extends FlxSprite
-{
-  public var swf:MovieClip;
-
-  public function new()
-  {
-    super();
-  }
-
-  override function draw()
-  {
-    for (camera in cameras)
-    {
-      if (!camera.visible || !camera.exists) continue;
-
-      getScreenPosition(_point, camera).subtractPoint(offset);
-      // assume no render blit for now
-      // use camera.canvas
-      // camera.canvas.graphics.
-    }
-  }
-}
-
-class FlxDrawSwfItem extends FlxDrawBaseItem<FlxDrawSwfItem>
-{
-  public function new()
-  {
-    super();
-    type = FlxDrawItemType.TILES;
-  }
-
-  override function render(camera:FlxCamera)
-  {
-    super.render(camera);
-  }
-}
diff --git a/source/funkin/Highscore.hx b/source/funkin/Highscore.hx
index 3c9fd82e4..2c18ffa2d 100644
--- a/source/funkin/Highscore.hx
+++ b/source/funkin/Highscore.hx
@@ -1,5 +1,8 @@
 package funkin;
 
+/**
+ * A core class which handles tracking score and combo for the current song.
+ */
 class Highscore
 {
   public static var tallies:Tallies = new Tallies();
diff --git a/source/funkin/InitState.hx b/source/funkin/InitState.hx
index 5299a3aa0..d74b5f639 100644
--- a/source/funkin/InitState.hx
+++ b/source/funkin/InitState.hx
@@ -1,5 +1,6 @@
 package funkin;
 
+import funkin.ui.transition.LoadingState;
 import flixel.FlxState;
 import flixel.addons.transition.FlxTransitionableState;
 import flixel.addons.transition.FlxTransitionSprite.GraphicTransTileDiamond;
@@ -10,7 +11,7 @@ import flixel.math.FlxRect;
 import flixel.FlxSprite;
 import flixel.system.debug.log.LogStyle;
 import flixel.util.FlxColor;
-import funkin.ui.PreferencesMenu;
+import funkin.ui.options.PreferencesMenu;
 import funkin.util.macro.MacroUtil;
 import funkin.util.WindowUtil;
 import funkin.play.PlayStatePlaylist;
@@ -26,11 +27,13 @@ import funkin.play.stage.StageData.StageDataParser;
 import funkin.play.character.CharacterData.CharacterDataParser;
 import funkin.modding.module.ModuleHandler;
 import funkin.ui.title.TitleState;
+import funkin.ui.transition.LoadingState;
 #if discord_rpc
 import Discord.DiscordClient;
 #end
 
 /**
+ * A core class which performs initialization of the game.
  * The initialization state has several functions:
  * - Calls code to set up the game, including loading saves and parsing game data.
  * - Chooses whether to start via debug or via launching normally.
@@ -228,13 +231,13 @@ class InitState extends FlxState
     #elseif FREEPLAY // -DFREEPLAY
     FlxG.switchState(new FreeplayState());
     #elseif ANIMATE // -DANIMATE
-    FlxG.switchState(new funkin.ui.animDebugShit.FlxAnimateTest());
+    FlxG.switchState(new funkin.ui.debug.anim.FlxAnimateTest());
     #elseif CHARTING // -DCHARTING
     FlxG.switchState(new funkin.ui.debug.charting.ChartEditorState());
     #elseif STAGEBUILD // -DSTAGEBUILD
-    FlxG.switchState(new funkin.ui.stageBullshit.StageBuilderState());
+    FlxG.switchState(new funkin.ui.debug.stage.StageBuilderState());
     #elseif ANIMDEBUG // -DANIMDEBUG
-    FlxG.switchState(new funkin.ui.animDebugShit.DebugBoundingState());
+    FlxG.switchState(new funkin.ui.debug.anim.DebugBoundingState());
     #elseif LATENCY // -DLATENCY
     FlxG.switchState(new funkin.LatencyState());
     #else
diff --git a/source/funkin/MenuCharacter.hx b/source/funkin/MenuCharacter.hx
deleted file mode 100644
index 3e05b9e9f..000000000
--- a/source/funkin/MenuCharacter.hx
+++ /dev/null
@@ -1,42 +0,0 @@
-package funkin;
-
-import flixel.FlxSprite;
-import flixel.graphics.frames.FlxAtlasFrames;
-
-class MenuCharacter extends FlxSprite
-{
-  public var character:String;
-
-  public function new(x:Float, character:String = 'bf')
-  {
-    super(x);
-
-    this.character = character;
-
-    var suffix:String = character;
-
-    if (character != "darnell" && character != "nene") suffix = "characters";
-
-    var tex = Paths.getSparrowAtlas('campaign_menu_UI_' + suffix);
-    frames = tex;
-
-    trace(character);
-
-    animation.addByPrefix('bf', "BF idle dance white", 24);
-    animation.addByPrefix('bfConfirm', 'BF HEY!!', 24, false);
-    animation.addByPrefix('gf', "GF Dancing Beat WHITE", 24);
-    animation.addByPrefix('dad', "Dad idle dance BLACK LINE", 24);
-    animation.addByPrefix('spooky', "spooky dance idle BLACK LINES", 24);
-    animation.addByPrefix('pico', "Pico Idle Dance", 24);
-    animation.addByPrefix('mom', "Mom Idle BLACK LINES", 24);
-    animation.addByPrefix('parents-christmas', "Parent Christmas Idle", 24);
-    animation.addByPrefix('senpai', "SENPAI idle Black Lines", 24);
-    animation.addByPrefix('tankman', "Tankman Menu BLACK", 24);
-    animation.addByPrefix('darnell', "Darnell Black Lines To Scale", 24);
-    animation.addByPrefix('nene', "Nene Black Lines To Scale", 24);
-    // Parent Christmas Idle
-
-    animation.play(character);
-    updateHitbox();
-  }
-}
diff --git a/source/funkin/NoteSplash.hx b/source/funkin/NoteSplash.hx
deleted file mode 100644
index 81b35b36d..000000000
--- a/source/funkin/NoteSplash.hx
+++ /dev/null
@@ -1,65 +0,0 @@
-package funkin;
-
-import flixel.FlxSprite;
-import haxe.io.Path;
-import flixel.graphics.frames.FlxAtlasFrames;
-
-class NoteSplash extends FlxSprite
-{
-  public function new(x:Float, y:Float, noteData:Int = 0):Void
-  {
-    super(x, y);
-
-    animation.addByPrefix('note0-0', 'note impact 1 purple', 24, false);
-    animation.addByPrefix('note1-0', 'note impact 1  blue', 24, false);
-    animation.addByPrefix('note2-0', 'note impact 1 green', 24, false);
-    animation.addByPrefix('note3-0', 'note impact 1 red', 24, false);
-    animation.addByPrefix('note0-1', 'note impact 2 purple', 24, false);
-    animation.addByPrefix('note1-1', 'note impact 2 blue', 24, false);
-    animation.addByPrefix('note2-1', 'note impact 2 green', 24, false);
-    animation.addByPrefix('note3-1', 'note impact 2 red', 24, false);
-
-    setupNoteSplash(x, y, noteData);
-
-    // alpha = 0.75;
-  }
-
-  public override function update(elapsed:Float):Void
-  {
-    super.update(elapsed);
-
-    if (animation.finished)
-    {
-      kill();
-    }
-  }
-
-  public static function buildSplashFrames(force:Bool = false):FlxAtlasFrames
-  {
-    // static variables inside functions are a cool of Haxe 4.3.0.
-    static var splashFrames:FlxAtlasFrames = null;
-
-    if (splashFrames != null && !force) return splashFrames;
-
-    splashFrames = Paths.getSparrowAtlas('noteSplashes');
-
-    splashFrames.parent.persist = true;
-
-    return splashFrames;
-  }
-
-  public function setupNoteSplash(x:Float, y:Float, noteData:Int = 0)
-  {
-    setPosition(x, y);
-    alpha = 0.6;
-
-    animation.play('note' + noteData + '-' + FlxG.random.int(0, 1), true);
-    animation.curAnim.frameRate = 24 + FlxG.random.int(-2, 2);
-    animation.finishCallback = function(name) {
-      kill();
-    };
-    updateHitbox();
-
-    offset.set(width * 0.3, height * 0.3);
-  }
-}
diff --git a/source/funkin/Options.hx b/source/funkin/Options.hx
deleted file mode 100644
index bc8a98570..000000000
--- a/source/funkin/Options.hx
+++ /dev/null
@@ -1,6 +0,0 @@
-package funkin;
-
-class Options
-{
-  public static var masterVolume:Float = 1;
-}
diff --git a/source/funkin/Paths.hx b/source/funkin/Paths.hx
index 07a15dae1..e0212e573 100644
--- a/source/funkin/Paths.hx
+++ b/source/funkin/Paths.hx
@@ -4,6 +4,9 @@ import flixel.graphics.frames.FlxAtlasFrames;
 import openfl.utils.AssetType;
 import openfl.utils.Assets as OpenFlAssets;
 
+/**
+ * A core class which handles determining asset paths.
+ */
 class Paths
 {
   static var currentLevel:String;
diff --git a/source/funkin/PlayerSettings.hx b/source/funkin/PlayerSettings.hx
index e97cfe384..0e728f57e 100644
--- a/source/funkin/PlayerSettings.hx
+++ b/source/funkin/PlayerSettings.hx
@@ -8,8 +8,9 @@ import flixel.input.actions.FlxActionInput;
 import flixel.input.gamepad.FlxGamepad;
 import flixel.util.FlxSignal;
 
-// import ui.DeviceManager;
-// import props.Player;
+/**
+ * A core class which represents the current player(s) and their controls and other configuration.
+ */
 class PlayerSettings
 {
   public static var numPlayers(default, null) = 0;
diff --git a/source/funkin/Preferences.hx b/source/funkin/Preferences.hx
index 7e3c3c6d7..6b0911ede 100644
--- a/source/funkin/Preferences.hx
+++ b/source/funkin/Preferences.hx
@@ -3,7 +3,7 @@ package funkin;
 import funkin.save.Save;
 
 /**
- * A store of user-configurable, globally relevant values.
+ * A core class which provides a store of user-configurable, globally relevant values.
  */
 class Preferences
 {
diff --git a/source/funkin/TankCutscene.hx b/source/funkin/TankCutscene.hx
deleted file mode 100644
index 4bc7349ad..000000000
--- a/source/funkin/TankCutscene.hx
+++ /dev/null
@@ -1,27 +0,0 @@
-package funkin;
-
-import flixel.FlxSprite;
-import flixel.sound.FlxSound;
-
-class TankCutscene extends FlxSprite
-{
-  public var startSyncAudio:FlxSound;
-
-  public function new(x:Float, y:Float)
-  {
-    super(x, y);
-  }
-
-  var startedPlayingSound:Bool = false;
-
-  override function update(elapsed:Float)
-  {
-    if (animation.curAnim.curFrame >= 1 && !startedPlayingSound)
-    {
-      startSyncAudio.play();
-      startedPlayingSound = true;
-    }
-
-    super.update(elapsed);
-  }
-}
diff --git a/source/funkin/Discord.hx b/source/funkin/api/discord/Discord.hx
similarity index 98%
rename from source/funkin/Discord.hx
rename to source/funkin/api/discord/Discord.hx
index d2cf12535..a4d65684e 100644
--- a/source/funkin/Discord.hx
+++ b/source/funkin/api/discord/Discord.hx
@@ -1,4 +1,4 @@
-package funkin;
+package funkin.api.discord;
 
 import Sys.sleep;
 #if discord_rpc
diff --git a/source/funkin/api/newgrounds/NGUtil.hx b/source/funkin/api/newgrounds/NGUtil.hx
index ba7d5f916..c8289fc46 100644
--- a/source/funkin/api/newgrounds/NGUtil.hx
+++ b/source/funkin/api/newgrounds/NGUtil.hx
@@ -241,15 +241,3 @@ class NGUtil
   }
   #end
 }
-
-enum ConnectionResult
-{
-  /** Log in successful */
-  Success;
-
-  /** Could not login */
-  Fail(msg:String);
-
-  /** User cancelled the login */
-  Cancelled;
-}
diff --git a/source/funkin/NGio.hx b/source/funkin/api/newgrounds/NGio.hx
similarity index 99%
rename from source/funkin/NGio.hx
rename to source/funkin/api/newgrounds/NGio.hx
index e5f60c8b5..e505bdedf 100644
--- a/source/funkin/NGio.hx
+++ b/source/funkin/api/newgrounds/NGio.hx
@@ -1,4 +1,4 @@
-package funkin;
+package funkin.api.newgrounds;
 
 #if newgrounds
 import flixel.util.FlxSignal;
diff --git a/source/funkin/api/newgrounds/README.md b/source/funkin/api/newgrounds/README.md
index f61e1b0fd..09534fb71 100644
--- a/source/funkin/api/newgrounds/README.md
+++ b/source/funkin/api/newgrounds/README.md
@@ -6,4 +6,4 @@ This package contains two main classes:
 		such as retrieving achievement status.
 - `NGUnsafe` contains sensitive utility functions for interacting with the Newgrounds API.
 	- This includes any functions which scripts should not be able to use,
-		such as writing high scores or posting achievements.
\ No newline at end of file
+		such as writing high scores or posting achievements.
diff --git a/source/funkin/audiovis/ABot.hx b/source/funkin/audio/visualize/ABot.hx
similarity index 85%
rename from source/funkin/audiovis/ABot.hx
rename to source/funkin/audio/visualize/ABot.hx
index 11c123fb2..0b2ec619e 100644
--- a/source/funkin/audiovis/ABot.hx
+++ b/source/funkin/audio/visualize/ABot.hx
@@ -1,4 +1,4 @@
-package funkin.audiovis;
+package funkin.audio.visualize;
 
 import flixel.FlxSprite;
 import flixel.group.FlxSpriteGroup.FlxTypedSpriteGroup;
diff --git a/source/funkin/audiovis/ABotVis.hx b/source/funkin/audio/visualize/ABotVis.hx
similarity index 96%
rename from source/funkin/audiovis/ABotVis.hx
rename to source/funkin/audio/visualize/ABotVis.hx
index 060bddcf7..681287808 100644
--- a/source/funkin/audiovis/ABotVis.hx
+++ b/source/funkin/audio/visualize/ABotVis.hx
@@ -1,12 +1,13 @@
-package funkin.audiovis;
+package funkin.audio.visualize;
 
-import funkin.audiovis.dsp.FFT;
+import funkin.audio.visualize.dsp.FFT;
 import flixel.FlxSprite;
 import flixel.addons.plugin.taskManager.FlxTask;
 import flixel.graphics.frames.FlxAtlasFrames;
 import flixel.group.FlxSpriteGroup.FlxTypedSpriteGroup;
 import flixel.math.FlxMath;
 import flixel.sound.FlxSound;
+import funkin.util.MathUtil;
 
 using Lambda;
 
@@ -86,7 +87,7 @@ class ABotVis extends FlxTypedSpriteGroup<FlxSprite>
         for (i in 0...group.members.length)
         {
           var getSliceShit = function(s:Int) {
-            var powShit = FlxMath.remapToRange(s, 0, group.members.length, 0, CoolUtil.coolBaseLog(10, freqShit[0].length));
+            var powShit = FlxMath.remapToRange(s, 0, group.members.length, 0, MathUtil.logBase(10, freqShit[0].length));
             return Math.round(Math.pow(10, powShit));
           };
 
diff --git a/source/funkin/audio/visualize/PolygonSpectogram.hx b/source/funkin/audio/visualize/PolygonSpectogram.hx
index 6b7e280ec..604bc910b 100644
--- a/source/funkin/audio/visualize/PolygonSpectogram.hx
+++ b/source/funkin/audio/visualize/PolygonSpectogram.hx
@@ -4,7 +4,7 @@ import flixel.math.FlxMath;
 import flixel.math.FlxPoint;
 import flixel.sound.FlxSound;
 import flixel.util.FlxColor;
-import funkin.audiovis.VisShit;
+import funkin.audio.visualize.VisShit;
 import funkin.graphics.rendering.MeshRender;
 import lime.utils.Int16Array;
 
diff --git a/source/funkin/audiovis/SpectogramSprite.hx b/source/funkin/audio/visualize/SpectogramSprite.hx
similarity index 98%
rename from source/funkin/audiovis/SpectogramSprite.hx
rename to source/funkin/audio/visualize/SpectogramSprite.hx
index c4f8234eb..63d0fcd2e 100644
--- a/source/funkin/audiovis/SpectogramSprite.hx
+++ b/source/funkin/audio/visualize/SpectogramSprite.hx
@@ -1,4 +1,4 @@
-package funkin.audiovis;
+package funkin.audio.visualize;
 
 import flixel.FlxSprite;
 import flixel.group.FlxSpriteGroup.FlxTypedSpriteGroup;
@@ -8,8 +8,8 @@ import flixel.math.FlxVector;
 import flixel.sound.FlxSound;
 import flixel.util.FlxColor;
 import funkin.audio.visualize.PolygonSpectogram.VISTYPE;
-import funkin.audiovis.VisShit.CurAudioInfo;
-import funkin.audiovis.dsp.FFT;
+import funkin.audio.visualize.VisShit.CurAudioInfo;
+import funkin.audio.visualize.dsp.FFT;
 import haxe.Timer;
 import lime.system.ThreadPool;
 import lime.utils.Int16Array;
diff --git a/source/funkin/audiovis/VisShit.hx b/source/funkin/audio/visualize/VisShit.hx
similarity index 93%
rename from source/funkin/audiovis/VisShit.hx
rename to source/funkin/audio/visualize/VisShit.hx
index ee531a977..5bfb8c7c5 100644
--- a/source/funkin/audiovis/VisShit.hx
+++ b/source/funkin/audio/visualize/VisShit.hx
@@ -1,11 +1,12 @@
-package funkin.audiovis;
+package funkin.audio.visualize;
 
 import flixel.math.FlxMath;
 import flixel.sound.FlxSound;
-import funkin.audiovis.dsp.FFT;
+import funkin.audio.visualize.dsp.FFT;
 import haxe.Timer;
 import lime.system.ThreadPool;
 import lime.utils.Int16Array;
+import funkin.util.MathUtil;
 
 using Lambda;
 
@@ -42,7 +43,7 @@ class VisShit
     // helpers, note that spectrum indexes suppose non-negative frequencies
     final binSize = fs / fftN;
     final indexToFreq = function(k:Int) {
-      var powShit:Float = FlxMath.remapToRange(k, 0, halfN, 0, CoolUtil.coolBaseLog(10, halfN)); // 4.3 is almost the log of 20Khz or so. Close enuf lol
+      var powShit:Float = FlxMath.remapToRange(k, 0, halfN, 0, MathUtil.logBase(10, halfN)); // 4.3 is almost the log of 20Khz or so. Close enuf lol
 
       return 1.0 * (Math.pow(10, powShit)); // we need the `1.0` to avoid overflows
     };
diff --git a/source/funkin/audiovis/dsp/Complex.hx b/source/funkin/audio/visualize/dsp/Complex.hx
similarity index 98%
rename from source/funkin/audiovis/dsp/Complex.hx
rename to source/funkin/audio/visualize/dsp/Complex.hx
index 523549e99..37861bcc3 100644
--- a/source/funkin/audiovis/dsp/Complex.hx
+++ b/source/funkin/audio/visualize/dsp/Complex.hx
@@ -1,4 +1,4 @@
-package funkin.audiovis.dsp;
+package funkin.audio.visualize.dsp;
 
 /**
   Complex number representation.
diff --git a/source/funkin/audiovis/dsp/FFT.hx b/source/funkin/audio/visualize/dsp/FFT.hx
similarity index 97%
rename from source/funkin/audiovis/dsp/FFT.hx
rename to source/funkin/audio/visualize/dsp/FFT.hx
index d1d99140e..dc75acb81 100644
--- a/source/funkin/audiovis/dsp/FFT.hx
+++ b/source/funkin/audio/visualize/dsp/FFT.hx
@@ -1,9 +1,9 @@
-package funkin.audiovis.dsp;
+package funkin.audio.visualize.dsp;
 
-import funkin.audiovis.dsp.Complex;
+import funkin.audio.visualize.dsp.Complex;
 
-using funkin.audiovis.dsp.OffsetArray;
-using funkin.audiovis.dsp.Signal;
+using funkin.audio.visualize.dsp.OffsetArray;
+using funkin.audio.visualize.dsp.Signal;
 
 // these are only used for testing, down in FFT.main()
 
diff --git a/source/funkin/audiovis/dsp/OffsetArray.hx b/source/funkin/audio/visualize/dsp/OffsetArray.hx
similarity index 98%
rename from source/funkin/audiovis/dsp/OffsetArray.hx
rename to source/funkin/audio/visualize/dsp/OffsetArray.hx
index bd066a727..c8a5c27c3 100644
--- a/source/funkin/audiovis/dsp/OffsetArray.hx
+++ b/source/funkin/audio/visualize/dsp/OffsetArray.hx
@@ -1,4 +1,4 @@
-package funkin.audiovis.dsp;
+package funkin.audio.visualize.dsp;
 
 /**
   A view into an Array with an indexing offset.
diff --git a/source/funkin/audiovis/dsp/Signal.hx b/source/funkin/audio/visualize/dsp/Signal.hx
similarity index 98%
rename from source/funkin/audiovis/dsp/Signal.hx
rename to source/funkin/audio/visualize/dsp/Signal.hx
index 1f7cc6114..4557dc199 100644
--- a/source/funkin/audiovis/dsp/Signal.hx
+++ b/source/funkin/audio/visualize/dsp/Signal.hx
@@ -1,4 +1,4 @@
-package funkin.audiovis.dsp;
+package funkin.audio.visualize.dsp;
 
 using Lambda;
 
diff --git a/source/funkin/shaderslmfao/AngleMask.hx b/source/funkin/graphics/shaders/AngleMask.hx
similarity index 96%
rename from source/funkin/shaderslmfao/AngleMask.hx
rename to source/funkin/graphics/shaders/AngleMask.hx
index b9188201b..30e508a58 100644
--- a/source/funkin/shaderslmfao/AngleMask.hx
+++ b/source/funkin/graphics/shaders/AngleMask.hx
@@ -1,4 +1,4 @@
-package funkin.shaderslmfao;
+package funkin.graphics.shaders;
 
 import flixel.system.FlxAssets.FlxShader;
 
diff --git a/source/funkin/shaderslmfao/BlendModeEffect.hx b/source/funkin/graphics/shaders/BlendModeEffect.hx
similarity index 95%
rename from source/funkin/shaderslmfao/BlendModeEffect.hx
rename to source/funkin/graphics/shaders/BlendModeEffect.hx
index 8fe98f70a..bf2246795 100644
--- a/source/funkin/shaderslmfao/BlendModeEffect.hx
+++ b/source/funkin/graphics/shaders/BlendModeEffect.hx
@@ -1,4 +1,4 @@
-package funkin.shaderslmfao;
+package funkin.graphics.shaders;
 
 import flixel.util.FlxColor;
 import openfl.display.ShaderParameter;
diff --git a/source/funkin/shaderslmfao/BlendModesShader.hx b/source/funkin/graphics/shaders/BlendModesShader.hx
similarity index 92%
rename from source/funkin/shaderslmfao/BlendModesShader.hx
rename to source/funkin/graphics/shaders/BlendModesShader.hx
index 6807a65c0..acd2c1586 100644
--- a/source/funkin/shaderslmfao/BlendModesShader.hx
+++ b/source/funkin/graphics/shaders/BlendModesShader.hx
@@ -1,4 +1,4 @@
-package funkin.shaderslmfao;
+package funkin.graphics.shaders;
 
 import flixel.addons.display.FlxRuntimeShader;
 import funkin.Paths;
diff --git a/source/funkin/shaderslmfao/ColorSwap.hx b/source/funkin/graphics/shaders/ColorSwap.hx
similarity index 99%
rename from source/funkin/shaderslmfao/ColorSwap.hx
rename to source/funkin/graphics/shaders/ColorSwap.hx
index 2c1f5664b..1be4d5429 100644
--- a/source/funkin/shaderslmfao/ColorSwap.hx
+++ b/source/funkin/graphics/shaders/ColorSwap.hx
@@ -1,4 +1,4 @@
-package funkin.shaderslmfao;
+package funkin.graphics.shaders;
 
 import flixel.system.FlxAssets.FlxShader;
 import flixel.util.FlxColor;
diff --git a/source/funkin/shaderslmfao/GaussianBlurShader.hx b/source/funkin/graphics/shaders/GaussianBlurShader.hx
similarity index 93%
rename from source/funkin/shaderslmfao/GaussianBlurShader.hx
rename to source/funkin/graphics/shaders/GaussianBlurShader.hx
index ad472ac31..81167655b 100644
--- a/source/funkin/shaderslmfao/GaussianBlurShader.hx
+++ b/source/funkin/graphics/shaders/GaussianBlurShader.hx
@@ -1,4 +1,4 @@
-package funkin.shaderslmfao;
+package funkin.graphics.shaders;
 
 import flixel.addons.display.FlxRuntimeShader;
 import funkin.Paths;
diff --git a/source/funkin/shaderslmfao/Grayscale.hx b/source/funkin/graphics/shaders/Grayscale.hx
similarity index 92%
rename from source/funkin/shaderslmfao/Grayscale.hx
rename to source/funkin/graphics/shaders/Grayscale.hx
index 016d64b46..6673ace24 100644
--- a/source/funkin/shaderslmfao/Grayscale.hx
+++ b/source/funkin/graphics/shaders/Grayscale.hx
@@ -1,4 +1,4 @@
-package funkin.shaderslmfao;
+package funkin.graphics.shaders;
 
 import flixel.addons.display.FlxRuntimeShader;
 import funkin.Paths;
diff --git a/source/funkin/shaderslmfao/HSVShader.hx b/source/funkin/graphics/shaders/HSVShader.hx
similarity index 96%
rename from source/funkin/shaderslmfao/HSVShader.hx
rename to source/funkin/graphics/shaders/HSVShader.hx
index 066a49c96..733bbca7f 100644
--- a/source/funkin/shaderslmfao/HSVShader.hx
+++ b/source/funkin/graphics/shaders/HSVShader.hx
@@ -1,4 +1,4 @@
-package funkin.shaderslmfao;
+package funkin.graphics.shaders;
 
 import flixel.addons.display.FlxRuntimeShader;
 import funkin.Paths;
diff --git a/source/funkin/shaderslmfao/LeftMaskShader.hx b/source/funkin/graphics/shaders/LeftMaskShader.hx
similarity index 97%
rename from source/funkin/shaderslmfao/LeftMaskShader.hx
rename to source/funkin/graphics/shaders/LeftMaskShader.hx
index e921a7f2b..f82a5c208 100644
--- a/source/funkin/shaderslmfao/LeftMaskShader.hx
+++ b/source/funkin/graphics/shaders/LeftMaskShader.hx
@@ -1,4 +1,4 @@
-package funkin.shaderslmfao;
+package funkin.graphics.shaders;
 
 import flixel.math.FlxRect;
 import flixel.system.FlxAssets.FlxShader;
diff --git a/source/funkin/shaderslmfao/MultiplyShader.hx b/source/funkin/graphics/shaders/MultiplyShader.hx
similarity index 94%
rename from source/funkin/shaderslmfao/MultiplyShader.hx
rename to source/funkin/graphics/shaders/MultiplyShader.hx
index 2868982a2..5fe95f04e 100644
--- a/source/funkin/shaderslmfao/MultiplyShader.hx
+++ b/source/funkin/graphics/shaders/MultiplyShader.hx
@@ -1,4 +1,4 @@
-package funkin.shaderslmfao;
+package funkin.graphics.shaders;
 
 import flixel.system.FlxAssets.FlxShader;
 
diff --git a/source/funkin/shaderslmfao/OverlayBlend.hx b/source/funkin/graphics/shaders/OverlayBlend.hx
similarity index 97%
rename from source/funkin/shaderslmfao/OverlayBlend.hx
rename to source/funkin/graphics/shaders/OverlayBlend.hx
index 8845a3b55..e44f3152a 100644
--- a/source/funkin/shaderslmfao/OverlayBlend.hx
+++ b/source/funkin/graphics/shaders/OverlayBlend.hx
@@ -1,4 +1,4 @@
-package funkin.shaderslmfao;
+package funkin.graphics.shaders;
 
 import flixel.math.FlxPoint;
 import flixel.system.FlxAssets.FlxShader;
diff --git a/source/funkin/shaderslmfao/PureColor.hx b/source/funkin/graphics/shaders/PureColor.hx
similarity index 96%
rename from source/funkin/shaderslmfao/PureColor.hx
rename to source/funkin/graphics/shaders/PureColor.hx
index 767a29d0d..1d2216a8c 100644
--- a/source/funkin/shaderslmfao/PureColor.hx
+++ b/source/funkin/graphics/shaders/PureColor.hx
@@ -1,4 +1,4 @@
-package funkin.shaderslmfao;
+package funkin.graphics.shaders;
 
 import flixel.system.FlxAssets.FlxShader;
 import flixel.util.FlxColor;
diff --git a/source/funkin/shaderslmfao/ScreenWipeShader.hx b/source/funkin/graphics/shaders/ScreenWipeShader.hx
similarity index 98%
rename from source/funkin/shaderslmfao/ScreenWipeShader.hx
rename to source/funkin/graphics/shaders/ScreenWipeShader.hx
index 1aeb069ba..bc45f0ef6 100644
--- a/source/funkin/shaderslmfao/ScreenWipeShader.hx
+++ b/source/funkin/graphics/shaders/ScreenWipeShader.hx
@@ -1,4 +1,4 @@
-package funkin.shaderslmfao;
+package funkin.graphics.shaders;
 
 import flixel.system.FlxAssets.FlxShader;
 
diff --git a/source/funkin/shaderslmfao/StrokeShader.hx b/source/funkin/graphics/shaders/StrokeShader.hx
similarity index 98%
rename from source/funkin/shaderslmfao/StrokeShader.hx
rename to source/funkin/graphics/shaders/StrokeShader.hx
index 38dc41636..fd133ac0a 100644
--- a/source/funkin/shaderslmfao/StrokeShader.hx
+++ b/source/funkin/graphics/shaders/StrokeShader.hx
@@ -1,4 +1,4 @@
-package funkin.shaderslmfao;
+package funkin.graphics.shaders;
 
 import flixel.system.FlxAssets.FlxShader;
 import flixel.util.FlxColor;
diff --git a/source/funkin/shaderslmfao/TitleOutline.hx b/source/funkin/graphics/shaders/TitleOutline.hx
similarity index 98%
rename from source/funkin/shaderslmfao/TitleOutline.hx
rename to source/funkin/graphics/shaders/TitleOutline.hx
index 9a849f795..db60fc3ae 100644
--- a/source/funkin/shaderslmfao/TitleOutline.hx
+++ b/source/funkin/graphics/shaders/TitleOutline.hx
@@ -1,4 +1,4 @@
-package funkin.shaderslmfao;
+package funkin.graphics.shaders;
 
 import flixel.math.FlxPoint;
 import flixel.system.FlxAssets.FlxShader;
diff --git a/source/funkin/shaderslmfao/WaveShader.hx b/source/funkin/graphics/shaders/WaveShader.hx
similarity index 90%
rename from source/funkin/shaderslmfao/WaveShader.hx
rename to source/funkin/graphics/shaders/WaveShader.hx
index 89171b089..8738cc405 100644
--- a/source/funkin/shaderslmfao/WaveShader.hx
+++ b/source/funkin/graphics/shaders/WaveShader.hx
@@ -1,4 +1,4 @@
-package funkin.shaderslmfao;
+package funkin.graphics.shaders;
 
 import flixel.system.FlxAssets.FlxShader;
 
diff --git a/source/funkin/shaderslmfao/WiggleEffectRuntime.hx b/source/funkin/graphics/shaders/WiggleEffectRuntime.hx
similarity index 98%
rename from source/funkin/shaderslmfao/WiggleEffectRuntime.hx
rename to source/funkin/graphics/shaders/WiggleEffectRuntime.hx
index 23f93b672..a04941df5 100644
--- a/source/funkin/shaderslmfao/WiggleEffectRuntime.hx
+++ b/source/funkin/graphics/shaders/WiggleEffectRuntime.hx
@@ -1,4 +1,4 @@
-package funkin.shaderslmfao;
+package funkin.graphics.shaders;
 
 import flixel.addons.display.FlxRuntimeShader;
 import openfl.Assets;
diff --git a/source/funkin/input/TurboKeyHandler.hx b/source/funkin/input/TurboKeyHandler.hx
index 099d373b4..36b8e2087 100644
--- a/source/funkin/input/TurboKeyHandler.hx
+++ b/source/funkin/input/TurboKeyHandler.hx
@@ -108,8 +108,7 @@ class TurboKeyHandler extends FlxBasic
    * @param repeatDelay How long to wait between repeats.
    * @return A TurboKeyHandler
    */
-  public static overload inline extern function build(inputKeys:Array<FlxKey>, ?delay:Float = DEFAULT_DELAY,
-      ?interval:Float = DEFAULT_INTERVAL):TurboKeyHandler
+  public static overload inline extern function build(inputKeys:Array<FlxKey>, ?delay:Float = DEFAULT_DELAY, ?interval:Float = DEFAULT_INTERVAL):TurboKeyHandler
   {
     return new TurboKeyHandler(inputKeys, delay, interval);
   }
diff --git a/source/funkin/modding/base/ScriptedMusicBeatState.hx b/source/funkin/modding/base/ScriptedMusicBeatState.hx
index 782e4d0b8..6dc6826c4 100644
--- a/source/funkin/modding/base/ScriptedMusicBeatState.hx
+++ b/source/funkin/modding/base/ScriptedMusicBeatState.hx
@@ -5,4 +5,4 @@ package funkin.modding.base;
  * Create a scripted class that extends MusicBeatState to use this.
  */
 @:hscriptClass
-class ScriptedMusicBeatState extends funkin.MusicBeatState implements HScriptedClass {}
+class ScriptedMusicBeatState extends funkin.ui.MusicBeatState implements HScriptedClass {}
diff --git a/source/funkin/modding/base/ScriptedMusicBeatSubState.hx b/source/funkin/modding/base/ScriptedMusicBeatSubState.hx
index 7dab3d7dd..79c98ea3f 100644
--- a/source/funkin/modding/base/ScriptedMusicBeatSubState.hx
+++ b/source/funkin/modding/base/ScriptedMusicBeatSubState.hx
@@ -5,4 +5,4 @@ package funkin.modding.base;
  * Create a scripted class that extends MusicBeatSubState to use this.
  */
 @:hscriptClass
-class ScriptedMusicBeatSubState extends funkin.MusicBeatSubState implements HScriptedClass {}
+class ScriptedMusicBeatSubState extends funkin.ui.MusicBeatSubState implements HScriptedClass {}
diff --git a/source/funkin/play/Fighter.hx b/source/funkin/play/Fighter.hx
deleted file mode 100644
index 691d21b83..000000000
--- a/source/funkin/play/Fighter.hx
+++ /dev/null
@@ -1,68 +0,0 @@
-package funkin.play;
-
-import funkin.play.character.BaseCharacter;
-import flixel.FlxSprite;
-
-class Fighter extends BaseCharacter
-{
-  public function new(?x:Float = 0, ?y:Float = 0, ?char:String = "pico-fighter")
-  {
-    super(char, Custom);
-    this.x = x;
-    this.y = y;
-
-    animation.finishCallback = function(anim:String) {
-      switch anim
-      {
-        case "punch low" | "punch high" | "block" | 'dodge':
-          dance(true);
-      }
-    };
-  }
-
-  public var actions:Array<ACTIONS> = [PUNCH, BLOCK, DODGE];
-
-  public function doSomething(?forceAction:ACTIONS)
-  {
-    var daAction:ACTIONS = FlxG.random.getObject(actions);
-
-    if (forceAction != null) daAction = forceAction;
-
-    switch (daAction)
-    {
-      case PUNCH:
-        punch();
-      case BLOCK:
-        block();
-      case DODGE:
-        dodge();
-    }
-  }
-
-  public var curAction:ACTIONS = DODGE;
-
-  function dodge()
-  {
-    playAnimation('dodge');
-    curAction = DODGE;
-  }
-
-  public function block()
-  {
-    playAnimation('block');
-    curAction = BLOCK;
-  }
-
-  public function punch()
-  {
-    curAction = PUNCH;
-    playAnimation('punch ' + (FlxG.random.bool() ? "low" : "high"));
-  }
-}
-
-enum ACTIONS
-{
-  DODGE;
-  BLOCK;
-  PUNCH;
-}
diff --git a/source/funkin/play/GameOverSubState.hx b/source/funkin/play/GameOverSubState.hx
index c5d9b4b0b..6eb53e2d5 100644
--- a/source/funkin/play/GameOverSubState.hx
+++ b/source/funkin/play/GameOverSubState.hx
@@ -7,9 +7,11 @@ import flixel.sound.FlxSound;
 import funkin.ui.story.StoryMenuState;
 import flixel.util.FlxColor;
 import flixel.util.FlxTimer;
+import funkin.ui.MusicBeatSubState;
 import funkin.modding.events.ScriptEvent;
 import funkin.modding.events.ScriptEventDispatcher;
 import funkin.play.PlayState;
+import funkin.ui.freeplay.FreeplayState;
 import funkin.play.character.BaseCharacter;
 
 /**
diff --git a/source/funkin/GitarooPause.hx b/source/funkin/play/GitarooPause.hx
similarity index 96%
rename from source/funkin/GitarooPause.hx
rename to source/funkin/play/GitarooPause.hx
index a4dc766be..dbfbf5961 100644
--- a/source/funkin/GitarooPause.hx
+++ b/source/funkin/play/GitarooPause.hx
@@ -1,9 +1,11 @@
-package funkin;
+package funkin.play;
 
 import flixel.FlxSprite;
 import flixel.graphics.frames.FlxAtlasFrames;
 import funkin.play.PlayState;
+import funkin.ui.MusicBeatState;
 import flixel.addons.transition.FlxTransitionableState;
+import funkin.ui.mainmenu.MainMenuState;
 
 class GitarooPause extends MusicBeatState
 {
diff --git a/source/funkin/PauseSubState.hx b/source/funkin/play/PauseSubState.hx
similarity index 97%
rename from source/funkin/PauseSubState.hx
rename to source/funkin/play/PauseSubState.hx
index 2ce9abf65..f5555b66e 100644
--- a/source/funkin/PauseSubState.hx
+++ b/source/funkin/play/PauseSubState.hx
@@ -1,9 +1,10 @@
-package funkin;
+package funkin.play;
 
 import funkin.play.PlayStatePlaylist;
 import flixel.FlxSprite;
 import flixel.addons.transition.FlxTransitionableState;
 import flixel.group.FlxGroup.FlxTypedGroup;
+import funkin.ui.MusicBeatSubState;
 import flixel.sound.FlxSound;
 import flixel.text.FlxText;
 import flixel.tweens.FlxEase;
@@ -11,6 +12,7 @@ import flixel.tweens.FlxTween;
 import flixel.util.FlxColor;
 import funkin.play.PlayState;
 import funkin.data.song.SongRegistry;
+import funkin.ui.Alphabet;
 
 class PauseSubState extends MusicBeatSubState
 {
@@ -231,11 +233,11 @@ class PauseSubState extends MusicBeatSubState
 
             if (PlayStatePlaylist.isStoryMode)
             {
-              openSubState(new funkin.ui.StickerSubState(null, STORY));
+              openSubState(new funkin.ui.transition.StickerSubState(null, STORY));
             }
             else
             {
-              openSubState(new funkin.ui.StickerSubState(null, FREEPLAY));
+              openSubState(new funkin.ui.transition.StickerSubState(null, FREEPLAY));
             }
 
           case 'Exit to Chart Editor':
diff --git a/source/funkin/play/PlayState.hx b/source/funkin/play/PlayState.hx
index 4542b9f98..c1e62146b 100644
--- a/source/funkin/play/PlayState.hx
+++ b/source/funkin/play/PlayState.hx
@@ -1,5 +1,6 @@
 package funkin.play;
 
+import funkin.ui.SwagCamera;
 import flixel.addons.transition.FlxTransitionableSubState;
 import funkin.ui.debug.charting.ChartEditorState;
 import haxe.Int64;
@@ -16,19 +17,24 @@ import flixel.FlxState;
 import flixel.FlxSubState;
 import flixel.input.keyboard.FlxKey;
 import flixel.math.FlxMath;
+import funkin.play.components.ComboMilestone;
 import flixel.math.FlxPoint;
+import funkin.play.components.HealthIcon;
+import funkin.ui.MusicBeatSubState;
 import flixel.math.FlxRect;
 import flixel.text.FlxText;
 import flixel.tweens.FlxEase;
 import flixel.tweens.FlxTween;
 import flixel.ui.FlxBar;
 import flixel.util.FlxColor;
+import funkin.api.newgrounds.NGio;
 import flixel.util.FlxTimer;
 import funkin.audio.VoicesGroup;
 import funkin.save.Save;
 import funkin.Highscore.Tallies;
 import funkin.input.PreciseInputManager;
 import funkin.modding.events.ScriptEvent;
+import funkin.ui.mainmenu.MainMenuState;
 import funkin.modding.events.ScriptEventDispatcher;
 import funkin.play.character.BaseCharacter;
 import funkin.play.character.CharacterData.CharacterDataParser;
@@ -42,7 +48,6 @@ import funkin.play.notes.NoteDirection;
 import funkin.play.notes.Strumline;
 import funkin.play.notes.SustainTrail;
 import funkin.play.scoring.Scoring;
-import funkin.NoteSplash;
 import funkin.play.song.Song;
 import funkin.data.song.SongRegistry;
 import funkin.data.song.SongData.SongEventData;
@@ -50,9 +55,10 @@ 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.PopUpStuff;
-import funkin.ui.PreferencesMenu;
-import funkin.ui.stageBuildShit.StageOffsetSubState;
+import funkin.ui.transition.LoadingState;
+import funkin.play.components.PopUpStuff;
+import funkin.ui.options.PreferencesMenu;
+import funkin.ui.debug.stage.StageOffsetSubState;
 import funkin.ui.story.StoryMenuState;
 import funkin.util.SerializerUtil;
 import funkin.util.SortUtil;
@@ -510,8 +516,6 @@ class PlayState extends MusicBeatSubState
     }
     instance = this;
 
-    NoteSplash.buildSplashFrames();
-
     if (!assertChartExists()) return;
 
     if (false)
diff --git a/source/funkin/play/ResultState.hx b/source/funkin/play/ResultState.hx
index 3f7231c2a..507fa1236 100644
--- a/source/funkin/play/ResultState.hx
+++ b/source/funkin/play/ResultState.hx
@@ -8,16 +8,18 @@ import flixel.graphics.frames.FlxAtlasFrames;
 import flixel.graphics.frames.FlxBitmapFont;
 import flixel.group.FlxGroup.FlxTypedGroup;
 import flixel.math.FlxPoint;
+import funkin.ui.MusicBeatSubState;
 import flixel.math.FlxRect;
 import flixel.text.FlxBitmapText;
 import flixel.text.FlxText;
 import flixel.tweens.FlxEase;
+import funkin.ui.freeplay.FreeplayState;
 import flixel.tweens.FlxTween;
 import flixel.util.FlxColor;
 import flixel.util.FlxGradient;
 import flixel.util.FlxTimer;
-import funkin.shaderslmfao.LeftMaskShader;
-import funkin.ui.TallyCounter;
+import funkin.graphics.shaders.LeftMaskShader;
+import funkin.play.components.TallyCounter;
 import flxanimate.FlxAnimate.Settings;
 
 class ResultState extends MusicBeatSubState
diff --git a/source/funkin/play/character/BaseCharacter.hx b/source/funkin/play/character/BaseCharacter.hx
index 588b5663d..7ad0892f6 100644
--- a/source/funkin/play/character/BaseCharacter.hx
+++ b/source/funkin/play/character/BaseCharacter.hx
@@ -58,7 +58,7 @@ class BaseCharacter extends Bopper
    */
   public var dropNoteCounts(default, null):Array<Int>;
 
-  @:allow(funkin.ui.animDebugShit.DebugBoundingState)
+  @:allow(funkin.ui.debug.anim.DebugBoundingState)
   final _data:CharacterData;
   final singTimeSec:Float;
 
diff --git a/source/funkin/ComboMilestone.hx b/source/funkin/play/components/ComboMilestone.hx
similarity index 98%
rename from source/funkin/ComboMilestone.hx
rename to source/funkin/play/components/ComboMilestone.hx
index 79e454c44..54d1438f1 100644
--- a/source/funkin/ComboMilestone.hx
+++ b/source/funkin/play/components/ComboMilestone.hx
@@ -1,4 +1,4 @@
-package funkin;
+package funkin.play.components;
 
 import flixel.FlxSprite;
 import flixel.group.FlxGroup.FlxTypedGroup;
diff --git a/source/funkin/play/HealthIcon.hx b/source/funkin/play/components/HealthIcon.hx
similarity index 98%
rename from source/funkin/play/HealthIcon.hx
rename to source/funkin/play/components/HealthIcon.hx
index 958933df8..0d90df5a0 100644
--- a/source/funkin/play/HealthIcon.hx
+++ b/source/funkin/play/components/HealthIcon.hx
@@ -1,4 +1,4 @@
-package funkin.play;
+package funkin.play.components;
 
 import funkin.play.character.CharacterData;
 import flixel.FlxSprite;
@@ -6,6 +6,7 @@ import flixel.math.FlxMath;
 import flixel.math.FlxPoint;
 import funkin.play.character.CharacterData.CharacterDataParser;
 import openfl.utils.Assets;
+import funkin.util.MathUtil;
 
 /**
  * This is a rework of the health icon with the following changes:
@@ -201,19 +202,19 @@ class HealthIcon extends FlxSprite
       if (this.width > this.height)
       {
         // Apply linear interpolation while accounting for frame rate.
-        var targetSize:Int = Std.int(CoolUtil.coolLerp(this.width, HEALTH_ICON_SIZE * this.size.x, 0.15));
+        var targetSize:Int = Std.int(MathUtil.coolLerp(this.width, HEALTH_ICON_SIZE * this.size.x, 0.15));
 
         setGraphicSize(targetSize, 0);
       }
       else
       {
-        var targetSize:Int = Std.int(CoolUtil.coolLerp(this.height, HEALTH_ICON_SIZE * this.size.y, 0.15));
+        var targetSize:Int = Std.int(MathUtil.coolLerp(this.height, HEALTH_ICON_SIZE * this.size.y, 0.15));
 
         setGraphicSize(0, targetSize);
       }
 
       // Lerp the health icon back to its normal angle.
-      this.angle = CoolUtil.coolLerp(this.angle, 0, 0.15);
+      this.angle = MathUtil.coolLerp(this.angle, 0, 0.15);
 
       this.updateHitbox();
     }
diff --git a/source/funkin/ui/PopUpStuff.hx b/source/funkin/play/components/PopUpStuff.hx
similarity index 99%
rename from source/funkin/ui/PopUpStuff.hx
rename to source/funkin/play/components/PopUpStuff.hx
index 75fc87c8b..38a6ec15a 100644
--- a/source/funkin/ui/PopUpStuff.hx
+++ b/source/funkin/play/components/PopUpStuff.hx
@@ -1,4 +1,4 @@
-package funkin.ui;
+package funkin.play.components;
 
 import flixel.FlxSprite;
 import flixel.group.FlxGroup.FlxTypedGroup;
diff --git a/source/funkin/ui/TallyCounter.hx b/source/funkin/play/components/TallyCounter.hx
similarity index 94%
rename from source/funkin/ui/TallyCounter.hx
rename to source/funkin/play/components/TallyCounter.hx
index 72857671e..77e6ef4ec 100644
--- a/source/funkin/ui/TallyCounter.hx
+++ b/source/funkin/play/components/TallyCounter.hx
@@ -1,4 +1,4 @@
-package funkin.ui;
+package funkin.play.components;
 
 import flixel.FlxSprite;
 import flixel.group.FlxGroup.FlxTypedGroup;
@@ -8,7 +8,7 @@ import flixel.tweens.FlxEase;
 import flixel.tweens.FlxTween;
 
 /**
- * Similar to ComboCounter, but it's not!
+ * Numerical counters used next to each judgement in the Results screen.
  */
 class TallyCounter extends FlxTypedSpriteGroup<FlxSprite>
 {
diff --git a/source/funkin/play/cutscene/dialogue/ConversationDebugState.hx b/source/funkin/play/cutscene/dialogue/ConversationDebugState.hx
index 70ac011a2..13697b9f4 100644
--- a/source/funkin/play/cutscene/dialogue/ConversationDebugState.hx
+++ b/source/funkin/play/cutscene/dialogue/ConversationDebugState.hx
@@ -4,6 +4,7 @@ import flixel.FlxState;
 import funkin.modding.events.ScriptEventDispatcher;
 import funkin.modding.events.ScriptEvent;
 import flixel.util.FlxColor;
+import funkin.ui.MusicBeatState;
 
 /**
  * A state with displays a conversation with no background.
diff --git a/source/funkin/play/notes/Strumline.hx b/source/funkin/play/notes/Strumline.hx
index 60b995c06..369a4144a 100644
--- a/source/funkin/play/notes/Strumline.hx
+++ b/source/funkin/play/notes/Strumline.hx
@@ -12,7 +12,7 @@ import funkin.play.notes.NoteSplash;
 import funkin.play.notes.NoteSprite;
 import funkin.play.notes.SustainTrail;
 import funkin.data.song.SongData.SongNoteData;
-import funkin.ui.PreferencesMenu;
+import funkin.ui.options.PreferencesMenu;
 import funkin.util.SortUtil;
 
 /**
diff --git a/source/funkin/play/notes/SustainTrail.hx b/source/funkin/play/notes/SustainTrail.hx
index f55799828..ab4bf5f16 100644
--- a/source/funkin/play/notes/SustainTrail.hx
+++ b/source/funkin/play/notes/SustainTrail.hx
@@ -8,7 +8,7 @@ import flixel.FlxSprite;
 import flixel.graphics.FlxGraphic;
 import flixel.graphics.tile.FlxDrawTrianglesItem;
 import flixel.math.FlxMath;
-import funkin.ui.PreferencesMenu;
+import funkin.ui.options.PreferencesMenu;
 
 /**
  * This is based heavily on the `FlxStrip` class. It uses `drawTriangles()` to clip a sustain note
diff --git a/source/funkin/play/stage/Bopper.hx b/source/funkin/play/stage/Bopper.hx
index 187b5ec32..1bc0632f9 100644
--- a/source/funkin/play/stage/Bopper.hx
+++ b/source/funkin/play/stage/Bopper.hx
@@ -85,7 +85,7 @@ class Bopper extends StageProp implements IPlayStateScriptedClass
     return globalOffsets = value;
   }
 
-  @:allow(funkin.ui.animDebugShit.DebugBoundingState)
+  @:allow(funkin.ui.debug.anim.DebugBoundingState)
   var animOffsets(default, set):Array<Float> = [0, 0];
 
   public var originalPosition:FlxPoint = new FlxPoint(0, 0);
diff --git a/source/funkin/Alphabet.hx b/source/funkin/ui/Alphabet.hx
similarity index 97%
rename from source/funkin/Alphabet.hx
rename to source/funkin/ui/Alphabet.hx
index 45e9a2aee..66b95f5b8 100644
--- a/source/funkin/Alphabet.hx
+++ b/source/funkin/ui/Alphabet.hx
@@ -1,9 +1,10 @@
-package funkin;
+package funkin.ui;
 
 import flixel.FlxSprite;
 import flixel.group.FlxSpriteGroup;
 import flixel.math.FlxMath;
 import flixel.util.FlxTimer;
+import funkin.util.MathUtil;
 
 /**
  * Loosley based on FlxTypeText lolol
@@ -151,7 +152,6 @@ class Alphabet extends FlxSpriteGroup
       if (AlphaCharacter.alphabet.indexOf(splitWords[loopNum].toLowerCase()) != -1
         || isNumber
         || isSymbol) // if (AlphaCharacter.alphabet.contains(splitWords[loopNum].toLowerCase()) || isNumber || isSymbol)
-
       {
         if (lastSprite != null && !xPosResetted)
         {
@@ -220,8 +220,8 @@ class Alphabet extends FlxSpriteGroup
     {
       var scaledY = FlxMath.remapToRange(targetY, 0, 1, 0, 1.3);
 
-      y = CoolUtil.coolLerp(y, (scaledY * 120) + (FlxG.height * 0.48), 0.16);
-      x = CoolUtil.coolLerp(x, (targetY * 20) + 90, 0.16);
+      y = MathUtil.coolLerp(y, (scaledY * 120) + (FlxG.height * 0.48), 0.16);
+      x = MathUtil.coolLerp(x, (targetY * 20) + 90, 0.16);
     }
 
     super.update(elapsed);
diff --git a/source/funkin/ui/AtlasMenuList.hx b/source/funkin/ui/AtlasMenuList.hx
index 028a00eef..efff6da93 100644
--- a/source/funkin/ui/AtlasMenuList.hx
+++ b/source/funkin/ui/AtlasMenuList.hx
@@ -38,7 +38,7 @@ class AtlasMenuList extends MenuTypedList<AtlasMenuItem>
 /**
  * A menu list item which uses single texture atlas.
  */
-class AtlasMenuItem extends MenuItem
+class AtlasMenuItem extends MenuListItem
 {
   var atlas:FlxAtlasFrames;
 
diff --git a/source/funkin/MenuItem.hx b/source/funkin/ui/MenuItem.hx
similarity index 93%
rename from source/funkin/MenuItem.hx
rename to source/funkin/ui/MenuItem.hx
index 261092a1a..ba5cc066b 100644
--- a/source/funkin/MenuItem.hx
+++ b/source/funkin/ui/MenuItem.hx
@@ -1,9 +1,10 @@
-package funkin;
+package funkin.ui;
 
 import flixel.FlxSprite;
 import flixel.graphics.frames.FlxAtlasFrames;
 import flixel.group.FlxSpriteGroup;
 import flixel.math.FlxMath;
+import funkin.util.MathUtil;
 import flixel.util.FlxColor;
 
 class MenuItem extends FlxSpriteGroup
@@ -44,7 +45,7 @@ class MenuItem extends FlxSpriteGroup
   override function update(elapsed:Float)
   {
     super.update(elapsed);
-    y = CoolUtil.coolLerp(y, (targetY * 120) + 480, 0.17);
+    y = MathUtil.coolLerp(y, (targetY * 120) + 480, 0.17);
 
     if (isFlashing) flashingInt += 1;
 
diff --git a/source/funkin/ui/MenuList.hx b/source/funkin/ui/MenuList.hx
index f1de8d40e..3ffe3c330 100644
--- a/source/funkin/ui/MenuList.hx
+++ b/source/funkin/ui/MenuList.hx
@@ -6,7 +6,7 @@ import flixel.group.FlxGroup;
 import flixel.math.FlxPoint;
 import flixel.util.FlxSignal;
 
-class MenuTypedList<T:MenuItem> extends FlxTypedGroup<T>
+class MenuTypedList<T:MenuListItem> extends FlxTypedGroup<T>
 {
   public var selectedIndex(default, null) = 0;
   public var selectedItem(get, never):T;
@@ -206,7 +206,7 @@ class MenuTypedList<T:MenuItem> extends FlxTypedGroup<T>
   }
 }
 
-class MenuItem extends FlxSprite
+class MenuListItem extends FlxSprite
 {
   public var callback:Void->Void;
   public var name:String;
@@ -261,7 +261,7 @@ class MenuItem extends FlxSprite
   }
 }
 
-class MenuTypedItem<T:FlxSprite> extends MenuItem
+class MenuTypedItem<T:FlxSprite> extends MenuListItem
 {
   public var label(default, set):T;
 
diff --git a/source/funkin/MusicBeatState.hx b/source/funkin/ui/MusicBeatState.hx
similarity index 98%
rename from source/funkin/MusicBeatState.hx
rename to source/funkin/ui/MusicBeatState.hx
index 9861c48c7..6d592f438 100644
--- a/source/funkin/MusicBeatState.hx
+++ b/source/funkin/ui/MusicBeatState.hx
@@ -1,6 +1,7 @@
-package funkin;
+package funkin.ui;
 
 import funkin.modding.IScriptedClass.IEventHandler;
+import funkin.ui.mainmenu.MainMenuState;
 import flixel.FlxState;
 import flixel.FlxSubState;
 import flixel.addons.transition.FlxTransitionableState;
diff --git a/source/funkin/MusicBeatSubState.hx b/source/funkin/ui/MusicBeatSubState.hx
similarity index 98%
rename from source/funkin/MusicBeatSubState.hx
rename to source/funkin/ui/MusicBeatSubState.hx
index 53fe19bdd..c4eedc7c6 100644
--- a/source/funkin/MusicBeatSubState.hx
+++ b/source/funkin/ui/MusicBeatSubState.hx
@@ -1,8 +1,9 @@
-package funkin;
+package funkin.ui;
 
 import flixel.addons.transition.FlxTransitionableSubState;
 import flixel.FlxSubState;
 import flixel.text.FlxText;
+import funkin.ui.mainmenu.MainMenuState;
 import flixel.util.FlxColor;
 import funkin.modding.events.ScriptEvent;
 import funkin.modding.IScriptedClass.IEventHandler;
diff --git a/source/funkin/SwagCamera.hx b/source/funkin/ui/SwagCamera.hx
similarity index 87%
rename from source/funkin/SwagCamera.hx
rename to source/funkin/ui/SwagCamera.hx
index 386eaea62..70791d38f 100644
--- a/source/funkin/SwagCamera.hx
+++ b/source/funkin/ui/SwagCamera.hx
@@ -1,8 +1,9 @@
-package funkin;
+package funkin.ui;
 
 import flixel.FlxCamera;
 import flixel.FlxSprite;
 import flixel.math.FlxPoint;
+import funkin.util.MathUtil;
 
 class SwagCamera extends FlxCamera
 {
@@ -92,10 +93,10 @@ class SwagCamera extends FlxCamera
       else
       {
         // THIS THE PART THAT ACTUALLY MATTERS LOL
-        scroll.x = CoolUtil.coolLerp(scroll.x, _scrollTarget.x, followLerp);
-        scroll.y = CoolUtil.coolLerp(scroll.y, _scrollTarget.y, followLerp);
-        // scroll.x += (_scrollTarget.x - scroll.x) * CoolUtil.camLerpShit(followLerp);
-        // scroll.y += (_scrollTarget.y - scroll.y) * CoolUtil.camLerpShit(followLerp);
+        scroll.x = MathUtil.coolLerp(scroll.x, _scrollTarget.x, followLerp);
+        scroll.y = MathUtil.coolLerp(scroll.y, _scrollTarget.y, followLerp);
+        // scroll.x += (_scrollTarget.x - scroll.x) * MathUtil.cameraLerp(followLerp);
+        // scroll.y += (_scrollTarget.y - scroll.y) * MathUtil.cameraLerp(followLerp);
       }
     }
   }
diff --git a/source/funkin/ui/debug/DebugMenuSubState.hx b/source/funkin/ui/debug/DebugMenuSubState.hx
index 7ef4cb238..ef02a802e 100644
--- a/source/funkin/ui/debug/DebugMenuSubState.hx
+++ b/source/funkin/ui/debug/DebugMenuSubState.hx
@@ -3,9 +3,10 @@ package funkin.ui.debug;
 import flixel.math.FlxPoint;
 import flixel.FlxObject;
 import flixel.FlxSprite;
-import funkin.MusicBeatSubState;
+import funkin.ui.MusicBeatSubState;
 import funkin.ui.TextMenuList;
 import funkin.ui.debug.charting.ChartEditorState;
+import funkin.ui.MusicBeatSubState;
 
 class DebugMenuSubState extends MusicBeatSubState
 {
@@ -85,13 +86,13 @@ class DebugMenuSubState extends MusicBeatSubState
 
   function openAnimationEditor()
   {
-    FlxG.switchState(new funkin.ui.animDebugShit.DebugBoundingState());
+    FlxG.switchState(new funkin.ui.debug.anim.DebugBoundingState());
     trace('Animation Editor');
   }
 
   function testStickers()
   {
-    openSubState(new funkin.ui.StickerSubState());
+    openSubState(new funkin.ui.transition.StickerSubState());
     trace('opened stickers');
   }
 
diff --git a/source/funkin/MemoryCounter.hx b/source/funkin/ui/debug/MemoryCounter.hx
similarity index 97%
rename from source/funkin/MemoryCounter.hx
rename to source/funkin/ui/debug/MemoryCounter.hx
index 658febe59..312d853e7 100644
--- a/source/funkin/MemoryCounter.hx
+++ b/source/funkin/ui/debug/MemoryCounter.hx
@@ -1,4 +1,4 @@
-package funkin;
+package funkin.ui.debug;
 
 import openfl.text.TextFormat;
 import openfl.system.System;
diff --git a/source/funkin/ui/animDebugShit/DebugBoundingState.hx b/source/funkin/ui/debug/anim/DebugBoundingState.hx
similarity index 98%
rename from source/funkin/ui/animDebugShit/DebugBoundingState.hx
rename to source/funkin/ui/debug/anim/DebugBoundingState.hx
index 297c44e8e..4e06913b4 100644
--- a/source/funkin/ui/animDebugShit/DebugBoundingState.hx
+++ b/source/funkin/ui/debug/anim/DebugBoundingState.hx
@@ -1,4 +1,4 @@
-package funkin.ui.animDebugShit;
+package funkin.ui.debug.anim;
 
 import funkin.util.SerializerUtil;
 import funkin.play.character.CharacterData;
@@ -15,6 +15,7 @@ import flixel.math.FlxPoint;
 import flixel.sound.FlxSound;
 import flixel.text.FlxText;
 import flixel.util.FlxColor;
+import funkin.util.MouseUtil;
 import flixel.util.FlxSpriteUtil;
 import flixel.util.FlxTimer;
 import funkin.play.character.BaseCharacter;
@@ -25,6 +26,7 @@ import haxe.ui.components.DropDown;
 import haxe.ui.core.Component;
 import haxe.ui.events.ItemEvent;
 import haxe.ui.events.UIEvent;
+import funkin.ui.mainmenu.MainMenuState;
 import lime.utils.Assets as LimeAssets;
 import openfl.Assets;
 import openfl.events.Event;
@@ -32,6 +34,7 @@ import openfl.events.IOErrorEvent;
 import openfl.geom.Rectangle;
 import openfl.net.FileReference;
 import openfl.net.URLLoader;
+import funkin.ui.mainmenu.MainMenuState;
 import openfl.net.URLRequest;
 import openfl.utils.ByteArray;
 import funkin.input.Cursor;
@@ -363,8 +366,8 @@ class DebugBoundingState extends FlxState
 
     if (FlxG.keys.justPressed.F4) FlxG.switchState(new MainMenuState());
 
-    CoolUtil.mouseCamDrag();
-    CoolUtil.mouseWheelZoom();
+    MouseUtil.mouseCamDrag();
+    MouseUtil.mouseWheelZoom();
 
     // bg.scale.x = FlxG.camera.zoom;
     // bg.scale.y = FlxG.camera.zoom;
diff --git a/source/funkin/ui/animDebugShit/FlxAnimateTest.hx b/source/funkin/ui/debug/anim/FlxAnimateTest.hx
similarity index 94%
rename from source/funkin/ui/animDebugShit/FlxAnimateTest.hx
rename to source/funkin/ui/debug/anim/FlxAnimateTest.hx
index 738e109ef..c83d2c370 100644
--- a/source/funkin/ui/animDebugShit/FlxAnimateTest.hx
+++ b/source/funkin/ui/debug/anim/FlxAnimateTest.hx
@@ -1,7 +1,8 @@
-package funkin.ui.animDebugShit;
+package funkin.ui.debug.anim;
 
 import flixel.FlxG;
 import funkin.graphics.adobeanimate.FlxAtlasSprite;
+import funkin.ui.MusicBeatState;
 
 /**
  * A simple test of FlxAnimate.
diff --git a/source/funkin/ui/debug/charting/ChartEditorState.hx b/source/funkin/ui/debug/charting/ChartEditorState.hx
index 366e446e5..abe3ebf6f 100644
--- a/source/funkin/ui/debug/charting/ChartEditorState.hx
+++ b/source/funkin/ui/debug/charting/ChartEditorState.hx
@@ -16,6 +16,7 @@ import flixel.tweens.FlxEase;
 import flixel.tweens.FlxTween;
 import flixel.tweens.misc.VarTween;
 import flixel.util.FlxColor;
+import funkin.ui.mainmenu.MainMenuState;
 import flixel.util.FlxSort;
 import flixel.util.FlxTimer;
 import funkin.audio.visualize.PolygonSpectogram;
@@ -31,7 +32,7 @@ import funkin.input.TurboKeyHandler;
 import funkin.modding.events.ScriptEvent;
 import funkin.play.character.BaseCharacter.CharacterType;
 import funkin.play.character.CharacterData;
-import funkin.play.HealthIcon;
+import funkin.play.components.HealthIcon;
 import funkin.play.notes.NoteSprite;
 import funkin.play.PlayState;
 import funkin.play.song.Song;
diff --git a/source/funkin/ui/CoolStatsGraph.hx b/source/funkin/ui/debug/latency/CoolStatsGraph.hx
similarity index 99%
rename from source/funkin/ui/CoolStatsGraph.hx
rename to source/funkin/ui/debug/latency/CoolStatsGraph.hx
index d3c4c3895..01689f494 100644
--- a/source/funkin/ui/CoolStatsGraph.hx
+++ b/source/funkin/ui/debug/latency/CoolStatsGraph.hx
@@ -1,4 +1,4 @@
-package funkin.ui;
+package funkin.ui.debug.latency;
 
 import flash.display.Graphics;
 import flash.display.Shape;
diff --git a/source/funkin/LatencyState.hx b/source/funkin/ui/debug/latency/LatencyState.hx
similarity index 98%
rename from source/funkin/LatencyState.hx
rename to source/funkin/ui/debug/latency/LatencyState.hx
index 7385ca640..7cb18a3de 100644
--- a/source/funkin/LatencyState.hx
+++ b/source/funkin/ui/debug/latency/LatencyState.hx
@@ -1,4 +1,4 @@
-package funkin;
+package funkin.ui.debug.latency;
 
 import funkin.data.notestyle.NoteStyleRegistry;
 import flixel.FlxSprite;
@@ -6,13 +6,14 @@ import flixel.FlxSubState;
 import flixel.group.FlxGroup;
 import flixel.group.FlxGroup.FlxTypedGroup;
 import flixel.math.FlxMath;
+import funkin.ui.MusicBeatSubState;
 import flixel.sound.FlxSound;
 import flixel.system.debug.stats.StatsGraph;
 import flixel.text.FlxText;
 import flixel.util.FlxColor;
 import funkin.audio.visualize.PolygonSpectogram;
 import funkin.play.notes.NoteSprite;
-import funkin.ui.CoolStatsGraph;
+import funkin.ui.debug.latency.CoolStatsGraph;
 import haxe.Timer;
 import openfl.events.KeyboardEvent;
 
diff --git a/source/funkin/ui/stageBuildShit/CharStage.hx b/source/funkin/ui/debug/stage/CharStage.hx
similarity index 81%
rename from source/funkin/ui/stageBuildShit/CharStage.hx
rename to source/funkin/ui/debug/stage/CharStage.hx
index cd13a7251..37e2e1c26 100644
--- a/source/funkin/ui/stageBuildShit/CharStage.hx
+++ b/source/funkin/ui/debug/stage/CharStage.hx
@@ -1,4 +1,4 @@
-package funkin.ui.stageBuildShit;
+package funkin.ui.debug.stage;
 
 class CharStage extends SprStage
 {
diff --git a/source/funkin/ui/stageBuildShit/SprStage.hx b/source/funkin/ui/debug/stage/SprStage.hx
similarity index 97%
rename from source/funkin/ui/stageBuildShit/SprStage.hx
rename to source/funkin/ui/debug/stage/SprStage.hx
index 2229adfe9..e53f3bc26 100644
--- a/source/funkin/ui/stageBuildShit/SprStage.hx
+++ b/source/funkin/ui/debug/stage/SprStage.hx
@@ -1,4 +1,4 @@
-package funkin.ui.stageBuildShit;
+package funkin.ui.debug.stage;
 
 import flixel.FlxSprite;
 import flixel.input.mouse.FlxMouseEvent;
diff --git a/source/funkin/ui/stageBuildShit/StageBuilderState.hx b/source/funkin/ui/debug/stage/StageBuilderState.hx
similarity index 98%
rename from source/funkin/ui/stageBuildShit/StageBuilderState.hx
rename to source/funkin/ui/debug/stage/StageBuilderState.hx
index 31a73ff8f..074914f58 100644
--- a/source/funkin/ui/stageBuildShit/StageBuilderState.hx
+++ b/source/funkin/ui/debug/stage/StageBuilderState.hx
@@ -1,4 +1,4 @@
-package funkin.ui.stageBuildShit;
+package funkin.ui.debug.stage;
 
 import flixel.FlxCamera;
 import flixel.FlxSprite;
@@ -7,10 +7,12 @@ import flixel.group.FlxGroup;
 import flixel.input.mouse.FlxMouseButton.FlxMouseButtonID;
 import flixel.input.mouse.FlxMouseEvent;
 import flixel.math.FlxPoint;
+import funkin.ui.MusicBeatState;
 import flixel.text.FlxText;
 import flixel.ui.FlxButton;
 import flixel.util.FlxColor;
 import flixel.util.FlxSort;
+import funkin.util.MouseUtil;
 import flixel.util.FlxTimer;
 
 class StageBuilderState extends MusicBeatState
@@ -185,9 +187,9 @@ class StageBuilderState extends MusicBeatState
       if (curSelectedSpr != null) sprGrp.remove(curSelectedSpr, true);
     }
 
-    CoolUtil.mouseCamDrag();
+    MouseUtil.mouseCamDrag();
 
-    if (FlxG.keys.pressed.CONTROL) CoolUtil.mouseWheelZoom();
+    if (FlxG.keys.pressed.CONTROL) MouseUtil.mouseWheelZoom();
 
     if (isShaking)
     {
diff --git a/source/funkin/ui/stageBuildShit/StageEditorCommand.hx b/source/funkin/ui/debug/stage/StageEditorCommand.hx
similarity index 95%
rename from source/funkin/ui/stageBuildShit/StageEditorCommand.hx
rename to source/funkin/ui/debug/stage/StageEditorCommand.hx
index d61281e07..ff59f67e5 100644
--- a/source/funkin/ui/stageBuildShit/StageEditorCommand.hx
+++ b/source/funkin/ui/debug/stage/StageEditorCommand.hx
@@ -1,6 +1,6 @@
-package funkin.ui.stageBuildShit;
+package funkin.ui.debug.stage;
 
-import funkin.ui.stageBuildShit.StageOffsetSubState;
+import funkin.ui.debug.stage.StageOffsetSubState;
 import flixel.FlxSprite;
 
 /**
diff --git a/source/funkin/ui/stageBuildShit/StageOffsetSubState.hx b/source/funkin/ui/debug/stage/StageOffsetSubState.hx
similarity index 97%
rename from source/funkin/ui/stageBuildShit/StageOffsetSubState.hx
rename to source/funkin/ui/debug/stage/StageOffsetSubState.hx
index a6aa6fa68..68546f1c7 100644
--- a/source/funkin/ui/stageBuildShit/StageOffsetSubState.hx
+++ b/source/funkin/ui/debug/stage/StageOffsetSubState.hx
@@ -1,4 +1,4 @@
-package funkin.ui.stageBuildShit;
+package funkin.ui.debug.stage;
 
 import flixel.FlxSprite;
 import flixel.input.mouse.FlxMouseEvent;
@@ -7,10 +7,11 @@ import funkin.play.character.BaseCharacter;
 import funkin.play.PlayState;
 import funkin.play.stage.StageData;
 import funkin.play.stage.StageProp;
-import funkin.shaderslmfao.StrokeShader;
+import funkin.graphics.shaders.StrokeShader;
 import funkin.ui.haxeui.HaxeUISubState;
-import funkin.ui.stageBuildShit.StageEditorCommand;
+import funkin.ui.debug.stage.StageEditorCommand;
 import funkin.util.SerializerUtil;
+import funkin.util.MouseUtil;
 import haxe.ui.containers.ListView;
 import haxe.ui.core.Component;
 import haxe.ui.events.UIEvent;
@@ -28,7 +29,7 @@ import openfl.net.FileReference;
  * @author ninjamuffin99
  */
 // Give other classes access to private instance fields
-@:allow(funkin.ui.stageBuildShit.StageEditorCommand)
+@:allow(funkin.ui.debug.stage.StageEditorCommand)
 class StageOffsetSubState extends HaxeUISubState
 {
   var uiStuff:Component;
@@ -244,9 +245,9 @@ class StageOffsetSubState extends HaxeUISubState
 
     FlxG.mouse.visible = true;
 
-    CoolUtil.mouseCamDrag();
+    MouseUtil.mouseCamDrag();
 
-    if (FlxG.keys.pressed.CONTROL) CoolUtil.mouseWheelZoom();
+    if (FlxG.keys.pressed.CONTROL) MouseUtil.mouseWheelZoom();
 
     if (FlxG.keys.pressed.CONTROL && FlxG.keys.justPressed.Z) undoLastCommand();
 
diff --git a/source/funkin/ui/stageBuildShit/StagetoolBar.hx b/source/funkin/ui/debug/stage/StagetoolBar.hx
similarity index 67%
rename from source/funkin/ui/stageBuildShit/StagetoolBar.hx
rename to source/funkin/ui/debug/stage/StagetoolBar.hx
index 1a443f42a..243fd3f69 100644
--- a/source/funkin/ui/stageBuildShit/StagetoolBar.hx
+++ b/source/funkin/ui/debug/stage/StagetoolBar.hx
@@ -1,4 +1,4 @@
-package funkin.ui.stageBuildShit;
+package funkin.ui.debug.stage;
 
 import flixel.group.FlxGroup;
 
@@ -10,13 +10,5 @@ class StagetoolBar extends FlxGroup
   public function new()
   {
     super();
-
-    for (icon in icons)
-    {
-      // switch (icon)
-      // {
-      // case SELECT:
-      // }
-    }
   }
 }
diff --git a/source/funkin/freeplayStuff/BGScrollingText.hx b/source/funkin/ui/freeplay/BGScrollingText.hx
similarity index 98%
rename from source/funkin/freeplayStuff/BGScrollingText.hx
rename to source/funkin/ui/freeplay/BGScrollingText.hx
index 586f83822..5511959f5 100644
--- a/source/funkin/freeplayStuff/BGScrollingText.hx
+++ b/source/funkin/ui/freeplay/BGScrollingText.hx
@@ -1,4 +1,4 @@
-package funkin.freeplayStuff;
+package funkin.ui.freeplay;
 
 import flixel.FlxObject;
 import flixel.group.FlxGroup.FlxTypedGroup;
diff --git a/source/funkin/freeplayStuff/CapsuleText.hx b/source/funkin/ui/freeplay/CapsuleText.hx
similarity index 93%
rename from source/funkin/freeplayStuff/CapsuleText.hx
rename to source/funkin/ui/freeplay/CapsuleText.hx
index dda687f5e..1a5a0a335 100644
--- a/source/funkin/freeplayStuff/CapsuleText.hx
+++ b/source/funkin/ui/freeplay/CapsuleText.hx
@@ -1,9 +1,9 @@
-package funkin.freeplayStuff;
+package funkin.ui.freeplay;
 
 import openfl.filters.BitmapFilterQuality;
 import flixel.text.FlxText;
 import flixel.group.FlxSpriteGroup;
-import funkin.shaderslmfao.GaussianBlurShader;
+import funkin.graphics.shaders.GaussianBlurShader;
 
 class CapsuleText extends FlxSpriteGroup
 {
diff --git a/source/funkin/freeplayStuff/DJBoyfriend.hx b/source/funkin/ui/freeplay/DJBoyfriend.hx
similarity index 99%
rename from source/funkin/freeplayStuff/DJBoyfriend.hx
rename to source/funkin/ui/freeplay/DJBoyfriend.hx
index ba0ce464d..2417cdf9a 100644
--- a/source/funkin/freeplayStuff/DJBoyfriend.hx
+++ b/source/funkin/ui/freeplay/DJBoyfriend.hx
@@ -1,4 +1,4 @@
-package funkin.freeplayStuff;
+package funkin.ui.freeplay;
 
 import flixel.FlxSprite;
 import flixel.util.FlxSignal;
diff --git a/source/funkin/freeplayStuff/DifficultyStars.hx b/source/funkin/ui/freeplay/DifficultyStars.hx
similarity index 96%
rename from source/funkin/freeplayStuff/DifficultyStars.hx
rename to source/funkin/ui/freeplay/DifficultyStars.hx
index 8611727be..51526bcbe 100644
--- a/source/funkin/freeplayStuff/DifficultyStars.hx
+++ b/source/funkin/ui/freeplay/DifficultyStars.hx
@@ -1,8 +1,8 @@
-package funkin.freeplayStuff;
+package funkin.ui.freeplay;
 
 import flixel.group.FlxSpriteGroup;
 import funkin.graphics.adobeanimate.FlxAtlasSprite;
-import funkin.shaderslmfao.HSVShader;
+import funkin.graphics.shaders.HSVShader;
 
 class DifficultyStars extends FlxSpriteGroup
 {
diff --git a/source/funkin/freeplayStuff/FreeplayFlames.hx b/source/funkin/ui/freeplay/FreeplayFlames.hx
similarity index 98%
rename from source/funkin/freeplayStuff/FreeplayFlames.hx
rename to source/funkin/ui/freeplay/FreeplayFlames.hx
index 8f54d210b..a116fb813 100644
--- a/source/funkin/freeplayStuff/FreeplayFlames.hx
+++ b/source/funkin/ui/freeplay/FreeplayFlames.hx
@@ -1,4 +1,4 @@
-package funkin.freeplayStuff;
+package funkin.ui.freeplay;
 
 import flixel.group.FlxSpriteGroup;
 import flixel.FlxSprite;
diff --git a/source/funkin/freeplayStuff/FreeplayScore.hx b/source/funkin/ui/freeplay/FreeplayScore.hx
similarity index 98%
rename from source/funkin/freeplayStuff/FreeplayScore.hx
rename to source/funkin/ui/freeplay/FreeplayScore.hx
index ec8f4baa7..e266efca1 100644
--- a/source/funkin/freeplayStuff/FreeplayScore.hx
+++ b/source/funkin/ui/freeplay/FreeplayScore.hx
@@ -1,4 +1,4 @@
-package funkin.freeplayStuff;
+package funkin.ui.freeplay;
 
 import flixel.FlxSprite;
 import flixel.group.FlxSpriteGroup.FlxTypedSpriteGroup;
diff --git a/source/funkin/FreeplayState.hx b/source/funkin/ui/freeplay/FreeplayState.hx
similarity index 97%
rename from source/funkin/FreeplayState.hx
rename to source/funkin/ui/freeplay/FreeplayState.hx
index 918e7c725..5c8c35753 100644
--- a/source/funkin/FreeplayState.hx
+++ b/source/funkin/ui/freeplay/FreeplayState.hx
@@ -1,6 +1,5 @@
-package funkin;
+package funkin.ui.freeplay;
 
-import funkin.play.song.Song;
 import flash.text.TextField;
 import flixel.addons.display.FlxGridOverlay;
 import flixel.addons.transition.FlxTransitionableState;
@@ -26,24 +25,31 @@ import flixel.util.FlxTimer;
 import funkin.Controls.Control;
 import funkin.data.level.LevelRegistry;
 import funkin.data.song.SongRegistry;
-import funkin.freeplayStuff.BGScrollingText;
-import funkin.freeplayStuff.DifficultyStars;
-import funkin.freeplayStuff.DJBoyfriend;
-import funkin.freeplayStuff.FreeplayScore;
-import funkin.freeplayStuff.LetterSort;
-import funkin.freeplayStuff.SongMenuItem;
 import funkin.graphics.adobeanimate.FlxAtlasSprite;
-import funkin.play.HealthIcon;
+import funkin.graphics.shaders.AngleMask;
+import funkin.graphics.shaders.HSVShader;
+import funkin.graphics.shaders.PureColor;
+import funkin.util.MathUtil;
+import funkin.graphics.shaders.StrokeShader;
+import funkin.play.components.HealthIcon;
 import funkin.play.PlayState;
 import funkin.play.PlayStatePlaylist;
 import funkin.play.song.Song;
+import funkin.play.song.Song;
 import funkin.save.Save;
 import funkin.save.Save.SaveScoreData;
-import funkin.shaderslmfao.AngleMask;
-import funkin.shaderslmfao.HSVShader;
-import funkin.shaderslmfao.PureColor;
-import funkin.shaderslmfao.StrokeShader;
-import funkin.ui.StickerSubState;
+import 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.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 lime.app.Future;
 import lime.utils.Assets;
 
@@ -664,8 +670,8 @@ class FreeplayState extends MusicBeatSubState
       }
     }
 
-    lerpScore = CoolUtil.coolLerp(lerpScore, intendedScore, 0.2);
-    lerpCompletion = CoolUtil.coolLerp(lerpCompletion, intendedCompletion, 0.9);
+    lerpScore = MathUtil.coolLerp(lerpScore, intendedScore, 0.2);
+    lerpCompletion = MathUtil.coolLerp(lerpCompletion, intendedCompletion, 0.9);
 
     fp.updateScore(Std.int(lerpScore));
 
diff --git a/source/funkin/freeplayStuff/LetterSort.hx b/source/funkin/ui/freeplay/LetterSort.hx
similarity index 99%
rename from source/funkin/freeplayStuff/LetterSort.hx
rename to source/funkin/ui/freeplay/LetterSort.hx
index e6d923c90..a66b386ee 100644
--- a/source/funkin/freeplayStuff/LetterSort.hx
+++ b/source/funkin/ui/freeplay/LetterSort.hx
@@ -1,4 +1,4 @@
-package funkin.freeplayStuff;
+package funkin.ui.freeplay;
 
 import flixel.FlxSprite;
 import flixel.group.FlxGroup.FlxTypedGroup;
diff --git a/source/funkin/freeplayStuff/SongMenuItem.hx b/source/funkin/ui/freeplay/SongMenuItem.hx
similarity index 96%
rename from source/funkin/freeplayStuff/SongMenuItem.hx
rename to source/funkin/ui/freeplay/SongMenuItem.hx
index 06de92886..477047c68 100644
--- a/source/funkin/freeplayStuff/SongMenuItem.hx
+++ b/source/funkin/ui/freeplay/SongMenuItem.hx
@@ -1,8 +1,8 @@
-package funkin.freeplayStuff;
+package funkin.ui.freeplay;
 
-import funkin.FreeplayState.FreeplaySongData;
-import funkin.shaderslmfao.HSVShader;
-import funkin.shaderslmfao.GaussianBlurShader;
+import funkin.ui.freeplay.FreeplayState.FreeplaySongData;
+import funkin.graphics.shaders.HSVShader;
+import funkin.graphics.shaders.GaussianBlurShader;
 import flixel.group.FlxGroup;
 import flixel.FlxSprite;
 import flixel.graphics.frames.FlxAtlasFrames;
@@ -12,7 +12,8 @@ import flixel.math.FlxMath;
 import flixel.math.FlxPoint;
 import flixel.text.FlxText;
 import flixel.util.FlxTimer;
-import funkin.shaderslmfao.Grayscale;
+import funkin.util.MathUtil;
+import funkin.graphics.shaders.Grayscale;
 
 class SongMenuItem extends FlxSpriteGroup
 {
@@ -312,8 +313,8 @@ class SongMenuItem extends FlxSpriteGroup
 
     if (doLerp)
     {
-      x = CoolUtil.coolLerp(x, targetPos.x, 0.3);
-      y = CoolUtil.coolLerp(y, targetPos.y, 0.4);
+      x = MathUtil.coolLerp(x, targetPos.x, 0.3);
+      y = MathUtil.coolLerp(y, targetPos.y, 0.4);
     }
 
     super.update(elapsed);
diff --git a/source/funkin/ui/haxeui/HaxeUIState.hx b/source/funkin/ui/haxeui/HaxeUIState.hx
index d9d00dd23..fa95b2d1d 100644
--- a/source/funkin/ui/haxeui/HaxeUIState.hx
+++ b/source/funkin/ui/haxeui/HaxeUIState.hx
@@ -5,6 +5,7 @@ import haxe.ui.containers.menus.MenuCheckBox;
 import haxe.ui.containers.menus.MenuItem;
 import haxe.ui.core.Component;
 import haxe.ui.core.Screen;
+import funkin.ui.MusicBeatState;
 import haxe.ui.events.MouseEvent;
 import haxe.ui.events.UIEvent;
 import haxe.ui.RuntimeComponentBuilder;
diff --git a/source/funkin/ui/haxeui/HaxeUISubState.hx b/source/funkin/ui/haxeui/HaxeUISubState.hx
index ba218f37c..82c15be4c 100644
--- a/source/funkin/ui/haxeui/HaxeUISubState.hx
+++ b/source/funkin/ui/haxeui/HaxeUISubState.hx
@@ -4,6 +4,9 @@ import haxe.ui.RuntimeComponentBuilder;
 import haxe.ui.components.CheckBox;
 import haxe.ui.containers.menus.MenuCheckBox;
 import haxe.ui.core.Component;
+import funkin.ui.MusicBeatState;
+import funkin.ui.mainmenu.MainMenuState;
+import funkin.ui.MusicBeatSubState;
 import haxe.ui.events.MouseEvent;
 import haxe.ui.events.UIEvent;
 
diff --git a/source/funkin/ui/haxeui/components/README.md b/source/funkin/ui/haxeui/components/README.md
index f051a6236..90f8ab553 100644
--- a/source/funkin/ui/haxeui/components/README.md
+++ b/source/funkin/ui/haxeui/components/README.md
@@ -1,3 +1,3 @@
 # funkin.ui.haxeui.components
 
-Since there is a line in `source/module.xml` pointing to this folder, all components in this folder will automatically be accessible in any HaxeUI layouts.
\ No newline at end of file
+Since there is a line in `source/module.xml` pointing to this folder, all components in this folder will automatically be accessible in any HaxeUI layouts.
diff --git a/source/funkin/MainMenuState.hx b/source/funkin/ui/mainmenu/MainMenuState.hx
similarity index 97%
rename from source/funkin/MainMenuState.hx
rename to source/funkin/ui/mainmenu/MainMenuState.hx
index 7267a6da8..769493c45 100644
--- a/source/funkin/MainMenuState.hx
+++ b/source/funkin/ui/mainmenu/MainMenuState.hx
@@ -1,4 +1,4 @@
-package funkin;
+package funkin.ui.mainmenu;
 
 import flixel.addons.transition.FlxTransitionableSubState;
 import funkin.ui.debug.DebugMenuSubState;
@@ -13,8 +13,10 @@ import flixel.input.touch.FlxTouch;
 import flixel.text.FlxText;
 import flixel.tweens.FlxEase;
 import flixel.tweens.FlxTween;
+import funkin.ui.MusicBeatState;
 import flixel.util.FlxTimer;
 import funkin.ui.AtlasMenuList;
+import funkin.ui.freeplay.FreeplayState;
 import funkin.ui.MenuList;
 import funkin.ui.title.TitleState;
 import funkin.ui.story.StoryMenuState;
@@ -108,7 +110,7 @@ class MainMenuState extends MusicBeatState
     #end
 
     createMenuItem('options', 'mainmenu/options', function() {
-      startExitState(new funkin.ui.OptionsState());
+      startExitState(new funkin.ui.options.OptionsState());
     });
 
     // Reset position of menu items.
@@ -187,7 +189,7 @@ class MainMenuState extends MusicBeatState
     // #end
   }
 
-  function onMenuItemChange(selected:MenuItem)
+  function onMenuItemChange(selected:MenuListItem)
   {
     camFollow.setPosition(selected.getGraphicMidpoint().x, selected.getGraphicMidpoint().y);
   }
diff --git a/source/funkin/ButtonRemapSubstate.hx b/source/funkin/ui/options/ButtonRemapSubstate.hx
similarity index 82%
rename from source/funkin/ButtonRemapSubstate.hx
rename to source/funkin/ui/options/ButtonRemapSubstate.hx
index 8905ec8ba..b692dbe82 100644
--- a/source/funkin/ButtonRemapSubstate.hx
+++ b/source/funkin/ui/options/ButtonRemapSubstate.hx
@@ -1,4 +1,4 @@
-package funkin;
+package funkin.ui.options;
 
 import flixel.FlxSubState;
 
diff --git a/source/funkin/ui/ColorsMenu.hx b/source/funkin/ui/options/ColorsMenu.hx
similarity index 96%
rename from source/funkin/ui/ColorsMenu.hx
rename to source/funkin/ui/options/ColorsMenu.hx
index 6a844eef3..928f74ba8 100644
--- a/source/funkin/ui/ColorsMenu.hx
+++ b/source/funkin/ui/options/ColorsMenu.hx
@@ -1,11 +1,11 @@
-package funkin.ui;
+package funkin.ui.options;
 
 import funkin.data.notestyle.NoteStyleRegistry;
 import flixel.addons.effects.chainable.FlxEffectSprite;
 import flixel.addons.effects.chainable.FlxOutlineEffect;
 import flixel.group.FlxGroup.FlxTypedGroup;
 import flixel.util.FlxColor;
-import funkin.ui.OptionsState.Page;
+import funkin.ui.options.OptionsState.Page;
 import funkin.play.notes.NoteSprite;
 
 class ColorsMenu extends Page
diff --git a/source/funkin/ui/ControlsMenu.hx b/source/funkin/ui/options/ControlsMenu.hx
similarity index 98%
rename from source/funkin/ui/ControlsMenu.hx
rename to source/funkin/ui/options/ControlsMenu.hx
index 8197424ee..2aee473d3 100644
--- a/source/funkin/ui/ControlsMenu.hx
+++ b/source/funkin/ui/options/ControlsMenu.hx
@@ -1,5 +1,6 @@
-package funkin.ui;
+package funkin.ui.options;
 
+import funkin.util.InputUtil;
 import flixel.FlxCamera;
 import flixel.FlxObject;
 import flixel.FlxSprite;
@@ -12,7 +13,7 @@ import funkin.ui.AtlasText;
 import funkin.ui.MenuList;
 import funkin.ui.TextMenuList;
 
-class ControlsMenu extends funkin.ui.OptionsState.Page
+class ControlsMenu extends funkin.ui.options.OptionsState.Page
 {
   public static inline final COLUMNS = 2;
   static var controlList = Control.createAll();
@@ -456,6 +457,6 @@ class InputItem extends TextMenuItem
 
   public function getLabel(input:Int)
   {
-    return input == FlxKey.NONE ? "---" : InputFormatter.format(input, device);
+    return input == FlxKey.NONE ? "---" : InputUtil.format(input, device);
   }
 }
diff --git a/source/funkin/ui/ModMenu.hx b/source/funkin/ui/options/ModMenu.hx
similarity index 97%
rename from source/funkin/ui/ModMenu.hx
rename to source/funkin/ui/options/ModMenu.hx
index 769d3eca2..574a93c49 100644
--- a/source/funkin/ui/ModMenu.hx
+++ b/source/funkin/ui/options/ModMenu.hx
@@ -1,11 +1,11 @@
-package funkin.ui;
+package funkin.ui.options;
 
 import funkin.modding.PolymodHandler;
 import flixel.group.FlxGroup.FlxTypedGroup;
 import flixel.text.FlxText;
 import flixel.util.FlxColor;
 import polymod.Polymod;
-import funkin.ui.OptionsState.Page;
+import funkin.ui.options.OptionsState.Page;
 
 class ModMenu extends Page
 {
diff --git a/source/funkin/ui/OptionsState.hx b/source/funkin/ui/options/OptionsState.hx
similarity index 98%
rename from source/funkin/ui/OptionsState.hx
rename to source/funkin/ui/options/OptionsState.hx
index 6c32c7f4c..72bb6c60c 100644
--- a/source/funkin/ui/OptionsState.hx
+++ b/source/funkin/ui/options/OptionsState.hx
@@ -1,10 +1,12 @@
-package funkin.ui;
+package funkin.ui.options;
 
 import flixel.FlxSprite;
 import flixel.FlxSubState;
 import flixel.addons.transition.FlxTransitionableState;
 import flixel.group.FlxGroup;
 import flixel.util.FlxSignal;
+import funkin.ui.mainmenu.MainMenuState;
+import funkin.ui.MusicBeatState;
 import funkin.util.WindowUtil;
 
 class OptionsState extends MusicBeatState
diff --git a/source/funkin/ui/PreferencesMenu.hx b/source/funkin/ui/options/PreferencesMenu.hx
similarity index 98%
rename from source/funkin/ui/PreferencesMenu.hx
rename to source/funkin/ui/options/PreferencesMenu.hx
index 812d0ab49..b4b3c7db8 100644
--- a/source/funkin/ui/PreferencesMenu.hx
+++ b/source/funkin/ui/options/PreferencesMenu.hx
@@ -1,11 +1,11 @@
-package funkin.ui;
+package funkin.ui.options;
 
 import flixel.FlxCamera;
 import flixel.FlxObject;
 import flixel.FlxSprite;
 import flixel.group.FlxSpriteGroup.FlxTypedSpriteGroup;
 import funkin.ui.AtlasText.AtlasFont;
-import funkin.ui.OptionsState.Page;
+import funkin.ui.options.OptionsState.Page;
 import funkin.ui.TextMenuList.TextMenuItem;
 
 class PreferencesMenu extends Page
diff --git a/source/funkin/ui/story/LevelTitle.hx b/source/funkin/ui/story/LevelTitle.hx
index e1765d453..e6f989016 100644
--- a/source/funkin/ui/story/LevelTitle.hx
+++ b/source/funkin/ui/story/LevelTitle.hx
@@ -4,7 +4,7 @@ import flixel.FlxSprite;
 import flixel.graphics.frames.FlxAtlasFrames;
 import flixel.group.FlxSpriteGroup;
 import flixel.util.FlxColor;
-import funkin.CoolUtil;
+import funkin.util.MathUtil;
 
 class LevelTitle extends FlxSpriteGroup
 {
@@ -54,7 +54,7 @@ class LevelTitle extends FlxSpriteGroup
 
   public override function update(elapsed:Float):Void
   {
-    this.y = CoolUtil.coolLerp(y, targetY, 0.17);
+    this.y = MathUtil.coolLerp(y, targetY, 0.17);
 
     if (isFlashing) flashingInt += 1;
     if (flashingInt % fakeFramerate >= Math.floor(fakeFramerate / 2)) title.color = 0xFF33ffff;
diff --git a/source/funkin/ui/story/StoryMenuState.hx b/source/funkin/ui/story/StoryMenuState.hx
index 09b9cb600..e5bb6c34c 100644
--- a/source/funkin/ui/story/StoryMenuState.hx
+++ b/source/funkin/ui/story/StoryMenuState.hx
@@ -1,5 +1,6 @@
 package funkin.ui.story;
 
+import funkin.ui.mainmenu.MainMenuState;
 import funkin.save.Save;
 import funkin.save.Save.SaveScoreData;
 import openfl.utils.Assets;
@@ -9,6 +10,7 @@ import flixel.group.FlxGroup.FlxTypedGroup;
 import flixel.text.FlxText;
 import flixel.addons.transition.FlxTransitionableState;
 import flixel.tweens.FlxEase;
+import funkin.ui.MusicBeatState;
 import flixel.tweens.FlxTween;
 import flixel.util.FlxColor;
 import flixel.util.FlxTimer;
@@ -17,9 +19,13 @@ import funkin.modding.events.ScriptEvent;
 import funkin.modding.events.ScriptEventDispatcher;
 import funkin.play.PlayState;
 import funkin.play.PlayStatePlaylist;
+import funkin.ui.mainmenu.MainMenuState;
 import funkin.play.song.Song;
 import funkin.data.song.SongData.SongMusicData;
 import funkin.data.song.SongRegistry;
+import funkin.util.MathUtil;
+import funkin.ui.transition.LoadingState;
+import funkin.ui.transition.StickerSubState;
 
 class StoryMenuState extends MusicBeatState
 {
@@ -313,7 +319,7 @@ class StoryMenuState extends MusicBeatState
   {
     Conductor.update();
 
-    highScoreLerp = Std.int(CoolUtil.coolLerp(highScoreLerp, highScore, 0.5));
+    highScoreLerp = Std.int(MathUtil.coolLerp(highScoreLerp, highScore, 0.5));
 
     scoreText.text = 'LEVEL SCORE: ${Math.round(highScoreLerp)}';
 
diff --git a/source/funkin/ui/title/AttractState.hx b/source/funkin/ui/title/AttractState.hx
index 67c762da4..38cff7cc8 100644
--- a/source/funkin/ui/title/AttractState.hx
+++ b/source/funkin/ui/title/AttractState.hx
@@ -5,6 +5,7 @@ import funkin.graphics.video.FlxVideo;
 #else
 import hxcodec.flixel.FlxVideoSprite;
 #end
+import funkin.ui.MusicBeatState;
 
 /**
  * After about 2 minutes of inactivity on the title screen,
diff --git a/source/funkin/ui/title/FlxSpriteOverlay.hx b/source/funkin/ui/title/FlxSpriteOverlay.hx
index ddf58bbfd..1900e25f0 100644
--- a/source/funkin/ui/title/FlxSpriteOverlay.hx
+++ b/source/funkin/ui/title/FlxSpriteOverlay.hx
@@ -1,7 +1,7 @@
 package funkin.ui.title;
 
 import flixel.FlxSprite;
-import funkin.shaderslmfao.BlendModesShader;
+import funkin.graphics.shaders.BlendModesShader;
 import openfl.display.BitmapData;
 import flixel.FlxCamera;
 import flixel.FlxG;
diff --git a/source/funkin/OutdatedSubState.hx b/source/funkin/ui/title/OutdatedSubState.hx
similarity index 95%
rename from source/funkin/OutdatedSubState.hx
rename to source/funkin/ui/title/OutdatedSubState.hx
index 7684e5ae4..d262fc4e4 100644
--- a/source/funkin/OutdatedSubState.hx
+++ b/source/funkin/ui/title/OutdatedSubState.hx
@@ -1,9 +1,10 @@
-package funkin;
+package funkin.ui.title;
 
 import flixel.FlxSprite;
 import flixel.FlxSubState;
 import flixel.text.FlxText;
 import flixel.util.FlxColor;
+import funkin.ui.MusicBeatState;
 import lime.app.Application;
 
 #if newgrounds
diff --git a/source/funkin/ui/title/TitleState.hx b/source/funkin/ui/title/TitleState.hx
index 9820e4ecc..da3ba6c89 100644
--- a/source/funkin/ui/title/TitleState.hx
+++ b/source/funkin/ui/title/TitleState.hx
@@ -9,20 +9,25 @@ import flixel.tweens.FlxTween;
 import flixel.util.FlxColor;
 import flixel.util.FlxDirectionFlags;
 import flixel.util.FlxTimer;
-import funkin.audiovis.SpectogramSprite;
-import funkin.shaderslmfao.ColorSwap;
-import funkin.shaderslmfao.LeftMaskShader;
+import funkin.audio.visualize.SpectogramSprite;
+import funkin.graphics.shaders.ColorSwap;
+import funkin.graphics.shaders.LeftMaskShader;
 import funkin.data.song.SongRegistry;
+import funkin.ui.MusicBeatState;
 import funkin.data.song.SongData.SongMusicData;
-import funkin.shaderslmfao.TitleOutline;
+import funkin.graphics.shaders.TitleOutline;
+import funkin.ui.freeplay.FreeplayState;
 import funkin.ui.AtlasText;
 import openfl.Assets;
 import openfl.display.Sprite;
 import openfl.events.AsyncErrorEvent;
+import funkin.ui.mainmenu.MainMenuState;
 import openfl.events.MouseEvent;
 import openfl.events.NetStatusEvent;
+import funkin.ui.freeplay.FreeplayState;
 import openfl.media.Video;
 import openfl.net.NetStream;
+import funkin.api.newgrounds.NGio;
 import openfl.display.BlendMode;
 
 #if desktop
@@ -167,10 +172,6 @@ class TitleState extends MusicBeatState
       credGroup.add(textGroup);
     }
 
-    // var atlasBullShit:FlxSprite = new FlxSprite();
-    // atlasBullShit.frames = CoolUtil.fromAnimate(Paths.image('money'), Paths.file('images/money.json'));
-    // credGroup.add(atlasBullShit);
-
     ngSpr = new FlxSprite(0, FlxG.height * 0.52);
 
     if (FlxG.random.bool(1))
diff --git a/source/funkin/LoadingState.hx b/source/funkin/ui/transition/LoadingState.hx
similarity index 89%
rename from source/funkin/LoadingState.hx
rename to source/funkin/ui/transition/LoadingState.hx
index 216d9ba74..69590bfe3 100644
--- a/source/funkin/LoadingState.hx
+++ b/source/funkin/ui/transition/LoadingState.hx
@@ -1,18 +1,24 @@
-package funkin;
+package funkin.ui.transition;
 
 import funkin.play.PlayStatePlaylist;
 import flixel.FlxSprite;
 import flixel.FlxState;
+import funkin.graphics.shaders.ScreenWipeShader;
 import flixel.math.FlxMath;
 import flixel.util.FlxTimer;
 import funkin.play.PlayState;
 import haxe.io.Path;
 import lime.app.Future;
+import flixel.tweens.FlxTween;
+import funkin.ui.MusicBeatState;
 import lime.app.Promise;
 import lime.utils.AssetLibrary;
+import flixel.tweens.FlxEase;
 import lime.utils.AssetManifest;
 import lime.utils.Assets as LimeAssets;
 import openfl.utils.Assets;
+import funkin.ui.mainmenu.MainMenuState;
+import openfl.filters.ShaderFilter;
 
 class LoadingState extends MusicBeatState
 {
@@ -321,4 +327,21 @@ class MultiCallback
 
   public function getUnfired():Array<Void->Void>
     return unfired.array();
+
+  public static function coolSwitchState(state:FlxState, transitionTex:String = "shaderTransitionStuff/coolDots", time:Float = 2)
+  {
+    var screenShit:FlxSprite = new FlxSprite().loadGraphic(Paths.image("shaderTransitionStuff/coolDots"));
+    var screenWipeShit:ScreenWipeShader = new ScreenWipeShader();
+
+    screenWipeShit.funnyShit.input = screenShit.pixels;
+    FlxTween.tween(screenWipeShit, {daAlphaShit: 1}, time,
+      {
+        ease: FlxEase.quadInOut,
+        onComplete: function(twn) {
+          screenShit.destroy();
+          FlxG.switchState(new MainMenuState());
+        }
+      });
+    FlxG.camera.setFilters([new ShaderFilter(screenWipeShit)]);
+  }
 }
diff --git a/source/funkin/ui/StickerSubState.hx b/source/funkin/ui/transition/StickerSubState.hx
similarity index 98%
rename from source/funkin/ui/StickerSubState.hx
rename to source/funkin/ui/transition/StickerSubState.hx
index a4e3a6acb..1c012e00c 100644
--- a/source/funkin/ui/StickerSubState.hx
+++ b/source/funkin/ui/transition/StickerSubState.hx
@@ -1,9 +1,10 @@
-package funkin.ui;
+package funkin.ui.transition;
 
 import flixel.FlxSprite;
 import haxe.Json;
 import lime.utils.Assets;
 // import flxtyped group
+import funkin.ui.MusicBeatSubState;
 import funkin.ui.story.StoryMenuState;
 import flixel.group.FlxGroup.FlxTypedGroup;
 import flixel.util.FlxTimer;
@@ -11,8 +12,10 @@ import flixel.FlxG;
 import flixel.math.FlxMath;
 import flixel.util.FlxSort;
 import flixel.util.FlxSignal;
+import funkin.ui.mainmenu.MainMenuState;
 import flixel.addons.transition.FlxTransitionableState;
 import openfl.display.BitmapData;
+import funkin.ui.freeplay.FreeplayState;
 import openfl.geom.Matrix;
 import openfl.display.Sprite;
 import openfl.display.Bitmap;
diff --git a/source/funkin/util/BezierUtil.hx b/source/funkin/util/BezierUtil.hx
index b3cf51d1d..37d91223b 100644
--- a/source/funkin/util/BezierUtil.hx
+++ b/source/funkin/util/BezierUtil.hx
@@ -2,6 +2,9 @@ package funkin.util;
 
 import flixel.math.FlxPoint;
 
+/**
+ * Utilities for performing math with bezier curves.
+ */
 class BezierUtil
 {
   /**
diff --git a/source/funkin/util/DateUtil.hx b/source/funkin/util/DateUtil.hx
index 72838477c..2d08fd48b 100644
--- a/source/funkin/util/DateUtil.hx
+++ b/source/funkin/util/DateUtil.hx
@@ -1,5 +1,8 @@
 package funkin.util;
 
+/**
+ * Utilities for performing operations on dates.
+ */
 class DateUtil
 {
   public static function generateTimestamp(?date:Date = null):String
diff --git a/source/funkin/util/FlxGamepadUtil.hx b/source/funkin/util/FlxGamepadUtil.hx
index d768b42c4..7e72e7138 100644
--- a/source/funkin/util/FlxGamepadUtil.hx
+++ b/source/funkin/util/FlxGamepadUtil.hx
@@ -6,6 +6,9 @@ import lime.ui.Gamepad as LimeGamepad;
 import lime.ui.GamepadAxis as LimeGamepadAxis;
 import lime.ui.GamepadButton as LimeGamepadButton;
 
+/**
+ * Utilities for working with Flixel gamepads.
+ */
 class FlxGamepadUtil
 {
   public static function getInputID(gamepad:FlxGamepad, button:LimeGamepadButton):FlxGamepadInputID
diff --git a/source/funkin/InputFormatter.hx b/source/funkin/util/InputUtil.hx
similarity index 96%
rename from source/funkin/InputFormatter.hx
rename to source/funkin/util/InputUtil.hx
index 2a7011f64..97478294a 100644
--- a/source/funkin/InputFormatter.hx
+++ b/source/funkin/util/InputUtil.hx
@@ -1,4 +1,4 @@
-package funkin;
+package funkin.util;
 
 import funkin.Controls;
 import flixel.input.gamepad.FlxGamepad;
@@ -7,9 +7,12 @@ import flixel.input.keyboard.FlxKey;
 
 using flixel.util.FlxStringUtil;
 
-class InputFormatter
+/**
+ * Utilities for working with inputs.
+ */
+class InputUtil
 {
-  static public function format(id:Int, device:Device):String
+  public static function format(id:Int, device:Device):String
   {
     return switch (device)
     {
@@ -18,7 +21,7 @@ class InputFormatter
     }
   }
 
-  static public function getKeyName(id:Int):String
+  public static function getKeyName(id:Int):String
   {
     return switch (id)
     {
diff --git a/source/funkin/util/MathUtil.hx b/source/funkin/util/MathUtil.hx
new file mode 100644
index 000000000..3cb6621a7
--- /dev/null
+++ b/source/funkin/util/MathUtil.hx
@@ -0,0 +1,31 @@
+package funkin.util;
+
+/**
+ * Utilities for performing mathematical operations.
+ */
+class MathUtil
+{
+  /**
+   * Perform linear interpolation between the base and the target, based on the current framerate.
+   */
+  public static function coolLerp(base:Float, target:Float, ratio:Float):Float
+  {
+    return base + cameraLerp(ratio) * (target - base);
+  }
+
+  public static function cameraLerp(lerp:Float):Float
+  {
+    return lerp * (FlxG.elapsed / (1 / 60));
+  }
+
+  /**
+   * Get the logarithm of a value with a given base.
+   * @param base The base of the logarithm.
+   * @param value The value to get the logarithm of.
+   * @return `log_base(value)`
+   */
+  public static function logBase(base:Float, value:Float):Float
+  {
+    return Math.log(value) / Math.log(base);
+  }
+}
diff --git a/source/funkin/util/MouseUtil.hx b/source/funkin/util/MouseUtil.hx
new file mode 100644
index 000000000..c3f46819e
--- /dev/null
+++ b/source/funkin/util/MouseUtil.hx
@@ -0,0 +1,45 @@
+package funkin.util;
+
+import flixel.math.FlxPoint;
+
+/**
+ * Utility functions related to the mouse.
+ */
+class MouseUtil
+{
+  static var oldCamPos:FlxPoint = new FlxPoint();
+  static var oldMousePos:FlxPoint = new FlxPoint();
+
+  /**
+   * Used to be for general camera middle click dragging, now generalized for any click and drag type shit!
+   * Listen I don't make the rules here
+   * @param target what you want to be dragged, defaults to CAMERA SCROLL
+   * @param jusPres the "justPressed", should be a button of some sort
+   * @param pressed the "pressed", which should be the same button as `jusPres`
+   */
+  public static function mouseCamDrag(?target:FlxPoint, ?jusPres:Bool, ?pressed:Bool):Void
+  {
+    if (target == null) target = FlxG.camera.scroll;
+
+    if (jusPres == null) jusPres = FlxG.mouse.justPressedMiddle;
+
+    if (pressed == null) pressed = FlxG.mouse.pressedMiddle;
+
+    if (jusPres)
+    {
+      oldCamPos.set(target.x, target.y);
+      oldMousePos.set(FlxG.mouse.screenX, FlxG.mouse.screenY);
+    }
+
+    if (pressed)
+    {
+      target.x = oldCamPos.x - (FlxG.mouse.screenX - oldMousePos.x);
+      target.y = oldCamPos.y - (FlxG.mouse.screenY - oldMousePos.y);
+    }
+  }
+
+  public static function mouseWheelZoom():Void
+  {
+    if (FlxG.mouse.wheel != 0) FlxG.camera.zoom += FlxG.mouse.wheel * (0.1 * FlxG.camera.zoom);
+  }
+}
diff --git a/source/funkin/util/PlatformUtil.hx b/source/funkin/util/PlatformUtil.hx
index b709eb475..e11a21c9f 100644
--- a/source/funkin/util/PlatformUtil.hx
+++ b/source/funkin/util/PlatformUtil.hx
@@ -1,5 +1,8 @@
 package funkin.util;
 
+/**
+ * Utility functions related to specific platforms.
+ */
 class PlatformUtil
 {
   /**
diff --git a/source/funkin/util/SerializerUtil.hx b/source/funkin/util/SerializerUtil.hx
index 0af0fc9ea..abac04115 100644
--- a/source/funkin/util/SerializerUtil.hx
+++ b/source/funkin/util/SerializerUtil.hx
@@ -12,8 +12,8 @@ typedef ScoreInput =
 }
 
 /**
- * A class of functions dedicated to serializing and deserializing data.
- * TODO: Rewrite/refactor this to use json2object.
+ * Functions dedicated to serializing and deserializing data.
+ * NOTE: Use `json2object` wherever possible, it's way more efficient.
  */
 class SerializerUtil
 {
diff --git a/source/funkin/util/SortUtil.hx b/source/funkin/util/SortUtil.hx
index 178015068..d87592c91 100644
--- a/source/funkin/util/SortUtil.hx
+++ b/source/funkin/util/SortUtil.hx
@@ -10,7 +10,7 @@ import funkin.data.song.SongData.SongEventData;
 import funkin.data.song.SongData.SongNoteData;
 
 /**
- * A set of functions related to sorting.
+ * Utility functions related to sorting.
  *
  * NOTE: `Array.sort()` takes a function `(x, y) -> Int`.
  * If the objects are in the correct order (x before y), return a negative value.
diff --git a/source/funkin/util/VersionUtil.hx b/source/funkin/util/VersionUtil.hx
index 1dc00473a..480bcea71 100644
--- a/source/funkin/util/VersionUtil.hx
+++ b/source/funkin/util/VersionUtil.hx
@@ -4,6 +4,8 @@ import thx.semver.Version;
 import thx.semver.VersionRule;
 
 /**
+ * Utility functions for operating on semantic versions.
+ *
  * Remember, increment the patch version (1.0.x) if you make a bugfix,
  * increment the minor version (1.x.0) if you make a new feature (but previous content is still compatible),
  * and increment the major version (x.0.0) if you make a breaking change (e.g. new API or reorganized file format).
diff --git a/source/funkin/util/WindowUtil.hx b/source/funkin/util/WindowUtil.hx
index 6e6a41641..0e9b76bc2 100644
--- a/source/funkin/util/WindowUtil.hx
+++ b/source/funkin/util/WindowUtil.hx
@@ -2,6 +2,9 @@ package funkin.util;
 
 import flixel.util.FlxSignal.FlxTypedSignal;
 
+/**
+ * Utilities for operating on the current window, such as changing the title.
+ */
 #if (cpp && windows)
 @:cppFileCode('
 #include <iostream>

From 2728f34512e31bcf87953dcec6b7e1bc86212d2f Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Tue, 7 Nov 2023 18:53:50 -0500
Subject: [PATCH 15/21] Fix bug where Freeplay random would crash the game

---
 source/funkin/FreeplayState.hx            | 14 +++++++++++++-
 source/funkin/freeplayStuff/LetterSort.hx |  9 +++++++--
 2 files changed, 20 insertions(+), 3 deletions(-)

diff --git a/source/funkin/FreeplayState.hx b/source/funkin/FreeplayState.hx
index 918e7c725..0ba5d5175 100644
--- a/source/funkin/FreeplayState.hx
+++ b/source/funkin/FreeplayState.hx
@@ -84,6 +84,7 @@ class FreeplayState extends MusicBeatSubState
 
   var dj:DJBoyfriend;
 
+  var letterSort:LetterSort;
   var typing:FlxInputText;
   var exitMovers:Map<Array<FlxSprite>, MoveData> = new Map();
 
@@ -413,7 +414,7 @@ class FreeplayState extends MusicBeatSubState
     txtCompletion.visible = false;
     add(txtCompletion);
 
-    var letterSort:LetterSort = new LetterSort(400, 75);
+    letterSort = new LetterSort(400, 75);
     add(letterSort);
     letterSort.visible = false;
 
@@ -953,6 +954,7 @@ class FreeplayState extends MusicBeatSubState
     trace("RANDOM SELECTED");
 
     busy = true;
+    letterSort.inputEnabled = false;
 
     var availableSongCapsules:Array<SongMenuItem> = grpCapsules.members.filter(function(cap:SongMenuItem) {
       // Dead capsules are ones which were removed from the list when changing filters.
@@ -963,6 +965,15 @@ class FreeplayState extends MusicBeatSubState
       return cap.songData.songName;
     })}');
 
+    if (availableSongCapsules.length == 0)
+    {
+      trace("No songs available!");
+      busy = false;
+      letterSort.inputEnabled = true;
+      FlxG.sound.play(Paths.sound('cancelMenu'));
+      return;
+    }
+
     var targetSong:SongMenuItem = FlxG.random.getObject(availableSongCapsules);
 
     // Seeing if I can do an animation...
@@ -976,6 +987,7 @@ class FreeplayState extends MusicBeatSubState
   function capsuleOnConfirmDefault(cap:SongMenuItem):Void
   {
     busy = true;
+    letterSort.inputEnabled = false;
 
     PlayStatePlaylist.isStoryMode = false;
 
diff --git a/source/funkin/freeplayStuff/LetterSort.hx b/source/funkin/freeplayStuff/LetterSort.hx
index e6d923c90..edf1221d2 100644
--- a/source/funkin/freeplayStuff/LetterSort.hx
+++ b/source/funkin/freeplayStuff/LetterSort.hx
@@ -23,6 +23,8 @@ class LetterSort extends FlxTypedSpriteGroup<FlxSprite>
   var rightArrow:FlxSprite;
   var grpSeperators:Array<FlxSprite> = [];
 
+  public var inputEnabled:Bool = true;
+
   public function new(x, y)
   {
     super(x, y);
@@ -72,8 +74,11 @@ class LetterSort extends FlxTypedSpriteGroup<FlxSprite>
   {
     super.update(elapsed);
 
-    if (FlxG.keys.justPressed.E) changeSelection(1);
-    if (FlxG.keys.justPressed.Q) changeSelection(-1);
+    if (inputEnabled)
+    {
+      if (FlxG.keys.justPressed.E) changeSelection(1);
+      if (FlxG.keys.justPressed.Q) changeSelection(-1);
+    }
   }
 
   public function changeSelection(diff:Int = 0)

From da08e0fa39ca4e66751cd462f1a9f74f7e4fb94d Mon Sep 17 00:00:00 2001
From: Cameron Taylor <cameron.taylor.ninja@gmail.com>
Date: Wed, 8 Nov 2023 23:22:55 -0500
Subject: [PATCH 16/21] assets update

---
 .gitmodules | 1 +
 assets      | 2 +-
 2 files changed, 2 insertions(+), 1 deletion(-)

diff --git a/.gitmodules b/.gitmodules
index 8968471e3..c7aa48202 100644
--- a/.gitmodules
+++ b/.gitmodules
@@ -5,3 +5,4 @@
 [submodule "art"]
 	path = art
 	url = https://github.com/FunkinCrew/Funkin-history-rewrite-art
+	branch = master
diff --git a/assets b/assets
index 53f63f549..bbd6386e1 160000
--- a/assets
+++ b/assets
@@ -1 +1 @@
-Subproject commit 53f63f549f34b9c03d0e2d7149dde551a61acb26
+Subproject commit bbd6386e11e3d0a8278ffcdef77f53e0f7ade750

From f8ec09b7c257df88241c0a93453faee51bfbfc74 Mon Sep 17 00:00:00 2001
From: Cameron Taylor <cameron.taylor.ninja@gmail.com>
Date: Thu, 9 Nov 2023 06:28:50 -0500
Subject: [PATCH 17/21] update modules + assets merge

---
 .gitmodules | 3 +++
 assets      | 2 +-
 2 files changed, 4 insertions(+), 1 deletion(-)

diff --git a/.gitmodules b/.gitmodules
index 8968471e3..17c3cc026 100644
--- a/.gitmodules
+++ b/.gitmodules
@@ -2,6 +2,9 @@
 	path = assets
 	url = https://github.com/FunkinCrew/Funkin-history-rewrite-assets
 	branch = master
+	update = merge
 [submodule "art"]
 	path = art
 	url = https://github.com/FunkinCrew/Funkin-history-rewrite-art
+	branch = master
+	update = merge
diff --git a/assets b/assets
index e634c8f50..11a1af02f 160000
--- a/assets
+++ b/assets
@@ -1 +1 @@
-Subproject commit e634c8f50c34845097283e0f411e1f89409e1498
+Subproject commit 11a1af02fd38ed6086f09bf59567e1efa57b5715

From a9dfd4adcf50aaaad3cfa9bec71540a460bd7c57 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Thu, 9 Nov 2023 17:00:46 -0500
Subject: [PATCH 18/21] Fix a bug where multiple vocal tracks would play at
 once

---
 source/funkin/play/PlayState.hx | 4 ++++
 1 file changed, 4 insertions(+)

diff --git a/source/funkin/play/PlayState.hx b/source/funkin/play/PlayState.hx
index 4542b9f98..0438ffecc 100644
--- a/source/funkin/play/PlayState.hx
+++ b/source/funkin/play/PlayState.hx
@@ -700,6 +700,8 @@ class PlayState extends MusicBeatSubState
 
       if (!overrideMusic)
       {
+        // Stop the vocals if they already exist.
+        if (vocals != null) vocals.stop();
         vocals = currentChart.buildVocals();
 
         if (vocals.members.length == 0)
@@ -1554,6 +1556,8 @@ class PlayState extends MusicBeatSubState
 
     if (!overrideMusic)
     {
+      // Stop the vocals if they already exist.
+      if (vocals != null) vocals.stop();
       vocals = currentChart.buildVocals();
 
       if (vocals.members.length == 0)

From 6956144df7164c0f759d22d8c824bca37ffd26aa Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Thu, 9 Nov 2023 17:03:13 -0500
Subject: [PATCH 19/21] Update haxeui-flixel

---
 hmm.json | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/hmm.json b/hmm.json
index 22dd6e42f..778b85604 100644
--- a/hmm.json
+++ b/hmm.json
@@ -56,7 +56,7 @@
       "name": "haxeui-flixel",
       "type": "git",
       "dir": null,
-      "ref": "9bd0b9e0fea40b8e06a89aac4949512d95064609",
+      "ref": "95c7d66e779626eabd6f48a1cd7aa7f9a503a7f3",
       "url": "https://github.com/haxeui/haxeui-flixel"
     },
     {

From 01be0293e3ef16d7c691f9568fb38707750b0631 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Mon, 13 Nov 2023 18:03:24 -0500
Subject: [PATCH 20/21] Revert asset changes (temporarily)

---
 assets | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/assets b/assets
index 11a1af02f..be9d790af 160000
--- a/assets
+++ b/assets
@@ -1 +1 @@
-Subproject commit 11a1af02fd38ed6086f09bf59567e1efa57b5715
+Subproject commit be9d790af9c6f1f5e3afc7aed2b1d5c21823bc20

From 70049765ee8f4b8090f6697d1e47f1aeb99e6b67 Mon Sep 17 00:00:00 2001
From: Cameron Taylor <cameron.taylor.ninja@gmail.com>
Date: Tue, 14 Nov 2023 15:30:49 -0500
Subject: [PATCH 21/21] assets push / update

---
 assets | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/assets b/assets
index bbd6386e1..69283c667 160000
--- a/assets
+++ b/assets
@@ -1 +1 @@
-Subproject commit bbd6386e11e3d0a8278ffcdef77f53e0f7ade750
+Subproject commit 69283c667d93e44da8d63f0588c7554f608575fb