From 9209bac02c1f89431876dcda56eb068c6cae381c Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Wed, 14 Feb 2024 03:27:34 -0500
Subject: [PATCH 01/30] Port improved AtlasSprite from char-select-rebase

---
 .../graphics/adobeanimate/FlxAtlasSprite.hx   | 32 +++++++++++--------
 1 file changed, 19 insertions(+), 13 deletions(-)

diff --git a/source/funkin/graphics/adobeanimate/FlxAtlasSprite.hx b/source/funkin/graphics/adobeanimate/FlxAtlasSprite.hx
index ae7a5708c..fe024e2f5 100644
--- a/source/funkin/graphics/adobeanimate/FlxAtlasSprite.hx
+++ b/source/funkin/graphics/adobeanimate/FlxAtlasSprite.hx
@@ -18,7 +18,7 @@ class FlxAtlasSprite extends FlxAnimate
       // ?OnComplete:Void -> Void,
       ShowPivot: #if debug false #else false #end,
       Antialiasing: true,
-      ScrollFactor: new FlxPoint(1, 1),
+      ScrollFactor: null,
       // Offset: new FlxPoint(0, 0), // This is just FlxSprite.offset
     };
 
@@ -55,8 +55,8 @@ class FlxAtlasSprite extends FlxAnimate
    */
   public function listAnimations():Array<String>
   {
-    // return this.anim.getFrameLabels();
-    return [""];
+    return this.anim.getFrameLabels();
+    // return [""];
   }
 
   /**
@@ -82,8 +82,10 @@ class FlxAtlasSprite extends FlxAnimate
    * @param restart Whether to restart the animation if it is already playing.
    * @param ignoreOther Whether to ignore all other animation inputs, until this one is done playing
    */
-  public function playAnimation(id:String, restart:Bool = false, ignoreOther:Bool = false):Void
+  public function playAnimation(id:String, restart:Bool = false, ignoreOther:Bool = false, ?loop:Bool = false):Void
   {
+    if (loop == null) loop = false;
+
     // Skip if not allowed to play animations.
     if ((!canPlayOtherAnims && !ignoreOther)) return;
 
@@ -110,15 +112,14 @@ class FlxAtlasSprite extends FlxAnimate
       return;
     }
 
-    // Stop the current animation if it is playing.
-    // This includes removing existing frame callbacks.
-    if (this.currentAnimation != null) this.stopAnimation();
-
-    // Add a callback to ensure `onAnimationFinish` is dispatched.
-    addFrameCallback(getNextFrameLabel(id), function() {
-      trace('Animation finished: ' + id);
-      onAnimationFinish.dispatch(id);
-    });
+    anim.callback = function(_, frame:Int) {
+      if (frame == (anim.getFrameLabel(id).duration - 1) + anim.getFrameLabel(id).index)
+      {
+        if (loop) playAnimation(id, true, false, true);
+        else
+          onAnimationFinish.dispatch(id);
+      }
+    };
 
     // Prevent other animations from playing if `ignoreOther` is true.
     if (ignoreOther) canPlayOtherAnims = false;
@@ -128,6 +129,11 @@ class FlxAtlasSprite extends FlxAnimate
     this.currentAnimation = id;
   }
 
+  override public function update(elapsed:Float)
+  {
+    super.update(elapsed);
+  }
+
   /**
    * Stops the current animation.
    */

From c896300b63627625ed14017d83ff5475f07d074a Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Thu, 15 Feb 2024 17:23:43 -0500
Subject: [PATCH 02/30] NoteData stringifies nicer now.

---
 source/funkin/data/song/SongData.hx | 6 ++++++
 1 file changed, 6 insertions(+)

diff --git a/source/funkin/data/song/SongData.hx b/source/funkin/data/song/SongData.hx
index 73ecbce14..8a07f9fb1 100644
--- a/source/funkin/data/song/SongData.hx
+++ b/source/funkin/data/song/SongData.hx
@@ -1016,6 +1016,12 @@ class SongNoteDataRaw implements ICloneable<SongNoteDataRaw>
   {
     return new SongNoteDataRaw(this.time, this.data, this.length, this.kind);
   }
+
+  public function toString():String
+  {
+    return 'SongNoteData(${this.time}ms, ' + (this.length > 0 ? '[${this.length}ms hold]' : '') + ' ${this.data}'
+      + (this.kind != '' ? ' [kind: ${this.kind}])' : ')');
+  }
 }
 
 /**

From 04b73dac9f4d1cfb034064f7978743b5380105fd Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Thu, 15 Feb 2024 17:23:57 -0500
Subject: [PATCH 03/30] Always force debug version in VSCode.

---
 .vscode/settings.json | 26 +++++++++++++++-----------
 1 file changed, 15 insertions(+), 11 deletions(-)

diff --git a/.vscode/settings.json b/.vscode/settings.json
index 3d1f488f7..8455fde93 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -93,57 +93,61 @@
     {
       "label": "Windows / Debug",
       "target": "windows",
-      "args": ["-debug"]
+      "args": ["-debug", "-DFORCE_DEBUG_VERSION"]
     },
     {
       "label": "Windows / Debug (FlxAnimate Test)",
       "target": "windows",
-      "args": ["-debug", "-DANIMATE"]
+      "args": ["-debug", "-DANIMATE", "-DFORCE_DEBUG_VERSION"]
     },
     {
       "label": "Windows / Debug (Straight to Freeplay)",
       "target": "windows",
-      "args": ["-debug", "-DFREEPLAY"]
+      "args": ["-debug", "-DFREEPLAY", "-DFORCE_DEBUG_VERSION"]
     },
     {
       "label": "Windows / Debug (Straight to Play - Bopeebo Normal)",
       "target": "windows",
-      "args": ["-debug", "-DSONG=bopeebo -DDIFFICULTY=normal"]
+      "args": [
+        "-debug",
+        "-DSONG=bopeebo -DDIFFICULTY=normal",
+        "-DFORCE_DEBUG_VERSION"
+      ]
     },
     {
       "label": "Windows / Debug (Conversation Test)",
       "target": "windows",
-      "args": ["-debug", "-DDIALOGUE"]
+      "args": ["-debug", "-DDIALOGUE", "-DFORCE_DEBUG_VERSION"]
     },
     {
       "label": "Windows / Debug (Straight to Chart Editor)",
       "target": "windows",
-      "args": ["-debug", "-DCHARTING"]
+      "args": ["-debug", "-DCHARTING", "-DFORCE_DEBUG_VERSION"]
     },
     {
       "label": "Windows / Debug (Straight to Animation Editor)",
       "target": "windows",
-      "args": ["-debug", "-DANIMDEBUG"]
+      "args": ["-debug", "-DANIMDEBUG", "-DFORCE_DEBUG_VERSION"]
     },
     {
       "label": "Windows / Debug (Latency Test)",
       "target": "windows",
-      "args": ["-debug", "-DLATENCY"]
+      "args": ["-debug", "-DLATENCY", "-DFORCE_DEBUG_VERSION"]
     },
     {
       "label": "Windows / Debug (Waveform Test)",
       "target": "windows",
-      "args": ["-debug", "-DWAVEFORM"]
+      "args": ["-debug", "-DWAVEFORM", "-DFORCE_DEBUG_VERSION"]
     },
     {
       "label": "HTML5 / Debug",
       "target": "html5",
-      "args": ["-debug"]
+      "args": ["-debug", "-DFORCE_DEBUG_VERSION"]
     },
     {
       "label": "HTML5 / Debug (Watch)",
       "target": "html5",
-      "args": ["-debug", "-watch"]
+      "args": ["-debug", "-watch", "-DFORCE_DEBUG_VERSION"]
     }
   ],
   "cmake.configureOnOpen": false,

From 5ec093926335400f502ced25733c71108e317fd3 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Thu, 15 Feb 2024 17:25:28 -0500
Subject: [PATCH 04/30] Bunch of changes to NoteScriptEvent and death logic

---
 source/funkin/modding/events/ScriptEvent.hx   |  7 ++++
 source/funkin/play/GameOverSubState.hx        | 24 ++++++++++---
 source/funkin/play/PlayState.hx               | 36 ++++++++++---------
 source/funkin/play/character/CharacterData.hx |  8 ++++-
 source/funkin/play/stage/Stage.hx             |  3 ++
 5 files changed, 55 insertions(+), 23 deletions(-)

diff --git a/source/funkin/modding/events/ScriptEvent.hx b/source/funkin/modding/events/ScriptEvent.hx
index 18f934aee..68265a103 100644
--- a/source/funkin/modding/events/ScriptEvent.hx
+++ b/source/funkin/modding/events/ScriptEvent.hx
@@ -106,12 +106,19 @@ class NoteScriptEvent extends ScriptEvent
    */
   public var playSound(default, default):Bool;
 
+  /**
+   * A multiplier to the health gained or lost from this note.
+   * This affects both hits and misses. Remember that max health is 2.00.
+   */
+  public var healthMulti:Float;
+
   public function new(type:ScriptEventType, note:NoteSprite, comboCount:Int = 0, cancelable:Bool = false):Void
   {
     super(type, cancelable);
     this.note = note;
     this.comboCount = comboCount;
     this.playSound = true;
+    this.healthMulti = 1.0;
   }
 
   public override function toString():String
diff --git a/source/funkin/play/GameOverSubState.hx b/source/funkin/play/GameOverSubState.hx
index 74b39417e..62c3409b7 100644
--- a/source/funkin/play/GameOverSubState.hx
+++ b/source/funkin/play/GameOverSubState.hx
@@ -4,16 +4,17 @@ import flixel.FlxG;
 import flixel.FlxObject;
 import flixel.FlxSprite;
 import flixel.sound.FlxSound;
-import funkin.ui.story.StoryMenuState;
 import flixel.util.FlxColor;
 import flixel.util.FlxTimer;
 import funkin.graphics.FunkinSprite;
-import funkin.ui.MusicBeatSubState;
 import funkin.modding.events.ScriptEvent;
 import funkin.modding.events.ScriptEventDispatcher;
+import funkin.play.character.BaseCharacter;
 import funkin.play.PlayState;
 import funkin.ui.freeplay.FreeplayState;
-import funkin.play.character.BaseCharacter;
+import funkin.ui.MusicBeatSubState;
+import funkin.ui.story.StoryMenuState;
+import openfl.utils.Assets;
 
 /**
  * A substate which renders over the PlayState when the player dies.
@@ -148,6 +149,12 @@ class GameOverSubState extends MusicBeatSubState
     Conductor.instance.update(0);
   }
 
+  public function resetCameraZoom():Void
+  {
+    // Apply camera zoom level from stage data.
+    FlxG.camera.zoom = PlayState?.instance?.currentStage?.camZoom ?? 1.0;
+  }
+
   var hasStartedAnimation:Bool = false;
 
   override function update(elapsed:Float)
@@ -295,7 +302,7 @@ class GameOverSubState extends MusicBeatSubState
    * Starts the death music at the appropriate volume.
    * @param startingVolume
    */
-  function startDeathMusic(?startingVolume:Float = 1, force:Bool = false):Void
+  public function startDeathMusic(?startingVolume:Float = 1, force:Bool = false):Void
   {
     var musicPath = Paths.music('gameplay/gameover/gameOver' + musicSuffix);
     if (isEnding)
@@ -320,7 +327,14 @@ class GameOverSubState extends MusicBeatSubState
   public static function playBlueBalledSFX()
   {
     blueballed = true;
-    FlxG.sound.play(Paths.sound('gameplay/gameover/fnf_loss_sfx' + blueBallSuffix));
+    if (Assets.exists(Paths.sound('gameplay/gameover/fnf_loss_sfx' + blueBallSuffix)))
+    {
+      FlxG.sound.play(Paths.sound('gameplay/gameover/fnf_loss_sfx' + blueBallSuffix));
+    }
+    else
+    {
+      FlxG.log.error('Missing blue ball sound effect: ' + Paths.sound('gameplay/gameover/fnf_loss_sfx' + blueBallSuffix));
+    }
   }
 
   var playingJeffQuote:Bool = false;
diff --git a/source/funkin/play/PlayState.hx b/source/funkin/play/PlayState.hx
index be4fab254..4b9349648 100644
--- a/source/funkin/play/PlayState.hx
+++ b/source/funkin/play/PlayState.hx
@@ -903,6 +903,7 @@ class PlayState extends MusicBeatSubState
     {
       FlxG.watch.addQuick('bfAnim', currentStage.getBoyfriend().getCurrentAnimation());
     }
+    FlxG.watch.addQuick('health', health);
 
     // TODO: Add a song event for Handle GF dance speed.
 
@@ -1390,8 +1391,7 @@ class PlayState extends MusicBeatSubState
       var event:ScriptEvent = new ScriptEvent(CREATE, false);
       ScriptEventDispatcher.callEvent(currentStage, event);
 
-      // Apply camera zoom level from stage data.
-      defaultCameraZoom = currentStage.camZoom;
+      resetCameraZoom();
 
       // Add the stage to the scene.
       this.add(currentStage);
@@ -1407,6 +1407,12 @@ class PlayState extends MusicBeatSubState
     }
   }
 
+  public function resetCameraZoom():Void
+  {
+    // Apply camera zoom level from stage data.
+    defaultCameraZoom = currentStage.camZoom;
+  }
+
   /**
    * Generates the character sprites and adds them to the stage.
    */
@@ -1960,7 +1966,7 @@ class PlayState extends MusicBeatSubState
         // Judge the miss.
         // NOTE: This is what handles the scoring.
         trace('Missed note! ${note.noteData}');
-        onNoteMiss(note);
+        onNoteMiss(note, event.playSound, event.healthMulti);
 
         note.handledMiss = true;
       }
@@ -2111,7 +2117,7 @@ class PlayState extends MusicBeatSubState
     // Calling event.cancelEvent() skips all the other logic! Neat!
     if (event.eventCanceled) return;
 
-    popUpScore(note, input);
+    popUpScore(note, input, event.healthMulti);
 
     if (note.isHoldNote && note.holdNoteSprite != null)
     {
@@ -2125,15 +2131,11 @@ class PlayState extends MusicBeatSubState
    * Called when a note leaves the screen and is considered missed by the player.
    * @param note
    */
-  function onNoteMiss(note:NoteSprite):Void
+  function onNoteMiss(note:NoteSprite, playSound:Bool = false, healthLossMulti:Float = 1.0):Void
   {
-    // a MISS is when you let a note scroll past you!!
-    var event:NoteScriptEvent = new NoteScriptEvent(NOTE_MISS, note, Highscore.tallies.combo, true);
-    dispatchEvent(event);
-    // Calling event.cancelEvent() skips all the other logic! Neat!
-    if (event.eventCanceled) return;
+    // If we are here, we already CALLED the onNoteMiss script hook!
 
-    health -= Constants.HEALTH_MISS_PENALTY;
+    health -= Constants.HEALTH_MISS_PENALTY * healthLossMulti;
     songScore -= 10;
 
     if (!isPracticeMode)
@@ -2183,7 +2185,7 @@ class PlayState extends MusicBeatSubState
       Highscore.tallies.combo = comboPopUps.displayCombo(0);
     }
 
-    if (event.playSound)
+    if (playSound)
     {
       vocals.playerVolume = 0;
       FlxG.sound.play(Paths.soundRandom('missnote', 1, 3), FlxG.random.float(0.1, 0.2));
@@ -2310,7 +2312,7 @@ class PlayState extends MusicBeatSubState
   /**
    * Handles health, score, and rating popups when a note is hit.
    */
-  function popUpScore(daNote:NoteSprite, input:PreciseInputEvent):Void
+  function popUpScore(daNote:NoteSprite, input:PreciseInputEvent, healthGainMulti:Float = 1.0):Void
   {
     vocals.playerVolume = 1;
 
@@ -2341,19 +2343,19 @@ class PlayState extends MusicBeatSubState
     {
       case 'sick':
         Highscore.tallies.sick += 1;
-        health += Constants.HEALTH_SICK_BONUS;
+        health += Constants.HEALTH_SICK_BONUS * healthGainMulti;
         isComboBreak = Constants.JUDGEMENT_SICK_COMBO_BREAK;
       case 'good':
         Highscore.tallies.good += 1;
-        health += Constants.HEALTH_GOOD_BONUS;
+        health += Constants.HEALTH_GOOD_BONUS * healthGainMulti;
         isComboBreak = Constants.JUDGEMENT_GOOD_COMBO_BREAK;
       case 'bad':
         Highscore.tallies.bad += 1;
-        health += Constants.HEALTH_BAD_BONUS;
+        health += Constants.HEALTH_BAD_BONUS * healthGainMulti;
         isComboBreak = Constants.JUDGEMENT_BAD_COMBO_BREAK;
       case 'shit':
         Highscore.tallies.shit += 1;
-        health += Constants.HEALTH_SHIT_BONUS;
+        health += Constants.HEALTH_SHIT_BONUS * healthGainMulti;
         isComboBreak = Constants.JUDGEMENT_SHIT_COMBO_BREAK;
     }
 
diff --git a/source/funkin/play/character/CharacterData.hx b/source/funkin/play/character/CharacterData.hx
index 69e3ca48e..f3c7d7613 100644
--- a/source/funkin/play/character/CharacterData.hx
+++ b/source/funkin/play/character/CharacterData.hx
@@ -305,9 +305,15 @@ class CharacterDataParser
         icon = "darnell";
       case "senpai-angry":
         icon = "senpai";
+      case "tankman" | "tankman-atlas":
+        icon = "tankmen";
     }
 
-    return Paths.image("freeplay/icons/" + icon + "pixel");
+    var path = Paths.image("freeplay/icons/" + icon + "pixel");
+    if (Assets.exists(path)) return path;
+
+    // TODO: Hardcode some additional behavior or a fallback.
+    return null;
   }
 
   /**
diff --git a/source/funkin/play/stage/Stage.hx b/source/funkin/play/stage/Stage.hx
index af5765b25..8b47eff2b 100644
--- a/source/funkin/play/stage/Stage.hx
+++ b/source/funkin/play/stage/Stage.hx
@@ -397,15 +397,18 @@ class Stage extends FlxSpriteGroup implements IPlayStateScriptedClass implements
         this.characters.set('bf', character);
         charData = _data.characters.bf;
         character.flipX = !character.getDataFlipX();
+        character.name = 'bf';
         character.initHealthIcon(false);
       case GF:
         this.characters.set('gf', character);
         charData = _data.characters.gf;
         character.flipX = character.getDataFlipX();
+        character.name = 'gf';
       case DAD:
         this.characters.set('dad', character);
         charData = _data.characters.dad;
         character.flipX = character.getDataFlipX();
+        character.name = 'dad';
         character.initHealthIcon(true);
       default:
         this.characters.set(character.characterId, character);

From 72d623bdf6179b4ffc9eea58ec6253531b183eac Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Thu, 15 Feb 2024 17:26:02 -0500
Subject: [PATCH 05/30] Don't prettier format FlxAnimate JSONs

---
 .prettierignore | 8 ++++++++
 1 file changed, 8 insertions(+)
 create mode 100644 .prettierignore

diff --git a/.prettierignore b/.prettierignore
new file mode 100644
index 000000000..c92ea0bae
--- /dev/null
+++ b/.prettierignore
@@ -0,0 +1,8 @@
+# Ignore artifacts
+export
+
+# Ignore all asset files (including FlxAnimate JSONs)
+assets
+
+# Don't ignore data files
+!assets/preload/data

From a9bcc492bc6eeb58208bd2597f321cd6aec27460 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Thu, 15 Feb 2024 17:26:10 -0500
Subject: [PATCH 06/30] Update assets submodule

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

diff --git a/assets b/assets
index 1f00d2413..6c657fc8f 160000
--- a/assets
+++ b/assets
@@ -1 +1 @@
-Subproject commit 1f00d24134231433180affecc67a617d54169ffa
+Subproject commit 6c657fc8f6537500a98ebcd04bb7ce3f9b69a322

From d6b3e2a9cf4aab093a129aef13ebd93294d6f929 Mon Sep 17 00:00:00 2001
From: Mike Welsh <mwelsh@gmail.com>
Date: Fri, 16 Feb 2024 00:07:16 -0800
Subject: [PATCH 07/30] Fix `FunkinSound` not resuming after focus

`FunkingSound.onFocus` was checking `_shouldPlay` before resuming,
but this would always be false, causing the sound to not resume
when tabbing out and back into the game.
---
 source/funkin/audio/FunkinSound.hx | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/source/funkin/audio/FunkinSound.hx b/source/funkin/audio/FunkinSound.hx
index e7ce68d08..c1d51800b 100644
--- a/source/funkin/audio/FunkinSound.hx
+++ b/source/funkin/audio/FunkinSound.hx
@@ -186,7 +186,7 @@ class FunkinSound extends FlxSound implements ICloneable<FunkinSound>
    */
   override function onFocus():Void
   {
-    if (!_alreadyPaused && this._shouldPlay)
+    if (!_alreadyPaused)
     {
       resume();
     }

From 77ff261be16ab6fecb5c4e5e02db58becf5dffa9 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Fri, 16 Feb 2024 13:08:34 -0500
Subject: [PATCH 08/30] Make functions non-inline so they work on HScript

---
 source/funkin/graphics/adobeanimate/FlxAtlasSprite.hx | 9 +++++----
 1 file changed, 5 insertions(+), 4 deletions(-)

diff --git a/source/funkin/graphics/adobeanimate/FlxAtlasSprite.hx b/source/funkin/graphics/adobeanimate/FlxAtlasSprite.hx
index fe024e2f5..2329a2791 100644
--- a/source/funkin/graphics/adobeanimate/FlxAtlasSprite.hx
+++ b/source/funkin/graphics/adobeanimate/FlxAtlasSprite.hx
@@ -55,6 +55,7 @@ class FlxAtlasSprite extends FlxAnimate
    */
   public function listAnimations():Array<String>
   {
+    if (this.anim == null) return [];
     return this.anim.getFrameLabels();
     // return [""];
   }
@@ -152,22 +153,22 @@ class FlxAtlasSprite extends FlxAnimate
     frameLabel.add(callback);
   }
 
-  inline function goToFrameLabel(label:String):Void
+  function goToFrameLabel(label:String):Void
   {
     this.anim.goToFrameLabel(label);
   }
 
-  inline function getNextFrameLabel(label:String):String
+  function getNextFrameLabel(label:String):String
   {
     return listAnimations()[(getLabelIndex(label) + 1) % listAnimations().length];
   }
 
-  inline function getLabelIndex(label:String):Int
+  function getLabelIndex(label:String):Int
   {
     return listAnimations().indexOf(label);
   }
 
-  inline function goToFrameIndex(index:Int):Void
+  function goToFrameIndex(index:Int):Void
   {
     this.anim.curFrame = index;
   }

From 8a9a7f3b97cd2afc84ce2898f4a8cb8a986e660f Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Sat, 17 Feb 2024 02:13:11 -0500
Subject: [PATCH 09/30] Additional chart editor fixes.

---
 source/funkin/audio/waveform/WaveformData.hx        |  2 ++
 source/funkin/data/song/SongData.hx                 |  6 ++++--
 .../debug/charting/commands/SelectItemsCommand.hx   |  4 ++--
 .../charting/commands/SetItemSelectionCommand.hx    |  4 ++--
 .../toolboxes/ChartEditorFreeplayToolbox.hx         |  6 +++---
 .../charting/toolboxes/ChartEditorOffsetsToolbox.hx | 13 +++++--------
 6 files changed, 18 insertions(+), 17 deletions(-)

diff --git a/source/funkin/audio/waveform/WaveformData.hx b/source/funkin/audio/waveform/WaveformData.hx
index b82d141e7..1f649b472 100644
--- a/source/funkin/audio/waveform/WaveformData.hx
+++ b/source/funkin/audio/waveform/WaveformData.hx
@@ -187,6 +187,8 @@ class WaveformData
    */
   public function merge(that:WaveformData):WaveformData
   {
+    if (that == null) return this.clone();
+
     var result = this.clone([]);
 
     for (channelIndex in 0...this.channels)
diff --git a/source/funkin/data/song/SongData.hx b/source/funkin/data/song/SongData.hx
index 4deba7088..cc568ec66 100644
--- a/source/funkin/data/song/SongData.hx
+++ b/source/funkin/data/song/SongData.hx
@@ -110,7 +110,8 @@ class SongMetadata implements ICloneable<SongMetadata>
    */
   public function serialize(pretty:Bool = true):String
   {
-    var writer = new json2object.JsonWriter<SongMetadata>();
+    var ignoreNullOptionals = true;
+    var writer = new json2object.JsonWriter<SongMetadata>(ignoreNullOptionals);
     // I believe @:jignored should be iggnored by the writer?
     // var output = this.clone();
     // output.variation = null; // Not sure how to make a field optional on the reader and ignored on the writer.
@@ -597,7 +598,8 @@ class SongChartData implements ICloneable<SongChartData>
    */
   public function serialize(pretty:Bool = true):String
   {
-    var writer = new json2object.JsonWriter<SongChartData>();
+    var ignoreNullOptionals = true;
+    var writer = new json2object.JsonWriter<SongChartData>(ignoreNullOptionals);
     return writer.write(this, pretty ? '  ' : null);
   }
 
diff --git a/source/funkin/ui/debug/charting/commands/SelectItemsCommand.hx b/source/funkin/ui/debug/charting/commands/SelectItemsCommand.hx
index 88f73cfed..891ac9ebd 100644
--- a/source/funkin/ui/debug/charting/commands/SelectItemsCommand.hx
+++ b/source/funkin/ui/debug/charting/commands/SelectItemsCommand.hx
@@ -34,7 +34,7 @@ class SelectItemsCommand implements ChartEditorCommand
     }
 
     // If we just selected one or more events (and no notes), then we should make the event data toolbox display the event data for the selected event.
-    if (this.notes.length == 0 && this.events.length >= 1)
+    if (this.notes.length == 0 && this.events.length == 1)
     {
       var eventSelected = this.events[0];
 
@@ -60,7 +60,7 @@ class SelectItemsCommand implements ChartEditorCommand
     }
 
     // If we just selected one or more notes (and no events), then we should make the note data toolbox display the note data for the selected note.
-    if (this.events.length == 0 && this.notes.length >= 1)
+    if (this.events.length == 0 && this.notes.length == 1)
     {
       var noteSelected = this.notes[0];
 
diff --git a/source/funkin/ui/debug/charting/commands/SetItemSelectionCommand.hx b/source/funkin/ui/debug/charting/commands/SetItemSelectionCommand.hx
index 5cc89e137..0b540dbeb 100644
--- a/source/funkin/ui/debug/charting/commands/SetItemSelectionCommand.hx
+++ b/source/funkin/ui/debug/charting/commands/SetItemSelectionCommand.hx
@@ -31,7 +31,7 @@ class SetItemSelectionCommand implements ChartEditorCommand
     state.currentEventSelection = events;
 
     // If we just selected one or more events (and no notes), then we should make the event data toolbox display the event data for the selected event.
-    if (this.notes.length == 0 && this.events.length >= 1)
+    if (this.notes.length == 0 && this.events.length == 1)
     {
       var eventSelected = this.events[0];
 
@@ -57,7 +57,7 @@ class SetItemSelectionCommand implements ChartEditorCommand
     }
 
     // IF we just selected one or more notes (and no events), then we should make the note data toolbox display the note data for the selected note.
-    if (this.events.length == 0 && this.notes.length >= 1)
+    if (this.events.length == 0 && this.notes.length == 1)
     {
       var noteSelected = this.notes[0];
 
diff --git a/source/funkin/ui/debug/charting/toolboxes/ChartEditorFreeplayToolbox.hx b/source/funkin/ui/debug/charting/toolboxes/ChartEditorFreeplayToolbox.hx
index c384e7a6d..1432c9205 100644
--- a/source/funkin/ui/debug/charting/toolboxes/ChartEditorFreeplayToolbox.hx
+++ b/source/funkin/ui/debug/charting/toolboxes/ChartEditorFreeplayToolbox.hx
@@ -289,10 +289,10 @@ class ChartEditorFreeplayToolbox extends ChartEditorBaseToolbox
     // Build player waveform.
     // waveformMusic.waveform.forceUpdate = true;
     var perfStart = haxe.Timer.stamp();
-    var waveformData1 = playerVoice.waveformData;
-    var waveformData2 = opponentVoice?.waveformData ?? playerVoice.waveformData; // this null check is for songs that only have 1 vocals file!
+    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 = waveformData1.merge(waveformData2).merge(waveformData3);
+    var waveformData = waveformData3.merge(waveformData1).merge(waveformData2);
     trace('Waveform data merging took: ${haxe.Timer.stamp() - perfStart} seconds');
 
     waveformMusic.waveform.waveformData = waveformData;
diff --git a/source/funkin/ui/debug/charting/toolboxes/ChartEditorOffsetsToolbox.hx b/source/funkin/ui/debug/charting/toolboxes/ChartEditorOffsetsToolbox.hx
index fd9209294..af1d75444 100644
--- a/source/funkin/ui/debug/charting/toolboxes/ChartEditorOffsetsToolbox.hx
+++ b/source/funkin/ui/debug/charting/toolboxes/ChartEditorOffsetsToolbox.hx
@@ -270,24 +270,21 @@ class ChartEditorOffsetsToolbox extends ChartEditorBaseToolbox
 
     // Build player waveform.
     // waveformPlayer.waveform.forceUpdate = true;
-    waveformPlayer.waveform.waveformData = playerVoice.waveformData;
+    waveformPlayer.waveform.waveformData = playerVoice?.waveformData;
     // Set the width and duration to render the full waveform, with the clipRect applied we only render a segment of it.
-    waveformPlayer.waveform.duration = playerVoice.length / Constants.MS_PER_SEC;
+    waveformPlayer.waveform.duration = (playerVoice?.length ?? 1000) / Constants.MS_PER_SEC;
 
     // Build opponent waveform.
     // waveformOpponent.waveform.forceUpdate = true;
     // note: if song only has one set of vocals (Vocals.ogg/mp3) then this is null and crashes charting editor
     // so we null check
-    if (opponentVoice != null)
-    {
-      waveformOpponent.waveform.waveformData = opponentVoice.waveformData;
-      waveformOpponent.waveform.duration = opponentVoice.length / Constants.MS_PER_SEC;
-    }
+    waveformOpponent.waveform.waveformData = opponentVoice?.waveformData;
+    waveformOpponent.waveform.duration = (opponentVoice?.length ?? 1000) / Constants.MS_PER_SEC;
 
     // Build instrumental waveform.
     // waveformInstrumental.waveform.forceUpdate = true;
     waveformInstrumental.waveform.waveformData = chartEditorState.audioInstTrack.waveformData;
-    waveformInstrumental.waveform.duration = instTrack.length / Constants.MS_PER_SEC;
+    waveformInstrumental.waveform.duration = (instTrack?.length ?? 1000) / Constants.MS_PER_SEC;
 
     addOffsetsToAudioPreview();
   }

From d535a3f5475c6a31e774287ee22d09aa26fa035d Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Sat, 17 Feb 2024 02:13:19 -0500
Subject: [PATCH 10/30] Update haxeui

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

diff --git a/hmm.json b/hmm.json
index 26cb0d0b4..700b42dfe 100644
--- a/hmm.json
+++ b/hmm.json
@@ -54,14 +54,14 @@
       "name": "haxeui-core",
       "type": "git",
       "dir": null,
-      "ref": "8a7846b",
+      "ref": "0212d8fdfcafeb5f0d5a41e1ddba8ff21d0e183b",
       "url": "https://github.com/haxeui/haxeui-core"
     },
     {
       "name": "haxeui-flixel",
       "type": "git",
       "dir": null,
-      "ref": "e9f880522e27134b29df4067f82df7d7e5237b70",
+      "ref": "63a906a6148958dbfde8c7b48d90b0693767fd95",
       "url": "https://github.com/haxeui/haxeui-flixel"
     },
     {

From da7cb559bb7a55f46c5c8643ac24ad79ac2b7152 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Sat, 17 Feb 2024 02:13:29 -0500
Subject: [PATCH 11/30] Update assets submodule.

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

diff --git a/assets b/assets
index 6c657fc8f..75ac8ec25 160000
--- a/assets
+++ b/assets
@@ -1 +1 @@
-Subproject commit 6c657fc8f6537500a98ebcd04bb7ce3f9b69a322
+Subproject commit 75ac8ec2564c9a56e8282b0853091ecd8b4f2dfd

From 44623071cd62821a8d2708b1664a7b4ba84c1015 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Sun, 18 Feb 2024 03:02:36 -0500
Subject: [PATCH 12/30] Redo event stuff for abot and game over audio logic

---
 source/funkin/audio/FunkinSound.hx            |  1 +
 source/funkin/data/event/SongEventRegistry.hx |  6 +-
 source/funkin/data/song/SongData.hx           | 28 +++----
 source/funkin/data/song/SongDataUtils.hx      |  2 +-
 source/funkin/modding/events/ScriptEvent.hx   |  8 +-
 source/funkin/play/GameOverSubState.hx        | 83 ++++++++++++++++---
 .../ui/debug/charting/ChartEditorState.hx     |  6 +-
 .../charting/commands/SelectItemsCommand.hx   |  4 +-
 .../commands/SetItemSelectionCommand.hx       |  4 +-
 .../components/ChartEditorEventSprite.hx      |  4 +-
 .../toolboxes/ChartEditorEventDataToolbox.hx  |  4 +-
 11 files changed, 104 insertions(+), 46 deletions(-)

diff --git a/source/funkin/audio/FunkinSound.hx b/source/funkin/audio/FunkinSound.hx
index e7ce68d08..0e6bd6893 100644
--- a/source/funkin/audio/FunkinSound.hx
+++ b/source/funkin/audio/FunkinSound.hx
@@ -128,6 +128,7 @@ class FunkinSound extends FlxSound implements ICloneable<FunkinSound>
 
   function fixMaxVolume():Void
   {
+    return;
     #if lime_openal
     // This code is pretty fragile, it reaches through 5 layers of private access.
     @:privateAccess
diff --git a/source/funkin/data/event/SongEventRegistry.hx b/source/funkin/data/event/SongEventRegistry.hx
index dc5589813..8732e3b98 100644
--- a/source/funkin/data/event/SongEventRegistry.hx
+++ b/source/funkin/data/event/SongEventRegistry.hx
@@ -108,8 +108,8 @@ class SongEventRegistry
 
   public static function handleEvent(data:SongEventData):Void
   {
-    var eventType:String = data.event;
-    var eventHandler:SongEvent = eventCache.get(eventType);
+    var eventKind:String = data.eventKind;
+    var eventHandler:SongEvent = eventCache.get(eventKind);
 
     if (eventHandler != null)
     {
@@ -117,7 +117,7 @@ class SongEventRegistry
     }
     else
     {
-      trace('WARNING: No event handler for event with id: ${eventType}');
+      trace('WARNING: No event handler for event with kind: ${eventKind}');
     }
 
     data.activated = true;
diff --git a/source/funkin/data/song/SongData.hx b/source/funkin/data/song/SongData.hx
index cc568ec66..24febea86 100644
--- a/source/funkin/data/song/SongData.hx
+++ b/source/funkin/data/song/SongData.hx
@@ -650,7 +650,7 @@ class SongEventDataRaw implements ICloneable<SongEventDataRaw>
    * Custom events can be added by scripts with the `ScriptedSongEvent` class.
    */
   @:alias("e")
-  public var event:String;
+  public var eventKind:String;
 
   /**
    * The data for the event.
@@ -670,10 +670,10 @@ class SongEventDataRaw implements ICloneable<SongEventDataRaw>
   @:jignored
   public var activated:Bool = false;
 
-  public function new(time:Float, event:String, value:Dynamic = null)
+  public function new(time:Float, eventKind:String, value:Dynamic = null)
   {
     this.time = time;
-    this.event = event;
+    this.eventKind = eventKind;
     this.value = value;
   }
 
@@ -689,19 +689,19 @@ class SongEventDataRaw implements ICloneable<SongEventDataRaw>
 
   public function clone():SongEventDataRaw
   {
-    return new SongEventDataRaw(this.time, this.event, this.value);
+    return new SongEventDataRaw(this.time, this.eventKind, this.value);
   }
 }
 
 /**
  * Wrap SongEventData in an abstract so we can overload operators.
  */
-@:forward(time, event, value, activated, getStepTime, clone)
+@:forward(time, eventKind, value, activated, getStepTime, clone)
 abstract SongEventData(SongEventDataRaw) from SongEventDataRaw to SongEventDataRaw
 {
-  public function new(time:Float, event:String, value:Dynamic = null)
+  public function new(time:Float, eventKind:String, value:Dynamic = null)
   {
-    this = new SongEventDataRaw(time, event, value);
+    this = new SongEventDataRaw(time, eventKind, value);
   }
 
   public inline function valueAsStruct(?defaultKey:String = "key"):Dynamic
@@ -728,12 +728,12 @@ abstract SongEventData(SongEventDataRaw) from SongEventDataRaw to SongEventDataR
 
   public inline function getHandler():Null<SongEvent>
   {
-    return SongEventRegistry.getEvent(this.event);
+    return SongEventRegistry.getEvent(this.eventKind);
   }
 
   public inline function getSchema():Null<SongEventSchema>
   {
-    return SongEventRegistry.getEventSchema(this.event);
+    return SongEventRegistry.getEventSchema(this.eventKind);
   }
 
   public inline function getDynamic(key:String):Null<Dynamic>
@@ -786,7 +786,7 @@ abstract SongEventData(SongEventDataRaw) from SongEventDataRaw to SongEventDataR
     var eventHandler = getHandler();
     var eventSchema = getSchema();
 
-    if (eventSchema == null) return 'Unknown Event: ${this.event}';
+    if (eventSchema == null) return 'Unknown Event: ${this.eventKind}';
 
     var result = '${eventHandler.getTitle()}';
 
@@ -811,19 +811,19 @@ abstract SongEventData(SongEventDataRaw) from SongEventDataRaw to SongEventDataR
 
   public function clone():SongEventData
   {
-    return new SongEventData(this.time, this.event, this.value);
+    return new SongEventData(this.time, this.eventKind, this.value);
   }
 
   @:op(A == B)
   public function op_equals(other:SongEventData):Bool
   {
-    return this.time == other.time && this.event == other.event && this.value == other.value;
+    return this.time == other.time && this.eventKind == other.eventKind && this.value == other.value;
   }
 
   @:op(A != B)
   public function op_notEquals(other:SongEventData):Bool
   {
-    return this.time != other.time || this.event != other.event || this.value != other.value;
+    return this.time != other.time || this.eventKind != other.eventKind || this.value != other.value;
   }
 
   @:op(A > B)
@@ -855,7 +855,7 @@ abstract SongEventData(SongEventDataRaw) from SongEventDataRaw to SongEventDataR
    */
   public function toString():String
   {
-    return 'SongEventData(${this.time}ms, ${this.event}: ${this.value})';
+    return 'SongEventData(${this.time}ms, ${this.eventKind}: ${this.value})';
   }
 }
 
diff --git a/source/funkin/data/song/SongDataUtils.hx b/source/funkin/data/song/SongDataUtils.hx
index 7f3b01eb4..c93c5379a 100644
--- a/source/funkin/data/song/SongDataUtils.hx
+++ b/source/funkin/data/song/SongDataUtils.hx
@@ -47,7 +47,7 @@ class SongDataUtils
   public static function offsetSongEventData(events:Array<SongEventData>, offset:Float):Array<SongEventData>
   {
     return events.map(function(event:SongEventData):SongEventData {
-      return new SongEventData(event.time + offset, event.event, event.value);
+      return new SongEventData(event.time + offset, event.eventKind, event.value);
     });
   }
 
diff --git a/source/funkin/modding/events/ScriptEvent.hx b/source/funkin/modding/events/ScriptEvent.hx
index 68265a103..5d522e3ae 100644
--- a/source/funkin/modding/events/ScriptEvent.hx
+++ b/source/funkin/modding/events/ScriptEvent.hx
@@ -189,17 +189,17 @@ class SongEventScriptEvent extends ScriptEvent
    * The note associated with this event.
    * You cannot replace it, but you can edit it.
    */
-  public var event(default, null):funkin.data.song.SongData.SongEventData;
+  public var eventData(default, null):funkin.data.song.SongData.SongEventData;
 
-  public function new(event:funkin.data.song.SongData.SongEventData):Void
+  public function new(eventData:funkin.data.song.SongData.SongEventData):Void
   {
     super(SONG_EVENT, true);
-    this.event = event;
+    this.eventData = eventData;
   }
 
   public override function toString():String
   {
-    return 'SongEventScriptEvent(event=' + event + ')';
+    return 'SongEventScriptEvent(event=' + eventData + ')';
   }
 }
 
diff --git a/source/funkin/play/GameOverSubState.hx b/source/funkin/play/GameOverSubState.hx
index 62c3409b7..b7e92d10f 100644
--- a/source/funkin/play/GameOverSubState.hx
+++ b/source/funkin/play/GameOverSubState.hx
@@ -4,6 +4,7 @@ import flixel.FlxG;
 import flixel.FlxObject;
 import flixel.FlxSprite;
 import flixel.sound.FlxSound;
+import funkin.audio.FunkinSound;
 import flixel.util.FlxColor;
 import flixel.util.FlxTimer;
 import funkin.graphics.FunkinSprite;
@@ -64,7 +65,7 @@ class GameOverSubState extends MusicBeatSubState
   /**
    * The music playing in the background of the state.
    */
-  var gameOverMusic:FlxSound = new FlxSound();
+  var gameOverMusic:Null<FunkinSound> = null;
 
   /**
    * Whether the player has confirmed and prepared to restart the level.
@@ -72,6 +73,11 @@ class GameOverSubState extends MusicBeatSubState
    */
   var isEnding:Bool = false;
 
+  /**
+   * Whether the death music is on its first loop.
+   */
+  var isStarting:Bool = true;
+
   var isChartingMode:Bool = false;
 
   var transparent:Bool;
@@ -141,10 +147,6 @@ class GameOverSubState extends MusicBeatSubState
     // Set up the audio
     //
 
-    // Prepare the game over music.
-    FlxG.sound.list.add(gameOverMusic);
-    gameOverMusic.stop();
-
     // The conductor now represents the BPM of the game over music.
     Conductor.instance.update(0);
   }
@@ -223,7 +225,7 @@ class GameOverSubState extends MusicBeatSubState
       }
     }
 
-    if (gameOverMusic.playing)
+    if (gameOverMusic != null && gameOverMusic.playing)
     {
       // Match the conductor to the music.
       // This enables the stepHit and beatHit events.
@@ -298,24 +300,71 @@ class GameOverSubState extends MusicBeatSubState
     ScriptEventDispatcher.callEvent(boyfriend, event);
   }
 
+  /**
+   * Rather than hardcoding stuff, we look for the presence of a music file
+   * with the given suffix, and strip it down until we find one that's valid.
+   */
+  function resolveMusicPath(suffix:String, starting:Bool = false, ending:Bool = false):Null<String>
+  {
+    var basePath = 'gameplay/gameover/gameOver';
+    if (starting) basePath += 'Start';
+    else if (ending) basePath += 'End';
+
+    var musicPath = Paths.music(basePath + suffix);
+    while (!Assets.exists(musicPath) && suffix.length > 0)
+    {
+      suffix = suffix.split('-').slice(0, -1).join('-');
+      musicPath = Paths.music(basePath + suffix);
+    }
+    if (!Assets.exists(musicPath)) return null;
+    trace('Resolved music path: ' + musicPath);
+    return musicPath;
+  }
+
   /**
    * Starts the death music at the appropriate volume.
    * @param startingVolume
    */
-  public function startDeathMusic(?startingVolume:Float = 1, force:Bool = false):Void
+  public function startDeathMusic(startingVolume:Float = 1, force:Bool = false):Void
   {
-    var musicPath = Paths.music('gameplay/gameover/gameOver' + musicSuffix);
-    if (isEnding)
+    var musicPath = resolveMusicPath(musicSuffix, isStarting, isEnding);
+    var onComplete = null;
+    if (isStarting)
     {
-      musicPath = Paths.music('gameplay/gameover/gameOverEnd' + musicSuffix);
+      if (musicPath == null)
+      {
+        isStarting = false;
+        musicPath = resolveMusicPath(musicSuffix, isStarting, isEnding);
+      }
+      else
+      {
+        isStarting = false;
+        onComplete = function() {
+          // We need to force to ensure that the non-starting music plays.
+          startDeathMusic(1.0, true);
+        };
+      }
     }
-    if (!gameOverMusic.playing || force)
+
+    if (musicPath == null)
     {
-      gameOverMusic.loadEmbedded(musicPath);
+      trace('Could not find game over music!');
+      return;
+    }
+    else if (gameOverMusic == null || !gameOverMusic.playing || force)
+    {
+      if (gameOverMusic != null) gameOverMusic.stop();
+      gameOverMusic = FunkinSound.load(musicPath);
       gameOverMusic.volume = startingVolume;
-      gameOverMusic.looped = !isEnding;
+      gameOverMusic.looped = !(isEnding || isStarting);
+      gameOverMusic.onComplete = onComplete;
       gameOverMusic.play();
     }
+    else
+    {
+      @:privateAccess
+      trace('Music already playing! ${gameOverMusic?._label}');
+    }
   }
 
   static var blueballed:Bool = false;
@@ -358,6 +407,14 @@ class GameOverSubState extends MusicBeatSubState
     });
   }
 
+  public override function destroy()
+  {
+    super.destroy();
+    if (gameOverMusic != null) gameOverMusic.stop();
+    gameOverMusic = null;
+    instance = null;
+  }
+
   public override function toString():String
   {
     return "GameOverSubState";
diff --git a/source/funkin/ui/debug/charting/ChartEditorState.hx b/source/funkin/ui/debug/charting/ChartEditorState.hx
index 48a6e70c9..e9748a4ba 100644
--- a/source/funkin/ui/debug/charting/ChartEditorState.hx
+++ b/source/funkin/ui/debug/charting/ChartEditorState.hx
@@ -3417,7 +3417,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
           // Update the event sprite's position.
           eventSprite.updateEventPosition(renderedEvents);
           // Update the sprite's graphic. TODO: Is this inefficient?
-          eventSprite.playAnimation(eventSprite.eventData.event);
+          eventSprite.playAnimation(eventSprite.eventData.eventKind);
         }
         else
         {
@@ -4678,9 +4678,9 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
 
             var eventData:SongEventData = gridGhostEvent.eventData != null ? gridGhostEvent.eventData : new SongEventData(cursorMs, eventKindToPlace, null);
 
-            if (eventKindToPlace != eventData.event)
+            if (eventKindToPlace != eventData.eventKind)
             {
-              eventData.event = eventKindToPlace;
+              eventData.eventKind = eventKindToPlace;
             }
             eventData.time = cursorSnappedMs;
 
diff --git a/source/funkin/ui/debug/charting/commands/SelectItemsCommand.hx b/source/funkin/ui/debug/charting/commands/SelectItemsCommand.hx
index 891ac9ebd..423295f1a 100644
--- a/source/funkin/ui/debug/charting/commands/SelectItemsCommand.hx
+++ b/source/funkin/ui/debug/charting/commands/SelectItemsCommand.hx
@@ -38,7 +38,7 @@ class SelectItemsCommand implements ChartEditorCommand
     {
       var eventSelected = this.events[0];
 
-      state.eventKindToPlace = eventSelected.event;
+      state.eventKindToPlace = eventSelected.eventKind;
 
       // This code is here to parse event data that's not built as a struct for some reason.
       // TODO: Clean this up or get rid of it.
@@ -46,7 +46,7 @@ class SelectItemsCommand implements ChartEditorCommand
       var defaultKey = null;
       if (eventSchema == null)
       {
-        trace('[WARNING] Event schema not found for event ${eventSelected.event}.');
+        trace('[WARNING] Event schema not found for event ${eventSelected.eventKind}.');
       }
       else
       {
diff --git a/source/funkin/ui/debug/charting/commands/SetItemSelectionCommand.hx b/source/funkin/ui/debug/charting/commands/SetItemSelectionCommand.hx
index 0b540dbeb..46fcca87c 100644
--- a/source/funkin/ui/debug/charting/commands/SetItemSelectionCommand.hx
+++ b/source/funkin/ui/debug/charting/commands/SetItemSelectionCommand.hx
@@ -35,7 +35,7 @@ class SetItemSelectionCommand implements ChartEditorCommand
     {
       var eventSelected = this.events[0];
 
-      state.eventKindToPlace = eventSelected.event;
+      state.eventKindToPlace = eventSelected.eventKind;
 
       // This code is here to parse event data that's not built as a struct for some reason.
       // TODO: Clean this up or get rid of it.
@@ -43,7 +43,7 @@ class SetItemSelectionCommand implements ChartEditorCommand
       var defaultKey = null;
       if (eventSchema == null)
       {
-        trace('[WARNING] Event schema not found for event ${eventSelected.event}.');
+        trace('[WARNING] Event schema not found for event ${eventSelected.eventKind}.');
       }
       else
       {
diff --git a/source/funkin/ui/debug/charting/components/ChartEditorEventSprite.hx b/source/funkin/ui/debug/charting/components/ChartEditorEventSprite.hx
index e3dae37cf..f680095d7 100644
--- a/source/funkin/ui/debug/charting/components/ChartEditorEventSprite.hx
+++ b/source/funkin/ui/debug/charting/components/ChartEditorEventSprite.hx
@@ -133,7 +133,7 @@ class ChartEditorEventSprite extends FlxSprite
 
   public function playAnimation(?name:String):Void
   {
-    if (name == null) name = eventData?.event ?? DEFAULT_EVENT;
+    if (name == null) name = eventData?.eventKind ?? DEFAULT_EVENT;
 
     var correctedName = correctAnimationName(name);
     this.animation.play(correctedName);
@@ -160,7 +160,7 @@ class ChartEditorEventSprite extends FlxSprite
     else
     {
       this.visible = true;
-      playAnimation(value.event);
+      playAnimation(value.eventKind);
       this.eventData = value;
       // Update the position to match the note data.
       updateEventPosition();
diff --git a/source/funkin/ui/debug/charting/toolboxes/ChartEditorEventDataToolbox.hx b/source/funkin/ui/debug/charting/toolboxes/ChartEditorEventDataToolbox.hx
index 7b163ad3d..ec46e1f85 100644
--- a/source/funkin/ui/debug/charting/toolboxes/ChartEditorEventDataToolbox.hx
+++ b/source/funkin/ui/debug/charting/toolboxes/ChartEditorEventDataToolbox.hx
@@ -90,7 +90,7 @@ class ChartEditorEventDataToolbox extends ChartEditorBaseToolbox
         // Edit the event data of any selected events.
         for (event in chartEditorState.currentEventSelection)
         {
-          event.event = chartEditorState.eventKindToPlace;
+          event.eventKind = chartEditorState.eventKindToPlace;
           event.value = chartEditorState.eventDataToPlace;
         }
         chartEditorState.saveDataDirty = true;
@@ -255,7 +255,7 @@ class ChartEditorEventDataToolbox extends ChartEditorBaseToolbox
         {
           for (event in chartEditorState.currentEventSelection)
           {
-            event.event = chartEditorState.eventKindToPlace;
+            event.eventKind = chartEditorState.eventKindToPlace;
             event.value = chartEditorState.eventDataToPlace;
           }
           chartEditorState.saveDataDirty = true;

From db428a3e3637ff3a633ffcf31c056c4053b299b0 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Mon, 19 Feb 2024 23:09:23 -0500
Subject: [PATCH 13/30] Update assets submodule.

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

diff --git a/assets b/assets
index 75ac8ec25..573ccf59f 160000
--- a/assets
+++ b/assets
@@ -1 +1 @@
-Subproject commit 75ac8ec2564c9a56e8282b0853091ecd8b4f2dfd
+Subproject commit 573ccf59f3ab5d2333d395810ef82195e8456467

From d888fb860d91d544a5d21864b9ddfccec576c8d0 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Tue, 20 Feb 2024 13:37:53 -0500
Subject: [PATCH 14/30] Remove support for >100% audio since it didn't actually
 boost the gain.

---
 source/funkin/audio/FunkinSound.hx                | 15 +--------------
 .../charting/handlers/ChartEditorAudioHandler.hx  |  4 +---
 2 files changed, 2 insertions(+), 17 deletions(-)

diff --git a/source/funkin/audio/FunkinSound.hx b/source/funkin/audio/FunkinSound.hx
index 0e6bd6893..ba157ed8e 100644
--- a/source/funkin/audio/FunkinSound.hx
+++ b/source/funkin/audio/FunkinSound.hx
@@ -23,7 +23,7 @@ import openfl.utils.AssetType;
 @:nullSafety
 class FunkinSound extends FlxSound implements ICloneable<FunkinSound>
 {
-  static final MAX_VOLUME:Float = 2.0;
+  static final MAX_VOLUME:Float = 1.0;
 
   static var cache(default, null):FlxTypedGroup<FunkinSound> = new FlxTypedGroup<FunkinSound>();
 
@@ -40,7 +40,6 @@ class FunkinSound extends FlxSound implements ICloneable<FunkinSound>
   override function set_volume(value:Float):Float
   {
     // Uncap the volume.
-    fixMaxVolume();
     _volume = FlxMath.bound(value, 0.0, MAX_VOLUME);
     updateTransform();
     return _volume;
@@ -126,18 +125,6 @@ class FunkinSound extends FlxSound implements ICloneable<FunkinSound>
     return this;
   }
 
-  function fixMaxVolume():Void
-  {
-    return;
-    #if lime_openal
-    // This code is pretty fragile, it reaches through 5 layers of private access.
-    @:privateAccess
-    var handle = this?._channel?.__source?.__backend?.handle;
-    if (handle == null) return;
-    lime.media.openal.AL.sourcef(handle, lime.media.openal.AL.MAX_GAIN, MAX_VOLUME);
-    #end
-  }
-
   public override function play(forceRestart:Bool = false, startTime:Float = 0, ?endTime:Float):FunkinSound
   {
     if (!exists) return this;
diff --git a/source/funkin/ui/debug/charting/handlers/ChartEditorAudioHandler.hx b/source/funkin/ui/debug/charting/handlers/ChartEditorAudioHandler.hx
index 363dc1567..5e3ffeb42 100644
--- a/source/funkin/ui/debug/charting/handlers/ChartEditorAudioHandler.hx
+++ b/source/funkin/ui/debug/charting/handlers/ChartEditorAudioHandler.hx
@@ -299,16 +299,14 @@ class ChartEditorAudioHandler
    */
   public static function playSound(_state:ChartEditorState, path:String, volume:Float = 1.0):Void
   {
-    var snd:FlxSound = FlxG.sound.list.recycle(FlxSound) ?? new FlxSound();
     var asset:Null<FlxSoundAsset> = FlxG.sound.cache(path);
     if (asset == null)
     {
       trace('WARN: Failed to play sound $path, asset not found.');
       return;
     }
-    snd.loadEmbedded(asset);
+    var snd:FunkinSound = FunkinSound.load(asset);
     snd.autoDestroy = true;
-    FlxG.sound.list.add(snd);
     snd.play(true);
     snd.volume = volume;
   }

From 907d9150c03e0b887a75b15163a8affe2eff1c07 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Wed, 21 Feb 2024 17:10:18 -0500
Subject: [PATCH 15/30] Add additional memory utilities and logging.

---
 .gitignore                                 |   1 +
 source/funkin/ui/debug/MemoryCounter.hx    |   3 +-
 source/funkin/util/MemoryUtil.hx           | 113 +++++++++++++++++++++
 source/funkin/util/logging/CrashHandler.hx |   8 ++
 4 files changed, 124 insertions(+), 1 deletion(-)
 create mode 100644 source/funkin/util/MemoryUtil.hx

diff --git a/.gitignore b/.gitignore
index b2fe731ea..34a0c5590 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,6 @@
 .DS_STORE
 .haxelib/
+.vs/
 APIStuff.hx
 dump/
 export/
diff --git a/source/funkin/ui/debug/MemoryCounter.hx b/source/funkin/ui/debug/MemoryCounter.hx
index 312d853e7..b25b55645 100644
--- a/source/funkin/ui/debug/MemoryCounter.hx
+++ b/source/funkin/ui/debug/MemoryCounter.hx
@@ -1,5 +1,6 @@
 package funkin.ui.debug;
 
+import funkin.util.MemoryUtil;
 import openfl.text.TextFormat;
 import openfl.system.System;
 import openfl.text.TextField;
@@ -35,7 +36,7 @@ class MemoryCounter extends TextField
   @:noCompletion
   #if !flash override #end function __enterFrame(deltaTime:Float):Void
   {
-    var mem:Float = Math.round(System.totalMemory / BYTES_PER_MEG / ROUND_TO) * ROUND_TO;
+    var mem:Float = Math.round(MemoryUtil.getMemoryUsed() / BYTES_PER_MEG / ROUND_TO) * ROUND_TO;
 
     if (mem > memPeak) memPeak = mem;
 
diff --git a/source/funkin/util/MemoryUtil.hx b/source/funkin/util/MemoryUtil.hx
new file mode 100644
index 000000000..6b5f7deea
--- /dev/null
+++ b/source/funkin/util/MemoryUtil.hx
@@ -0,0 +1,113 @@
+package funkin.util;
+
+/**
+ * Utilities for working with the garbage collector.
+ *
+ * HXCPP is built on Immix.
+ * HTML5 builds use the browser's built-in mark-and-sweep and JS has no APIs to interact with it.
+ * @see https://www.cs.cornell.edu/courses/cs6120/2019fa/blog/immix/
+ * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Memory_management
+ * @see https://betterprogramming.pub/deep-dive-into-garbage-collection-in-javascript-6881610239a
+ * @see https://github.com/HaxeFoundation/hxcpp/blob/master/docs/build_xml/Defines.md
+ * @see cpp.vm.Gc
+ */
+class MemoryUtil
+{
+  public static function buildGCInfo():String
+  {
+    #if cpp
+    var result = "HXCPP-Immix:";
+    result += '\n- Memory Used: ${cpp.vm.Gc.memInfo(cpp.vm.Gc.MEM_INFO_USAGE)} bytes';
+    result += '\n- Memory Reserved: ${cpp.vm.Gc.memInfo(cpp.vm.Gc.MEM_INFO_RESERVED)} bytes';
+    result += '\n- Memory Current Pool: ${cpp.vm.Gc.memInfo(cpp.vm.Gc.MEM_INFO_CURRENT)} bytes';
+    result += '\n- Memory Large Pool: ${cpp.vm.Gc.memInfo(cpp.vm.Gc.MEM_INFO_LARGE)} bytes';
+    result += '\n- HXCPP Debugger: ${#if HXCPP_DEBUGGER 'Enabled' #else 'Disabled' #end}';
+    result += '\n- HXCPP Exp Generational Mode: ${#if HXCPP_GC_GENERATIONAL 'Enabled' #else 'Disabled' #end}';
+    result += '\n- HXCPP Exp Moving GC: ${#if HXCPP_GC_MOVING 'Enabled' #else 'Disabled' #end}';
+    result += '\n- HXCPP Exp Moving GC: ${#if HXCPP_GC_DYNAMIC_SIZE 'Enabled' #else 'Disabled' #end}';
+    result += '\n- HXCPP Exp Moving GC: ${#if HXCPP_GC_BIG_BLOCKS 'Enabled' #else 'Disabled' #end}';
+    result += '\n- HXCPP Debug Link: ${#if HXCPP_DEBUG_LINK 'Enabled' #else 'Disabled' #end}';
+    result += '\n- HXCPP Stack Trace: ${#if HXCPP_STACK_TRACE 'Enabled' #else 'Disabled' #end}';
+    result += '\n- HXCPP Stack Trace Line Numbers: ${#if HXCPP_STACK_LINE 'Enabled' #else 'Disabled' #end}';
+    result += '\n- HXCPP Pointer Validation: ${#if HXCPP_CHECK_POINTER 'Enabled' #else 'Disabled' #end}';
+    result += '\n- HXCPP Profiler: ${#if HXCPP_PROFILER 'Enabled' #else 'Disabled' #end}';
+    result += '\n- HXCPP Local Telemetry: ${#if HXCPP_TELEMETRY 'Enabled' #else 'Disabled' #end}';
+    result += '\n- HXCPP C++11: ${#if HXCPP_CPP11 'Enabled' #else 'Disabled' #end}';
+    result += '\n- Source Annotation: ${#if annotate_source 'Enabled' #else 'Disabled' #end}';
+    #elseif js
+    var result = "JS-MNS:";
+    result += '\n- Memory Used: ${getMemoryUsed()} bytes';
+    #else
+    var result = "Unknown GC";
+    #end
+
+    return result;
+  }
+
+  /**
+   * Calculate the total memory usage of the program, in bytes.
+   * @return Int
+   */
+  public static function getMemoryUsed():Int
+  {
+    #if cpp
+    // There is also Gc.MEM_INFO_RESERVED, MEM_INFO_CURRENT, and MEM_INFO_LARGE.
+    return cpp.vm.Gc.memInfo(cpp.vm.Gc.MEM_INFO_USAGE);
+    #else
+    return openfl.system.System.totalMemory;
+    #end
+  }
+
+  /**
+   * Enable garbage collection if it was previously disabled.
+   */
+  public static function enable():Void
+  {
+    #if cpp
+    cpp.vm.Gc.enable(true);
+    #else
+    throw "Not implemented!";
+    #end
+  }
+
+  /**
+   * Disable garbage collection entirely.
+   */
+  public static function disable():Void
+  {
+    #if cpp
+    cpp.vm.Gc.enable(false);
+    #else
+    throw "Not implemented!";
+    #end
+  }
+
+  /**
+   * Manually perform garbage collection once.
+   * Should only be called from the main thread.
+   * @param major `true` to perform major collection, whatever that means.
+   */
+  public static function collect(major:Bool = false):Void
+  {
+    #if cpp
+    cpp.vm.Gc.run(major);
+    #else
+    throw "Not implemented!";
+    #end
+  }
+
+  /**
+   * Perform major garbage collection repeatedly until less than 16kb of memory is freed in one operation.
+   * Should only be called from the main thread.
+   *
+   * NOTE: This is DIFFERENT from actual compaction,
+   */
+  public static function compact():Void
+  {
+    #if cpp
+    cpp.vm.Gc.compact();
+    #else
+    throw "Not implemented!";
+    #end
+  }
+}
diff --git a/source/funkin/util/logging/CrashHandler.hx b/source/funkin/util/logging/CrashHandler.hx
index ad5983e52..93d566710 100644
--- a/source/funkin/util/logging/CrashHandler.hx
+++ b/source/funkin/util/logging/CrashHandler.hx
@@ -125,6 +125,14 @@ class CrashHandler
 
     fullContents += '=====================\n';
 
+    fullContents += '\n';
+
+    fullContents += MemoryUtil.buildGCInfo();
+
+    fullContents += '\n\n';
+
+    fullContents += '=====================\n';
+
     fullContents += 'Haxelibs: \n';
 
     for (lib in Constants.LIBRARY_VERSIONS)

From 539b688055a75aa706c631c8c0fc13ec6cf782b6 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Thu, 22 Feb 2024 18:55:24 -0500
Subject: [PATCH 16/30] Exploration in expanding FunkinSprite for optimization

---
 assets                                        |   2 +-
 source/funkin/graphics/FunkinSprite.hx        | 122 +++++++++++++++++-
 source/funkin/play/Countdown.hx               |   3 +-
 source/funkin/play/GitarooPause.hx            |  12 +-
 source/funkin/play/PauseSubState.hx           |   3 +-
 source/funkin/play/PlayState.hx               |  25 ++--
 source/funkin/play/ResultState.hx             |  19 +--
 source/funkin/play/components/HealthIcon.hx   |   5 +-
 source/funkin/play/components/PopUpStuff.hx   |  26 ++--
 source/funkin/play/notes/NoteSprite.hx        |   3 +-
 .../funkin/play/notes/notestyle/NoteStyle.hx  |   6 +
 source/funkin/play/stage/Stage.hx             |   6 +-
 source/funkin/ui/freeplay/DJBoyfriend.hx      |   2 -
 source/funkin/ui/freeplay/FreeplayFlames.hx   |   2 +-
 source/funkin/ui/freeplay/FreeplayScore.hx    |   2 +-
 source/funkin/ui/freeplay/FreeplayState.hx    |   8 +-
 source/funkin/ui/transition/LoadingState.hx   |  10 +-
 17 files changed, 197 insertions(+), 59 deletions(-)

diff --git a/assets b/assets
index 03f544a7b..ffbf73c76 160000
--- a/assets
+++ b/assets
@@ -1 +1 @@
-Subproject commit 03f544a7b42fed43c521cb596e487ad4ae129576
+Subproject commit ffbf73c76860a2747eb11eeed14099e186700956
diff --git a/source/funkin/graphics/FunkinSprite.hx b/source/funkin/graphics/FunkinSprite.hx
index 487aaac34..6c8ce1308 100644
--- a/source/funkin/graphics/FunkinSprite.hx
+++ b/source/funkin/graphics/FunkinSprite.hx
@@ -6,6 +6,8 @@ import flixel.graphics.FlxGraphic;
 
 /**
  * An FlxSprite with additional functionality.
+ * - A more efficient method for creating solid color sprites.
+ * - TODO: Better cache handling for textures.
  */
 class FunkinSprite extends FlxSprite
 {
@@ -18,19 +20,135 @@ class FunkinSprite extends FlxSprite
     super(x, y);
   }
 
+  /**
+   * Create a new FunkinSprite with a static texture.
+   * @param x The starting X position.
+   * @param y The starting Y position.
+   * @param key The key of the texture to load.
+   * @return The new FunkinSprite.
+   */
+  public static function create(x:Float = 0.0, y:Float = 0.0, key:String):FunkinSprite
+  {
+    var sprite = new FunkinSprite(x, y);
+    sprite.loadTexture(key);
+    return sprite;
+  }
+
+  /**
+   * Create a new FunkinSprite with a Sparrow atlas animated texture.
+   * @param x The starting X position.
+   * @param y The starting Y position.
+   * @param key The key of the texture to load.
+   * @return The new FunkinSprite.
+   */
+  public static function createSparrow(x:Float = 0.0, y:Float = 0.0, key:String):FunkinSprite
+  {
+    var sprite = new FunkinSprite(x, y);
+    sprite.loadSparrow(key);
+    return sprite;
+  }
+
+  /**
+   * Create a new FunkinSprite with a Packer atlas animated texture.
+   * @param x The starting X position.
+   * @param y The starting Y position.
+   * @param key The key of the texture to load.
+   * @return The new FunkinSprite.
+   */
+  public static function createPacker(x:Float = 0.0, y:Float = 0.0, key:String):FunkinSprite
+  {
+    var sprite = new FunkinSprite(x, y);
+    sprite.loadPacker(key);
+    return sprite;
+  }
+
+  /**
+   * Load a static image as the sprite's texture.
+   * @param key The key of the texture to load.
+   * @return This sprite, for chaining.
+   */
+  public function loadTexture(key:String):FunkinSprite
+  {
+    if (!isTextureCached(key)) FlxG.log.warn('Texture not cached, may experience stuttering! $key');
+
+    loadGraphic(key);
+
+    return this;
+  }
+
+  /**
+   * Load an animated texture (Sparrow atlas spritesheet) as the sprite's texture.
+   * @param key The key of the texture to load.
+   * @return This sprite, for chaining.
+   */
+  public function loadSparrow(key:String):FunkinSprite
+  {
+    var graphicKey = Paths.image(key);
+    if (!isTextureCached(graphicKey)) FlxG.log.warn('Texture not cached, may experience stuttering! $graphicKey');
+
+    this.frames = Paths.getSparrowAtlas(key);
+
+    return this;
+  }
+
+  /**
+   * Load an animated texture (Packer atlas spritesheet) as the sprite's texture.
+   * @param key The key of the texture to load.
+   * @return This sprite, for chaining.
+   */
+  public function loadPacker(key:String):FunkinSprite
+  {
+    var graphicKey = Paths.image(key);
+    if (!isTextureCached(graphicKey)) FlxG.log.warn('Texture not cached, may experience stuttering! $graphicKey');
+
+    this.frames = Paths.getPackerAtlas(key);
+
+    return this;
+  }
+
+  public static function isTextureCached(key:String):Bool
+  {
+    return FlxG.bitmap.get(key) != null;
+  }
+
+  public static function cacheTexture(key:String):Void
+  {
+    var graphic = flixel.graphics.FlxGraphic.fromAssetKey(key, false, null, true);
+    if (graphic == null)
+    {
+      FlxG.log.warn('Failed to cache graphic: $key');
+    }
+    else
+    {
+      trace('Successfully cached graphic: $key');
+    }
+  }
+
+  public static function cacheSparrow(key:String):Void
+  {
+    cacheTexture(Paths.image(key));
+  }
+
+  public static function cachePacker(key:String):Void
+  {
+    cacheTexture(Paths.image(key));
+  }
+
   /**
    * Acts similarly to `makeGraphic`, but with improved memory usage,
-   * at the expense of not being able to paint onto the sprite.
+   * at the expense of not being able to paint onto the resulting sprite.
    *
    * @param width The target width of the sprite.
    * @param height The target height of the sprite.
    * @param color The color to fill the sprite with.
+   * @return This sprite, for chaining.
    */
   public function makeSolidColor(width:Int, height:Int, color:FlxColor = FlxColor.WHITE):FunkinSprite
   {
+    // Create a tiny solid color graphic and scale it up to the desired size.
     var graphic:FlxGraphic = FlxG.bitmap.create(2, 2, color, false, 'solid#${color.toHexString(true, false)}');
     frames = graphic.imageFrame;
-    scale.set(width / 2, height / 2);
+    scale.set(width / 2.0, height / 2.0);
     updateHitbox();
 
     return this;
diff --git a/source/funkin/play/Countdown.hx b/source/funkin/play/Countdown.hx
index 5b7ce9fc2..38e8986ef 100644
--- a/source/funkin/play/Countdown.hx
+++ b/source/funkin/play/Countdown.hx
@@ -3,6 +3,7 @@ package funkin.play;
 import flixel.tweens.FlxEase;
 import flixel.tweens.FlxTween;
 import flixel.FlxSprite;
+import funkin.graphics.FunkinSprite;
 import funkin.modding.events.ScriptEventDispatcher;
 import funkin.modding.module.ModuleHandler;
 import funkin.modding.events.ScriptEvent;
@@ -214,7 +215,7 @@ class Countdown
 
     if (spritePath == null) return;
 
-    var countdownSprite:FlxSprite = new FlxSprite(0, 0).loadGraphic(Paths.image(spritePath));
+    var countdownSprite:FunkinSprite = FunkinSprite.create(Paths.image(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 edeb4229c..1ed9dcf3b 100644
--- a/source/funkin/play/GitarooPause.hx
+++ b/source/funkin/play/GitarooPause.hx
@@ -3,6 +3,7 @@ package funkin.play;
 import flixel.FlxSprite;
 import flixel.graphics.frames.FlxAtlasFrames;
 import funkin.play.PlayState;
+import funkin.graphics.FunkinSprite;
 import funkin.ui.MusicBeatState;
 import flixel.addons.transition.FlxTransitionableState;
 import funkin.ui.mainmenu.MainMenuState;
@@ -27,25 +28,22 @@ class GitarooPause extends MusicBeatState
   {
     if (FlxG.sound.music != null) FlxG.sound.music.stop();
 
-    var bg:FlxSprite = new FlxSprite().loadGraphic(Paths.image('pauseAlt/pauseBG'));
+    var bg:FunkinSprite = FunkinSprite.create(Paths.image('pauseAlt/pauseBG'));
     add(bg);
 
-    var bf:FlxSprite = new FlxSprite(0, 30);
-    bf.frames = Paths.getSparrowAtlas('pauseAlt/bfLol');
+    var bf:FunkinSprite = FunkinSprite.createSparrow(0, 30, 'pauseAlt/bfLol');
     bf.animation.addByPrefix('lol', "funnyThing", 13);
     bf.animation.play('lol');
     add(bf);
     bf.screenCenter(X);
 
-    replayButton = new FlxSprite(FlxG.width * 0.28, FlxG.height * 0.7);
-    replayButton.frames = Paths.getSparrowAtlas('pauseAlt/pauseUI');
+    replayButton = FunkinSprite.createSparrow(FlxG.width * 0.28, FlxG.height * 0.7, 'pauseAlt/pauseUI');
     replayButton.animation.addByPrefix('selected', 'bluereplay', 0, false);
     replayButton.animation.appendByPrefix('selected', 'yellowreplay');
     replayButton.animation.play('selected');
     add(replayButton);
 
-    cancelButton = new FlxSprite(FlxG.width * 0.58, replayButton.y);
-    cancelButton.frames = Paths.getSparrowAtlas('pauseAlt/pauseUI');
+    cancelButton = FunkinSprite.createSparrow(FlxG.width * 0.58, replayButton.y, 'pauseAlt/pauseUI');
     cancelButton.animation.addByPrefix('selected', 'bluecancel', 0, false);
     cancelButton.animation.appendByPrefix('selected', 'cancelyellow');
     cancelButton.animation.play('selected');
diff --git a/source/funkin/play/PauseSubState.hx b/source/funkin/play/PauseSubState.hx
index 023b8d5be..1ae96268d 100644
--- a/source/funkin/play/PauseSubState.hx
+++ b/source/funkin/play/PauseSubState.hx
@@ -13,6 +13,7 @@ import flixel.util.FlxColor;
 import funkin.play.PlayState;
 import funkin.data.song.SongRegistry;
 import funkin.ui.Alphabet;
+import funkin.graphics.FunkinSprite;
 
 class PauseSubState extends MusicBeatSubState
 {
@@ -72,7 +73,7 @@ class PauseSubState extends MusicBeatSubState
 
     FlxG.sound.list.add(pauseMusic);
 
-    bg = new FlxSprite().makeGraphic(FlxG.width, FlxG.height, FlxColor.BLACK);
+    bg = new FunkinSprite().makeSolidColor(FlxG.width, FlxG.height, FlxColor.BLACK);
     bg.alpha = 0;
     bg.scrollFactor.set();
     add(bg);
diff --git a/source/funkin/play/PlayState.hx b/source/funkin/play/PlayState.hx
index 5033dd45b..bde68461b 100644
--- a/source/funkin/play/PlayState.hx
+++ b/source/funkin/play/PlayState.hx
@@ -10,12 +10,14 @@ import flixel.addons.transition.Transition;
 import flixel.addons.transition.Transition;
 import flixel.FlxCamera;
 import flixel.FlxObject;
-import flixel.FlxSprite;
 import flixel.FlxState;
+import funkin.graphics.FunkinSprite;
 import flixel.FlxSubState;
+import funkin.graphics.FunkinSprite;
 import flixel.math.FlxMath;
 import flixel.math.FlxPoint;
 import flixel.math.FlxRect;
+import funkin.graphics.FunkinSprite;
 import flixel.text.FlxText;
 import flixel.tweens.FlxEase;
 import flixel.tweens.FlxTween;
@@ -213,7 +215,7 @@ class PlayState extends MusicBeatSubState
    * The current gameplay camera will always follow this object. Tween its position to move the camera smoothly.
    *
    * It needs to be an object in the scene for the camera to be configured to follow it.
-   * We optionally make this an FlxSprite so we can draw a debug graphic with it.
+   * We optionally make this a sprite so we can draw a debug graphic with it.
    */
   public var cameraFollowPoint:FlxObject;
 
@@ -400,7 +402,7 @@ class PlayState extends MusicBeatSubState
    * The background image used for the health bar.
    * Emma says the image is slightly skewed so I'm leaving it as an image instead of a `createGraphic`.
    */
-  public var healthBarBG:FlxSprite;
+  public var healthBarBG:FunkinSprite;
 
   /**
    * The health icon representing the player.
@@ -568,12 +570,15 @@ class PlayState extends MusicBeatSubState
 
     if (!assertChartExists()) return;
 
+    // TODO: Add something to toggle this on!
     if (false)
     {
       // Displays the camera follow point as a sprite for debug purposes.
-      cameraFollowPoint = new FlxSprite(0, 0).makeGraphic(8, 8, 0xFF00FF00);
+      var cameraFollowPoint = new FunkinSprite(0, 0);
+      cameraFollowPoint.makeSolidColor(8, 8, 0xFF00FF00);
       cameraFollowPoint.visible = false;
       cameraFollowPoint.zIndex = 1000000;
+      this.cameraFollowPoint = cameraFollowPoint;
     }
     else
     {
@@ -1349,7 +1354,7 @@ class PlayState extends MusicBeatSubState
   function initHealthBar():Void
   {
     var healthBarYPos:Float = Preferences.downscroll ? FlxG.height * 0.1 : FlxG.height * 0.9;
-    healthBarBG = new FlxSprite(0, healthBarYPos).loadGraphic(Paths.image('healthBar'));
+    healthBarBG = FunkinSprite.create(0, healthBarYPos, Paths.image('healthBar'));
     healthBarBG.screenCenter(X);
     healthBarBG.scrollFactor.set(0, 0);
     add(healthBarBG);
@@ -1383,7 +1388,7 @@ class PlayState extends MusicBeatSubState
   function initMinimalMode():Void
   {
     // Create the green background.
-    var menuBG = new FlxSprite().loadGraphic(Paths.image('menuDesat'));
+    var menuBG = FunkinSprite.create(Paths.image('menuDesat'));
     menuBG.color = 0xFF4CAF50;
     menuBG.setGraphicSize(Std.int(menuBG.width * 1.1));
     menuBG.updateHitbox();
@@ -2623,10 +2628,10 @@ class PlayState extends MusicBeatSubState
         // TODO: Softcode this cutscene.
         if (currentSong.id == 'eggnog')
         {
-          var blackShit:FlxSprite = new FlxSprite(-FlxG.width * FlxG.camera.zoom,
-            -FlxG.height * FlxG.camera.zoom).makeGraphic(FlxG.width * 3, FlxG.height * 3, FlxColor.BLACK);
-          blackShit.scrollFactor.set();
-          add(blackShit);
+          var blackBG:FunkinSprite = new FunkinSprite(-FlxG.width * FlxG.camera.zoom, -FlxG.height * FlxG.camera.zoom);
+          blackBG.makeSolidColor(FlxG.width * 3, FlxG.height * 3, FlxColor.BLACK);
+          blackBG.scrollFactor.set();
+          add(blackBG);
           camHUD.visible = false;
           isInCutscene = true;
 
diff --git a/source/funkin/play/ResultState.hx b/source/funkin/play/ResultState.hx
index 9ffeefcfd..223043c28 100644
--- a/source/funkin/play/ResultState.hx
+++ b/source/funkin/play/ResultState.hx
@@ -4,6 +4,7 @@ import funkin.ui.story.StoryMenuState;
 import funkin.graphics.adobeanimate.FlxAtlasSprite;
 import flixel.FlxBasic;
 import flixel.FlxSprite;
+import funkin.graphics.FunkinSprite;
 import flixel.graphics.frames.FlxAtlasFrames;
 import flixel.graphics.frames.FlxBitmapFont;
 import flixel.group.FlxGroup.FlxTypedGroup;
@@ -96,8 +97,7 @@ class ResultState extends MusicBeatSubState
       bfSHIT.anim.play(); // unpauses this anim, since it's on PlayOnce!
     };
 
-    var gf:FlxSprite = new FlxSprite(500, 300);
-    gf.frames = Paths.getSparrowAtlas('resultScreen/resultGirlfriendGOOD');
+    var gf:FlxSprite = FunkinSprite.createSparrow(500, 300, 'resultScreen/resultGirlfriendGOOD');
     gf.animation.addByPrefix("clap", "Girlfriend Good Anim", 24, false);
     gf.visible = false;
     gf.animation.finishCallback = _ -> {
@@ -105,8 +105,7 @@ class ResultState extends MusicBeatSubState
     };
     add(gf);
 
-    var boyfriend:FlxSprite = new FlxSprite(640, -200);
-    boyfriend.frames = Paths.getSparrowAtlas('resultScreen/resultBoyfriendGOOD');
+    var boyfriend:FlxSprite = FunkinSprite.createSparrow(640, -200, 'resultScreen/resultBoyfriendGOOD');
     boyfriend.animation.addByPrefix("fall", "Boyfriend Good", 24, false);
     boyfriend.visible = false;
     boyfriend.animation.finishCallback = function(_) {
@@ -115,8 +114,7 @@ class ResultState extends MusicBeatSubState
 
     add(boyfriend);
 
-    var soundSystem:FlxSprite = new FlxSprite(-15, -180);
-    soundSystem.frames = Paths.getSparrowAtlas("resultScreen/soundSystem");
+    var soundSystem:FlxSprite = FunkinSprite.createSparrow(-15, -180, 'resultScreen/soundSystem');
     soundSystem.animation.addByPrefix("idle", "sound system", 24, false);
     soundSystem.visible = false;
     new FlxTimer().start(0.4, _ -> {
@@ -162,20 +160,17 @@ class ResultState extends MusicBeatSubState
     FlxTween.tween(blackTopBar, {y: 0}, 0.4, {ease: FlxEase.quartOut, startDelay: 0.5});
     add(blackTopBar);
 
-    var resultsAnim:FlxSprite = new FlxSprite(-200, -10);
-    resultsAnim.frames = Paths.getSparrowAtlas("resultScreen/results");
+    var resultsAnim:FunkinSprite = FunkinSprite.createSparrow(-200, -10, "resultScreen/results");
     resultsAnim.animation.addByPrefix("result", "results", 24, false);
     resultsAnim.animation.play("result");
     add(resultsAnim);
 
-    var ratingsPopin:FlxSprite = new FlxSprite(-150, 120);
-    ratingsPopin.frames = Paths.getSparrowAtlas("resultScreen/ratingsPopin");
+    var ratingsPopin:FunkinSprite = FunkinSprite.createSparrow(-150, 120, "resultScreen/ratingsPopin");
     ratingsPopin.animation.addByPrefix("idle", "Categories", 24, false);
     ratingsPopin.visible = false;
     add(ratingsPopin);
 
-    var scorePopin:FlxSprite = new FlxSprite(-180, 520);
-    scorePopin.frames = Paths.getSparrowAtlas("resultScreen/scorePopin");
+    var scorePopin:FunkinSprite = FunkinSprite.createSparrow(-180, 520, "resultScreen/scorePopin");
     scorePopin.animation.addByPrefix("score", "tally score", 24, false);
     scorePopin.visible = false;
     add(scorePopin);
diff --git a/source/funkin/play/components/HealthIcon.hx b/source/funkin/play/components/HealthIcon.hx
index 420a4fdc4..419c5b3ea 100644
--- a/source/funkin/play/components/HealthIcon.hx
+++ b/source/funkin/play/components/HealthIcon.hx
@@ -6,6 +6,7 @@ import flixel.math.FlxMath;
 import flixel.math.FlxPoint;
 import funkin.play.character.CharacterData.CharacterDataParser;
 import openfl.utils.Assets;
+import funkin.graphics.FunkinSprite;
 import funkin.util.MathUtil;
 
 /**
@@ -26,7 +27,7 @@ import funkin.util.MathUtil;
  * @author MasterEric
  */
 @:nullSafety
-class HealthIcon extends FlxSprite
+class HealthIcon extends FunkinSprite
 {
   /**
    * The character this icon is representing.
@@ -408,7 +409,7 @@ class HealthIcon extends FlxSprite
 
     if (!isLegacyStyle)
     {
-      frames = Paths.getSparrowAtlas('icons/icon-$charId');
+      loadSparrow('icons/icon-$charId');
 
       loadAnimationNew();
     }
diff --git a/source/funkin/play/components/PopUpStuff.hx b/source/funkin/play/components/PopUpStuff.hx
index 9553856a9..593a7333e 100644
--- a/source/funkin/play/components/PopUpStuff.hx
+++ b/source/funkin/play/components/PopUpStuff.hx
@@ -3,6 +3,7 @@ package funkin.play.components;
 import flixel.FlxSprite;
 import flixel.group.FlxGroup.FlxTypedGroup;
 import flixel.tweens.FlxTween;
+import funkin.graphics.FunkinSprite;
 import funkin.play.PlayState;
 
 class PopUpStuff extends FlxTypedGroup<FlxSprite>
@@ -14,17 +15,18 @@ class PopUpStuff extends FlxTypedGroup<FlxSprite>
 
   public function displayRating(daRating:String)
   {
+    var perfStart:Float = Sys.time();
+
     if (daRating == null) daRating = "good";
 
-    var rating:FlxSprite = new FlxSprite(0, 0);
-    rating.scrollFactor.set(0.2, 0.2);
-
-    rating.zIndex = 1000;
     var ratingPath:String = daRating;
 
     if (PlayState.instance.currentStageId.startsWith('school')) ratingPath = "weeb/pixelUI/" + ratingPath + "-pixel";
 
-    rating.loadGraphic(Paths.image(ratingPath));
+    var rating:FunkinSprite = FunkinSprite.create(0, 0, Paths.image(ratingPath));
+    rating.scrollFactor.set(0.2, 0.2);
+
+    rating.zIndex = 1000;
     rating.x = FlxG.width * 0.50;
     rating.x -= FlxG.camera.scroll.x * 0.2;
     // make sure rating is visible lol!
@@ -61,10 +63,16 @@ class PopUpStuff extends FlxTypedGroup<FlxSprite>
         },
         startDelay: Conductor.instance.beatLengthMs * 0.001
       });
+
+    var perfEnd:Float = Sys.time();
+
+    trace("displayRating took: " + (perfEnd - perfStart));
   }
 
   public function displayCombo(?combo:Int = 0):Int
   {
+    var perfStart:Float = Sys.time();
+
     if (combo == null) combo = 0;
 
     var pixelShitPart1:String = "";
@@ -75,7 +83,7 @@ class PopUpStuff extends FlxTypedGroup<FlxSprite>
       pixelShitPart1 = 'weeb/pixelUI/';
       pixelShitPart2 = '-pixel';
     }
-    var comboSpr:FlxSprite = new FlxSprite().loadGraphic(Paths.image(pixelShitPart1 + 'combo' + pixelShitPart2));
+    var comboSpr:FunkinSprite = FunkinSprite.create(Paths.image(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;
@@ -129,8 +137,7 @@ class PopUpStuff extends FlxTypedGroup<FlxSprite>
     var daLoop:Int = 1;
     for (i in seperatedScore)
     {
-      var numScore:FlxSprite = new FlxSprite().loadGraphic(Paths.image(pixelShitPart1 + 'num' + Std.int(i) + pixelShitPart2));
-      numScore.y = comboSpr.y;
+      var numScore:FunkinSprite = FunkinSprite.create(0, comboSpr.y, Paths.image(pixelShitPart1 + 'num' + Std.int(i) + pixelShitPart2));
 
       if (PlayState.instance.currentStageId.startsWith('school'))
       {
@@ -163,6 +170,9 @@ class PopUpStuff extends FlxTypedGroup<FlxSprite>
       daLoop++;
     }
 
+    var perfEnd:Float = Sys.time();
+    trace("displayCombo took: " + (perfEnd - perfStart));
+
     return combo;
   }
 }
diff --git a/source/funkin/play/notes/NoteSprite.hx b/source/funkin/play/notes/NoteSprite.hx
index 0368b18e9..45862b26d 100644
--- a/source/funkin/play/notes/NoteSprite.hx
+++ b/source/funkin/play/notes/NoteSprite.hx
@@ -4,9 +4,10 @@ import funkin.data.song.SongData.SongNoteData;
 import funkin.play.notes.notestyle.NoteStyle;
 import flixel.graphics.frames.FlxAtlasFrames;
 import flixel.FlxSprite;
+import funkin.graphics.FunkinSprite;
 import funkin.graphics.shaders.HSVShader;
 
-class NoteSprite extends FlxSprite
+class NoteSprite extends FunkinSprite
 {
   static final DIRECTION_COLORS:Array<String> = ['purple', 'blue', 'green', 'red'];
 
diff --git a/source/funkin/play/notes/notestyle/NoteStyle.hx b/source/funkin/play/notes/notestyle/NoteStyle.hx
index 34c1ce9c3..f560396e8 100644
--- a/source/funkin/play/notes/notestyle/NoteStyle.hx
+++ b/source/funkin/play/notes/notestyle/NoteStyle.hx
@@ -4,6 +4,7 @@ import flixel.graphics.frames.FlxAtlasFrames;
 import flixel.graphics.frames.FlxFramesCollection;
 import funkin.data.animation.AnimationData;
 import funkin.data.IRegistryEntry;
+import funkin.graphics.FunkinSprite;
 import funkin.data.notestyle.NoteStyleData;
 import funkin.data.notestyle.NoteStyleRegistry;
 import funkin.data.notestyle.NoteStyleRegistry;
@@ -100,6 +101,11 @@ class NoteStyle implements IRegistryEntry<NoteStyleData>
 
   function buildNoteFrames(force:Bool = false):FlxAtlasFrames
   {
+    if (!FunkinSprite.isTextureCached(Paths.image(getNoteAssetPath())))
+    {
+      FlxG.log.warn('Note texture is not cached: ${getNoteAssetPath()}');
+    }
+
     if (noteFrames != null && !force) return noteFrames;
 
     noteFrames = Paths.getSparrowAtlas(getNoteAssetPath(), getNoteAssetLibrary());
diff --git a/source/funkin/play/stage/Stage.hx b/source/funkin/play/stage/Stage.hx
index 8b47eff2b..c20202245 100644
--- a/source/funkin/play/stage/Stage.hx
+++ b/source/funkin/play/stage/Stage.hx
@@ -185,9 +185,9 @@ class Stage extends FlxSpriteGroup implements IPlayStateScriptedClass implements
         switch (dataProp.animType)
         {
           case 'packer':
-            propSprite.frames = Paths.getPackerAtlas(dataProp.assetPath);
+            propSprite.loadPacker(dataProp.assetPath);
           default: // 'sparrow'
-            propSprite.frames = Paths.getSparrowAtlas(dataProp.assetPath);
+            propSprite.loadSparrow(dataProp.assetPath);
         }
       }
       else if (isSolidColor)
@@ -209,7 +209,7 @@ class Stage extends FlxSpriteGroup implements IPlayStateScriptedClass implements
       else
       {
         // Initalize static sprite.
-        propSprite.loadGraphic(Paths.image(dataProp.assetPath));
+        propSprite.loadTexture(Paths.image(dataProp.assetPath));
 
         // Disables calls to update() for a performance boost.
         propSprite.active = false;
diff --git a/source/funkin/ui/freeplay/DJBoyfriend.hx b/source/funkin/ui/freeplay/DJBoyfriend.hx
index 2417cdf9a..9d37fe2c1 100644
--- a/source/funkin/ui/freeplay/DJBoyfriend.hx
+++ b/source/funkin/ui/freeplay/DJBoyfriend.hx
@@ -156,8 +156,6 @@ class DJBoyfriend extends FlxAtlasSprite
 
   function setupAnimations():Void
   {
-    // frames = FlxAnimationUtil.combineFramesCollections(Paths.getSparrowAtlas('freeplay/bfFreeplay'), Paths.getSparrowAtlas('freeplay/bf-freeplay-afk'));
-
     // animation.addByPrefix('intro', "boyfriend dj intro", 24, false);
     addOffset('boyfriend dj intro', 8, 3);
 
diff --git a/source/funkin/ui/freeplay/FreeplayFlames.hx b/source/funkin/ui/freeplay/FreeplayFlames.hx
index a116fb813..c20d85898 100644
--- a/source/funkin/ui/freeplay/FreeplayFlames.hx
+++ b/source/funkin/ui/freeplay/FreeplayFlames.hx
@@ -23,7 +23,7 @@ class FreeplayFlames extends FlxSpriteGroup
     {
       var flame:FlxSprite = new FlxSprite(flameX + (flameSpreadX * i), flameY + (flameSpreadY * i));
       flame.frames = Paths.getSparrowAtlas("freeplay/freeplayFlame");
-      flame.animation.addByPrefix("flame", "fire loop", FlxG.random.int(23, 25), false);
+      flame.animation.addByPrefix("flame", "fire loop full instance 1", FlxG.random.int(23, 25), false);
       flame.animation.play("flame");
       flame.visible = false;
       flameCount = 0;
diff --git a/source/funkin/ui/freeplay/FreeplayScore.hx b/source/funkin/ui/freeplay/FreeplayScore.hx
index e266efca1..413b182e0 100644
--- a/source/funkin/ui/freeplay/FreeplayScore.hx
+++ b/source/funkin/ui/freeplay/FreeplayScore.hx
@@ -111,7 +111,7 @@ class ScoreNum extends FlxSprite
     for (i in 0...10)
     {
       var stringNum:String = numToString[i];
-      animation.addByPrefix(stringNum, stringNum, 24, false);
+      animation.addByPrefix(stringNum, '$stringNum DIGITAL', 24, false);
     }
 
     this.digit = initDigit;
diff --git a/source/funkin/ui/freeplay/FreeplayState.hx b/source/funkin/ui/freeplay/FreeplayState.hx
index 39cab8759..669354345 100644
--- a/source/funkin/ui/freeplay/FreeplayState.hx
+++ b/source/funkin/ui/freeplay/FreeplayState.hx
@@ -226,17 +226,17 @@ class FreeplayState extends MusicBeatSubState
     trace(FlxG.camera.initialZoom);
     trace(FlxCamera.defaultZoom);
 
-    var pinkBack:FlxSprite = new FlxSprite().loadGraphic(Paths.image('freeplay/pinkBack'));
+    var pinkBack:FunkinSprite = FunkinSprite.create(Paths.image('freeplay/pinkBack'));
     pinkBack.color = 0xFFffd4e9; // sets it to pink!
     pinkBack.x -= pinkBack.width;
 
     FlxTween.tween(pinkBack, {x: 0}, 0.6, {ease: FlxEase.quartOut});
     add(pinkBack);
 
-    var orangeBackShit:FlxSprite = new FlxSprite(84, 440).makeGraphic(Std.int(pinkBack.width), 75, 0xFFfeda00);
+    var orangeBackShit:FunkinSprite = new FunkinSprite(84, 440).makeSolidColor(Std.int(pinkBack.width), 75, 0xFFfeda00);
     add(orangeBackShit);
 
-    var alsoOrangeLOL:FlxSprite = new FlxSprite(0, orangeBackShit.y).makeGraphic(100, Std.int(orangeBackShit.height), 0xFFffd400);
+    var alsoOrangeLOL:FunkinSprite = new FunkinSprite(0, orangeBackShit.y).makeSolidColor(100, Std.int(orangeBackShit.height), 0xFFffd400);
     add(alsoOrangeLOL);
 
     exitMovers.set([pinkBack, orangeBackShit, alsoOrangeLOL],
@@ -462,7 +462,7 @@ class FreeplayState extends MusicBeatSubState
 
     var fnfHighscoreSpr:FlxSprite = new FlxSprite(860, 70);
     fnfHighscoreSpr.frames = Paths.getSparrowAtlas('freeplay/highscore');
-    fnfHighscoreSpr.animation.addByPrefix("highscore", "highscore", 24, false);
+    fnfHighscoreSpr.animation.addByPrefix("highscore", "highscore small instance 1", 24, false);
     fnfHighscoreSpr.visible = false;
     fnfHighscoreSpr.setGraphicSize(0, Std.int(fnfHighscoreSpr.height * 1));
     fnfHighscoreSpr.updateHitbox();
diff --git a/source/funkin/ui/transition/LoadingState.hx b/source/funkin/ui/transition/LoadingState.hx
index 86f443d1d..e893b0cec 100644
--- a/source/funkin/ui/transition/LoadingState.hx
+++ b/source/funkin/ui/transition/LoadingState.hx
@@ -44,11 +44,10 @@ class LoadingState extends MusicBeatState
 
   override function create():Void
   {
-    var bg:FlxSprite = new FunkinSprite().makeSolidColor(FlxG.width, FlxG.height, 0xFFcaff4d);
+    var bg:FunkinSprite = new FunkinSprite().makeSolidColor(FlxG.width, FlxG.height, 0xFFcaff4d);
     add(bg);
 
-    funkay = new FlxSprite();
-    funkay.loadGraphic(Paths.image('funkay'));
+    funkay = FunkinSprite.create(Paths.image('funkay'));
     funkay.setGraphicSize(0, FlxG.height);
     funkay.updateHitbox();
     add(funkay);
@@ -209,6 +208,11 @@ class LoadingState extends MusicBeatState
       params.targetSong.cacheCharts(true);
     }
 
+    // TODO: This is a hack! Redo this later when we have a proper asset caching system.
+    FunkinSprite.cacheTexture(Paths.image('combo'));
+    FunkinSprite.cacheTexture(Paths.image('healthBar'));
+    FunkinSprite.cacheTexture(Paths.image('menuDesat'));
+
     FlxG.switchState(playStateCtor);
     #end
   }

From 01ed1730f4338ea99ad6c472689780867a91cff1 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Thu, 22 Feb 2024 20:56:41 -0500
Subject: [PATCH 17/30] Fix some issues with cutscenes.

---
 Project.xml                                  |  2 +-
 source/funkin/Paths.hx                       | 14 ++++++++++++++
 source/funkin/play/cutscene/VideoCutscene.hx | 19 +++++++++++++++----
 source/funkin/ui/freeplay/FreeplayState.hx   |  1 +
 4 files changed, 31 insertions(+), 5 deletions(-)

diff --git a/Project.xml b/Project.xml
index c58153575..c368dacef 100644
--- a/Project.xml
+++ b/Project.xml
@@ -108,7 +108,7 @@
 	<haxelib name="flixel-text-input" /> <!-- Improved text field rendering for HaxeUI -->
 	<haxelib name="polymod" /> <!-- Modding framework -->
 	<haxelib name="flxanimate" /> <!-- Texture atlas rendering -->
-	<haxelib name="hxCodec" if="desktop release" /> <!-- Video playback -->
+	<haxelib name="hxCodec" if="desktop" /> <!-- Video playback -->
 
 	<haxelib name="json2object" /> <!-- JSON parsing -->
 	<haxelib name="thx.semver" /> <!-- Version string handling -->
diff --git a/source/funkin/Paths.hx b/source/funkin/Paths.hx
index e0212e573..6006939be 100644
--- a/source/funkin/Paths.hx
+++ b/source/funkin/Paths.hx
@@ -16,6 +16,20 @@ class Paths
     currentLevel = name.toLowerCase();
   }
 
+  public static function stripLibrary(path:String):String
+  {
+    var parts = path.split(':');
+    if (parts.length < 2) return path;
+    return parts[1];
+  }
+
+  public static function getLibrary(path:String):String
+  {
+    var parts = path.split(':');
+    if (parts.length < 2) return "preload";
+    return parts[0];
+  }
+
   static function getPath(file:String, type:AssetType, library:Null<String>)
   {
     if (library != null) return getLibraryPath(file, library);
diff --git a/source/funkin/play/cutscene/VideoCutscene.hx b/source/funkin/play/cutscene/VideoCutscene.hx
index 934919b65..df31accb2 100644
--- a/source/funkin/play/cutscene/VideoCutscene.hx
+++ b/source/funkin/play/cutscene/VideoCutscene.hx
@@ -36,6 +36,8 @@ class VideoCutscene
       return;
     }
 
+    var rawFilePath = Paths.stripLibrary(filePath);
+
     // Trigger the cutscene. Don't play the song in the background.
     PlayState.instance.isInCutscene = true;
     PlayState.instance.camHUD.visible = false;
@@ -49,10 +51,10 @@ class VideoCutscene
 
     #if html5
     playVideoHTML5(filePath);
-    #end
-
-    #if hxCodec
-    playVideoNative(filePath);
+    #elseif hxCodec
+    playVideoNative(rawFilePath);
+    #else
+    throw "No video support for this platform!";
     #end
   }
 
@@ -110,6 +112,15 @@ class VideoCutscene
 
       PlayState.instance.refresh();
       vid.play(filePath, false);
+
+      // Resize videos bigger or smaller than the screen.
+      vid.bitmap.onTextureSetup.add(() -> {
+        vid.setGraphicSize(FlxG.width, FlxG.height);
+        vid.updateHitbox();
+        vid.x = 0;
+        vid.y = 0;
+        // vid.scale.set(0.5, 0.5);
+      });
     }
     else
     {
diff --git a/source/funkin/ui/freeplay/FreeplayState.hx b/source/funkin/ui/freeplay/FreeplayState.hx
index 669354345..e4a6b96d8 100644
--- a/source/funkin/ui/freeplay/FreeplayState.hx
+++ b/source/funkin/ui/freeplay/FreeplayState.hx
@@ -7,6 +7,7 @@ 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;

From 50e208cf436445c26f9399b7dd44ad842930d7b8 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Thu, 22 Feb 2024 20:56:50 -0500
Subject: [PATCH 18/30] Update assets submodule.

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

diff --git a/assets b/assets
index ffbf73c76..24bf8d3c1 160000
--- a/assets
+++ b/assets
@@ -1 +1 @@
-Subproject commit ffbf73c76860a2747eb11eeed14099e186700956
+Subproject commit 24bf8d3c13532ee06923818d04cd44699d6be952

From ddfb0c6a61fe445a880229fda99b8c2c6700d662 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Thu, 22 Feb 2024 23:37:52 -0500
Subject: [PATCH 19/30] Working on more asset caching improvements.

---
 source/funkin/graphics/FunkinSprite.hx        | 61 +++++++++++++++++++
 source/funkin/play/components/PopUpStuff.hx   |  9 ++-
 source/funkin/ui/transition/LoadingState.hx   | 26 +++++++-
 .../funkin/ui/transition/StickerSubState.hx   |  5 +-
 4 files changed, 96 insertions(+), 5 deletions(-)

diff --git a/source/funkin/graphics/FunkinSprite.hx b/source/funkin/graphics/FunkinSprite.hx
index 6c8ce1308..f47b4138a 100644
--- a/source/funkin/graphics/FunkinSprite.hx
+++ b/source/funkin/graphics/FunkinSprite.hx
@@ -11,6 +11,18 @@ import flixel.graphics.FlxGraphic;
  */
 class FunkinSprite extends FlxSprite
 {
+  /**
+   * An internal list of all the textures cached with `cacheTexture`.
+   * This excludes any temporary textures like those from `FlxText` or `makeSolidColor`.
+   */
+  static var currentCachedTextures:Map<String, FlxGraphic> = [];
+
+  /**
+   * An internal list of textures that were cached in the previous state.
+   * We don't know whether we want to keep them cached or not.
+   */
+  static var previousCachedTextures:Map<String, FlxGraphic> = [];
+
   /**
    * @param x Starting X position
    * @param y Starting Y position
@@ -113,6 +125,19 @@ class FunkinSprite extends FlxSprite
 
   public static function cacheTexture(key:String):Void
   {
+    // We don't want to cache the same texture twice.
+    if (currentCachedTextures.exists(key)) return;
+
+    if (previousCachedTextures.exists(key))
+    {
+      // Move the graphic from the previous cache to the current cache.
+      var graphic = previousCachedTextures.get(key);
+      previousCachedTextures.remove(key);
+      currentCachedTextures.set(key, graphic);
+      return;
+    }
+
+    // Else, texture is currently uncached.
     var graphic = flixel.graphics.FlxGraphic.fromAssetKey(key, false, null, true);
     if (graphic == null)
     {
@@ -121,6 +146,8 @@ class FunkinSprite extends FlxSprite
     else
     {
       trace('Successfully cached graphic: $key');
+      graphic.persist = true;
+      currentCachedTextures.set(key, graphic);
     }
   }
 
@@ -134,6 +161,40 @@ class FunkinSprite extends FlxSprite
     cacheTexture(Paths.image(key));
   }
 
+  /**
+   * Call this, then `cacheTexture` to keep the textures we still need, then `purgeCache` to remove the textures that we won't be using anymore.
+   */
+  public static function preparePurgeCache():Void
+  {
+    previousCachedTextures = currentCachedTextures;
+    currentCachedTextures = [];
+  }
+
+  public static function purgeCache():Void
+  {
+    // Everything that is in previousCachedTextures but not in currentCachedTextures should be destroyed.
+    for (graphicKey in previousCachedTextures.keys())
+    {
+      var graphic = previousCachedTextures.get(graphicKey);
+      FlxG.bitmap.remove(graphic);
+      graphic.destroy();
+      previousCachedTextures.remove(graphicKey);
+    }
+  }
+
+  static function isGraphicCached(graphic:FlxGraphic):Bool
+  {
+    if (graphic == null) return false;
+    var result = FlxG.bitmap.get(graphic.key);
+    if (result == null) return false;
+    if (result != graphic)
+    {
+      FlxG.log.warn('Cached graphic does not match original: ${graphic.key}');
+      return false;
+    }
+    return true;
+  }
+
   /**
    * Acts similarly to `makeGraphic`, but with improved memory usage,
    * at the expense of not being able to paint onto the resulting sprite.
diff --git a/source/funkin/play/components/PopUpStuff.hx b/source/funkin/play/components/PopUpStuff.hx
index 593a7333e..88ffa468c 100644
--- a/source/funkin/play/components/PopUpStuff.hx
+++ b/source/funkin/play/components/PopUpStuff.hx
@@ -15,7 +15,9 @@ class PopUpStuff extends FlxTypedGroup<FlxSprite>
 
   public function displayRating(daRating:String)
   {
+    #if sys
     var perfStart:Float = Sys.time();
+    #end
 
     if (daRating == null) daRating = "good";
 
@@ -64,14 +66,17 @@ class PopUpStuff extends FlxTypedGroup<FlxSprite>
         startDelay: Conductor.instance.beatLengthMs * 0.001
       });
 
+    #if sys
     var perfEnd:Float = Sys.time();
-
     trace("displayRating took: " + (perfEnd - perfStart));
+    #end
   }
 
   public function displayCombo(?combo:Int = 0):Int
   {
+    #if sys
     var perfStart:Float = Sys.time();
+    #end
 
     if (combo == null) combo = 0;
 
@@ -170,8 +175,10 @@ class PopUpStuff extends FlxTypedGroup<FlxSprite>
       daLoop++;
     }
 
+    #if sys
     var perfEnd:Float = Sys.time();
     trace("displayCombo took: " + (perfEnd - perfStart));
+    #end
 
     return combo;
   }
diff --git a/source/funkin/ui/transition/LoadingState.hx b/source/funkin/ui/transition/LoadingState.hx
index e893b0cec..3a41340a6 100644
--- a/source/funkin/ui/transition/LoadingState.hx
+++ b/source/funkin/ui/transition/LoadingState.hx
@@ -3,6 +3,7 @@ package funkin.ui.transition;
 import flixel.FlxSprite;
 import flixel.math.FlxMath;
 import flixel.tweens.FlxEase;
+import funkin.graphics.FunkinSprite;
 import flixel.tweens.FlxTween;
 import flixel.util.FlxTimer;
 import funkin.graphics.shaders.ScreenWipeShader;
@@ -208,10 +209,31 @@ class LoadingState extends MusicBeatState
       params.targetSong.cacheCharts(true);
     }
 
-    // TODO: This is a hack! Redo this later when we have a proper asset caching system.
+    // TODO: This section is a hack! Redo this later when we have a proper asset caching system.
+    FunkinSprite.preparePurgeCache();
     FunkinSprite.cacheTexture(Paths.image('combo'));
     FunkinSprite.cacheTexture(Paths.image('healthBar'));
     FunkinSprite.cacheTexture(Paths.image('menuDesat'));
+    FunkinSprite.cacheTexture(Paths.image('combo'));
+    FunkinSprite.cacheTexture(Paths.image('num0'));
+    FunkinSprite.cacheTexture(Paths.image('num1'));
+    FunkinSprite.cacheTexture(Paths.image('num2'));
+    FunkinSprite.cacheTexture(Paths.image('num3'));
+    FunkinSprite.cacheTexture(Paths.image('num4'));
+    FunkinSprite.cacheTexture(Paths.image('num5'));
+    FunkinSprite.cacheTexture(Paths.image('num6'));
+    FunkinSprite.cacheTexture(Paths.image('num7'));
+    FunkinSprite.cacheTexture(Paths.image('num8'));
+    FunkinSprite.cacheTexture(Paths.image('num9'));
+    FunkinSprite.cacheTexture(Paths.image('ready', 'shared'));
+    FunkinSprite.cacheTexture(Paths.image('set', 'shared'));
+    FunkinSprite.cacheTexture(Paths.image('go', 'shared'));
+    FunkinSprite.cacheTexture(Paths.image('sick', 'shared'));
+    FunkinSprite.cacheTexture(Paths.image('good', 'shared'));
+    FunkinSprite.cacheTexture(Paths.image('bad', 'shared'));
+    FunkinSprite.cacheTexture(Paths.image('shit', 'shared'));
+    FunkinSprite.cacheTexture(Paths.image('miss', 'shared')); // TODO: Remove
+    FunkinSprite.purgeCache();
 
     FlxG.switchState(playStateCtor);
     #end
@@ -358,7 +380,7 @@ class MultiCallback
 
   public static function coolSwitchState(state:NextState, transitionTex:String = "shaderTransitionStuff/coolDots", time:Float = 2)
   {
-    var screenShit:FlxSprite = new FlxSprite().loadGraphic(Paths.image("shaderTransitionStuff/coolDots"));
+    var screenShit:FunkinSprite = FunkinSprite.create(Paths.image("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 e94eed7d5..fda7d1d5d 100644
--- a/source/funkin/ui/transition/StickerSubState.hx
+++ b/source/funkin/ui/transition/StickerSubState.hx
@@ -3,6 +3,7 @@ package funkin.ui.transition;
 import flixel.FlxSprite;
 import haxe.Json;
 import lime.utils.Assets;
+import funkin.graphics.FunkinSprite;
 // import flxtyped group
 import funkin.ui.MusicBeatSubState;
 import funkin.ui.story.StoryMenuState;
@@ -301,14 +302,14 @@ class StickerSubState extends MusicBeatSubState
   }
 }
 
-class StickerSprite extends FlxSprite
+class StickerSprite extends FunkinSprite
 {
   public var timing:Float = 0;
 
   public function new(x:Float, y:Float, stickerSet:String, stickerName:String):Void
   {
     super(x, y);
-    loadGraphic(Paths.image('transitionSwag/' + stickerSet + '/' + stickerName));
+    loadTexture(Paths.image('transitionSwag/' + stickerSet + '/' + stickerName));
     updateHitbox();
     scrollFactor.set();
   }

From e349b0bb49775fac832b4d094eec6c7f19f3747f Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Fri, 23 Feb 2024 00:16:11 -0500
Subject: [PATCH 20/30] New pre-caching techniques should reduce stuttering on
 Weekend 1.

---
 source/funkin/play/PlayState.hx                 |  5 -----
 source/funkin/play/notes/NoteSplash.hx          |  3 +++
 source/funkin/play/notes/notestyle/NoteStyle.hx |  5 +++--
 source/funkin/ui/transition/LoadingState.hx     | 13 ++++++++++++-
 source/funkin/ui/transition/StickerSubState.hx  |  4 ++++
 5 files changed, 22 insertions(+), 8 deletions(-)

diff --git a/source/funkin/play/PlayState.hx b/source/funkin/play/PlayState.hx
index bde68461b..934e0b403 100644
--- a/source/funkin/play/PlayState.hx
+++ b/source/funkin/play/PlayState.hx
@@ -2281,11 +2281,6 @@ class PlayState extends MusicBeatSubState
     if (FlxG.keys.justPressed.H) camHUD.visible = !camHUD.visible;
     #end
 
-    // Eject button
-    if (FlxG.keys.justPressed.F4) FlxG.switchState(() -> new MainMenuState());
-
-    if (FlxG.keys.justPressed.F5) debug_refreshModules();
-
     // Open the stage editor overlaying the current state.
     if (controls.DEBUG_STAGE)
     {
diff --git a/source/funkin/play/notes/NoteSplash.hx b/source/funkin/play/notes/NoteSplash.hx
index 0ff8076c8..2411e5615 100644
--- a/source/funkin/play/notes/NoteSplash.hx
+++ b/source/funkin/play/notes/NoteSplash.hx
@@ -35,6 +35,7 @@ class NoteSplash extends FlxSprite
    */
   function setup():Void
   {
+    if (frameCollection?.parent?.isDestroyed ?? false) frameCollection = null;
     if (frameCollection == null) preloadFrames();
 
     this.frames = frameCollection;
@@ -75,6 +76,8 @@ class NoteSplash extends FlxSprite
         this.playAnimation('splash${variant}Right');
     }
 
+    if (animation.curAnim == null) return;
+
     // Vary the speed of the animation a bit.
     animation.curAnim.frameRate = FRAMERATE_DEFAULT + FlxG.random.int(-FRAMERATE_VARIANCE, FRAMERATE_VARIANCE);
 
diff --git a/source/funkin/play/notes/notestyle/NoteStyle.hx b/source/funkin/play/notes/notestyle/NoteStyle.hx
index f560396e8..d0cc09f6a 100644
--- a/source/funkin/play/notes/notestyle/NoteStyle.hx
+++ b/source/funkin/play/notes/notestyle/NoteStyle.hx
@@ -106,6 +106,9 @@ class NoteStyle implements IRegistryEntry<NoteStyleData>
       FlxG.log.warn('Note texture is not cached: ${getNoteAssetPath()}');
     }
 
+    // Purge the note frames if the cached atlas is invalid.
+    if (noteFrames?.parent?.isDestroyed ?? false) noteFrames = null;
+
     if (noteFrames != null && !force) return noteFrames;
 
     noteFrames = Paths.getSparrowAtlas(getNoteAssetPath(), getNoteAssetLibrary());
@@ -115,8 +118,6 @@ class NoteStyle implements IRegistryEntry<NoteStyleData>
       throw 'Could not load note frames for note style: $id';
     }
 
-    noteFrames.parent.persist = true;
-
     return noteFrames;
   }
 
diff --git a/source/funkin/ui/transition/LoadingState.hx b/source/funkin/ui/transition/LoadingState.hx
index 3a41340a6..5f755872f 100644
--- a/source/funkin/ui/transition/LoadingState.hx
+++ b/source/funkin/ui/transition/LoadingState.hx
@@ -225,6 +225,10 @@ class LoadingState extends MusicBeatState
     FunkinSprite.cacheTexture(Paths.image('num7'));
     FunkinSprite.cacheTexture(Paths.image('num8'));
     FunkinSprite.cacheTexture(Paths.image('num9'));
+    FunkinSprite.cacheTexture(Paths.image('notes', 'shared'));
+    FunkinSprite.cacheTexture(Paths.image('noteSplashes', 'shared'));
+    FunkinSprite.cacheTexture(Paths.image('noteStrumline', 'shared'));
+    FunkinSprite.cacheTexture(Paths.image('NOTE_hold_assets'));
     FunkinSprite.cacheTexture(Paths.image('ready', 'shared'));
     FunkinSprite.cacheTexture(Paths.image('set', 'shared'));
     FunkinSprite.cacheTexture(Paths.image('go', 'shared'));
@@ -232,7 +236,14 @@ class LoadingState extends MusicBeatState
     FunkinSprite.cacheTexture(Paths.image('good', 'shared'));
     FunkinSprite.cacheTexture(Paths.image('bad', 'shared'));
     FunkinSprite.cacheTexture(Paths.image('shit', 'shared'));
-    FunkinSprite.cacheTexture(Paths.image('miss', 'shared')); // TODO: Remove
+    FunkinSprite.cacheTexture(Paths.image('miss', 'shared')); // TODO: remove this
+
+    // FunkinSprite.cacheAllNoteStyleTextures(noteStyle) // This will replace the stuff above!
+    // FunkinSprite.cacheAllCharacterTextures(player)
+    // FunkinSprite.cacheAllCharacterTextures(girlfriend)
+    // FunkinSprite.cacheAllCharacterTextures(opponent)
+    // FunkinSprite.cacheAllStageTextures(stage)
+
     FunkinSprite.purgeCache();
 
     FlxG.switchState(playStateCtor);
diff --git a/source/funkin/ui/transition/StickerSubState.hx b/source/funkin/ui/transition/StickerSubState.hx
index fda7d1d5d..40fce6f7d 100644
--- a/source/funkin/ui/transition/StickerSubState.hx
+++ b/source/funkin/ui/transition/StickerSubState.hx
@@ -246,6 +246,10 @@ class StickerSubState extends MusicBeatSubState
             FlxTransitionableState.skipNextTransIn = true;
             FlxTransitionableState.skipNextTransOut = true;
 
+            // TODO: Rework this asset caching stuff
+            FunkinSprite.preparePurgeCache();
+            FunkinSprite.purgeCache();
+
             // I think this grabs the screen and puts it under the stickers?
             // Leaving this commented out rather than stripping it out because it's cool...
             /*

From eb4a21f2d5eca92ebdcb2e107c976e41002da32a Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Fri, 23 Feb 2024 03:16:41 -0500
Subject: [PATCH 21/30] Update assets submodule.

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

diff --git a/assets b/assets
index 499bb17c1..7d031153c 160000
--- a/assets
+++ b/assets
@@ -1 +1 @@
-Subproject commit 499bb17c15620ca9140f8254029f05bb7c96f1f4
+Subproject commit 7d031153cf073e9d49ab59d7c72956cf4a68bcda

From fb9fd572102466cd4a1c5829e6af3197c107ee2e Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Fri, 23 Feb 2024 03:16:51 -0500
Subject: [PATCH 22/30] Improve json parsing error handling.

---
 source/funkin/data/BaseRegistry.hx | 16 +++++++++++++++-
 1 file changed, 15 insertions(+), 1 deletion(-)

diff --git a/source/funkin/data/BaseRegistry.hx b/source/funkin/data/BaseRegistry.hx
index 0ccbe2f18..62a7eb0f7 100644
--- a/source/funkin/data/BaseRegistry.hx
+++ b/source/funkin/data/BaseRegistry.hx
@@ -61,7 +61,16 @@ abstract class BaseRegistry<T:(IRegistryEntry<J> & Constructible<EntryConstructo
 
     for (entryCls in scriptedEntryClassNames)
     {
-      var entry:T = createScriptedEntry(entryCls);
+      var entry:Null<T> = null;
+      try
+      {
+        entry = createScriptedEntry(entryCls);
+      }
+      catch (e:Dynamic)
+      {
+        log('Failed to create scripted entry (${entryCls})');
+        continue;
+      }
 
       if (entry != null)
       {
@@ -196,6 +205,11 @@ abstract class BaseRegistry<T:(IRegistryEntry<J> & Constructible<EntryConstructo
    */
   public function parseEntryDataWithMigration(id:String, version:thx.semver.Version):Null<J>
   {
+    if (version == null)
+    {
+      throw '[${registryId}] Entry ${id} could not be JSON-parsed or does not have a parseable version.';
+    }
+
     // If a version rule is not specified, do not check against it.
     if (versionRule == null || VersionUtil.validateVersion(version, versionRule))
     {

From 4168962fecd4129c5335146b9c1b64222ab1c2b2 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Fri, 23 Feb 2024 03:19:34 -0500
Subject: [PATCH 23/30] Revert "Fix `FunkinSound` not resuming after focus"

This reverts commit d6b3e2a9cf4aab093a129aef13ebd93294d6f929.
---
 source/funkin/audio/FunkinSound.hx | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/source/funkin/audio/FunkinSound.hx b/source/funkin/audio/FunkinSound.hx
index 1059185cf..ba157ed8e 100644
--- a/source/funkin/audio/FunkinSound.hx
+++ b/source/funkin/audio/FunkinSound.hx
@@ -174,7 +174,7 @@ class FunkinSound extends FlxSound implements ICloneable<FunkinSound>
    */
   override function onFocus():Void
   {
-    if (!_alreadyPaused)
+    if (!_alreadyPaused && this._shouldPlay)
     {
       resume();
     }

From 66c91d8b3eb7f52ba66437d984a39b0ef4d7907f Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Fri, 23 Feb 2024 03:23:00 -0500
Subject: [PATCH 24/30] Sort the chart editor note kind dropdown.

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

diff --git a/source/funkin/ui/debug/charting/util/ChartEditorDropdowns.hx b/source/funkin/ui/debug/charting/util/ChartEditorDropdowns.hx
index b26082f98..26015161b 100644
--- a/source/funkin/ui/debug/charting/util/ChartEditorDropdowns.hx
+++ b/source/funkin/ui/debug/charting/util/ChartEditorDropdowns.hx
@@ -147,6 +147,8 @@ class ChartEditorDropdowns
       dropDown.dataSource.add(value);
     }
 
+    dropDown.dataSource.sort('id', ASCENDING);
+
     return returnValue;
   }
 

From 85cb20e23daf71cf9e0ba260ae7b6e639f8c2914 Mon Sep 17 00:00:00 2001
From: Cameron Taylor <cameron.taylor.ninja@gmail.com>
Date: Mon, 26 Feb 2024 15:42:56 -0500
Subject: [PATCH 25/30] renames urls in gitmodules to current updated names of
 the repos

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

diff --git a/.gitmodules b/.gitmodules
index 17c3cc026..903904103 100644
--- a/.gitmodules
+++ b/.gitmodules
@@ -1,10 +1,10 @@
 [submodule "assets"]
 	path = assets
-	url = https://github.com/FunkinCrew/Funkin-history-rewrite-assets
+	url = https://github.com/FunkinCrew/Funkin-Assets-secret
 	branch = master
 	update = merge
 [submodule "art"]
 	path = art
-	url = https://github.com/FunkinCrew/Funkin-history-rewrite-art
+	url = https://github.com/FunkinCrew/Funkin-Art-secret
 	branch = master
 	update = merge

From 90360de0d01c72b552a87096fdb0549ebc76d443 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Mon, 26 Feb 2024 19:03:04 -0500
Subject: [PATCH 26/30] Working Blazin cutscene and fixed time travel

---
 assets                                        |   2 +-
 source/funkin/data/event/SongEventRegistry.hx |  23 ++++
 source/funkin/play/PlayState.hx               | 124 ++++++++++++------
 source/funkin/play/cutscene/VideoCutscene.hx  |  50 ++++++-
 4 files changed, 155 insertions(+), 44 deletions(-)

diff --git a/assets b/assets
index 7d031153c..1b0e09750 160000
--- a/assets
+++ b/assets
@@ -1 +1 @@
-Subproject commit 7d031153cf073e9d49ab59d7c72956cf4a68bcda
+Subproject commit 1b0e097508ec694012043a4c059885b05a569e2a
diff --git a/source/funkin/data/event/SongEventRegistry.hx b/source/funkin/data/event/SongEventRegistry.hx
index 8732e3b98..9b0163557 100644
--- a/source/funkin/data/event/SongEventRegistry.hx
+++ b/source/funkin/data/event/SongEventRegistry.hx
@@ -148,6 +148,29 @@ class SongEventRegistry
     });
   }
 
+  /**
+   * The currentTime has jumped far ahead or back.
+   * If we moved back in time, we need to reset all the events in that space.
+   * If we moved forward in time, we need to skip all the events in that space.
+   */
+  public static function handleSkippedEvents(events:Array<SongEventData>, currentTime:Float):Void
+  {
+    for (event in events)
+    {
+      // Deactivate future events.
+      if (event.time > currentTime)
+      {
+        event.activated = false;
+      }
+
+      // Skip past events.
+      if (event.time < currentTime)
+      {
+        event.activated = true;
+      }
+    }
+  }
+
   /**
    * Reset activation of all the provided events.
    */
diff --git a/source/funkin/play/PlayState.hx b/source/funkin/play/PlayState.hx
index 934e0b403..5bbf83e17 100644
--- a/source/funkin/play/PlayState.hx
+++ b/source/funkin/play/PlayState.hx
@@ -987,8 +987,21 @@ class PlayState extends MusicBeatSubState
       }
     }
 
+    processSongEvents();
+
+    // Handle keybinds.
+    processInputQueue();
+    if (!isInCutscene && !disableKeys) debugKeyShit();
+    if (isInCutscene && !disableKeys) handleCutsceneKeys(elapsed);
+
+    // Moving notes into position is now done by Strumline.update().
+    processNotes(elapsed);
+  }
+
+  function processSongEvents():Void
+  {
     // Query and activate song events.
-    // TODO: Check that these work even when songPosition is less than 0.
+    // TODO: Check that these work appropriately even when songPosition is less than 0, to play events during countdown.
     if (songEvents != null && songEvents.length > 0)
     {
       var songEventsToActivate:Array<SongEventData> = SongEventRegistry.queryEvents(songEvents, Conductor.instance.songPosition);
@@ -998,8 +1011,9 @@ class PlayState extends MusicBeatSubState
         trace('Found ${songEventsToActivate.length} event(s) to activate.');
         for (event in songEventsToActivate)
         {
-          // If an event is trying to play, but it's over 5 seconds old, skip it.
-          if (event.time - Conductor.instance.songPosition < -5000)
+          // If an event is trying to play, but it's over 1 second old, skip it.
+          var eventAge:Float = Conductor.instance.songPosition - event.time;
+          if (eventAge > 1000)
           {
             event.activated = true;
             continue;
@@ -1015,14 +1029,6 @@ class PlayState extends MusicBeatSubState
         }
       }
     }
-
-    // Handle keybinds.
-    processInputQueue();
-    if (!isInCutscene && !disableKeys) debugKeyShit();
-    if (isInCutscene && !disableKeys) handleCutsceneKeys(elapsed);
-
-    // Moving notes into position is now done by Strumline.update().
-    processNotes(elapsed);
   }
 
   public override function dispatchEvent(event:ScriptEvent):Void
@@ -1761,7 +1767,7 @@ class PlayState extends MusicBeatSubState
       currentChart.playInst(1.0, false);
     }
 
-    FlxG.sound.music.onComplete = endSong;
+    FlxG.sound.music.onComplete = endSong.bind(false);
     // A negative instrumental offset means the song skips the first few milliseconds of the track.
     // This just gets added into the startTimestamp behavior so we don't need to do anything extra.
     FlxG.sound.music.time = startTimestamp - Conductor.instance.instrumentalOffset;
@@ -2041,6 +2047,7 @@ class PlayState extends MusicBeatSubState
       }
     }
 
+    // Respawns notes that were b
     playerStrumline.handleSkippedNotes();
     opponentStrumline.handleSkippedNotes();
   }
@@ -2303,7 +2310,7 @@ class PlayState extends MusicBeatSubState
 
     #if (debug || FORCE_DEBUG_VERSION)
     // 1: End the song immediately.
-    if (FlxG.keys.justPressed.ONE) endSong();
+    if (FlxG.keys.justPressed.ONE) endSong(true);
 
     // 2: Gain 10% health.
     if (FlxG.keys.justPressed.TWO) health += 0.1 * Constants.HEALTH_MAX;
@@ -2497,16 +2504,35 @@ class PlayState extends MusicBeatSubState
 
     if (skipHeldTimer >= 1.5)
     {
-      VideoCutscene.finishVideo();
+      skipVideoCutscene();
     }
   }
 
   /**
-   * End the song. Handle saving high scores and transitioning to the results screen.
+   * Handle logic for actually skipping a video cutscene after it has been held.
    */
-  function endSong():Void
+  function skipVideoCutscene():Void
   {
-    dispatchEvent(new ScriptEvent(SONG_END));
+    VideoCutscene.finishVideo();
+  }
+
+  /**
+   * End the song. Handle saving high scores and transitioning to the results screen.
+   *
+   * Broadcasts an `onSongEnd` event, which can be cancelled to prevent the song from ending (for a cutscene or something).
+   * Remember to call `endSong` again when the song should actually end!
+   * @param rightGoddamnNow If true, don't play the fancy animation where you zoom onto Girlfriend. Used after a cutscene.
+   */
+  public function endSong(rightGoddamnNow:Bool = false):Void
+  {
+    FlxG.sound.music.volume = 0;
+    vocals.volume = 0;
+    mayPauseGame = false;
+
+    // Check if any events want to prevent the song from ending.
+    var event = new ScriptEvent(SONG_END, true);
+    dispatchEvent(event);
+    if (event.eventCanceled) return;
 
     #if sys
     // spitter for ravy, teehee!!
@@ -2516,9 +2542,7 @@ class PlayState extends MusicBeatSubState
     #end
 
     deathCounter = 0;
-    mayPauseGame = false;
-    FlxG.sound.music.volume = 0;
-    vocals.volume = 0;
+
     if (currentSong != null && currentSong.validScore)
     {
       // crackhead double thingie, sets whether was new highscore, AND saves the song!
@@ -2605,7 +2629,14 @@ class PlayState extends MusicBeatSubState
         }
         else
         {
-          moveToResultsScreen();
+          if (rightGoddamnNow)
+          {
+            moveToResultsScreen();
+          }
+          else
+          {
+            zoomIntoResultsScreen();
+          }
         }
       }
       else
@@ -2663,7 +2694,14 @@ class PlayState extends MusicBeatSubState
       }
       else
       {
-        moveToResultsScreen();
+        if (rightGoddamnNow)
+        {
+          moveToResultsScreen();
+        }
+        else
+        {
+          zoomIntoResultsScreen();
+        }
       }
     }
   }
@@ -2717,9 +2755,9 @@ class PlayState extends MusicBeatSubState
   }
 
   /**
-   * Play the camera zoom animation and move to the results screen.
+   * Play the camera zoom animation and then move to the results screen once it's done.
    */
-  function moveToResultsScreen():Void
+  function zoomIntoResultsScreen():Void
   {
     trace('WENT TO RESULTS SCREEN!');
 
@@ -2773,22 +2811,30 @@ class PlayState extends MusicBeatSubState
         {
           ease: FlxEase.expoIn,
           onComplete: function(_) {
-            persistentUpdate = false;
-            vocals.stop();
-            camHUD.alpha = 1;
-            var res:ResultState = new ResultState(
-              {
-                storyMode: PlayStatePlaylist.isStoryMode,
-                title: PlayStatePlaylist.isStoryMode ? ('${PlayStatePlaylist.campaignTitle}') : ('${currentChart.songName} by ${currentChart.songArtist}'),
-                tallies: Highscore.tallies,
-              });
-            res.camera = camHUD;
-            openSubState(res);
+            moveToResultsScreen();
           }
         });
     });
   }
 
+  /**
+   * Move to the results screen right goddamn now.
+   */
+  function moveToResultsScreen():Void
+  {
+    persistentUpdate = false;
+    vocals.stop();
+    camHUD.alpha = 1;
+    var res:ResultState = new ResultState(
+      {
+        storyMode: PlayStatePlaylist.isStoryMode,
+        title: PlayStatePlaylist.isStoryMode ? ('${PlayStatePlaylist.campaignTitle}') : ('${currentChart.songName} by ${currentChart.songArtist}'),
+        tallies: Highscore.tallies,
+      });
+    res.camera = camHUD;
+    openSubState(res);
+  }
+
   /**
    * Pauses music and vocals easily.
    */
@@ -2818,14 +2864,18 @@ class PlayState extends MusicBeatSubState
    */
   function changeSection(sections:Int):Void
   {
-    FlxG.sound.music.pause();
+    // FlxG.sound.music.pause();
 
-    var targetTimeSteps:Float = Conductor.instance.currentStepTime + (Conductor.instance.timeSignatureNumerator * Constants.STEPS_PER_BEAT * sections);
+    var targetTimeSteps:Float = Conductor.instance.currentStepTime + (Conductor.instance.stepsPerMeasure * sections);
     var targetTimeMs:Float = Conductor.instance.getStepTimeInMs(targetTimeSteps);
 
+    // Don't go back in time to before the song started.
+    targetTimeMs = Math.max(0, targetTimeMs);
+
     FlxG.sound.music.time = targetTimeMs;
 
     handleSkippedNotes();
+    SongEventRegistry.handleSkippedEvents(songEvents, Conductor.instance.songPosition);
     // regenNoteData(FlxG.sound.music.time);
 
     Conductor.instance.update(FlxG.sound.music.time);
diff --git a/source/funkin/play/cutscene/VideoCutscene.hx b/source/funkin/play/cutscene/VideoCutscene.hx
index df31accb2..75e69bf04 100644
--- a/source/funkin/play/cutscene/VideoCutscene.hx
+++ b/source/funkin/play/cutscene/VideoCutscene.hx
@@ -19,13 +19,22 @@ import hxcodec.flixel.FlxVideoSprite;
 class VideoCutscene
 {
   static var blackScreen:FlxSprite;
+  static var cutsceneType:CutsceneType;
+
+  #if html5
+  static var vid:FlxVideo;
+  #end
+  #if hxCodec
+  static var vid:FlxVideoSprite;
+  #end
 
   /**
    * Play a video cutscene.
    * TODO: Currently this is hardcoded to start the countdown after the video is done.
    * @param path The path to the video file. Use Paths.file(path) to get the correct path.
+   * @param cutseneType The type of cutscene to play, determines what the game does after. Defaults to `CutsceneType.STARTING`.
    */
-  public static function play(filePath:String):Void
+  public static function play(filePath:String, ?cutsceneType:CutsceneType = STARTING):Void
   {
     if (PlayState.instance == null) return;
 
@@ -49,6 +58,8 @@ class VideoCutscene
     blackScreen.cameras = [PlayState.instance.camCutscene];
     PlayState.instance.add(blackScreen);
 
+    VideoCutscene.cutsceneType = cutsceneType;
+
     #if html5
     playVideoHTML5(filePath);
     #elseif hxCodec
@@ -68,8 +79,6 @@ class VideoCutscene
   }
 
   #if html5
-  static var vid:FlxVideo;
-
   static function playVideoHTML5(filePath:String):Void
   {
     // Video displays OVER the FlxState.
@@ -94,8 +103,6 @@ class VideoCutscene
   #end
 
   #if hxCodec
-  static var vid:FlxVideoSprite;
-
   static function playVideoNative(filePath:String):Void
   {
     // Video displays OVER the FlxState.
@@ -129,10 +136,17 @@ class VideoCutscene
   }
   #end
 
+  /**
+   * Finish the active video cutscene. Done when the video is finished or when the player skips the cutscene.
+   * @param transitionTime The duration of the transition to the next state. Defaults to 0.5 seconds (this time is always used when cancelling the video).
+   * @param finishCutscene The callback to call when the transition is finished.
+   */
   public static function finishVideo(?transitionTime:Float = 0.5):Void
   {
     trace('ALERT: Finish video cutscene called!');
 
+    var cutsceneType:CutsceneType = VideoCutscene.cutsceneType;
+
     #if html5
     if (vid != null)
     {
@@ -168,8 +182,32 @@ class VideoCutscene
       {
         ease: FlxEase.quadInOut,
         onComplete: function(twn:FlxTween) {
-          PlayState.instance.startCountdown();
+          onCutsceneFinish(cutsceneType);
         }
       });
   }
+
+  /**
+   * The default callback used when a cutscene is finished.
+   * You can specify your own callback when calling `VideoCutscene#play()`.
+   */
+  static function onCutsceneFinish(cutsceneType:CutsceneType):Void
+  {
+    switch (cutsceneType)
+    {
+      case CutsceneType.STARTING:
+        PlayState.instance.startCountdown();
+      case CutsceneType.ENDING:
+        PlayState.instance.endSong(true); // true = right goddamn now
+      case CutsceneType.MIDSONG:
+        throw "Not implemented!";
+    }
+  }
+}
+
+enum CutsceneType
+{
+  STARTING; // The default cutscene type. Starts the countdown after the video is done.
+  MIDSONG; // TODO: Implement this!
+  ENDING; // Ends the song after the video is done.
 }

From d8fdf45ddac201c68cde476cf30050ab14146966 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Mon, 26 Feb 2024 21:07:22 -0500
Subject: [PATCH 27/30] Update assets submodule

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

diff --git a/assets b/assets
index 1b0e09750..f8c259584 160000
--- a/assets
+++ b/assets
@@ -1 +1 @@
-Subproject commit 1b0e097508ec694012043a4c059885b05a569e2a
+Subproject commit f8c2595844eff9375b522f117bfdadbdc6728c49

From ddeac1db15858037eed160eaa32427bd20fa29fb Mon Sep 17 00:00:00 2001
From: Cameron Taylor <cameron.taylor.ninja@gmail.com>
Date: Tue, 27 Feb 2024 22:02:04 -0500
Subject: [PATCH 28/30] deubbershish...

---
 source/funkin/InitState.hx | 3 +--
 1 file changed, 1 insertion(+), 2 deletions(-)

diff --git a/source/funkin/InitState.hx b/source/funkin/InitState.hx
index 0a59fb70b..33674439d 100644
--- a/source/funkin/InitState.hx
+++ b/source/funkin/InitState.hx
@@ -146,12 +146,11 @@ class InitState extends FlxState
     #end
 
     // Make errors and warnings less annoying.
-    #if FORCE_DEBUG_VERSION
+    // 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;
-    #end
 
     //
     // FLIXEL TRANSITIONS

From 5dafd5409794aeb28089132cee507595dd0c44aa Mon Sep 17 00:00:00 2001
From: Cameron Taylor <cameron.taylor.ninja@gmail.com>
Date: Tue, 27 Feb 2024 22:09:09 -0500
Subject: [PATCH 29/30] update submodules, and add change from other PR

---
 .gitmodules | 4 ----
 assets      | 2 +-
 2 files changed, 1 insertion(+), 5 deletions(-)

diff --git a/.gitmodules b/.gitmodules
index 903904103..452c0089b 100644
--- a/.gitmodules
+++ b/.gitmodules
@@ -1,10 +1,6 @@
 [submodule "assets"]
 	path = assets
 	url = https://github.com/FunkinCrew/Funkin-Assets-secret
-	branch = master
-	update = merge
 [submodule "art"]
 	path = art
 	url = https://github.com/FunkinCrew/Funkin-Art-secret
-	branch = master
-	update = merge
diff --git a/assets b/assets
index cb0fbb56b..f8c259584 160000
--- a/assets
+++ b/assets
@@ -1 +1 @@
-Subproject commit cb0fbb56b9667f68a9776a216c16a4e2b29f7096
+Subproject commit f8c2595844eff9375b522f117bfdadbdc6728c49

From 6da1dd57ad77a2a25273c78f7bc9be64c3f0ed2d Mon Sep 17 00:00:00 2001
From: Cameron Taylor <cameron.taylor.ninja@gmail.com>
Date: Tue, 27 Feb 2024 22:27:50 -0500
Subject: [PATCH 30/30] Update build-shit.yml

---
 .github/workflows/build-shit.yml | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/.github/workflows/build-shit.yml b/.github/workflows/build-shit.yml
index 8ea3b16f3..388670a01 100644
--- a/.github/workflows/build-shit.yml
+++ b/.github/workflows/build-shit.yml
@@ -39,7 +39,7 @@ jobs:
           build-dir: export/release/html5/bin
           target: html5
   create-nightly-win:
-    runs-on: windows-latest
+    runs-on: [self-hosted, windows]
     steps:
       - name: get token from gh app
         uses: actions/create-github-app-token@v1