From 7a9bff248e3351f051884ef01d7e3b083491000a Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Wed, 6 Mar 2024 12:24:25 -0500
Subject: [PATCH 01/25] Fix an issue with array.clone() on HTML5

---
 source/funkin/import.hx                        |  2 +-
 source/funkin/util/tools/DynamicAccessTools.hx | 16 ++++++++++++++++
 source/funkin/util/tools/DynamicTools.hx       | 14 --------------
 3 files changed, 17 insertions(+), 15 deletions(-)
 create mode 100644 source/funkin/util/tools/DynamicAccessTools.hx
 delete mode 100644 source/funkin/util/tools/DynamicTools.hx

diff --git a/source/funkin/import.hx b/source/funkin/import.hx
index 02055d4ed..66c3470ff 100644
--- a/source/funkin/import.hx
+++ b/source/funkin/import.hx
@@ -13,7 +13,7 @@ using Lambda;
 using StringTools;
 using funkin.util.tools.ArraySortTools;
 using funkin.util.tools.ArrayTools;
-using funkin.util.tools.DynamicTools;
+using funkin.util.tools.DynamicAccessTools;
 using funkin.util.tools.FloatTools;
 using funkin.util.tools.Int64Tools;
 using funkin.util.tools.IntTools;
diff --git a/source/funkin/util/tools/DynamicAccessTools.hx b/source/funkin/util/tools/DynamicAccessTools.hx
new file mode 100644
index 000000000..1c83ce039
--- /dev/null
+++ b/source/funkin/util/tools/DynamicAccessTools.hx
@@ -0,0 +1,16 @@
+package funkin.util.tools;
+
+import haxe.DynamicAccess;
+
+class DynamicAccessTools
+{
+  /**
+   * Creates a full clone of the input `DynamicAccess`.
+   * @param input The `Dynamic` to clone.
+   * @return A clone of the input `Dynamic`.
+   */
+  public static function clone(input:DynamicAccess<T>):DynamicAccess<T>
+  {
+    return Reflect.copy(input);
+  }
+}
diff --git a/source/funkin/util/tools/DynamicTools.hx b/source/funkin/util/tools/DynamicTools.hx
deleted file mode 100644
index 47501ea22..000000000
--- a/source/funkin/util/tools/DynamicTools.hx
+++ /dev/null
@@ -1,14 +0,0 @@
-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 2fa1d18dce85a6127148406d5f6822279e48bfc6 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Wed, 6 Mar 2024 12:29:54 -0500
Subject: [PATCH 02/25] Fix build

---
 source/funkin/ui/debug/charting/ChartEditorState.hx | 2 +-
 source/funkin/util/tools/DynamicAccessTools.hx      | 2 +-
 2 files changed, 2 insertions(+), 2 deletions(-)

diff --git a/source/funkin/ui/debug/charting/ChartEditorState.hx b/source/funkin/ui/debug/charting/ChartEditorState.hx
index 78e73bf27..191f3cb15 100644
--- a/source/funkin/ui/debug/charting/ChartEditorState.hx
+++ b/source/funkin/ui/debug/charting/ChartEditorState.hx
@@ -4532,7 +4532,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
                 else
                 {
                   // Create a note and place it in the chart.
-                  var newNoteData:SongNoteData = new SongNoteData(cursorSnappedMs, cursorColumn, 0, noteKindToPlace.clone());
+                  var newNoteData:SongNoteData = new SongNoteData(cursorSnappedMs, cursorColumn, 0, noteKindToPlace);
 
                   performCommand(new AddNotesCommand([newNoteData], FlxG.keys.pressed.CONTROL));
 
diff --git a/source/funkin/util/tools/DynamicAccessTools.hx b/source/funkin/util/tools/DynamicAccessTools.hx
index 1c83ce039..14b9a6c68 100644
--- a/source/funkin/util/tools/DynamicAccessTools.hx
+++ b/source/funkin/util/tools/DynamicAccessTools.hx
@@ -9,7 +9,7 @@ class DynamicAccessTools
    * @param input The `Dynamic` to clone.
    * @return A clone of the input `Dynamic`.
    */
-  public static function clone(input:DynamicAccess<T>):DynamicAccess<T>
+  public static function clone<T>(input:DynamicAccess<T>):DynamicAccess<T>
   {
     return Reflect.copy(input);
   }

From f671cc856902713618d270137592a4bb0a606348 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Wed, 6 Mar 2024 14:13:48 -0500
Subject: [PATCH 03/25] Remove DynamicAccessTools entirely.

---
 source/funkin/import.hx                          |  1 -
 .../funkin/ui/debug/charting/ChartEditorState.hx |  2 +-
 source/funkin/util/tools/DynamicAccessTools.hx   | 16 ----------------
 3 files changed, 1 insertion(+), 18 deletions(-)
 delete mode 100644 source/funkin/util/tools/DynamicAccessTools.hx

diff --git a/source/funkin/import.hx b/source/funkin/import.hx
index 66c3470ff..250de99cb 100644
--- a/source/funkin/import.hx
+++ b/source/funkin/import.hx
@@ -13,7 +13,6 @@ using Lambda;
 using StringTools;
 using funkin.util.tools.ArraySortTools;
 using funkin.util.tools.ArrayTools;
-using funkin.util.tools.DynamicAccessTools;
 using funkin.util.tools.FloatTools;
 using funkin.util.tools.Int64Tools;
 using funkin.util.tools.IntTools;
diff --git a/source/funkin/ui/debug/charting/ChartEditorState.hx b/source/funkin/ui/debug/charting/ChartEditorState.hx
index 191f3cb15..29d7ddf97 100644
--- a/source/funkin/ui/debug/charting/ChartEditorState.hx
+++ b/source/funkin/ui/debug/charting/ChartEditorState.hx
@@ -4525,7 +4525,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
                 {
                   // Create an event and place it in the chart.
                   // TODO: Figure out configuring event data.
-                  var newEventData:SongEventData = new SongEventData(cursorSnappedMs, eventKindToPlace, eventDataToPlace.clone());
+                  var newEventData:SongEventData = new SongEventData(cursorSnappedMs, eventKindToPlace, eventDataToPlace.copy());
 
                   performCommand(new AddEventsCommand([newEventData], FlxG.keys.pressed.CONTROL));
                 }
diff --git a/source/funkin/util/tools/DynamicAccessTools.hx b/source/funkin/util/tools/DynamicAccessTools.hx
deleted file mode 100644
index 14b9a6c68..000000000
--- a/source/funkin/util/tools/DynamicAccessTools.hx
+++ /dev/null
@@ -1,16 +0,0 @@
-package funkin.util.tools;
-
-import haxe.DynamicAccess;
-
-class DynamicAccessTools
-{
-  /**
-   * Creates a full clone of the input `DynamicAccess`.
-   * @param input The `Dynamic` to clone.
-   * @return A clone of the input `Dynamic`.
-   */
-  public static function clone<T>(input:DynamicAccess<T>):DynamicAccess<T>
-  {
-    return Reflect.copy(input);
-  }
-}

From a7b531e57fd769276017e8c7c0cd92762970aff1 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Fri, 8 Mar 2024 15:07:10 -0500
Subject: [PATCH 04/25] Fix swapped entries in Blazin' dropdown.

---
 source/funkin/ui/debug/charting/util/ChartEditorDropdowns.hx | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/source/funkin/ui/debug/charting/util/ChartEditorDropdowns.hx b/source/funkin/ui/debug/charting/util/ChartEditorDropdowns.hx
index b53de174e..d2a0a053e 100644
--- a/source/funkin/ui/debug/charting/util/ChartEditorDropdowns.hx
+++ b/source/funkin/ui/debug/charting/util/ChartEditorDropdowns.hx
@@ -128,9 +128,9 @@ class ChartEditorDropdowns
     "weekend-1-picouppercutprep" => "Pico Uppercut (Prep) (Blazin')",
     "weekend-1-picouppercut" => "Pico Uppercut (Blazin')",
     "weekend-1-blockhigh" => "Block High (Blazin')",
-    "weekend-1-blocklow" => "Dodge High (Blazin')",
+    "weekend-1-blocklow" => "Block Low (Blazin')",
     "weekend-1-blockspin" => "Block High (Spin) (Blazin')",
-    "weekend-1-dodgehigh" => "Block Low (Blazin')",
+    "weekend-1-dodgehigh" => "Dodge High (Blazin')",
     "weekend-1-dodgelow" => "Dodge Low (Blazin')",
     "weekend-1-dodgespin" => "Dodge High (Spin) (Blazin')",
     "weekend-1-hithigh" => "Hit High (Blazin')",

From 083d66f879b877b7d301474d0aa9498e33c01e39 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Fri, 8 Mar 2024 15:29:02 -0500
Subject: [PATCH 05/25] Some animation fixes for Blazin

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

diff --git a/assets b/assets
index 095e91fb3..79ac27372 160000
--- a/assets
+++ b/assets
@@ -1 +1 @@
-Subproject commit 095e91fb33dad70a5b51e37542e335cedd025d09
+Subproject commit 79ac27372fd36431072d438caa76cec0fde69095

From 9284f1495b84f6e0f553d5ff468d827e5f046cd7 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Fri, 8 Mar 2024 20:56:34 -0500
Subject: [PATCH 06/25] Fix default value for singTime to use correct unit.

---
 source/funkin/play/character/CharacterData.hx | 8 ++++----
 1 file changed, 4 insertions(+), 4 deletions(-)

diff --git a/source/funkin/play/character/CharacterData.hx b/source/funkin/play/character/CharacterData.hx
index 56d7b7793..7d3d6cfb9 100644
--- a/source/funkin/play/character/CharacterData.hx
+++ b/source/funkin/play/character/CharacterData.hx
@@ -378,12 +378,12 @@ class CharacterDataParser
   }
 
   /**
-   * The default time the character should sing for, in beats.
+   * The default time the character should sing for, in steps.
    * Values that are too low will cause the character to stop singing between notes.
-   * Originally, this value was set to 1, but it was changed to 2 because that became
-   * too low after some other code changes.
+   * Values that are too high will cause the character to hold their singing pose for too long after they're done.
+   * @default `8 steps`
    */
-  static final DEFAULT_SINGTIME:Float = 2.0;
+  static final DEFAULT_SINGTIME:Float = 8.0;
 
   static final DEFAULT_DANCEEVERY:Int = 1;
   static final DEFAULT_FLIPX:Bool = false;

From 09029718aaf44d79c2e961e24c24abf2e16a7b42 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Fri, 8 Mar 2024 21:33:06 -0500
Subject: [PATCH 07/25] Fix an issue where story menu characters bop too fast

---
 source/funkin/play/stage/Bopper.hx  | 28 +++++++++++++++++-----------
 source/funkin/ui/story/LevelProp.hx |  3 +++
 2 files changed, 20 insertions(+), 11 deletions(-)

diff --git a/source/funkin/play/stage/Bopper.hx b/source/funkin/play/stage/Bopper.hx
index 3a57072e6..262aff7bc 100644
--- a/source/funkin/play/stage/Bopper.hx
+++ b/source/funkin/play/stage/Bopper.hx
@@ -167,10 +167,7 @@ class Bopper extends StageProp implements IPlayStateScriptedClass
 
   function update_shouldAlternate():Void
   {
-    if (hasAnimation('danceLeft'))
-    {
-      this.shouldAlternate = true;
-    }
+    this.shouldAlternate = hasAnimation('danceLeft');
   }
 
   /**
@@ -228,10 +225,11 @@ class Bopper extends StageProp implements IPlayStateScriptedClass
 
   /**
    * Ensure that a given animation exists before playing it.
-   * Will gracefully check for name, then name with stripped suffixes, then 'idle', then fail to play.
-   * @param name
+   * Will gracefully check for name, then name with stripped suffixes, then fail to play.
+   * @param name The animation name to attempt to correct.
+   * @param fallback Instead of failing to play, try to play this animation instead.
    */
-  function correctAnimationName(name:String):String
+  function correctAnimationName(name:String, ?fallback:String):String
   {
     // If the animation exists, we're good.
     if (hasAnimation(name)) return name;
@@ -247,14 +245,22 @@ class Bopper extends StageProp implements IPlayStateScriptedClass
     }
     else
     {
-      if (name != 'idle')
+      if (fallback != null)
       {
-        FlxG.log.warn('Bopper tried to play animation "$name" that does not exist, fallback to idle...');
-        return correctAnimationName('idle');
+        if (fallback == name)
+        {
+          FlxG.log.error('Bopper tried to play animation "$name" that does not exist! This is bad!');
+          return null;
+        }
+        else
+        {
+          FlxG.log.warn('Bopper tried to play animation "$name" that does not exist, fallback to idle...');
+          return correctAnimationName('idle');
+        }
       }
       else
       {
-        FlxG.log.error('Bopper tried to play animation "idle" that does not exist! This is bad!');
+        FlxG.log.error('Bopper tried to play animation "$name" that does not exist! This is bad!');
         return null;
       }
     }
diff --git a/source/funkin/ui/story/LevelProp.hx b/source/funkin/ui/story/LevelProp.hx
index b126f0243..d8eae9c77 100644
--- a/source/funkin/ui/story/LevelProp.hx
+++ b/source/funkin/ui/story/LevelProp.hx
@@ -45,6 +45,9 @@ class LevelProp extends Bopper
       this.visible = true;
     }
 
+    // Reset animation state.
+    this.shouldAlternate = null;
+
     var isAnimated:Bool = propData.animations.length > 0;
     if (isAnimated)
     {

From f1c13f77966542e9ef1d54572f3a24224069589f Mon Sep 17 00:00:00 2001
From: Cameron Taylor <cameron.taylor.ninja@gmail.com>
Date: Sun, 10 Mar 2024 13:34:06 -0400
Subject: [PATCH 08/25] assets submod

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

diff --git a/assets b/assets
index 79ac27372..86248e6c9 160000
--- a/assets
+++ b/assets
@@ -1 +1 @@
-Subproject commit 79ac27372fd36431072d438caa76cec0fde69095
+Subproject commit 86248e6c9c64f70349fa7d3055f1df8facab894a

From ee35bf3044c49c6ba8b5592236474b97e5484859 Mon Sep 17 00:00:00 2001
From: Cameron Taylor <cameron.taylor.ninja@gmail.com>
Date: Mon, 11 Mar 2024 00:47:56 -0400
Subject: [PATCH 09/25] bugfix/darnell-can-sfx-sync assets merge

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

diff --git a/assets b/assets
index 86248e6c9..fe8c987eb 160000
--- a/assets
+++ b/assets
@@ -1 +1 @@
-Subproject commit 86248e6c9c64f70349fa7d3055f1df8facab894a
+Subproject commit fe8c987eb846ceb73b8518879b506111aaccdf80

From be8f5699b5439af8314c1708ad4596ba0c86f7a8 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Mon, 11 Mar 2024 23:42:32 -0400
Subject: [PATCH 10/25] 2hot stutter actually fixed!

---
 assets                                        |  2 +-
 source/funkin/audio/FunkinSound.hx            | 64 ++++++++++++----
 source/funkin/data/song/SongRegistry.hx       |  7 ++
 source/funkin/play/PlayState.hx               |  3 +-
 source/funkin/ui/freeplay/FreeplayState.hx    | 28 ++++---
 source/funkin/ui/mainmenu/MainMenuState.hx    |  9 ++-
 source/funkin/ui/story/StoryMenuState.hx      | 13 +---
 source/funkin/ui/title/TitleState.hx          | 16 ++--
 source/funkin/ui/transition/LoadingState.hx   | 27 +++++++
 .../{tools/TimerTools.hx => TimerUtil.hx}     |  0
 source/funkin/util/logging/Perf.hx            | 76 +++++++++++++++++++
 source/funkin/util/tools/StringTools.hx       | 30 ++++++++
 12 files changed, 222 insertions(+), 53 deletions(-)
 rename source/funkin/util/{tools/TimerTools.hx => TimerUtil.hx} (100%)
 create mode 100644 source/funkin/util/logging/Perf.hx

diff --git a/assets b/assets
index 2d3db0cce..7e37fd971 160000
--- a/assets
+++ b/assets
@@ -1 +1 @@
-Subproject commit 2d3db0cce9bd06cf280bbf6a0b10e57982f32fc3
+Subproject commit 7e37fd971006140db30aa7b4746f4b94f2e5a613
diff --git a/source/funkin/audio/FunkinSound.hx b/source/funkin/audio/FunkinSound.hx
index a1e14d705..9efa6ed50 100644
--- a/source/funkin/audio/FunkinSound.hx
+++ b/source/funkin/audio/FunkinSound.hx
@@ -8,6 +8,8 @@ import flixel.sound.FlxSound;
 import flixel.group.FlxGroup.FlxTypedGroup;
 import flixel.system.FlxAssets.FlxSoundAsset;
 import funkin.util.tools.ICloneable;
+import funkin.data.song.SongData.SongMusicData;
+import funkin.data.song.SongRegistry;
 import funkin.audio.waveform.WaveformData;
 import funkin.audio.waveform.WaveformDataParser;
 import flixel.math.FlxMath;
@@ -28,7 +30,7 @@ class FunkinSound extends FlxSound implements ICloneable<FunkinSound>
   /**
    * Using `FunkinSound.load` will override a dead instance from here rather than creating a new one, if possible!
    */
-  static var cache(default, null):FlxTypedGroup<FunkinSound> = new FlxTypedGroup<FunkinSound>();
+  static var pool(default, null):FlxTypedGroup<FunkinSound> = new FlxTypedGroup<FunkinSound>();
 
   public var muted(default, set):Bool = false;
 
@@ -265,23 +267,55 @@ class FunkinSound extends FlxSound implements ICloneable<FunkinSound>
   }
 
   /**
-   * Creates a new `FunkinSound` object.
+   * Creates a new `FunkinSound` object and loads it as the current music track.
    *
-   * @param   embeddedSound   The embedded sound resource you want to play.  To stream, use the optional URL parameter instead.
-   * @param   volume          How loud to play it (0 to 1).
-   * @param   looped          Whether to loop this sound.
-   * @param   group           The group to add this sound to.
-   * @param   autoDestroy     Whether to destroy this sound when it finishes playing.
+   * @param key The key of the music you want to play. Music should be at `music/<key>/<key>.ogg`.
+   * @param overrideExisting Whether to override music if it is already playing.
+   * @param mapTimeChanges Whether to check for `SongMusicData` to update the Conductor with.
+   *   Data should be at `music/<key>/<key>-metadata.json`.
+   */
+  public static function playMusic(key:String, overrideExisting:Bool = false, mapTimeChanges:Bool = true):Void
+  {
+    if (!overrideExisting && FlxG.sound.music?.playing) return;
+
+    if (mapTimeChanges)
+    {
+      var songMusicData:Null<SongMusicData> = SongRegistry.instance.parseMusicData(key);
+      // Will fall back and return null if the metadata doesn't exist or can't be parsed.
+      if (songMusicData != null)
+      {
+        Conductor.instance.mapTimeChanges(songMusicData.timeChanges);
+      }
+      else
+      {
+        FlxG.log.warn('Tried and failed to find music metadata for $key');
+      }
+    }
+
+    FlxG.sound.music = FunkinSound.load(Paths.music('$key/$key'));
+
+    // Prevent repeat update() and onFocus() calls.
+    FlxG.sound.list.remove(FlxG.sound.music);
+  }
+
+  /**
+   * Creates a new `FunkinSound` object synchronously.
+   *
+   * @param embeddedSound   The embedded sound resource you want to play.  To stream, use the optional URL parameter instead.
+   * @param volume          How loud to play it (0 to 1).
+   * @param looped          Whether to loop this sound.
+   * @param group           The group to add this sound to.
+   * @param autoDestroy     Whether to destroy this sound when it finishes playing.
    *                          Leave this value set to `false` if you want to re-use this `FunkinSound` instance.
-   * @param   autoPlay        Whether to play the sound immediately or wait for a `play()` call.
-   * @param   onComplete      Called when the sound finished playing.
-   * @param   onLoad          Called when the sound finished loading.  Called immediately for succesfully loaded embedded sounds.
-   * @return  A `FunkinSound` object.
+   * @param autoPlay        Whether to play the sound immediately or wait for a `play()` call.
+   * @param onComplete      Called when the sound finished playing.
+   * @param onLoad          Called when the sound finished loading.  Called immediately for succesfully loaded embedded sounds.
+   * @return A `FunkinSound` object.
    */
   public static function load(embeddedSound:FlxSoundAsset, volume:Float = 1.0, looped:Bool = false, autoDestroy:Bool = false, autoPlay:Bool = false,
       ?onComplete:Void->Void, ?onLoad:Void->Void):FunkinSound
   {
-    var sound:FunkinSound = cache.recycle(construct);
+    var sound:FunkinSound = pool.recycle(construct);
 
     // Load the sound.
     // Sets `exists = true` as a side effect.
@@ -297,9 +331,11 @@ class FunkinSound extends FlxSound implements ICloneable<FunkinSound>
     sound.persist = true;
     if (autoPlay) sound.play();
 
-    // Call OnlLoad() because the sound already loaded
+    // Call onLoad() because the sound already loaded
     if (onLoad != null && sound._sound != null) onLoad();
 
+    FlxG.sound.list.remove(FlxG.sound.music);
+
     return sound;
   }
 
@@ -307,7 +343,7 @@ class FunkinSound extends FlxSound implements ICloneable<FunkinSound>
   {
     var sound:FunkinSound = new FunkinSound();
 
-    cache.add(sound);
+    pool.add(sound);
     FlxG.sound.list.add(sound);
 
     return sound;
diff --git a/source/funkin/data/song/SongRegistry.hx b/source/funkin/data/song/SongRegistry.hx
index dad287e82..e2edc055a 100644
--- a/source/funkin/data/song/SongRegistry.hx
+++ b/source/funkin/data/song/SongRegistry.hx
@@ -441,6 +441,13 @@ class SongRegistry extends BaseRegistry<Song, SongMetadata>
     return {fileName: entryFilePath, contents: rawJson};
   }
 
+  function hasMusicDataFile(id:String, ?variation:String):Bool
+  {
+    variation = variation == null ? Constants.DEFAULT_VARIATION : variation;
+    var entryFilePath:String = Paths.file('music/$id/$id-metadata${variation == Constants.DEFAULT_VARIATION ? '' : '-$variation'}.json');
+    return openfl.Assets.exists(entryFilePath);
+  }
+
   function loadEntryChartFile(id:String, ?variation:String):Null<BaseRegistry.JsonFile>
   {
     variation = variation == null ? Constants.DEFAULT_VARIATION : variation;
diff --git a/source/funkin/play/PlayState.hx b/source/funkin/play/PlayState.hx
index a6e4b4632..7609c356b 100644
--- a/source/funkin/play/PlayState.hx
+++ b/source/funkin/play/PlayState.hx
@@ -1,5 +1,6 @@
 package funkin.play;
 
+import funkin.audio.FunkinSound;
 import flixel.addons.display.FlxPieDial;
 import flixel.addons.display.FlxPieDial;
 import flixel.addons.transition.FlxTransitionableState;
@@ -2711,7 +2712,7 @@ class PlayState extends MusicBeatSubState
 
       if (targetSongId == null)
       {
-        FlxG.sound.playMusic(Paths.music('freakyMenu/freakyMenu'));
+        FunkinSound.playMusic('freakyMenu');
 
         // transIn = FlxTransitionableState.defaultTransIn;
         // transOut = FlxTransitionableState.defaultTransOut;
diff --git a/source/funkin/ui/freeplay/FreeplayState.hx b/source/funkin/ui/freeplay/FreeplayState.hx
index 45f9a4d27..ec873d103 100644
--- a/source/funkin/ui/freeplay/FreeplayState.hx
+++ b/source/funkin/ui/freeplay/FreeplayState.hx
@@ -1,21 +1,19 @@
 package funkin.ui.freeplay;
 
-import flash.text.TextField;
+import openfl.text.TextField;
 import flixel.addons.display.FlxGridOverlay;
 import flixel.addons.transition.FlxTransitionableState;
 import flixel.addons.ui.FlxInputText;
 import flixel.FlxCamera;
 import flixel.FlxGame;
 import flixel.FlxSprite;
-import funkin.graphics.FunkinSprite;
 import flixel.FlxState;
 import flixel.group.FlxGroup;
 import flixel.group.FlxGroup.FlxTypedGroup;
-import flixel.group.FlxSpriteGroup;
+import flixel.group.FlxSpriteGroup.FlxTypedSpriteGroup;
 import flixel.input.touch.FlxTouch;
 import flixel.math.FlxAngle;
 import flixel.math.FlxMath;
-import funkin.graphics.FunkinCamera;
 import flixel.math.FlxPoint;
 import flixel.system.debug.watch.Tracker.TrackerProfile;
 import flixel.text.FlxText;
@@ -24,9 +22,12 @@ import flixel.tweens.FlxTween;
 import flixel.util.FlxColor;
 import flixel.util.FlxSpriteUtil;
 import flixel.util.FlxTimer;
+import funkin.audio.FunkinSound;
 import funkin.data.level.LevelRegistry;
 import funkin.data.song.SongRegistry;
 import funkin.graphics.adobeanimate.FlxAtlasSprite;
+import funkin.graphics.FunkinCamera;
+import funkin.graphics.FunkinSprite;
 import funkin.graphics.shaders.AngleMask;
 import funkin.graphics.shaders.HSVShader;
 import funkin.graphics.shaders.PureColor;
@@ -187,10 +188,7 @@ class FreeplayState extends MusicBeatSubState
     isDebug = true;
     #end
 
-    if (FlxG.sound.music == null || (FlxG.sound.music != null && !FlxG.sound.music.playing))
-    {
-      FlxG.sound.playMusic(Paths.music('freakyMenu/freakyMenu'));
-    }
+    FunkinSound.playMusic('freakyMenu');
 
     // Add a null entry that represents the RANDOM option
     songs.push(null);
@@ -590,7 +588,7 @@ class FreeplayState extends MusicBeatSubState
     });
   }
 
-  public function generateSongList(?filterStuff:SongFilter, force:Bool = false)
+  public function generateSongList(?filterStuff:SongFilter, force:Bool = false):Void
   {
     curSelected = 1;
 
@@ -693,7 +691,7 @@ class FreeplayState extends MusicBeatSubState
 
   var busy:Bool = false; // Set to true once the user has pressed enter to select a song.
 
-  override function update(elapsed:Float)
+  override function update(elapsed:Float):Void
   {
     super.update(elapsed);
 
@@ -983,7 +981,7 @@ class FreeplayState extends MusicBeatSubState
     }
   }
 
-  function changeDiff(change:Int = 0)
+  function changeDiff(change:Int = 0):Void
   {
     touchTimer = 0;
 
@@ -1173,7 +1171,7 @@ class FreeplayState extends MusicBeatSubState
     difficultyStars.difficulty = daSong?.songRating ?? 0;
   }
 
-  function changeSelection(change:Int = 0)
+  function changeSelection(change:Int = 0):Void
   {
     // NGio.logEvent('Fresh');
     FlxG.sound.play(Paths.sound('scrollMenu'), 0.4);
@@ -1228,7 +1226,7 @@ class FreeplayState extends MusicBeatSubState
         // TODO: Stream the instrumental of the selected song?
         if (prevSelected == 0)
         {
-          FlxG.sound.playMusic(Paths.music('freakyMenu/freakyMenu'));
+          FunkinSound.playMusic('freakyMenu');
           FlxG.sound.music.fadeIn(2, 0, 0.8);
         }
       }
@@ -1259,7 +1257,7 @@ class DifficultySelector extends FlxSprite
     flipX = flipped;
   }
 
-  override function update(elapsed:Float)
+  override function update(elapsed:Float):Void
   {
     if (flipX && controls.UI_RIGHT_P) moveShitDown();
     if (!flipX && controls.UI_LEFT_P) moveShitDown();
@@ -1267,7 +1265,7 @@ class DifficultySelector extends FlxSprite
     super.update(elapsed);
   }
 
-  function moveShitDown()
+  function moveShitDown():Void
   {
     offset.y -= 5;
 
diff --git a/source/funkin/ui/mainmenu/MainMenuState.hx b/source/funkin/ui/mainmenu/MainMenuState.hx
index 8842c37de..1892bdec1 100644
--- a/source/funkin/ui/mainmenu/MainMenuState.hx
+++ b/source/funkin/ui/mainmenu/MainMenuState.hx
@@ -12,8 +12,10 @@ import flixel.util.typeLimit.NextState;
 import flixel.group.FlxGroup.FlxTypedGroup;
 import flixel.input.touch.FlxTouch;
 import flixel.text.FlxText;
+import funkin.data.song.SongData.SongMusicData;
 import flixel.tweens.FlxEase;
 import funkin.graphics.FunkinCamera;
+import funkin.audio.FunkinSound;
 import flixel.tweens.FlxTween;
 import funkin.ui.MusicBeatState;
 import flixel.util.FlxTimer;
@@ -51,7 +53,7 @@ class MainMenuState extends MusicBeatState
 
     if (!(FlxG?.sound?.music?.playing ?? false))
     {
-      FlxG.sound.playMusic(Paths.music('freakyMenu/freakyMenu'));
+      playMenuMusic();
     }
 
     persistentUpdate = persistentDraw = true;
@@ -151,6 +153,11 @@ class MainMenuState extends MusicBeatState
     // NG.core.calls.event.logEvent('swag').send();
   }
 
+  function playMenuMusic():Void
+  {
+    FunkinSound.playMusic('freakyMenu');
+  }
+
   function resetCamStuff()
   {
     FlxG.cameras.reset(new FunkinCamera());
diff --git a/source/funkin/ui/story/StoryMenuState.hx b/source/funkin/ui/story/StoryMenuState.hx
index 404dfb67e..1f78eb375 100644
--- a/source/funkin/ui/story/StoryMenuState.hx
+++ b/source/funkin/ui/story/StoryMenuState.hx
@@ -16,6 +16,7 @@ import flixel.tweens.FlxTween;
 import flixel.util.FlxColor;
 import flixel.util.FlxTimer;
 import funkin.data.level.LevelRegistry;
+import funkin.audio.FunkinSound;
 import funkin.modding.events.ScriptEvent;
 import funkin.modding.events.ScriptEventDispatcher;
 import funkin.play.PlayState;
@@ -234,17 +235,7 @@ class StoryMenuState extends MusicBeatState
 
   function playMenuMusic():Void
   {
-    if (FlxG.sound.music == null || !FlxG.sound.music.playing)
-    {
-      var freakyMenuMetadata:Null<SongMusicData> = SongRegistry.instance.parseMusicData('freakyMenu');
-      if (freakyMenuMetadata != null)
-      {
-        Conductor.instance.mapTimeChanges(freakyMenuMetadata.timeChanges);
-      }
-
-      FlxG.sound.playMusic(Paths.music('freakyMenu/freakyMenu'), 0);
-      FlxG.sound.music.fadeIn(4, 0, 0.7);
-    }
+    FunkinSound.playMusic('freakyMenu');
   }
 
   function updateData():Void
diff --git a/source/funkin/ui/title/TitleState.hx b/source/funkin/ui/title/TitleState.hx
index 5424e2255..1c194d80d 100644
--- a/source/funkin/ui/title/TitleState.hx
+++ b/source/funkin/ui/title/TitleState.hx
@@ -18,6 +18,7 @@ import funkin.graphics.FunkinSprite;
 import funkin.ui.MusicBeatState;
 import funkin.data.song.SongData.SongMusicData;
 import funkin.graphics.shaders.TitleOutline;
+import funkin.audio.FunkinSound;
 import funkin.ui.freeplay.FreeplayState;
 import funkin.ui.AtlasText;
 import openfl.Assets;
@@ -219,16 +220,11 @@ class TitleState extends MusicBeatState
 
   function playMenuMusic():Void
   {
-    if (FlxG.sound.music == null || !FlxG.sound.music.playing)
-    {
-      var freakyMenuMetadata:Null<SongMusicData> = SongRegistry.instance.parseMusicData('freakyMenu');
-      if (freakyMenuMetadata != null)
-      {
-        Conductor.instance.mapTimeChanges(freakyMenuMetadata.timeChanges);
-      }
-      FlxG.sound.playMusic(Paths.music('freakyMenu/freakyMenu'), 0);
-      FlxG.sound.music.fadeIn(4, 0, 0.7);
-    }
+    var shouldFadeIn = (FlxG.sound.music == null);
+    // Load music. Includes logic to handle BPM changes.
+    FunkinSound.playMusic('freakyMenu', false, true);
+    // Fade from 0.0 to 0.7 over 4 seconds
+    if (shouldFadeIn) FlxG.sound.music.fadeIn(4, 0, 0.7);
   }
 
   function getIntroTextShit():Array<Array<String>>
diff --git a/source/funkin/ui/transition/LoadingState.hx b/source/funkin/ui/transition/LoadingState.hx
index 5f755872f..c53af36de 100644
--- a/source/funkin/ui/transition/LoadingState.hx
+++ b/source/funkin/ui/transition/LoadingState.hx
@@ -238,11 +238,38 @@ class LoadingState extends MusicBeatState
     FunkinSprite.cacheTexture(Paths.image('shit', 'shared'));
     FunkinSprite.cacheTexture(Paths.image('miss', 'shared')); // TODO: remove this
 
+    // List all image assets in the level's library.
+    // This is crude and I want to remove it when we have a proper asset caching system.
+    // TODO: Get rid of this junk!
+    var library = openfl.utils.Assets.getLibrary(PlayStatePlaylist.campaignId);
+    var assets = library.list(lime.utils.AssetType.IMAGE);
+    trace('Got ${assets.length} assets: ${assets}');
+
+    // TODO: assets includes non-images! This is a bug with Polymod
+    for (asset in assets)
+    {
+      // Exclude items of the wrong type.
+      var path = '${PlayStatePlaylist.campaignId}:${asset}';
+      // TODO DUMB HACK DUMB HACK why doesn't filtering by AssetType.IMAGE above work
+      // I will fix this properly later I swear -eric
+      if (!path.endsWith('.png')) continue;
+
+      FunkinSprite.cacheTexture(path);
+
+      // Another dumb hack: FlxAnimate fetches from OpenFL's BitmapData cache directly and skips the FlxGraphic cache.
+      // Since FlxGraphic tells OpenFL to not cache it, we have to do it manually.
+      if (path.endsWith('spritemap1.png'))
+      {
+        openfl.Assets.getBitmapData(path, true);
+      }
+    }
+
     // FunkinSprite.cacheAllNoteStyleTextures(noteStyle) // This will replace the stuff above!
     // FunkinSprite.cacheAllCharacterTextures(player)
     // FunkinSprite.cacheAllCharacterTextures(girlfriend)
     // FunkinSprite.cacheAllCharacterTextures(opponent)
     // FunkinSprite.cacheAllStageTextures(stage)
+    // FunkinSprite.cacheAllSongTextures(stage)
 
     FunkinSprite.purgeCache();
 
diff --git a/source/funkin/util/tools/TimerTools.hx b/source/funkin/util/TimerUtil.hx
similarity index 100%
rename from source/funkin/util/tools/TimerTools.hx
rename to source/funkin/util/TimerUtil.hx
diff --git a/source/funkin/util/logging/Perf.hx b/source/funkin/util/logging/Perf.hx
new file mode 100644
index 000000000..83da7a32f
--- /dev/null
+++ b/source/funkin/util/logging/Perf.hx
@@ -0,0 +1,76 @@
+package funkin.util.logging;
+
+/**
+ * A small utility class for timing how long functions take.
+ * Specify a string as a label (or don't, by default it uses the name of the function it was called from.)
+ *
+ * Example:
+ * ```haxe
+ *
+ * var perf = new Perf();
+ * ...
+ * perf.print();
+ * ```
+ */
+class Perf
+{
+  final startTime:Float;
+  final label:Null<String>;
+  final posInfos:Null<haxe.PosInfos>;
+
+  /**
+   * Create a new performance marker.
+   * @param label Optionally specify a label to use for the performance marker. Defaults to the function name.
+   * @param posInfos The position of the calling function. Used to build the default label.
+   *   Note: `haxe.PosInfos` is magic and automatically populated by the compiler!
+   */
+  public function new(?label:String, ?posInfos:haxe.PosInfos)
+  {
+    this.label = label;
+    this.posInfos = posInfos;
+    startTime = current();
+  }
+
+  /**
+   * The current timestamp, in fractional seconds.
+   * @return The current timestamp.
+   */
+  static function current():Float
+  {
+    #if sys
+    // This one is more accurate if it's available.
+    return Sys.time();
+    #else
+    return haxe.Timer.stamp();
+    #end
+  }
+
+  /**
+   * The duration in seconds since this `Perf` was created.
+   * @return The duration in seconds
+   */
+  public function duration():Float
+  {
+    return current() - startTime;
+  }
+
+  /**
+   * A rounded millisecond duration
+   * @return The duration in milliseconds
+   */
+  public function durationClean():Float
+  {
+    var round:Float = 100;
+    return Math.floor(duration() * Constants.MS_PER_SEC * round) / round;
+  }
+
+  /**
+   * Cleanly prints the duration since this `Perf` was created.
+   */
+  public function print():Void
+  {
+    var label:String = label ?? (posInfos == null ? 'unknown' : '${posInfos.className}#${posInfos.methodName}()');
+
+    trace('[PERF] [$label] Took ${durationClean()}ms.');
+  }
+}
diff --git a/source/funkin/util/tools/StringTools.hx b/source/funkin/util/tools/StringTools.hx
index 0585ffeae..e69a13f1a 100644
--- a/source/funkin/util/tools/StringTools.hx
+++ b/source/funkin/util/tools/StringTools.hx
@@ -27,6 +27,36 @@ class StringTools
     return result;
   }
 
+  /**
+   * Strip a given prefix from a string.
+   * @param value The string to strip.
+   * @param prefix The prefix to strip. If the prefix isn't found, the original string is returned.
+   * @return The stripped string.
+   */
+  public static function stripPrefix(value:String, prefix:String):String
+  {
+    if (value.startsWith(prefix))
+    {
+      return value.substr(prefix.length);
+    }
+    return value;
+  }
+
+  /**
+   * Strip a given suffix from a string.
+   * @param value The string to strip.
+   * @param suffix The suffix to strip. If the suffix isn't found, the original string is returned.
+   * @return The stripped string.
+   */
+  public static function stripSuffix(value:String, suffix:String):String
+  {
+    if (value.endsWith(suffix))
+    {
+      return value.substr(0, value.length - suffix.length);
+    }
+    return value;
+  }
+
   /**
    * Converts a string to lower kebab case. For example, "Hello World" becomes "hello-world".
    *

From bbaf8dfb3e2115fcd02caec88610f2176a898d25 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Tue, 12 Mar 2024 17:57:52 -0400
Subject: [PATCH 11/25] Debugger popup fixes

---
 .github/workflows/build-shit.yml              |   2 +-
 Project.xml                                   |   2 +-
 assets                                        |   2 +-
 source/funkin/InitState.hx                    | 138 ++++++++++--------
 .../ui/debug/charting/ChartEditorState.hx     |   8 -
 5 files changed, 78 insertions(+), 74 deletions(-)

diff --git a/.github/workflows/build-shit.yml b/.github/workflows/build-shit.yml
index 5a1f5609a..49bab1ac1 100644
--- a/.github/workflows/build-shit.yml
+++ b/.github/workflows/build-shit.yml
@@ -77,7 +77,7 @@ jobs:
           key: ${{ runner.os }}-build-win-${{ github.ref_name }}
       - name: Build game
         run: |
-          haxelib run lime build windows -v -release -DNO_REDIRECT_ASSETS_FOLDER -DGITHUB_BUILD
+          haxelib run lime build windows -v -release -DGITHUB_BUILD
         env:
           HXCPP_COMPILE_CACHE: "${{ runner.temp }}\\hxcpp_cache"
       - name: Upload build artifacts
diff --git a/Project.xml b/Project.xml
index 99c46ef9f..9e9695ce1 100644
--- a/Project.xml
+++ b/Project.xml
@@ -194,7 +194,7 @@
 	<!-- Uncomment this to wipe your input settings. -->
 	<!-- <haxedef name="CLEAR_INPUT_SAVE"/> -->
 
-	<section if="debug" unless="NO_REDIRECT_ASSETS_FOLDER || html5">
+	<section if="debug" unless="NO_REDIRECT_ASSETS_FOLDER || html5 || GITHUB_BUILD">
 		<!--
 			Use the parent assets folder rather than the exported one
 			No more will we accidentally undo our changes!
diff --git a/assets b/assets
index fe8c987eb..221667bc2 160000
--- a/assets
+++ b/assets
@@ -1 +1 @@
-Subproject commit fe8c987eb846ceb73b8518879b506111aaccdf80
+Subproject commit 221667bc207c5c59896f0b8fdf74260be8efd630
diff --git a/source/funkin/InitState.hx b/source/funkin/InitState.hx
index 9ecf66ec7..705283768 100644
--- a/source/funkin/InitState.hx
+++ b/source/funkin/InitState.hx
@@ -90,69 +90,7 @@ class InitState extends FlxState
     // Set the game to a lower frame rate while it is in the background.
     FlxG.game.focusLostFramerate = 30;
 
-    //
-    // FLIXEL DEBUG SETUP
-    //
-    #if (debug || FORCE_DEBUG_VERSION)
-    // Disable using ~ to open the console (we use that for the Editor menu)
-    FlxG.debugger.toggleKeys = [F2];
-    TrackerUtil.initTrackers();
-    // Adds an additional Close Debugger button.
-    // This big obnoxious white button is for MOBILE, so that you can press it
-    // easily with your finger when debug bullshit pops up during testing lol!
-    FlxG.debugger.addButton(LEFT, new BitmapData(200, 200), function() {
-      FlxG.debugger.visible = false;
-    });
-
-    // Adds a red button to the debugger.
-    // This pauses the game AND the music! This ensures the Conductor stops.
-    FlxG.debugger.addButton(CENTER, new BitmapData(20, 20, true, 0xFFCC2233), function() {
-      if (FlxG.vcr.paused)
-      {
-        FlxG.vcr.resume();
-
-        for (snd in FlxG.sound.list)
-        {
-          snd.resume();
-        }
-
-        FlxG.sound.music.resume();
-      }
-      else
-      {
-        FlxG.vcr.pause();
-
-        for (snd in FlxG.sound.list)
-        {
-          snd.pause();
-        }
-
-        FlxG.sound.music.pause();
-      }
-    });
-
-    // Adds a blue button to the debugger.
-    // This skips forward in the song.
-    FlxG.debugger.addButton(CENTER, new BitmapData(20, 20, true, 0xFF2222CC), function() {
-      FlxG.game.debugger.vcr.onStep();
-
-      for (snd in FlxG.sound.list)
-      {
-        snd.pause();
-        snd.time += FlxG.elapsed * 1000;
-      }
-
-      FlxG.sound.music.pause();
-      FlxG.sound.music.time += FlxG.elapsed * 1000;
-    });
-    #end
-
-    // Make errors and warnings less annoying.
-    // Forcing this always since I have never been happy to have the debugger to pop up
-    LogStyle.ERROR.openConsole = false;
-    LogStyle.ERROR.errorSound = null;
-    LogStyle.WARNING.openConsole = false;
-    LogStyle.WARNING.errorSound = null;
+    setupFlixelDebug();
 
     //
     // FLIXEL TRANSITIONS
@@ -349,6 +287,80 @@ class InitState extends FlxState
       });
   }
 
+  function setupFlixelDebug():Void
+  {
+    //
+    // FLIXEL DEBUG SETUP
+    //
+    #if (debug || FORCE_DEBUG_VERSION)
+    // Make errors and warnings less annoying.
+    // Forcing this always since I have never been happy to have the debugger to pop up
+    LogStyle.ERROR.openConsole = false;
+    LogStyle.ERROR.errorSound = null;
+    LogStyle.WARNING.openConsole = false;
+    LogStyle.WARNING.errorSound = null;
+
+    // Disable using ~ to open the console (we use that for the Editor menu)
+    FlxG.debugger.toggleKeys = [F2];
+    TrackerUtil.initTrackers();
+    // Adds an additional Close Debugger button.
+    // This big obnoxious white button is for MOBILE, so that you can press it
+    // easily with your finger when debug bullshit pops up during testing lol!
+    FlxG.debugger.addButton(LEFT, new BitmapData(200, 200), function() {
+      FlxG.debugger.visible = false;
+
+      // Make errors and warnings less annoying.
+      // Forcing this always since I have never been happy to have the debugger to pop up
+      LogStyle.ERROR.openConsole = false;
+      LogStyle.ERROR.errorSound = null;
+      LogStyle.WARNING.openConsole = false;
+      LogStyle.WARNING.errorSound = null;
+    });
+
+    // Adds a red button to the debugger.
+    // This pauses the game AND the music! This ensures the Conductor stops.
+    FlxG.debugger.addButton(CENTER, new BitmapData(20, 20, true, 0xFFCC2233), function() {
+      if (FlxG.vcr.paused)
+      {
+        FlxG.vcr.resume();
+
+        for (snd in FlxG.sound.list)
+        {
+          snd.resume();
+        }
+
+        FlxG.sound.music.resume();
+      }
+      else
+      {
+        FlxG.vcr.pause();
+
+        for (snd in FlxG.sound.list)
+        {
+          snd.pause();
+        }
+
+        FlxG.sound.music.pause();
+      }
+    });
+
+    // Adds a blue button to the debugger.
+    // This skips forward in the song.
+    FlxG.debugger.addButton(CENTER, new BitmapData(20, 20, true, 0xFF2222CC), function() {
+      FlxG.game.debugger.vcr.onStep();
+
+      for (snd in FlxG.sound.list)
+      {
+        snd.pause();
+        snd.time += FlxG.elapsed * 1000;
+      }
+
+      FlxG.sound.music.pause();
+      FlxG.sound.music.time += FlxG.elapsed * 1000;
+    });
+    #end
+  }
+
   function defineSong():String
   {
     return MacroUtil.getDefine('SONG');
diff --git a/source/funkin/ui/debug/charting/ChartEditorState.hx b/source/funkin/ui/debug/charting/ChartEditorState.hx
index 942f28297..0db991148 100644
--- a/source/funkin/ui/debug/charting/ChartEditorState.hx
+++ b/source/funkin/ui/debug/charting/ChartEditorState.hx
@@ -597,11 +597,6 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
    */
   var playtestBotPlayMode:Bool = false;
 
-  /**
-   * Enables or disables the "debugger" popup that appears when you run into a flixel error.
-   */
-  var enabledDebuggerPopup:Bool = true;
-
   // Visuals
 
   /**
@@ -5328,9 +5323,6 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
       return;
     }
 
-    LogStyle.WARNING.openConsole = enabledDebuggerPopup;
-    LogStyle.ERROR.openConsole = enabledDebuggerPopup;
-
     // TODO: Rework asset system so we can remove this.
     switch (currentSongStage)
     {

From 09e52e47ae3fb89cdbcd32fa088d8329ac97ea70 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Tue, 12 Mar 2024 18:01:09 -0400
Subject: [PATCH 12/25] Commit an unsaved file

---
 .../charting/handlers/ChartEditorToolboxHandler.hx     | 10 ----------
 1 file changed, 10 deletions(-)

diff --git a/source/funkin/ui/debug/charting/handlers/ChartEditorToolboxHandler.hx b/source/funkin/ui/debug/charting/handlers/ChartEditorToolboxHandler.hx
index 3b32edf5d..5a83d8a87 100644
--- a/source/funkin/ui/debug/charting/handlers/ChartEditorToolboxHandler.hx
+++ b/source/funkin/ui/debug/charting/handlers/ChartEditorToolboxHandler.hx
@@ -308,16 +308,6 @@ class ChartEditorToolboxHandler
       state.playtestBotPlayMode = checkboxBotPlay.selected;
     };
 
-    var checkboxDebugger:Null<CheckBox> = toolbox.findComponent('playtestDebuggerCheckbox', CheckBox);
-
-    if (checkboxDebugger == null) throw 'ChartEditorToolboxHandler.buildToolboxPlaytestPropertiesLayout() - Could not find playtestDebuggerCheckbox component.';
-
-    state.enabledDebuggerPopup = checkboxDebugger.selected;
-
-    checkboxDebugger.onClick = _ -> {
-      state.enabledDebuggerPopup = checkboxDebugger.selected;
-    };
-
     return toolbox;
   }
 

From 66085ff8673d1512ce0716a31a1a12f6effc23da Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Tue, 12 Mar 2024 21:34:50 -0400
Subject: [PATCH 13/25] Song scripts can now be (optionally) enabled in the
 Chart Editor playtest

---
 source/funkin/data/BaseRegistry.hx            | 87 ++++++++++++++---
 source/funkin/data/song/SongRegistry.hx       |  1 +
 source/funkin/graphics/FunkinSprite.hx        |  9 +-
 source/funkin/play/Countdown.hx               |  2 +-
 source/funkin/play/GitarooPause.hx            |  2 +-
 source/funkin/play/PlayState.hx               |  4 +-
 source/funkin/play/components/PopUpStuff.hx   |  6 +-
 source/funkin/play/song/Song.hx               | 93 +++++++++++++++++--
 source/funkin/play/stage/Stage.hx             |  2 +-
 .../ui/debug/charting/ChartEditorState.hx     | 12 ++-
 .../handlers/ChartEditorToolboxHandler.hx     | 11 +++
 source/funkin/ui/freeplay/FreeplayState.hx    |  2 +-
 source/funkin/ui/transition/LoadingState.hx   | 10 +-
 .../funkin/ui/transition/StickerSubState.hx   |  2 +-
 source/funkin/util/tools/StringTools.hx       | 27 +++++-
 15 files changed, 224 insertions(+), 46 deletions(-)

diff --git a/source/funkin/data/BaseRegistry.hx b/source/funkin/data/BaseRegistry.hx
index 2df0c18f0..ad028fa94 100644
--- a/source/funkin/data/BaseRegistry.hx
+++ b/source/funkin/data/BaseRegistry.hx
@@ -1,6 +1,5 @@
 package funkin.data;
 
-import openfl.Assets;
 import funkin.util.assets.DataAssets;
 import funkin.util.VersionUtil;
 import haxe.Constraints.Constructible;
@@ -19,12 +18,23 @@ typedef EntryConstructorFunction = String->Void;
 @:generic
 abstract class BaseRegistry<T:(IRegistryEntry<J> & Constructible<EntryConstructorFunction>), J>
 {
+  /**
+   * The ID of the registry. Used when logging.
+   */
   public final registryId:String;
 
   final dataFilePath:String;
 
+  /**
+   * A map of entry IDs to entries.
+   */
   final entries:Map<String, T>;
 
+  /**
+   * A map of entry IDs to scripted class names.
+   */
+  final scriptedEntryIds:Map<String, String>;
+
   /**
    * The version rule to use when loading entries.
    * If the entry's version does not match this rule, migration is needed.
@@ -37,17 +47,18 @@ abstract class BaseRegistry<T:(IRegistryEntry<J> & Constructible<EntryConstructo
    * @param registryId A readable ID for this registry, used when logging.
    * @param dataFilePath The path (relative to `assets/data`) to search for JSON files.
    */
-  public function new(registryId:String, dataFilePath:String, versionRule:thx.semver.VersionRule = null)
+  public function new(registryId:String, dataFilePath:String, ?versionRule:thx.semver.VersionRule)
   {
     this.registryId = registryId;
     this.dataFilePath = dataFilePath;
-    this.versionRule = versionRule == null ? "1.0.x" : versionRule;
+    this.versionRule = versionRule == null ? '1.0.x' : versionRule;
 
     this.entries = new Map<String, T>();
+    this.scriptedEntryIds = [];
   }
 
   /**
-   * TODO: Create a `loadEntriesAsync()` function.
+   * TODO: Create a `loadEntriesAsync(onProgress, onComplete)` function.
    */
   public function loadEntries():Void
   {
@@ -66,7 +77,7 @@ abstract class BaseRegistry<T:(IRegistryEntry<J> & Constructible<EntryConstructo
       {
         entry = createScriptedEntry(entryCls);
       }
-      catch (e:Dynamic)
+      catch (e)
       {
         log('Failed to create scripted entry (${entryCls})');
         continue;
@@ -76,6 +87,7 @@ abstract class BaseRegistry<T:(IRegistryEntry<J> & Constructible<EntryConstructo
       {
         log('Successfully created scripted entry (${entryCls} = ${entry.id})');
         entries.set(entry.id, entry);
+        scriptedEntryIds.set(entry.id, entryCls);
       }
       else
       {
@@ -102,7 +114,7 @@ abstract class BaseRegistry<T:(IRegistryEntry<J> & Constructible<EntryConstructo
           entries.set(entry.id, entry);
         }
       }
-      catch (e:Dynamic)
+      catch (e)
       {
         // Print the error.
         trace('  Failed to load entry data: ${entryId}');
@@ -130,6 +142,36 @@ abstract class BaseRegistry<T:(IRegistryEntry<J> & Constructible<EntryConstructo
     return entries.size();
   }
 
+  /**
+   * Return whether the entry ID is known to have an attached script.
+   * @param id The ID of the entry.
+   * @return `true` if the entry has an attached script, `false` otherwise.
+   */
+  public function isScriptedEntry(id:String):Bool
+  {
+    return scriptedEntryIds.exists(id);
+  }
+
+  /**
+   * Return the class name of the scripted entry with the given ID, if it exists.
+   * @param id The ID of the entry.
+   * @return The class name, or `null` if it does not exist.
+   */
+  public function getScriptedEntryClassName(id:String):String
+  {
+    return scriptedEntryIds.get(id);
+  }
+
+  /**
+   * Return whether the registry has successfully parsed an entry with the given ID.
+   * @param id The ID of the entry.
+   * @return `true` if the entry exists, `false` otherwise.
+   */
+  public function hasEntry(id:String):Bool
+  {
+    return entries.exists(id);
+  }
+
   /**
    * Fetch an entry by its ID.
    * @param id The ID of the entry to fetch.
@@ -145,6 +187,11 @@ abstract class BaseRegistry<T:(IRegistryEntry<J> & Constructible<EntryConstructo
     return 'Registry(' + registryId + ', ${countEntries()} entries)';
   }
 
+  /**
+   * Retrieve the data for an entry and parse its Semantic Version.
+   * @param id The ID of the entry.
+   * @return The entry's version, or `null` if it does not exist or is invalid.
+   */
   public function fetchEntryVersion(id:String):Null<thx.semver.Version>
   {
     var entryStr:String = loadEntryFile(id).contents;
@@ -185,6 +232,8 @@ abstract class BaseRegistry<T:(IRegistryEntry<J> & Constructible<EntryConstructo
    * Read, parse, and validate the JSON data and produce the corresponding data object.
    *
    * NOTE: Must be implemented on the implementation class.
+   * @param id The ID of the entry.
+   * @return The created entry.
    */
   public abstract function parseEntryData(id:String):Null<J>;
 
@@ -194,6 +243,7 @@ abstract class BaseRegistry<T:(IRegistryEntry<J> & Constructible<EntryConstructo
    * NOTE: Must be implemented on the implementation class.
    * @param contents The JSON as a string.
    * @param fileName An optional file name for error reporting.
+   * @return The created entry.
    */
   public abstract function parseEntryDataRaw(contents:String, ?fileName:String):Null<J>;
 
@@ -202,6 +252,9 @@ abstract class BaseRegistry<T:(IRegistryEntry<J> & Constructible<EntryConstructo
    * accounting for old versions of the data.
    *
    * NOTE: Extend this function to handle migration.
+   * @param id The ID of the entry.
+   * @param version The entry's version (use `fetchEntryVersion(id)`).
+   * @return The created entry.
    */
   public function parseEntryDataWithMigration(id:String, version:thx.semver.Version):Null<J>
   {
@@ -220,12 +273,17 @@ abstract class BaseRegistry<T:(IRegistryEntry<J> & Constructible<EntryConstructo
       throw '[${registryId}] Entry ${id} does not support migration to version ${versionRule}.';
     }
 
-    // Example:
-    // if (VersionUtil.validateVersion(version, "0.1.x")) {
-    //   return parseEntryData_v0_1_x(id);
-    // } else {
-    //   super.parseEntryDataWithMigration(id, version);
-    // }
+    /*
+     * An example of what you should override this with:
+     *
+     * ```haxe
+     * if (VersionUtil.validateVersion(version, "0.1.x")) {
+     *   return parseEntryData_v0_1_x(id);
+     * } else {
+     *   super.parseEntryDataWithMigration(id, version);
+     * }
+     * ```
+     */
   }
 
   /**
@@ -255,10 +313,15 @@ abstract class BaseRegistry<T:(IRegistryEntry<J> & Constructible<EntryConstructo
     trace('[${registryId}] Failed to parse entry data: ${id}');
 
     for (error in errors)
+    {
       DataError.printError(error);
+    }
   }
 }
 
+/**
+ * A pair of a file name and its contents.
+ */
 typedef JsonFile =
 {
   fileName:String,
diff --git a/source/funkin/data/song/SongRegistry.hx b/source/funkin/data/song/SongRegistry.hx
index dad287e82..9f811d45e 100644
--- a/source/funkin/data/song/SongRegistry.hx
+++ b/source/funkin/data/song/SongRegistry.hx
@@ -68,6 +68,7 @@ class SongRegistry extends BaseRegistry<Song, SongMetadata>
       {
         log('Successfully created scripted entry (${entryCls} = ${entry.id})');
         entries.set(entry.id, entry);
+        scriptedEntryIds.set(entry.id, entryCls);
       }
       else
       {
diff --git a/source/funkin/graphics/FunkinSprite.hx b/source/funkin/graphics/FunkinSprite.hx
index f47b4138a..03382f757 100644
--- a/source/funkin/graphics/FunkinSprite.hx
+++ b/source/funkin/graphics/FunkinSprite.hx
@@ -81,9 +81,10 @@ class FunkinSprite extends FlxSprite
    */
   public function loadTexture(key:String):FunkinSprite
   {
-    if (!isTextureCached(key)) FlxG.log.warn('Texture not cached, may experience stuttering! $key');
+    var graphicKey:String = Paths.image(key);
+    if (!isTextureCached(graphicKey)) FlxG.log.warn('Texture not cached, may experience stuttering! $graphicKey');
 
-    loadGraphic(key);
+    loadGraphic(graphicKey);
 
     return this;
   }
@@ -95,7 +96,7 @@ class FunkinSprite extends FlxSprite
    */
   public function loadSparrow(key:String):FunkinSprite
   {
-    var graphicKey = Paths.image(key);
+    var graphicKey:String = Paths.image(key);
     if (!isTextureCached(graphicKey)) FlxG.log.warn('Texture not cached, may experience stuttering! $graphicKey');
 
     this.frames = Paths.getSparrowAtlas(key);
@@ -110,7 +111,7 @@ class FunkinSprite extends FlxSprite
    */
   public function loadPacker(key:String):FunkinSprite
   {
-    var graphicKey = Paths.image(key);
+    var graphicKey:String = Paths.image(key);
     if (!isTextureCached(graphicKey)) FlxG.log.warn('Texture not cached, may experience stuttering! $graphicKey');
 
     this.frames = Paths.getPackerAtlas(key);
diff --git a/source/funkin/play/Countdown.hx b/source/funkin/play/Countdown.hx
index 38e8986ef..747565100 100644
--- a/source/funkin/play/Countdown.hx
+++ b/source/funkin/play/Countdown.hx
@@ -215,7 +215,7 @@ class Countdown
 
     if (spritePath == null) return;
 
-    var countdownSprite:FunkinSprite = FunkinSprite.create(Paths.image(spritePath));
+    var countdownSprite:FunkinSprite = FunkinSprite.create(spritePath);
     countdownSprite.scrollFactor.set(0, 0);
 
     if (isPixelStyle) countdownSprite.setGraphicSize(Std.int(countdownSprite.width * Constants.PIXEL_ART_SCALE));
diff --git a/source/funkin/play/GitarooPause.hx b/source/funkin/play/GitarooPause.hx
index 1ed9dcf3b..eae56a9c3 100644
--- a/source/funkin/play/GitarooPause.hx
+++ b/source/funkin/play/GitarooPause.hx
@@ -28,7 +28,7 @@ class GitarooPause extends MusicBeatState
   {
     if (FlxG.sound.music != null) FlxG.sound.music.stop();
 
-    var bg:FunkinSprite = FunkinSprite.create(Paths.image('pauseAlt/pauseBG'));
+    var bg:FunkinSprite = FunkinSprite.create('pauseAlt/pauseBG');
     add(bg);
 
     var bf:FunkinSprite = FunkinSprite.createSparrow(0, 30, 'pauseAlt/bfLol');
diff --git a/source/funkin/play/PlayState.hx b/source/funkin/play/PlayState.hx
index a6e4b4632..55c54e0fb 100644
--- a/source/funkin/play/PlayState.hx
+++ b/source/funkin/play/PlayState.hx
@@ -1416,7 +1416,7 @@ class PlayState extends MusicBeatSubState
   function initHealthBar():Void
   {
     var healthBarYPos:Float = Preferences.downscroll ? FlxG.height * 0.1 : FlxG.height * 0.9;
-    healthBarBG = FunkinSprite.create(0, healthBarYPos, Paths.image('healthBar'));
+    healthBarBG = FunkinSprite.create(0, healthBarYPos, 'healthBar');
     healthBarBG.screenCenter(X);
     healthBarBG.scrollFactor.set(0, 0);
     healthBarBG.zIndex = 800;
@@ -1453,7 +1453,7 @@ class PlayState extends MusicBeatSubState
   function initMinimalMode():Void
   {
     // Create the green background.
-    var menuBG = FunkinSprite.create(Paths.image('menuDesat'));
+    var menuBG = FunkinSprite.create('menuDesat');
     menuBG.color = 0xFF4CAF50;
     menuBG.setGraphicSize(Std.int(menuBG.width * 1.1));
     menuBG.updateHitbox();
diff --git a/source/funkin/play/components/PopUpStuff.hx b/source/funkin/play/components/PopUpStuff.hx
index 0fe50f513..105fce2b8 100644
--- a/source/funkin/play/components/PopUpStuff.hx
+++ b/source/funkin/play/components/PopUpStuff.hx
@@ -25,7 +25,7 @@ class PopUpStuff extends FlxTypedGroup<FlxSprite>
 
     if (PlayState.instance.currentStageId.startsWith('school')) ratingPath = "weeb/pixelUI/" + ratingPath + "-pixel";
 
-    var rating:FunkinSprite = FunkinSprite.create(0, 0, Paths.image(ratingPath));
+    var rating:FunkinSprite = FunkinSprite.create(0, 0, ratingPath);
     rating.scrollFactor.set(0.2, 0.2);
 
     rating.zIndex = 1000;
@@ -76,7 +76,7 @@ class PopUpStuff extends FlxTypedGroup<FlxSprite>
       pixelShitPart1 = 'weeb/pixelUI/';
       pixelShitPart2 = '-pixel';
     }
-    var comboSpr:FunkinSprite = FunkinSprite.create(Paths.image(pixelShitPart1 + 'combo' + pixelShitPart2));
+    var comboSpr:FunkinSprite = FunkinSprite.create(pixelShitPart1 + 'combo' + pixelShitPart2);
     comboSpr.y = FlxG.camera.height * 0.4 + 80;
     comboSpr.x = FlxG.width * 0.50;
     // comboSpr.x -= FlxG.camera.scroll.x * 0.2;
@@ -124,7 +124,7 @@ class PopUpStuff extends FlxTypedGroup<FlxSprite>
     var daLoop:Int = 1;
     for (i in seperatedScore)
     {
-      var numScore:FunkinSprite = FunkinSprite.create(0, comboSpr.y, Paths.image(pixelShitPart1 + 'num' + Std.int(i) + pixelShitPart2));
+      var numScore:FunkinSprite = FunkinSprite.create(0, comboSpr.y, pixelShitPart1 + 'num' + Std.int(i) + pixelShitPart2);
 
       if (PlayState.instance.currentStageId.startsWith('school'))
       {
diff --git a/source/funkin/play/song/Song.hx b/source/funkin/play/song/Song.hx
index 3997692c2..1b7740408 100644
--- a/source/funkin/play/song/Song.hx
+++ b/source/funkin/play/song/Song.hx
@@ -1,6 +1,5 @@
 package funkin.play.song;
 
-import flixel.sound.FlxSound;
 import funkin.audio.VoicesGroup;
 import funkin.audio.FunkinSound;
 import funkin.data.IRegistryEntry;
@@ -13,9 +12,8 @@ import funkin.data.song.SongData.SongOffsets;
 import funkin.data.song.SongData.SongTimeChange;
 import funkin.data.song.SongData.SongTimeFormat;
 import funkin.data.song.SongRegistry;
-import funkin.data.song.SongRegistry;
+import funkin.modding.IScriptedClass.IPlayStateScriptedClass;
 import funkin.modding.events.ScriptEvent;
-import funkin.modding.IScriptedClass;
 import funkin.util.SortUtil;
 import openfl.utils.Assets;
 
@@ -31,14 +29,44 @@ import openfl.utils.Assets;
 @:nullSafety
 class Song implements IPlayStateScriptedClass implements IRegistryEntry<SongMetadata>
 {
-  public static final DEFAULT_SONGNAME:String = "Unknown";
-  public static final DEFAULT_ARTIST:String = "Unknown";
+  /**
+   * The default value for the song's name
+   */
+  public static final DEFAULT_SONGNAME:String = 'Unknown';
+
+  /**
+   * The default value for the song's artist
+   */
+  public static final DEFAULT_ARTIST:String = 'Unknown';
+
+  /**
+   * The default value for the song's time format
+   */
   public static final DEFAULT_TIMEFORMAT:SongTimeFormat = SongTimeFormat.MILLISECONDS;
+
+  /**
+   * The default value for the song's divisions
+   */
   public static final DEFAULT_DIVISIONS:Null<Int> = null;
+
+  /**
+   * The default value for whether the song loops.
+   */
   public static final DEFAULT_LOOPED:Bool = false;
-  public static final DEFAULT_STAGE:String = "mainStage";
+
+  /**
+   * The default value for the song's playable stage.
+   */
+  public static final DEFAULT_STAGE:String = 'mainStage';
+
+  /**
+   * The default value for the song's scroll speed.
+   */
   public static final DEFAULT_SCROLLSPEED:Float = 1.0;
 
+  /**
+   * The internal ID of the song.
+   */
   public final id:String;
 
   /**
@@ -53,6 +81,9 @@ class Song implements IPlayStateScriptedClass implements IRegistryEntry<SongMeta
   final _metadata:Map<String, SongMetadata>;
   final difficulties:Map<String, SongDifficulty>;
 
+  /**
+   * The list of variations a song has.
+   */
   public var variations(get, never):Array<String>;
 
   function get_variations():Array<String>
@@ -65,6 +96,9 @@ class Song implements IPlayStateScriptedClass implements IRegistryEntry<SongMeta
    */
   public var validScore:Bool = true;
 
+  /**
+   * The readable name of the song.
+   */
   public var songName(get, never):String;
 
   function get_songName():String
@@ -74,6 +108,9 @@ class Song implements IPlayStateScriptedClass implements IRegistryEntry<SongMeta
     return DEFAULT_SONGNAME;
   }
 
+  /**
+   * The artist of the song.
+   */
   public var songArtist(get, never):String;
 
   function get_songArtist():String
@@ -101,7 +138,7 @@ class Song implements IPlayStateScriptedClass implements IRegistryEntry<SongMeta
     {
       for (vari in _data.playData.songVariations)
       {
-        var variMeta = fetchVariationMetadata(id, vari);
+        var variMeta:Null<SongMetadata> = fetchVariationMetadata(id, vari);
         if (variMeta != null) _metadata.set(variMeta.variation, variMeta);
       }
     }
@@ -115,27 +152,62 @@ class Song implements IPlayStateScriptedClass implements IRegistryEntry<SongMeta
     populateDifficulties();
   }
 
-  @:allow(funkin.play.song.Song)
+  /**
+   * Build a song from existing metadata rather than loading it from the `assets` folder.
+   * Used by the Chart Editor.
+   *
+   * @param songId The ID of the song.
+   * @param metadata The metadata of the song.
+   * @param variations The list of variations this song has.
+   * @param charts The chart data for each variation.
+   * @param includeScript Whether to initialize the scripted class tied to the song, if it exists.
+   * @param validScore Whether the song is elegible for highscores.
+   * @return The constructed song object.
+   */
   public static function buildRaw(songId:String, metadata:Array<SongMetadata>, variations:Array<String>, charts:Map<String, SongChartData>,
-      validScore:Bool = false):Song
+      includeScript:Bool = true, validScore:Bool = false):Song
   {
-    var result:Song = new Song(songId);
+    @:privateAccess
+    var result:Null<Song>;
+
+    if (includeScript && SongRegistry.instance.isScriptedEntry(songId))
+    {
+      var songClassName:String = SongRegistry.instance.getScriptedEntryClassName(songId);
+
+      @:privateAccess
+      result = SongRegistry.instance.createScriptedEntry(songClassName);
+    }
+    else
+    {
+      @:privateAccess
+      result = SongRegistry.instance.createEntry(songId);
+    }
+
+    if (result == null) throw 'ERROR: Could not build Song instance ($songId), is the attached script bad?';
 
     result._metadata.clear();
     for (meta in metadata)
+    {
       result._metadata.set(meta.variation, meta);
+    }
 
     result.difficulties.clear();
     result.populateDifficulties();
 
     for (variation => chartData in charts)
+    {
       result.applyChartData(chartData, variation);
+    }
 
     result.validScore = validScore;
 
     return result;
   }
 
+  /**
+   * Retrieve a list of the raw metadata for the song.
+   * @return The metadata JSON objects for the song's variations.
+   */
   public function getRawMetadata():Array<SongMetadata>
   {
     return _metadata.values();
@@ -192,6 +264,7 @@ class Song implements IPlayStateScriptedClass implements IRegistryEntry<SongMeta
 
   /**
    * Parse and cache the chart for all difficulties of this song.
+   * @param force Whether to forcibly clear the list of charts first.
    */
   public function cacheCharts(force:Bool = false):Void
   {
diff --git a/source/funkin/play/stage/Stage.hx b/source/funkin/play/stage/Stage.hx
index 9605c6989..56026469a 100644
--- a/source/funkin/play/stage/Stage.hx
+++ b/source/funkin/play/stage/Stage.hx
@@ -212,7 +212,7 @@ class Stage extends FlxSpriteGroup implements IPlayStateScriptedClass implements
       else
       {
         // Initalize static sprite.
-        propSprite.loadTexture(Paths.image(dataProp.assetPath));
+        propSprite.loadTexture(dataProp.assetPath);
 
         // Disables calls to update() for a performance boost.
         propSprite.active = false;
diff --git a/source/funkin/ui/debug/charting/ChartEditorState.hx b/source/funkin/ui/debug/charting/ChartEditorState.hx
index 942f28297..1eb2a0b02 100644
--- a/source/funkin/ui/debug/charting/ChartEditorState.hx
+++ b/source/funkin/ui/debug/charting/ChartEditorState.hx
@@ -602,6 +602,12 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
    */
   var enabledDebuggerPopup:Bool = true;
 
+  /**
+   * Whether song scripts should be enabled during playtesting.
+   * You should probably check the box if the song has custom mechanics.
+   */
+  var playtestSongScripts:Bool = true;
+
   // Visuals
 
   /**
@@ -1396,7 +1402,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
 
   function get_currentSongId():String
   {
-    return currentSongName.toLowerKebabCase().replace('.', '').replace(' ', '-');
+    return currentSongName.toLowerKebabCase().replace(' ', '-').sanitize();
   }
 
   var currentSongArtist(get, set):String;
@@ -5320,7 +5326,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
     var targetSong:Song;
     try
     {
-      targetSong = Song.buildRaw(currentSongId, songMetadata.values(), availableVariations, songChartData, false);
+      targetSong = Song.buildRaw(currentSongId, songMetadata.values(), availableVariations, songChartData, playtestSongScripts, false);
     }
     catch (e)
     {
@@ -5348,7 +5354,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
         Paths.setCurrentLevel('week6');
       case 'tankmanBattlefield':
         Paths.setCurrentLevel('week7');
-      case 'phillyStreets' | 'phillyBlazin':
+      case 'phillyStreets' | 'phillyBlazin' | 'phillyBlazin2':
         Paths.setCurrentLevel('weekend1');
     }
 
diff --git a/source/funkin/ui/debug/charting/handlers/ChartEditorToolboxHandler.hx b/source/funkin/ui/debug/charting/handlers/ChartEditorToolboxHandler.hx
index 3b32edf5d..8c7b1a8c1 100644
--- a/source/funkin/ui/debug/charting/handlers/ChartEditorToolboxHandler.hx
+++ b/source/funkin/ui/debug/charting/handlers/ChartEditorToolboxHandler.hx
@@ -318,6 +318,17 @@ class ChartEditorToolboxHandler
       state.enabledDebuggerPopup = checkboxDebugger.selected;
     };
 
+    var checkboxSongScripts:Null<CheckBox> = toolbox.findComponent('playtestSongScriptsCheckbox', CheckBox);
+
+    if (checkboxSongScripts == null)
+      throw 'ChartEditorToolboxHandler.buildToolboxPlaytestPropertiesLayout() - Could not find playtestSongScriptsCheckbox component.';
+
+    state.playtestSongScripts = checkboxSongScripts.selected;
+
+    checkboxSongScripts.onClick = _ -> {
+      state.playtestSongScripts = checkboxSongScripts.selected;
+    };
+
     return toolbox;
   }
 
diff --git a/source/funkin/ui/freeplay/FreeplayState.hx b/source/funkin/ui/freeplay/FreeplayState.hx
index 45f9a4d27..24008b19d 100644
--- a/source/funkin/ui/freeplay/FreeplayState.hx
+++ b/source/funkin/ui/freeplay/FreeplayState.hx
@@ -227,7 +227,7 @@ class FreeplayState extends MusicBeatSubState
     trace(FlxG.camera.initialZoom);
     trace(FlxCamera.defaultZoom);
 
-    var pinkBack:FunkinSprite = FunkinSprite.create(Paths.image('freeplay/pinkBack'));
+    var pinkBack:FunkinSprite = FunkinSprite.create('freeplay/pinkBack');
     pinkBack.color = 0xFFffd4e9; // sets it to pink!
     pinkBack.x -= pinkBack.width;
 
diff --git a/source/funkin/ui/transition/LoadingState.hx b/source/funkin/ui/transition/LoadingState.hx
index 5f755872f..e2f89a8b3 100644
--- a/source/funkin/ui/transition/LoadingState.hx
+++ b/source/funkin/ui/transition/LoadingState.hx
@@ -48,7 +48,7 @@ class LoadingState extends MusicBeatState
     var bg:FunkinSprite = new FunkinSprite().makeSolidColor(FlxG.width, FlxG.height, 0xFFcaff4d);
     add(bg);
 
-    funkay = FunkinSprite.create(Paths.image('funkay'));
+    funkay = FunkinSprite.create('funkay');
     funkay.setGraphicSize(0, FlxG.height);
     funkay.updateHitbox();
     add(funkay);
@@ -389,9 +389,15 @@ class MultiCallback
   public function getUnfired():Array<Void->Void>
     return unfired.array();
 
+  /**
+   * Perform an FlxG.switchState with a nice transition
+   * @param state
+   * @param transitionTex
+   * @param time
+   */
   public static function coolSwitchState(state:NextState, transitionTex:String = "shaderTransitionStuff/coolDots", time:Float = 2)
   {
-    var screenShit:FunkinSprite = FunkinSprite.create(Paths.image("shaderTransitionStuff/coolDots"));
+    var screenShit:FunkinSprite = FunkinSprite.create('shaderTransitionStuff/coolDots');
     var screenWipeShit:ScreenWipeShader = new ScreenWipeShader();
 
     screenWipeShit.funnyShit.input = screenShit.pixels;
diff --git a/source/funkin/ui/transition/StickerSubState.hx b/source/funkin/ui/transition/StickerSubState.hx
index 40fce6f7d..981a30e09 100644
--- a/source/funkin/ui/transition/StickerSubState.hx
+++ b/source/funkin/ui/transition/StickerSubState.hx
@@ -313,7 +313,7 @@ class StickerSprite extends FunkinSprite
   public function new(x:Float, y:Float, stickerSet:String, stickerName:String):Void
   {
     super(x, y);
-    loadTexture(Paths.image('transitionSwag/' + stickerSet + '/' + stickerName));
+    loadTexture('transitionSwag/' + stickerSet + '/' + stickerName);
     updateHitbox();
     scrollFactor.set();
   }
diff --git a/source/funkin/util/tools/StringTools.hx b/source/funkin/util/tools/StringTools.hx
index 0585ffeae..b15808d00 100644
--- a/source/funkin/util/tools/StringTools.hx
+++ b/source/funkin/util/tools/StringTools.hx
@@ -13,15 +13,15 @@ class StringTools
    */
   public static function toTitleCase(value:String):String
   {
-    var words:Array<String> = value.split(" ");
-    var result:String = "";
+    var words:Array<String> = value.split(' ');
+    var result:String = '';
     for (i in 0...words.length)
     {
       var word:String = words[i];
       result += word.charAt(0).toUpperCase() + word.substr(1).toLowerCase();
       if (i < words.length - 1)
       {
-        result += " ";
+        result += ' ';
       }
     }
     return result;
@@ -35,7 +35,7 @@ class StringTools
    */
   public static function toLowerKebabCase(value:String):String
   {
-    return value.toLowerCase().replace(' ', "-");
+    return value.toLowerCase().replace(' ', '-');
   }
 
   /**
@@ -46,13 +46,30 @@ class StringTools
    */
   public static function toUpperKebabCase(value:String):String
   {
-    return value.toUpperCase().replace(' ', "-");
+    return value.toUpperCase().replace(' ', '-');
+  }
+
+  /**
+   * The regular expression to sanitize strings.
+   */
+  static final SANTIZE_REGEX:EReg = ~/[^-a-zA-Z0-9]/g;
+
+  /**
+   * Remove all instances of symbols other than alpha-numeric characters (and dashes)from a string.
+   * @param value The string to sanitize.
+   * @return The sanitized string.
+   */
+  public static function sanitize(value:String):String
+  {
+    return SANTIZE_REGEX.replace(value, '');
   }
 
   /**
    * Parses the string data as JSON and returns the resulting object.
    * This is here so you can use `string.parseJSON()` when `using StringTools`.
    *
+   * TODO: Remove this and replace with `json2object`
+   * @param value The
    * @return The parsed object.
    */
   public static function parseJSON(value:String):Dynamic

From 43cf1e71e2785ff95c0de779d6af1032140ecdc4 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Tue, 12 Mar 2024 21:35:55 -0400
Subject: [PATCH 14/25] Disable camera events in the minimal playtest.

---
 source/funkin/play/event/FocusCameraSongEvent.hx | 3 +++
 source/funkin/play/event/ZoomCameraSongEvent.hx  | 5 ++++-
 2 files changed, 7 insertions(+), 1 deletion(-)

diff --git a/source/funkin/play/event/FocusCameraSongEvent.hx b/source/funkin/play/event/FocusCameraSongEvent.hx
index 847df4a60..4ea6fa8c0 100644
--- a/source/funkin/play/event/FocusCameraSongEvent.hx
+++ b/source/funkin/play/event/FocusCameraSongEvent.hx
@@ -57,6 +57,9 @@ class FocusCameraSongEvent extends SongEvent
     // Does nothing if there is no PlayState camera or stage.
     if (PlayState.instance == null || PlayState.instance.currentStage == null) return;
 
+    // Does nothing if we are minimal mode.
+    if (PlayState.instance.minimalMode) return;
+
     var posX:Null<Float> = data.getFloat('x');
     if (posX == null) posX = 0.0;
     var posY:Null<Float> = data.getFloat('y');
diff --git a/source/funkin/play/event/ZoomCameraSongEvent.hx b/source/funkin/play/event/ZoomCameraSongEvent.hx
index 809130499..3a903a4ff 100644
--- a/source/funkin/play/event/ZoomCameraSongEvent.hx
+++ b/source/funkin/play/event/ZoomCameraSongEvent.hx
@@ -55,7 +55,10 @@ class ZoomCameraSongEvent extends SongEvent
   public override function handleEvent(data:SongEventData):Void
   {
     // Does nothing if there is no PlayState camera or stage.
-    if (PlayState.instance == null) return;
+    if (PlayState.instance == null || PlayState.instance.currentStage == null) return;
+
+    // Does nothing if we are minimal mode.
+    if (PlayState.instance.minimalMode) return;
 
     var zoom:Null<Float> = data.getFloat('zoom');
     if (zoom == null) zoom = 1.0;

From 541c7b40414a191b0464f57e0cea6e4948d122ec Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Tue, 12 Mar 2024 21:36:32 -0400
Subject: [PATCH 15/25] Update assets submodule

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

diff --git a/assets b/assets
index fe8c987eb..b71005291 160000
--- a/assets
+++ b/assets
@@ -1 +1 @@
-Subproject commit fe8c987eb846ceb73b8518879b506111aaccdf80
+Subproject commit b71005291132a09043cabb59511d9316a21039ca

From a13dc6e585c061252a01f122bca9946c60cb1eff Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Wed, 13 Mar 2024 12:07:06 -0400
Subject: [PATCH 16/25] Remove deprecated imports.

---
 source/funkin/audio/visualize/SpectogramSprite.hx | 1 -
 source/funkin/ui/freeplay/DJBoyfriend.hx          | 2 +-
 2 files changed, 1 insertion(+), 2 deletions(-)

diff --git a/source/funkin/audio/visualize/SpectogramSprite.hx b/source/funkin/audio/visualize/SpectogramSprite.hx
index 470dbf7fe..636c0726a 100644
--- a/source/funkin/audio/visualize/SpectogramSprite.hx
+++ b/source/funkin/audio/visualize/SpectogramSprite.hx
@@ -4,7 +4,6 @@ import flixel.FlxSprite;
 import flixel.group.FlxSpriteGroup.FlxTypedSpriteGroup;
 import flixel.math.FlxMath;
 import flixel.math.FlxPoint;
-import flixel.math.FlxVector;
 import flixel.sound.FlxSound;
 import flixel.util.FlxColor;
 import funkin.audio.visualize.PolygonSpectogram.VISTYPE;
diff --git a/source/funkin/ui/freeplay/DJBoyfriend.hx b/source/funkin/ui/freeplay/DJBoyfriend.hx
index 9d37fe2c1..55f43d2ef 100644
--- a/source/funkin/ui/freeplay/DJBoyfriend.hx
+++ b/source/funkin/ui/freeplay/DJBoyfriend.hx
@@ -4,7 +4,7 @@ import flixel.FlxSprite;
 import flixel.util.FlxSignal;
 import funkin.util.assets.FlxAnimationUtil;
 import funkin.graphics.adobeanimate.FlxAtlasSprite;
-import flixel.system.FlxSound;
+import flixel.sound.FlxSound;
 import flixel.util.FlxTimer;
 import funkin.audio.FlxStreamSound;
 

From c67e64a8f5f128034d289b9db6e98b91951e6f02 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Wed, 13 Mar 2024 12:07:19 -0400
Subject: [PATCH 17/25] Update Flixel and assets submodule

---
 assets   | 2 +-
 hmm.json | 2 +-
 2 files changed, 2 insertions(+), 2 deletions(-)

diff --git a/assets b/assets
index fe8c987eb..46bad6850 160000
--- a/assets
+++ b/assets
@@ -1 +1 @@
-Subproject commit fe8c987eb846ceb73b8518879b506111aaccdf80
+Subproject commit 46bad6850be34afa3742640f57da07d89d5573b8
diff --git a/hmm.json b/hmm.json
index cbd5fea30..42d17743f 100644
--- a/hmm.json
+++ b/hmm.json
@@ -11,7 +11,7 @@
       "name": "flixel",
       "type": "git",
       "dir": null,
-      "ref": "4d054bd10b05bb1309a0ba3427ffa5378e0b4b99",
+      "ref": "5823c46b4e7410372d58b99f8d5c52fc18b3cb3d",
       "url": "https://github.com/FunkinCrew/flixel"
     },
     {

From 9328ac84cc42548a80c7a9e50ef0903fd5d52c56 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Wed, 13 Mar 2024 12:57:46 -0400
Subject: [PATCH 18/25] New lightning graphic

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

diff --git a/assets b/assets
index fe8c987eb..64586275f 160000
--- a/assets
+++ b/assets
@@ -1 +1 @@
-Subproject commit fe8c987eb846ceb73b8518879b506111aaccdf80
+Subproject commit 64586275f5e47960242faa687f9a26e52fcf2f9a

From 238a11debdff15c891d7957a1e18823e34920a7e Mon Sep 17 00:00:00 2001
From: Hazel <hazel@farfrom.earth>
Date: Wed, 13 Mar 2024 21:15:22 +0100
Subject: [PATCH 19/25] retrigger ci


From d22cb1a96f354b2ec650a38ec937cd9fac955aee Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Wed, 13 Mar 2024 17:38:00 -0400
Subject: [PATCH 20/25] Fix some issues resulting in release builds breaking!

---
 .vscode/settings.json                                 | 10 ++++++++++
 Project.xml                                           | 11 +++++++----
 assets                                                |  2 +-
 source/funkin/audio/visualize/SpectogramSprite.hx     |  1 -
 .../charting/handlers/ChartEditorDialogHandler.hx     |  9 +++++++--
 source/funkin/ui/freeplay/DJBoyfriend.hx              |  2 +-
 6 files changed, 26 insertions(+), 9 deletions(-)

diff --git a/.vscode/settings.json b/.vscode/settings.json
index 87ed06aed..fa036f0e9 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -180,6 +180,16 @@
       "target": "windows",
       "args": ["-debug", "-DWAVEFORM", "-DFORCE_DEBUG_VERSION"]
     },
+    {
+      "label": "Windows / Release",
+      "target": "windows",
+      "args": ["-release"]
+    },
+    {
+      "label": "Windows / Release (GitHub Actions)",
+      "target": "windows",
+      "args": ["-release", "-DGITHUB_BUILD"]
+    },
     {
       "label": "HashLink / Debug (Waveform Test)",
       "target": "hl",
diff --git a/Project.xml b/Project.xml
index 99c46ef9f..76d9bbe5e 100644
--- a/Project.xml
+++ b/Project.xml
@@ -91,8 +91,11 @@
 		NOT USING A DIRECT THING TO THE ASSET!!!
 	-->
 	<assets path="assets/fonts" embed="true" />
-	<!-- If compiled via github actions, enable force debug -->
-	<set name="FORCE_DEBUG_VERSION" if="GITHUB_BUILD"/>
+
+	<!-- If compiled via github actions, show debug version number. -->
+	<define name="FORCE_DEBUG_VERSION" if="GITHUB_BUILD" />
+	<define name="NO_REDIRECT_ASSETS_FOLDER" if="GITHUB_BUILD" />
+
 	<!-- _______________________________ Libraries ______________________________ -->
 	<haxelib name="lime" /> <!-- Game engine backend -->
 	<haxelib name="openfl" /> <!-- Game engine backend -->
@@ -216,9 +219,9 @@
 	<postbuild haxe="source/Postbuild.hx"/> -->
 
 	<!-- Enable this on platforms which do not support dropping files onto the window. -->
-	<set name="FILE_DROP_UNSUPPORTED" if="mac" />
+	<haxedef name="FILE_DROP_UNSUPPORTED" if="mac" />
 	<section unless="FILE_DROP_UNSUPPORTED">
-		<set name="FILE_DROP_SUPPORTED" />
+		<haxedef name="FILE_DROP_SUPPORTED" />
 	</section>
 
 	<!-- Options for Polymod -->
diff --git a/assets b/assets
index fe8c987eb..a9c472d5d 160000
--- a/assets
+++ b/assets
@@ -1 +1 @@
-Subproject commit fe8c987eb846ceb73b8518879b506111aaccdf80
+Subproject commit a9c472d5d3ae1729dc109ab50c2f9824dbb9e5e7
diff --git a/source/funkin/audio/visualize/SpectogramSprite.hx b/source/funkin/audio/visualize/SpectogramSprite.hx
index 470dbf7fe..636c0726a 100644
--- a/source/funkin/audio/visualize/SpectogramSprite.hx
+++ b/source/funkin/audio/visualize/SpectogramSprite.hx
@@ -4,7 +4,6 @@ import flixel.FlxSprite;
 import flixel.group.FlxSpriteGroup.FlxTypedSpriteGroup;
 import flixel.math.FlxMath;
 import flixel.math.FlxPoint;
-import flixel.math.FlxVector;
 import flixel.sound.FlxSound;
 import flixel.util.FlxColor;
 import funkin.audio.visualize.PolygonSpectogram.VISTYPE;
diff --git a/source/funkin/ui/debug/charting/handlers/ChartEditorDialogHandler.hx b/source/funkin/ui/debug/charting/handlers/ChartEditorDialogHandler.hx
index 970f021ac..b84c68f8d 100644
--- a/source/funkin/ui/debug/charting/handlers/ChartEditorDialogHandler.hx
+++ b/source/funkin/ui/debug/charting/handlers/ChartEditorDialogHandler.hx
@@ -808,7 +808,8 @@ class ChartEditorDialogHandler
         }
         songVariationMetadataEntry.onClick = onClickMetadataVariation.bind(variation).bind(songVariationMetadataEntryLabel);
         #if FILE_DROP_SUPPORTED
-        addDropHandler(songVariationMetadataEntry, onDropFileMetadataVariation.bind(variation).bind(songVariationMetadataEntryLabel));
+        state.addDropHandler({component: songVariationMetadataEntry, handler: onDropFileMetadataVariation.bind(variation)
+          .bind(songVariationMetadataEntryLabel)});
         #end
         chartContainerB.addComponent(songVariationMetadataEntry);
 
@@ -832,7 +833,11 @@ class ChartEditorDialogHandler
         }
         songVariationChartDataEntry.onClick = onClickChartDataVariation.bind(variation).bind(songVariationChartDataEntryLabel);
         #if FILE_DROP_SUPPORTED
-        addDropHandler(songVariationChartDataEntry, onDropFileChartDataVariation.bind(variation).bind(songVariationChartDataEntryLabel));
+        state.addDropHandler(
+          {
+            component: songVariationChartDataEntry,
+            handler: onDropFileChartDataVariation.bind(variation).bind(songVariationChartDataEntryLabel)
+          });
         #end
         chartContainerB.addComponent(songVariationChartDataEntry);
       }
diff --git a/source/funkin/ui/freeplay/DJBoyfriend.hx b/source/funkin/ui/freeplay/DJBoyfriend.hx
index 9d37fe2c1..55f43d2ef 100644
--- a/source/funkin/ui/freeplay/DJBoyfriend.hx
+++ b/source/funkin/ui/freeplay/DJBoyfriend.hx
@@ -4,7 +4,7 @@ import flixel.FlxSprite;
 import flixel.util.FlxSignal;
 import funkin.util.assets.FlxAnimationUtil;
 import funkin.graphics.adobeanimate.FlxAtlasSprite;
-import flixel.system.FlxSound;
+import flixel.sound.FlxSound;
 import flixel.util.FlxTimer;
 import funkin.audio.FlxStreamSound;
 

From b9ab38e0b9fb4d9738518d6c978397bf9f9e9206 Mon Sep 17 00:00:00 2001
From: Cameron Taylor <cameron.taylor.ninja@gmail.com>
Date: Wed, 13 Mar 2024 18:47:15 -0700
Subject: [PATCH 21/25] timertools -> timerutil fix

---
 source/funkin/InitState.hx                    |  6 +--
 .../audio/waveform/WaveformDataParser.hx      |  6 +--
 source/funkin/play/components/PopUpStuff.hx   | 10 ++---
 .../handlers/ChartEditorAudioHandler.hx       | 44 +++++++++----------
 .../toolboxes/ChartEditorFreeplayToolbox.hx   |  6 +--
 source/funkin/util/TimerUtil.hx               |  4 +-
 source/funkin/util/plugins/MemoryGCPlugin.hx  |  6 +--
 7 files changed, 41 insertions(+), 41 deletions(-)

diff --git a/source/funkin/InitState.hx b/source/funkin/InitState.hx
index 9ecf66ec7..8c6af4e51 100644
--- a/source/funkin/InitState.hx
+++ b/source/funkin/InitState.hx
@@ -30,7 +30,7 @@ import funkin.modding.module.ModuleHandler;
 import funkin.ui.title.TitleState;
 import funkin.util.CLIUtil;
 import funkin.util.CLIUtil.CLIParams;
-import funkin.util.tools.TimerTools;
+import funkin.util.TimerUtil;
 import funkin.ui.transition.LoadingState;
 import funkin.util.TrackerUtil;
 #if discord_rpc
@@ -221,7 +221,7 @@ class InitState extends FlxState
     // NOTE: Registries must be imported and not referenced with fully qualified names,
     // to ensure build macros work properly.
     trace('Parsing game data...');
-    var perfStart:Float = TimerTools.start();
+    var perfStart:Float = TimerUtil.start();
     SongEventRegistry.loadEventCache(); // SongEventRegistry is structured differently so it's not a BaseRegistry.
     SongRegistry.instance.loadEntries();
     LevelRegistry.instance.loadEntries();
@@ -238,7 +238,7 @@ class InitState extends FlxState
     ModuleHandler.loadModuleCache();
     ModuleHandler.callOnCreate();
 
-    trace('Parsing game data took: ${TimerTools.ms(perfStart)}');
+    trace('Parsing game data took: ${TimerUtil.ms(perfStart)}');
   }
 
   /**
diff --git a/source/funkin/audio/waveform/WaveformDataParser.hx b/source/funkin/audio/waveform/WaveformDataParser.hx
index c667f2002..5aa54d744 100644
--- a/source/funkin/audio/waveform/WaveformDataParser.hx
+++ b/source/funkin/audio/waveform/WaveformDataParser.hx
@@ -1,6 +1,6 @@
 package funkin.audio.waveform;
 
-import funkin.util.tools.TimerTools;
+import funkin.util.TimerUtil;
 
 class WaveformDataParser
 {
@@ -73,7 +73,7 @@ class WaveformDataParser
 
     var outputData:Array<Int> = [];
 
-    var perfStart:Float = TimerTools.start();
+    var perfStart:Float = TimerUtil.start();
 
     for (pointIndex in 0...outputPointCount)
     {
@@ -110,7 +110,7 @@ class WaveformDataParser
     var outputDataLength:Int = Std.int(outputData.length / channels / 2);
     var result = new WaveformData(null, channels, sampleRate, samplesPerPoint, bitsPerSample, outputPointCount, outputData);
 
-    trace('[WAVEFORM] Interpreted audio buffer in ${TimerTools.seconds(perfStart)}.');
+    trace('[WAVEFORM] Interpreted audio buffer in ${TimerUtil.seconds(perfStart)}.');
 
     return result;
   }
diff --git a/source/funkin/play/components/PopUpStuff.hx b/source/funkin/play/components/PopUpStuff.hx
index 0fe50f513..69ce40140 100644
--- a/source/funkin/play/components/PopUpStuff.hx
+++ b/source/funkin/play/components/PopUpStuff.hx
@@ -6,7 +6,7 @@ import flixel.tweens.FlxTween;
 import flixel.util.FlxDirection;
 import funkin.graphics.FunkinSprite;
 import funkin.play.PlayState;
-import funkin.util.tools.TimerTools;
+import funkin.util.TimerUtil;
 
 class PopUpStuff extends FlxTypedGroup<FlxSprite>
 {
@@ -17,7 +17,7 @@ class PopUpStuff extends FlxTypedGroup<FlxSprite>
 
   public function displayRating(daRating:String)
   {
-    var perfStart:Float = TimerTools.start();
+    var perfStart:Float = TimerUtil.start();
 
     if (daRating == null) daRating = "good";
 
@@ -59,12 +59,12 @@ class PopUpStuff extends FlxTypedGroup<FlxSprite>
         startDelay: Conductor.instance.beatLengthMs * 0.001
       });
 
-    trace('displayRating took: ${TimerTools.seconds(perfStart)}');
+    trace('displayRating took: ${TimerUtil.seconds(perfStart)}');
   }
 
   public function displayCombo(?combo:Int = 0):Int
   {
-    var perfStart:Float = TimerTools.start();
+    var perfStart:Float = TimerUtil.start();
 
     if (combo == null) combo = 0;
 
@@ -157,7 +157,7 @@ class PopUpStuff extends FlxTypedGroup<FlxSprite>
       daLoop++;
     }
 
-    trace('displayCombo took: ${TimerTools.seconds(perfStart)}');
+    trace('displayCombo took: ${TimerUtil.seconds(perfStart)}');
 
     return combo;
   }
diff --git a/source/funkin/ui/debug/charting/handlers/ChartEditorAudioHandler.hx b/source/funkin/ui/debug/charting/handlers/ChartEditorAudioHandler.hx
index 889f5764f..1e1d165f3 100644
--- a/source/funkin/ui/debug/charting/handlers/ChartEditorAudioHandler.hx
+++ b/source/funkin/ui/debug/charting/handlers/ChartEditorAudioHandler.hx
@@ -7,7 +7,7 @@ import funkin.audio.FunkinSound;
 import funkin.play.character.BaseCharacter.CharacterType;
 import funkin.util.FileUtil;
 import funkin.util.assets.SoundUtil;
-import funkin.util.tools.TimerTools;
+import funkin.util.TimerUtil;
 import funkin.audio.waveform.WaveformData;
 import funkin.audio.waveform.WaveformDataParser;
 import funkin.audio.waveform.WaveformSprite;
@@ -129,41 +129,41 @@ class ChartEditorAudioHandler
 
   public static function switchToInstrumental(state:ChartEditorState, instId:String = '', playerId:String, opponentId:String):Bool
   {
-    var perfA:Float = TimerTools.start();
+    var perfA:Float = TimerUtil.start();
 
     var result:Bool = playInstrumental(state, instId);
     if (!result) return false;
 
-    var perfB:Float = TimerTools.start();
+    var perfB:Float = TimerUtil.start();
 
     stopExistingVocals(state);
 
-    var perfC:Float = TimerTools.start();
+    var perfC:Float = TimerUtil.start();
 
     result = playVocals(state, BF, playerId, instId);
 
-    var perfD:Float = TimerTools.start();
+    var perfD:Float = TimerUtil.start();
 
     // if (!result) return false;
     result = playVocals(state, DAD, opponentId, instId);
     // if (!result) return false;
 
-    var perfE:Float = TimerTools.start();
+    var perfE:Float = TimerUtil.start();
 
     state.hardRefreshOffsetsToolbox();
 
-    var perfF:Float = TimerTools.start();
+    var perfF:Float = TimerUtil.start();
 
     state.hardRefreshFreeplayToolbox();
 
-    var perfG:Float = TimerTools.start();
+    var perfG:Float = TimerUtil.start();
 
-    trace('Switched to instrumental in ${TimerTools.seconds(perfA, perfB)}.');
-    trace('Stopped existing vocals in ${TimerTools.seconds(perfB, perfC)}.');
-    trace('Played BF vocals in ${TimerTools.seconds(perfC, perfD)}.');
-    trace('Played DAD vocals in ${TimerTools.seconds(perfD, perfE)}.');
-    trace('Hard refreshed offsets toolbox in ${TimerTools.seconds(perfE, perfF)}.');
-    trace('Hard refreshed freeplay toolbox in ${TimerTools.seconds(perfF, perfG)}.');
+    trace('Switched to instrumental in ${TimerUtil.seconds(perfA, perfB)}.');
+    trace('Stopped existing vocals in ${TimerUtil.seconds(perfB, perfC)}.');
+    trace('Played BF vocals in ${TimerUtil.seconds(perfC, perfD)}.');
+    trace('Played DAD vocals in ${TimerUtil.seconds(perfD, perfE)}.');
+    trace('Hard refreshed offsets toolbox in ${TimerUtil.seconds(perfE, perfF)}.');
+    trace('Hard refreshed freeplay toolbox in ${TimerUtil.seconds(perfF, perfG)}.');
 
     return true;
   }
@@ -175,9 +175,9 @@ class ChartEditorAudioHandler
   {
     if (instId == '') instId = 'default';
     var instTrackData:Null<Bytes> = state.audioInstTrackData.get(instId);
-    var perfStart:Float = TimerTools.start();
+    var perfStart:Float = TimerUtil.start();
     var instTrack:Null<FunkinSound> = SoundUtil.buildSoundFromBytes(instTrackData);
-    trace('Built instrumental track in ${TimerTools.seconds(perfStart)} seconds.');
+    trace('Built instrumental track in ${TimerUtil.seconds(perfStart)} seconds.');
     if (instTrack == null) return false;
 
     stopExistingInstrumental(state);
@@ -205,9 +205,9 @@ class ChartEditorAudioHandler
   {
     var trackId:String = '${charId}${instId == '' ? '' : '-${instId}'}';
     var vocalTrackData:Null<Bytes> = state.audioVocalTrackData.get(trackId);
-    var perfStart:Float = TimerTools.start();
+    var perfStart:Float = TimerUtil.start();
     var vocalTrack:Null<FunkinSound> = SoundUtil.buildSoundFromBytes(vocalTrackData);
-    trace('Built vocal track in ${TimerTools.seconds(perfStart)}.');
+    trace('Built vocal track in ${TimerUtil.seconds(perfStart)}.');
 
     if (state.audioVocalTrackGroup == null) state.audioVocalTrackGroup = new VoicesGroup();
 
@@ -218,9 +218,9 @@ class ChartEditorAudioHandler
         case BF:
           state.audioVocalTrackGroup.addPlayerVoice(vocalTrack);
 
-          var perfStart:Float = TimerTools.start();
+          var perfStart:Float = TimerUtil.start();
           var waveformData:Null<WaveformData> = vocalTrack.waveformData;
-          trace('Interpreted waveform data in ${TimerTools.seconds(perfStart)}.');
+          trace('Interpreted waveform data in ${TimerUtil.seconds(perfStart)}.');
 
           if (waveformData != null)
           {
@@ -244,9 +244,9 @@ class ChartEditorAudioHandler
         case DAD:
           state.audioVocalTrackGroup.addOpponentVoice(vocalTrack);
 
-          var perfStart:Float = TimerTools.start();
+          var perfStart:Float = TimerUtil.start();
           var waveformData:Null<WaveformData> = vocalTrack.waveformData;
-          trace('Interpreted waveform data in ${TimerTools.seconds(perfStart)}.');
+          trace('Interpreted waveform data in ${TimerUtil.seconds(perfStart)}.');
 
           if (waveformData != null)
           {
diff --git a/source/funkin/ui/debug/charting/toolboxes/ChartEditorFreeplayToolbox.hx b/source/funkin/ui/debug/charting/toolboxes/ChartEditorFreeplayToolbox.hx
index 28d435c54..c65781259 100644
--- a/source/funkin/ui/debug/charting/toolboxes/ChartEditorFreeplayToolbox.hx
+++ b/source/funkin/ui/debug/charting/toolboxes/ChartEditorFreeplayToolbox.hx
@@ -7,7 +7,7 @@ import funkin.audio.waveform.WaveformDataParser;
 import funkin.ui.debug.charting.commands.SetFreeplayPreviewCommand;
 import funkin.ui.haxeui.components.WaveformPlayer;
 import funkin.ui.freeplay.FreeplayState;
-import funkin.util.tools.TimerTools;
+import funkin.util.TimerUtil;
 import haxe.ui.backend.flixel.components.SpriteWrapper;
 import haxe.ui.components.Button;
 import haxe.ui.components.HorizontalSlider;
@@ -289,12 +289,12 @@ class ChartEditorFreeplayToolbox extends ChartEditorBaseToolbox
 
     // Build player waveform.
     // waveformMusic.waveform.forceUpdate = true;
-    var perfStart:Float = TimerTools.start();
+    var perfStart:Float = TimerUtil.start();
     var waveformData1 = playerVoice?.waveformData;
     var waveformData2 = opponentVoice?.waveformData ?? playerVoice?.waveformData; // this null check is for songs that only have 1 vocals file!
     var waveformData3 = chartEditorState.audioInstTrack.waveformData;
     var waveformData = waveformData3.merge(waveformData1).merge(waveformData2);
-    trace('Waveform data merging took: ${TimerTools.seconds(perfStart)}');
+    trace('Waveform data merging took: ${TimerUtil.seconds(perfStart)}');
 
     waveformMusic.waveform.waveformData = waveformData;
     // Set the width and duration to render the full waveform, with the clipRect applied we only render a segment of it.
diff --git a/source/funkin/util/TimerUtil.hx b/source/funkin/util/TimerUtil.hx
index 5322ada92..caf49341b 100644
--- a/source/funkin/util/TimerUtil.hx
+++ b/source/funkin/util/TimerUtil.hx
@@ -1,9 +1,9 @@
-package funkin.util.tools;
+package funkin.util;
 
 import funkin.util.tools.FloatTools;
 import haxe.Timer;
 
-class TimerTools
+class TimerUtil
 {
   public static function start():Float
   {
diff --git a/source/funkin/util/plugins/MemoryGCPlugin.hx b/source/funkin/util/plugins/MemoryGCPlugin.hx
index 67a4fe18e..4b89fa637 100644
--- a/source/funkin/util/plugins/MemoryGCPlugin.hx
+++ b/source/funkin/util/plugins/MemoryGCPlugin.hx
@@ -1,7 +1,7 @@
 package funkin.util.plugins;
 
 import flixel.FlxBasic;
-import funkin.util.tools.TimerTools;
+import funkin.util.TimerUtil;
 
 /**
  * A plugin which adds functionality to press `Ins` to immediately perform memory garbage collection.
@@ -24,9 +24,9 @@ class MemoryGCPlugin extends FlxBasic
 
     if (FlxG.keys.justPressed.INSERT)
     {
-      var perfStart:Float = TimerTools.start();
+      var perfStart:Float = TimerUtil.start();
       funkin.util.MemoryUtil.collect(true);
-      trace('Memory GC took: ${TimerTools.seconds(perfStart)}');
+      trace('Memory GC took: ${TimerUtil.seconds(perfStart)}');
     }
   }
 

From a6fecd05e05d555b1769a43a7c38b28d9d324e1c Mon Sep 17 00:00:00 2001
From: Cameron Taylor <cameron.taylor.ninja@gmail.com>
Date: Wed, 13 Mar 2024 18:58:18 -0700
Subject: [PATCH 22/25] assets submod

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

diff --git a/assets b/assets
index b71005291..b498f7f75 160000
--- a/assets
+++ b/assets
@@ -1 +1 @@
-Subproject commit b71005291132a09043cabb59511d9316a21039ca
+Subproject commit b498f7f7569af24c25c88836d087f93529c2c6be

From b5bc63fecaf6674f60d744de7cf02e82b0536f4f Mon Sep 17 00:00:00 2001
From: Cameron Taylor <cameron.taylor.ninja@gmail.com>
Date: Wed, 13 Mar 2024 21:27:09 -0700
Subject: [PATCH 23/25] use isMinimalMode

---
 source/funkin/play/event/FocusCameraSongEvent.hx | 2 +-
 source/funkin/play/event/ZoomCameraSongEvent.hx  | 2 +-
 2 files changed, 2 insertions(+), 2 deletions(-)

diff --git a/source/funkin/play/event/FocusCameraSongEvent.hx b/source/funkin/play/event/FocusCameraSongEvent.hx
index 4ea6fa8c0..625b9cb7a 100644
--- a/source/funkin/play/event/FocusCameraSongEvent.hx
+++ b/source/funkin/play/event/FocusCameraSongEvent.hx
@@ -58,7 +58,7 @@ class FocusCameraSongEvent extends SongEvent
     if (PlayState.instance == null || PlayState.instance.currentStage == null) return;
 
     // Does nothing if we are minimal mode.
-    if (PlayState.instance.minimalMode) return;
+    if (PlayState.instance.isMinimalMode) return;
 
     var posX:Null<Float> = data.getFloat('x');
     if (posX == null) posX = 0.0;
diff --git a/source/funkin/play/event/ZoomCameraSongEvent.hx b/source/funkin/play/event/ZoomCameraSongEvent.hx
index 3a903a4ff..d1ce97e40 100644
--- a/source/funkin/play/event/ZoomCameraSongEvent.hx
+++ b/source/funkin/play/event/ZoomCameraSongEvent.hx
@@ -58,7 +58,7 @@ class ZoomCameraSongEvent extends SongEvent
     if (PlayState.instance == null || PlayState.instance.currentStage == null) return;
 
     // Does nothing if we are minimal mode.
-    if (PlayState.instance.minimalMode) return;
+    if (PlayState.instance.isMinimalMode) return;
 
     var zoom:Null<Float> = data.getFloat('zoom');
     if (zoom == null) zoom = 1.0;

From a21ce20e4056e64b180b2a49e522f4461b84f351 Mon Sep 17 00:00:00 2001
From: Cameron Taylor <cameron.taylor.ninja@gmail.com>
Date: Wed, 13 Mar 2024 21:59:40 -0700
Subject: [PATCH 24/25] assets submod merge

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

diff --git a/assets b/assets
index 64586275f..f71741990 160000
--- a/assets
+++ b/assets
@@ -1 +1 @@
-Subproject commit 64586275f5e47960242faa687f9a26e52fcf2f9a
+Subproject commit f7174199004f77eef8eaecfa81cd9307fe020699

From 0d2167d330b9a6a5e0720b3b8210d0ddda614c5f Mon Sep 17 00:00:00 2001
From: Cameron Taylor <cameron.taylor.ninja@gmail.com>
Date: Thu, 14 Mar 2024 03:08:51 -0700
Subject: [PATCH 25/25] assets submod

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

diff --git a/assets b/assets
index a9c472d5d..0e2c5bf21 160000
--- a/assets
+++ b/assets
@@ -1 +1 @@
-Subproject commit a9c472d5d3ae1729dc109ab50c2f9824dbb9e5e7
+Subproject commit 0e2c5bf2134c7e517b70cf74afd58abe5c7b5e50