From 5ff546baccf803127087faea055537dd0a1e5b05 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Wed, 26 Jul 2023 16:52:58 -0400
Subject: [PATCH 01/29] First iteration of song playtesting from editor!

---
 .../transition/FlxTransitionableSubState.hx   | 234 ++++++++++++++++++
 source/funkin/Controls.hx                     |   1 -
 source/funkin/FreeplayState.hx                |   7 +
 source/funkin/MainMenuState.hx                |   1 -
 source/funkin/MusicBeatState.hx               |   4 +-
 source/funkin/MusicBeatSubState.hx            |  21 +-
 source/funkin/PauseSubState.hx                |  25 +-
 source/funkin/play/PlayState.hx               | 110 ++++++--
 source/funkin/play/song/Song.hx               |  67 +++--
 source/funkin/play/song/SongData.hx           |   4 +-
 source/funkin/ui/debug/DebugMenuSubState.hx   |   4 +
 .../charting/ChartEditorDialogHandler.hx      |   2 +
 .../ui/debug/charting/ChartEditorState.hx     |  78 +++++-
 source/funkin/ui/story/StoryMenuState.hx      |   7 +
 source/funkin/util/tools/ArrayTools.hx        |  11 +
 15 files changed, 520 insertions(+), 56 deletions(-)
 create mode 100644 source/flixel/addons/transition/FlxTransitionableSubState.hx

diff --git a/source/flixel/addons/transition/FlxTransitionableSubState.hx b/source/flixel/addons/transition/FlxTransitionableSubState.hx
new file mode 100644
index 000000000..7bb536bb2
--- /dev/null
+++ b/source/flixel/addons/transition/FlxTransitionableSubState.hx
@@ -0,0 +1,234 @@
+package flixel.addons.transition;
+
+import flixel.FlxSubState;
+import flixel.addons.transition.FlxTransitionableState;
+
+/**
+ * A `FlxSubState` which can perform visual transitions
+ *
+ * Usage:
+ *
+ * First, extend `FlxTransitionableSubState` as ie, `FooState`.
+ *
+ * Method 1:
+ *
+ * ```haxe
+ * var in:TransitionData = new TransitionData(...); // add your data where "..." is
+ * var out:TransitionData = new TransitionData(...);
+ *
+ * FlxG.switchState(new FooState(in,out));
+ * ```
+ *
+ * Method 2:
+ *
+ * ```haxe
+ * FlxTransitionableSubState.defaultTransIn = new TransitionData(...);
+ * FlxTransitionableSubState.defaultTransOut = new TransitionData(...);
+ *
+ * FlxG.switchState(new FooState());
+ * ```
+ */
+class FlxTransitionableSubState extends FlxSubState
+{
+  // global default transitions for ALL states, used if transIn/transOut are null
+  public static var defaultTransIn(get, set):TransitionData;
+
+  static function get_defaultTransIn():TransitionData
+  {
+    return FlxTransitionableState.defaultTransIn;
+  }
+
+  static function set_defaultTransIn(value:TransitionData):TransitionData
+  {
+    return FlxTransitionableState.defaultTransIn = value;
+  }
+
+  public static var defaultTransOut(get, set):TransitionData;
+
+  static function get_defaultTransOut():TransitionData
+  {
+    return FlxTransitionableState.defaultTransOut;
+  }
+
+  static function set_defaultTransOut(value:TransitionData):TransitionData
+  {
+    return FlxTransitionableState.defaultTransOut = value;
+  }
+
+  public static var skipNextTransIn(get, set):Bool;
+
+  static function get_skipNextTransIn():Bool
+  {
+    return FlxTransitionableState.skipNextTransIn;
+  }
+
+  static function set_skipNextTransIn(value:Bool):Bool
+  {
+    return FlxTransitionableState.skipNextTransIn = value;
+  }
+
+  public static var skipNextTransOut(get, set):Bool;
+
+  static function get_skipNextTransOut():Bool
+  {
+    return FlxTransitionableState.skipNextTransOut;
+  }
+
+  static function set_skipNextTransOut(value:Bool):Bool
+  {
+    return FlxTransitionableState.skipNextTransOut = value;
+  }
+
+  // beginning & ending transitions for THIS state:
+  public var transIn:TransitionData;
+  public var transOut:TransitionData;
+
+  public var hasTransIn(get, never):Bool;
+  public var hasTransOut(get, never):Bool;
+
+  /**
+   * Create a state with the ability to do visual transitions
+   * @param	TransIn		Plays when the state begins
+   * @param	TransOut	Plays when the state ends
+   */
+  public function new(?TransIn:TransitionData, ?TransOut:TransitionData)
+  {
+    transIn = TransIn;
+    transOut = TransOut;
+
+    if (transIn == null && defaultTransIn != null)
+    {
+      transIn = defaultTransIn;
+    }
+    if (transOut == null && defaultTransOut != null)
+    {
+      transOut = defaultTransOut;
+    }
+    super();
+  }
+
+  override function destroy():Void
+  {
+    super.destroy();
+    transIn = null;
+    transOut = null;
+    _onExit = null;
+  }
+
+  override function create():Void
+  {
+    super.create();
+    transitionIn();
+  }
+
+  override function startOutro(onOutroComplete:() -> Void)
+  {
+    if (!hasTransOut) onOutroComplete();
+    else if (!_exiting)
+    {
+      // play the exit transition, and when it's done call FlxG.switchState
+      _exiting = true;
+      transitionOut(onOutroComplete);
+
+      if (skipNextTransOut)
+      {
+        skipNextTransOut = false;
+        finishTransOut();
+      }
+    }
+  }
+
+  /**
+   * Starts the in-transition. Can be called manually at any time.
+   */
+  public function transitionIn():Void
+  {
+    if (transIn != null && transIn.type != NONE)
+    {
+      if (skipNextTransIn)
+      {
+        skipNextTransIn = false;
+        if (finishTransIn != null)
+        {
+          finishTransIn();
+        }
+        return;
+      }
+
+      var _trans = createTransition(transIn);
+
+      _trans.setStatus(FULL);
+      openSubState(_trans);
+
+      _trans.finishCallback = finishTransIn;
+      _trans.start(OUT);
+    }
+  }
+
+  /**
+   * Starts the out-transition. Can be called manually at any time.
+   */
+  public function transitionOut(?OnExit:Void->Void):Void
+  {
+    _onExit = OnExit;
+    if (hasTransOut)
+    {
+      var _trans = createTransition(transOut);
+
+      _trans.setStatus(EMPTY);
+      openSubState(_trans);
+
+      _trans.finishCallback = finishTransOut;
+      _trans.start(IN);
+    }
+    else
+    {
+      _onExit();
+    }
+  }
+
+  var transOutFinished:Bool = false;
+
+  var _exiting:Bool = false;
+  var _onExit:Void->Void;
+
+  function get_hasTransIn():Bool
+  {
+    return transIn != null && transIn.type != NONE;
+  }
+
+  function get_hasTransOut():Bool
+  {
+    return transOut != null && transOut.type != NONE;
+  }
+
+  function createTransition(data:TransitionData):Transition
+  {
+    return switch (data.type)
+    {
+      case TILES: new Transition(data);
+      case FADE: new Transition(data);
+      default: null;
+    }
+  }
+
+  function finishTransIn()
+  {
+    closeSubState();
+  }
+
+  function finishTransOut()
+  {
+    transOutFinished = true;
+
+    if (!_exiting)
+    {
+      closeSubState();
+    }
+
+    if (_onExit != null)
+    {
+      _onExit();
+    }
+  }
+}
diff --git a/source/funkin/Controls.hx b/source/funkin/Controls.hx
index 88b637e72..e8a66cb14 100644
--- a/source/funkin/Controls.hx
+++ b/source/funkin/Controls.hx
@@ -16,7 +16,6 @@ import flixel.input.keyboard.FlxKey;
 import flixel.input.mouse.FlxMouseButton.FlxMouseButtonID;
 import flixel.math.FlxAngle;
 import flixel.math.FlxPoint;
-import flixel.ui.FlxVirtualPad;
 import flixel.util.FlxColor;
 import flixel.util.FlxTimer;
 import lime.ui.Haptic;
diff --git a/source/funkin/FreeplayState.hx b/source/funkin/FreeplayState.hx
index 608898a5f..a86bfe8cc 100644
--- a/source/funkin/FreeplayState.hx
+++ b/source/funkin/FreeplayState.hx
@@ -890,6 +890,13 @@ class FreeplayState extends MusicBeatSubState
       FlxG.sound.play(Paths.sound('confirmMenu'));
       dj.confirm();
 
+      if (targetSong != null)
+      {
+        // Load and cache the song's charts.
+        // TODO: Do this in the loading state.
+        targetSong.cacheCharts(true);
+      }
+
       new FlxTimer().start(1, function(tmr:FlxTimer) {
         LoadingState.loadAndSwitchState(new PlayState(
           {
diff --git a/source/funkin/MainMenuState.hx b/source/funkin/MainMenuState.hx
index 2c251635c..bca20980c 100644
--- a/source/funkin/MainMenuState.hx
+++ b/source/funkin/MainMenuState.hx
@@ -12,7 +12,6 @@ import flixel.input.touch.FlxTouch;
 import flixel.text.FlxText;
 import flixel.tweens.FlxEase;
 import flixel.tweens.FlxTween;
-import flixel.ui.FlxButton;
 import flixel.util.FlxColor;
 import flixel.util.FlxTimer;
 import funkin.NGio;
diff --git a/source/funkin/MusicBeatState.hx b/source/funkin/MusicBeatState.hx
index 20330f257..9a986a8b5 100644
--- a/source/funkin/MusicBeatState.hx
+++ b/source/funkin/MusicBeatState.hx
@@ -3,7 +3,7 @@ package funkin;
 import funkin.modding.IScriptedClass.IEventHandler;
 import flixel.FlxState;
 import flixel.FlxSubState;
-import flixel.addons.ui.FlxUIState;
+import flixel.addons.transition.FlxTransitionableState;
 import flixel.text.FlxText;
 import flixel.util.FlxColor;
 import flixel.util.FlxSort;
@@ -16,7 +16,7 @@ import funkin.util.SortUtil;
  * MusicBeatState actually represents the core utility FlxState of the game.
  * It includes functionality for event handling, as well as maintaining BPM-based update events.
  */
-class MusicBeatState extends FlxUIState implements IEventHandler
+class MusicBeatState extends FlxTransitionableState implements IEventHandler
 {
   var controls(get, never):Controls;
 
diff --git a/source/funkin/MusicBeatSubState.hx b/source/funkin/MusicBeatSubState.hx
index 244d2ceea..1958c6074 100644
--- a/source/funkin/MusicBeatSubState.hx
+++ b/source/funkin/MusicBeatSubState.hx
@@ -1,24 +1,28 @@
 package funkin;
 
+import flixel.addons.transition.FlxTransitionableSubState;
 import flixel.FlxSubState;
-import funkin.modding.IScriptedClass.IEventHandler;
+import flixel.text.FlxText;
 import flixel.util.FlxColor;
 import funkin.modding.events.ScriptEvent;
+import funkin.modding.IScriptedClass.IEventHandler;
 import funkin.modding.module.ModuleHandler;
-import flixel.text.FlxText;
 import funkin.modding.PolymodHandler;
+import funkin.util.SortUtil;
+import flixel.util.FlxSort;
 
 /**
  * MusicBeatSubState reincorporates the functionality of MusicBeatState into an FlxSubState.
  */
-class MusicBeatSubState extends FlxSubState implements IEventHandler
+class MusicBeatSubState extends FlxTransitionableSubState implements IEventHandler
 {
   public var leftWatermarkText:FlxText = null;
   public var rightWatermarkText:FlxText = null;
 
   public function new(bgColor:FlxColor = FlxColor.TRANSPARENT)
   {
-    super(bgColor);
+    super();
+    this.bgColor = bgColor;
   }
 
   var controls(get, never):Controls;
@@ -67,6 +71,15 @@ class MusicBeatSubState extends FlxSubState implements IEventHandler
     FlxG.resetState();
   }
 
+  /**
+   * Refreshes the state, by redoing the render order of all sprites.
+   * It does this based on the `zIndex` of each prop.
+   */
+  public function refresh()
+  {
+    sort(SortUtil.byZIndex, FlxSort.ASCENDING);
+  }
+
   /**
    * Called when a step is hit in the current song.
    * Continues outside of PlayState, for things like animations in menus.
diff --git a/source/funkin/PauseSubState.hx b/source/funkin/PauseSubState.hx
index d5584fbc7..9133a8fab 100644
--- a/source/funkin/PauseSubState.hx
+++ b/source/funkin/PauseSubState.hx
@@ -16,14 +16,17 @@ class PauseSubState extends MusicBeatSubState
 {
   var grpMenuShit:FlxTypedGroup<Alphabet>;
 
-  var pauseOG:Array<String> = [
+  var pauseOptionsBase:Array<String> = [
     'Resume',
     'Restart Song',
     'Change Difficulty',
     'Toggle Practice Mode',
     'Exit to Menu'
   ];
-  var difficultyChoices:Array<String> = ['EASY', 'NORMAL', 'HARD', 'ERECT', 'BACK'];
+
+  var pauseOptionsDifficulty:Array<String> = ['EASY', 'NORMAL', 'HARD', 'ERECT', 'BACK'];
+
+  var pauseOptionsCharting:Array<String> = ['Resume', 'Restart Song', 'Exit to Chart Editor'];
 
   var menuItems:Array<String> = [];
   var curSelected:Int = 0;
@@ -36,11 +39,15 @@ class PauseSubState extends MusicBeatSubState
   var bg:FlxSprite;
   var metaDataGrp:FlxTypedGroup<FlxSprite>;
 
-  public function new()
+  var isChartingMode:Bool;
+
+  public function new(?isChartingMode:Bool = false)
   {
     super();
 
-    menuItems = pauseOG;
+    this.isChartingMode = isChartingMode;
+
+    menuItems = this.isChartingMode ? pauseOptionsCharting : pauseOptionsBase;
 
     if (PlayStatePlaylist.campaignId == 'week6')
     {
@@ -180,14 +187,13 @@ class PauseSubState extends MusicBeatSubState
       {
         var daSelected:String = menuItems[curSelected];
 
-        // TODO: Why is this based on the menu item's name? Make this an enum or something.
         switch (daSelected)
         {
           case 'Resume':
             close();
 
           case 'Change Difficulty':
-            menuItems = difficultyChoices;
+            menuItems = pauseOptionsDifficulty;
             regenMenu();
 
           case 'EASY' | 'NORMAL' | 'HARD' | 'ERECT':
@@ -199,7 +205,7 @@ class PauseSubState extends MusicBeatSubState
 
             close();
           case 'BACK':
-            menuItems = pauseOG;
+            menuItems = this.isChartingMode ? pauseOptionsCharting : pauseOptionsBase;
             regenMenu();
 
           case 'Toggle Practice Mode':
@@ -226,6 +232,11 @@ class PauseSubState extends MusicBeatSubState
             if (PlayStatePlaylist.isStoryMode) openSubState(new funkin.ui.StickerSubState(null, STORY));
             else
               openSubState(new funkin.ui.StickerSubState(null, FREEPLAY));
+
+          case 'Exit to Chart Editor':
+            this.close();
+            if (FlxG.sound.music != null) FlxG.sound.music.stop();
+            PlayState.instance.close(); // This only works because PlayState is a substate!
         }
       }
 
diff --git a/source/funkin/play/PlayState.hx b/source/funkin/play/PlayState.hx
index c0705bd96..cf12db06b 100644
--- a/source/funkin/play/PlayState.hx
+++ b/source/funkin/play/PlayState.hx
@@ -1,5 +1,6 @@
 package funkin.play;
 
+import funkin.ui.debug.charting.ChartEditorState;
 import haxe.Int64;
 import funkin.play.notes.notestyle.NoteStyle;
 import funkin.data.notestyle.NoteStyleData;
@@ -77,12 +78,23 @@ typedef PlayStateParams =
    * @default `bf`, or the first character in the song's character list.
    */
   ?targetCharacter:String,
+  /**
+   * Whether the song should start in Practice Mode.
+   * @default `false`
+   */
+  ?practiceMode:Bool,
+  /**
+   * Whether the song should be in minimal mode.
+   * @default `false`
+   */
+  ?minimalMode:Bool,
 }
 
 /**
  * The gameplay state, where all the rhythm gaming happens.
+ * SubState so it can be loaded as a child of the chart editor.
  */
-class PlayState extends MusicBeatState
+class PlayState extends MusicBeatSubState
 {
   /**
    * STATIC VARIABLES
@@ -209,6 +221,11 @@ class PlayState extends MusicBeatState
    */
   public var isPracticeMode:Bool = false;
 
+  /**
+   * In Minimal Mode, the stage and characters are not loaded and a standard background is used.
+   */
+  public var isMinimalMode:Bool = false;
+
   /**
    * Whether the game is currently in an animated cutscene, and gameplay should be stopped.
    */
@@ -219,6 +236,20 @@ class PlayState extends MusicBeatState
    */
   public var disableKeys:Bool = false;
 
+  public var isSubState(get, null):Bool;
+
+  function get_isSubState():Bool
+  {
+    return this._parentState != null;
+  }
+
+  public var isChartingMode(get, null):Bool;
+
+  function get_isChartingMode():Bool
+  {
+    return this._parentState != null && Std.isOfType(this._parentState, ChartEditorState);
+  }
+
   /**
    * The current dialogue.
    */
@@ -438,6 +469,8 @@ class PlayState extends MusicBeatState
     currentSong = params.targetSong;
     if (params.targetDifficulty != null) currentDifficulty = params.targetDifficulty;
     if (params.targetCharacter != null) currentPlayerId = params.targetCharacter;
+    isPracticeMode = params.practiceMode ?? false;
+    isMinimalMode = params.minimalMode ?? false;
 
     // Don't do anything else here! Wait until create() when we attach to the camera.
   }
@@ -458,13 +491,6 @@ class PlayState extends MusicBeatState
 
     NoteSplash.buildSplashFrames();
 
-    if (currentSong != null)
-    {
-      // Load and cache the song's charts.
-      // TODO: Do this in the loading state.
-      currentSong.cacheCharts(true);
-    }
-
     // Returns null if the song failed to load or doesn't have the selected difficulty.
     if (currentSong == null || currentChart == null)
     {
@@ -490,7 +516,14 @@ class PlayState extends MusicBeatState
       lime.app.Application.current.window.alert(message, 'Error loading PlayState');
 
       // Force the user back to the main menu.
-      FlxG.switchState(new MainMenuState());
+      if (isSubState)
+      {
+        this.close();
+      }
+      else
+      {
+        FlxG.switchState(new MainMenuState());
+      }
       return;
     }
 
@@ -532,8 +565,15 @@ class PlayState extends MusicBeatState
     // The song is now loaded. We can continue to initialize the play state.
     initCameras();
     initHealthBar();
-    initStage();
-    initCharacters();
+    if (!isMinimalMode)
+    {
+      initStage();
+      initCharacters();
+    }
+    else
+    {
+      initMinimalMode();
+    }
     initStrumlines();
 
     // Initialize the judgements and combo meter.
@@ -706,7 +746,7 @@ class PlayState extends MusicBeatState
         // There is a 1/1000 change to use a special pause menu.
         // This prevents the player from resuming, but that's the point.
         // It's a reference to Gitaroo Man, which doesn't let you pause the game.
-        if (event.gitaroo)
+        if (!isSubState && event.gitaroo)
         {
           FlxG.switchState(new GitarooPause(
             {
@@ -725,7 +765,7 @@ class PlayState extends MusicBeatState
             boyfriendPos = currentStage.getBoyfriend().getScreenPosition();
           }
 
-          var pauseSubState:FlxSubState = new PauseSubState();
+          var pauseSubState:FlxSubState = new PauseSubState(isChartingMode);
 
           openSubState(pauseSubState);
           pauseSubState.camera = camHUD;
@@ -1202,6 +1242,19 @@ class PlayState extends MusicBeatState
     loadStage(currentStageId);
   }
 
+  function initMinimalMode():Void
+  {
+    // Create the green background.
+    var menuBG = new FlxSprite().loadGraphic(Paths.image('menuDesat'));
+    menuBG.color = 0xFF4CAF50;
+    menuBG.setGraphicSize(Std.int(menuBG.width * 1.1));
+    menuBG.updateHitbox();
+    menuBG.screenCenter();
+    menuBG.scrollFactor.set(0, 0);
+    menuBG.zIndex = -1000;
+    add(menuBG);
+  }
+
   /**
    * Loads stage data from cache, assembles the props,
    * and adds it to the state.
@@ -2132,6 +2185,7 @@ class PlayState extends MusicBeatState
     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();
@@ -2163,7 +2217,10 @@ class PlayState extends MusicBeatState
     }
 
     // 8: Move to the offset editor.
-    if (FlxG.keys.justPressed.EIGHT) FlxG.switchState(new funkin.ui.animDebugShit.DebugBoundingState());
+    if (FlxG.keys.justPressed.EIGHT)
+    {
+      lime.app.Application.current.window.alert("Press ~ on the main menu to get to the editor", 'LOL');
+    }
 
     // 9: Toggle the old icon.
     if (FlxG.keys.justPressed.NINE) iconP1.toggleOldIcon();
@@ -2384,7 +2441,14 @@ class PlayState extends MusicBeatState
         // FlxG.save.data.weekUnlocked = StoryMenuState.weekUnlocked;
         FlxG.save.flush();
 
-        moveToResultsScreen();
+        if (isSubState)
+        {
+          this.close();
+        }
+        else
+        {
+          moveToResultsScreen();
+        }
       }
       else
       {
@@ -2438,10 +2502,23 @@ class PlayState extends MusicBeatState
     }
     else
     {
-      moveToResultsScreen();
+      if (isSubState)
+      {
+        this.close();
+      }
+      else
+      {
+        moveToResultsScreen();
+      }
     }
   }
 
+  public override function close():Void
+  {
+    performCleanup();
+    super.close();
+  }
+
   /**
    * Perform necessary cleanup before leaving the PlayState.
    */
@@ -2552,6 +2629,7 @@ class PlayState extends MusicBeatState
     FlxG.camera.follow(cameraFollowPoint, LOCKON, 0.04);
     FlxG.camera.targetOffset.set();
     FlxG.camera.zoom = defaultCameraZoom;
+    // Snap the camera to the follow point immediately.
     FlxG.camera.focusOn(cameraFollowPoint.getPosition());
   }
 
diff --git a/source/funkin/play/song/Song.hx b/source/funkin/play/song/Song.hx
index 4cbf1ade3..a8f004520 100644
--- a/source/funkin/play/song/Song.hx
+++ b/source/funkin/play/song/Song.hx
@@ -56,6 +56,32 @@ class Song implements IPlayStateScriptedClass
     populateFromMetadata();
   }
 
+  @:allow(funkin.play.song.Song)
+  public static function buildRaw(songId:String, metadata:Array<SongMetadata>, variations:Array<String>, charts:Map<String, SongChartData>,
+      ?validScore:Bool = false):Song
+  {
+    var result:Song = new Song(songId);
+
+    result._metadata.clear();
+    for (meta in metadata)
+      result._metadata.push(meta);
+
+    result.variations.clear();
+    for (vari in variations)
+      result.variations.push(vari);
+
+    result.difficultyIds.clear();
+
+    result.populateFromMetadata();
+
+    for (variation => chartData in charts)
+      result.applyChartData(chartData, variation);
+
+    result.validScore = validScore;
+
+    return result;
+  }
+
   public function getRawMetadata():Array<SongMetadata>
   {
     return _metadata;
@@ -119,28 +145,33 @@ class Song implements IPlayStateScriptedClass
     for (variation in variations)
     {
       var chartData:SongChartData = SongDataParser.parseSongChartData(songId, variation);
-      var chartNotes = chartData.notes;
-
-      for (diffId in chartNotes.keys())
-      {
-        // Retrieve the cached difficulty data.
-        var difficulty:Null<SongDifficulty> = difficulties.get(diffId);
-        if (difficulty == null)
-        {
-          trace('Fabricated new difficulty for $diffId.');
-          difficulty = new SongDifficulty(this, diffId, variation);
-          difficulties.set(diffId, difficulty);
-        }
-        // Add the chart data to the difficulty.
-        difficulty.notes = chartData.notes.get(diffId);
-        difficulty.scrollSpeed = chartData.getScrollSpeed(diffId);
-
-        difficulty.events = chartData.events;
-      }
+      applyChartData(chartData, variation);
     }
     trace('Done caching charts.');
   }
 
+  function applyChartData(chartData:SongChartData, variation:String):Void
+  {
+    var chartNotes = chartData.notes;
+
+    for (diffId in chartNotes.keys())
+    {
+      // Retrieve the cached difficulty data.
+      var difficulty:Null<SongDifficulty> = difficulties.get(diffId);
+      if (difficulty == null)
+      {
+        trace('Fabricated new difficulty for $diffId.');
+        difficulty = new SongDifficulty(this, diffId, variation);
+        difficulties.set(diffId, difficulty);
+      }
+      // Add the chart data to the difficulty.
+      difficulty.notes = chartData.notes.get(diffId);
+      difficulty.scrollSpeed = chartData.getScrollSpeed(diffId);
+
+      difficulty.events = chartData.events;
+    }
+  }
+
   /**
    * Retrieve the metadata for a specific difficulty, including the chart if it is loaded.
    * @param diffId The difficulty ID, such as `easy` or `hard`.
diff --git a/source/funkin/play/song/SongData.hx b/source/funkin/play/song/SongData.hx
index c2a701ce9..740fec9d1 100644
--- a/source/funkin/play/song/SongData.hx
+++ b/source/funkin/play/song/SongData.hx
@@ -202,7 +202,7 @@ class SongDataParser
 
   static function loadMusicMetadataFile(musicPath:String, variation:String = ''):String
   {
-    var musicMetadataFilePath:String = (variation != '') ? Paths.file('$MUSIC_DATA_PATH$musicPath/$musicPath-metadata-$variation.json') : Paths.file('$MUSIC_DATA_PATH$musicPath/$musicPath-metadata.json');
+    var musicMetadataFilePath:String = (variation != '' || variation == "default") ? Paths.file('$MUSIC_DATA_PATH$musicPath/$musicPath-metadata-$variation.json') : Paths.file('$MUSIC_DATA_PATH$musicPath/$musicPath-metadata.json');
 
     var rawJson:String = Assets.getText(musicMetadataFilePath).trim();
 
@@ -238,7 +238,7 @@ class SongDataParser
 
   static function loadSongChartDataFile(songPath:String, variation:String = ''):String
   {
-    var songChartDataFilePath:String = (variation != '') ? Paths.json('$SONG_DATA_PATH$songPath/$songPath-chart-$variation') : Paths.json('$SONG_DATA_PATH$songPath/$songPath-chart');
+    var songChartDataFilePath:String = (variation != '' || variation == 'default') ? Paths.json('$SONG_DATA_PATH$songPath/$songPath-chart-$variation') : Paths.json('$SONG_DATA_PATH$songPath/$songPath-chart');
 
     var rawJson:String = Assets.getText(songChartDataFilePath).trim();
 
diff --git a/source/funkin/ui/debug/DebugMenuSubState.hx b/source/funkin/ui/debug/DebugMenuSubState.hx
index 9f18acd35..7ef4cb238 100644
--- a/source/funkin/ui/debug/DebugMenuSubState.hx
+++ b/source/funkin/ui/debug/DebugMenuSubState.hx
@@ -1,5 +1,6 @@
 package funkin.ui.debug;
 
+import flixel.math.FlxPoint;
 import flixel.FlxObject;
 import flixel.FlxSprite;
 import funkin.MusicBeatSubState;
@@ -48,6 +49,9 @@ class DebugMenuSubState extends MusicBeatSubState
     createItem("ANIMATION EDITOR", openAnimationEditor);
     createItem("STAGE EDITOR", openStageEditor);
     createItem("TEST STICKERS", testStickers);
+
+    FlxG.camera.focusOn(new FlxPoint(camFocusPoint.x, camFocusPoint.y));
+    FlxG.camera.focusOn(new FlxPoint(camFocusPoint.x, camFocusPoint.y + 500));
   }
 
   function onMenuChange(selected:TextMenuItem)
diff --git a/source/funkin/ui/debug/charting/ChartEditorDialogHandler.hx b/source/funkin/ui/debug/charting/ChartEditorDialogHandler.hx
index 9453c8c94..2e8cc78d4 100644
--- a/source/funkin/ui/debug/charting/ChartEditorDialogHandler.hx
+++ b/source/funkin/ui/debug/charting/ChartEditorDialogHandler.hx
@@ -580,6 +580,8 @@ class ChartEditorDialogHandler
       state.isHaxeUIDialogOpen = false;
     };
 
+    dialog.zIndex = 1000;
+
     return dialog;
   }
 }
diff --git a/source/funkin/ui/debug/charting/ChartEditorState.hx b/source/funkin/ui/debug/charting/ChartEditorState.hx
index 2238fff3f..a3fcd0f22 100644
--- a/source/funkin/ui/debug/charting/ChartEditorState.hx
+++ b/source/funkin/ui/debug/charting/ChartEditorState.hx
@@ -2,7 +2,9 @@ package funkin.ui.debug.charting;
 
 import flixel.addons.display.FlxSliceSprite;
 import flixel.addons.display.FlxTiledSprite;
+import flixel.FlxCamera;
 import flixel.FlxSprite;
+import flixel.FlxSubState;
 import flixel.group.FlxSpriteGroup;
 import flixel.input.keyboard.FlxKey;
 import flixel.math.FlxPoint;
@@ -20,6 +22,7 @@ import funkin.modding.events.ScriptEvent;
 import funkin.play.HealthIcon;
 import funkin.play.notes.NoteSprite;
 import funkin.play.notes.Strumline;
+import funkin.play.PlayState;
 import funkin.play.song.Song;
 import funkin.play.song.SongData.SongChartData;
 import funkin.play.song.SongData.SongDataParser;
@@ -977,6 +980,13 @@ class ChartEditorState extends HaxeUIState
 
   override function create():Void
   {
+    // super.create() must be called first, the HaxeUI components get created here.
+    super.create();
+    // Set the z-index of the HaxeUI.
+    this.component.zIndex = 100;
+
+    fixCamera();
+
     // Get rid of any music from the previous state.
     FlxG.sound.music.stop();
 
@@ -989,8 +999,6 @@ class ChartEditorState extends HaxeUIState
     buildGrid();
     buildSelectionBox();
 
-    // Add the HaxeUI components after the grid so they're on top.
-    super.create();
     buildAdditionalUI();
 
     // Setup the onClick listeners for the UI after it's been created.
@@ -999,6 +1007,8 @@ class ChartEditorState extends HaxeUIState
 
     setupAutoSave();
 
+    refresh();
+
     ChartEditorDialogHandler.openWelcomeDialog(this, false);
   }
 
@@ -1028,6 +1038,7 @@ class ChartEditorState extends HaxeUIState
     menuBG.updateHitbox();
     menuBG.screenCenter();
     menuBG.scrollFactor.set(0, 0);
+    menuBG.zIndex = -100;
   }
 
   /**
@@ -1039,28 +1050,33 @@ class ChartEditorState extends HaxeUIState
     gridTiledSprite.x = FlxG.width / 2 - GRID_SIZE * STRUMLINE_SIZE; // Center the grid.
     gridTiledSprite.y = MENU_BAR_HEIGHT + GRID_TOP_PAD; // Push down to account for the menu bar.
     add(gridTiledSprite);
+    gridTiledSprite.zIndex = 10;
 
     gridGhostNote = new ChartEditorNoteSprite(this);
     gridGhostNote.alpha = 0.6;
     gridGhostNote.noteData = new SongNoteData(0, 0, 0, "");
     gridGhostNote.visible = false;
     add(gridGhostNote);
+    gridGhostNote.zIndex = 11;
 
     gridGhostEvent = new ChartEditorEventSprite(this);
     gridGhostEvent.alpha = 0.6;
     gridGhostEvent.eventData = new SongEventData(-1, "", {});
     gridGhostEvent.visible = false;
     add(gridGhostEvent);
+    gridGhostEvent.zIndex = 12;
 
     buildNoteGroup();
 
     gridPlayheadScrollArea = new FlxSprite(gridTiledSprite.x - PLAYHEAD_SCROLL_AREA_WIDTH,
       MENU_BAR_HEIGHT).makeGraphic(PLAYHEAD_SCROLL_AREA_WIDTH, FlxG.height - MENU_BAR_HEIGHT, PLAYHEAD_SCROLL_AREA_COLOR);
     add(gridPlayheadScrollArea);
+    gridPlayheadScrollArea.zIndex = 25;
 
     // The playhead that show the current position in the song.
     gridPlayhead = new FlxSpriteGroup();
     add(gridPlayhead);
+    gridPlayhead.zIndex = 30;
 
     var playheadWidth = GRID_SIZE * (STRUMLINE_SIZE * 2 + 1) + (PLAYHEAD_SCROLL_AREA_WIDTH * 2);
     var playheadBaseYPos = MENU_BAR_HEIGHT + GRID_TOP_PAD;
@@ -1082,6 +1098,7 @@ class ChartEditorState extends HaxeUIState
     healthIconDad.x = gridTiledSprite.x - 15 - (HealthIcon.HEALTH_ICON_SIZE * 0.5);
     healthIconDad.y = gridTiledSprite.y + 5;
     add(healthIconDad);
+    healthIconDad.zIndex = 30;
 
     healthIconBF = new HealthIcon('bf');
     healthIconBF.autoUpdate = false;
@@ -1090,12 +1107,14 @@ class ChartEditorState extends HaxeUIState
     healthIconBF.y = gridTiledSprite.y + 5;
     healthIconBF.flipX = true;
     add(healthIconBF);
+    healthIconBF.zIndex = 30;
   }
 
   function buildSelectionBox():Void
   {
     selectionBoxSprite.scrollFactor.set(0, 0);
     add(selectionBoxSprite);
+    selectionBoxSprite.zIndex = 30;
 
     setSelectionBoxBounds();
   }
@@ -1140,18 +1159,22 @@ class ChartEditorState extends HaxeUIState
     renderedHoldNotes = new FlxTypedSpriteGroup<ChartEditorHoldNoteSprite>();
     renderedHoldNotes.setPosition(gridTiledSprite.x, gridTiledSprite.y);
     add(renderedHoldNotes);
+    renderedHoldNotes.zIndex = 24;
 
     renderedNotes = new FlxTypedSpriteGroup<ChartEditorNoteSprite>();
     renderedNotes.setPosition(gridTiledSprite.x, gridTiledSprite.y);
     add(renderedNotes);
+    renderedNotes.zIndex = 25;
 
     renderedEvents = new FlxTypedSpriteGroup<ChartEditorEventSprite>();
     renderedEvents.setPosition(gridTiledSprite.x, gridTiledSprite.y);
     add(renderedEvents);
+    renderedNotes.zIndex = 25;
 
     renderedSelectionSquares = new FlxTypedSpriteGroup<FlxSprite>();
     renderedSelectionSquares.setPosition(gridTiledSprite.x, gridTiledSprite.y);
     add(renderedSelectionSquares);
+    renderedNotes.zIndex = 26;
   }
 
   var playbarHeadLayout:Component;
@@ -1159,6 +1182,7 @@ class ChartEditorState extends HaxeUIState
   function buildAdditionalUI():Void
   {
     playbarHeadLayout = buildComponent(CHART_EDITOR_PLAYBARHEAD_LAYOUT);
+    playbarHeadLayout.zIndex = 110;
 
     playbarHeadLayout.width = FlxG.width - 8;
     playbarHeadLayout.height = 10;
@@ -1271,6 +1295,9 @@ class ChartEditorState extends HaxeUIState
     // addUIClickListener('menubarItemSelectBeforeCursor', _ -> doSomething());
     // addUIClickListener('menubarItemSelectAfterCursor', _ -> doSomething());
 
+    addUIClickListener('menubarItemPlaytestFull', _ -> testSongInPlayState(false));
+    addUIClickListener('menubarItemPlaytestMinimal', _ -> testSongInPlayState(true));
+
     addUIClickListener('menubarItemAbout', _ -> ChartEditorDialogHandler.openAboutDialog(this));
 
     addUIClickListener('menubarItemUserGuide', _ -> ChartEditorDialogHandler.openUserGuideDialog(this));
@@ -1423,6 +1450,7 @@ class ChartEditorState extends HaxeUIState
     handleFileKeybinds();
     handleEditKeybinds();
     handleViewKeybinds();
+    handleTestKeybinds();
     handleHelpKeybinds();
 
     // DEBUG
@@ -2536,6 +2564,18 @@ class ChartEditorState extends HaxeUIState
    */
   function handleViewKeybinds():Void {}
 
+  /**
+   * Handle keybinds for the Test menu items.
+   */
+  function handleTestKeybinds():Void
+  {
+    if (FlxG.keys.justPressed.ENTER)
+    {
+      var minimal = FlxG.keys.pressed.SHIFT;
+      testSongInPlayState(minimal);
+    }
+  }
+
   /**
    * Handle keybinds for Help menu items.
    */
@@ -2907,9 +2947,9 @@ class ChartEditorState extends HaxeUIState
 
   function startAudioPlayback():Void
   {
-    if (audioInstTrack != null) audioInstTrack.play();
-    if (audioVocalTrackGroup != null) audioVocalTrackGroup.play();
-    if (audioVocalTrackGroup != null) audioVocalTrackGroup.play();
+    if (audioInstTrack != null) audioInstTrack.play(false, audioInstTrack.time);
+    if (audioVocalTrackGroup != null) audioVocalTrackGroup.play(false, audioInstTrack.time);
+    if (audioVocalTrackGroup != null) audioVocalTrackGroup.play(false, audioInstTrack.time);
 
     setComponentText('playbarPlay', '||');
   }
@@ -3004,6 +3044,34 @@ class ChartEditorState extends HaxeUIState
     return this.scrollPositionInPixels;
   }
 
+  /**
+   * Transitions to the Play State to test the song
+   */
+  public function testSongInPlayState(?minimal:Bool = false):Void
+  {
+    var targetSong:Song = Song.buildRaw(currentSongId, songMetadata.values(), availableVariations, songChartData, false);
+
+    subStateClosed.add(fixCamera);
+
+    openSubState(new PlayState(
+      {
+        targetSong: targetSong,
+        targetDifficulty: selectedDifficulty,
+        // TODO: Add this.
+        // targetCharacter: targetCharacter,
+        practiceMode: true,
+        minimalMode: minimal,
+      }));
+  }
+
+  function fixCamera(_:FlxSubState = null):Void
+  {
+    FlxG.cameras.reset(new FlxCamera());
+    FlxG.camera.focusOn(new FlxPoint(FlxG.width / 2, FlxG.height / 2));
+
+    add(this.component);
+  }
+
   /**
    * Loads an instrumental from an absolute file path, replacing the current instrumental.
    *
diff --git a/source/funkin/ui/story/StoryMenuState.hx b/source/funkin/ui/story/StoryMenuState.hx
index 12b0f58c5..29bc4beca 100644
--- a/source/funkin/ui/story/StoryMenuState.hx
+++ b/source/funkin/ui/story/StoryMenuState.hx
@@ -513,6 +513,13 @@ class StoryMenuState extends MusicBeatState
     PlayStatePlaylist.campaignId = currentLevel.id;
     PlayStatePlaylist.campaignTitle = currentLevel.getTitle();
 
+    if (targetSong != null)
+    {
+      // Load and cache the song's charts.
+      // TODO: Do this in the loading state.
+      targetSong.cacheCharts(true);
+    }
+
     new FlxTimer().start(1, function(tmr:FlxTimer) {
       LoadingState.loadAndSwitchState(new PlayState(
         {
diff --git a/source/funkin/util/tools/ArrayTools.hx b/source/funkin/util/tools/ArrayTools.hx
index c27f1bf43..67cc1c041 100644
--- a/source/funkin/util/tools/ArrayTools.hx
+++ b/source/funkin/util/tools/ArrayTools.hx
@@ -37,4 +37,15 @@ class ArrayTools
     }
     return null;
   }
+
+  /**
+   * Remove all elements from the array, without creating a new array.
+   * @param array The array to clear.
+   */
+  public static function clear<T>(array:Array<T>):Void
+  {
+    // This method is faster than array.splice(0, array.length)
+    while (array.length > 0)
+      array.pop();
+  }
 }

From 2048e65bf28a01a2a582f0c5a1ae4e7749eb1c62 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Wed, 26 Jul 2023 20:03:31 -0400
Subject: [PATCH 02/29] Added ability to start song at a specific timestamp

---
 source/funkin/LatencyState.hx                 |  2 +-
 source/funkin/MusicBeatSubState.hx            |  9 ++
 source/funkin/TitleState.hx                   |  2 +-
 source/funkin/play/Countdown.hx               | 10 ++-
 source/funkin/play/GameOverSubState.hx        |  4 +-
 source/funkin/play/PlayState.hx               | 88 +++++++++++--------
 source/funkin/play/notes/Strumline.hx         |  2 +
 .../ui/debug/charting/ChartEditorState.hx     | 12 +++
 8 files changed, 88 insertions(+), 41 deletions(-)

diff --git a/source/funkin/LatencyState.hx b/source/funkin/LatencyState.hx
index 4a8ed2d2e..7385ca640 100644
--- a/source/funkin/LatencyState.hx
+++ b/source/funkin/LatencyState.hx
@@ -191,7 +191,7 @@ class LatencyState extends MusicBeatSubState
 
     if (FlxG.keys.pressed.D) FlxG.sound.music.time += 1000 * FlxG.elapsed;
 
-    Conductor.songPosition = swagSong.getTimeWithDiff() - Conductor.offset;
+    Conductor.update(swagSong.getTimeWithDiff() - Conductor.offset);
     // Conductor.songPosition += (Timer.stamp() * 1000) - FlxG.sound.music.prevTimestamp;
 
     songPosVis.x = songPosToX(Conductor.songPosition);
diff --git a/source/funkin/MusicBeatSubState.hx b/source/funkin/MusicBeatSubState.hx
index 1958c6074..31d1bd14c 100644
--- a/source/funkin/MusicBeatSubState.hx
+++ b/source/funkin/MusicBeatSubState.hx
@@ -61,6 +61,15 @@ class MusicBeatSubState extends FlxTransitionableSubState implements IEventHandl
 
     // This can now be used in EVERY STATE YAY!
     if (FlxG.keys.justPressed.F5) debug_refreshModules();
+
+    // Display Conductor info in the watch window.
+    FlxG.watch.addQuick("songPosition", Conductor.songPosition);
+    FlxG.watch.addQuick("bpm", Conductor.bpm);
+    FlxG.watch.addQuick("currentMeasureTime", Conductor.currentBeatTime);
+    FlxG.watch.addQuick("currentBeatTime", Conductor.currentBeatTime);
+    FlxG.watch.addQuick("currentStepTime", Conductor.currentStepTime);
+
+    dispatchEvent(new UpdateScriptEvent(elapsed));
   }
 
   function debug_refreshModules()
diff --git a/source/funkin/TitleState.hx b/source/funkin/TitleState.hx
index 8ba5121fa..47cc33a38 100644
--- a/source/funkin/TitleState.hx
+++ b/source/funkin/TitleState.hx
@@ -256,7 +256,7 @@ class TitleState extends MusicBeatState
       FlxTween.tween(FlxG.stage.window, {y: FlxG.stage.window.y + 100}, 0.7, {ease: FlxEase.quadInOut, type: PINGPONG});
     }
 
-    if (FlxG.sound.music != null) Conductor.songPosition = FlxG.sound.music.time;
+    if (FlxG.sound.music != null) Conductor.update(FlxG.sound.music.time);
     if (FlxG.keys.justPressed.F) FlxG.fullscreen = !FlxG.fullscreen;
 
     // do controls.PAUSE | controls.ACCEPT instead?
diff --git a/source/funkin/play/Countdown.hx b/source/funkin/play/Countdown.hx
index 51d72693e..48e98e84f 100644
--- a/source/funkin/play/Countdown.hx
+++ b/source/funkin/play/Countdown.hx
@@ -37,7 +37,7 @@ class Countdown
     stopCountdown();
 
     PlayState.instance.isInCountdown = true;
-    Conductor.songPosition = Conductor.beatLengthMs * -5;
+    Conductor.update(PlayState.instance.startTimestamp + Conductor.beatLengthMs * -5);
     // Handle onBeatHit events manually
     @:privateAccess
     PlayState.instance.dispatchEvent(new SongTimeScriptEvent(ScriptEvent.SONG_BEAT_HIT, 0, 0));
@@ -46,6 +46,12 @@ class Countdown
     countdownTimer = new FlxTimer();
 
     countdownTimer.start(Conductor.beatLengthMs / 1000, function(tmr:FlxTimer) {
+      if (PlayState.instance == null)
+      {
+        tmr.cancel();
+        return;
+      }
+
       countdownStep = decrement(countdownStep);
 
       // Handle onBeatHit events manually
@@ -146,7 +152,7 @@ class Countdown
   {
     stopCountdown();
     // This will trigger PlayState.startSong()
-    Conductor.songPosition = 0;
+    Conductor.update(0);
     // PlayState.isInCountdown = false;
   }
 
diff --git a/source/funkin/play/GameOverSubState.hx b/source/funkin/play/GameOverSubState.hx
index f38dabea4..161da5191 100644
--- a/source/funkin/play/GameOverSubState.hx
+++ b/source/funkin/play/GameOverSubState.hx
@@ -117,7 +117,7 @@ class GameOverSubState extends MusicBeatSubState
     gameOverMusic.stop();
 
     // The conductor now represents the BPM of the game over music.
-    Conductor.songPosition = 0;
+    Conductor.update(0);
   }
 
   var hasStartedAnimation:Bool = false;
@@ -183,7 +183,7 @@ class GameOverSubState extends MusicBeatSubState
     {
       // Match the conductor to the music.
       // This enables the stepHit and beatHit events.
-      Conductor.songPosition = gameOverMusic.time;
+      Conductor.update(gameOverMusic.time);
     }
     else
     {
diff --git a/source/funkin/play/PlayState.hx b/source/funkin/play/PlayState.hx
index cf12db06b..ae57f3cd5 100644
--- a/source/funkin/play/PlayState.hx
+++ b/source/funkin/play/PlayState.hx
@@ -88,6 +88,10 @@ typedef PlayStateParams =
    * @default `false`
    */
   ?minimalMode:Bool,
+  /**
+   * If specified, the game will jump to the specified timestamp after the countdown ends.
+   */
+  ?startTimestamp:Float,
 }
 
 /**
@@ -236,6 +240,8 @@ class PlayState extends MusicBeatSubState
    */
   public var disableKeys:Bool = false;
 
+  public var startTimestamp:Float = 0.0;
+
   public var isSubState(get, null):Bool;
 
   function get_isSubState():Bool
@@ -471,6 +477,7 @@ class PlayState extends MusicBeatSubState
     if (params.targetCharacter != null) currentPlayerId = params.targetCharacter;
     isPracticeMode = params.practiceMode ?? false;
     isMinimalMode = params.minimalMode ?? false;
+    startTimestamp = params.startTimestamp ?? 0.0;
 
     // Don't do anything else here! Wait until create() when we attach to the camera.
   }
@@ -560,7 +567,7 @@ class PlayState extends MusicBeatSubState
 
     // Prepare the Conductor.
     Conductor.mapTimeChanges(currentChart.timeChanges);
-    Conductor.update(-5000);
+    Conductor.update((Conductor.beatLengthMs * -5) + startTimestamp);
 
     // The song is now loaded. We can continue to initialize the play state.
     initCameras();
@@ -669,7 +676,7 @@ class PlayState extends MusicBeatSubState
 
       FlxG.sound.music.pause();
       vocals.pause();
-      FlxG.sound.music.time = 0;
+      FlxG.sound.music.time = (startTimestamp);
       vocals.time = 0;
 
       FlxG.sound.music.volume = 1;
@@ -700,8 +707,8 @@ class PlayState extends MusicBeatSubState
     {
       if (isInCountdown)
       {
-        Conductor.songPosition += elapsed * 1000;
-        if (Conductor.songPosition >= 0) startSong();
+        Conductor.update(Conductor.songPosition + elapsed * 1000);
+        if (Conductor.songPosition >= startTimestamp) startSong();
       }
     }
     else
@@ -1067,7 +1074,8 @@ class PlayState extends MusicBeatSubState
     // super.stepHit() returns false if a module cancelled the event.
     if (!super.stepHit()) return false;
 
-    if (FlxG.sound.music != null
+    if (!startingSong
+      && FlxG.sound.music != null
       && (Math.abs(FlxG.sound.music.time - (Conductor.songPosition - Conductor.offset)) > 200
         || Math.abs(vocals.checkSyncError(Conductor.songPosition - Conductor.offset)) > 200))
     {
@@ -1515,7 +1523,7 @@ class PlayState extends MusicBeatSubState
   /**
    * Read note data from the chart and generate the notes.
    */
-  function regenNoteData():Void
+  function regenNoteData(startTime:Float = 0):Void
   {
     Highscore.tallies.combo = 0;
     Highscore.tallies = new Tallies();
@@ -1531,6 +1539,8 @@ class PlayState extends MusicBeatSubState
     for (songNote in currentChart.notes)
     {
       var strumTime:Float = songNote.time;
+      if (strumTime < startTime) continue; // Skip notes that are before the start time.
+
       var noteData:Int = songNote.getDirection();
 
       var playerNote:Bool = true;
@@ -1617,14 +1627,22 @@ class PlayState extends MusicBeatSubState
     }
 
     FlxG.sound.music.onComplete = endSong;
+    FlxG.sound.music.play(false, startTimestamp);
     trace('Playing vocals...');
     add(vocals);
     vocals.play();
+    resyncVocals();
 
     #if discord_rpc
     // Updating Discord Rich Presence (with Time Left)
     DiscordClient.changePresence(detailsText, '${currentChart.songName} ($storyDifficultyText)', iconRPC, true, currentSongLengthMs);
     #end
+
+    if (startTimestamp > 0)
+    {
+      FlxG.sound.music.time = startTimestamp;
+      handleSkippedNotes();
+    }
   }
 
   /**
@@ -1640,7 +1658,7 @@ class PlayState extends MusicBeatSubState
     Conductor.update();
 
     vocals.time = FlxG.sound.music.time;
-    vocals.play();
+    vocals.play(false, FlxG.sound.music.time);
   }
 
   /**
@@ -1836,6 +1854,23 @@ class PlayState extends MusicBeatSubState
    */
   var inputSpitter:Array<ScoreInput> = [];
 
+  function handleSkippedNotes():Void
+  {
+    for (note in playerStrumline.notes.members)
+    {
+      var hitWindowStart = note.strumTime - Constants.HIT_WINDOW_MS;
+      var hitWindowCenter = note.strumTime;
+      var hitWindowEnd = note.strumTime + Constants.HIT_WINDOW_MS;
+
+      if (Conductor.songPosition > hitWindowEnd)
+      {
+        // We have passed this note.
+        // Flag the note for deletion without actually penalizing the player.
+        note.handledMiss = true;
+      }
+    }
+  }
+
   /**
    * PreciseInputEvents are put into a queue between update() calls,
    * and then processed here.
@@ -2064,11 +2099,10 @@ class PlayState extends MusicBeatSubState
     if (event.eventCanceled) return;
 
     health -= Constants.HEALTH_MISS_PENALTY;
+    songScore -= 10;
 
     if (!isPracticeMode)
     {
-      songScore -= 10;
-
       // messy copy paste rn lol
       var pressArray:Array<Bool> = [
         controls.NOTE_LEFT_P,
@@ -2139,11 +2173,10 @@ class PlayState extends MusicBeatSubState
     if (event.eventCanceled) return;
 
     health += event.healthChange;
+    songScore += event.scoreChange;
 
     if (!isPracticeMode)
     {
-      songScore += event.scoreChange;
-
       var pressArray:Array<Bool> = [
         controls.NOTE_LEFT_P,
         controls.NOTE_DOWN_P,
@@ -2282,11 +2315,10 @@ class PlayState extends MusicBeatSubState
       playerStrumline.playNoteSplash(daNote.noteData.getDirection());
     }
 
-    // Only add the score if you're not on practice mode
+    songScore += score;
+
     if (!isPracticeMode)
     {
-      songScore += score;
-
       // TODO: Input splitter uses old input system, make it pull from the precise input queue directly.
       var pressArray:Array<Bool> = [
         controls.NOTE_LEFT_P,
@@ -2643,30 +2675,16 @@ class PlayState extends MusicBeatSubState
   {
     FlxG.sound.music.pause();
 
-    FlxG.sound.music.time += sections * Conductor.measureLengthMs;
+    var targetTimeSteps:Float = Conductor.currentStepTime + (Conductor.timeSignatureNumerator * Constants.STEPS_PER_BEAT * sections);
+    var targetTimeMs:Float = Conductor.getStepTimeInMs(targetTimeSteps);
+
+    FlxG.sound.music.time = targetTimeMs;
+
+    handleSkippedNotes();
+    // regenNoteData(FlxG.sound.music.time);
 
     Conductor.update(FlxG.sound.music.time);
 
-    /**
-      *
-      // TODO: Redo this for the new conductor.
-      var daBPM:Float = Conductor.bpm;
-      var daPos:Float = 0;
-      for (i in 0...(Std.int(Conductor.currentStep / 16 + sec)))
-      {
-        var section = .getSong()[i];
-        if (section == null) continue;
-        if (section.changeBPM)
-        {
-          daBPM = .getSong()[i].bpm;
-        }
-        daPos += 4 * (1000 * 60 / daBPM);
-      }
-      Conductor.songPosition = FlxG.sound.music.time = daPos;
-      Conductor.songPosition += Conductor.offset;
-
-     */
-
     resyncVocals();
   }
   #end
diff --git a/source/funkin/play/notes/Strumline.hx b/source/funkin/play/notes/Strumline.hx
index 454ec13e1..40bd8656a 100644
--- a/source/funkin/play/notes/Strumline.hx
+++ b/source/funkin/play/notes/Strumline.hx
@@ -220,6 +220,7 @@ class Strumline extends FlxSpriteGroup
   {
     if (noteData.length == 0) return;
 
+    var songStart:Float = PlayState.instance.startTimestamp ?? 0.0;
     var renderWindowStart:Float = Conductor.songPosition + RENDER_DISTANCE_MS;
 
     for (noteIndex in nextNoteIndex...noteData.length)
@@ -227,6 +228,7 @@ class Strumline extends FlxSpriteGroup
       var note:Null<SongNoteData> = noteData[noteIndex];
 
       if (note == null) continue;
+      if (note.time < songStart) continue;
       if (note.time > renderWindowStart) break;
 
       var noteSprite = buildNoteSprite(note);
diff --git a/source/funkin/ui/debug/charting/ChartEditorState.hx b/source/funkin/ui/debug/charting/ChartEditorState.hx
index a3fcd0f22..8cf496637 100644
--- a/source/funkin/ui/debug/charting/ChartEditorState.hx
+++ b/source/funkin/ui/debug/charting/ChartEditorState.hx
@@ -400,6 +400,11 @@ class ChartEditorState extends HaxeUIState
     return isViewDownscroll;
   }
 
+  /**
+   * If true, playtesting a chart will skip to the current playhead position.
+   */
+  var playtestStartTime:Bool = false;
+
   /**
    * Whether hitsounds are enabled for at least one character.
    */
@@ -1305,6 +1310,9 @@ class ChartEditorState extends HaxeUIState
     addUIChangeListener('menubarItemDownscroll', event -> isViewDownscroll = event.value);
     setUICheckboxSelected('menubarItemDownscroll', isViewDownscroll);
 
+    addUIChangeListener('menubarItemPlaytestStartTime', event -> playtestStartTime = event.value);
+    setUICheckboxSelected('menubarItemPlaytestStartTime', playtestStartTime);
+
     addUIChangeListener('menuBarItemThemeLight', function(event:UIEvent) {
       if (event.target.value) currentTheme = ChartEditorTheme.Light;
     });
@@ -3049,6 +3057,9 @@ class ChartEditorState extends HaxeUIState
    */
   public function testSongInPlayState(?minimal:Bool = false):Void
   {
+    var startTimestamp:Float = 0;
+    if (playtestStartTime) startTimestamp = scrollPositionInMs + playheadPositionInMs;
+
     var targetSong:Song = Song.buildRaw(currentSongId, songMetadata.values(), availableVariations, songChartData, false);
 
     subStateClosed.add(fixCamera);
@@ -3061,6 +3072,7 @@ class ChartEditorState extends HaxeUIState
         // targetCharacter: targetCharacter,
         practiceMode: true,
         minimalMode: minimal,
+        startTimestamp: startTimestamp,
       }));
   }
 

From b974e6d6d7ef8aa846b96db611f783680f9f7b3a Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Wed, 26 Jul 2023 21:34:38 -0400
Subject: [PATCH 03/29] Fix issues with playtest and backwards time travel

---
 source/funkin/play/PlayState.hx               | 34 ++++++++++++++-----
 source/funkin/play/notes/Strumline.hx         | 30 +++++++++++++---
 .../ui/debug/charting/ChartEditorState.hx     |  8 +++++
 3 files changed, 59 insertions(+), 13 deletions(-)

diff --git a/source/funkin/play/PlayState.hx b/source/funkin/play/PlayState.hx
index 78c953e99..479bc7424 100644
--- a/source/funkin/play/PlayState.hx
+++ b/source/funkin/play/PlayState.hx
@@ -684,7 +684,7 @@ class PlayState extends MusicBeatSubState
       vocals.playerVolume = 1;
       vocals.opponentVolume = 1;
 
-      currentStage.resetStage();
+      if (currentStage != null) currentStage.resetStage();
 
       playerStrumline.clean();
       opponentStrumline.clean();
@@ -871,6 +871,13 @@ 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.songPosition < -5000)
+          {
+            event.activated = true;
+            continue;
+          };
+
           var eventEvent:SongEventScriptEvent = new SongEventScriptEvent(event);
           dispatchEvent(eventEvent);
           // Calling event.cancelEvent() skips the event. Neat!
@@ -1828,6 +1835,7 @@ class PlayState extends MusicBeatSubState
 
         // Judge the miss.
         // NOTE: This is what handles the scoring.
+        trace('Missed note! ${note.noteData}');
         onNoteMiss(note);
 
         note.handledMiss = true;
@@ -1861,8 +1869,7 @@ class PlayState extends MusicBeatSubState
   {
     for (note in playerStrumline.notes.members)
     {
-      var hitWindowStart = note.strumTime - Constants.HIT_WINDOW_MS;
-      var hitWindowCenter = note.strumTime;
+      if (note == null || note.hasBeenHit) continue;
       var hitWindowEnd = note.strumTime + Constants.HIT_WINDOW_MS;
 
       if (Conductor.songPosition > hitWindowEnd)
@@ -1872,6 +1879,9 @@ class PlayState extends MusicBeatSubState
         note.handledMiss = true;
       }
     }
+
+    playerStrumline.handleSkippedNotes();
+    opponentStrumline.handleSkippedNotes();
   }
 
   /**
@@ -1935,6 +1945,7 @@ class PlayState extends MusicBeatSubState
         if (targetNote == null) continue;
 
         // Judge and hit the note.
+        trace('Hit note! ${targetNote.noteData}');
         goodNoteHit(targetNote, input);
 
         targetNote.visible = false;
@@ -2262,12 +2273,12 @@ class PlayState extends MusicBeatSubState
     if (FlxG.keys.justPressed.NINE) iconP1.toggleOldIcon();
 
     #if debug
-    // PAGEUP: Skip forward one section.
-    // SHIFT+PAGEUP: Skip forward ten sections.
-    if (FlxG.keys.justPressed.PAGEUP) changeSection(FlxG.keys.pressed.SHIFT ? 10 : 1);
-    // PAGEDOWN: Skip backward one section. Doesn't replace notes.
-    // SHIFT+PAGEDOWN: Skip backward ten sections.
-    if (FlxG.keys.justPressed.PAGEDOWN) changeSection(FlxG.keys.pressed.SHIFT ? -10 : -1);
+    // PAGEUP: Skip forward two sections.
+    // SHIFT+PAGEUP: Skip forward twenty sections.
+    if (FlxG.keys.justPressed.PAGEUP) changeSection(FlxG.keys.pressed.SHIFT ? 20 : 2);
+    // PAGEDOWN: Skip backward two section. Doesn't replace notes.
+    // SHIFT+PAGEDOWN: Skip backward twenty sections.
+    if (FlxG.keys.justPressed.PAGEDOWN) changeSection(FlxG.keys.pressed.SHIFT ? -20 : -2);
     #end
 
     if (FlxG.keys.justPressed.B) trace(inputSpitter.join('\n'));
@@ -2550,6 +2561,7 @@ class PlayState extends MusicBeatSubState
 
   public override function close():Void
   {
+    criticalFailure = true; // Stop game updates.
     performCleanup();
     super.close();
   }
@@ -2564,6 +2576,10 @@ class PlayState extends MusicBeatSubState
       // TODO: Uncache the song.
     }
 
+    // Stop the music.
+    FlxG.sound.music.pause();
+    vocals.stop();
+
     // Remove reference to stage and remove sprites from it to save memory.
     if (currentStage != null)
     {
diff --git a/source/funkin/play/notes/Strumline.hx b/source/funkin/play/notes/Strumline.hx
index 0407d8ffc..15069f9b7 100644
--- a/source/funkin/play/notes/Strumline.hx
+++ b/source/funkin/play/notes/Strumline.hx
@@ -55,7 +55,12 @@ class Strumline extends FlxSpriteGroup
 
   final noteStyle:NoteStyle;
 
+  /**
+   * The note data for the song. Should NOT be altered after the song starts,
+   * so we can easily rewind.
+   */
   var noteData:Array<SongNoteData> = [];
+
   var nextNoteIndex:Int = -1;
 
   var heldKeys:Array<Bool> = [];
@@ -221,15 +226,21 @@ class Strumline extends FlxSpriteGroup
     if (noteData.length == 0) return;
 
     var songStart:Float = PlayState.instance.startTimestamp ?? 0.0;
+    var hitWindowStart:Float = Conductor.songPosition - Constants.HIT_WINDOW_MS;
     var renderWindowStart:Float = Conductor.songPosition + RENDER_DISTANCE_MS;
 
     for (noteIndex in nextNoteIndex...noteData.length)
     {
       var note:Null<SongNoteData> = noteData[noteIndex];
 
-      if (note == null) continue;
-      if (note.time < songStart) continue;
-      if (note.time > renderWindowStart) break;
+      if (note == null) continue; // Note is blank
+      if (note.time < songStart || note.time < hitWindowStart)
+      {
+        // Note is in the past, skip it.
+        nextNoteIndex = noteIndex + 1;
+        continue;
+      }
+      if (note.time > renderWindowStart) break; // Note is too far ahead to render
 
       var noteSprite = buildNoteSprite(note);
 
@@ -238,7 +249,7 @@ class Strumline extends FlxSpriteGroup
         noteSprite.holdNoteSprite = buildHoldNoteSprite(note);
       }
 
-      nextNoteIndex++; // Increment the nextNoteIndex rather than splicing the array, because splicing is slow.
+      nextNoteIndex = noteIndex + 1; // Increment the nextNoteIndex rather than splicing the array, because splicing is slow.
     }
 
     // Update rendering of notes.
@@ -376,6 +387,17 @@ class Strumline extends FlxSpriteGroup
     }
   }
 
+  /**
+   * Called when the PlayState skips a large amount of time forward or backward.
+   */
+  public function handleSkippedNotes():Void
+  {
+    // By calling clean(), we remove all existing notes so they can be re-added.
+    clean();
+    // By setting noteIndex to 0, the next update will skip past all the notes that are in the past.
+    nextNoteIndex = 0;
+  }
+
   public function onBeatHit():Void
   {
     if (notes.members.length > 1) notes.members.insertionSort(compareNoteSprites.bind(FlxSort.ASCENDING));
diff --git a/source/funkin/ui/debug/charting/ChartEditorState.hx b/source/funkin/ui/debug/charting/ChartEditorState.hx
index 8cf496637..c57835ca7 100644
--- a/source/funkin/ui/debug/charting/ChartEditorState.hx
+++ b/source/funkin/ui/debug/charting/ChartEditorState.hx
@@ -3063,6 +3063,7 @@ class ChartEditorState extends HaxeUIState
     var targetSong:Song = Song.buildRaw(currentSongId, songMetadata.values(), availableVariations, songChartData, false);
 
     subStateClosed.add(fixCamera);
+    subStateClosed.add(updateConductor);
 
     openSubState(new PlayState(
       {
@@ -3080,10 +3081,17 @@ class ChartEditorState extends HaxeUIState
   {
     FlxG.cameras.reset(new FlxCamera());
     FlxG.camera.focusOn(new FlxPoint(FlxG.width / 2, FlxG.height / 2));
+    FlxG.camera.zoom = 1.0;
 
     add(this.component);
   }
 
+  function updateConductor(_:FlxSubState = null):Void
+  {
+    var targetPos = scrollPositionInMs;
+    Conductor.update(targetPos);
+  }
+
   /**
    * Loads an instrumental from an absolute file path, replacing the current instrumental.
    *

From 26112aa6ed1326ea8e7a1013427e517f977839b5 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Wed, 26 Jul 2023 21:55:47 -0400
Subject: [PATCH 04/29] Update HaxeUI

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

diff --git a/hmm.json b/hmm.json
index be6b7fea0..a5e7e0f05 100644
--- a/hmm.json
+++ b/hmm.json
@@ -42,14 +42,14 @@
       "name": "haxeui-core",
       "type": "git",
       "dir": null,
-      "ref": "1ac2f76",
+      "ref": "6136bf6",
       "url": "https://github.com/haxeui/haxeui-core/"
     },
     {
       "name": "haxeui-flixel",
       "type": "git",
       "dir": null,
-      "ref": "999fadd",
+      "ref": "be0b185",
       "url": "https://github.com/haxeui/haxeui-flixel"
     },
     {

From 0136202c36e728b9fc2bd3439aa448f9769bacfd Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Wed, 2 Aug 2023 10:15:54 -0400
Subject: [PATCH 05/29] Janky fix

---
 .../ui/debug/charting/ChartEditorState.hx     | 23 +++++++++++++++++++
 1 file changed, 23 insertions(+)

diff --git a/source/funkin/ui/debug/charting/ChartEditorState.hx b/source/funkin/ui/debug/charting/ChartEditorState.hx
index 5aaf3f787..ddd50073a 100644
--- a/source/funkin/ui/debug/charting/ChartEditorState.hx
+++ b/source/funkin/ui/debug/charting/ChartEditorState.hx
@@ -3310,6 +3310,29 @@ class ChartEditorState extends HaxeUIState
 
     var targetSong:Song = Song.buildRaw(currentSongId, songMetadata.values(), availableVariations, songChartData, false);
 
+    // TODO: Rework asset system so we can remove this.
+    switch (currentSongId)
+    {
+      case 'tutorial':
+        Paths.setCurrentLevel('tutorial');
+      case 'bopeebo' | 'fresh' | 'dadbattle':
+        Paths.setCurrentLevel('week1');
+      case 'spookeez' | 'south' | 'monster':
+        Paths.setCurrentLevel('week2');
+      case 'pico' | 'blammed' | 'philly-nice':
+        Paths.setCurrentLevel('week3');
+      case 'satin-panties' | 'high' | 'milf':
+        Paths.setCurrentLevel('week4');
+      case 'cocoa' | 'eggnog' | 'winter-horrorland':
+        Paths.setCurrentLevel('week5');
+      case 'senpai' | 'roses' | 'thorns':
+        Paths.setCurrentLevel('week6');
+      case 'ugh' | 'guns' | 'stress':
+        Paths.setCurrentLevel('week7');
+      case 'darnell' | 'lit-up' | '2hot' | 'blazin':
+        Paths.setCurrentLevel('weekend1');
+    }
+
     subStateClosed.add(fixCamera);
     subStateClosed.add(updateConductor);
 

From b54c335886823e9e3c08ecdfab10452b5e9f1ede Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Wed, 2 Aug 2023 11:00:23 -0400
Subject: [PATCH 06/29] Fix for Menu->Freeplay and Play->Pause transitions

---
 source/funkin/MainMenuState.hx  | 4 ++++
 source/funkin/play/PlayState.hx | 3 +++
 2 files changed, 7 insertions(+)

diff --git a/source/funkin/MainMenuState.hx b/source/funkin/MainMenuState.hx
index bca20980c..fc493ef4b 100644
--- a/source/funkin/MainMenuState.hx
+++ b/source/funkin/MainMenuState.hx
@@ -1,5 +1,6 @@
 package funkin;
 
+import flixel.addons.transition.FlxTransitionableSubState;
 import funkin.ui.debug.DebugMenuSubState;
 import flixel.FlxObject;
 import flixel.FlxSprite;
@@ -102,6 +103,9 @@ class MainMenuState extends MusicBeatState
     createMenuItem('freeplay', 'mainmenu/freeplay', function() {
       persistentDraw = true;
       persistentUpdate = false;
+      // Freeplay has its own custom transition
+      FlxTransitionableSubState.skipNextTransIn = true;
+      FlxTransitionableSubState.skipNextTransOut = true;
       openSubState(new FreeplayState());
     });
 
diff --git a/source/funkin/play/PlayState.hx b/source/funkin/play/PlayState.hx
index 1674eec25..4d4cf3d40 100644
--- a/source/funkin/play/PlayState.hx
+++ b/source/funkin/play/PlayState.hx
@@ -1,5 +1,6 @@
 package funkin.play;
 
+import flixel.addons.transition.FlxTransitionableSubState;
 import funkin.ui.debug.charting.ChartEditorState;
 import haxe.Int64;
 import funkin.play.notes.notestyle.NoteStyle;
@@ -780,6 +781,8 @@ class PlayState extends MusicBeatSubState
 
           var pauseSubState:FlxSubState = new PauseSubState(isChartingMode);
 
+          FlxTransitionableSubState.skipNextTransIn = true;
+          FlxTransitionableSubState.skipNextTransOut = true;
           openSubState(pauseSubState);
           pauseSubState.camera = camHUD;
           // boyfriendPos.put(); // TODO: Why is this here?

From 3a31c9731c048155f5e77301737fb327f47b6a35 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Wed, 2 Aug 2023 18:08:49 -0400
Subject: [PATCH 07/29] Fixed several bugs with Play State (mostly restarting
 the song)

---
 source/funkin/GitarooPause.hx                 |  3 ++
 source/funkin/play/GameOverSubState.hx        |  7 ++---
 source/funkin/play/PlayState.hx               | 27 ++++++++++++++++--
 source/funkin/play/character/BaseCharacter.hx | 23 ++++-----------
 source/funkin/play/stage/Bopper.hx            |  7 +++--
 source/funkin/play/stage/Stage.hx             | 28 ++++++++++++++++---
 .../ui/debug/charting/ChartEditorState.hx     |  4 +++
 source/funkin/ui/story/StoryMenuState.hx      |  4 +++
 8 files changed, 74 insertions(+), 29 deletions(-)

diff --git a/source/funkin/GitarooPause.hx b/source/funkin/GitarooPause.hx
index 5747de5e5..a4dc766be 100644
--- a/source/funkin/GitarooPause.hx
+++ b/source/funkin/GitarooPause.hx
@@ -3,6 +3,7 @@ package funkin;
 import flixel.FlxSprite;
 import flixel.graphics.frames.FlxAtlasFrames;
 import funkin.play.PlayState;
+import flixel.addons.transition.FlxTransitionableState;
 
 class GitarooPause extends MusicBeatState
 {
@@ -61,6 +62,8 @@ class GitarooPause extends MusicBeatState
     {
       if (replaySelect)
       {
+        FlxTransitionableState.skipNextTransIn = false;
+        FlxTransitionableState.skipNextTransOut = false;
         FlxG.switchState(new PlayState(previousParams));
       }
       else
diff --git a/source/funkin/play/GameOverSubState.hx b/source/funkin/play/GameOverSubState.hx
index 161da5191..6483ea9e5 100644
--- a/source/funkin/play/GameOverSubState.hx
+++ b/source/funkin/play/GameOverSubState.hx
@@ -124,8 +124,6 @@ class GameOverSubState extends MusicBeatSubState
 
   override function update(elapsed:Float)
   {
-    super.update(elapsed);
-
     if (!hasStartedAnimation)
     {
       hasStartedAnimation = true;
@@ -205,12 +203,13 @@ class GameOverSubState extends MusicBeatSubState
           if (boyfriend.getCurrentAnimation().startsWith('firstDeath') && boyfriend.isAnimationFinished())
           {
             startDeathMusic(1.0, false);
+            boyfriend.playAnimation('deathLoop' + animationSuffix);
           }
       }
     }
 
-    // Dispatch the onUpdate event.
-    dispatchEvent(new UpdateScriptEvent(elapsed));
+    // Start death music before firstDeath gets replaced
+    super.update(elapsed);
   }
 
   /**
diff --git a/source/funkin/play/PlayState.hx b/source/funkin/play/PlayState.hx
index 4d4cf3d40..5e8f964d1 100644
--- a/source/funkin/play/PlayState.hx
+++ b/source/funkin/play/PlayState.hx
@@ -488,8 +488,6 @@ class PlayState extends MusicBeatSubState
    */
   public override function create():Void
   {
-    super.create();
-
     if (instance != null)
     {
       // TODO: Do something in this case? IDK.
@@ -630,6 +628,9 @@ class PlayState extends MusicBeatSubState
       startCountdown();
     }
 
+    // Do this last to prevent beatHit from being called before create() is done.
+    super.create();
+
     leftWatermarkText.cameras = [camHUD];
     rightWatermarkText.cameras = [camHUD];
 
@@ -810,6 +811,16 @@ class PlayState extends MusicBeatSubState
       FlxG.watch.addQuick('bfAnim', currentStage.getBoyfriend().getCurrentAnimation());
     }
 
+    if (currentStage.getBoyfriend() != null)
+    {
+      FlxG.watch.addQuick('bfCameraFocus', currentStage.getBoyfriend().cameraFocusPoint);
+    }
+
+    if (currentStage.getDad() != null)
+    {
+      FlxG.watch.addQuick('dadCameraFocus', currentStage.getDad().cameraFocusPoint);
+    }
+
     // TODO: Add a song event for Handle GF dance speed.
 
     // Handle player death.
@@ -857,6 +868,8 @@ class PlayState extends MusicBeatSubState
         #end
 
         var gameOverSubState = new GameOverSubState();
+        FlxTransitionableSubState.skipNextTransIn = true;
+        FlxTransitionableSubState.skipNextTransOut = true;
         openSubState(gameOverSubState);
 
         #if discord_rpc
@@ -971,6 +984,9 @@ class PlayState extends MusicBeatSubState
 
       if (event.eventCanceled) return;
 
+      // Resume
+      FlxG.sound.music.play();
+
       if (FlxG.sound.music != null && !startingSong && !isInCutscene) resyncVocals();
 
       // Resume the countdown.
@@ -1669,6 +1685,9 @@ class PlayState extends MusicBeatSubState
   {
     if (_exiting || vocals == null) return;
 
+    // Skip this if the music is paused (GameOver, Pause menu, etc.)
+    if (!FlxG.sound.music.playing) return;
+
     vocals.pause();
 
     FlxG.sound.music.play();
@@ -1700,6 +1719,8 @@ class PlayState extends MusicBeatSubState
    */
   function onKeyPress(event:PreciseInputEvent):Void
   {
+    if (isGamePaused) return;
+
     // Do the minimal possible work here.
     inputPressQueue.push(event);
   }
@@ -1709,6 +1730,8 @@ class PlayState extends MusicBeatSubState
    */
   function onKeyRelease(event:PreciseInputEvent):Void
   {
+    if (isGamePaused) return;
+
     // Do the minimal possible work here.
     inputReleaseQueue.push(event);
   }
diff --git a/source/funkin/play/character/BaseCharacter.hx b/source/funkin/play/character/BaseCharacter.hx
index bcb73d543..42dfd2da4 100644
--- a/source/funkin/play/character/BaseCharacter.hx
+++ b/source/funkin/play/character/BaseCharacter.hx
@@ -227,12 +227,15 @@ class BaseCharacter extends Bopper
   public function resetCharacter(resetCamera:Bool = true):Void
   {
     // Reset the animation offsets. This will modify x and y to be the absolute position of the character.
-    this.animOffsets = [0, 0];
+    // this.animOffsets = [0, 0];
 
     // Now we can set the x and y to be their original values without having to account for animOffsets.
     this.resetPosition();
 
-    // Make sure we are playing the idle animation (to reapply animOffsets)...
+    // Then reapply animOffsets...
+    // applyAnimationOffsets(getCurrentAnimation());
+
+    // Make sure we are playing the idle animation
     this.dance(true); // Force to avoid the old animation playing with the wrong offset at the start of the song.
     // ...then update the hitbox so that this.width and this.height are correct.
     this.updateHitbox();
@@ -344,7 +347,7 @@ class BaseCharacter extends Bopper
 
     if (isDead)
     {
-      playDeathAnimation();
+      // playDeathAnimation();
       return;
     }
 
@@ -392,20 +395,6 @@ class BaseCharacter extends Bopper
     FlxG.watch.addQuick('holdTimer-${characterId}', holdTimer);
   }
 
-  /**
-   * Since no `onBeatHit` or `dance` calls happen in GameOverSubState,
-   * this regularly gets called instead.
-   *
-   * @param force Force the deathLoop animation to play, even if `firstDeath` is still playing.
-   */
-  public function playDeathAnimation(force:Bool = false):Void
-  {
-    if (force || (getCurrentAnimation().startsWith('firstDeath') && isAnimationFinished()))
-    {
-      playAnimation('deathLoop' + GameOverSubState.animationSuffix);
-    }
-  }
-
   public function isSinging():Bool
   {
     return getCurrentAnimation().startsWith('sing');
diff --git a/source/funkin/play/stage/Bopper.hx b/source/funkin/play/stage/Bopper.hx
index 5fb1022fe..d88618f8a 100644
--- a/source/funkin/play/stage/Bopper.hx
+++ b/source/funkin/play/stage/Bopper.hx
@@ -158,8 +158,11 @@ class Bopper extends StageProp implements IPlayStateScriptedClass
    */
   public function resetPosition()
   {
-    this.x = originalPosition.x + animOffsets[0];
-    this.y = originalPosition.y + animOffsets[1];
+    var oldAnimOffsets = [animOffsets[0], animOffsets[1]];
+    animOffsets = [0, 0];
+    this.x = originalPosition.x;
+    this.y = originalPosition.y;
+    animOffsets = oldAnimOffsets;
   }
 
   function update_shouldAlternate():Void
diff --git a/source/funkin/play/stage/Stage.hx b/source/funkin/play/stage/Stage.hx
index ef2d28430..dafba1c06 100644
--- a/source/funkin/play/stage/Stage.hx
+++ b/source/funkin/play/stage/Stage.hx
@@ -78,6 +78,10 @@ class Stage extends FlxSpriteGroup implements IPlayStateScriptedClass
     if (getBoyfriend() != null)
     {
       getBoyfriend().resetCharacter(true);
+      // Reapply the camera offsets.
+      var charData = _data.characters.bf;
+      getBoyfriend().cameraFocusPoint.x += charData.cameraOffsets[0];
+      getBoyfriend().cameraFocusPoint.y += charData.cameraOffsets[1];
     }
     else
     {
@@ -86,10 +90,18 @@ class Stage extends FlxSpriteGroup implements IPlayStateScriptedClass
     if (getGirlfriend() != null)
     {
       getGirlfriend().resetCharacter(true);
+      // Reapply the camera offsets.
+      var charData = _data.characters.gf;
+      getGirlfriend().cameraFocusPoint.x += charData.cameraOffsets[0];
+      getGirlfriend().cameraFocusPoint.y += charData.cameraOffsets[1];
     }
     if (getDad() != null)
     {
       getDad().resetCharacter(true);
+      // Reapply the camera offsets.
+      var charData = _data.characters.dad;
+      getDad().cameraFocusPoint.x += charData.cameraOffsets[0];
+      getDad().cameraFocusPoint.y += charData.cameraOffsets[1];
     }
 
     // Reset positions of named props.
@@ -216,8 +228,12 @@ class Stage extends FlxSpriteGroup implements IPlayStateScriptedClass
         {
           cast(propSprite, Bopper).setAnimationOffsets(propAnim.name, propAnim.offsets[0], propAnim.offsets[1]);
         }
-        cast(propSprite, Bopper).originalPosition.x = dataProp.position[0];
-        cast(propSprite, Bopper).originalPosition.y = dataProp.position[1];
+
+        if (!Std.isOfType(propSprite, BaseCharacter))
+        {
+          cast(propSprite, Bopper).originalPosition.x = dataProp.position[0];
+          cast(propSprite, Bopper).originalPosition.y = dataProp.position[1];
+        }
       }
 
       if (dataProp.startingAnimation != null)
@@ -357,8 +373,12 @@ class Stage extends FlxSpriteGroup implements IPlayStateScriptedClass
       character.x = charData.position[0] - character.characterOrigin.x + character.globalOffsets[0];
       character.y = charData.position[1] - character.characterOrigin.y + character.globalOffsets[1];
 
-      character.originalPosition.x = character.x;
-      character.originalPosition.y = character.y;
+      @:privateAccess(funkin.play.stage.Bopper)
+      {
+        // Undo animOffsets before saving original position.
+        character.originalPosition.x = character.x + character.animOffsets[0];
+        character.originalPosition.y = character.y + character.animOffsets[1];
+      }
 
       character.cameraFocusPoint.x += charData.cameraOffsets[0];
       character.cameraFocusPoint.y += charData.cameraOffsets[1];
diff --git a/source/funkin/ui/debug/charting/ChartEditorState.hx b/source/funkin/ui/debug/charting/ChartEditorState.hx
index ddd50073a..8e5a65c80 100644
--- a/source/funkin/ui/debug/charting/ChartEditorState.hx
+++ b/source/funkin/ui/debug/charting/ChartEditorState.hx
@@ -6,6 +6,7 @@ import flixel.FlxCamera;
 import flixel.FlxSprite;
 import flixel.FlxSubState;
 import flixel.group.FlxSpriteGroup;
+import flixel.addons.transition.FlxTransitionableState;
 import flixel.input.keyboard.FlxKey;
 import flixel.math.FlxPoint;
 import flixel.math.FlxRect;
@@ -3336,6 +3337,9 @@ class ChartEditorState extends HaxeUIState
     subStateClosed.add(fixCamera);
     subStateClosed.add(updateConductor);
 
+    FlxTransitionableState.skipNextTransIn = false;
+    FlxTransitionableState.skipNextTransOut = false;
+
     openSubState(new PlayState(
       {
         targetSong: targetSong,
diff --git a/source/funkin/ui/story/StoryMenuState.hx b/source/funkin/ui/story/StoryMenuState.hx
index 29bc4beca..8d2db0543 100644
--- a/source/funkin/ui/story/StoryMenuState.hx
+++ b/source/funkin/ui/story/StoryMenuState.hx
@@ -5,6 +5,7 @@ import flixel.addons.transition.FlxTransitionableState;
 import flixel.FlxSprite;
 import flixel.group.FlxGroup.FlxTypedGroup;
 import flixel.text.FlxText;
+import flixel.addons.transition.FlxTransitionableState;
 import flixel.tweens.FlxEase;
 import flixel.tweens.FlxTween;
 import flixel.util.FlxColor;
@@ -521,6 +522,9 @@ class StoryMenuState extends MusicBeatState
     }
 
     new FlxTimer().start(1, function(tmr:FlxTimer) {
+      FlxTransitionableState.skipNextTransIn = false;
+      FlxTransitionableState.skipNextTransOut = false;
+
       LoadingState.loadAndSwitchState(new PlayState(
         {
           targetSong: targetSong,

From 113b4c45a41b0685960ced8fb2cab03cfe3874e9 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Thu, 3 Aug 2023 09:23:53 -0400
Subject: [PATCH 08/29] Attempt at fixing Github Actions on WIN and HTML5

---
 .github/workflows/build-shit.yml | 2 +-
 hmm.json                         | 9 ++++++++-
 2 files changed, 9 insertions(+), 2 deletions(-)

diff --git a/.github/workflows/build-shit.yml b/.github/workflows/build-shit.yml
index 32c2a0ede..45d04b92f 100644
--- a/.github/workflows/build-shit.yml
+++ b/.github/workflows/build-shit.yml
@@ -39,7 +39,7 @@ jobs:
           haxelib run lime rebuild linux --clean
       - name: Build game
         run: |
-          sudo apt-get install -y libx11-dev libxinerama-dev libxrandr-dev libgl1-mesa-dev libgl-dev libxi-dev libxext-dev libasound2-dev
+          sudo apt-get install -y libx11-dev xorg-dev libxinerama-dev libxrandr-dev libgl1-mesa-dev libgl-dev libxi-dev libxext-dev libasound2-dev
           haxelib run lime build html5 -debug --times
           ls
       - uses: ./.github/actions/upload-itch
diff --git a/hmm.json b/hmm.json
index f9ac62363..d0dde499d 100644
--- a/hmm.json
+++ b/hmm.json
@@ -91,6 +91,13 @@
       "ref": "acb0334c59bd4618f3c0277584d524ed0b288b5f",
       "url": "https://github.com/EliteMasterEric/lime"
     },
+    {
+      "name": "mockatoo",
+      "type": "git",
+      "dir": null,
+      "ref": "master",
+      "url": "https://github.com/EliteMasterEric/mockatoo"
+    },
     {
       "name": "openfl",
       "type": "git",
@@ -116,4 +123,4 @@
       "version": "0.11.0"
     }
   ]
-}
+}
\ No newline at end of file

From adc5043da2e6d4fa476d2a2ce39e60e27d1a968e Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Thu, 3 Aug 2023 09:31:54 -0400
Subject: [PATCH 09/29] Improvements to build workflows

---
 .github/workflows/build-shit.yml | 10 +++++++---
 1 file changed, 7 insertions(+), 3 deletions(-)

diff --git a/.github/workflows/build-shit.yml b/.github/workflows/build-shit.yml
index 45d04b92f..7c986c15d 100644
--- a/.github/workflows/build-shit.yml
+++ b/.github/workflows/build-shit.yml
@@ -10,7 +10,7 @@ jobs:
     outputs:
       should_run: ${{ steps.should_run.outputs.should_run }}
     steps:
-      - uses: actions/checkout@v2
+      - uses: actions/checkout@v3
       - name: print latest_commit
         run: echo ${{ github.sha }}
       - id: should_run
@@ -24,6 +24,8 @@ jobs:
     runs-on: ubuntu-latest
     steps:
       - uses: actions/checkout@v3
+        with:
+          submodules: 'recursive'
       - uses: ./.github/actions/setup-haxeshit
       - name: Build Lime
         # TODO: Remove the step that builds Lime later.
@@ -35,11 +37,11 @@ jobs:
           git submodule sync --recursive
           git submodule update --recursive
           git status
-          sudo apt-get install -y libxinerama-dev
+          sudo apt-get install -y libxinerama-dev libxrandr-dev
           haxelib run lime rebuild linux --clean
       - name: Build game
         run: |
-          sudo apt-get install -y libx11-dev xorg-dev libxinerama-dev libxrandr-dev libgl1-mesa-dev libgl-dev libxi-dev libxext-dev libasound2-dev
+          sudo apt-get install -y libx11-dev xorg-dev libgl1-mesa-dev libgl-dev libxi-dev libxext-dev libasound2-dev
           haxelib run lime build html5 -debug --times
           ls
       - uses: ./.github/actions/upload-itch
@@ -56,6 +58,8 @@ jobs:
        actions: write
     steps:
       - uses: actions/checkout@v3
+        with:
+          submodules: 'recursive'
       - uses: ./.github/actions/setup-haxeshit
       - name: Build Lime
         # TODO: Remove the step that builds Lime later.

From 70d8a3638b3aae7c8f0fc0de2fce7e28b09eea24 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Thu, 3 Aug 2023 09:39:06 -0400
Subject: [PATCH 10/29] .

---
 .github/workflows/build-shit.yml | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/.github/workflows/build-shit.yml b/.github/workflows/build-shit.yml
index 7c986c15d..c52a1554f 100644
--- a/.github/workflows/build-shit.yml
+++ b/.github/workflows/build-shit.yml
@@ -37,11 +37,11 @@ jobs:
           git submodule sync --recursive
           git submodule update --recursive
           git status
-          sudo apt-get install -y libxinerama-dev libxrandr-dev
+          sudo apt-get install -y libxinerama-dev libxrandr-dev libgl1-mesa-dev
           haxelib run lime rebuild linux --clean
       - name: Build game
         run: |
-          sudo apt-get install -y libx11-dev xorg-dev libgl1-mesa-dev libgl-dev libxi-dev libxext-dev libasound2-dev
+          sudo apt-get install -y libx11-dev xorg-dev libgl-dev libxi-dev libxext-dev libasound2-dev
           haxelib run lime build html5 -debug --times
           ls
       - uses: ./.github/actions/upload-itch

From 050086fb36278726ea89bee64dea269386bcaf29 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Thu, 3 Aug 2023 11:40:19 -0400
Subject: [PATCH 11/29] Fixed camera focus on game over.

---
 .github/actions/setup-haxeshit/action.yml |  2 +-
 source/funkin/play/PlayState.hx           |  4 ++++
 source/funkin/play/stage/Stage.hx         | 21 +++++++++++++++++++++
 3 files changed, 26 insertions(+), 1 deletion(-)

diff --git a/.github/actions/setup-haxeshit/action.yml b/.github/actions/setup-haxeshit/action.yml
index 6b565bfa2..ec8ed52d8 100644
--- a/.github/actions/setup-haxeshit/action.yml
+++ b/.github/actions/setup-haxeshit/action.yml
@@ -21,7 +21,7 @@ runs:
       - name: Installing Haxe lol
         run: |
           haxe -version
-          haxelib git haxelib https://github.com/HaxeFoundation/haxelib.git
+          haxelib git haxelib https://github.com/HaxeFoundation/haxelib.git development
           haxelib version
           haxelib --global install hmm
           haxelib --global run hmm install --quiet
diff --git a/source/funkin/play/PlayState.hx b/source/funkin/play/PlayState.hx
index 5e8f964d1..297f14d69 100644
--- a/source/funkin/play/PlayState.hx
+++ b/source/funkin/play/PlayState.hx
@@ -1107,6 +1107,8 @@ class PlayState extends MusicBeatSubState
     // super.stepHit() returns false if a module cancelled the event.
     if (!super.stepHit()) return false;
 
+    if (isGamePaused) return false;
+
     if (!startingSong
       && FlxG.sound.music != null
       && (Math.abs(FlxG.sound.music.time - (Conductor.songPosition - Conductor.offset)) > 200
@@ -1131,6 +1133,8 @@ class PlayState extends MusicBeatSubState
     // super.beatHit() returns false if a module cancelled the event.
     if (!super.beatHit()) return false;
 
+    if (isGamePaused) return false;
+
     if (generatedMusic)
     {
       // TODO: Sort more efficiently, or less often, to improve performance.
diff --git a/source/funkin/play/stage/Stage.hx b/source/funkin/play/stage/Stage.hx
index dafba1c06..d2b157acd 100644
--- a/source/funkin/play/stage/Stage.hx
+++ b/source/funkin/play/stage/Stage.hx
@@ -694,6 +694,27 @@ class Stage extends FlxSpriteGroup implements IPlayStateScriptedClass
     }
   }
 
+  public override function kill()
+  {
+    _skipTransformChildren = true;
+    alive = false;
+    exists = false;
+    _skipTransformChildren = false;
+    if (group != null) group.kill();
+  }
+
+  public override function remove(Sprite:FlxSprite, Splice:Bool = false):FlxSprite
+  {
+    var sprite:FlxSprite = cast Sprite;
+    sprite.x -= x;
+    sprite.y -= y;
+    // alpha
+    sprite.cameras = null;
+
+    if (group != null) group.remove(Sprite, Splice);
+    return Sprite;
+  }
+
   public function onScriptEvent(event:ScriptEvent) {}
 
   public function onPause(event:PauseScriptEvent) {}

From 4e1a5d6d65e98108e4e40db3ae5ec64cbbad415e Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Thu, 3 Aug 2023 11:47:40 -0400
Subject: [PATCH 12/29] Use cached builds for Lime

---
 .github/workflows/build-shit.yml | 25 +------------------------
 hmm.json                         |  4 ++--
 2 files changed, 3 insertions(+), 26 deletions(-)

diff --git a/.github/workflows/build-shit.yml b/.github/workflows/build-shit.yml
index c52a1554f..ddd6e8be0 100644
--- a/.github/workflows/build-shit.yml
+++ b/.github/workflows/build-shit.yml
@@ -27,21 +27,9 @@ jobs:
         with:
           submodules: 'recursive'
       - uses: ./.github/actions/setup-haxeshit
-      - name: Build Lime
-        # TODO: Remove the step that builds Lime later.
-        # Bash method
-        run: |
-          LIME_PATH=`haxelib libpath lime`
-          echo "Moving to $LIME_PATH"
-          cd $LIME_PATH
-          git submodule sync --recursive
-          git submodule update --recursive
-          git status
-          sudo apt-get install -y libxinerama-dev libxrandr-dev libgl1-mesa-dev
-          haxelib run lime rebuild linux --clean
       - name: Build game
         run: |
-          sudo apt-get install -y libx11-dev xorg-dev libgl-dev libxi-dev libxext-dev libasound2-dev
+          sudo apt-get install -y libx11-dev xorg-dev libgl-dev libxi-dev libxext-dev libasound2-dev libxinerama-dev libxrandr-dev libgl1-mesa-dev
           haxelib run lime build html5 -debug --times
           ls
       - uses: ./.github/actions/upload-itch
@@ -61,17 +49,6 @@ jobs:
         with:
           submodules: 'recursive'
       - uses: ./.github/actions/setup-haxeshit
-      - name: Build Lime
-        # TODO: Remove the step that builds Lime later.
-        # Powershell method
-        run: |
-          $LIME_PATH = haxelib libpath lime
-          echo "Moving to $LIME_PATH"
-          cd $LIME_PATH
-          git submodule sync --recursive
-          git submodule update --recursive
-          git status
-          haxelib run lime rebuild windows --clean
       - name: Build game
         run: |
           haxelib run lime build windows -debug
diff --git a/hmm.json b/hmm.json
index d0dde499d..7c23f2880 100644
--- a/hmm.json
+++ b/hmm.json
@@ -88,7 +88,7 @@
       "name": "lime",
       "type": "git",
       "dir": null,
-      "ref": "acb0334c59bd4618f3c0277584d524ed0b288b5f",
+      "ref": "58800b7810123cbe5c1f5b88f23e4ebf6fae6f3a",
       "url": "https://github.com/EliteMasterEric/lime"
     },
     {
@@ -123,4 +123,4 @@
       "version": "0.11.0"
     }
   ]
-}
\ No newline at end of file
+}

From 29466af4b7b9c5993d9a803ed6c5e69ed425e5c7 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Thu, 3 Aug 2023 12:19:36 -0400
Subject: [PATCH 13/29] Fix issue where strumline would stay pressed when
 resetting.

---
 source/funkin/play/notes/Strumline.hx | 7 +++++++
 1 file changed, 7 insertions(+)

diff --git a/source/funkin/play/notes/Strumline.hx b/source/funkin/play/notes/Strumline.hx
index e7c5fc2a7..b343bee86 100644
--- a/source/funkin/play/notes/Strumline.hx
+++ b/source/funkin/play/notes/Strumline.hx
@@ -509,6 +509,13 @@ class Strumline extends FlxSpriteGroup
       if (cover == null) continue;
       cover.kill();
     }
+
+    heldKeys = [false, false, false, false];
+
+    for (dir in DIRECTIONS)
+    {
+      playStatic(dir);
+    }
   }
 
   public function applyNoteData(data:Array<SongNoteData>):Void

From 5590331e10a47190f2c36e7faa4899c6454f6435 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Thu, 3 Aug 2023 12:41:54 -0400
Subject: [PATCH 14/29] Fix HTML typing issue

---
 source/funkin/input/PreciseInputManager.hx | 12 ++++--------
 1 file changed, 4 insertions(+), 8 deletions(-)

diff --git a/source/funkin/input/PreciseInputManager.hx b/source/funkin/input/PreciseInputManager.hx
index 11a3c2007..7e6a6569b 100644
--- a/source/funkin/input/PreciseInputManager.hx
+++ b/source/funkin/input/PreciseInputManager.hx
@@ -28,10 +28,6 @@ class PreciseInputManager extends FlxKeyManager<FlxKey, PreciseInputList>
     return instance ?? (instance = new PreciseInputManager());
   }
 
-  static final MS_TO_US:Int64 = 1000;
-  static final US_TO_NS:Int64 = 1000;
-  static final MS_TO_NS:Int64 = MS_TO_US * US_TO_NS;
-
   static final DIRECTIONS:Array<NoteDirection> = [NoteDirection.LEFT, NoteDirection.DOWN, NoteDirection.UP, NoteDirection.RIGHT];
 
   public var onInputPressed:FlxTypedSignal<PreciseInputEvent->Void>;
@@ -101,11 +97,11 @@ class PreciseInputManager extends FlxKeyManager<FlxKey, PreciseInputList>
     // NOTE: This timestamp isn't that precise on standard HTML5 builds.
     // This is because of browser safeguards against timing attacks.
     // See https://web.dev/coop-coep to enable headers which allow for more precise timestamps.
-    return js.Browser.window.performance.now() * MS_TO_NS;
+    return haxe.Int64.fromFloat(js.Browser.window.performance.now() * Constants.NS_PER_MS);
     #elseif cpp
     // NOTE: If the game hard crashes on this line, rebuild Lime!
     // `lime rebuild windows -clean`
-    return lime._internal.backend.native.NativeCFFI.lime_sdl_get_ticks() * MS_TO_NS;
+    return lime._internal.backend.native.NativeCFFI.lime_sdl_get_ticks() * Constants.NS_PER_MS;
     #else
     throw "Eric didn't implement precise timestamps on this platform!";
     #end
@@ -176,7 +172,7 @@ class PreciseInputManager extends FlxKeyManager<FlxKey, PreciseInputList>
 
     // TODO: Remove this line with SDL3 when timestamps change meaning.
     // This is because SDL3's timestamps are measured in nanoseconds, not milliseconds.
-    timestamp *= MS_TO_NS;
+    timestamp *= Constants.NS_PER_MS;
 
     updateKeyStates(key, true);
 
@@ -198,7 +194,7 @@ class PreciseInputManager extends FlxKeyManager<FlxKey, PreciseInputList>
 
     // TODO: Remove this line with SDL3 when timestamps change meaning.
     // This is because SDL3's timestamps are measured in nanoseconds, not milliseconds.
-    timestamp *= MS_TO_NS;
+    timestamp *= Constants.NS_PER_MS;
 
     updateKeyStates(key, false);
 

From 2ba2aab2e3e6318f22e6a3227fd97e5a5c997449 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Thu, 3 Aug 2023 12:55:29 -0400
Subject: [PATCH 15/29] Update Polymod

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

diff --git a/hmm.json b/hmm.json
index 7c23f2880..c9dc959c7 100644
--- a/hmm.json
+++ b/hmm.json
@@ -109,7 +109,7 @@
       "name": "polymod",
       "type": "git",
       "dir": null,
-      "ref": "631a3637f30997e47cd37bbab3cb6a75636a4b2a",
+      "ref": "bb82bfe040965dd55c3b48cb8a3008afdc101ce7",
       "url": "https://github.com/larsiusprime/polymod"
     },
     {

From ba0ed22c1b4df71cf41567cd8d42a01cc3fda60d Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Thu, 3 Aug 2023 13:07:30 -0400
Subject: [PATCH 16/29] Update Polymod

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

diff --git a/hmm.json b/hmm.json
index c9dc959c7..a52055d03 100644
--- a/hmm.json
+++ b/hmm.json
@@ -109,7 +109,7 @@
       "name": "polymod",
       "type": "git",
       "dir": null,
-      "ref": "bb82bfe040965dd55c3b48cb8a3008afdc101ce7",
+      "ref": "4bcd614103469af79a320898b823d1df8a55c3de",
       "url": "https://github.com/larsiusprime/polymod"
     },
     {

From 1caf905ef85ccc501d24911b76dda20f1b25c5eb Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Thu, 3 Aug 2023 17:44:17 -0400
Subject: [PATCH 17/29] Update Lime

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

diff --git a/hmm.json b/hmm.json
index a52055d03..52d5ffad8 100644
--- a/hmm.json
+++ b/hmm.json
@@ -88,7 +88,7 @@
       "name": "lime",
       "type": "git",
       "dir": null,
-      "ref": "58800b7810123cbe5c1f5b88f23e4ebf6fae6f3a",
+      "ref": "558798adc5bf0e82d70fef589a59ce88892e0b5b",
       "url": "https://github.com/EliteMasterEric/lime"
     },
     {

From 49622f2441aa77214840e923cc0565c27192a1e2 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Thu, 3 Aug 2023 22:22:29 -0400
Subject: [PATCH 18/29] Fix build issues caused by int64 handling

---
 source/funkin/import.hx                |  1 +
 source/funkin/play/PlayState.hx        |  3 ++-
 source/funkin/util/tools/Int64Tools.hx | 32 ++++++++++++++++++++++++++
 3 files changed, 35 insertions(+), 1 deletion(-)
 create mode 100644 source/funkin/util/tools/Int64Tools.hx

diff --git a/source/funkin/import.hx b/source/funkin/import.hx
index 4ba062b8f..cd0af4b55 100644
--- a/source/funkin/import.hx
+++ b/source/funkin/import.hx
@@ -11,6 +11,7 @@ using Lambda;
 using StringTools;
 using funkin.util.tools.ArrayTools;
 using funkin.util.tools.ArraySortTools;
+using funkin.util.tools.Int64Tools;
 using funkin.util.tools.IteratorTools;
 using funkin.util.tools.MapTools;
 using funkin.util.tools.StringTools;
diff --git a/source/funkin/play/PlayState.hx b/source/funkin/play/PlayState.hx
index 297f14d69..4e8b1ce9d 100644
--- a/source/funkin/play/PlayState.hx
+++ b/source/funkin/play/PlayState.hx
@@ -2326,7 +2326,8 @@ class PlayState extends MusicBeatSubState
     vocals.playerVolume = 1;
 
     // Calculate the input latency (do this as late as possible).
-    var inputLatencyMs:Float = haxe.Int64.toInt(PreciseInputManager.getCurrentTimestamp() - input.timestamp) / 1000.0 / 1000.0;
+    var currentTimestampNs:Int64 = PreciseInputManager.getCurrentTimestamp();
+    var inputLatencyMs:Float = haxe.Int64.toInt(currentTimestampNs - input.timestamp) / Constants.NS_PER_MS;
     trace('Input: ${daNote.noteData.getDirectionName()} pressed ${inputLatencyMs}ms ago!');
 
     // Get the offset and compensate for input latency.
diff --git a/source/funkin/util/tools/Int64Tools.hx b/source/funkin/util/tools/Int64Tools.hx
new file mode 100644
index 000000000..75448b36f
--- /dev/null
+++ b/source/funkin/util/tools/Int64Tools.hx
@@ -0,0 +1,32 @@
+package funkin.util.tools;
+
+/**
+ * @see https://github.com/fponticelli/thx.core/blob/master/src/thx/Int64s.hx
+ */
+class Int64Tools
+{
+  static var min = haxe.Int64.make(0x80000000, 0);
+  static var one = haxe.Int64.make(0, 1);
+  static var two = haxe.Int64.ofInt(2);
+  static var zero = haxe.Int64.make(0, 0);
+  static var ten = haxe.Int64.ofInt(10);
+
+  public static function toFloat(i:haxe.Int64):Float
+  {
+    var isNegative = false;
+    if (i < 0)
+    {
+      if (i < min) return -9223372036854775808.0; // most -ve value can't be made +ve
+      isNegative = true;
+      i = -i;
+    }
+    var multiplier = 1.0, ret = 0.0;
+    for (_ in 0...64)
+    {
+      if (haxe.Int64.and(i, one) != zero) ret += multiplier;
+      multiplier *= 2.0;
+      i = haxe.Int64.shr(i, 1);
+    }
+    return (isNegative ? -1 : 1) * ret;
+  }
+}

From 13671cf29053cb68e12ed18ddbb432ceb75a4ed2 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Fri, 4 Aug 2023 11:13:41 -0400
Subject: [PATCH 19/29] Make sure timestamps are consistent with use of Int64.

---
 source/funkin/input/PreciseInputManager.hx | 9 +++++++--
 source/funkin/play/PlayState.hx            | 3 +--
 2 files changed, 8 insertions(+), 4 deletions(-)

diff --git a/source/funkin/input/PreciseInputManager.hx b/source/funkin/input/PreciseInputManager.hx
index 7e6a6569b..6217b2fe7 100644
--- a/source/funkin/input/PreciseInputManager.hx
+++ b/source/funkin/input/PreciseInputManager.hx
@@ -84,6 +84,11 @@ class PreciseInputManager extends FlxKeyManager<FlxKey, PreciseInputList>
     };
   }
 
+  /**
+   * Convert from int to Int64.
+   */
+  static final NS_PER_MS:Int64 = Constants.NS_PER_MS;
+
   /**
    * Returns a precise timestamp, measured in nanoseconds.
    * Timestamp is only useful for comparing against other timestamps.
@@ -97,11 +102,11 @@ class PreciseInputManager extends FlxKeyManager<FlxKey, PreciseInputList>
     // NOTE: This timestamp isn't that precise on standard HTML5 builds.
     // This is because of browser safeguards against timing attacks.
     // See https://web.dev/coop-coep to enable headers which allow for more precise timestamps.
-    return haxe.Int64.fromFloat(js.Browser.window.performance.now() * Constants.NS_PER_MS);
+    return haxe.Int64.fromFloat(js.Browser.window.performance.now()) * NS_PER_MS;
     #elseif cpp
     // NOTE: If the game hard crashes on this line, rebuild Lime!
     // `lime rebuild windows -clean`
-    return lime._internal.backend.native.NativeCFFI.lime_sdl_get_ticks() * Constants.NS_PER_MS;
+    return lime._internal.backend.native.NativeCFFI.lime_sdl_get_ticks() * NS_PER_MS;
     #else
     throw "Eric didn't implement precise timestamps on this platform!";
     #end
diff --git a/source/funkin/play/PlayState.hx b/source/funkin/play/PlayState.hx
index 4e8b1ce9d..297f14d69 100644
--- a/source/funkin/play/PlayState.hx
+++ b/source/funkin/play/PlayState.hx
@@ -2326,8 +2326,7 @@ class PlayState extends MusicBeatSubState
     vocals.playerVolume = 1;
 
     // Calculate the input latency (do this as late as possible).
-    var currentTimestampNs:Int64 = PreciseInputManager.getCurrentTimestamp();
-    var inputLatencyMs:Float = haxe.Int64.toInt(currentTimestampNs - input.timestamp) / Constants.NS_PER_MS;
+    var inputLatencyMs:Float = haxe.Int64.toInt(PreciseInputManager.getCurrentTimestamp() - input.timestamp) / 1000.0 / 1000.0;
     trace('Input: ${daNote.noteData.getDirectionName()} pressed ${inputLatencyMs}ms ago!');
 
     // Get the offset and compensate for input latency.

From 4852515c4295a3b87bb9e435c0f8e38a67257323 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Fri, 4 Aug 2023 11:18:00 -0400
Subject: [PATCH 20/29] Fix bug where pressing ENTER in UI would cause song to
 try to preview. Fix bug where trying to preview a newly created song would
 crash.

---
 source/funkin/play/song/Song.hx                     | 10 +++++++---
 source/funkin/ui/debug/charting/ChartEditorState.hx |  2 +-
 2 files changed, 8 insertions(+), 4 deletions(-)

diff --git a/source/funkin/play/song/Song.hx b/source/funkin/play/song/Song.hx
index 189adb840..ab22ad9e9 100644
--- a/source/funkin/play/song/Song.hx
+++ b/source/funkin/play/song/Song.hx
@@ -39,7 +39,11 @@ class Song implements IPlayStateScriptedClass
 
   var difficultyIds:Array<String>;
 
-  public function new(id:String)
+  /**
+   * @param id The ID of the song to load.
+   * @param ignoreErrors If false, an exception will be thrown if the song data could not be loaded.
+   */
+  public function new(id:String, ignoreErrors:Bool = false)
   {
     this.songId = id;
 
@@ -48,7 +52,7 @@ class Song implements IPlayStateScriptedClass
     difficulties = new Map<String, SongDifficulty>();
 
     _metadata = SongDataParser.loadSongMetadata(songId);
-    if (_metadata == null || _metadata.length == 0)
+    if (_metadata == null || _metadata.length == 0 && !ignoreErrors)
     {
       throw 'Could not find song data for songId: $songId';
     }
@@ -60,7 +64,7 @@ class Song implements IPlayStateScriptedClass
   public static function buildRaw(songId:String, metadata:Array<SongMetadata>, variations:Array<String>, charts:Map<String, SongChartData>,
       ?validScore:Bool = false):Song
   {
-    var result:Song = new Song(songId);
+    var result:Song = new Song(songId, true);
 
     result._metadata.clear();
     for (meta in metadata)
diff --git a/source/funkin/ui/debug/charting/ChartEditorState.hx b/source/funkin/ui/debug/charting/ChartEditorState.hx
index 8e5a65c80..4f487f70c 100644
--- a/source/funkin/ui/debug/charting/ChartEditorState.hx
+++ b/source/funkin/ui/debug/charting/ChartEditorState.hx
@@ -2761,7 +2761,7 @@ class ChartEditorState extends HaxeUIState
    */
   function handleTestKeybinds():Void
   {
-    if (FlxG.keys.justPressed.ENTER)
+    if (!isHaxeUIDialogOpen && FlxG.keys.justPressed.ENTER)
     {
       var minimal = FlxG.keys.pressed.SHIFT;
       testSongInPlayState(minimal);

From 3e093510af31fa3261bf4dcb10ae9008a766046b Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Fri, 4 Aug 2023 12:35:01 -0400
Subject: [PATCH 21/29] Fixed a bug where beat hit events were called multiple
 times during the conductor

---
 source/funkin/play/Countdown.hx    |  6 +++---
 source/funkin/play/PlayState.hx    | 10 ----------
 source/funkin/play/song/Song.hx    | 20 ++++++++++++++++----
 source/funkin/play/stage/Bopper.hx |  2 ++
 source/funkin/play/stage/Stage.hx  |  6 +++++-
 5 files changed, 26 insertions(+), 18 deletions(-)

diff --git a/source/funkin/play/Countdown.hx b/source/funkin/play/Countdown.hx
index 48e98e84f..33c0f852f 100644
--- a/source/funkin/play/Countdown.hx
+++ b/source/funkin/play/Countdown.hx
@@ -54,9 +54,9 @@ class Countdown
 
       countdownStep = decrement(countdownStep);
 
-      // Handle onBeatHit events manually
-      @:privateAccess
-      PlayState.instance.dispatchEvent(new SongTimeScriptEvent(ScriptEvent.SONG_BEAT_HIT, 0, 0));
+      // onBeatHit events are now properly dispatched by the Conductor even at negative timestamps,
+      // so calling this is no longer necessary.
+      // PlayState.instance.dispatchEvent(new SongTimeScriptEvent(ScriptEvent.SONG_BEAT_HIT, 0, 0));
 
       // Countdown graphic.
       showCountdownGraphic(countdownStep, isPixelStyle);
diff --git a/source/funkin/play/PlayState.hx b/source/funkin/play/PlayState.hx
index 297f14d69..e3d14289e 100644
--- a/source/funkin/play/PlayState.hx
+++ b/source/funkin/play/PlayState.hx
@@ -811,16 +811,6 @@ class PlayState extends MusicBeatSubState
       FlxG.watch.addQuick('bfAnim', currentStage.getBoyfriend().getCurrentAnimation());
     }
 
-    if (currentStage.getBoyfriend() != null)
-    {
-      FlxG.watch.addQuick('bfCameraFocus', currentStage.getBoyfriend().cameraFocusPoint);
-    }
-
-    if (currentStage.getDad() != null)
-    {
-      FlxG.watch.addQuick('dadCameraFocus', currentStage.getDad().cameraFocusPoint);
-    }
-
     // TODO: Add a song event for Handle GF dance speed.
 
     // Handle player death.
diff --git a/source/funkin/play/song/Song.hx b/source/funkin/play/song/Song.hx
index ab22ad9e9..398c28753 100644
--- a/source/funkin/play/song/Song.hx
+++ b/source/funkin/play/song/Song.hx
@@ -51,13 +51,23 @@ class Song implements IPlayStateScriptedClass
     difficultyIds = [];
     difficulties = new Map<String, SongDifficulty>();
 
-    _metadata = SongDataParser.loadSongMetadata(songId);
-    if (_metadata == null || _metadata.length == 0 && !ignoreErrors)
+    try
+    {
+      _metadata = SongDataParser.loadSongMetadata(songId);
+    }
+    catch (e)
+    {
+      _metadata = [];
+    }
+
+    if (_metadata.length == 0 && !ignoreErrors)
     {
       throw 'Could not find song data for songId: $songId';
     }
-
-    populateFromMetadata();
+    else
+    {
+      populateFromMetadata();
+    }
   }
 
   @:allow(funkin.play.song.Song)
@@ -97,6 +107,8 @@ class Song implements IPlayStateScriptedClass
    */
   function populateFromMetadata():Void
   {
+    if (_metadata == null || _metadata.length == 0) return;
+
     // Variations may have different artist, time format, generatedBy, etc.
     for (metadata in _metadata)
     {
diff --git a/source/funkin/play/stage/Bopper.hx b/source/funkin/play/stage/Bopper.hx
index d88618f8a..a144026f5 100644
--- a/source/funkin/play/stage/Bopper.hx
+++ b/source/funkin/play/stage/Bopper.hx
@@ -203,10 +203,12 @@ class Bopper extends StageProp implements IPlayStateScriptedClass
     {
       if (hasDanced)
       {
+        trace('DanceRight (alternate)');
         playAnimation('danceRight$idleSuffix', forceRestart);
       }
       else
       {
+        trace('DanceLeft (alternate)');
         playAnimation('danceLeft$idleSuffix', forceRestart);
       }
       hasDanced = !hasDanced;
diff --git a/source/funkin/play/stage/Stage.hx b/source/funkin/play/stage/Stage.hx
index d2b157acd..f4f380a0b 100644
--- a/source/funkin/play/stage/Stage.hx
+++ b/source/funkin/play/stage/Stage.hx
@@ -241,7 +241,11 @@ class Stage extends FlxSpriteGroup implements IPlayStateScriptedClass
         propSprite.animation.play(dataProp.startingAnimation);
       }
 
-      if (Std.isOfType(propSprite, Bopper))
+      if (Std.isOfType(propSprite, BaseCharacter))
+      {
+        // Character stuff.
+      }
+      else if (Std.isOfType(propSprite, Bopper))
       {
         addBopper(cast propSprite, dataProp.name);
       }

From a0a8d472165fcf3a8b1954bb5f8a89995480712c Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Fri, 4 Aug 2023 16:15:07 -0400
Subject: [PATCH 22/29] Imported music now plays properly in chart editor state

---
 source/funkin/play/Countdown.hx               |  4 +-
 source/funkin/play/PlayState.hx               | 50 ++++++++++++++-----
 .../ui/debug/charting/ChartEditorState.hx     | 11 +++-
 3 files changed, 49 insertions(+), 16 deletions(-)

diff --git a/source/funkin/play/Countdown.hx b/source/funkin/play/Countdown.hx
index 33c0f852f..f521f9ffc 100644
--- a/source/funkin/play/Countdown.hx
+++ b/source/funkin/play/Countdown.hx
@@ -39,8 +39,8 @@ class Countdown
     PlayState.instance.isInCountdown = true;
     Conductor.update(PlayState.instance.startTimestamp + Conductor.beatLengthMs * -5);
     // Handle onBeatHit events manually
-    @:privateAccess
-    PlayState.instance.dispatchEvent(new SongTimeScriptEvent(ScriptEvent.SONG_BEAT_HIT, 0, 0));
+    // @:privateAccess
+    // PlayState.instance.dispatchEvent(new SongTimeScriptEvent(ScriptEvent.SONG_BEAT_HIT, 0, 0));
 
     // The timer function gets called based on the beat of the song.
     countdownTimer = new FlxTimer();
diff --git a/source/funkin/play/PlayState.hx b/source/funkin/play/PlayState.hx
index e3d14289e..d85cc6a03 100644
--- a/source/funkin/play/PlayState.hx
+++ b/source/funkin/play/PlayState.hx
@@ -7,6 +7,7 @@ import funkin.play.notes.notestyle.NoteStyle;
 import funkin.data.notestyle.NoteStyleData;
 import funkin.data.notestyle.NoteStyleRegistry;
 import flixel.addons.display.FlxPieDial;
+import flixel.addons.transition.Transition;
 import flixel.addons.transition.FlxTransitionableState;
 import flixel.FlxCamera;
 import flixel.FlxObject;
@@ -93,6 +94,11 @@ typedef PlayStateParams =
    * If specified, the game will jump to the specified timestamp after the countdown ends.
    */
   ?startTimestamp:Float,
+  /**
+   * If specified, the game will not load the instrumental or vocal tracks,
+   * and must be loaded externally.
+   */
+  ?overrideMusic:Bool,
 }
 
 /**
@@ -243,6 +249,8 @@ class PlayState extends MusicBeatSubState
 
   public var startTimestamp:Float = 0.0;
 
+  var overrideMusic:Bool = false;
+
   public var isSubState(get, null):Bool;
 
   function get_isSubState():Bool
@@ -316,7 +324,7 @@ class PlayState extends MusicBeatSubState
   /**
    * A group of audio tracks, used to play the song's vocals.
    */
-  var vocals:VoicesGroup;
+  public var vocals:VoicesGroup;
 
   #if discord_rpc
   // Discord RPC variables
@@ -479,6 +487,7 @@ class PlayState extends MusicBeatSubState
     isPracticeMode = params.practiceMode ?? false;
     isMinimalMode = params.minimalMode ?? false;
     startTimestamp = params.startTimestamp ?? 0.0;
+    overrideMusic = params.overrideMusic ?? false;
 
     // Don't do anything else here! Wait until create() when we attach to the camera.
   }
@@ -555,16 +564,17 @@ class PlayState extends MusicBeatSubState
     this.persistentDraw = true;
 
     // Stop any pre-existing music.
-    if (FlxG.sound.music != null) FlxG.sound.music.stop();
+    if (!overrideMusic && FlxG.sound.music != null) FlxG.sound.music.stop();
 
     // Prepare the current song's instrumental and vocals to be played.
-    if (currentChart != null)
+    if (!overrideMusic && currentChart != null)
     {
       currentChart.cacheInst(currentPlayerId);
       currentChart.cacheVocals(currentPlayerId);
     }
 
     // Prepare the Conductor.
+    Conductor.forceBPM(null);
     Conductor.mapTimeChanges(currentChart.timeChanges);
     Conductor.update((Conductor.beatLengthMs * -5) + startTimestamp);
 
@@ -966,7 +976,7 @@ class PlayState extends MusicBeatSubState
    */
   public override function closeSubState():Void
   {
-    if (isGamePaused)
+    if (Std.isOfType(subState, PauseSubState))
     {
       var event:ScriptEvent = new ScriptEvent(ScriptEvent.RESUME, true);
 
@@ -994,6 +1004,10 @@ class PlayState extends MusicBeatSubState
       }
       #end
     }
+    else if (Std.isOfType(subState, Transition))
+    {
+      // Do nothing.
+    }
 
     super.closeSubState();
   }
@@ -1534,12 +1548,16 @@ class PlayState extends MusicBeatSubState
       trace('Song difficulty could not be loaded.');
     }
 
-    Conductor.forceBPM(currentChart.getStartingBPM());
+    // Conductor.forceBPM(currentChart.getStartingBPM());
 
-    vocals = currentChart.buildVocals(currentPlayerId);
-    if (vocals.members.length == 0)
+    if (!overrideMusic)
     {
-      trace('WARNING: No vocals found for this song.');
+      vocals = currentChart.buildVocals(currentPlayerId);
+
+      if (vocals.members.length == 0)
+      {
+        trace('WARNING: No vocals found for this song.');
+      }
     }
 
     regenNoteData();
@@ -1648,7 +1666,7 @@ class PlayState extends MusicBeatSubState
 
     startingSong = false;
 
-    if (!isGamePaused && currentChart != null)
+    if (!overrideMusic && !isGamePaused && currentChart != null)
     {
       currentChart.playInst(1.0, false);
     }
@@ -2600,9 +2618,17 @@ class PlayState extends MusicBeatSubState
       // TODO: Uncache the song.
     }
 
-    // Stop the music.
-    FlxG.sound.music.pause();
-    vocals.stop();
+    if (!overrideMusic)
+    {
+      // Stop the music.
+      FlxG.sound.music.pause();
+      vocals.stop();
+    }
+    else
+    {
+      FlxG.sound.music.pause();
+      remove(vocals);
+    }
 
     // Remove reference to stage and remove sprites from it to save memory.
     if (currentStage != null)
diff --git a/source/funkin/ui/debug/charting/ChartEditorState.hx b/source/funkin/ui/debug/charting/ChartEditorState.hx
index 4f487f70c..b89748d87 100644
--- a/source/funkin/ui/debug/charting/ChartEditorState.hx
+++ b/source/funkin/ui/debug/charting/ChartEditorState.hx
@@ -3340,7 +3340,7 @@ class ChartEditorState extends HaxeUIState
     FlxTransitionableState.skipNextTransIn = false;
     FlxTransitionableState.skipNextTransOut = false;
 
-    openSubState(new PlayState(
+    var targetState = new PlayState(
       {
         targetSong: targetSong,
         targetDifficulty: selectedDifficulty,
@@ -3349,7 +3349,14 @@ class ChartEditorState extends HaxeUIState
         practiceMode: true,
         minimalMode: minimal,
         startTimestamp: startTimestamp,
-      }));
+        overrideMusic: true,
+      });
+
+    // Override music.
+    FlxG.sound.music = audioInstTrack;
+    targetState.vocals = audioVocalTrackGroup;
+
+    openSubState(targetState);
   }
 
   function fixCamera(_:FlxSubState = null):Void

From b9c25d6ed9afed2d1b51b39b1c9f563365e0de65 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Fri, 4 Aug 2023 17:25:13 -0400
Subject: [PATCH 23/29] Fix crashing, broken countdown, broken miss muting in
 song preview

---
 source/funkin/play/Countdown.hx               | 10 ++++--
 source/funkin/play/PlayState.hx               |  7 ++--
 source/funkin/play/character/BaseCharacter.hx |  2 +-
 .../debug/charting/ChartEditorNotePreview.hx  |  2 +-
 .../ui/debug/charting/ChartEditorState.hx     | 32 +++++++++++++++----
 5 files changed, 40 insertions(+), 13 deletions(-)

diff --git a/source/funkin/play/Countdown.hx b/source/funkin/play/Countdown.hx
index f521f9ffc..5ccb6e24c 100644
--- a/source/funkin/play/Countdown.hx
+++ b/source/funkin/play/Countdown.hx
@@ -31,7 +31,10 @@ class Countdown
   {
     countdownStep = BEFORE;
     var cancelled:Bool = propagateCountdownEvent(countdownStep);
-    if (cancelled) return false;
+    if (cancelled)
+    {
+      return false;
+    }
 
     // Stop any existing countdown.
     stopCountdown();
@@ -67,7 +70,10 @@ class Countdown
       // Event handling bullshit.
       var cancelled:Bool = propagateCountdownEvent(countdownStep);
 
-      if (cancelled) pauseCountdown();
+      if (cancelled)
+      {
+        pauseCountdown();
+      }
 
       if (countdownStep == AFTER)
       {
diff --git a/source/funkin/play/PlayState.hx b/source/funkin/play/PlayState.hx
index d85cc6a03..5409040ec 100644
--- a/source/funkin/play/PlayState.hx
+++ b/source/funkin/play/PlayState.hx
@@ -952,7 +952,7 @@ class PlayState extends MusicBeatSubState
   {
     // If there is a substate which requires the game to continue,
     // then make this a condition.
-    var shouldPause = true;
+    var shouldPause = (Std.isOfType(subState, PauseSubState) || Std.isOfType(subState, GameOverSubState));
 
     if (shouldPause)
     {
@@ -966,6 +966,7 @@ class PlayState extends MusicBeatSubState
       // Pause the countdown.
       Countdown.pauseCountdown();
     }
+    else {}
 
     super.openSubState(subState);
   }
@@ -1672,7 +1673,8 @@ class PlayState extends MusicBeatSubState
     }
 
     FlxG.sound.music.onComplete = endSong;
-    FlxG.sound.music.play(false, startTimestamp);
+    FlxG.sound.music.play();
+    FlxG.sound.music.time = startTimestamp;
     trace('Playing vocals...');
     add(vocals);
     vocals.play();
@@ -2627,6 +2629,7 @@ class PlayState extends MusicBeatSubState
     else
     {
       FlxG.sound.music.pause();
+      vocals.pause();
       remove(vocals);
     }
 
diff --git a/source/funkin/play/character/BaseCharacter.hx b/source/funkin/play/character/BaseCharacter.hx
index 42dfd2da4..72f968538 100644
--- a/source/funkin/play/character/BaseCharacter.hx
+++ b/source/funkin/play/character/BaseCharacter.hx
@@ -235,8 +235,8 @@ class BaseCharacter extends Bopper
     // Then reapply animOffsets...
     // applyAnimationOffsets(getCurrentAnimation());
 
-    // Make sure we are playing the idle animation
     this.dance(true); // Force to avoid the old animation playing with the wrong offset at the start of the song.
+    // Make sure we are playing the idle animation
     // ...then update the hitbox so that this.width and this.height are correct.
     this.updateHitbox();
 
diff --git a/source/funkin/ui/debug/charting/ChartEditorNotePreview.hx b/source/funkin/ui/debug/charting/ChartEditorNotePreview.hx
index 27951f079..d3296c400 100644
--- a/source/funkin/ui/debug/charting/ChartEditorNotePreview.hx
+++ b/source/funkin/ui/debug/charting/ChartEditorNotePreview.hx
@@ -40,7 +40,7 @@ class ChartEditorNotePreview extends FlxSprite
    */
   function buildBackground():Void
   {
-    makeGraphic(WIDTH, 0, BG_COLOR);
+    makeGraphic(WIDTH, previewHeight, BG_COLOR);
   }
 
   /**
diff --git a/source/funkin/ui/debug/charting/ChartEditorState.hx b/source/funkin/ui/debug/charting/ChartEditorState.hx
index b89748d87..94cf05fc5 100644
--- a/source/funkin/ui/debug/charting/ChartEditorState.hx
+++ b/source/funkin/ui/debug/charting/ChartEditorState.hx
@@ -1247,7 +1247,8 @@ class ChartEditorState extends HaxeUIState
     var height:Int = FlxG.height - MENU_BAR_HEIGHT - GRID_TOP_PAD - 200;
     notePreview = new ChartEditorNotePreview(height);
     notePreview.y = MENU_BAR_HEIGHT + GRID_TOP_PAD;
-    add(notePreview);
+    // TODO: Re-enable.
+    // add(notePreview);
   }
 
   function buildSpectrogram(target:FlxSound):Void
@@ -3496,14 +3497,23 @@ class ChartEditorState extends HaxeUIState
    * @param charKey Character to load the vocal track for.
    * @return Success or failure.
    */
-  public function loadVocalsFromAsset(path:String, charKey:String = 'default'):Bool
+  public function loadVocalsFromAsset(path:String, charType:CharacterType = OTHER):Bool
   {
     var vocalTrack:FlxSound = FlxG.sound.load(path, 1.0, false);
     if (vocalTrack != null)
     {
-      audioVocalTrackGroup.add(vocalTrack);
-
-      audioVocalTrackData.set(charKey, Assets.getBytes(path));
+      switch (charType)
+      {
+        case CharacterType.BF:
+          audioVocalTrackGroup.addPlayerVoice(vocalTrack);
+          audioVocalTrackData.set(currentSongCharacterPlayer, Assets.getBytes(path));
+        case CharacterType.DAD:
+          audioVocalTrackGroup.addOpponentVoice(vocalTrack);
+          audioVocalTrackData.set(currentSongCharacterOpponent, Assets.getBytes(path));
+        default:
+          audioVocalTrackGroup.add(vocalTrack);
+          audioVocalTrackData.set('default', Assets.getBytes(path));
+      }
 
       return true;
     }
@@ -3565,9 +3575,17 @@ class ChartEditorState extends HaxeUIState
     loadInstrumentalFromAsset(Paths.inst(songId));
 
     var voiceList:Array<String> = song.getDifficulty(selectedDifficulty).buildVoiceList();
-    for (voicePath in voiceList)
+    if (voiceList.length == 2)
     {
-      loadVocalsFromAsset(voicePath);
+      loadVocalsFromAsset(voiceList[0], BF);
+      loadVocalsFromAsset(voiceList[1], DAD);
+    }
+    else
+    {
+      for (voicePath in voiceList)
+      {
+        loadVocalsFromAsset(voicePath);
+      }
     }
 
     NotificationManager.instance.addNotification(

From 3e02f7fca931f7d73cb9bc06171525c28f9e8113 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Wed, 9 Aug 2023 00:28:09 -0400
Subject: [PATCH 24/29] Fix a bug where `beatState` sometimes gets called
 before `create`

---
 source/funkin/play/PlayState.hx | 11 +++++++++--
 1 file changed, 9 insertions(+), 2 deletions(-)

diff --git a/source/funkin/play/PlayState.hx b/source/funkin/play/PlayState.hx
index 3e7325ae4..de096e2a0 100644
--- a/source/funkin/play/PlayState.hx
+++ b/source/funkin/play/PlayState.hx
@@ -275,6 +275,11 @@ class PlayState extends MusicBeatState
    */
   var startingSong:Bool = false;
 
+  /**
+   * False until `create()` has completed.
+   */
+  var initialized:Bool = false;
+
   /**
    * A group of audio tracks, used to play the song's vocals.
    */
@@ -592,6 +597,8 @@ class PlayState extends MusicBeatState
 
     FlxG.console.registerObject('playState', this);
     #end
+
+    initialized = true;
   }
 
   public override function update(elapsed:Float):Void
@@ -1029,7 +1036,7 @@ class PlayState extends MusicBeatState
 
   override function stepHit():Bool
   {
-    if (criticalFailure) return false;
+    if (criticalFailure || !initialized) return false;
 
     // super.stepHit() returns false if a module cancelled the event.
     if (!super.stepHit()) return false;
@@ -1052,7 +1059,7 @@ class PlayState extends MusicBeatState
 
   override function beatHit():Bool
   {
-    if (criticalFailure) return false;
+    if (criticalFailure || !initialized) return false;
 
     // super.beatHit() returns false if a module cancelled the event.
     if (!super.beatHit()) return false;

From 34abee594db015d0ab90595fbaa3004dd0857da7 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Wed, 9 Aug 2023 18:11:19 -0400
Subject: [PATCH 25/29] Fix bug where onUpdate was called twice per frame

---
 source/funkin/play/PlayState.hx | 3 ---
 1 file changed, 3 deletions(-)

diff --git a/source/funkin/play/PlayState.hx b/source/funkin/play/PlayState.hx
index a4f29fda9..a28c35c67 100644
--- a/source/funkin/play/PlayState.hx
+++ b/source/funkin/play/PlayState.hx
@@ -923,9 +923,6 @@ class PlayState extends MusicBeatSubState
 
     // Moving notes into position is now done by Strumline.update().
     processNotes(elapsed);
-
-    // Dispatch the onUpdate event to scripted elements.
-    dispatchEvent(new UpdateScriptEvent(elapsed));
   }
 
   public override function dispatchEvent(event:ScriptEvent):Void

From de6972cb90049f89604c1baa7da9a5d49d29a6ea Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Wed, 9 Aug 2023 18:11:50 -0400
Subject: [PATCH 26/29] Fix issue where stage wasn't loaded properly, and
 metronome would play in song preview.

---
 .../funkin/ui/debug/charting/ChartEditorState.hx | 16 ++++++++--------
 1 file changed, 8 insertions(+), 8 deletions(-)

diff --git a/source/funkin/ui/debug/charting/ChartEditorState.hx b/source/funkin/ui/debug/charting/ChartEditorState.hx
index 94cf05fc5..8d6743eca 100644
--- a/source/funkin/ui/debug/charting/ChartEditorState.hx
+++ b/source/funkin/ui/debug/charting/ChartEditorState.hx
@@ -378,7 +378,7 @@ class ChartEditorState extends HaxeUIState
   /**
    * Whether to play a metronome sound while the playhead is moving.
    */
-  var shouldPlayMetronome:Bool = true;
+  var isMetronomeEnabled:Bool = true;
 
   /**
    * Use the tool window to affect how the user interacts with the program.
@@ -903,7 +903,7 @@ class ChartEditorState extends HaxeUIState
 
   function get_currentSongId():String
   {
-    return currentSongName.toLowerKebabCase();
+    return currentSongName.toLowerKebabCase().replace('.', '').replace(' ', '-');
   }
 
   var currentSongArtist(get, set):String;
@@ -1197,7 +1197,7 @@ class ChartEditorState extends HaxeUIState
     gridPlayhead.add(playheadBlock);
 
     // Character icons.
-    healthIconDad = new HealthIcon('dad');
+    healthIconDad = new HealthIcon(currentSongCharacterOpponent);
     healthIconDad.autoUpdate = false;
     healthIconDad.size.set(0.5, 0.5);
     healthIconDad.x = gridTiledSprite.x - 15 - (HealthIcon.HEALTH_ICON_SIZE * 0.5);
@@ -1205,7 +1205,7 @@ class ChartEditorState extends HaxeUIState
     add(healthIconDad);
     healthIconDad.zIndex = 30;
 
-    healthIconBF = new HealthIcon('bf');
+    healthIconBF = new HealthIcon(currentSongCharacterPlayer);
     healthIconBF.autoUpdate = false;
     healthIconBF.size.set(0.5, 0.5);
     healthIconBF.x = gridTiledSprite.x + GRID_SIZE * (STRUMLINE_SIZE * 2 + 1) + 15;
@@ -1451,8 +1451,8 @@ class ChartEditorState extends HaxeUIState
     });
     setUICheckboxSelected('menuBarItemThemeDark', currentTheme == ChartEditorTheme.Dark);
 
-    addUIChangeListener('menubarItemMetronomeEnabled', event -> shouldPlayMetronome = event.value);
-    setUICheckboxSelected('menubarItemMetronomeEnabled', shouldPlayMetronome);
+    addUIChangeListener('menubarItemMetronomeEnabled', event -> isMetronomeEnabled = event.value);
+    setUICheckboxSelected('menubarItemMetronomeEnabled', isMetronomeEnabled);
 
     addUIChangeListener('menubarItemPlayerHitsounds', event -> hitsoundsEnabledPlayer = event.value);
     setUICheckboxSelected('menubarItemPlayerHitsounds', hitsoundsEnabledPlayer);
@@ -1616,7 +1616,7 @@ class ChartEditorState extends HaxeUIState
     // dispatchEvent gets called here.
     if (!super.beatHit()) return false;
 
-    if (shouldPlayMetronome && (audioInstTrack != null && audioInstTrack.playing))
+    if (isMetronomeEnabled && this.subState == null && (audioInstTrack != null && audioInstTrack.playing))
     {
       playMetronomeTick(Conductor.currentBeat % 4 == 0);
     }
@@ -3574,7 +3574,7 @@ class ChartEditorState extends HaxeUIState
 
     loadInstrumentalFromAsset(Paths.inst(songId));
 
-    var voiceList:Array<String> = song.getDifficulty(selectedDifficulty).buildVoiceList();
+    var voiceList:Array<String> = song.getDifficulty(selectedDifficulty).buildVoiceList(currentSongCharacterPlayer);
     if (voiceList.length == 2)
     {
       loadVocalsFromAsset(voiceList[0], BF);

From 214c706cac07d02b26247d9cf78447c87cd8bf1c Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Thu, 10 Aug 2023 14:03:57 -0400
Subject: [PATCH 27/29] Fix a story menu crash

---
 source/funkin/ui/story/StoryMenuState.hx | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/source/funkin/ui/story/StoryMenuState.hx b/source/funkin/ui/story/StoryMenuState.hx
index 8d2db0543..8276777ab 100644
--- a/source/funkin/ui/story/StoryMenuState.hx
+++ b/source/funkin/ui/story/StoryMenuState.hx
@@ -470,7 +470,7 @@ class StoryMenuState extends MusicBeatState
     // super.dispatchEvent(event) dispatches event to module scripts.
     super.dispatchEvent(event);
 
-    if ((levelProps?.length ?? 0) > 0)
+    if (levelProps != null && levelProps.length > 0)
     {
       // Dispatch event to props.
       for (prop in levelProps)

From 2226f1f05e5687c9853ccf96d8409e7b9f418ed5 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Thu, 10 Aug 2023 14:04:09 -0400
Subject: [PATCH 28/29] Fix an issue where tracknames weren't loading properly

---
 source/funkin/ui/story/Level.hx | 7 ++++++-
 1 file changed, 6 insertions(+), 1 deletion(-)

diff --git a/source/funkin/ui/story/Level.hx b/source/funkin/ui/story/Level.hx
index 83682fec9..7913ac8ca 100644
--- a/source/funkin/ui/story/Level.hx
+++ b/source/funkin/ui/story/Level.hx
@@ -71,7 +71,12 @@ class Level implements IRegistryEntry<LevelData>
   {
     var songList:Array<String> = getSongs() ?? [];
     var songNameList:Array<String> = songList.map(function(songId) {
-      return funkin.play.song.SongData.SongDataParser.fetchSong(songId) ?.getDifficulty(difficulty) ?.songName ?? 'Unknown';
+      var song:Song = funkin.play.song.SongData.SongDataParser.fetchSong(songId);
+      if (song == null) return 'Unknown';
+      var songDifficulty:SongDifficulty = song.getDifficulty(difficulty);
+      if (songDifficulty == null) songDifficulty = song.getDifficulty();
+      var songName:String = songDifficulty?.songName;
+      return songName ?? 'Unknown';
     });
     return songNameList;
   }

From 6096f12307f76d41670e00455f170d20ccc0a14a Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Thu, 10 Aug 2023 14:21:10 -0400
Subject: [PATCH 29/29] Fix an issue where new songs on specific stages would
 crash

---
 .../ui/debug/charting/ChartEditorState.hx     | 20 +++++++++----------
 1 file changed, 9 insertions(+), 11 deletions(-)

diff --git a/source/funkin/ui/debug/charting/ChartEditorState.hx b/source/funkin/ui/debug/charting/ChartEditorState.hx
index 8d6743eca..26b001d7e 100644
--- a/source/funkin/ui/debug/charting/ChartEditorState.hx
+++ b/source/funkin/ui/debug/charting/ChartEditorState.hx
@@ -3313,25 +3313,23 @@ class ChartEditorState extends HaxeUIState
     var targetSong:Song = Song.buildRaw(currentSongId, songMetadata.values(), availableVariations, songChartData, false);
 
     // TODO: Rework asset system so we can remove this.
-    switch (currentSongId)
+    switch (currentSongStage)
     {
-      case 'tutorial':
-        Paths.setCurrentLevel('tutorial');
-      case 'bopeebo' | 'fresh' | 'dadbattle':
+      case 'mainStage':
         Paths.setCurrentLevel('week1');
-      case 'spookeez' | 'south' | 'monster':
+      case 'spookyMansion':
         Paths.setCurrentLevel('week2');
-      case 'pico' | 'blammed' | 'philly-nice':
+      case 'phillyTrain':
         Paths.setCurrentLevel('week3');
-      case 'satin-panties' | 'high' | 'milf':
+      case 'limoRide':
         Paths.setCurrentLevel('week4');
-      case 'cocoa' | 'eggnog' | 'winter-horrorland':
+      case 'mallXmas' | 'mallEvil':
         Paths.setCurrentLevel('week5');
-      case 'senpai' | 'roses' | 'thorns':
+      case 'school' | 'schoolEvil':
         Paths.setCurrentLevel('week6');
-      case 'ugh' | 'guns' | 'stress':
+      case 'tankmanBattlefield':
         Paths.setCurrentLevel('week7');
-      case 'darnell' | 'lit-up' | '2hot' | 'blazin':
+      case 'phillyStreets' | 'phillyBlazin':
         Paths.setCurrentLevel('weekend1');
     }