From 25c70564bd7fcc7f7a97e89d17f164a62685a306 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Thu, 22 Jun 2023 01:41:01 -0400
Subject: [PATCH 01/30] WIP on new note rendering, inputs.

---
 hmm.json                                      |    6 +-
 source/funkin/Conductor.hx                    |    8 +-
 source/funkin/Controls.hx                     |   20 +
 source/funkin/Highscore.hx                    |    2 +
 source/funkin/LatencyState.hx                 |   13 +-
 source/funkin/Note.hx                         |  301 --
 source/funkin/Paths.hx                        |    4 +-
 source/funkin/PlayerSettings.hx               |    4 +
 source/funkin/Section.hx                      |   33 -
 source/funkin/SongLoad.hx                     |  325 --
 source/funkin/import.hx                       |    1 +
 source/funkin/input/PreciseInputManager.hx    |  303 ++
 source/funkin/modding/events/ScriptEvent.hx   |   18 +-
 source/funkin/noteStuff/NoteBasic.hx          |  197 -
 source/funkin/noteStuff/NoteEvent.hx          |   12 -
 source/funkin/noteStuff/NoteUtil.hx           |   98 -
 source/funkin/play/PlayState.hx               | 3220 +++++++++--------
 source/funkin/play/Strumline.hx               |  253 --
 source/funkin/play/character/BaseCharacter.hx |   22 +-
 source/funkin/play/notes/NoteDirection.hx     |   82 +
 source/funkin/play/notes/NoteSplash.hx        |   90 +
 source/funkin/play/notes/NoteSprite.hx        |  178 +
 source/funkin/play/notes/Strumline.hx         |  565 +++
 source/funkin/play/notes/StrumlineNote.hx     |  187 +
 source/funkin/play/notes/SustainTrail.hx      |  272 ++
 source/funkin/play/song/Song.hx               |   11 +-
 source/funkin/play/song/SongData.hx           |    6 +
 source/funkin/ui/ColorsMenu.hx                |   15 +-
 .../ui/debug/charting/ChartEditorState.hx     |    9 +-
 source/funkin/util/Constants.hx               |   12 +-
 source/funkin/util/SortUtil.hx                |    5 +-
 source/funkin/util/WindowUtil.hx              |    9 +
 source/funkin/util/tools/ArraySortTools.hx    |  154 +
 source/funkin/util/tools/ArrayTools.hx        |   15 +
 34 files changed, 3578 insertions(+), 2872 deletions(-)
 delete mode 100644 source/funkin/Note.hx
 delete mode 100644 source/funkin/Section.hx
 delete mode 100644 source/funkin/SongLoad.hx
 create mode 100644 source/funkin/input/PreciseInputManager.hx
 delete mode 100644 source/funkin/noteStuff/NoteBasic.hx
 delete mode 100644 source/funkin/noteStuff/NoteEvent.hx
 delete mode 100644 source/funkin/noteStuff/NoteUtil.hx
 delete mode 100644 source/funkin/play/Strumline.hx
 create mode 100644 source/funkin/play/notes/NoteDirection.hx
 create mode 100644 source/funkin/play/notes/NoteSplash.hx
 create mode 100644 source/funkin/play/notes/NoteSprite.hx
 create mode 100644 source/funkin/play/notes/Strumline.hx
 create mode 100644 source/funkin/play/notes/StrumlineNote.hx
 create mode 100644 source/funkin/play/notes/SustainTrail.hx
 create mode 100644 source/funkin/util/tools/ArraySortTools.hx

diff --git a/hmm.json b/hmm.json
index f79a2ca56..a1d78a29f 100644
--- a/hmm.json
+++ b/hmm.json
@@ -95,8 +95,8 @@
       "name": "lime",
       "type": "git",
       "dir": null,
-      "ref": "5634ad7",
-      "url": "https://github.com/openfl/lime"
+      "ref": "2447ae6",
+      "url": "https://github.com/elitemastereric/lime"
     },
     {
       "name": "openfl",
@@ -123,4 +123,4 @@
       "version": null
     }
   ]
-}
\ No newline at end of file
+}
diff --git a/source/funkin/Conductor.hx b/source/funkin/Conductor.hx
index 7f7e2b356..5c9c23ee3 100644
--- a/source/funkin/Conductor.hx
+++ b/source/funkin/Conductor.hx
@@ -16,7 +16,11 @@ typedef BPMChangeEvent =
  */
 class Conductor
 {
-  static final STEPS_PER_BEAT:Int = 4;
+  public static final PIXELS_PER_MS:Float = 0.45;
+  public static final HIT_WINDOW_MS:Float = 160;
+  public static final SECONDS_PER_MINUTE:Float = 60;
+  public static final MILLIS_PER_SECOND:Float = 1000;
+  public static final STEPS_PER_BEAT:Int = 4;
 
   // onBeatHit is called every quarter note
   // onStepHit is called every sixteenth note
@@ -93,7 +97,7 @@ class Conductor
   static function get_beatLengthMs():Float
   {
     // Tied directly to BPM.
-    return ((60 / bpm) * 1000);
+    return ((SECONDS_PER_MINUTE / bpm) * MILLIS_PER_SECOND);
   }
 
   /**
diff --git a/source/funkin/Controls.hx b/source/funkin/Controls.hx
index 46681adbd..88b637e72 100644
--- a/source/funkin/Controls.hx
+++ b/source/funkin/Controls.hx
@@ -391,6 +391,26 @@ class Controls extends FlxActionSet
     return byName[name].check();
   }
 
+  public function getKeysForAction(name:Action):Array<FlxKey> {
+    #if debug
+    if (!byName.exists(name))
+      throw 'Invalid name: $name';
+    #end
+
+    return byName[name].inputs.map(function(input) return (input.device == KEYBOARD) ? input.inputID : null)
+      .filter(function(key) return key != null);
+  }
+
+  public function getButtonsForAction(name:Action):Array<FlxGamepadInputID> {
+    #if debug
+    if (!byName.exists(name))
+      throw 'Invalid name: $name';
+    #end
+
+    return byName[name].inputs.map(function(input) return (input.device == GAMEPAD) ? input.inputID : null)
+      .filter(function(key) return key != null);
+  }
+
   public function getDialogueName(action:FlxActionDigital):String
   {
     var input = action.inputs[0];
diff --git a/source/funkin/Highscore.hx b/source/funkin/Highscore.hx
index 904d2cb45..46e98d8dc 100644
--- a/source/funkin/Highscore.hx
+++ b/source/funkin/Highscore.hx
@@ -192,6 +192,7 @@ abstract Tallies(RawTallies)
         bad: 0,
         good: 0,
         sick: 0,
+        killer: 0,
         totalNotes: 0,
         totalNotesHit: 0,
         maxCombo: 0,
@@ -213,6 +214,7 @@ typedef RawTallies =
   var bad:Int;
   var good:Int;
   var sick:Int;
+  var killer:Int;
   var maxCombo:Int;
   var isNewHighscore:Bool;
 
diff --git a/source/funkin/LatencyState.hx b/source/funkin/LatencyState.hx
index 347454253..bd78a4298 100644
--- a/source/funkin/LatencyState.hx
+++ b/source/funkin/LatencyState.hx
@@ -2,14 +2,15 @@ package funkin;
 
 import flixel.FlxSprite;
 import flixel.FlxSubState;
-import flixel.group.FlxGroup.FlxTypedGroup;
 import flixel.group.FlxGroup;
+import flixel.group.FlxGroup.FlxTypedGroup;
 import flixel.math.FlxMath;
 import flixel.sound.FlxSound;
 import flixel.system.debug.stats.StatsGraph;
 import flixel.text.FlxText;
 import flixel.util.FlxColor;
 import funkin.audio.visualize.PolygonSpectogram;
+import funkin.play.notes.NoteSprite;
 import funkin.ui.CoolStatsGraph;
 import haxe.Timer;
 import openfl.events.KeyboardEvent;
@@ -17,7 +18,7 @@ import openfl.events.KeyboardEvent;
 class LatencyState extends MusicBeatSubState
 {
   var offsetText:FlxText;
-  var noteGrp:FlxTypedGroup<Note>;
+  var noteGrp:FlxTypedGroup<NoteSprite>;
   var strumLine:FlxSprite;
 
   var blocks:FlxTypedGroup<FlxSprite>;
@@ -74,7 +75,7 @@ class LatencyState extends MusicBeatSubState
 
     Conductor.forceBPM(60);
 
-    noteGrp = new FlxTypedGroup<Note>();
+    noteGrp = new FlxTypedGroup<NoteSprite>();
     add(noteGrp);
 
     diffGrp = new FlxTypedGroup<FlxText>();
@@ -127,7 +128,7 @@ class LatencyState extends MusicBeatSubState
 
     for (i in 0...32)
     {
-      var note:Note = new Note(Conductor.beatLengthMs * i, 1);
+      var note:NoteSprite = new NoteSprite(Conductor.beatLengthMs * i);
       noteGrp.add(note);
     }
 
@@ -246,8 +247,8 @@ class LatencyState extends MusicBeatSubState
         FlxG.resetState();
     }*/
 
-    noteGrp.forEach(function(daNote:Note) {
-      daNote.y = (strumLine.y - ((Conductor.songPosition - Conductor.audioOffset) - daNote.data.strumTime) * 0.45);
+    noteGrp.forEach(function(daNote:NoteSprite) {
+      daNote.y = (strumLine.y - ((Conductor.songPosition - Conductor.audioOffset) - daNote.noteData.time) * 0.45);
       daNote.x = strumLine.x + 30;
 
       if (daNote.y < strumLine.y) daNote.alpha = 0.5;
diff --git a/source/funkin/Note.hx b/source/funkin/Note.hx
deleted file mode 100644
index ea99449b1..000000000
--- a/source/funkin/Note.hx
+++ /dev/null
@@ -1,301 +0,0 @@
-package funkin;
-
-import funkin.play.Strumline.StrumlineArrow;
-import flixel.FlxSprite;
-import flixel.math.FlxMath;
-import funkin.noteStuff.NoteBasic.NoteData;
-import funkin.noteStuff.NoteBasic.NoteType;
-import funkin.play.PlayState;
-import funkin.play.Strumline.StrumlineStyle;
-import funkin.shaderslmfao.ColorSwap;
-import funkin.ui.PreferencesMenu;
-import funkin.util.Constants;
-
-class Note extends FlxSprite
-{
-  public var data = new NoteData();
-
-  /**
-   * code colors for.... code....
-   * i think goes in order of left to right
-   *
-   * left 	0
-   * down 	1
-   * up 		2
-   * right 	3
-   */
-  public static var codeColors:Array<Int> = [0xFFFF22AA, 0xFF00EEFF, 0xFF00CC00, 0xFFCC1111];
-
-  public var mustPress:Bool = false;
-  public var followsTime:Bool = true; // used if you want the note to follow the time shit!
-  public var canBeHit:Bool = false;
-  public var tooLate:Bool = false;
-  public var wasGoodHit:Bool = false;
-  public var prevNote:Note;
-
-  var willMiss:Bool = false;
-
-  public var invisNote:Bool = false;
-
-  public var isSustainNote:Bool = false;
-
-  public var colorSwap:ColorSwap;
-
-  /** the lowercase name of the note, for anim control, i.e. left right up down */
-  public var dirName(get, never):String;
-
-  inline function get_dirName()
-    return data.dirName;
-
-  /** the uppercase name of the note, for anim control, i.e. left right up down */
-  public var dirNameUpper(get, never):String;
-
-  inline function get_dirNameUpper()
-    return data.dirNameUpper;
-
-  /** the lowercase name of the note's color, for anim control, i.e. purple blue green red */
-  public var colorName(get, never):String;
-
-  inline function get_colorName()
-    return data.colorName;
-
-  /** the lowercase name of the note's color, for anim control, i.e. purple blue green red */
-  public var colorNameUpper(get, never):String;
-
-  inline function get_colorNameUpper()
-    return data.colorNameUpper;
-
-  public var highStakes(get, never):Bool;
-
-  inline function get_highStakes()
-    return data.highStakes;
-
-  public var lowStakes(get, never):Bool;
-
-  inline function get_lowStakes()
-    return data.lowStakes;
-
-  public static var swagWidth:Float = 160 * 0.7;
-  public static var PURP_NOTE:Int = 0;
-  public static var GREEN_NOTE:Int = 2;
-  public static var BLUE_NOTE:Int = 1;
-  public static var RED_NOTE:Int = 3;
-
-  // SCORING STUFF
-  public static var HIT_WINDOW:Float = (10 / 60) * 1000; // 166.67 ms hit window (10 frames at 60fps)
-  // thresholds are fractions of HIT_WINDOW ^^
-  // anything above bad threshold is shit
-  public static var BAD_THRESHOLD:Float = 0.8; // 	125ms	, 8 frames
-  public static var GOOD_THRESHOLD:Float = 0.55; // 	91.67ms	, 5.5 frames
-  public static var SICK_THRESHOLD:Float = 0.2; // 	33.33ms	, 2 frames
-
-  public var noteSpeedMulti:Float = 1;
-  public var pastHalfWay:Bool = false;
-
-  // anything below sick threshold is sick
-  public static var arrowColors:Array<Float> = [1, 1, 1, 1];
-
-  // Which note asset to load?
-  public var style:StrumlineStyle = NORMAL;
-
-  public function new(strumTime:Float = 0, noteData:NoteType, ?prevNote:Note, ?sustainNote:Bool = false, ?style:StrumlineStyle = NORMAL)
-  {
-    super();
-
-    if (prevNote == null) prevNote = this;
-
-    this.prevNote = prevNote;
-    isSustainNote = sustainNote;
-
-    x += 50;
-    // MAKE SURE ITS DEFINITELY OFF SCREEN?
-    y -= 2000;
-    data.strumTime = strumTime;
-
-    data.noteData = noteData;
-
-    this.style = style;
-
-    if (this.style == null) this.style = StrumlineStyle.NORMAL;
-
-    // TODO: Make this logic more generic
-    switch (this.style)
-    {
-      case PIXEL:
-        loadGraphic(Paths.image('weeb/pixelUI/arrows-pixels'), true, 17, 17);
-
-        animation.add('greenScroll', [6]);
-        animation.add('redScroll', [7]);
-        animation.add('blueScroll', [5]);
-        animation.add('purpleScroll', [4]);
-
-        if (isSustainNote)
-        {
-          loadGraphic(Paths.image('weeb/pixelUI/arrowEnds'), true, 7, 6);
-
-          animation.add('purpleholdend', [4]);
-          animation.add('greenholdend', [6]);
-          animation.add('redholdend', [7]);
-          animation.add('blueholdend', [5]);
-
-          animation.add('purplehold', [0]);
-          animation.add('greenhold', [2]);
-          animation.add('redhold', [3]);
-          animation.add('bluehold', [1]);
-        }
-
-        setGraphicSize(Std.int(width * Constants.PIXEL_ART_SCALE));
-        updateHitbox();
-
-      default:
-        frames = Paths.getSparrowAtlas('NOTE_assets');
-
-        animation.addByPrefix('purpleScroll', 'purple instance');
-        animation.addByPrefix('blueScroll', 'blue instance');
-        animation.addByPrefix('greenScroll', 'green instance');
-        animation.addByPrefix('redScroll', 'red instance');
-
-        animation.addByPrefix('purpleholdend', 'pruple end hold');
-        animation.addByPrefix('greenholdend', 'green hold end');
-        animation.addByPrefix('redholdend', 'red hold end');
-        animation.addByPrefix('blueholdend', 'blue hold end');
-
-        animation.addByPrefix('purplehold', 'purple hold piece');
-        animation.addByPrefix('greenhold', 'green hold piece');
-        animation.addByPrefix('redhold', 'red hold piece');
-        animation.addByPrefix('bluehold', 'blue hold piece');
-
-        setGraphicSize(Std.int(width * 0.7));
-        updateHitbox();
-        antialiasing = true;
-
-        // colorSwap.colorToReplace = 0xFFF9393F;
-        // colorSwap.newColor = 0xFF00FF00;
-
-        // color = FlxG.random.color();
-        // color.saturation *= 4;
-        // replaceColor(0xFFC1C1C1, FlxColor.RED);
-    }
-
-    colorSwap = new ColorSwap();
-    shader = colorSwap.shader;
-    updateColors();
-
-    x += swagWidth * data.int;
-    animation.play(data.colorName + 'Scroll');
-
-    // trace(prevNote);
-
-    if (isSustainNote && prevNote != null)
-    {
-      alpha = 0.6;
-
-      if (PreferencesMenu.getPref('downscroll')) angle = 180;
-
-      x += width / 2;
-
-      animation.play(data.colorName + 'holdend');
-
-      updateHitbox();
-
-      x -= width / 2;
-
-      if (PlayState.instance.currentStageId.startsWith('school')) x += 30;
-
-      if (prevNote.isSustainNote)
-      {
-        prevNote.animation.play(prevNote.colorName + 'hold');
-        prevNote.updateHitbox();
-
-        var scaleThing:Float = Math.round((Conductor.stepLengthMs) * (0.45 * FlxMath.roundDecimal(PlayState.instance.currentChart.scrollSpeed, 2)));
-        // get them a LIL closer together cuz the antialiasing blurs the edges
-        if (antialiasing) scaleThing *= 1.0 + (1.0 / prevNote.frameHeight);
-        prevNote.scale.y = scaleThing / prevNote.frameHeight;
-        prevNote.updateHitbox();
-      }
-    }
-  }
-
-  public function alignToSturmlineArrow(arrow:StrumlineArrow):Void
-  {
-    x = arrow.x;
-
-    if (isSustainNote && prevNote != null)
-    {
-      if (prevNote.isSustainNote)
-      {
-        x = prevNote.x;
-      }
-      else
-      {
-        x += prevNote.width / 2;
-        x -= width / 2;
-      }
-    }
-  }
-
-  override function destroy()
-  {
-    prevNote = null;
-
-    super.destroy();
-  }
-
-  public function updateColors():Void
-  {
-    colorSwap.update(arrowColors[data.noteData]);
-  }
-
-  override function update(elapsed:Float)
-  {
-    super.update(elapsed);
-
-    // mustPress indicates the player is the one pressing the key
-    if (mustPress)
-    {
-      // miss on the NEXT frame so lag doesnt make u miss notes
-      if (willMiss && !wasGoodHit)
-      {
-        tooLate = true;
-        canBeHit = false;
-      }
-      else
-      {
-        if (!pastHalfWay && data.strumTime <= Conductor.songPosition)
-        {
-          pastHalfWay = true;
-          noteSpeedMulti *= 2;
-        }
-
-        if (data.strumTime > Conductor.songPosition - HIT_WINDOW)
-        {
-          // * 0.5 if sustain note, so u have to keep holding it closer to all the way thru!
-          if (data.strumTime < Conductor.songPosition + (HIT_WINDOW * (isSustainNote ? 0.5 : 1))) canBeHit = true;
-        }
-        else
-        {
-          canBeHit = true;
-          willMiss = true;
-        }
-      }
-    }
-    else
-    {
-      canBeHit = false;
-
-      if (data.strumTime <= Conductor.songPosition) wasGoodHit = true;
-    }
-
-    if (tooLate)
-    {
-      if (alpha > 0.3) alpha = 0.3;
-    }
-  }
-
-  static public function fromData(data:NoteData, prevNote:Note, isSustainNote = false)
-  {
-    var result = new Note(data.strumTime, data.noteData, prevNote, isSustainNote);
-    result.data = data;
-    return result;
-  }
-}
diff --git a/source/funkin/Paths.hx b/source/funkin/Paths.hx
index 60dcfad38..3943d84ee 100644
--- a/source/funkin/Paths.hx
+++ b/source/funkin/Paths.hx
@@ -96,14 +96,14 @@ class Paths
     return getPath('music/$key.$SOUND_EXT', MUSIC, library);
   }
 
-  inline static public function voices(song:String, ?suffix:String)
+  inline static public function voices(song:String, ?suffix:String = '')
   {
     if (suffix == null) suffix = ""; // no suffix, for a sorta backwards compatibility with older-ish voice files
 
     return 'songs:assets/songs/${song.toLowerCase()}/Voices$suffix.$SOUND_EXT';
   }
 
-  inline static public function inst(song:String, ?suffix:String)
+  inline static public function inst(song:String, ?suffix:String = '')
   {
     return 'songs:assets/songs/${song.toLowerCase()}/Inst$suffix.$SOUND_EXT';
   }
diff --git a/source/funkin/PlayerSettings.hx b/source/funkin/PlayerSettings.hx
index b9ad87a93..1b64d26c2 100644
--- a/source/funkin/PlayerSettings.hx
+++ b/source/funkin/PlayerSettings.hx
@@ -2,6 +2,7 @@ package funkin;
 
 import funkin.Controls;
 import flixel.FlxCamera;
+import funkin.input.PreciseInputManager;
 import flixel.input.actions.FlxActionInput;
 import flixel.input.gamepad.FlxGamepad;
 import flixel.util.FlxSignal;
@@ -52,6 +53,9 @@ class PlayerSettings
     }
 
     if (useDefault) controls.setKeyboardScheme(Solo);
+
+    // Apply loaded settings.
+    PreciseInputManager.instance.initializeKeys(controls);
   }
 
   function addGamepad(gamepad:FlxGamepad)
diff --git a/source/funkin/Section.hx b/source/funkin/Section.hx
deleted file mode 100644
index f239baaad..000000000
--- a/source/funkin/Section.hx
+++ /dev/null
@@ -1,33 +0,0 @@
-package funkin;
-
-import funkin.noteStuff.NoteBasic.NoteData;
-
-typedef SwagSection =
-{
-  var sectionNotes:Array<NoteData>;
-  var lengthInSteps:Int;
-  var typeOfSection:Int;
-  var mustHitSection:Bool;
-  var bpm:Float;
-  var changeBPM:Bool;
-  var altAnim:Bool;
-}
-
-class Section
-{
-  public var sectionNotes:Array<Dynamic> = [];
-
-  public var lengthInSteps:Int = 16;
-  public var typeOfSection:Int = 0;
-  public var mustHitSection:Bool = true;
-
-  /**
-   *	Copies the first section into the second section!
-   */
-  public static var COPYCAT:Int = 0;
-
-  public function new(lengthInSteps:Int = 16)
-  {
-    this.lengthInSteps = lengthInSteps;
-  }
-}
diff --git a/source/funkin/SongLoad.hx b/source/funkin/SongLoad.hx
deleted file mode 100644
index ca3bc72d0..000000000
--- a/source/funkin/SongLoad.hx
+++ /dev/null
@@ -1,325 +0,0 @@
-package funkin;
-
-import funkin.Section.SwagSection;
-import funkin.noteStuff.NoteBasic.NoteData;
-import funkin.play.PlayState;
-import haxe.Json;
-import lime.utils.Assets;
-
-typedef SwagSong =
-{
-  var song:String;
-  var notes:FunnyNotes;
-  var difficulties:Array<String>;
-  var noteMap:Map<String, Array<SwagSection>>;
-  var bpm:Float;
-  var needsVoices:Bool;
-  var voiceList:Array<String>;
-  var speed:FunnySpeed;
-  var speedMap:Map<String, Float>;
-
-  var player1:String;
-  var player2:String;
-  var validScore:Bool;
-  var extraNotes:Map<String, Array<SwagSection>>;
-}
-
-typedef FunnySpeed =
-{
-  var ?easy:Float;
-  var ?normal:Float;
-  var ?hard:Float;
-}
-
-typedef FunnyNotes =
-{
-  var ?easy:Array<SwagSection>;
-  var ?normal:Array<SwagSection>;
-  var ?hard:Array<SwagSection>;
-}
-
-class SongLoad
-{
-  public static var curDiff:String = 'normal';
-  public static var curNotes:Array<SwagSection>;
-  public static var songData:SwagSong;
-
-  public static function loadFromJson(jsonInput:String, ?folder:String):SwagSong
-  {
-    var rawJson:String = null;
-    try
-    {
-      rawJson = Assets.getText(Paths.json('songs/${folder.toLowerCase()}/${jsonInput.toLowerCase()}')).trim();
-    }
-    catch (e)
-    {
-      trace('Failed to load song data: ${e}');
-      rawJson = null;
-    }
-
-    if (rawJson == null)
-    {
-      return null;
-    }
-
-    while (!rawJson.endsWith("}"))
-    {
-      rawJson = rawJson.substr(0, rawJson.length - 1);
-    }
-
-    return parseJSONshit(rawJson);
-  }
-
-  public static function getSong(?diff:String):Array<SwagSection>
-  {
-    if (diff == null) diff = SongLoad.curDiff;
-
-    var songShit:Array<SwagSection> = [];
-
-    // THIS IS OVERWRITTEN, WILL BE DEPRECTATED AND REPLACED SOOOOON
-    if (songData != null)
-    {
-      switch (diff)
-      {
-        case 'easy':
-          songShit = songData.notes.easy;
-        case 'normal':
-          songShit = songData.notes.normal;
-        case 'hard':
-          songShit = songData.notes.hard;
-      }
-    }
-
-    checkAndCreateNotemap(curDiff);
-
-    songShit = songData.noteMap[diff];
-
-    return songShit;
-  }
-
-  public static function checkAndCreateNotemap(diff:String):Void
-  {
-    if (songData == null || songData.noteMap == null) return;
-    if (songData.noteMap[diff] == null) songData.noteMap[diff] = [];
-  }
-
-  public static function getSpeed(?diff:String):Float
-  {
-    if (PlayState.instance != null && PlayState.instance.currentChart != null)
-    {
-      return getSpeed_NEW(diff);
-    }
-
-    if (diff == null) diff = SongLoad.curDiff;
-
-    var speedShit:Float = 1;
-
-    // all this shit is overridden by the thing that loads it from speedMap Map object!!!
-    // replace and delete later!
-    switch (diff)
-    {
-      case 'easy':
-        speedShit = songData?.speed?.easy ?? 1.0;
-      case 'normal':
-        speedShit = songData?.speed?.normal ?? 1.0;
-      case 'hard':
-        speedShit = songData?.speed?.hard ?? 1.0;
-    }
-
-    if (songData?.speedMap == null || songData?.speedMap[diff] == null)
-    {
-      speedShit = 1;
-    }
-    else
-    {
-      speedShit = songData.speedMap[diff];
-    }
-
-    return speedShit;
-  }
-
-  public static function getSpeed_NEW(?diff:String):Float
-  {
-    if (PlayState.instance == null
-      || PlayState.instance.currentChart == null
-      || PlayState.instance.currentChart.scrollSpeed == 0.0) return 1.0;
-
-    return PlayState.instance.currentChart.scrollSpeed;
-  }
-
-  public static function getDefaultSwagSong():SwagSong
-  {
-    return {
-      song: 'Test',
-      notes: {easy: [], normal: [], hard: []},
-      difficulties: ["easy", "normal", "hard"],
-      noteMap: new Map(),
-      speedMap: new Map(),
-      bpm: 150,
-      needsVoices: true,
-      player1: 'bf',
-      player2: 'dad',
-      speed:
-        {
-          easy: 1,
-          normal: 1,
-          hard: 1
-        },
-      validScore: false,
-      voiceList: ["BF", "BF-pixel"],
-      extraNotes: []
-    };
-  }
-
-  public static function getDefaultNoteData():NoteData
-  {
-    return new NoteData();
-  }
-
-  /**
-   *	Casts the an array to NOTE data (for LOADING shit from json usually)
-   */
-  public static function castArrayToNoteData(noteStuff:Array<SwagSection>)
-  {
-    if (noteStuff == null) return;
-
-    for (sectionIndex => section in noteStuff)
-    {
-      if (section == null || section.sectionNotes == null) continue;
-      for (noteIndex => noteDataArray in section.sectionNotes)
-      {
-        var arrayDipshit:Array<Dynamic> = cast noteDataArray; // crackhead
-
-        if (arrayDipshit != null) // array isnt null, that means it loaded it as an array and needs to be manually parsed?
-        {
-          // at this point noteStuff[sectionIndex].sectionNotes[noteIndex] is an array because of the cast from the first line in this function
-          // so this line right here turns it back into the NoteData typedef type because of another bastard cast
-          noteStuff[sectionIndex].sectionNotes[noteIndex] = cast SongLoad.getDefaultNoteData(); // turn it from an array (because of the cast), back to noteData? yeah that works
-
-          noteStuff[sectionIndex].sectionNotes[noteIndex].strumTime = arrayDipshit[0];
-          noteStuff[sectionIndex].sectionNotes[noteIndex].noteData = arrayDipshit[1];
-          noteStuff[sectionIndex].sectionNotes[noteIndex].sustainLength = arrayDipshit[2];
-          if (arrayDipshit.length > 3)
-          {
-            noteStuff[sectionIndex].sectionNotes[noteIndex].noteKind = arrayDipshit[3];
-          }
-        }
-        else if (noteDataArray != null)
-        {
-          // array is NULL, so it checks if noteDataArray (doesnt exactly NEED to be an 'array' is also null or not.)
-          // At this point it should be an OBJECT that can be easily casted!!!
-
-          noteStuff[sectionIndex].sectionNotes[noteIndex] = cast noteDataArray;
-        }
-        else
-          throw "shit brokey"; // i actually dont know how throw works lol
-      }
-    }
-  }
-
-  /**
-   * Cast notedata to ARRAY (usually used for level SAVING)
-   */
-  public static function castNoteDataToArray(noteStuff:Array<SwagSection>)
-  {
-    if (noteStuff == null) return;
-
-    for (sectionIndex => section in noteStuff)
-    {
-      for (noteIndex => noteTypeDefShit in section.sectionNotes)
-      {
-        var dipshitArray:Array<Dynamic> = [
-          noteTypeDefShit.strumTime,
-          noteTypeDefShit.noteData,
-          noteTypeDefShit.sustainLength,
-          noteTypeDefShit.noteKind
-        ];
-
-        noteStuff[sectionIndex].sectionNotes[noteIndex] = cast dipshitArray;
-      }
-    }
-  }
-
-  public static function castNoteDataToNoteData(noteStuff:Array<SwagSection>)
-  {
-    if (noteStuff == null) return;
-
-    for (sectionIndex => section in noteStuff)
-    {
-      for (noteIndex => noteTypedefShit in section.sectionNotes)
-      {
-        trace(noteTypedefShit);
-        noteStuff[sectionIndex].sectionNotes[noteIndex] = noteTypedefShit;
-      }
-    }
-  }
-
-  public static function parseJSONshit(rawJson:String):SwagSong
-  {
-    var songParsed:Dynamic;
-    try
-    {
-      songParsed = Json.parse(rawJson);
-    }
-    catch (e)
-    {
-      FlxG.log.warn("Error parsing JSON: " + e.message);
-      trace("Error parsing JSON: " + e.message);
-      return null;
-    }
-
-    var swagShit:SwagSong = cast songParsed.song;
-    swagShit.difficulties = []; // reset it to default before load
-    swagShit.noteMap = new Map();
-    swagShit.speedMap = new Map();
-    for (diff in Reflect.fields(songParsed.song.notes))
-    {
-      swagShit.difficulties.push(diff);
-      swagShit.noteMap[diff] = cast Reflect.field(songParsed.song.notes, diff);
-
-      castArrayToNoteData(swagShit.noteMap[diff]);
-
-      // castNoteDataToNoteData(swagShit.noteMap[diff]);
-
-      /*
-        switch (diff)
-        {
-          case "easy":
-            castArrayToNoteData(swagShit.notes.hard);
-
-          case "normal":
-            castArrayToNoteData(swagShit.notes.normal);
-          case "hard":
-            castArrayToNoteData(swagShit.notes.hard);
-        }
-       */
-    }
-
-    for (diff in swagShit.difficulties)
-    {
-      swagShit.speedMap[diff] = cast Reflect.field(songParsed.song.speed, diff);
-    }
-
-    // trace(swagShit.noteMap.toString());
-    // trace(swagShit.speedMap.toString());
-    // trace('that was just notemap string lol');
-
-    swagShit.validScore = true;
-
-    trace("SONG SHIT ABOUTTA WEEK AGOOO");
-    for (field in Reflect.fields(Json.parse(rawJson).song.speed))
-    {
-      // swagShit.speed[field] = Reflect.field(Json.parse(rawJson).song.speed, field);
-      // swagShit.notes[field] = Reflect.field(Json.parse(rawJson).song.notes, field);
-      // trace(swagShit.notes[field]);
-    }
-
-    // swagShit.notes = cast Json.parse(rawJson).song.notes[SongLoad.curDiff]; // by default uses
-
-    trace('THAT SHIT WAS JUST THE NORMAL NOTES!!!');
-    songData = swagShit;
-    // curNotes = songData.notes.get('normal');
-
-    return swagShit;
-  }
-}
diff --git a/source/funkin/import.hx b/source/funkin/import.hx
index f54ccea86..9aa99fade 100644
--- a/source/funkin/import.hx
+++ b/source/funkin/import.hx
@@ -9,6 +9,7 @@ import flixel.FlxG; // This one in particular causes a compile error if you're u
 using Lambda;
 using StringTools;
 using funkin.util.tools.ArrayTools;
+using funkin.util.tools.ArraySortTools;
 using funkin.util.tools.IteratorTools;
 using funkin.util.tools.MapTools;
 using funkin.util.tools.StringTools;
diff --git a/source/funkin/input/PreciseInputManager.hx b/source/funkin/input/PreciseInputManager.hx
new file mode 100644
index 000000000..11a3c2007
--- /dev/null
+++ b/source/funkin/input/PreciseInputManager.hx
@@ -0,0 +1,303 @@
+package funkin.input;
+
+import openfl.ui.Keyboard;
+import funkin.play.notes.NoteDirection;
+import flixel.input.keyboard.FlxKeyboard.FlxKeyInput;
+import openfl.events.KeyboardEvent;
+import flixel.FlxG;
+import flixel.input.FlxInput.FlxInputState;
+import flixel.input.FlxKeyManager;
+import flixel.input.keyboard.FlxKey;
+import flixel.input.keyboard.FlxKeyList;
+import flixel.util.FlxSignal.FlxTypedSignal;
+import haxe.Int64;
+import lime.ui.KeyCode;
+import lime.ui.KeyModifier;
+
+/**
+ * A precise input manager that:
+ * - Records the exact timestamp of when a key was pressed or released
+ * - Only records key presses for keys bound to game inputs (up/down/left/right)
+ */
+class PreciseInputManager extends FlxKeyManager<FlxKey, PreciseInputList>
+{
+  public static var instance(get, null):PreciseInputManager;
+
+  static function get_instance():PreciseInputManager
+  {
+    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>;
+  public var onInputReleased:FlxTypedSignal<PreciseInputEvent->Void>;
+
+  /**
+   * The list of keys that are bound to game inputs (up/down/left/right).
+   */
+  var _keyList:Array<FlxKey>;
+
+  /**
+   * The direction that a given key is bound to.
+   */
+  var _keyListDir:Map<FlxKey, NoteDirection>;
+
+  /**
+   * The timestamp at which a given note direction was last pressed.
+   */
+  var _dirPressTimestamps:Map<NoteDirection, Int64>;
+
+  /**
+   * The timestamp at which a given note direction was last released.
+   */
+  var _dirReleaseTimestamps:Map<NoteDirection, Int64>;
+
+  public function new()
+  {
+    super(PreciseInputList.new);
+
+    _keyList = [];
+    _dirPressTimestamps = new Map<NoteDirection, Int64>();
+    _dirReleaseTimestamps = new Map<NoteDirection, Int64>();
+    _keyListDir = new Map<FlxKey, NoteDirection>();
+
+    FlxG.stage.removeEventListener(KeyboardEvent.KEY_DOWN, onKeyDown);
+    FlxG.stage.removeEventListener(KeyboardEvent.KEY_UP, onKeyUp);
+    FlxG.stage.application.window.onKeyDownPrecise.add(handleKeyDown);
+    FlxG.stage.application.window.onKeyUpPrecise.add(handleKeyUp);
+
+    preventDefaultKeys = getPreventDefaultKeys();
+
+    onInputPressed = new FlxTypedSignal<PreciseInputEvent->Void>();
+    onInputReleased = new FlxTypedSignal<PreciseInputEvent->Void>();
+  }
+
+  public static function getKeysForDirection(controls:Controls, noteDirection:NoteDirection)
+  {
+    return switch (noteDirection)
+    {
+      case NoteDirection.LEFT: controls.getKeysForAction(NOTE_LEFT);
+      case NoteDirection.DOWN: controls.getKeysForAction(NOTE_DOWN);
+      case NoteDirection.UP: controls.getKeysForAction(NOTE_UP);
+      case NoteDirection.RIGHT: controls.getKeysForAction(NOTE_RIGHT);
+    };
+  }
+
+  /**
+   * Returns a precise timestamp, measured in nanoseconds.
+   * Timestamp is only useful for comparing against other timestamps.
+   *
+   * @return Int64
+   */
+  @:access(lime._internal.backend.native.NativeCFFI)
+  public static function getCurrentTimestamp():Int64
+  {
+    #if html5
+    // 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;
+    #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;
+    #else
+    throw "Eric didn't implement precise timestamps on this platform!";
+    #end
+  }
+
+  static function getPreventDefaultKeys():Array<FlxKey>
+  {
+    return [];
+  }
+
+  /**
+   * Call this whenever the user's inputs change.
+   */
+  public function initializeKeys(controls:Controls):Void
+  {
+    clearKeys();
+
+    for (noteDirection in DIRECTIONS)
+    {
+      var keys = getKeysForDirection(controls, noteDirection);
+      for (key in keys)
+      {
+        var input = new FlxKeyInput(key);
+        _keyList.push(key);
+        _keyListArray.push(input);
+        _keyListMap.set(key, input);
+        _keyListDir.set(key, noteDirection);
+      }
+    }
+  }
+
+  /**
+   * Get the time, in nanoseconds, since the given note direction was last pressed.
+   * @param noteDirection The note direction to check.
+   * @return An Int64 representing the time since the given note direction was last pressed.
+   */
+  public function getTimeSincePressed(noteDirection:NoteDirection):Int64
+  {
+    return getCurrentTimestamp() - _dirPressTimestamps.get(noteDirection);
+  }
+
+  /**
+   * Get the time, in nanoseconds, since the given note direction was last released.
+   * @param noteDirection The note direction to check.
+   * @return An Int64 representing the time since the given note direction was last released.
+   */
+  public function getTimeSinceReleased(noteDirection:NoteDirection):Int64
+  {
+    return getCurrentTimestamp() - _dirReleaseTimestamps.get(noteDirection);
+  }
+
+  // TODO: Why doesn't this work?
+  // @:allow(funkin.input.PreciseInputManager.PreciseInputList)
+  public function getInputByKey(key:FlxKey):FlxKeyInput
+  {
+    return _keyListMap.get(key);
+  }
+
+  public function getDirectionForKey(key:FlxKey):NoteDirection
+  {
+    return _keyListDir.get(key);
+  }
+
+  function handleKeyDown(keyCode:KeyCode, _:KeyModifier, timestamp:Int64):Void
+  {
+    var key:FlxKey = convertKeyCode(keyCode);
+    if (_keyList.indexOf(key) == -1) return;
+
+    // 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;
+
+    updateKeyStates(key, true);
+
+    if (getInputByKey(key) ?.justPressed ?? false)
+    {
+      onInputPressed.dispatch(
+        {
+          noteDirection: getDirectionForKey(key),
+          timestamp: timestamp
+        });
+      _dirPressTimestamps.set(getDirectionForKey(key), timestamp);
+    }
+  }
+
+  function handleKeyUp(keyCode:KeyCode, _:KeyModifier, timestamp:Int64):Void
+  {
+    var key:FlxKey = convertKeyCode(keyCode);
+    if (_keyList.indexOf(key) == -1) return;
+
+    // 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;
+
+    updateKeyStates(key, false);
+
+    if (getInputByKey(key) ?.justReleased ?? false)
+    {
+      onInputReleased.dispatch(
+        {
+          noteDirection: getDirectionForKey(key),
+          timestamp: timestamp
+        });
+      _dirReleaseTimestamps.set(getDirectionForKey(key), timestamp);
+    }
+  }
+
+  static function convertKeyCode(input:KeyCode):FlxKey
+  {
+    @:privateAccess
+    {
+      return Keyboard.__convertKeyCode(input);
+    }
+  }
+
+  function clearKeys():Void
+  {
+    _keyListArray = [];
+    _keyListMap.clear();
+    _keyListDir.clear();
+  }
+}
+
+class PreciseInputList extends FlxKeyList
+{
+  var _preciseInputManager:PreciseInputManager;
+
+  public function new(state:FlxInputState, preciseInputManager:FlxKeyManager<Dynamic, Dynamic>)
+  {
+    super(state, preciseInputManager);
+
+    _preciseInputManager = cast preciseInputManager;
+  }
+
+  static function getKeysForDir(noteDir:NoteDirection):Array<FlxKey>
+  {
+    return PreciseInputManager.getKeysForDirection(PlayerSettings.player1.controls, noteDir);
+  }
+
+  function isKeyValid(key:FlxKey):Bool
+  {
+    @:privateAccess
+    {
+      return _preciseInputManager._keyListMap.exists(key);
+    }
+  }
+
+  public function checkFlxKey(key:FlxKey):Bool
+  {
+    if (isKeyValid(key)) return check(cast key);
+    return false;
+  }
+
+  public function checkDir(noteDir:NoteDirection):Bool
+  {
+    for (key in getKeysForDir(noteDir))
+    {
+      if (check(_preciseInputManager.getInputByKey(key) ?.ID)) return true;
+    }
+    return false;
+  }
+
+  public var NOTE_LEFT(get, never):Bool;
+
+  function get_NOTE_LEFT():Bool
+    return checkDir(NoteDirection.LEFT);
+
+  public var NOTE_DOWN(get, never):Bool;
+
+  function get_NOTE_DOWN():Bool
+    return checkDir(NoteDirection.DOWN);
+
+  public var NOTE_UP(get, never):Bool;
+
+  function get_NOTE_UP():Bool
+    return checkDir(NoteDirection.UP);
+
+  public var NOTE_RIGHT(get, never):Bool;
+
+  function get_NOTE_RIGHT():Bool
+    return checkDir(NoteDirection.RIGHT);
+}
+
+typedef PreciseInputEvent =
+{
+  /**
+   * The direction of the input.
+   */
+  noteDirection:NoteDirection,
+
+  /**
+   * The timestamp of the input. Measured in nanoseconds.
+   */
+  timestamp:Int64,
+};
diff --git a/source/funkin/modding/events/ScriptEvent.hx b/source/funkin/modding/events/ScriptEvent.hx
index 95922ded1..3f29ad833 100644
--- a/source/funkin/modding/events/ScriptEvent.hx
+++ b/source/funkin/modding/events/ScriptEvent.hx
@@ -1,10 +1,12 @@
 package funkin.modding.events;
 
+import funkin.play.song.SongData.SongNoteData;
 import flixel.FlxState;
 import flixel.FlxSubState;
-import funkin.noteStuff.NoteBasic.NoteDir;
+import funkin.play.notes.NoteSprite;
 import funkin.play.cutscene.dialogue.Conversation;
 import funkin.play.Countdown.CountdownStep;
+import funkin.play.notes.NoteDirection;
 import openfl.events.EventType;
 import openfl.events.KeyboardEvent;
 
@@ -344,7 +346,7 @@ class NoteScriptEvent extends ScriptEvent
    * The note associated with this event.
    * You cannot replace it, but you can edit it.
    */
-  public var note(default, null):Note;
+  public var note(default, null):NoteSprite;
 
   /**
    * The combo count as it is with this event.
@@ -357,7 +359,7 @@ class NoteScriptEvent extends ScriptEvent
    */
   public var playSound(default, default):Bool;
 
-  public function new(type:ScriptEventType, note:Note, comboCount:Int = 0, cancelable:Bool = false):Void
+  public function new(type:ScriptEventType, note:NoteSprite, comboCount:Int = 0, cancelable:Bool = false):Void
   {
     super(type, cancelable);
     this.note = note;
@@ -379,7 +381,7 @@ class GhostMissNoteScriptEvent extends ScriptEvent
   /**
    * The direction that was mistakenly pressed.
    */
-  public var dir(default, null):NoteDir;
+  public var dir(default, null):NoteDirection;
 
   /**
    * Whether there was a note within judgement range when this ghost note was pressed.
@@ -407,7 +409,7 @@ class GhostMissNoteScriptEvent extends ScriptEvent
    */
   public var playAnim(default, default):Bool;
 
-  public function new(dir:NoteDir, hasPossibleNotes:Bool, healthChange:Float, scoreChange:Int):Void
+  public function new(dir:NoteDirection, hasPossibleNotes:Bool, healthChange:Float, scoreChange:Int):Void
   {
     super(ScriptEvent.NOTE_GHOST_MISS, true);
     this.dir = dir;
@@ -575,19 +577,19 @@ class SongLoadScriptEvent extends ScriptEvent
    * The note associated with this event.
    * You cannot replace it, but you can edit it.
    */
-  public var notes(default, set):Array<Note>;
+  public var notes(default, set):Array<SongNoteData>;
 
   public var id(default, null):String;
 
   public var difficulty(default, null):String;
 
-  function set_notes(notes:Array<Note>):Array<Note>
+  function set_notes(notes:Array<SongNoteData>):Array<SongNoteData>
   {
     this.notes = notes;
     return this.notes;
   }
 
-  public function new(id:String, difficulty:String, notes:Array<Note>):Void
+  public function new(id:String, difficulty:String, notes:Array<SongNoteData>):Void
   {
     super(ScriptEvent.SONG_LOADED, false);
     this.id = id;
diff --git a/source/funkin/noteStuff/NoteBasic.hx b/source/funkin/noteStuff/NoteBasic.hx
deleted file mode 100644
index c1900710f..000000000
--- a/source/funkin/noteStuff/NoteBasic.hx
+++ /dev/null
@@ -1,197 +0,0 @@
-package funkin.noteStuff;
-
-import flixel.FlxSprite;
-import flixel.text.FlxText;
-
-typedef RawNoteData =
-{
-  var strumTime:Float;
-  var noteData:NoteType;
-  var sustainLength:Float;
-  var altNote:String;
-  var noteKind:NoteKind;
-}
-
-@:forward
-abstract NoteData(RawNoteData)
-{
-  public function new(strumTime = 0.0, noteData:NoteType = 0, sustainLength = 0.0, altNote = "", noteKind = NORMAL)
-  {
-    this =
-      {
-        strumTime: strumTime,
-        noteData: noteData,
-        sustainLength: sustainLength,
-        altNote: altNote,
-        noteKind: noteKind
-      }
-  }
-
-  public var note(get, never):NoteType;
-
-  inline function get_note()
-    return this.noteData.value;
-
-  public var int(get, never):Int;
-
-  inline function get_int()
-    return this.noteData.int;
-
-  public var dir(get, never):NoteDir;
-
-  inline function get_dir()
-    return this.noteData.value;
-
-  public var dirName(get, never):String;
-
-  inline function get_dirName()
-    return dir.name;
-
-  public var dirNameUpper(get, never):String;
-
-  inline function get_dirNameUpper()
-    return dir.nameUpper;
-
-  public var color(get, never):NoteColor;
-
-  inline function get_color()
-    return this.noteData.value;
-
-  public var colorName(get, never):String;
-
-  inline function get_colorName()
-    return color.name;
-
-  public var colorNameUpper(get, never):String;
-
-  inline function get_colorNameUpper()
-    return color.nameUpper;
-
-  public var highStakes(get, never):Bool;
-
-  inline function get_highStakes()
-    return this.noteData.highStakes;
-
-  public var lowStakes(get, never):Bool;
-
-  inline function get_lowStakes()
-    return this.noteData.lowStakes;
-}
-
-enum abstract NoteType(Int) from Int to Int
-{
-  // public var raw(get, never):Int;
-  // inline function get_raw() return this;
-  public var int(get, never):Int;
-
-  inline function get_int()
-    return this < 0 ? -this : this % 4;
-
-  public var value(get, never):NoteType;
-
-  inline function get_value()
-    return int;
-
-  public var highStakes(get, never):Bool;
-
-  inline function get_highStakes()
-    return this > 3;
-
-  public var lowStakes(get, never):Bool;
-
-  inline function get_lowStakes()
-    return this < 0;
-}
-
-@:forward
-enum abstract NoteDir(NoteType) from Int to Int from NoteType
-{
-  var LEFT = 0;
-  var DOWN = 1;
-  var UP = 2;
-  var RIGHT = 3;
-  var value(get, never):NoteDir;
-
-  inline function get_value()
-    return this.value;
-
-  public var name(get, never):String;
-
-  function get_name()
-  {
-    return switch (value)
-    {
-      case LEFT: "left";
-      case DOWN: "down";
-      case UP: "up";
-      case RIGHT: "right";
-    }
-  }
-
-  public var nameUpper(get, never):String;
-
-  function get_nameUpper()
-  {
-    return switch (value)
-    {
-      case LEFT: "LEFT";
-      case DOWN: "DOWN";
-      case UP: "UP";
-      case RIGHT: "RIGHT";
-    }
-  }
-}
-
-@:forward
-enum abstract NoteColor(NoteType) from Int to Int from NoteType
-{
-  var PURPLE = 0;
-  var BLUE = 1;
-  var GREEN = 2;
-  var RED = 3;
-  var value(get, never):NoteColor;
-
-  inline function get_value()
-    return this.value;
-
-  public var name(get, never):String;
-
-  function get_name()
-  {
-    return switch (value)
-    {
-      case PURPLE: "purple";
-      case BLUE: "blue";
-      case GREEN: "green";
-      case RED: "red";
-    }
-  }
-
-  public var nameUpper(get, never):String;
-
-  function get_nameUpper()
-  {
-    return switch (value)
-    {
-      case PURPLE: "PURPLE";
-      case BLUE: "BLUE";
-      case GREEN: "GREEN";
-      case RED: "RED";
-    }
-  }
-}
-
-enum abstract NoteKind(String) from String to String
-{
-  /**
-   * The default note type.
-   */
-  var NORMAL = "normal";
-
-  // Testing shiz
-  var PYRO_LIGHT = "pyro_light";
-  var PYRO_KICK = "pyro_kick";
-  var PYRO_TOSS = "pyro_toss";
-  var PYRO_COCK = "pyro_cock"; // lol
-  var PYRO_SHOOT = "pyro_shoot";
-}
diff --git a/source/funkin/noteStuff/NoteEvent.hx b/source/funkin/noteStuff/NoteEvent.hx
deleted file mode 100644
index 2d0c60073..000000000
--- a/source/funkin/noteStuff/NoteEvent.hx
+++ /dev/null
@@ -1,12 +0,0 @@
-package funkin.noteStuff;
-
-import funkin.noteStuff.NoteBasic.NoteType;
-import funkin.play.Strumline.StrumlineStyle;
-
-class NoteEvent extends Note
-{
-  public function new(strumTime:Float = 0, noteData:NoteType, ?prevNote:Note, ?sustainNote:Bool = false, ?style:StrumlineStyle = NORMAL)
-  {
-    super(strumTime, noteData, prevNote, sustainNote, style);
-  }
-}
diff --git a/source/funkin/noteStuff/NoteUtil.hx b/source/funkin/noteStuff/NoteUtil.hx
deleted file mode 100644
index a36c32482..000000000
--- a/source/funkin/noteStuff/NoteUtil.hx
+++ /dev/null
@@ -1,98 +0,0 @@
-package funkin.noteStuff;
-
-import haxe.Json;
-import openfl.Assets;
-
-/**
- * Just various functions that IDK where to put em!!!
- * Semi-temp for now? the note stuff is super clutter-y right now
- * so I am putting this new stuff here right now XDD
- *
- * A lot of this stuff can probably be moved to where appropriate!
- * i dont care about NoteUtil.hx at all!!!
- */
-class NoteUtil
-{
-  /**
-   * IDK THING FOR BOTH LOL! DIS SHIT HACK-Y
-   * @param jsonPath
-   * @return Map<Int, Array<SongEventInfo>>
-   */
-  public static function loadSongEvents(jsonPath:String):Map<Int, Array<SongEventInfo>>
-  {
-    return parseSongEvents(loadSongEventFromJson(jsonPath));
-  }
-
-  public static function loadSongEventFromJson(jsonPath:String):Array<SongEvent>
-  {
-    var daEvents:Array<SongEvent>;
-    daEvents = cast Json.parse(Assets.getText(jsonPath)).events; // DUMB LIL DETAIL HERE: MAKE SURE THAT .events IS THERE??
-    trace('GET JSON SONG EVENTS:');
-    trace(daEvents);
-    return daEvents;
-  }
-
-  /**
-   * Parses song event json stuff into a neater lil map grouping?
-   * @param songEvents
-   */
-  public static function parseSongEvents(songEvents:Array<SongEvent>):Map<Int, Array<SongEventInfo>>
-  {
-    var songData:Map<Int, Array<SongEventInfo>> = new Map();
-
-    for (songEvent in songEvents)
-    {
-      trace(songEvent);
-      if (songData[songEvent.t] == null) songData[songEvent.t] = [];
-
-      songData[songEvent.t].push({songEventType: songEvent.e, value: songEvent.v, activated: false});
-    }
-
-    trace("FINISH SONG EVENTS!");
-    trace(songData);
-
-    return songData;
-  }
-
-  public static function checkSongEvents(songData:Map<Int, Array<SongEventInfo>>, time:Float)
-  {
-    for (eventGrp in songData.keys())
-    {
-      if (time >= eventGrp)
-      {
-        for (events in songData[eventGrp])
-        {
-          if (!events.activated)
-          {
-            // TURN TO NICER SWITCH STATEMENT CHECKER OF EVENT TYPES!!
-            trace(events.value);
-            trace(eventGrp);
-            trace(Conductor.songPosition);
-            events.activated = true;
-          }
-        }
-      }
-    }
-  }
-}
-
-typedef SongEventInfo =
-{
-  var songEventType:SongEventType;
-  var value:Dynamic;
-  var activated:Bool;
-}
-
-typedef SongEvent =
-{
-  var t:Int;
-  var e:SongEventType;
-  var v:Dynamic;
-}
-
-enum abstract SongEventType(String)
-{
-  var FocusCamera;
-  var PlayCharAnim;
-  var Trace;
-}
diff --git a/source/funkin/play/PlayState.hx b/source/funkin/play/PlayState.hx
index 6dfbfcf65..5583b7fed 100644
--- a/source/funkin/play/PlayState.hx
+++ b/source/funkin/play/PlayState.hx
@@ -1,5 +1,6 @@
 package funkin.play;
 
+import haxe.Int64;
 import flixel.addons.display.FlxPieDial;
 import flixel.addons.transition.FlxTransitionableState;
 import flixel.FlxCamera;
@@ -7,24 +8,21 @@ import flixel.FlxObject;
 import flixel.FlxSprite;
 import flixel.FlxState;
 import flixel.FlxSubState;
-import flixel.group.FlxGroup.FlxTypedGroup;
 import flixel.input.keyboard.FlxKey;
 import flixel.math.FlxMath;
 import flixel.math.FlxPoint;
 import flixel.math.FlxRect;
-import flixel.sound.FlxSound;
 import flixel.text.FlxText;
 import flixel.tweens.FlxEase;
 import flixel.tweens.FlxTween;
 import flixel.ui.FlxBar;
 import flixel.util.FlxColor;
-import flixel.util.FlxSort;
 import flixel.util.FlxTimer;
 import funkin.audio.VoicesGroup;
 import funkin.Highscore.Tallies;
+import funkin.input.PreciseInputManager;
 import funkin.modding.events.ScriptEvent;
 import funkin.modding.events.ScriptEventDispatcher;
-import funkin.Note;
 import funkin.play.character.BaseCharacter;
 import funkin.play.character.CharacterData.CharacterDataParser;
 import funkin.play.cutscene.dialogue.Conversation;
@@ -32,17 +30,17 @@ import funkin.play.cutscene.dialogue.ConversationDataParser;
 import funkin.play.cutscene.VanillaCutscenes;
 import funkin.play.cutscene.VideoCutscene;
 import funkin.play.event.SongEventData.SongEventParser;
+import funkin.play.notes.NoteSprite;
+import funkin.play.notes.NoteDirection;
+import funkin.play.notes.Strumline;
 import funkin.play.scoring.Scoring;
 import funkin.play.song.Song;
 import funkin.play.song.SongData.SongDataParser;
 import funkin.play.song.SongData.SongEventData;
 import funkin.play.song.SongData.SongNoteData;
 import funkin.play.song.SongData.SongPlayableChar;
-import funkin.play.song.SongValidator;
 import funkin.play.stage.Stage;
 import funkin.play.stage.StageData.StageDataParser;
-import funkin.play.Strumline.StrumlineArrow;
-import funkin.play.Strumline.StrumlineStyle;
 import funkin.ui.PopUpStuff;
 import funkin.ui.PreferencesMenu;
 import funkin.ui.stageBuildShit.StageOffsetSubState;
@@ -93,6 +91,12 @@ class PlayState extends MusicBeatState
    */
   public static var instance:PlayState = null;
 
+  /**
+   * This sucks. We need this because FlxG.resetState(); assumes the constructor has no arguments.
+   * @see https://github.com/HaxeFlixel/flixel/issues/2541
+   */
+  static var lastParams:PlayStateParams = null;
+
   /**
    * PUBLIC INSTANCE VARIABLES
    * Public instance variables should be used for information that must be reset or dereferenced
@@ -118,18 +122,6 @@ class PlayState extends MusicBeatState
    */
   public var currentStage:Stage = null;
 
-  /**
-   * Data for the current difficulty for the current song.
-   * Includes chart data, scroll speed, and other information.
-   */
-  public var currentChart(get, null):SongDifficulty;
-
-  /**
-   * The internal ID of the currently active Stage.
-   * Used to retrieve the data required to build the `currentStage`.
-   */
-  public var currentStageId(get, null):String;
-
   /**
    * Gets set to true when the PlayState needs to reset (player opted to restart or died).
    * Gets disabled once resetting happens.
@@ -145,23 +137,24 @@ class PlayState extends MusicBeatState
   /**
    * The player's current health.
    * The default maximum health is 2.0, and the default starting health is 1.0.
+   * TODO: Refactor this to [0.0, 1.0]
    */
   public var health:Float = 1;
 
   /**
    * The player's current score.
+   * TODO: Move this to its own class.
    */
   public var songScore:Int = 0;
 
   /**
    * An empty FlxObject contained in the scene.
-   * The current gameplay camera will be centered on this object. Tween its position to move the camera smoothly.
+   * The current gameplay camera will always follow this object. Tween its position to move the camera smoothly.
    *
-   * This is an FlxSprite for two reasons:
-   * 1. It needs to be an object in the scene for the camera to be configured to follow it.
-   * 2. It needs to be an FlxSprite to allow a graphic (optionally, for debug purposes) to be drawn on it.
+   * It needs to be an object in the scene for the camera to be configured to follow it.
+   * We optionally make this an FlxSprite so we can draw a debug graphic with it.
    */
-  public var cameraFollowPoint:FlxSprite = new FlxSprite(0, 0);
+  public var cameraFollowPoint:FlxObject;
 
   /**
    * The camera follow point from the last stage.
@@ -229,17 +222,23 @@ class PlayState extends MusicBeatState
    */
   public var currentConversation:Conversation;
 
+  /**
+   * Key press inputs which have been received but not yet processed.
+   * These are encoded with an OS timestamp, so they
+  **/
+  var inputPressQueue:Array<PreciseInputEvent> = [];
+
+  /**
+   * Key release inputs which have been received but not yet processed.
+   * These are encoded with an OS timestamp, so they
+  **/
+  var inputReleaseQueue:Array<PreciseInputEvent> = [];
+
   /**
    * PRIVATE INSTANCE VARIABLES
    * Private instance variables should be used for information that must be reset or dereferenced
    * every time the state is reset, but should not be accessed externally.
    */
-  /**
-   * The Array containing the notes that are not currently on the screen.
-   * The `update()` function regularly shifts these out to add new notes to the screen.
-   */
-  var inactiveNotes:Array<Note>;
-
   /**
    * The Array containing the upcoming song events.
    * The `update()` function regularly shifts these out to trigger events.
@@ -279,14 +278,17 @@ class PlayState extends MusicBeatState
    */
   var vocals:VoicesGroup;
 
+  #if discord_rpc
+  // Discord RPC variables
+  var storyDifficultyText:String = '';
+  var iconRPC:String = '';
+  var detailsText:String = '';
+  var detailsPausedText:String = '';
+  #end
+
   /**
    * RENDER OBJECTS
    */
-  /**
-   * The SpriteGroup containing the notes that are currently on the screen or are about to be on the screen.
-   */
-  var activeNotes:FlxTypedGroup<Note> = null;
-
   /**
    * The FlxText which displays the current score.
    */
@@ -322,7 +324,7 @@ class PlayState extends MusicBeatState
   /**
    * The sprite group containing opponent's strumline notes.
    */
-  public var enemyStrumline:Strumline;
+  public var opponentStrumline:Strumline;
 
   /**
    * The camera which contains, and controls visibility of, the user interface elements.
@@ -339,6 +341,14 @@ class PlayState extends MusicBeatState
    */
   public var camCutscene:FlxCamera;
 
+  /**
+   * The combo popups. Includes the real-time combo counter and the rating.
+   */
+  var comboPopUps:PopUpStuff;
+
+  /**
+   * The circular sprite that appears while the user is holding down the Skip Cutscene button.
+   */
   var skipTimer:FlxPieDial;
 
   /**
@@ -360,34 +370,54 @@ class PlayState extends MusicBeatState
     return this.subState != null;
   }
 
-  var gfSpeed:Int = 1;
-  var generatedMusic:Bool = false;
+  /**
+   * Data for the current difficulty for the current song.
+   * Includes chart data, scroll speed, and other information.
+   */
+  public var currentChart(get, null):SongDifficulty;
 
-  var grpNoteSplashes:FlxTypedGroup<NoteSplash>;
-  var comboPopUps:PopUpStuff;
-  var perfectMode:Bool = false;
-  var previousFrameTime:Int = 0;
-  var songTime:Float = 0;
-
-  #if discord_rpc
-  // Discord RPC variables
-  var storyDifficultyText:String = '';
-  var iconRPC:String = '';
-  var songLength:Float = 0;
-  var detailsText:String = '';
-  var detailsPausedText:String = '';
-  #end
+  function get_currentChart():SongDifficulty
+  {
+    if (currentSong == null || currentDifficulty == null) return null;
+    return currentSong.getDifficulty(currentDifficulty);
+  }
 
   /**
-   * This sucks. We need this because FlxG.resetState(); assumes the constructor has no arguments.
-   * @see https://github.com/HaxeFlixel/flixel/issues/2541
+   * The internal ID of the currently active Stage.
+   * Used to retrieve the data required to build the `currentStage`.
    */
-  static var lastParams:PlayStateParams = null;
+  public var currentStageId(get, null):String;
 
+  function get_currentStageId():String
+  {
+    if (currentChart == null || currentChart.stage == null || currentChart.stage == '') return Constants.DEFAULT_STAGE;
+    return currentChart.stage;
+  }
+
+  /**
+   * The length of the current song, in milliseconds.
+   */
+  var currentSongLengthMs(get, never):Float;
+
+  function get_currentSongLengthMs():Float
+  {
+    return FlxG?.sound?.music?.length;
+  }
+
+  // TODO: Refactor or document
+  var generatedMusic:Bool = false;
+  var perfectMode:Bool = false;
+
+  /**
+   * Instantiate a new PlayState.
+   * @param params The parameters used to initialize the PlayState.
+   *   Includes information about what song to play and more.
+   */
   public function new(params:PlayStateParams)
   {
     super();
 
+    // Validate parameters.
     if (params == null && lastParams == null)
     {
       throw 'PlayState constructor called with no available parameters.';
@@ -402,34 +432,43 @@ class PlayState extends MusicBeatState
       lastParams = params;
     }
 
+    // Apply parameters.
     currentSong = params.targetSong;
     if (params.targetDifficulty != null) currentDifficulty = params.targetDifficulty;
     if (params.targetCharacter != null) currentPlayerId = params.targetCharacter;
+
+    // Don't do anything else here! Wait until create() when we attach to the camera.
   }
 
+  /**
+   * Called when the PlayState is switched to.
+   */
   public override function create():Void
   {
     super.create();
 
     if (instance != null)
     {
+      // TODO: Do something in this case? IDK.
       trace('WARNING: PlayState instance already exists. This should not happen.');
     }
     instance = this;
 
     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 (currentChart == null)
+    if (currentSong == null || currentChart == null)
     {
+      // We have encountered a critical error. Prevent Flixel from trying to run any gameplay logic.
       criticalFailure = true;
 
+      // Choose an error message.
       var message:String = 'There was a critical error. Click OK to return to the main menu.';
-
       if (currentSong == null)
       {
         message = 'The was a critical error loading this song\'s chart. Click OK to return to the main menu.';
@@ -443,16 +482,26 @@ class PlayState extends MusicBeatState
         message = 'The was a critical error retrieving data for this song on "$currentDifficulty" difficulty. Click OK to return to the main menu.';
       }
 
+      // Display a popup. This blocks the application until the user clicks OK.
       lime.app.Application.current.window.alert(message, 'Error loading PlayState');
+
+      // Force the user back to the main menu.
       FlxG.switchState(new MainMenuState());
       return;
     }
 
-    // Displays the camera follow point as a sprite for debug purposes.
-    // TODO: Put this on a toggle?
-    cameraFollowPoint.makeGraphic(8, 8, 0xFF00FF00);
-    cameraFollowPoint.visible = false;
-    cameraFollowPoint.zIndex = 1000000;
+    if (false)
+    {
+      // Displays the camera follow point as a sprite for debug purposes.
+      cameraFollowPoint = new FlxSprite(0, 0).makeGraphic(8, 8, 0xFF00FF00);
+      cameraFollowPoint.visible = false;
+      cameraFollowPoint.zIndex = 1000000;
+    }
+    else
+    {
+      // Camera follow point is an invisible point in space.
+      cameraFollowPoint = new FlxObject(0, 0);
+    }
 
     // Reduce physics accuracy (who cares!!!) to improve animation quality.
     FlxG.fixedTimestep = false;
@@ -465,590 +514,82 @@ class PlayState extends MusicBeatState
     // Stop any pre-existing music.
     if (FlxG.sound.music != null) FlxG.sound.music.stop();
 
-    // Prepare the current song to be played.
+    // Prepare the current song's instrumental and vocals to be played.
     if (currentChart != null)
     {
-      currentChart.cacheInst();
+      currentChart.cacheInst(currentPlayerId);
       currentChart.cacheVocals(currentPlayerId);
     }
 
-    // Initialize stage stuff.
-    initCameras();
-
+    // Prepare the Conductor.
     Conductor.mapTimeChanges(currentChart.timeChanges);
-
     Conductor.update(-5000);
 
-    // Once the song is loaded, we can continue and initialize the stage.
-
-    var healthBarYPos:Float = PreferencesMenu.getPref('downscroll') ? FlxG.height * 0.1 : FlxG.height * 0.9;
-    healthBarBG = new FlxSprite(0, healthBarYPos).loadGraphic(Paths.image('healthBar'));
-    healthBarBG.screenCenter(X);
-    healthBarBG.scrollFactor.set(0, 0);
-    add(healthBarBG);
-
-    healthBar = new FlxBar(healthBarBG.x + 4, healthBarBG.y + 4, RIGHT_TO_LEFT, Std.int(healthBarBG.width - 8), Std.int(healthBarBG.height - 8), this,
-      'healthLerp', 0, 2);
-    healthBar.scrollFactor.set();
-    healthBar.createFilledBar(Constants.COLOR_HEALTH_BAR_RED, Constants.COLOR_HEALTH_BAR_GREEN);
-    add(healthBar);
-
+    // The song is now loaded. We can continue to initialize the play state.
+    initCameras();
+    initHealthBar();
     initStage();
     initCharacters();
-    #if discord_rpc
-    initDiscord();
-    #end
-
-    // Configure camera follow point.
-    if (previousCameraFollowPoint != null)
-    {
-      cameraFollowPoint.setPosition(previousCameraFollowPoint.x, previousCameraFollowPoint.y);
-      previousCameraFollowPoint = null;
-    }
-    add(cameraFollowPoint);
+    initStrumlines();
 
+    // Initialize the judgements and combo meter.
     comboPopUps = new PopUpStuff();
     comboPopUps.cameras = [camHUD];
     add(comboPopUps);
 
-    buildStrumlines();
-
-    grpNoteSplashes = new FlxTypedGroup<NoteSplash>();
-
-    var noteSplash:NoteSplash = new NoteSplash(100, 100, 0);
-    grpNoteSplashes.add(noteSplash);
-    noteSplash.alpha = 0.1;
-
-    add(grpNoteSplashes);
-
-    generateSong();
-
-    resetCamera();
-
-    FlxG.worldBounds.set(0, 0, FlxG.width, FlxG.height);
-
-    scoreText = new FlxText(healthBarBG.x + healthBarBG.width - 190, healthBarBG.y + 30, 0, '', 20);
-    scoreText.setFormat(Paths.font('vcr.ttf'), 16, FlxColor.WHITE, RIGHT, FlxTextBorderStyle.OUTLINE, FlxColor.BLACK);
-    scoreText.scrollFactor.set();
-    add(scoreText);
-
-    // Skip Video Cutscene
+    // The little dial that shows up when you hold the Skip Cutscene key.
     skipTimer = new FlxPieDial(16, 16, 32, FlxColor.WHITE, 36, CIRCLE, true, 24);
     skipTimer.amount = 0;
     skipTimer.zIndex = 1000;
+    add(skipTimer);
     // Renders only in video cutscene mode.
     skipTimer.cameras = [camCutscene];
-    add(skipTimer);
 
-    // Attach the groups to the HUD camera so they are rendered independent of the stage.
-    grpNoteSplashes.cameras = [camHUD];
-    activeNotes.cameras = [camHUD];
-    healthBar.cameras = [camHUD];
-    healthBarBG.cameras = [camHUD];
-    iconP1.cameras = [camHUD];
-    iconP2.cameras = [camHUD];
-    scoreText.cameras = [camHUD];
-    leftWatermarkText.cameras = [camHUD];
-    rightWatermarkText.cameras = [camHUD];
+    #if discord_rpc
+    // Initialize Discord Rich Presence.
+    initDiscord();
+    #end
 
-    // Starting song!
+    // Read the song's note data and pass it to the strumlines.
+    generateSong();
+
+    // Reset the camera's zoom and force it to focus on the camera follow point.
+    resetCamera();
+
+    initPreciseInputs();
+
+    FlxG.worldBounds.set(0, 0, FlxG.width, FlxG.height);
+
+    // The song is loaded and in the process of starting.
+    // This gets set back to false when the chart actually starts.
     startingSong = true;
 
-    // TODO: Softcode cutscenes.
-    // TODO: Alternatively: make a song script that allows startCountdown to be called,
-    // then cancels the countdown, hides the UI, plays the cutscene,
-    // then calls PlayState.startCountdown later?
-    if (currentSong != null)
+    // TODO: We hardcoded the transition into Winter Horrorland. Do this with a ScriptedSong instead.
+    if ((currentSong?.songId ?? '').toLowerCase() == 'winter-horrorland')
     {
-      switch (currentSong.songId.toLowerCase())
-      {
-        case 'winter-horrorland':
-          VanillaCutscenes.playHorrorStartCutscene();
-        // This one is softcoded now WOOOO!
-        // case 'senpai' | 'roses' | 'thorns':
-        //   schoolIntro(doof);
-        // case 'ugh':
-        // VanillaCutscenes.playUghCutscene();
-        // case 'stress':
-        // VanillaCutscenes.playStressCutscene();
-        // case 'guns':
-        // VanillaCutscenes.playGunsCutscene();
-        default:
-          // VanillaCutscenes will call startCountdown later.
-          startCountdown();
-      }
+      // VanillaCutscenes will call startCountdown later.
+      VanillaCutscenes.playHorrorStartCutscene();
     }
     else
     {
+      // Call a script event to start the countdown.
+      // Songs with cutscenes should call event.cancel().
+      // As long as they call `PlayState.instance.startCountdown()` later, the countdown will start.
       startCountdown();
     }
 
-    #if debug
-    this.rightWatermarkText.text = Constants.VERSION;
-    #end
+    leftWatermarkText.cameras = [camHUD];
+    rightWatermarkText.cameras = [camHUD];
 
+    // Initialize some debug stuff.
     #if debug
+    // Display the version number (and git commit hash) in the bottom right corner.
+    this.rightWatermarkText.text = Constants.VERSION;
+
     FlxG.console.registerObject('playState', this);
     #end
   }
 
-  function get_currentChart():SongDifficulty
-  {
-    if (currentSong == null || currentDifficulty == null) return null;
-    return currentSong.getDifficulty(currentDifficulty);
-  }
-
-  function get_currentStageId():String
-  {
-    if (currentChart == null || currentChart.stage == null || currentChart.stage == '') return Constants.DEFAULT_STAGE;
-    return currentChart.stage;
-  }
-
-  /**
-   * Initializes the game and HUD cameras.
-   */
-  function initCameras():Void
-  {
-    // Set the camera zoom. This gets overridden by the value in the stage data.
-    // defaultCameraZoom = FlxCamera.defaultZoom * 1.05;
-
-    camGame = new SwagCamera();
-    camHUD = new FlxCamera();
-    camHUD.bgColor.alpha = 0;
-    camCutscene = new FlxCamera();
-    camCutscene.bgColor.alpha = 0;
-
-    FlxG.cameras.reset(camGame);
-    FlxG.cameras.add(camHUD, false);
-    FlxG.cameras.add(camCutscene, false);
-  }
-
-  function initStage():Void
-  {
-    if (currentSong != null)
-    {
-      if (currentChart == null)
-      {
-        trace('Song difficulty could not be loaded.');
-      }
-
-      loadStage(currentStageId);
-    }
-    else
-    {
-      // Fallback.
-      loadStage('mainStage');
-    }
-  }
-
-  function initCharacters():Void
-  {
-    if (currentSong == null || currentChart == null)
-    {
-      trace('Song difficulty could not be loaded.');
-    }
-
-    // TODO: Switch playable character by manipulating this value.
-    // TODO: How to choose which one to use for story mode?
-
-    var playableChars:Array<String> = currentChart.getPlayableChars();
-
-    if (playableChars.length == 0)
-    {
-      trace('WARNING: No playable characters found for this song.');
-    }
-    else if (playableChars.indexOf(currentPlayerId) == -1)
-    {
-      currentPlayerId = playableChars[0];
-    }
-
-    var currentCharData:SongPlayableChar = currentChart.getPlayableChar(currentPlayerId);
-
-    //
-    // GIRLFRIEND
-    //
-    var girlfriend:BaseCharacter = CharacterDataParser.fetchCharacter(currentCharData.girlfriend);
-
-    if (girlfriend != null)
-    {
-      girlfriend.characterType = CharacterType.GF;
-    }
-    else if (currentCharData.girlfriend != '')
-    {
-      trace('WARNING: Could not load girlfriend character with ID ${currentCharData.girlfriend}, skipping...');
-    }
-    else
-    {
-      // Chosen GF was '' so we don't load one.
-    }
-
-    //
-    // DAD
-    //
-    var dad:BaseCharacter = CharacterDataParser.fetchCharacter(currentCharData.opponent);
-
-    if (dad != null)
-    {
-      dad.characterType = CharacterType.DAD;
-    }
-
-    //
-    // OPPONENT HEALTH ICON
-    //
-    iconP2 = new HealthIcon(currentCharData.opponent, 1);
-    iconP2.y = healthBar.y - (iconP2.height / 2);
-    dad.initHealthIcon(true);
-    add(iconP2);
-
-    //
-    // BOYFRIEND
-    //
-    var boyfriend:BaseCharacter = CharacterDataParser.fetchCharacter(currentPlayerId);
-
-    if (boyfriend != null)
-    {
-      boyfriend.characterType = CharacterType.BF;
-    }
-
-    //
-    // PLAYER HEALTH ICON
-    //
-    iconP1 = new HealthIcon(currentPlayerId, 0);
-    iconP1.y = healthBar.y - (iconP1.height / 2);
-    boyfriend.initHealthIcon(false);
-    add(iconP1);
-
-    //
-    // ADD CHARACTERS TO SCENE
-    //
-
-    if (currentStage != null)
-    {
-      // Characters get added to the stage, not the main scene.
-      if (girlfriend != null)
-      {
-        currentStage.addCharacter(girlfriend, GF);
-
-        #if debug
-        FlxG.console.registerObject('gf', girlfriend);
-        #end
-      }
-
-      if (boyfriend != null)
-      {
-        currentStage.addCharacter(boyfriend, BF);
-
-        #if debug
-        FlxG.console.registerObject('bf', boyfriend);
-        #end
-      }
-
-      if (dad != null)
-      {
-        currentStage.addCharacter(dad, DAD);
-        // Camera starts at dad.
-        cameraFollowPoint.setPosition(dad.cameraFocusPoint.x, dad.cameraFocusPoint.y);
-
-        #if debug
-        FlxG.console.registerObject('dad', dad);
-        #end
-      }
-
-      // Rearrange by z-indexes.
-      currentStage.refresh();
-    }
-  }
-
-  /**
-   * Removes any references to the current stage, then clears the stage cache,
-   * then reloads all the stages.
-   *
-   * This is useful for when you want to edit a stage without reloading the whole game.
-   * Reloading works on both the JSON and the HXC, if applicable.
-   *
-   * Call this by pressing F5 on a debug build.
-   */
-  override function debug_refreshModules():Void
-  {
-    // Remove the current stage. If the stage gets deleted while it's still in use,
-    // it'll probably crash the game or something.
-    if (this.currentStage != null)
-    {
-      remove(currentStage);
-      var event:ScriptEvent = new ScriptEvent(ScriptEvent.DESTROY, false);
-      ScriptEventDispatcher.callEvent(currentStage, event);
-      currentStage = null;
-    }
-
-    // Stop the vocals.
-    if (vocals != null)
-    {
-      vocals.stop();
-    }
-
-    super.debug_refreshModules();
-
-    var event:ScriptEvent = new ScriptEvent(ScriptEvent.CREATE, false);
-    ScriptEventDispatcher.callEvent(currentSong, event);
-  }
-
-  /**
-   * Pauses music and vocals easily.
-   */
-  public function pauseMusic():Void
-  {
-    FlxG.sound.music.pause();
-    vocals.pause();
-  }
-
-  /**
-   * Loads stage data from cache, assembles the props,
-   * and adds it to the state.
-   * @param id
-   */
-  function loadStage(id:String):Void
-  {
-    currentStage = StageDataParser.fetchStage(id);
-
-    if (currentStage != null)
-    {
-      // Actually create and position the sprites.
-      var event:ScriptEvent = new ScriptEvent(ScriptEvent.CREATE, false);
-      ScriptEventDispatcher.callEvent(currentStage, event);
-
-      // Apply camera zoom level from stage data.
-      defaultCameraZoom = currentStage.camZoom;
-
-      // Add the stage to the scene.
-      this.add(currentStage);
-
-      #if debug
-      FlxG.console.registerObject('stage', currentStage);
-      #end
-    }
-    else
-    {
-      // lolol
-      lime.app.Application.current.window.alert('Nice job, you ignoramus. $id isn\'t a real stage.\nI\'m falling back to the default so the game doesn\'t shit itself.',
-        'Stage Error');
-    }
-  }
-
-  function initDiscord():Void
-  {
-    #if discord_rpc
-    storyDifficultyText = difficultyString();
-    iconRPC = currentSong.player2;
-
-    // To avoid having duplicate images in Discord assets
-    switch (iconRPC)
-    {
-      case 'senpai-angry':
-        iconRPC = 'senpai';
-      case 'monster-christmas':
-        iconRPC = 'monster';
-      case 'mom-car':
-        iconRPC = 'mom';
-    }
-
-    // String that contains the mode defined here so it isn't necessary to call changePresence for each mode
-    detailsText = isStoryMode ? 'Story Mode: Week $storyWeek' : 'Freeplay';
-    detailsPausedText = 'Paused - $detailsText';
-
-    // Updating Discord Rich Presence.
-    DiscordClient.changePresence(detailsText, '${currentChart.songName} ($storyDifficultyText)', iconRPC);
-    #end
-  }
-
-  function startSong():Void
-  {
-    dispatchEvent(new ScriptEvent(ScriptEvent.SONG_START));
-
-    startingSong = false;
-
-    previousFrameTime = FlxG.game.ticks;
-
-    if (!isGamePaused && currentChart != null)
-    {
-      currentChart.playInst(1.0, false);
-    }
-
-    FlxG.sound.music.onComplete = endSong;
-    trace('Playing vocals...');
-    add(vocals);
-    vocals.play();
-
-    #if discord_rpc
-    // Song duration in a float, useful for the time left feature
-    songLength = FlxG.sound.music.length;
-
-    // Updating Discord Rich Presence (with Time Left)
-    DiscordClient.changePresence(detailsText, '${currentChart.songName} ($storyDifficultyText)', iconRPC, true, songLength);
-    #end
-  }
-
-  function generateSong():Void
-  {
-    if (currentChart == null)
-    {
-      trace('Song difficulty could not be loaded.');
-    }
-
-    Conductor.forceBPM(currentChart.getStartingBPM());
-
-    vocals = currentChart.buildVocals(currentPlayerId);
-    if (vocals.members.length == 0)
-    {
-      trace('WARNING: No vocals found for this song.');
-    }
-
-    // Create the rendered note group.
-    activeNotes = new FlxTypedGroup<Note>();
-    activeNotes.zIndex = 1000;
-    add(activeNotes);
-
-    regenNoteData();
-
-    generatedMusic = true;
-  }
-
-  function regenNoteData():Void
-  {
-    Highscore.tallies.combo = 0;
-    Highscore.tallies = new Tallies();
-
-    // Reset song events.
-    songEvents = currentChart.getEvents();
-    SongEventParser.resetEvents(songEvents);
-
-    // Destroy inactive notes.
-    inactiveNotes = [];
-
-    // Destroy active notes.
-    activeNotes.forEach(function(nt) {
-      nt.followsTime = false;
-      FlxTween.tween(nt, {y: FlxG.height + nt.y}, 0.5,
-        {
-          ease: FlxEase.expoIn,
-          onComplete: function(twn) {
-            nt.kill();
-            activeNotes.remove(nt, true);
-            nt.destroy();
-          }
-        });
-    });
-
-    var noteData:Array<SongNoteData> = currentChart.notes;
-
-    var oldNote:Note = null;
-    for (songNote in noteData)
-    {
-      var mustHitNote:Bool = songNote.getMustHitNote();
-
-      // TODO: Put this in the chart or something?
-      var strumlineStyle:StrumlineStyle = null;
-      switch (currentStageId)
-      {
-        case 'school':
-          strumlineStyle = PIXEL;
-        case 'schoolEvil':
-          strumlineStyle = PIXEL;
-        default:
-          strumlineStyle = NORMAL;
-      }
-
-      var newNote:Note = new Note(songNote.time, songNote.data, oldNote, false, strumlineStyle);
-      newNote.mustPress = mustHitNote;
-      newNote.data.sustainLength = songNote.length;
-      newNote.data.noteKind = songNote.kind;
-      newNote.scrollFactor.set(0, 0);
-
-      // Note positioning.
-      // TODO: Make this more robust.
-      if (newNote.mustPress)
-      {
-        newNote.alignToSturmlineArrow(playerStrumline.getArrow(songNote.getDirection()));
-      }
-      else
-      {
-        newNote.alignToSturmlineArrow(enemyStrumline.getArrow(songNote.getDirection()));
-      }
-
-      inactiveNotes.push(newNote);
-
-      oldNote = newNote;
-
-      // Generate X sustain notes.
-      var sustainSections = Math.round(songNote.length / Conductor.stepLengthMs);
-      for (noteIndex in 0...sustainSections)
-      {
-        var noteTimeOffset:Float = Conductor.stepLengthMs + (Conductor.stepLengthMs * noteIndex);
-        var sustainNote:Note = new Note(songNote.time + noteTimeOffset, songNote.data, oldNote, true, strumlineStyle);
-        sustainNote.mustPress = mustHitNote;
-        sustainNote.data.noteKind = songNote.kind;
-        sustainNote.scrollFactor.set(0, 0);
-
-        if (sustainNote.mustPress)
-        {
-          // Align with the strumline arrow.
-          sustainNote.alignToSturmlineArrow(playerStrumline.getArrow(songNote.getDirection()));
-        }
-        else
-        {
-          sustainNote.alignToSturmlineArrow(enemyStrumline.getArrow(songNote.getDirection()));
-        }
-
-        inactiveNotes.push(sustainNote);
-
-        oldNote = sustainNote;
-      }
-    }
-
-    // Sorting is an expensive operation.
-    // TODO: Make this more efficient.
-    // DO NOT assume it was done in the chart file. Notes created artificially by sustains are in here too.
-    inactiveNotes.sort(function(a:Note, b:Note):Int {
-      return SortUtil.byStrumtime(FlxSort.ASCENDING, a, b);
-    });
-    /**
-    **/
-  }
-
-  #if discord_rpc
-  override public function onFocus():Void
-  {
-    if (health > 0 && !paused && FlxG.autoPause)
-    {
-      if (Conductor.songPosition > 0.0) DiscordClient.changePresence(detailsText, currentSong.song + ' (' + storyDifficultyText + ')', iconRPC, true,
-        songLength - Conductor.songPosition);
-      else
-        DiscordClient.changePresence(detailsText, currentSong.song + ' (' + storyDifficultyText + ')', iconRPC);
-    }
-
-    super.onFocus();
-  }
-
-  override public function onFocusLost():Void
-  {
-    if (health > 0 && !paused && FlxG.autoPause) DiscordClient.changePresence(detailsPausedText, currentSong.song + ' (' + storyDifficultyText + ')', iconRPC);
-
-    super.onFocusLost();
-  }
-  #end
-
-  function resyncVocals():Void
-  {
-    if (_exiting || vocals == null) return;
-
-    vocals.pause();
-
-    FlxG.sound.music.play();
-    Conductor.update();
-
-    vocals.time = FlxG.sound.music.time;
-    vocals.play();
-  }
-
   public override function update(elapsed:Float):Void
   {
     if (criticalFailure) return;
@@ -1130,13 +671,9 @@ class PlayState extends MusicBeatState
 
       if (!isGamePaused)
       {
-        songTime += FlxG.game.ticks - previousFrameTime;
-        previousFrameTime = FlxG.game.ticks;
-
         // Interpolation type beat
         if (Conductor.lastSongPos != Conductor.songPosition)
         {
-          songTime = (songTime + Conductor.songPosition) / 2;
           Conductor.lastSongPos = Conductor.songPosition;
         }
       }
@@ -1216,22 +753,7 @@ class PlayState extends MusicBeatState
     }
     FlxG.watch.addQuick('songPos', Conductor.songPosition);
 
-    // Handle GF dance speed.
-    // TODO: Add a song event for this.
-    if (currentSong.songId == 'fresh')
-    {
-      switch (Conductor.currentBeat)
-      {
-        case 16:
-          gfSpeed = 2;
-        case 48:
-          gfSpeed = 1;
-        case 80:
-          gfSpeed = 2;
-        case 112:
-          gfSpeed = 1;
-      }
-    }
+    // TODO: Add a song event for Handle GF dance speed.
 
     // Handle player death.
     if (!isInCutscene && !disableKeys && !_exiting)
@@ -1287,128 +809,8 @@ class PlayState extends MusicBeatState
       }
     }
 
-    // Iterate over inactive notes.
-    while (inactiveNotes[0] != null && inactiveNotes[0].data.strumTime - Conductor.songPosition < 1800 / currentChart.scrollSpeed)
-    {
-      var dunceNote:Note = inactiveNotes[0];
-
-      if (dunceNote.mustPress && !dunceNote.isSustainNote) Highscore.tallies.totalNotes++;
-
-      activeNotes.add(dunceNote);
-
-      inactiveNotes.shift();
-    }
-
-    // Iterate over active notes.
-    if (generatedMusic && playerStrumline != null)
-    {
-      activeNotes.forEachAlive(function(daNote:Note) {
-        if ((PreferencesMenu.getPref('downscroll') && daNote.y < -daNote.height)
-          || (!PreferencesMenu.getPref('downscroll') && daNote.y > FlxG.height))
-        {
-          daNote.active = false;
-          daNote.visible = false;
-        }
-        else
-        {
-          daNote.visible = true;
-          daNote.active = true;
-        }
-
-        var strumLineMid:Float = playerStrumline.y + Note.swagWidth / 2;
-
-        if (daNote.followsTime)
-        {
-          daNote.y = (Conductor.songPosition - daNote.data.strumTime) * (0.45 * FlxMath.roundDecimal(currentChart.scrollSpeed, 2) * daNote.noteSpeedMulti);
-        }
-
-        if (PreferencesMenu.getPref('downscroll'))
-        {
-          daNote.y += playerStrumline.y;
-          if (daNote.isSustainNote)
-          {
-            if (daNote.animation.curAnim.name.endsWith('end') && daNote.prevNote != null)
-            {
-              daNote.y += daNote.prevNote.height;
-            }
-            else
-            {
-              daNote.y += daNote.height / 2;
-            }
-
-            if ((!daNote.mustPress || (daNote.wasGoodHit || (daNote.prevNote.wasGoodHit && !daNote.canBeHit)))
-              && daNote.y - daNote.offset.y * daNote.scale.y + daNote.height >= strumLineMid)
-            {
-              applyClipRect(daNote);
-            }
-          }
-        }
-        else
-        {
-          if (daNote.followsTime) daNote.y = playerStrumline.y - daNote.y;
-          if (daNote.isSustainNote
-            && (!daNote.mustPress || (daNote.wasGoodHit || (daNote.prevNote.wasGoodHit && !daNote.canBeHit)))
-            && daNote.y + daNote.offset.y * daNote.scale.y <= strumLineMid)
-          {
-            applyClipRect(daNote);
-          }
-        }
-
-        if (!daNote.mustPress && daNote.wasGoodHit && !daNote.tooLate)
-        {
-          var event:NoteScriptEvent = new NoteScriptEvent(ScriptEvent.NOTE_HIT, daNote, Highscore.tallies.combo, true);
-          dispatchEvent(event);
-
-          // Calling event.cancelEvent() in a module should force the CPU to miss the note.
-          // This is useful for cool shit, including but not limited to:
-          // - Making the AI ignore notes which are hazardous.
-          // - Making the AI miss notes on purpose for aesthetic reasons.
-          if (event.eventCanceled)
-          {
-            daNote.tooLate = true;
-          }
-        }
-
-        // WIP interpolation shit? Need to fix the pause issue
-        // daNote.y = (strumLine.y - (songTime - daNote.strumTime) * (0.45 * SONG.speed[.curDiff]));
-
-        // removing this so whether the note misses or not is entirely up to Note class
-        // var noteMiss:Bool = daNote.y < -daNote.height;
-
-        // if (PreferencesMenu.getPref('downscroll'))
-        // noteMiss = daNote.y > FlxG.height;
-
-        if (daNote.isSustainNote && daNote.wasGoodHit)
-        {
-          if ((!PreferencesMenu.getPref('downscroll') && daNote.y < -daNote.height)
-            || (PreferencesMenu.getPref('downscroll') && daNote.y > FlxG.height))
-          {
-            daNote.active = false;
-            daNote.visible = false;
-
-            daNote.kill();
-            activeNotes.remove(daNote, true);
-            daNote.destroy();
-          }
-        }
-        if (daNote.wasGoodHit)
-        {
-          daNote.active = false;
-          daNote.visible = false;
-
-          daNote.kill();
-          activeNotes.remove(daNote, true);
-          daNote.destroy();
-        }
-
-        if (daNote.tooLate)
-        {
-          noteMiss(daNote);
-        }
-      });
-    }
-
     // Query and activate song events.
+    // TODO: Check that these work even when songPosition is less than 0.
     if (songEvents != null && songEvents.length > 0)
     {
       var songEventsToActivate:Array<SongEventData> = SongEventParser.queryEvents(songEvents, Conductor.songPosition);
@@ -1430,16 +832,1408 @@ class PlayState extends MusicBeatState
     }
 
     // Handle keybinds.
-    if (!isInCutscene && !disableKeys) keyShit(true);
+    // if (!isInCutscene && !disableKeys) keyShit(true);
+    processInputQueue();
     if (!isInCutscene && !disableKeys) debugKeyShit();
     if (isInCutscene && !disableKeys) handleCutsceneKeys(elapsed);
 
+    // Moving notes into position is now done by Strumline.update().
+    processNotes();
+
     // Dispatch the onUpdate event to scripted elements.
     dispatchEvent(new UpdateScriptEvent(elapsed));
   }
 
-  static final CUTSCENE_KEYS:Array<FlxKey> = [SPACE, ESCAPE, ENTER];
+  public override function dispatchEvent(event:ScriptEvent):Void
+  {
+    // ORDER: Module, Stage, Character, Song, Conversation, Note
+    // Modules should get the first chance to cancel the event.
 
+    // super.dispatchEvent(event) dispatches event to module scripts.
+    super.dispatchEvent(event);
+
+    // Dispatch event to stage script.
+    ScriptEventDispatcher.callEvent(currentStage, event);
+
+    // Dispatch event to character script(s).
+    if (currentStage != null) currentStage.dispatchToCharacters(event);
+
+    // Dispatch event to song script.
+    ScriptEventDispatcher.callEvent(currentSong, event);
+
+    // Dispatch event to conversation script.
+    ScriptEventDispatcher.callEvent(currentConversation, event);
+
+    // TODO: Dispatch event to note scripts
+  }
+
+  /**
+   * Function called before opening a new substate.
+   * @param subState The substate to open.
+   */
+  public override function openSubState(subState:FlxSubState):Void
+  {
+    // If there is a substate which requires the game to continue,
+    // then make this a condition.
+    var shouldPause = true;
+
+    if (shouldPause)
+    {
+      // Pause the music.
+      if (FlxG.sound.music != null)
+      {
+        FlxG.sound.music.pause();
+        if (vocals != null) vocals.pause();
+      }
+
+      // Pause the countdown.
+      Countdown.pauseCountdown();
+    }
+
+    super.openSubState(subState);
+  }
+
+  /**
+   * Function called before closing the current substate.
+   * @param subState
+   */
+  public override function closeSubState():Void
+  {
+    if (isGamePaused)
+    {
+      var event:ScriptEvent = new ScriptEvent(ScriptEvent.RESUME, true);
+
+      dispatchEvent(event);
+
+      if (event.eventCanceled) return;
+
+      if (FlxG.sound.music != null && !startingSong && !isInCutscene) resyncVocals();
+
+      // Resume the countdown.
+      Countdown.resumeCountdown();
+
+      #if discord_rpc
+      if (startTimer.finished)
+      {
+        DiscordClient.changePresence(detailsText, '${currentChart.songName} ($storyDifficultyText)', iconRPC, true,
+          currentSongLengthMs - Conductor.songPosition);
+      }
+      else
+      {
+        DiscordClient.changePresence(detailsText, '${currentChart.songName} ($storyDifficultyText)', iconRPC);
+      }
+      #end
+    }
+
+    super.closeSubState();
+  }
+
+  #if discord_rpc
+  /**
+   * Function called when the game window gains focus.
+   */
+  public override function onFocus():Void
+  {
+    if (health > 0 && !paused && FlxG.autoPause)
+    {
+      if (Conductor.songPosition > 0.0) DiscordClient.changePresence(detailsText, currentSong.song
+        + ' ('
+        + storyDifficultyText
+        + ')', iconRPC, true,
+        currentSongLengthMs
+        - Conductor.songPosition);
+      else
+        DiscordClient.changePresence(detailsText, currentSong.song + ' (' + storyDifficultyText + ')', iconRPC);
+    }
+
+    super.onFocus();
+  }
+
+  /**
+   * Function called when the game window loses focus.
+   */
+  public override function onFocusLost():Void
+  {
+    if (health > 0 && !paused && FlxG.autoPause) DiscordClient.changePresence(detailsPausedText, currentSong.song + ' (' + storyDifficultyText + ')', iconRPC);
+
+    super.onFocusLost();
+  }
+  #end
+
+  /**
+   * This function is called whenever Flixel switches switching to a new FlxState.
+   * @return Whether to actually switch to the new state.
+   */
+  override function switchTo(nextState:FlxState):Bool
+  {
+    var result:Bool = super.switchTo(nextState);
+
+    if (result)
+    {
+      performCleanup();
+    }
+
+    return result;
+  }
+
+  /**
+   * Removes any references to the current stage, then clears the stage cache,
+   * then reloads all the stages.
+   *
+   * This is useful for when you want to edit a stage without reloading the whole game.
+   * Reloading works on both the JSON and the HXC, if applicable.
+   *
+   * Call this by pressing F5 on a debug build.
+   */
+  override function debug_refreshModules():Void
+  {
+    // Remove the current stage. If the stage gets deleted while it's still in use,
+    // it'll probably crash the game or something.
+    if (this.currentStage != null)
+    {
+      remove(currentStage);
+      var event:ScriptEvent = new ScriptEvent(ScriptEvent.DESTROY, false);
+      ScriptEventDispatcher.callEvent(currentStage, event);
+      currentStage = null;
+    }
+
+    // Stop the vocals.
+    if (vocals != null)
+    {
+      vocals.stop();
+    }
+
+    super.debug_refreshModules();
+
+    var event:ScriptEvent = new ScriptEvent(ScriptEvent.CREATE, false);
+    ScriptEventDispatcher.callEvent(currentSong, event);
+  }
+
+  override function stepHit():Bool
+  {
+    // super.stepHit() returns false if a module cancelled the event.
+    if (!super.stepHit()) return false;
+
+    if (Math.abs(FlxG.sound.music.time - (Conductor.songPosition - Conductor.offset)) > 200
+      || Math.abs(vocals.checkSyncError(Conductor.songPosition - Conductor.offset)) > 200)
+    {
+      trace("VOCALS NEED RESYNC");
+      if (vocals != null) trace(vocals.checkSyncError(Conductor.songPosition - Conductor.offset));
+      trace(FlxG.sound.music.time - (Conductor.songPosition - Conductor.offset));
+      resyncVocals();
+    }
+
+    if (iconP1 != null) iconP1.onStepHit(Std.int(Conductor.currentStep));
+    if (iconP2 != null) iconP2.onStepHit(Std.int(Conductor.currentStep));
+
+    return true;
+  }
+
+  override function beatHit():Bool
+  {
+    // super.beatHit() returns false if a module cancelled the event.
+    if (!super.beatHit()) return false;
+
+    if (generatedMusic)
+    {
+      // TODO: Sort more efficiently, or less often, to improve performance.
+      // activeNotes.sort(SortUtil.byStrumtime, FlxSort.DESCENDING);
+    }
+
+    // Only zoom camera if we are zoomed by less than 35%.
+    if (FlxG.camera.zoom < (1.35 * defaultCameraZoom) && cameraZoomRate > 0 && Conductor.currentBeat % cameraZoomRate == 0)
+    {
+      // Zoom camera in (1.5%)
+      FlxG.camera.zoom += cameraZoomIntensity * defaultCameraZoom;
+      // Hud zooms double (3%)
+      camHUD.zoom += hudCameraZoomIntensity * defaultHUDCameraZoom;
+    }
+    // trace('Not bopping camera: ${FlxG.camera.zoom} < ${(1.35 * defaultCameraZoom)} && ${cameraZoomRate} > 0 && ${Conductor.currentBeat} % ${cameraZoomRate} == ${Conductor.currentBeat % cameraZoomRate}}');
+
+    // That combo milestones that got spoiled that one time.
+    // Comes with NEAT visual and audio effects.
+
+    // bruh this var is bonkers i thot it was a function lmfaooo
+
+    // Break up into individual lines to aid debugging.
+
+    var shouldShowComboText:Bool = false;
+    // TODO: Re-enable combo text (how to do this without sections?).
+    // if (currentSong != null)
+    // {
+    //  shouldShowComboText = (Conductor.currentBeat % 8 == 7);
+    //  var daSection = .getSong()[Std.int(Conductor.currentBeat / 16)];
+    //  shouldShowComboText = shouldShowComboText && (daSection != null && daSection.mustHitSection);
+    //  shouldShowComboText = shouldShowComboText && (Highscore.tallies.combo > 5);
+    //
+    //  var daNextSection = .getSong()[Std.int(Conductor.currentBeat / 16) + 1];
+    //  var isEndOfSong = .getSong().length < Std.int(Conductor.currentBeat / 16);
+    //  shouldShowComboText = shouldShowComboText && (isEndOfSong || (daNextSection != null && !daNextSection.mustHitSection));
+    // }
+
+    if (shouldShowComboText)
+    {
+      var animShit:ComboMilestone = new ComboMilestone(-100, 300, Highscore.tallies.combo);
+      animShit.scrollFactor.set(0.6, 0.6);
+      animShit.cameras = [camHUD];
+      add(animShit);
+
+      var frameShit:Float = (1 / 24) * 2; // equals 2 frames in the animation
+
+      new FlxTimer().start(((Conductor.beatLengthMs / 1000) * 1.25) - frameShit, function(tmr) {
+        animShit.forceFinish();
+      });
+    }
+
+    if (playerStrumline != null) playerStrumline.onBeatHit();
+    if (opponentStrumline != null) opponentStrumline.onBeatHit();
+
+    // Make the characters dance on the beat
+    danceOnBeat();
+
+    return true;
+  }
+
+  override function destroy():Void
+  {
+    if (currentConversation != null)
+    {
+      remove(currentConversation);
+      currentConversation.kill();
+    }
+
+    super.destroy();
+  }
+
+  /**
+   * Handles characters dancing to the beat of the current song.
+   *
+   * TODO: Move some of this logic into `Bopper.hx`, or individual character scripts.
+   */
+  function danceOnBeat():Void
+  {
+    if (currentStage == null) return;
+
+    // TODO: Add HEY! song events to Tutorial.
+    if (Conductor.currentBeat % 16 == 15
+      && currentStage.getDad().characterId == 'gf'
+      && Conductor.currentBeat > 16
+      && Conductor.currentBeat < 48)
+    {
+      currentStage.getBoyfriend().playAnimation('hey', true);
+      currentStage.getDad().playAnimation('cheer', true);
+    }
+  }
+
+  /**
+   * Initializes the game and HUD cameras.
+   */
+  function initCameras():Void
+  {
+    camGame = new SwagCamera();
+    camHUD = new FlxCamera();
+    camHUD.bgColor.alpha = 0; // Show the game scene behind the camera.
+    camCutscene = new FlxCamera();
+    camCutscene.bgColor.alpha = 0; // Show the game scene behind the camera.
+
+    FlxG.cameras.reset(camGame);
+    FlxG.cameras.add(camHUD, false);
+    FlxG.cameras.add(camCutscene, false);
+
+    // Configure camera follow point.
+    if (previousCameraFollowPoint != null)
+    {
+      cameraFollowPoint.setPosition(previousCameraFollowPoint.x, previousCameraFollowPoint.y);
+      previousCameraFollowPoint = null;
+    }
+    add(cameraFollowPoint);
+  }
+
+  /**
+   * Initializes the health bar on the HUD.
+   */
+  function initHealthBar():Void
+  {
+    var healthBarYPos:Float = PreferencesMenu.getPref('downscroll') ? FlxG.height * 0.1 : FlxG.height * 0.9;
+    healthBarBG = new FlxSprite(0, healthBarYPos).loadGraphic(Paths.image('healthBar'));
+    healthBarBG.screenCenter(X);
+    healthBarBG.scrollFactor.set(0, 0);
+    add(healthBarBG);
+
+    healthBar = new FlxBar(healthBarBG.x + 4, healthBarBG.y + 4, RIGHT_TO_LEFT, Std.int(healthBarBG.width - 8), Std.int(healthBarBG.height - 8), this,
+      'healthLerp', 0, 2);
+    healthBar.scrollFactor.set();
+    healthBar.createFilledBar(Constants.COLOR_HEALTH_BAR_RED, Constants.COLOR_HEALTH_BAR_GREEN);
+    add(healthBar);
+
+    // The score text below the health bar.
+    scoreText = new FlxText(healthBarBG.x + healthBarBG.width - 190, healthBarBG.y + 30, 0, '', 20);
+    scoreText.setFormat(Paths.font('vcr.ttf'), 16, FlxColor.WHITE, RIGHT, FlxTextBorderStyle.OUTLINE, FlxColor.BLACK);
+    scoreText.scrollFactor.set();
+    add(scoreText);
+
+    // Move the health bar to the HUD camera.
+    healthBar.cameras = [camHUD];
+    healthBarBG.cameras = [camHUD];
+    scoreText.cameras = [camHUD];
+  }
+
+  /**
+   * Generates the stage and all its props.
+   */
+  function initStage():Void
+  {
+    loadStage(currentStageId);
+  }
+
+  /**
+   * Loads stage data from cache, assembles the props,
+   * and adds it to the state.
+   * @param id
+   */
+  function loadStage(id:String):Void
+  {
+    currentStage = StageDataParser.fetchStage(id);
+
+    if (currentStage != null)
+    {
+      // Actually create and position the sprites.
+      var event:ScriptEvent = new ScriptEvent(ScriptEvent.CREATE, false);
+      ScriptEventDispatcher.callEvent(currentStage, event);
+
+      // Apply camera zoom level from stage data.
+      defaultCameraZoom = currentStage.camZoom;
+
+      // Add the stage to the scene.
+      this.add(currentStage);
+
+      #if debug
+      FlxG.console.registerObject('stage', currentStage);
+      #end
+    }
+    else
+    {
+      // lolol
+      lime.app.Application.current.window.alert('Nice job, you ignoramus. $id isn\'t a real stage.\nI\'m falling back to the default so the game doesn\'t shit itself.',
+        'Stage Error');
+    }
+  }
+
+  /**
+   * Generates the character sprites and adds them to the stage.
+   */
+  function initCharacters():Void
+  {
+    if (currentSong == null || currentChart == null)
+    {
+      trace('Song difficulty could not be loaded.');
+    }
+
+    // Switch the character we are playing as by manipulating currentPlayerId.
+    // TODO: How to choose which one to use for story mode?
+    var playableChars:Array<String> = currentChart.getPlayableChars();
+
+    if (playableChars.length == 0)
+    {
+      trace('WARNING: No playable characters found for this song.');
+    }
+    else if (playableChars.indexOf(currentPlayerId) == -1)
+    {
+      currentPlayerId = playableChars[0];
+    }
+
+    //
+    var currentCharData:SongPlayableChar = currentChart.getPlayableChar(currentPlayerId);
+
+    //
+    // GIRLFRIEND
+    //
+    var girlfriend:BaseCharacter = CharacterDataParser.fetchCharacter(currentCharData.girlfriend);
+
+    if (girlfriend != null)
+    {
+      girlfriend.characterType = CharacterType.GF;
+    }
+    else if (currentCharData.girlfriend != '')
+    {
+      trace('WARNING: Could not load girlfriend character with ID ${currentCharData.girlfriend}, skipping...');
+    }
+    else
+    {
+      // Chosen GF was '' so we don't load one.
+    }
+
+    //
+    // DAD
+    //
+    var dad:BaseCharacter = CharacterDataParser.fetchCharacter(currentCharData.opponent);
+
+    if (dad != null)
+    {
+      dad.characterType = CharacterType.DAD;
+    }
+
+    //
+    // OPPONENT HEALTH ICON
+    //
+    iconP2 = new HealthIcon(currentCharData.opponent, 1);
+    iconP2.y = healthBar.y - (iconP2.height / 2);
+    dad.initHealthIcon(true);
+    add(iconP2);
+    iconP2.cameras = [camHUD];
+
+    //
+    // BOYFRIEND
+    //
+    var boyfriend:BaseCharacter = CharacterDataParser.fetchCharacter(currentPlayerId);
+
+    if (boyfriend != null)
+    {
+      boyfriend.characterType = CharacterType.BF;
+    }
+
+    //
+    // PLAYER HEALTH ICON
+    //
+    iconP1 = new HealthIcon(currentPlayerId, 0);
+    iconP1.y = healthBar.y - (iconP1.height / 2);
+    boyfriend.initHealthIcon(false);
+    add(iconP1);
+    iconP1.cameras = [camHUD];
+
+    //
+    // ADD CHARACTERS TO SCENE
+    //
+
+    if (currentStage != null)
+    {
+      // Characters get added to the stage, not the main scene.
+      if (girlfriend != null)
+      {
+        currentStage.addCharacter(girlfriend, GF);
+
+        #if debug
+        FlxG.console.registerObject('gf', girlfriend);
+        #end
+      }
+
+      if (boyfriend != null)
+      {
+        currentStage.addCharacter(boyfriend, BF);
+
+        #if debug
+        FlxG.console.registerObject('bf', boyfriend);
+        #end
+      }
+
+      if (dad != null)
+      {
+        currentStage.addCharacter(dad, DAD);
+        // Camera starts at dad.
+        cameraFollowPoint.setPosition(dad.cameraFocusPoint.x, dad.cameraFocusPoint.y);
+
+        #if debug
+        FlxG.console.registerObject('dad', dad);
+        #end
+      }
+
+      // Rearrange by z-indexes.
+      currentStage.refresh();
+    }
+  }
+
+  /**
+   * Constructs the strumlines for each player.
+   */
+  function initStrumlines():Void
+  {
+    // var strumlineStyle:StrumlineStyle = NORMAL;
+    //
+    //// TODO: Put this in the chart or something?
+    // switch (currentStageId)
+    // {
+    //  case 'school':
+    //    strumlineStyle = PIXEL;
+    //  case 'schoolEvil':
+    //    strumlineStyle = PIXEL;
+    // }
+
+    playerStrumline = new Strumline(true);
+    opponentStrumline = new Strumline(false);
+    add(playerStrumline);
+    add(opponentStrumline);
+
+    // Position the player strumline on the right
+    playerStrumline.x = FlxG.width - playerStrumline.width - Constants.STRUMLINE_X_OFFSET;
+    playerStrumline.y = PreferencesMenu.getPref('downscroll') ? FlxG.height - playerStrumline.height - Constants.STRUMLINE_Y_OFFSET : Constants.STRUMLINE_Y_OFFSET;
+    playerStrumline.zIndex = 200;
+    playerStrumline.cameras = [camHUD];
+
+    // Position the opponent strumline on the left
+    opponentStrumline.x = Constants.STRUMLINE_X_OFFSET;
+    opponentStrumline.y = PreferencesMenu.getPref('downscroll') ? FlxG.height - opponentStrumline.height - Constants.STRUMLINE_Y_OFFSET : Constants.STRUMLINE_Y_OFFSET;
+    opponentStrumline.zIndex = 100;
+    opponentStrumline.cameras = [camHUD];
+
+    if (!PlayStatePlaylist.isStoryMode)
+    {
+      playerStrumline.fadeInArrows();
+    }
+
+    if (!PlayStatePlaylist.isStoryMode)
+    {
+      opponentStrumline.fadeInArrows();
+    }
+
+    this.refresh();
+  }
+
+  /**
+   * Initializes the Discord Rich Presence.
+   */
+  function initDiscord():Void
+  {
+    #if discord_rpc
+    storyDifficultyText = difficultyString();
+    iconRPC = currentSong.player2;
+
+    // To avoid having duplicate images in Discord assets
+    switch (iconRPC)
+    {
+      case 'senpai-angry':
+        iconRPC = 'senpai';
+      case 'monster-christmas':
+        iconRPC = 'monster';
+      case 'mom-car':
+        iconRPC = 'mom';
+    }
+
+    // String that contains the mode defined here so it isn't necessary to call changePresence for each mode
+    detailsText = isStoryMode ? 'Story Mode: Week $storyWeek' : 'Freeplay';
+    detailsPausedText = 'Paused - $detailsText';
+
+    // Updating Discord Rich Presence.
+    DiscordClient.changePresence(detailsText, '${currentChart.songName} ($storyDifficultyText)', iconRPC);
+    #end
+  }
+
+  function initPreciseInputs():Void
+  {
+    FlxG.keys.preventDefaultKeys = [];
+    PreciseInputManager.instance.onInputPressed.add(onKeyPress);
+    PreciseInputManager.instance.onInputReleased.add(onKeyRelease);
+  }
+
+  /**
+   * Initializes the song (applying the chart, generating the notes, etc.)
+   * Should be done before the countdown starts.
+   */
+  function generateSong():Void
+  {
+    if (currentChart == null)
+    {
+      trace('Song difficulty could not be loaded.');
+    }
+
+    Conductor.forceBPM(currentChart.getStartingBPM());
+
+    vocals = currentChart.buildVocals(currentPlayerId);
+    if (vocals.members.length == 0)
+    {
+      trace('WARNING: No vocals found for this song.');
+    }
+
+    regenNoteData();
+
+    generatedMusic = true;
+  }
+
+  /**
+   * Read note data from the chart and generate the notes.
+   */
+  function regenNoteData():Void
+  {
+    Highscore.tallies.combo = 0;
+    Highscore.tallies = new Tallies();
+
+    // Reset song events.
+    songEvents = currentChart.getEvents();
+    SongEventParser.resetEvents(songEvents);
+
+    // TODO: Put this in the chart or something?
+    // var strumlineStyle:StrumlineStyle = null;
+    // switch (currentStageId)
+    // {
+    //   case 'school':
+    //     strumlineStyle = PIXEL;
+    //   case 'schoolEvil':
+    //     strumlineStyle = PIXEL;
+    //   default:
+    //     strumlineStyle = NORMAL;
+    // }
+
+    // Reset the notes on each strumline.
+    var noteData:Array<SongNoteData> = currentChart.notes;
+    var playerNoteData:Array<SongNoteData> = [];
+    var opponentNoteData:Array<SongNoteData> = [];
+
+    for (songNote in currentChart.notes)
+    {
+      var strumTime:Float = songNote.time;
+      var noteData:Int = songNote.getDirection();
+
+      var playerNote:Bool = true;
+
+      if (noteData > 3) playerNote = false;
+
+      switch (songNote.getStrumlineIndex())
+      {
+        case 0:
+          playerNoteData.push(songNote);
+        case 1:
+          opponentNoteData.push(songNote);
+      }
+    }
+
+    playerStrumline.applyNoteData(playerNoteData);
+    opponentStrumline.applyNoteData(opponentNoteData);
+  }
+
+  /**
+   * Prepares to start the countdown.
+   * Ends any running cutscenes, creates the strumlines, and starts the countdown.
+   * This is public so that scripts can call it.
+   */
+  public function startCountdown():Void
+  {
+    // If Countdown.performCountdown returns false, then the countdown was canceled by a script.
+    var result:Bool = Countdown.performCountdown(currentStageId.startsWith('school'));
+    if (!result) return;
+
+    isInCutscene = false;
+    camCutscene.visible = false;
+    camHUD.visible = true;
+  }
+
+  /**
+   * Displays a dialogue cutscene with the given ID.
+   * This is used by song scripts to display dialogue.
+   */
+  public function startConversation(conversationId:String):Void
+  {
+    isInCutscene = true;
+
+    currentConversation = ConversationDataParser.fetchConversation(conversationId);
+    if (currentConversation == null) return;
+
+    currentConversation.completeCallback = onConversationComplete;
+    currentConversation.cameras = [camCutscene];
+    currentConversation.zIndex = 1000;
+    add(currentConversation);
+    refresh();
+
+    var event:ScriptEvent = new ScriptEvent(ScriptEvent.CREATE, false);
+    ScriptEventDispatcher.callEvent(currentConversation, event);
+  }
+
+  /**
+   * Handler function called when a conversation ends.
+   */
+  function onConversationComplete():Void
+  {
+    isInCutscene = true;
+    remove(currentConversation);
+    currentConversation = null;
+
+    if (startingSong && !isInCountdown)
+    {
+      startCountdown();
+    }
+  }
+
+  /**
+   * Starts playing the song after the countdown has completed.
+   */
+  function startSong():Void
+  {
+    dispatchEvent(new ScriptEvent(ScriptEvent.SONG_START));
+
+    startingSong = false;
+
+    if (!isGamePaused && currentChart != null)
+    {
+      currentChart.playInst(1.0, false);
+    }
+
+    FlxG.sound.music.onComplete = endSong;
+    trace('Playing vocals...');
+    add(vocals);
+    vocals.play();
+
+    #if discord_rpc
+    // Updating Discord Rich Presence (with Time Left)
+    DiscordClient.changePresence(detailsText, '${currentChart.songName} ($storyDifficultyText)', iconRPC, true, currentSongLengthMs);
+    #end
+  }
+
+  /**
+   * Resyncronize the vocal tracks if they have become offset from the instrumental.
+   */
+  function resyncVocals():Void
+  {
+    if (_exiting || vocals == null) return;
+
+    vocals.pause();
+
+    FlxG.sound.music.play();
+    Conductor.update();
+
+    vocals.time = FlxG.sound.music.time;
+    vocals.play();
+  }
+
+  /**
+   * Updates the position and contents of the score display.
+   */
+  function updateScoreText():Void
+  {
+    // TODO: Add functionality for modules to update the score text.
+    scoreText.text = 'Score:' + songScore;
+  }
+
+  /**
+   * Updates the values of the health bar.
+   */
+  function updateHealthBar():Void
+  {
+    healthLerp = FlxMath.lerp(healthLerp, health, 0.15);
+  }
+
+  /**
+   * Callback executed when one of the note keys is pressed.
+   */
+  function onKeyPress(event:PreciseInputEvent):Void
+  {
+    // Do the minimal possible work here.
+    inputPressQueue.push(event);
+  }
+
+  /**
+   * Callback executed when one of the note keys is released.
+   */
+  function onKeyRelease(event:PreciseInputEvent):Void
+  {
+    // Do the minimal possible work here.
+    inputReleaseQueue.push(event);
+  }
+
+  /**
+   * Handles opponent note hits and player note misses.
+   */
+  function processNotes():Void
+  {
+    // Process notes on the opponent's side.
+    for (note in opponentStrumline.notes.members)
+    {
+      if (note == null) continue;
+
+      var hitWindowStart = note.strumTime - Conductor.HIT_WINDOW_MS;
+      var hitWindowCenter = note.strumTime;
+      var hitWindowEnd = note.strumTime + Conductor.HIT_WINDOW_MS;
+
+      if (Conductor.songPosition > hitWindowEnd)
+      {
+        note.tooEarly = false;
+        note.mayHit = false;
+        note.tooLate = true;
+      }
+      else if (Conductor.songPosition > hitWindowCenter)
+      {
+        // Call an event to allow canceling the note hit.
+        // NOTE: This is what handles the character animations!
+        var event:NoteScriptEvent = new NoteScriptEvent(ScriptEvent.NOTE_HIT, note, 0, true);
+        dispatchEvent(event);
+
+        // Calling event.cancelEvent() skips all the other logic! Neat!
+        if (event.eventCanceled) continue;
+
+        // Command the opponent to hit the note on time.
+        // NOTE: This is what handles the strumline and cleaning up the note itself!
+        opponentStrumline.hitNote(note);
+
+        // scoreNote();
+      }
+      else if (Conductor.songPosition > hitWindowStart)
+      {
+        note.tooEarly = false;
+        note.mayHit = true;
+        note.tooLate = false;
+      }
+      else
+      {
+        note.tooEarly = true;
+        note.mayHit = false;
+        note.tooLate = false;
+      }
+    }
+
+    // Process notes on the player's side.
+    for (note in playerStrumline.notes.members)
+    {
+      if (note == null || note.hasBeenHit) continue;
+
+      // If this is true, the note is already properly off the screen.
+      if (note.hasMissed)
+      {
+        // Call an event to allow canceling the note miss.
+        // NOTE: This is what handles the character animations!
+        var event:NoteScriptEvent = new NoteScriptEvent(ScriptEvent.NOTE_MISS, note, 0, true);
+        dispatchEvent(event);
+
+        // Calling event.cancelEvent() skips all the other logic! Neat!
+        if (event.eventCanceled) continue;
+
+        // Judge the miss.
+        // NOTE: This is what handles the scoring.
+        onNoteMiss(note);
+
+        // Kill the note.
+        // NOTE: This is what handles recycling the note graphic.
+        playerStrumline.killNote(note);
+      }
+    }
+  }
+
+  /**
+   * Spitting out the input for ravy 🙇‍♂️!!
+   */
+  var inputSpitter:Array<ScoreInput> = [];
+
+  /**
+   * PreciseInputEvents are put into a queue between update() calls,
+   * and then processed here.
+   */
+  function processInputQueue():Void
+  {
+    if (inputPressQueue.length + inputReleaseQueue.length == 0) return;
+
+    // Ignore inputs during cutscenes.
+    if (isInCutscene || disableKeys)
+    {
+      inputPressQueue = [];
+      inputReleaseQueue = [];
+      return;
+    }
+
+    // Generate a list of notes within range.
+    var notesInRange:Array<NoteSprite> = playerStrumline.getNotesInRange(Conductor.songPosition, Conductor.HIT_WINDOW_MS);
+
+    // If there are notes in range, pressing a key will cause a ghost miss.
+    // var canMiss:Bool = notesInRange.length > 0;
+    var canMiss:Bool = true; // Forced to true for consistency with other input systems.
+
+    var notesByDirection:Array<Array<NoteSprite>> = [[], [], [], []];
+
+    for (note in notesInRange)
+      notesByDirection[note.direction].push(note);
+
+    while (inputPressQueue.length > 0)
+    {
+      var input:PreciseInputEvent = inputPressQueue.shift();
+
+      var notesInDirection:Array<NoteSprite> = notesByDirection[input.noteDirection];
+
+      if (canMiss && notesInDirection.length == 0)
+      {
+        // Pressed a wrong key with notes in range.
+        // Perform a ghost miss.
+        ghostNoteMiss(input.noteDirection, notesInRange.length > 0);
+
+        // Play the strumline animation.
+        playerStrumline.playPress(input.noteDirection);
+      }
+      else if (notesInDirection.length > 0)
+      {
+        // Choose the first note, deprioritizing low priority notes.
+        var targetNote:Null<NoteSprite> = notesInDirection.find((note) -> !note.lowPriority);
+        if (targetNote == null) targetNote = notesInDirection[0];
+        if (targetNote == null) continue;
+
+        // Judge and hit the note.
+        goodNoteHit(targetNote, input);
+
+        targetNote.visible = false;
+        targetNote.kill();
+
+        // Play the strumline animation.
+        playerStrumline.playConfirm(input.noteDirection);
+      }
+      else
+      {
+        // Play the strumline animation.
+        playerStrumline.playPress(input.noteDirection);
+      }
+    }
+
+    while (inputReleaseQueue.length > 0)
+    {
+      var input:PreciseInputEvent = inputReleaseQueue.shift();
+
+      // Play the strumline animation.
+      playerStrumline.playStatic(input.noteDirection);
+    }
+  }
+
+  /**
+   * Handle player inputs.
+   */
+  function keyShit(test:Bool):Void
+  {
+    // control arrays, order L D R U
+    var holdArray:Array<Bool> = [controls.NOTE_LEFT, controls.NOTE_DOWN, controls.NOTE_UP, controls.NOTE_RIGHT];
+    var pressArray:Array<Bool> = [
+      controls.NOTE_LEFT_P,
+      controls.NOTE_DOWN_P,
+      controls.NOTE_UP_P,
+      controls.NOTE_RIGHT_P
+    ];
+    var releaseArray:Array<Bool> = [
+      controls.NOTE_LEFT_R,
+      controls.NOTE_DOWN_R,
+      controls.NOTE_UP_R,
+      controls.NOTE_RIGHT_R
+    ];
+
+    // if (pressArray.contains(true))
+    // {
+    //   var lol:Array<Int> = cast pressArray;
+    //   inputSpitter.push(Std.int(Conductor.songPosition) + ' ' + lol.join(' '));
+    // }
+
+    // HOLDS, check for sustain notes
+    if (holdArray.contains(true) && generatedMusic)
+    {
+      /*
+        activeNotes.forEachAlive(function(daNote:Note) {
+          if (daNote.isSustainNote && daNote.canBeHit && daNote.mustPress && holdArray[daNote.data.noteData]) goodNoteHit(daNote);
+        });
+       */
+    }
+
+    // PRESSES, check for note hits
+    if (pressArray.contains(true) && generatedMusic)
+    {
+      Haptic.vibrate(100, 100);
+
+      if (currentStage != null && currentStage.getBoyfriend() != null)
+      {
+        currentStage.getBoyfriend().holdTimer = 0;
+      }
+
+      var possibleNotes:Array<NoteSprite> = []; // notes that can be hit
+      var directionList:Array<Int> = []; // directions that can be hit
+      var dumbNotes:Array<NoteSprite> = []; // notes to kill later
+
+      /*
+        activeNotes.forEachAlive(function(daNote:Note) {
+          if (daNote.canBeHit && daNote.mustPress && !daNote.tooLate && !daNote.hasBeenHit)
+          {
+            if (directionList.contains(daNote.data.noteData))
+            {
+              for (coolNote in possibleNotes)
+              {
+                if (coolNote.data.noteData == daNote.data.noteData && Math.abs(daNote.data.strumTime - coolNote.data.strumTime) < 10)
+                { // if it's the same note twice at < 10ms distance, just delete it
+                  // EXCEPT u cant delete it in this loop cuz it fucks with the collection lol
+                  dumbNotes.push(daNote);
+                  break;
+                }
+                else if (coolNote.data.noteData == daNote.data.noteData && daNote.data.strumTime < coolNote.data.strumTime)
+                { // if daNote is earlier than existing note (coolNote), replace
+                  possibleNotes.remove(coolNote);
+                  possibleNotes.push(daNote);
+                  break;
+                }
+              }
+            }
+            else
+            {
+              possibleNotes.push(daNote);
+              directionList.push(daNote.data.noteData);
+            }
+          }
+        });
+       */
+
+      for (note in dumbNotes)
+      {
+        FlxG.log.add('killing dumb ass note at ' + note.noteData.time);
+        note.kill();
+        // activeNotes.remove(note, true);
+        note.destroy();
+      }
+
+      possibleNotes.sort((a, b) -> Std.int(a.noteData.time - b.noteData.time));
+
+      if (perfectMode)
+      {
+        goodNoteHit(possibleNotes[0], null);
+      }
+      else if (possibleNotes.length > 0)
+      {
+        for (shit in 0...pressArray.length)
+        { // if a direction is hit that shouldn't be
+          if (pressArray[shit] && !directionList.contains(shit)) ghostNoteMiss(shit);
+        }
+        for (coolNote in possibleNotes)
+        {
+          if (pressArray[coolNote.noteData.getDirection()]) goodNoteHit(coolNote, null);
+        }
+      }
+      else
+      {
+        // HNGGG I really want to add an option for ghost tapping
+        // L + ratio
+        for (shit in 0...pressArray.length)
+          if (pressArray[shit]) ghostNoteMiss(shit, false);
+      }
+    }
+
+    if (currentStage == null) return;
+
+    for (keyId => isPressed in pressArray)
+    {
+      if (playerStrumline == null) continue;
+
+      var dir:NoteDirection = Strumline.DIRECTIONS[keyId];
+
+      if (isPressed && !playerStrumline.isConfirm(dir)) playerStrumline.playPress(dir);
+      if (!holdArray[keyId]) playerStrumline.playStatic(dir);
+    }
+  }
+
+  function goodNoteHit(note:NoteSprite, input:PreciseInputEvent):Void
+  {
+    if (!note.hasBeenHit)
+    {
+      var event:NoteScriptEvent = new NoteScriptEvent(ScriptEvent.NOTE_HIT, note, Highscore.tallies.combo + 1, true);
+      dispatchEvent(event);
+
+      // Calling event.cancelEvent() skips all the other logic! Neat!
+      if (event.eventCanceled) return;
+
+      if (!note.isSustainNote)
+      {
+        Highscore.tallies.combo++;
+        Highscore.tallies.totalNotesHit++;
+
+        if (Highscore.tallies.combo > Highscore.tallies.maxCombo) Highscore.tallies.maxCombo = Highscore.tallies.combo;
+
+        popUpScore(note, input);
+      }
+
+      playerStrumline.playConfirm(note.noteData.getDirection());
+
+      note.hasBeenHit = true;
+      vocals.playerVolume = 1;
+
+      if (!note.isSustainNote)
+      {
+        note.kill();
+        // activeNotes.remove(note, true);
+        note.destroy();
+      }
+    }
+  }
+
+  /**
+   * Called when a note leaves the screen and is considered missed by the player.
+   * @param note
+   */
+  function onNoteMiss(note:NoteSprite):Void
+  {
+    // a MISS is when you let a note scroll past you!!
+    Highscore.tallies.missed++;
+
+    var event:NoteScriptEvent = new NoteScriptEvent(ScriptEvent.NOTE_MISS, note, Highscore.tallies.combo, true);
+    dispatchEvent(event);
+    // Calling event.cancelEvent() skips all the other logic! Neat!
+    if (event.eventCanceled) return;
+
+    health -= 0.0775;
+
+    if (!isPracticeMode)
+    {
+      songScore -= 10;
+
+      // messy copy paste rn lol
+      var pressArray:Array<Bool> = [
+        controls.NOTE_LEFT_P,
+        controls.NOTE_DOWN_P,
+        controls.NOTE_UP_P,
+        controls.NOTE_RIGHT_P
+      ];
+
+      var indices:Array<Int> = [];
+      for (i in 0...pressArray.length)
+      {
+        if (pressArray[i]) indices.push(i);
+      }
+      if (indices.length > 0)
+      {
+        for (i in 0...indices.length)
+        {
+          inputSpitter.push(
+            {
+              t: Std.int(Conductor.songPosition),
+              d: indices[i],
+              l: 20
+            });
+        }
+      }
+      else
+      {
+        inputSpitter.push(
+          {
+            t: Std.int(Conductor.songPosition),
+            d: -1,
+            l: 20
+          });
+      }
+    }
+    vocals.playerVolume = 0;
+
+    if (Highscore.tallies.combo != 0)
+    {
+      Highscore.tallies.combo = comboPopUps.displayCombo(0);
+    }
+
+    if (event.playSound)
+    {
+      vocals.playerVolume = 0;
+      FlxG.sound.play(Paths.soundRandom('missnote', 1, 3), FlxG.random.float(0.1, 0.2));
+    }
+
+    note.active = false;
+    note.visible = false;
+
+    note.kill();
+    // activeNotes.remove(note, true);
+    note.destroy();
+  }
+
+  /**
+   * Called when a player presses a key with no note present.
+   * Scripts can modify the amount of health/score lost, whether player animations or sounds are used,
+   * or even cancel the event entirely.
+   *
+   * @param direction
+   * @param hasPossibleNotes
+   */
+  function ghostNoteMiss(direction:NoteDirection, hasPossibleNotes:Bool = true):Void
+  {
+    var event:GhostMissNoteScriptEvent = new GhostMissNoteScriptEvent(direction, // Direction missed in.
+      hasPossibleNotes, // Whether there was a note you could have hit.
+      - 0.035 * 2, // How much health to add (negative).
+      - 10 // Amount of score to add (negative).
+    );
+    dispatchEvent(event);
+
+    // Calling event.cancelEvent() skips animations and penalties. Neat!
+    if (event.eventCanceled) return;
+
+    health += event.healthChange;
+
+    if (!isPracticeMode)
+    {
+      songScore += event.scoreChange;
+
+      var pressArray:Array<Bool> = [
+        controls.NOTE_LEFT_P,
+        controls.NOTE_DOWN_P,
+        controls.NOTE_UP_P,
+        controls.NOTE_RIGHT_P
+      ];
+
+      var indices:Array<Int> = [];
+      for (i in 0...pressArray.length)
+      {
+        if (pressArray[i]) indices.push(i);
+      }
+      for (i in 0...indices.length)
+      {
+        inputSpitter.push(
+          {
+            t: Std.int(Conductor.songPosition),
+            d: indices[i],
+            l: 20
+          });
+      }
+    }
+
+    if (event.playSound)
+    {
+      vocals.playerVolume = 0;
+      FlxG.sound.play(Paths.soundRandom('missnote', 1, 3), FlxG.random.float(0.1, 0.2));
+    }
+  }
+
+  /**
+   * Debug keys. Disabled while in cutscenes.
+   */
+  function debugKeyShit():Void
+  {
+    #if !debug
+    perfectMode = false;
+    #else
+    if (FlxG.keys.justPressed.H) camHUD.visible = !camHUD.visible;
+    #end
+
+    if (FlxG.keys.justPressed.F4) FlxG.switchState(new MainMenuState());
+
+    if (FlxG.keys.justPressed.F5) debug_refreshModules();
+
+    // Press U to open stage ditor.
+    if (FlxG.keys.justPressed.U)
+    {
+      // hack for HaxeUI generation, doesn't work unless persistentUpdate is false at state creation!!
+      disableKeys = true;
+      persistentUpdate = false;
+      openSubState(new StageOffsetSubState());
+    }
+
+    #if debug
+    // 1: End the song immediately.
+    if (FlxG.keys.justPressed.ONE) endSong();
+
+    // 2: Gain 10% health.
+    if (FlxG.keys.justPressed.TWO) health += 0.1 * 2.0;
+
+    // 3: Lose 5% health.
+    if (FlxG.keys.justPressed.THREE) health -= 0.05 * 2.0;
+    #end
+
+    // 7: Move to the charter.
+    if (FlxG.keys.justPressed.SEVEN)
+    {
+      lime.app.Application.current.window.alert("Press ~ on the main menu to get to the editor", 'LOL');
+    }
+
+    // 8: Move to the offset editor.
+    if (FlxG.keys.justPressed.EIGHT) FlxG.switchState(new funkin.ui.animDebugShit.DebugBoundingState());
+
+    // 9: Toggle the old icon.
+    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);
+    #end
+
+    if (FlxG.keys.justPressed.B) trace(inputSpitter.join('\n'));
+  }
+
+  /**
+   * Handles health, score, and rating popups when a note is hit.
+   */
+  function popUpScore(daNote:NoteSprite, input:PreciseInputEvent):Void
+  {
+    vocals.playerVolume = 1;
+
+    // Calculate the input latency (do this as late as possible).
+    var inputLatencyMs:Float = haxe.Int64.toInt(PreciseInputManager.getCurrentTimestamp() - input.timestamp) / 1000.0 / 1000.0;
+    trace('Input: ${daNote.noteData.getDirectionName()} pressed ${inputLatencyMs}ms ago!');
+
+    // Get the offset and compensate for input latency.
+    // Round inward (trim remainder) for consistency.
+    var noteDiff:Int = Std.int(Conductor.songPosition - daNote.noteData.time - inputLatencyMs);
+
+    var score = Scoring.scoreNote(noteDiff, PBOT1);
+    var daRating = Scoring.judgeNote(noteDiff, PBOT1);
+
+    var isSick:Bool = false;
+    var healthMulti:Float = 0;
+
+    switch (daRating)
+    {
+      case 'killer':
+        Highscore.tallies.killer += 1;
+        healthMulti = 0.033;
+      case 'sick':
+        Highscore.tallies.sick += 1;
+        healthMulti = 0.033;
+      case 'good':
+        Highscore.tallies.good += 1;
+        healthMulti = 0.033 * 0.78;
+      case 'bad':
+        Highscore.tallies.bad += 1;
+        healthMulti = 0.033 * 0.2;
+      case 'shit':
+        Highscore.tallies.shit += 1;
+        healthMulti = 0;
+      case 'miss':
+        Highscore.tallies.missed += 1;
+        healthMulti = 0;
+    }
+
+    health += healthMulti;
+    if (daRating == "sick" || daRating == "killer")
+    {
+      playerStrumline.playNoteSplash(daNote.noteData.getDirection());
+    }
+    // Only add the score if you're not on practice mode
+    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,
+        controls.NOTE_DOWN_P,
+        controls.NOTE_UP_P,
+        controls.NOTE_RIGHT_P
+      ];
+
+      var indices:Array<Int> = [];
+      for (i in 0...pressArray.length)
+      {
+        if (pressArray[i]) indices.push(i);
+      }
+      if (indices.length > 0)
+      {
+        for (i in 0...indices.length)
+        {
+          inputSpitter.push(
+            {
+              t: Std.int(Conductor.songPosition),
+              d: indices[i],
+              l: 20
+            });
+        }
+      }
+      else
+      {
+        inputSpitter.push(
+          {
+            t: Std.int(Conductor.songPosition),
+            d: -1,
+            l: 20
+          });
+      }
+    }
+    comboPopUps.displayRating(daRating);
+    if (Highscore.tallies.combo >= 10 || Highscore.tallies.combo == 0) comboPopUps.displayCombo(Highscore.tallies.combo);
+  }
+
+  /**
+   * Handle keyboard inputs during cutscenes.
+   * This includes advancing conversations and skipping videos.
+   * @param elapsed Time elapsed since last game update.
+   */
   function handleCutsceneKeys(elapsed:Float):Void
   {
     if (currentConversation != null)
@@ -1470,7 +2264,12 @@ class PlayState extends MusicBeatState
     }
   }
 
-  public function trySkipVideoCutscene(elapsed:Float):Void
+  /**
+   * Handle logic for the skip timer.
+   * If the skip button is being held, pass the amount of time elapsed since last game update.
+   * If the skip button has been released, pass a negative number.
+   */
+  function trySkipVideoCutscene(elapsed:Float):Void
   {
     if (skipTimer == null || skipTimer.animation == null) return;
 
@@ -1492,81 +2291,9 @@ class PlayState extends MusicBeatState
     }
   }
 
-  function applyClipRect(daNote:Note):Void
-  {
-    // clipRect is applied to graphic itself so use frame Heights
-    var swagRect:FlxRect = new FlxRect(0, 0, daNote.frameWidth, daNote.frameHeight);
-    var strumLineMid:Float = playerStrumline.y + Note.swagWidth / 2;
-
-    if (PreferencesMenu.getPref('downscroll'))
-    {
-      swagRect.height = (strumLineMid - daNote.y) / daNote.scale.y;
-      swagRect.y = daNote.frameHeight - swagRect.height;
-    }
-    else
-    {
-      swagRect.y = (strumLineMid - daNote.y) / daNote.scale.y;
-      swagRect.height -= swagRect.y;
-    }
-
-    daNote.clipRect = swagRect;
-  }
-
-  function killCombo():Void
-  {
-    // Girlfriend gets sad if you combo break after hitting 5 notes.
-    if (currentStage != null && currentStage.getGirlfriend() != null)
-    {
-      if (Highscore.tallies.combo > 5 && currentStage.getGirlfriend().hasAnimation('sad'))
-      {
-        currentStage.getGirlfriend().playAnimation('sad');
-      }
-    }
-
-    if (Highscore.tallies.combo != 0)
-    {
-      Highscore.tallies.combo = comboPopUps.displayCombo(0);
-    }
-  }
-
-  #if debug
   /**
-   * Jumps forward or backward a number of sections in the song.
-   * Accounts for BPM changes, does not prevent death from skipped notes.
-   * @param sections The number of sections to jump, negative to go backwards.
+   * End the song. Handle saving high scores and transitioning to the results screen.
    */
-  function changeSection(sections:Int):Void
-  {
-    FlxG.sound.music.pause();
-
-    FlxG.sound.music.time += sections * Conductor.measureLengthMs;
-
-    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
-
   function endSong():Void
   {
     dispatchEvent(new ScriptEvent(ScriptEvent.SONG_END));
@@ -1675,6 +2402,31 @@ class PlayState extends MusicBeatState
     }
   }
 
+  /**
+   * Perform necessary cleanup before leaving the PlayState.
+   */
+  function performCleanup():Void
+  {
+    if (currentChart != null)
+    {
+      // TODO: Uncache the song.
+    }
+
+    // Remove reference to stage and remove sprites from it to save memory.
+    if (currentStage != null)
+    {
+      remove(currentStage);
+      currentStage.kill();
+      dispatchEvent(new ScriptEvent(ScriptEvent.DESTROY, false));
+      currentStage = null;
+    }
+
+    GameOverSubState.reset();
+
+    // Clear the static reference to this state.
+    instance = null;
+  }
+
   /**
    * Play the camera zoom animation and move to the results screen.
    */
@@ -1688,24 +2440,24 @@ class PlayState extends MusicBeatState
     // If the opponent is GF, zoom in on the opponent.
     // Else, if there is no GF, zoom in on BF.
     // Else, zoom in on GF.
-    var targetDad:Bool = PlayState.instance.currentStage.getDad() != null && PlayState.instance.currentStage.getDad().characterId == 'gf';
-    var targetBF:Bool = PlayState.instance.currentStage.getGirlfriend() == null && !targetDad;
+    var targetDad:Bool = currentStage.getDad() != null && currentStage.getDad().characterId == 'gf';
+    var targetBF:Bool = currentStage.getGirlfriend() == null && !targetDad;
 
     if (targetBF)
     {
-      FlxG.camera.follow(PlayState.instance.currentStage.getBoyfriend(), null, 0.05);
+      FlxG.camera.follow(currentStage.getBoyfriend(), null, 0.05);
       FlxG.camera.targetOffset.y -= 350;
       FlxG.camera.targetOffset.x += 20;
     }
     else if (targetDad)
     {
-      FlxG.camera.follow(PlayState.instance.currentStage.getDad(), null, 0.05);
+      FlxG.camera.follow(currentStage.getDad(), null, 0.05);
       FlxG.camera.targetOffset.y -= 350;
       FlxG.camera.targetOffset.x += 20;
     }
     else
     {
-      FlxG.camera.follow(PlayState.instance.currentStage.getGirlfriend(), null, 0.05);
+      FlxG.camera.follow(currentStage.getGirlfriend(), null, 0.05);
       FlxG.camera.targetOffset.y -= 350;
       FlxG.camera.targetOffset.x += 20;
     }
@@ -1743,748 +2495,13 @@ class PlayState extends MusicBeatState
     });
   }
 
-  // gives score and pops up rating
-  function popUpScore(strumtime:Float, daNote:Note):Void
-  {
-    var noteDiff:Float = Math.abs(strumtime - Conductor.songPosition);
-    // boyfriend.playAnimation('hey');
-    vocals.playerVolume = 1;
-
-    var isSick:Bool = false;
-    var score = Scoring.scoreNote(noteDiff, PBOT1);
-    var daRating = Scoring.judgeNote(noteDiff, PBOT1);
-    var healthMulti:Float = daNote.lowStakes ? 0.002 : 0.033;
-
-    if (noteDiff > Note.HIT_WINDOW * Note.BAD_THRESHOLD)
-    {
-      healthMulti *= 0; // no health on shit note
-      daRating = 'shit';
-      Highscore.tallies.shit += 1;
-      // score = 50;
-    }
-    else if (noteDiff > Note.HIT_WINDOW * Note.GOOD_THRESHOLD)
-    {
-      healthMulti *= 0.2;
-      daRating = 'bad';
-      Highscore.tallies.bad += 1;
-    }
-    else if (noteDiff > Note.HIT_WINDOW * Note.SICK_THRESHOLD)
-    {
-      healthMulti *= 0.78;
-      daRating = 'good';
-      Highscore.tallies.good += 1;
-      // score = 200;
-    }
-    else
-    {
-      isSick = true;
-    }
-
-    health += healthMulti;
-    if (isSick)
-    {
-      Highscore.tallies.sick += 1;
-      var noteSplash:NoteSplash = grpNoteSplashes.recycle(NoteSplash);
-      noteSplash.setupNoteSplash(daNote.x, daNote.y, daNote.data.noteData);
-      // new NoteSplash(daNote.x, daNote.y, daNote.noteData);
-      grpNoteSplashes.add(noteSplash);
-    }
-    // Only add the score if you're not on practice mode
-    if (!isPracticeMode)
-    {
-      songScore += score;
-
-      var pressArray:Array<Bool> = [
-        controls.NOTE_LEFT_P,
-        controls.NOTE_DOWN_P,
-        controls.NOTE_UP_P,
-        controls.NOTE_RIGHT_P
-      ];
-
-      var indices:Array<Int> = [];
-      for (i in 0...pressArray.length)
-      {
-        if (pressArray[i]) indices.push(i);
-      }
-      if (indices.length > 0)
-      {
-        for (i in 0...indices.length)
-        {
-          inputSpitter.push(
-            {
-              t: Std.int(Conductor.songPosition),
-              d: indices[i],
-              l: 20
-            });
-        }
-      }
-      else
-      {
-        inputSpitter.push(
-          {
-            t: Std.int(Conductor.songPosition),
-            d: -1,
-            l: 20
-          });
-      }
-    }
-    comboPopUps.displayRating(daRating);
-    if (Highscore.tallies.combo >= 10 || Highscore.tallies.combo == 0) comboPopUps.displayCombo(Highscore.tallies.combo);
-  }
-
   /**
-   * Spitting out the input for ravy 🙇‍♂️!!
+   * Pauses music and vocals easily.
    */
-  var inputSpitter:Array<ScoreInput> = [];
-
-  public function keyShit(test:Bool):Void
+  public function pauseMusic():Void
   {
-    if (PlayState.instance == null) return;
-
-    // control arrays, order L D R U
-    var holdArray:Array<Bool> = [controls.NOTE_LEFT, controls.NOTE_DOWN, controls.NOTE_UP, controls.NOTE_RIGHT];
-    var pressArray:Array<Bool> = [
-      controls.NOTE_LEFT_P,
-      controls.NOTE_DOWN_P,
-      controls.NOTE_UP_P,
-      controls.NOTE_RIGHT_P
-    ];
-    var releaseArray:Array<Bool> = [
-      controls.NOTE_LEFT_R,
-      controls.NOTE_DOWN_R,
-      controls.NOTE_UP_R,
-      controls.NOTE_RIGHT_R
-    ];
-
-    // if (pressArray.contains(true))
-    // {
-    //   var lol:Array<Int> = cast pressArray;
-    //   inputSpitter.push(Std.int(Conductor.songPosition) + ' ' + lol.join(' '));
-    // }
-
-    // HOLDS, check for sustain notes
-    if (holdArray.contains(true) && PlayState.instance.generatedMusic)
-    {
-      PlayState.instance.activeNotes.forEachAlive(function(daNote:Note) {
-        if (daNote.isSustainNote && daNote.canBeHit && daNote.mustPress && holdArray[daNote.data.noteData]) PlayState.instance.goodNoteHit(daNote);
-      });
-    }
-
-    // PRESSES, check for note hits
-    if (pressArray.contains(true) && PlayState.instance.generatedMusic)
-    {
-      Haptic.vibrate(100, 100);
-
-      if (currentStage != null && currentStage.getBoyfriend() != null)
-      {
-        currentStage.getBoyfriend().holdTimer = 0;
-      }
-
-      var possibleNotes:Array<Note> = []; // notes that can be hit
-      var directionList:Array<Int> = []; // directions that can be hit
-      var dumbNotes:Array<Note> = []; // notes to kill later
-
-      PlayState.instance.activeNotes.forEachAlive(function(daNote:Note) {
-        if (daNote.canBeHit && daNote.mustPress && !daNote.tooLate && !daNote.wasGoodHit)
-        {
-          if (directionList.contains(daNote.data.noteData))
-          {
-            for (coolNote in possibleNotes)
-            {
-              if (coolNote.data.noteData == daNote.data.noteData && Math.abs(daNote.data.strumTime - coolNote.data.strumTime) < 10)
-              { // if it's the same note twice at < 10ms distance, just delete it
-                // EXCEPT u cant delete it in this loop cuz it fucks with the collection lol
-                dumbNotes.push(daNote);
-                break;
-              }
-              else if (coolNote.data.noteData == daNote.data.noteData && daNote.data.strumTime < coolNote.data.strumTime)
-              { // if daNote is earlier than existing note (coolNote), replace
-                possibleNotes.remove(coolNote);
-                possibleNotes.push(daNote);
-                break;
-              }
-            }
-          }
-          else
-          {
-            possibleNotes.push(daNote);
-            directionList.push(daNote.data.noteData);
-          }
-        }
-      });
-
-      for (note in dumbNotes)
-      {
-        FlxG.log.add('killing dumb ass note at ' + note.data.strumTime);
-        note.kill();
-        PlayState.instance.activeNotes.remove(note, true);
-        note.destroy();
-      }
-
-      possibleNotes.sort((a, b) -> Std.int(a.data.strumTime - b.data.strumTime));
-
-      if (PlayState.instance.perfectMode) PlayState.instance.goodNoteHit(possibleNotes[0]);
-      else if (possibleNotes.length > 0)
-      {
-        for (shit in 0...pressArray.length)
-        { // if a direction is hit that shouldn't be
-          if (pressArray[shit] && !directionList.contains(shit)) PlayState.instance.ghostNoteMiss(shit);
-        }
-        for (coolNote in possibleNotes)
-        {
-          if (pressArray[coolNote.data.noteData]) PlayState.instance.goodNoteHit(coolNote);
-        }
-      }
-      else
-      {
-        // HNGGG I really want to add an option for ghost tapping
-        // L + ratio
-        for (shit in 0...pressArray.length)
-          if (pressArray[shit]) PlayState.instance.ghostNoteMiss(shit, false);
-      }
-    }
-
-    if (PlayState.instance == null || PlayState.instance.currentStage == null) return;
-
-    for (keyId => isPressed in pressArray)
-    {
-      if (playerStrumline == null) continue;
-      var arrow:StrumlineArrow = PlayState.instance.playerStrumline.getArrow(keyId);
-
-      if (isPressed && arrow.animation.curAnim.name != 'confirm')
-      {
-        arrow.playAnimation('pressed');
-      }
-      if (!holdArray[keyId])
-      {
-        arrow.playAnimation('static');
-      }
-    }
-  }
-
-  /**
-   * Debug keys. Disabled while in cutscenes.
-   */
-  public function debugKeyShit():Void
-  {
-    #if !debug
-    perfectMode = false;
-    #else
-    if (FlxG.keys.justPressed.H) camHUD.visible = !camHUD.visible;
-    #end
-
-    if (FlxG.keys.justPressed.F4) FlxG.switchState(new MainMenuState());
-
-    if (FlxG.keys.justPressed.F5) debug_refreshModules();
-
-    // Press U to open stage ditor.
-    if (FlxG.keys.justPressed.U)
-    {
-      // hack for HaxeUI generation, doesn't work unless persistentUpdate is false at state creation!!
-      disableKeys = true;
-      persistentUpdate = false;
-      openSubState(new StageOffsetSubState());
-    }
-
-    #if debug
-    // 1: End the song immediately.
-    if (FlxG.keys.justPressed.ONE) endSong();
-
-    // 2: Gain 10% health.
-    if (FlxG.keys.justPressed.TWO) health += 0.1 * 2.0;
-
-    // 3: Lose 5% health.
-    if (FlxG.keys.justPressed.THREE) health -= 0.05 * 2.0;
-    #end
-
-    // 7: Move to the charter.
-    if (FlxG.keys.justPressed.SEVEN)
-    {
-      lime.app.Application.current.window.alert("Press ~ on the main menu to get to the editor", 'LOL');
-    }
-
-    // 8: Move to the offset editor.
-    if (FlxG.keys.justPressed.EIGHT) FlxG.switchState(new funkin.ui.animDebugShit.DebugBoundingState());
-
-    // 9: Toggle the old icon.
-    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);
-    #end
-
-    if (FlxG.keys.justPressed.B) trace(inputSpitter.join('\n'));
-  }
-
-  /**
-   * Called when a player presses a key with no note present.
-   * Scripts can modify the amount of health/score lost, whether player animations or sounds are used,
-   * or even cancel the event entirely.
-   *
-   * @param direction
-   * @param hasPossibleNotes
-   */
-  function ghostNoteMiss(direction:funkin.noteStuff.NoteBasic.NoteType = 1, hasPossibleNotes:Bool = true):Void
-  {
-    var event:GhostMissNoteScriptEvent = new GhostMissNoteScriptEvent(direction, // Direction missed in.
-      hasPossibleNotes, // Whether there was a note you could have hit.
-      - 0.035 * 2, // How much health to add (negative).
-      - 10 // Amount of score to add (negative).
-    );
-    dispatchEvent(event);
-
-    // Calling event.cancelEvent() skips animations and penalties. Neat!
-    if (event.eventCanceled) return;
-
-    health += event.healthChange;
-
-    if (!isPracticeMode)
-    {
-      songScore += event.scoreChange;
-
-      var pressArray:Array<Bool> = [
-        controls.NOTE_LEFT_P,
-        controls.NOTE_DOWN_P,
-        controls.NOTE_UP_P,
-        controls.NOTE_RIGHT_P
-      ];
-
-      var indices:Array<Int> = [];
-      for (i in 0...pressArray.length)
-      {
-        if (pressArray[i]) indices.push(i);
-      }
-      for (i in 0...indices.length)
-      {
-        inputSpitter.push(
-          {
-            t: Std.int(Conductor.songPosition),
-            d: indices[i],
-            l: 20
-          });
-      }
-    }
-
-    if (event.playSound)
-    {
-      vocals.playerVolume = 0;
-      FlxG.sound.play(Paths.soundRandom('missnote', 1, 3), FlxG.random.float(0.1, 0.2));
-    }
-  }
-
-  function noteMiss(note:Note):Void
-  {
-    // a MISS is when you let a note scroll past you!!
-    Highscore.tallies.missed++;
-
-    var event:NoteScriptEvent = new NoteScriptEvent(ScriptEvent.NOTE_MISS, note, Highscore.tallies.combo, true);
-    dispatchEvent(event);
-    // Calling event.cancelEvent() skips all the other logic! Neat!
-    if (event.eventCanceled) return;
-
-    health -= 0.0775;
-
-    if (!isPracticeMode)
-    {
-      songScore -= 10;
-
-      // messy copy paste rn lol
-      var pressArray:Array<Bool> = [
-        controls.NOTE_LEFT_P,
-        controls.NOTE_DOWN_P,
-        controls.NOTE_UP_P,
-        controls.NOTE_RIGHT_P
-      ];
-
-      var indices:Array<Int> = [];
-      for (i in 0...pressArray.length)
-      {
-        if (pressArray[i]) indices.push(i);
-      }
-      if (indices.length > 0)
-      {
-        for (i in 0...indices.length)
-        {
-          inputSpitter.push(
-            {
-              t: Std.int(Conductor.songPosition),
-              d: indices[i],
-              l: 20
-            });
-        }
-      }
-      else
-      {
-        inputSpitter.push(
-          {
-            t: Std.int(Conductor.songPosition),
-            d: -1,
-            l: 20
-          });
-      }
-    }
-    vocals.playerVolume = 0;
-
-    if (Highscore.tallies.combo != 0)
-    {
-      Highscore.tallies.combo = comboPopUps.displayCombo(0);
-    }
-
-    if (event.playSound)
-    {
-      vocals.playerVolume = 0;
-      FlxG.sound.play(Paths.soundRandom('missnote', 1, 3), FlxG.random.float(0.1, 0.2));
-    }
-
-    note.active = false;
-    note.visible = false;
-
-    note.kill();
-    activeNotes.remove(note, true);
-    note.destroy();
-  }
-
-  function goodNoteHit(note:Note):Void
-  {
-    if (!note.wasGoodHit)
-    {
-      var event:NoteScriptEvent = new NoteScriptEvent(ScriptEvent.NOTE_HIT, note, Highscore.tallies.combo + 1, true);
-      dispatchEvent(event);
-
-      // Calling event.cancelEvent() skips all the other logic! Neat!
-      if (event.eventCanceled) return;
-
-      if (!note.isSustainNote)
-      {
-        Highscore.tallies.combo++;
-        Highscore.tallies.totalNotesHit++;
-
-        if (Highscore.tallies.combo > Highscore.tallies.maxCombo) Highscore.tallies.maxCombo = Highscore.tallies.combo;
-
-        popUpScore(note.data.strumTime, note);
-      }
-
-      playerStrumline.getArrow(note.data.noteData).playAnimation('confirm', true);
-
-      note.wasGoodHit = true;
-      vocals.playerVolume = 1;
-
-      if (!note.isSustainNote)
-      {
-        note.kill();
-        activeNotes.remove(note, true);
-        note.destroy();
-      }
-    }
-  }
-
-  override function stepHit():Bool
-  {
-    // super.stepHit() returns false if a module cancelled the event.
-    if (!super.stepHit()) return false;
-
-    if (Math.abs(FlxG.sound.music.time - (Conductor.songPosition - Conductor.offset)) > 200
-      || Math.abs(vocals.checkSyncError(Conductor.songPosition - Conductor.offset)) > 200)
-    {
-      trace("VOCALS NEED RESYNC");
-      if (vocals != null) trace(vocals.checkSyncError(Conductor.songPosition - Conductor.offset));
-      trace(FlxG.sound.music.time - (Conductor.songPosition - Conductor.offset));
-      resyncVocals();
-    }
-
-    if (iconP1 != null) iconP1.onStepHit(Std.int(Conductor.currentStep));
-    if (iconP2 != null) iconP2.onStepHit(Std.int(Conductor.currentStep));
-
-    return true;
-  }
-
-  override function beatHit():Bool
-  {
-    // super.beatHit() returns false if a module cancelled the event.
-    if (!super.beatHit()) return false;
-
-    if (generatedMusic)
-    {
-      // TODO: Sort more efficiently, or less often, to improve performance.
-      activeNotes.sort(SortUtil.byStrumtime, FlxSort.DESCENDING);
-    }
-
-    // Only zoom camera if we are zoomed by less than 35%.
-    if (FlxG.camera.zoom < (1.35 * defaultCameraZoom) && cameraZoomRate > 0 && Conductor.currentBeat % cameraZoomRate == 0)
-    {
-      // Zoom camera in (1.5%)
-      FlxG.camera.zoom += cameraZoomIntensity * defaultCameraZoom;
-      // Hud zooms double (3%)
-      camHUD.zoom += hudCameraZoomIntensity * defaultHUDCameraZoom;
-    }
-    // trace('Not bopping camera: ${FlxG.camera.zoom} < ${(1.35 * defaultCameraZoom)} && ${cameraZoomRate} > 0 && ${Conductor.currentBeat} % ${cameraZoomRate} == ${Conductor.currentBeat % cameraZoomRate}}');
-
-    // That combo milestones that got spoiled that one time.
-    // Comes with NEAT visual and audio effects.
-
-    // bruh this var is bonkers i thot it was a function lmfaooo
-
-    // Break up into individual lines to aid debugging.
-
-    var shouldShowComboText:Bool = false;
-    // TODO: Re-enable combo text (how to do this without sections?).
-    // if (currentSong != null)
-    // {
-    //  shouldShowComboText = (Conductor.currentBeat % 8 == 7);
-    //  var daSection = .getSong()[Std.int(Conductor.currentBeat / 16)];
-    //  shouldShowComboText = shouldShowComboText && (daSection != null && daSection.mustHitSection);
-    //  shouldShowComboText = shouldShowComboText && (Highscore.tallies.combo > 5);
-    //
-    //  var daNextSection = .getSong()[Std.int(Conductor.currentBeat / 16) + 1];
-    //  var isEndOfSong = .getSong().length < Std.int(Conductor.currentBeat / 16);
-    //  shouldShowComboText = shouldShowComboText && (isEndOfSong || (daNextSection != null && !daNextSection.mustHitSection));
-    // }
-
-    if (shouldShowComboText)
-    {
-      var animShit:ComboMilestone = new ComboMilestone(-100, 300, Highscore.tallies.combo);
-      animShit.scrollFactor.set(0.6, 0.6);
-      animShit.cameras = [camHUD];
-      add(animShit);
-
-      var frameShit:Float = (1 / 24) * 2; // equals 2 frames in the animation
-
-      new FlxTimer().start(((Conductor.beatLengthMs / 1000) * 1.25) - frameShit, function(tmr) {
-        animShit.forceFinish();
-      });
-    }
-
-    // Make the characters dance on the beat
-    danceOnBeat();
-
-    return true;
-  }
-
-  /**
-   * Handles characters dancing to the beat of the current song.
-   *
-   * TODO: Move some of this logic into `Bopper.hx`
-   */
-  public function danceOnBeat():Void
-  {
-    if (currentStage == null) return;
-
-    // TODO: Add HEY! song events to Tutorial.
-    if (Conductor.currentBeat % 16 == 15
-      && currentStage.getDad().characterId == 'gf'
-      && Conductor.currentBeat > 16
-      && Conductor.currentBeat < 48)
-    {
-      currentStage.getBoyfriend().playAnimation('hey', true);
-      currentStage.getDad().playAnimation('cheer', true);
-    }
-  }
-
-  /**
-   * Constructs the strumlines for each player.
-   */
-  function buildStrumlines():Void
-  {
-    var strumlineStyle:StrumlineStyle = NORMAL;
-
-    // TODO: Put this in the chart or something?
-    switch (currentStageId)
-    {
-      case 'school':
-        strumlineStyle = PIXEL;
-      case 'schoolEvil':
-        strumlineStyle = PIXEL;
-    }
-
-    var strumlineYPos = Strumline.getYPos();
-
-    playerStrumline = new Strumline(0, strumlineStyle, 4);
-    playerStrumline.x = 50 + FlxG.width / 2;
-    playerStrumline.y = strumlineYPos;
-    // Set the z-index so they don't appear in front of notes.
-    playerStrumline.zIndex = 100;
-    add(playerStrumline);
-    playerStrumline.cameras = [camHUD];
-
-    if (!PlayStatePlaylist.isStoryMode)
-    {
-      playerStrumline.fadeInArrows();
-    }
-
-    enemyStrumline = new Strumline(1, strumlineStyle, 4);
-    enemyStrumline.x = 50;
-    enemyStrumline.y = strumlineYPos;
-    // Set the z-index so they don't appear in front of notes.
-    enemyStrumline.zIndex = 100;
-    add(enemyStrumline);
-    enemyStrumline.cameras = [camHUD];
-
-    if (!PlayStatePlaylist.isStoryMode)
-    {
-      enemyStrumline.fadeInArrows();
-    }
-
-    this.refresh();
-  }
-
-  /**
-   * Function called before opening a new substate.
-   * @param subState The substate to open.
-   */
-  public override function openSubState(subState:FlxSubState):Void
-  {
-    // If there is a substate which requires the game to continue,
-    // then make this a condition.
-    var shouldPause = true;
-
-    if (shouldPause)
-    {
-      // Pause the music.
-      if (FlxG.sound.music != null)
-      {
-        FlxG.sound.music.pause();
-        if (vocals != null) vocals.pause();
-      }
-
-      // Pause the countdown.
-      Countdown.pauseCountdown();
-    }
-
-    super.openSubState(subState);
-  }
-
-  /**
-   * Function called before closing the current substate.
-   * @param subState
-   */
-  public override function closeSubState():Void
-  {
-    if (isGamePaused)
-    {
-      var event:ScriptEvent = new ScriptEvent(ScriptEvent.RESUME, true);
-
-      dispatchEvent(event);
-
-      if (event.eventCanceled) return;
-
-      if (FlxG.sound.music != null && !startingSong && !isInCutscene) resyncVocals();
-
-      // Resume the countdown.
-      Countdown.resumeCountdown();
-
-      #if discord_rpc
-      if (startTimer.finished)
-      {
-        DiscordClient.changePresence(detailsText, '${currentChart.songName} ($storyDifficultyText)', iconRPC, true, songLength - Conductor.songPosition);
-      }
-      else
-      {
-        DiscordClient.changePresence(detailsText, '${currentChart.songName} ($storyDifficultyText)', iconRPC);
-      }
-      #end
-    }
-
-    super.closeSubState();
-  }
-
-  /**
-   * Prepares to start the countdown.
-   * Ends any running cutscenes, creates the strumlines, and starts the countdown.
-   */
-  public function startCountdown():Void
-  {
-    // If Countdown.performCountdown returns false, then the countdown was canceled by a script.
-    var result:Bool = Countdown.performCountdown(currentStageId.startsWith('school'));
-    if (!result) return;
-
-    isInCutscene = false;
-    camCutscene.visible = false;
-    camHUD.visible = true;
-  }
-
-  public override function dispatchEvent(event:ScriptEvent):Void
-  {
-    // ORDER: Module, Stage, Character, Song, Conversation, Note
-    // Modules should get the first chance to cancel the event.
-
-    // super.dispatchEvent(event) dispatches event to module scripts.
-    super.dispatchEvent(event);
-
-    // Dispatch event to stage script.
-    ScriptEventDispatcher.callEvent(currentStage, event);
-
-    // Dispatch event to character script(s).
-    if (currentStage != null) currentStage.dispatchToCharacters(event);
-
-    // Dispatch event to song script.
-    ScriptEventDispatcher.callEvent(currentSong, event);
-
-    // Dispatch event to conversation script.
-    ScriptEventDispatcher.callEvent(currentConversation, event);
-
-    // TODO: Dispatch event to note scripts
-  }
-
-  public function startConversation(conversationId:String):Void
-  {
-    isInCutscene = true;
-
-    currentConversation = ConversationDataParser.fetchConversation(conversationId);
-    if (currentConversation == null) return;
-
-    currentConversation.completeCallback = onConversationComplete;
-    currentConversation.cameras = [camCutscene];
-    currentConversation.zIndex = 1000;
-    add(currentConversation);
-    refresh();
-
-    var event:ScriptEvent = new ScriptEvent(ScriptEvent.CREATE, false);
-    ScriptEventDispatcher.callEvent(currentConversation, event);
-  }
-
-  function onConversationComplete():Void
-  {
-    isInCutscene = true;
-    remove(currentConversation);
-    currentConversation = null;
-
-    if (startingSong && !isInCountdown)
-    {
-      startCountdown();
-    }
-  }
-
-  override function destroy():Void
-  {
-    if (currentConversation != null)
-    {
-      remove(currentConversation);
-      currentConversation.kill();
-    }
-
-    super.destroy();
-  }
-
-  /**
-   * Updates the position and contents of the score display.
-   */
-  function updateScoreText():Void
-  {
-    // TODO: Add functionality for modules to update the score text.
-    scoreText.text = 'Score:' + songScore;
-  }
-
-  /**
-   * Updates the values of the health bar.
-   */
-  function updateHealthBar():Void
-  {
-    healthLerp = FlxMath.lerp(healthLerp, health, 0.15);
+    FlxG.sound.music.pause();
+    vocals.pause();
   }
 
   /**
@@ -2498,44 +2515,41 @@ class PlayState extends MusicBeatState
     FlxG.camera.focusOn(cameraFollowPoint.getPosition());
   }
 
+  #if debug
   /**
-   * Perform necessary cleanup before leaving the PlayState.
+   * Jumps forward or backward a number of sections in the song.
+   * Accounts for BPM changes, does not prevent death from skipped notes.
+   * @param sections The number of sections to jump, negative to go backwards.
    */
-  function performCleanup():Void
+  function changeSection(sections:Int):Void
   {
-    if (currentChart != null)
-    {
-      // TODO: Uncache the song.
-    }
+    FlxG.sound.music.pause();
 
-    // Remove reference to stage and remove sprites from it to save memory.
-    if (currentStage != null)
-    {
-      remove(currentStage);
-      currentStage.kill();
-      dispatchEvent(new ScriptEvent(ScriptEvent.DESTROY, false));
-      currentStage = null;
-    }
+    FlxG.sound.music.time += sections * Conductor.measureLengthMs;
 
-    GameOverSubState.reset();
+    Conductor.update(FlxG.sound.music.time);
 
-    // Clear the static reference to this state.
-    instance = null;
-  }
-
-  /**
-   * This function is called whenever Flixel switches switching to a new FlxState.
-   * @return Whether to actually switch to the new state.
-   */
-  override function switchTo(nextState:FlxState):Bool
-  {
-    var result:Bool = super.switchTo(nextState);
-
-    if (result)
-    {
-      performCleanup();
-    }
-
-    return result;
+    /**
+      *
+      // 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/Strumline.hx b/source/funkin/play/Strumline.hx
deleted file mode 100644
index 4bbcc720a..000000000
--- a/source/funkin/play/Strumline.hx
+++ /dev/null
@@ -1,253 +0,0 @@
-package funkin.play;
-
-import flixel.FlxSprite;
-import flixel.group.FlxSpriteGroup.FlxTypedSpriteGroup;
-import flixel.math.FlxPoint;
-import flixel.tweens.FlxEase;
-import flixel.tweens.FlxTween;
-import funkin.noteStuff.NoteBasic.NoteColor;
-import funkin.noteStuff.NoteBasic.NoteDir;
-import funkin.noteStuff.NoteBasic.NoteType;
-import funkin.ui.PreferencesMenu;
-import funkin.util.Constants;
-
-/**
- * A group controlling the individual notes of the strumline for a given player.
- *
- * FUN FACT: Setting the X and Y of a FlxSpriteGroup will move all the sprites in the group.
- */
-class Strumline extends FlxTypedSpriteGroup<StrumlineArrow>
-{
-  /**
-   * The style of the strumline.
-   * Options are normal and pixel.
-   */
-  var style:StrumlineStyle;
-
-  /**
-   * The player this strumline belongs to.
-   * 0 is Player 1, etc.
-   */
-  var playerId:Int;
-
-  /**
-   * The number of notes in the strumline.
-   */
-  var size:Int;
-
-  public function new(playerId:Int = 0, style:StrumlineStyle = NORMAL, size:Int = 4)
-  {
-    super(0);
-    this.playerId = playerId;
-    this.style = style;
-    this.size = size;
-
-    generateStrumline();
-  }
-
-  function generateStrumline():Void
-  {
-    for (index in 0...size)
-    {
-      createStrumlineArrow(index);
-    }
-  }
-
-  function createStrumlineArrow(index:Int):Void
-  {
-    var arrow:StrumlineArrow = new StrumlineArrow(index, style);
-    add(arrow);
-  }
-
-  /**
-   * Apply a small animation which moves the arrow down and fades it in.
-   * Only plays at the start of Free Play songs.
-   *
-   * Note that modifying the offset of the whole strumline won't have the
-   * @param arrow The arrow to animate.
-   * @param index The index of the arrow in the strumline.
-   */
-  function fadeInArrow(arrow:FlxSprite):Void
-  {
-    arrow.y -= 10;
-    arrow.alpha = 0;
-    FlxTween.tween(arrow, {y: arrow.y + 10, alpha: 1}, 1, {ease: FlxEase.circOut, startDelay: 0.5 + (0.2 * arrow.ID)});
-  }
-
-  public function fadeInArrows():Void
-  {
-    for (arrow in this.members)
-    {
-      fadeInArrow(arrow);
-    }
-  }
-
-  function updatePositions()
-  {
-    for (arrow in members)
-    {
-      arrow.x = Note.swagWidth * arrow.ID;
-      arrow.x += offset.x;
-
-      arrow.y = 0;
-      arrow.y += offset.y;
-    }
-  }
-
-  /**
-   * Retrieves the arrow at the given position in the strumline.
-   * @param index The index to retrieve.
-   * @return The corresponding FlxSprite.
-   */
-  public inline function getArrow(value:Int):StrumlineArrow
-  {
-    // members maintains the order that the arrows were added.
-    return this.members[value];
-  }
-
-  public inline function getArrowByNoteType(value:NoteType):StrumlineArrow
-  {
-    return getArrow(value.int);
-  }
-
-  public inline function getArrowByNoteDir(value:NoteDir):StrumlineArrow
-  {
-    return getArrow(value.int);
-  }
-
-  public inline function getArrowByNoteColor(value:funkin.noteStuff.NoteBasic.NoteColor):StrumlineArrow
-  {
-    return getArrow(value.int);
-  }
-
-  /**
-   * Get the default Y offset of the strumline.
-   * @return Int
-   */
-  public static inline function getYPos():Int
-  {
-    return PreferencesMenu.getPref('downscroll') ? (FlxG.height - 150) : 50;
-  }
-}
-
-class StrumlineArrow extends FlxSprite
-{
-  var style:StrumlineStyle;
-
-  public function new(id:Int, style:StrumlineStyle)
-  {
-    super(0, 0);
-
-    this.ID = id;
-    this.style = style;
-
-    // TODO: Unhardcode this. Maybe use a note style system>
-    switch (style)
-    {
-      case PIXEL:
-        buildPixelGraphic();
-      case NORMAL:
-        buildNormalGraphic();
-    }
-
-    this.updateHitbox();
-    scrollFactor.set(0, 0);
-    animation.play('static');
-  }
-
-  public function playAnimation(anim:String, force:Bool = false)
-  {
-    animation.play(anim, force);
-    centerOffsets();
-    centerOrigin();
-  }
-
-  /**
-   * Applies the default note style to an arrow.
-   */
-  function buildNormalGraphic():Void
-  {
-    this.frames = Paths.getSparrowAtlas('NOTE_assets');
-
-    this.animation.addByPrefix('green', 'arrowUP');
-    this.animation.addByPrefix('blue', 'arrowDOWN');
-    this.animation.addByPrefix('purple', 'arrowLEFT');
-    this.animation.addByPrefix('red', 'arrowRIGHT');
-
-    this.setGraphicSize(Std.int(this.width * 0.7));
-    this.antialiasing = true;
-
-    this.x += Note.swagWidth * this.ID;
-
-    switch (Math.abs(this.ID))
-    {
-      case 0:
-        this.animation.addByPrefix('static', 'arrow static instance 1');
-        this.animation.addByPrefix('pressed', 'left press', 24, false);
-        this.animation.addByPrefix('confirm', 'left confirm', 24, false);
-      case 1:
-        this.animation.addByPrefix('static', 'arrow static instance 2');
-        this.animation.addByPrefix('pressed', 'down press', 24, false);
-        this.animation.addByPrefix('confirm', 'down confirm', 24, false);
-      case 2:
-        this.animation.addByPrefix('static', 'arrow static instance 4');
-        this.animation.addByPrefix('pressed', 'up press', 24, false);
-        this.animation.addByPrefix('confirm', 'up confirm', 24, false);
-      case 3:
-        this.animation.addByPrefix('static', 'arrow static instance 3');
-        this.animation.addByPrefix('pressed', 'right press', 24, false);
-        this.animation.addByPrefix('confirm', 'right confirm', 24, false);
-    }
-  }
-
-  /**
-   * Applies the pixel note style to an arrow.
-   */
-  function buildPixelGraphic():Void
-  {
-    this.loadGraphic(Paths.image('weeb/pixelUI/arrows-pixels'), true, 17, 17);
-
-    this.animation.add('purplel', [4]);
-    this.animation.add('blue', [5]);
-    this.animation.add('green', [6]);
-    this.animation.add('red', [7]);
-
-    this.setGraphicSize(Std.int(this.width * Constants.PIXEL_ART_SCALE));
-    this.updateHitbox();
-
-    // Forcibly disable anti-aliasing on pixel graphics to stop blur.
-    this.antialiasing = false;
-
-    this.x += Note.swagWidth * this.ID;
-
-    // TODO: Seems weird that these are hardcoded like this... no XML?
-    switch (Math.abs(this.ID))
-    {
-      case 0:
-        this.animation.add('static', [0]);
-        this.animation.add('pressed', [4, 8], 12, false);
-        this.animation.add('confirm', [12, 16], 24, false);
-      case 1:
-        this.animation.add('static', [1]);
-        this.animation.add('pressed', [5, 9], 12, false);
-        this.animation.add('confirm', [13, 17], 24, false);
-      case 2:
-        this.animation.add('static', [2]);
-        this.animation.add('pressed', [6, 10], 12, false);
-        this.animation.add('confirm', [14, 18], 12, false);
-      case 3:
-        this.animation.add('static', [3]);
-        this.animation.add('pressed', [7, 11], 12, false);
-        this.animation.add('confirm', [15, 19], 24, false);
-    }
-  }
-}
-
-/**
- * TODO: Unhardcode this and make it part of the note style system.
- */
-enum StrumlineStyle
-{
-  NORMAL;
-  PIXEL;
-}
diff --git a/source/funkin/play/character/BaseCharacter.hx b/source/funkin/play/character/BaseCharacter.hx
index bdf7ef591..b27a46a0f 100644
--- a/source/funkin/play/character/BaseCharacter.hx
+++ b/source/funkin/play/character/BaseCharacter.hx
@@ -2,10 +2,10 @@ package funkin.play.character;
 
 import flixel.math.FlxPoint;
 import funkin.modding.events.ScriptEvent;
-import funkin.noteStuff.NoteBasic.NoteDir;
 import funkin.play.character.CharacterData.CharacterDataParser;
 import funkin.play.character.CharacterData.CharacterRenderType;
 import funkin.play.stage.Bopper;
+import funkin.play.notes.NoteDirection;
 
 /**
  * A Character is a stage prop which bops to the music as well as controlled by the strumlines.
@@ -488,16 +488,16 @@ class BaseCharacter extends Bopper
   {
     super.onNoteHit(event);
 
-    if (event.note.mustPress && characterType == BF)
+    if (event.note.noteData.getMustHitNote() && characterType == BF)
     {
       // If the note is from the same strumline, play the sing animation.
-      this.playSingAnimation(event.note.data.dir, false);
+      this.playSingAnimation(event.note.noteData.getDirection(), false);
       holdTimer = 0;
     }
-    else if (!event.note.mustPress && characterType == DAD)
+    else if (!event.note.noteData.getMustHitNote() && characterType == DAD)
     {
       // If the note is from the same strumline, play the sing animation.
-      this.playSingAnimation(event.note.data.dir, false);
+      this.playSingAnimation(event.note.noteData.getDirection(), false);
       holdTimer = 0;
     }
   }
@@ -510,17 +510,17 @@ class BaseCharacter extends Bopper
   {
     super.onNoteMiss(event);
 
-    if (event.note.mustPress && characterType == BF)
+    if (event.note.noteData.getMustHitNote() && characterType == BF)
     {
       // If the note is from the same strumline, play the sing animation.
-      this.playSingAnimation(event.note.data.dir, true);
+      this.playSingAnimation(event.note.noteData.getDirection(), true);
     }
-    else if (!event.note.mustPress && characterType == DAD)
+    else if (!event.note.noteData.getMustHitNote() && characterType == DAD)
     {
       // If the note is from the same strumline, play the sing animation.
-      this.playSingAnimation(event.note.data.dir, true);
+      this.playSingAnimation(event.note.noteData.getDirection(), true);
     }
-    else if (event.note.mustPress && characterType == GF)
+    else if (event.note.noteData.getMustHitNote() && characterType == GF)
     {
       var dropAnim = '';
 
@@ -575,7 +575,7 @@ class BaseCharacter extends Bopper
    * @param miss If true, play the miss animation instead of the sing animation.
    * @param suffix A suffix to append to the animation name, like `alt`.
    */
-  public function playSingAnimation(dir:NoteDir, ?miss:Bool = false, ?suffix:String = ''):Void
+  public function playSingAnimation(dir:NoteDirection, ?miss:Bool = false, ?suffix:String = ''):Void
   {
     var anim:String = 'sing${dir.nameUpper}${miss ? 'miss' : ''}${suffix != '' ? '-${suffix}' : ''}';
 
diff --git a/source/funkin/play/notes/NoteDirection.hx b/source/funkin/play/notes/NoteDirection.hx
new file mode 100644
index 000000000..8a0fb5ecc
--- /dev/null
+++ b/source/funkin/play/notes/NoteDirection.hx
@@ -0,0 +1,82 @@
+package funkin.play.notes;
+
+import funkin.util.Constants;
+import flixel.util.FlxColor;
+
+/**
+ * The direction of a note.
+ * This has implicit casting set up, so you can use this as an integer.
+ */
+enum abstract NoteDirection(Int) from Int to Int
+{
+  var LEFT = 0;
+  var DOWN = 1;
+  var UP = 2;
+  var RIGHT = 3;
+  public var name(get, never):String;
+  public var nameUpper(get, never):String;
+  public var color(get, never):FlxColor;
+  public var colorName(get, never):String;
+
+  @:from
+  public static function fromInt(value:Int):NoteDirection
+  {
+    return switch (value % 4)
+    {
+      case 0: LEFT;
+      case 1: DOWN;
+      case 2: UP;
+      case 3: RIGHT;
+      default: LEFT;
+    }
+  }
+
+  function get_name():String
+  {
+    return switch (abstract)
+    {
+      case LEFT:
+        'left';
+      case DOWN:
+        'down';
+      case UP:
+        'up';
+      case RIGHT:
+        'right';
+      default:
+        'unknown';
+    }
+  }
+
+  function get_nameUpper():String
+  {
+    return abstract.name.toUpperCase();
+  }
+
+  function get_color():FlxColor
+  {
+    return Constants.COLOR_NOTES[this];
+  }
+
+  function get_colorName():String
+  {
+    return switch (abstract)
+    {
+      case LEFT:
+        'purple';
+      case DOWN:
+        'blue';
+      case UP:
+        'green';
+      case RIGHT:
+        'red';
+      default:
+        'unknown';
+    }
+  }
+
+  public function toString():String
+  {
+    return abstract.name;
+  }
+}
diff --git a/source/funkin/play/notes/NoteSplash.hx b/source/funkin/play/notes/NoteSplash.hx
new file mode 100644
index 000000000..90c9825e9
--- /dev/null
+++ b/source/funkin/play/notes/NoteSplash.hx
@@ -0,0 +1,90 @@
+package funkin.play.notes;
+
+import funkin.play.notes.NoteDirection;
+import flixel.graphics.frames.FlxFramesCollection;
+import flixel.FlxG;
+import flixel.graphics.frames.FlxAtlasFrames;
+import flixel.FlxSprite;
+
+class NoteSplash extends FlxSprite
+{
+  static final ALPHA:Float = 0.6;
+  static final FRAMERATE_DEFAULT:Int = 24;
+  static final FRAMERATE_VARIANCE:Int = 2;
+
+  static var frameCollection:FlxFramesCollection;
+
+  public static function preloadFrames():Void
+  {
+    frameCollection = Paths.getSparrowAtlas('noteSplashes');
+  }
+
+  public function new()
+  {
+    super(0, 0);
+
+    setup();
+
+    this.alpha = ALPHA;
+    this.antialiasing = true;
+    this.animation.finishCallback = this.onAnimationFinished;
+  }
+
+  /**
+   * Add ALL the animations to this sprite. We will recycle and reuse the FlxSprite multiple times.
+   */
+  function setup():Void
+  {
+    if (frameCollection == null) preloadFrames();
+
+    this.frames = frameCollection;
+
+    this.animation.addByPrefix('splash1Left', 'note impact 1 purple0', FRAMERATE_DEFAULT, false, false, false);
+    this.animation.addByPrefix('splash1Down', 'note impact 1  blue0', FRAMERATE_DEFAULT, false, false, false);
+    this.animation.addByPrefix('splash1Up', 'note impact 1 green0', FRAMERATE_DEFAULT, false, false, false);
+    this.animation.addByPrefix('splash1Right', 'note impact 1 red0', FRAMERATE_DEFAULT, false, false, false);
+    this.animation.addByPrefix('splash2Left', 'note impact 2 purple0', FRAMERATE_DEFAULT, false, false, false);
+    this.animation.addByPrefix('splash2Down', 'note impact 2 blue0', FRAMERATE_DEFAULT, false, false, false);
+    this.animation.addByPrefix('splash2Up', 'note impact 2 green0', FRAMERATE_DEFAULT, false, false, false);
+    this.animation.addByPrefix('splash2Right', 'note impact 2 red0', FRAMERATE_DEFAULT, false, false, false);
+
+    if (this.animation.getAnimationList().length < 8)
+    {
+      trace('WARNING: NoteSplash failed to initialize all animations.');
+    }
+  }
+
+  public function playAnimation(name:String, force:Bool = false, reversed:Bool = false, startFrame:Int = 0):Void
+  {
+    this.animation.play(name, force, reversed, startFrame);
+  }
+
+  public function play(direction:NoteDirection, variant:Int = null):Void
+  {
+    if (variant == null) variant = FlxG.random.int(1, 2);
+
+    switch (direction)
+    {
+      case NoteDirection.LEFT:
+        this.playAnimation('splash${variant}Left');
+      case NoteDirection.DOWN:
+        this.playAnimation('splash${variant}Down');
+      case NoteDirection.UP:
+        this.playAnimation('splash${variant}Up');
+      case NoteDirection.RIGHT:
+        this.playAnimation('splash${variant}Right');
+    }
+
+    // Vary the speed of the animation a bit.
+    animation.curAnim.frameRate = FRAMERATE_DEFAULT + FlxG.random.int(-FRAMERATE_VARIANCE, FRAMERATE_VARIANCE);
+
+    // Center the animation on the note splash.
+    offset.set(width * 0.3, height * 0.3);
+  }
+
+  public function onAnimationFinished(animationName:String):Void
+  {
+    // *lightning* *zap* *crackle*
+    this.kill();
+  }
+}
diff --git a/source/funkin/play/notes/NoteSprite.hx b/source/funkin/play/notes/NoteSprite.hx
new file mode 100644
index 000000000..e4b866cc4
--- /dev/null
+++ b/source/funkin/play/notes/NoteSprite.hx
@@ -0,0 +1,178 @@
+package funkin.play.notes;
+
+import funkin.play.song.SongData.SongNoteData;
+import flixel.graphics.frames.FlxAtlasFrames;
+import flixel.FlxSprite;
+
+class NoteSprite extends FlxSprite
+{
+  static final DIRECTION_COLORS:Array<String> = ['purple', 'blue', 'green', 'red'];
+
+  public var holdNoteSprite:SustainTrail;
+
+  /**
+   * The time at which the note should be hit, in milliseconds.
+   */
+  public var strumTime(default, set):Float;
+
+  function set_strumTime(value:Float):Float
+  {
+    this.strumTime = value;
+    return this.strumTime;
+  }
+
+  /**
+   * The length of the note's sustain, in milliseconds.
+   * If 0, the note is a tap note.
+   */
+  public var length(default, set):Float;
+
+  function set_length(value:Float):Float
+  {
+    this.length = value;
+    this.isSustainNote = (this.length > 0);
+    return this.length;
+  }
+
+  /**
+   * The time at which the note should be hit, in steps.
+   */
+  public var stepTime(get, never):Float;
+
+  function get_stepTime():Float
+  {
+    // TODO: Account for changes in BPM.
+    return this.strumTime / Conductor.stepLengthMs;
+  }
+
+  /**
+   * An extra attribute for the note.
+   * For example, whether the note is an "alt" note, or whether it has custom behavior on hit.
+   */
+  public var kind(default, set):String;
+
+  function set_kind(value:String):String
+  {
+    this.kind = value;
+    return this.kind;
+  }
+
+  /**
+   * The data of the note (i.e. the direction.)
+   */
+  public var direction(default, set):NoteDirection;
+
+  function set_direction(value:Int):Int
+  {
+    if (frames == null) return value;
+
+    animation.play(DIRECTION_COLORS[value] + 'Scroll');
+
+    this.direction = value;
+    return this.direction;
+  }
+
+  public var noteData:SongNoteData;
+
+  public var isSustainNote:Bool = false;
+
+  /**
+   * Set this flag to true when hitting the note to avoid scoring it multiple times.
+   */
+  public var hasBeenHit:Bool = false;
+
+  /**
+   * Register this note as hit only after any other notes
+   */
+  public var lowPriority:Bool = false;
+
+  /**
+   * This is true if the note has been fully missed by the player.
+   * It will be destroyed immediately.
+   */
+  public var hasMissed:Bool;
+
+  /**
+   * This is true if the note is earlier than 10 frames within the strumline.
+   * and thus can't be hit by the player.
+   * Managed by PlayState.
+   */
+  public var tooEarly:Bool;
+
+  /**
+   * This is true if the note is within 10 frames of the strumline,
+   * and thus may be hit by the player.
+   * Managed by PlayState.
+   */
+  public var mayHit:Bool;
+
+  /**
+   * This is true if the note is earlier than 10 frames after the strumline,
+   * and thus can't be hit by the player.
+   * Managed by PlayState.
+   */
+  public var tooLate:Bool;
+
+  public function new(strumTime:Float = 0, direction:Int = 0)
+  {
+    super(0, -9999);
+    this.strumTime = strumTime;
+    this.direction = direction;
+
+    if (this.strumTime < 0) this.strumTime = 0;
+
+    setupNoteGraphic();
+
+    // Disables the update() function for performance.
+    this.active = false;
+  }
+
+  public static function buildNoteFrames(force:Bool = false):FlxAtlasFrames
+  {
+    // static variables inside functions are a cool of Haxe 4.3.0.
+    static var noteFrames:FlxAtlasFrames = null;
+
+    if (noteFrames != null && !force) return noteFrames;
+
+    noteFrames = Paths.getSparrowAtlas('NOTE_assets');
+
+    noteFrames.parent.persist = true;
+
+    return noteFrames;
+  }
+
+  function setupNoteGraphic():Void
+  {
+    this.frames = buildNoteFrames();
+
+    animation.addByPrefix('greenScroll', 'green instance');
+    animation.addByPrefix('redScroll', 'red instance');
+    animation.addByPrefix('blueScroll', 'blue instance');
+    animation.addByPrefix('purpleScroll', 'purple instance');
+
+    animation.addByPrefix('purpleholdend', 'pruple end hold');
+    animation.addByPrefix('greenholdend', 'green hold end');
+    animation.addByPrefix('redholdend', 'red hold end');
+    animation.addByPrefix('blueholdend', 'blue hold end');
+
+    animation.addByPrefix('purplehold', 'purple hold piece');
+    animation.addByPrefix('greenhold', 'green hold piece');
+    animation.addByPrefix('redhold', 'red hold piece');
+    animation.addByPrefix('bluehold', 'blue hold piece');
+
+    setGraphicSize(Strumline.STRUMLINE_SIZE);
+    updateHitbox();
+    antialiasing = true;
+  }
+
+  public override function revive():Void
+  {
+    super.revive();
+    this.active = false;
+    this.tooEarly = false;
+    this.hasBeenHit = false;
+    this.mayHit = false;
+    this.tooLate = false;
+    this.hasMissed = false;
+  }
+}
diff --git a/source/funkin/play/notes/Strumline.hx b/source/funkin/play/notes/Strumline.hx
new file mode 100644
index 000000000..7be17a4cb
--- /dev/null
+++ b/source/funkin/play/notes/Strumline.hx
@@ -0,0 +1,565 @@
+package funkin.play.notes;
+
+import flixel.tweens.FlxEase;
+import flixel.tweens.FlxTween;
+import funkin.ui.PreferencesMenu;
+import funkin.play.notes.NoteSprite;
+import flixel.util.FlxSort;
+import funkin.play.notes.SustainTrail;
+import funkin.util.SortUtil;
+import funkin.play.song.SongData.SongNoteData;
+import flixel.FlxG;
+import flixel.group.FlxSpriteGroup;
+import flixel.group.FlxSpriteGroup.FlxTypedSpriteGroup;
+
+/**
+ * A group of sprites which handles the receptor, the note splashes, and the notes (with sustains) for a given player.
+ */
+class Strumline extends FlxSpriteGroup
+{
+  public static final DIRECTIONS:Array<NoteDirection> = [NoteDirection.LEFT, NoteDirection.DOWN, NoteDirection.UP, NoteDirection.RIGHT];
+  public static final STRUMLINE_SIZE:Int = 112;
+  public static final NOTE_SPACING:Int = STRUMLINE_SIZE + 8;
+
+  // Positional fixes for new strumline graphics.
+  static final INITIAL_OFFSET = -0.275 * STRUMLINE_SIZE;
+  static final NUDGE:Float = 2.0;
+
+  static final KEY_COUNT:Int = 4;
+  static final NOTE_SPLASH_CAP:Int = 6;
+
+  static var RENDER_DISTANCE_MS(get, null):Float;
+
+  static function get_RENDER_DISTANCE_MS():Float
+  {
+    return FlxG.height / 0.45;
+  }
+
+  public var isPlayer:Bool;
+
+  /**
+   * The notes currently being rendered on the strumline.
+   * This group iterates over this every frame to update note positions.
+   * The PlayState also iterates over this to calculate user inputs.
+   */
+  public var notes:FlxTypedSpriteGroup<NoteSprite>;
+
+  public var holdNotes:FlxTypedSpriteGroup<SustainTrail>;
+
+  var strumlineNotes:FlxTypedSpriteGroup<StrumlineNote>;
+  var noteSplashes:FlxTypedSpriteGroup<NoteSplash>;
+  var sustainSplashes:FlxTypedSpriteGroup<NoteSplash>;
+
+  var noteData:Array<SongNoteData> = [];
+  var nextNoteIndex:Int = -1;
+
+  public function new(isPlayer:Bool)
+  {
+    super();
+
+    this.isPlayer = isPlayer;
+
+    this.strumlineNotes = new FlxTypedSpriteGroup<StrumlineNote>();
+    this.add(this.strumlineNotes);
+
+    // Hold notes are added first so they render behind regular notes.
+    this.holdNotes = new FlxTypedSpriteGroup<SustainTrail>();
+    this.add(this.holdNotes);
+
+    this.notes = new FlxTypedSpriteGroup<NoteSprite>();
+    this.add(this.notes);
+
+    this.noteSplashes = new FlxTypedSpriteGroup<NoteSplash>(0, 0, NOTE_SPLASH_CAP);
+    this.add(this.noteSplashes);
+
+    for (i in 0...DIRECTIONS.length)
+    {
+      var child:StrumlineNote = new StrumlineNote(isPlayer, DIRECTIONS[i]);
+      child.x = getXPos(DIRECTIONS[i]);
+      child.x += INITIAL_OFFSET;
+      child.y = 0;
+      this.strumlineNotes.add(child);
+    }
+
+    // This MUST be true for children to update!
+    this.active = true;
+  }
+
+  override function get_width():Float
+  {
+    return 4 * Strumline.NOTE_SPACING;
+  }
+
+  public override function update(elapsed:Float):Void
+  {
+    super.update(elapsed);
+
+    updateNotes();
+  }
+
+  /**
+   * Get a list of notes within + or - the given strumtime.
+   * @param strumTime The current time.
+   * @param hitWindow The hit window to check.
+   */
+  public function getNotesInRange(strumTime:Float, hitWindow:Float):Array<NoteSprite>
+  {
+    var hitWindowStart:Float = strumTime - hitWindow;
+    var hitWindowEnd:Float = strumTime + hitWindow;
+
+    return notes.members.filter(function(note:NoteSprite) {
+      return note != null && note.alive && !note.hasBeenHit && note.strumTime >= hitWindowStart && note.strumTime <= hitWindowEnd;
+    });
+  }
+
+  public function getHoldNotesInRange(strumTime:Float, hitWindow:Float):Array<SustainTrail>
+  {
+    var hitWindowStart:Float = strumTime - hitWindow;
+    var hitWindowEnd:Float = strumTime + hitWindow;
+
+    return holdNotes.members.filter(function(note:SustainTrail) {
+      return note != null
+        && note.alive
+        && note.strumTime >= hitWindowStart
+        && (note.strumTime + note.fullSustainLength) <= hitWindowEnd;
+    });
+  }
+
+  public function getNoteSprite(noteData:SongNoteData):NoteSprite
+  {
+    if (noteData == null) return null;
+
+    for (note in notes.members)
+    {
+      if (note == null) continue;
+      if (note.alive) continue;
+
+      if (note.noteData == noteData) return note;
+    }
+
+    return null;
+  }
+
+  public function getHoldNoteSprite(noteData:SongNoteData):SustainTrail
+  {
+    if (noteData == null || ((noteData.length ?? 0.0) <= 0.0)) return null;
+
+    for (holdNote in holdNotes.members)
+    {
+      if (holdNote == null) continue;
+      if (holdNote.alive) continue;
+
+      if (holdNote.noteData == noteData) return holdNote;
+    }
+
+    return null;
+  }
+
+  /**
+   * For a note's strumTime, calculate its Y position relative to the strumline.
+   * NOTE: Assumes Conductor and PlayState are both initialized.
+   * @param strumTime
+   * @return Float
+   */
+  static function calculateNoteYPos(strumTime:Float):Float
+  {
+    // Make the note move faster visually as it moves offscreen.
+    var vwoosh:Float = (strumTime < Conductor.songPosition) ? 2.0 : 1.0;
+    var scrollSpeed:Float = PlayState.instance?.currentChart?.scrollSpeed ?? 1.0;
+
+    return Conductor.PIXELS_PER_MS * (Conductor.songPosition - strumTime) * scrollSpeed * vwoosh * (PreferencesMenu.getPref('downscroll') ? 1 : -1);
+  }
+
+  function updateNotes():Void
+  {
+    if (noteData.length == 0) return;
+
+    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 > renderWindowStart) break;
+
+      buildNoteSprite(note);
+
+      if (note.length > 0)
+      {
+        buildHoldNoteSprite(note);
+      }
+
+      nextNoteIndex++; // Increment the nextNoteIndex rather than splicing the array, because splicing is slow.
+    }
+
+    // Update rendering of notes.
+    for (note in notes.members)
+    {
+      if (note == null || note.hasBeenHit) continue;
+
+      note.y = this.y - INITIAL_OFFSET + calculateNoteYPos(note.strumTime);
+
+      // Check if the note is outside the hit window, and if so, mark it as missed.
+      // TODO: Check to make sure this doesn't happen when the note is on screen because it'll probably get deleted.
+      if (Conductor.songPosition > (note.noteData.time + Conductor.HIT_WINDOW_MS))
+      {
+        note.visible = false;
+        note.hasMissed = true;
+        if (note.holdNoteSprite != null) note.holdNoteSprite.missed = true;
+      }
+      else
+      {
+        note.visible = true;
+        note.hasMissed = false;
+        if (note.holdNoteSprite != null) note.holdNoteSprite.missed = false;
+      }
+    }
+
+    // Update rendering of hold notes.
+    for (holdNote in holdNotes.members)
+    {
+      if (holdNote == null || !holdNote.alive) continue;
+
+      var renderWindowEnd = holdNote.strumTime + holdNote.fullSustainLength + Conductor.HIT_WINDOW_MS + RENDER_DISTANCE_MS / 8;
+
+      if (Conductor.songPosition >= renderWindowEnd || holdNote.sustainLength <= 0)
+      {
+        // Hold note is offscreen, kill it.
+        holdNote.visible = false;
+        holdNote.kill(); // Do not destroy! Recycling is faster.
+      }
+      else if (holdNote.sustainLength <= 0)
+      {
+        // Hold note is completed, kill it.
+        playStatic(holdNote.noteDirection);
+        holdNote.visible = false;
+        holdNote.kill();
+      }
+      else if (holdNote.sustainLength <= 10)
+      {
+        // TODO: Better handle the weird edge case where the hold note is almost completed.
+        holdNote.visible = false;
+      }
+      else if (Conductor.songPosition > holdNote.strumTime && !holdNote.missed)
+      {
+        // Hold note is currently being hit, clip it off.
+        holdConfirm(holdNote.noteDirection);
+        holdNote.visible = true;
+
+        holdNote.sustainLength = (holdNote.strumTime + holdNote.fullSustainLength) - Conductor.songPosition;
+
+        if (PreferencesMenu.getPref('downscroll'))
+        {
+          holdNote.y = this.y - holdNote.height + STRUMLINE_SIZE / 2;
+        }
+        else
+        {
+          holdNote.y = this.y - INITIAL_OFFSET + STRUMLINE_SIZE / 2;
+        }
+      }
+      else if (holdNote.missed && (holdNote.fullSustainLength > holdNote.sustainLength))
+      {
+        // Hold note was dropped before completing, keep it in its clipped state.
+        holdNote.visible = true;
+
+        var yOffset:Float = (holdNote.fullSustainLength - holdNote.sustainLength) * Conductor.PIXELS_PER_MS;
+
+        trace('yOffset: ' + yOffset);
+        trace('holdNote.fullSustainLength: ' + holdNote.fullSustainLength);
+        trace('holdNote.sustainLength: ' + holdNote.sustainLength);
+
+        if (PreferencesMenu.getPref('downscroll'))
+        {
+          holdNote.y = this.y + calculateNoteYPos(holdNote.strumTime) - holdNote.height + STRUMLINE_SIZE / 2;
+        }
+        else
+        {
+          holdNote.y = this.y - INITIAL_OFFSET + calculateNoteYPos(holdNote.strumTime) + yOffset + STRUMLINE_SIZE / 2;
+        }
+      }
+      else
+      {
+        // Hold note is new, render it normally.
+        holdNote.visible = true;
+
+        if (PreferencesMenu.getPref('downscroll'))
+        {
+          holdNote.y = this.y + calculateNoteYPos(holdNote.strumTime) - holdNote.height + STRUMLINE_SIZE / 2;
+        }
+        else
+        {
+          holdNote.y = this.y - INITIAL_OFFSET + calculateNoteYPos(holdNote.strumTime) + STRUMLINE_SIZE / 2;
+        }
+      }
+    }
+  }
+
+  public function onBeatHit():Void
+  {
+    if (notes.members.length > 1) notes.members.insertionSort(compareNoteSprites.bind(FlxSort.ASCENDING));
+
+    if (holdNotes.members.length > 1) holdNotes.members.insertionSort(compareHoldNoteSprites.bind(FlxSort.ASCENDING));
+  }
+
+  public function applyNoteData(data:Array<SongNoteData>):Void
+  {
+    this.notes.clear();
+
+    this.noteData = data.copy();
+    this.nextNoteIndex = 0;
+
+    // Sort the notes by strumtime.
+    this.noteData.insertionSort(compareNoteData.bind(FlxSort.ASCENDING));
+  }
+
+  public function hitNote(note:NoteSprite):Void
+  {
+    playConfirm(note.direction);
+    killNote(note);
+  }
+
+  public function killNote(note:NoteSprite):Void
+  {
+    note.visible = false;
+    notes.remove(note, false);
+    note.kill();
+
+    if (note.holdNoteSprite != null)
+    {
+      holdNoteSprite.missed = true;
+      holdNoteSprite.alpha = 0.6;
+    }
+  }
+
+  public function getByIndex(index:Int):StrumlineNote
+  {
+    return this.strumlineNotes.members[index];
+  }
+
+  public function getByDirection(direction:NoteDirection):StrumlineNote
+  {
+    return getByIndex(DIRECTIONS.indexOf(direction));
+  }
+
+  public function playStatic(direction:NoteDirection):Void
+  {
+    getByDirection(direction).playStatic();
+  }
+
+  public function playPress(direction:NoteDirection):Void
+  {
+    getByDirection(direction).playPress();
+  }
+
+  public function playConfirm(direction:NoteDirection):Void
+  {
+    getByDirection(direction).playConfirm();
+  }
+
+  public function holdConfirm(direction:NoteDirection):Void
+  {
+    getByDirection(direction).holdConfirm();
+  }
+
+  public function isConfirm(direction:NoteDirection):Bool
+  {
+    return getByDirection(direction).isConfirm();
+  }
+
+  public function playNoteSplash(direction:NoteDirection):Void
+  {
+    // TODO: Add a setting to disable note splashes.
+    // if (Settings.noSplash) return;
+
+    var splash:NoteSplash = this.constructNoteSplash();
+
+    if (splash != null)
+    {
+      splash.play(direction);
+
+      splash.x = this.x;
+      splash.x += getXPos(direction);
+      splash.x += INITIAL_OFFSET;
+      splash.y = this.y;
+      splash.y -= INITIAL_OFFSET;
+      splash.y += 0;
+    }
+  }
+
+  public function buildNoteSprite(note:SongNoteData):Void
+  {
+    var noteSprite:NoteSprite = constructNoteSprite();
+
+    if (noteSprite != null)
+    {
+      noteSprite.strumTime = note.time;
+      noteSprite.direction = note.getDirection();
+      noteSprite.noteData = note;
+
+      noteSprite.x = this.x;
+      noteSprite.x += getXPos(DIRECTIONS[note.getDirection() % KEY_COUNT]);
+      noteSprite.x -= NUDGE;
+      // noteSprite.x += INITIAL_OFFSET;
+      noteSprite.y = -9999;
+    }
+  }
+
+  public function buildHoldNoteSprite(note:SongNoteData):Void
+  {
+    var holdNoteSprite:SustainTrail = constructHoldNoteSprite();
+
+    if (holdNoteSprite != null)
+    {
+      holdNoteSprite.noteData = note;
+      holdNoteSprite.strumTime = note.time;
+      holdNoteSprite.noteDirection = note.getDirection();
+      holdNoteSprite.fullSustainLength = note.length;
+      holdNoteSprite.sustainLength = note.length;
+      holdNoteSprite.missed = false;
+
+      holdNoteSprite.x = this.x;
+      holdNoteSprite.x += getXPos(DIRECTIONS[note.getDirection() % KEY_COUNT]);
+      // holdNoteSprite.x += INITIAL_OFFSET;
+      holdNoteSprite.x += STRUMLINE_SIZE / 2;
+      holdNoteSprite.x -= holdNoteSprite.width / 2;
+      holdNoteSprite.y = -9999;
+    }
+  }
+
+  /**
+   * Custom recycling behavior.
+   */
+  function constructNoteSplash():NoteSplash
+  {
+    var result:NoteSplash = null;
+
+    // If we haven't filled the pool yet...
+    if (noteSplashes.length < noteSplashes.maxSize)
+    {
+      // Create a new note splash.
+      result = new NoteSplash();
+      this.noteSplashes.add(result);
+    }
+    else
+    {
+      // Else, find a note splash which is inactive so we can revive it.
+      result = this.noteSplashes.getFirstAvailable();
+
+      if (result != null)
+      {
+        result.revive();
+      }
+      else
+      {
+        // The note splash pool is full and all note splashes are active,
+        // so we just pick one at random to destroy and restart.
+        result = FlxG.random.getObject(this.noteSplashes.members);
+      }
+    }
+
+    return result;
+  }
+
+  /**
+   * Custom recycling behavior.
+   */
+  function constructNoteSprite():NoteSprite
+  {
+    var result:NoteSprite = null;
+
+    // Else, find a note which is inactive so we can revive it.
+    result = this.notes.getFirstAvailable();
+
+    if (result != null)
+    {
+      // Revive and reuse the note.
+      result.revive();
+    }
+    else
+    {
+      // The note sprite pool is full and all note splashes are active.
+      // We have to create a new note.
+      result = new NoteSprite();
+      this.notes.add(result);
+    }
+
+    return result;
+  }
+
+  /**
+   * Custom recycling behavior.
+   */
+  function constructHoldNoteSprite():SustainTrail
+  {
+    var result:SustainTrail = null;
+
+    // Else, find a note which is inactive so we can revive it.
+    result = this.holdNotes.getFirstAvailable();
+
+    if (result != null)
+    {
+      // Revive and reuse the note.
+      result.revive();
+    }
+    else
+    {
+      // The note sprite pool is full and all note splashes are active.
+      // We have to create a new note.
+      result = new SustainTrail(0, 100, Paths.image("NOTE_hold_assets"));
+      this.holdNotes.add(result);
+    }
+
+    return result;
+  }
+
+  function getXPos(direction:NoteDirection):Float
+  {
+    return switch (direction)
+    {
+      case NoteDirection.LEFT: 0;
+      case NoteDirection.DOWN: 0 + (1 * Strumline.NOTE_SPACING);
+      case NoteDirection.UP: 0 + (2 * Strumline.NOTE_SPACING);
+      case NoteDirection.RIGHT: 0 + (3 * Strumline.NOTE_SPACING);
+      default: 0;
+    }
+  }
+
+  /**
+   * Apply a small animation which moves the arrow down and fades it in.
+   * Only plays at the start of Free Play songs.
+   *
+   * Note that modifying the offset of the whole strumline won't have the
+   * @param arrow The arrow to animate.
+   * @param index The index of the arrow in the strumline.
+   */
+  function fadeInArrow(arrow:StrumlineNote):Void
+  {
+    arrow.y -= 10;
+    arrow.alpha = 0;
+    FlxTween.tween(arrow, {y: arrow.y + 10, alpha: 1}, 1, {ease: FlxEase.circOut, startDelay: 0.5 + (0.2 * arrow.ID)});
+  }
+
+  public function fadeInArrows():Void
+  {
+    for (arrow in this.strumlineNotes)
+    {
+      fadeInArrow(arrow);
+    }
+  }
+
+  function compareNoteData(order:Int, a:SongNoteData, b:SongNoteData):Int
+  {
+    return FlxSort.byValues(order, a.time, b.time);
+  }
+
+  function compareNoteSprites(order:Int, a:NoteSprite, b:NoteSprite):Int
+  {
+    return FlxSort.byValues(order, a?.strumTime, b?.strumTime);
+  }
+
+  function compareHoldNoteSprites(order:Int, a:SustainTrail, b:SustainTrail):Int
+  {
+    return FlxSort.byValues(order, a?.strumTime, b?.strumTime);
+  }
+}
diff --git a/source/funkin/play/notes/StrumlineNote.hx b/source/funkin/play/notes/StrumlineNote.hx
new file mode 100644
index 000000000..7fbb3a0f9
--- /dev/null
+++ b/source/funkin/play/notes/StrumlineNote.hx
@@ -0,0 +1,187 @@
+package funkin.play.notes;
+
+import flixel.graphics.frames.FlxAtlasFrames;
+import flixel.FlxSprite;
+import funkin.play.notes.NoteSprite;
+
+/**
+ * The actual receptor that you see on screen.
+ */
+class StrumlineNote extends FlxSprite
+{
+  public var isPlayer(default, null):Bool;
+
+  public var direction(default, set):NoteDirection;
+
+  public function updatePosition(parentNote:NoteSprite)
+  {
+    this.x = parentNote.x;
+    this.x += parentNote.width / 2;
+    this.x -= this.width / 2;
+
+    this.y = parentNote.y;
+    this.y += parentNote.height / 2;
+  }
+
+  function set_direction(value:NoteDirection):NoteDirection
+  {
+    this.direction = value;
+    setup();
+    return this.direction;
+  }
+
+  public function new(isPlayer:Bool, direction:NoteDirection)
+  {
+    super(0, 0);
+
+    this.isPlayer = isPlayer;
+
+    this.direction = direction;
+
+    this.animation.callback = onAnimationFrame;
+    this.animation.finishCallback = onAnimationFinished;
+
+    this.active = true;
+  }
+
+  function onAnimationFrame(name:String, frameNumber:Int, frameIndex:Int):Void {}
+
+  function onAnimationFinished(name:String):Void
+  {
+    if (!isPlayer && name.startsWith('confirm'))
+    {
+      playStatic();
+    }
+  }
+
+  override function update(elapsed:Float)
+  {
+    super.update(elapsed);
+
+    centerOrigin();
+  }
+
+  function setup():Void
+  {
+    this.frames = Paths.getSparrowAtlas('StrumlineNotes');
+
+    switch (this.direction)
+    {
+      case NoteDirection.LEFT:
+        this.animation.addByIndices('static', 'left confirm', [6, 7], '', 24, false, false, false);
+        this.animation.addByPrefix('press', 'left press', 24, false, false, false);
+        this.animation.addByIndices('confirm', 'left confirm', [0, 1, 2, 3], '', 24, false, false, false);
+        this.animation.addByIndices('confirm-hold', 'left confirm', [2, 3, 4, 5], '', 24, true, false, false);
+
+      case NoteDirection.DOWN:
+        this.animation.addByIndices('static', 'down confirm', [6, 7], '', 24, false, false, false);
+        this.animation.addByPrefix('press', 'down press', 24, false, false, false);
+        this.animation.addByIndices('confirm', 'down confirm', [0, 1, 2, 3], '', 24, false, false, false);
+        this.animation.addByIndices('confirm-hold', 'down confirm', [2, 3, 4, 5], '', 24, true, false, false);
+
+      case NoteDirection.UP:
+        this.animation.addByIndices('static', 'up confirm', [6, 7], '', 24, false, false, false);
+        this.animation.addByPrefix('press', 'up press', 24, false, false, false);
+        this.animation.addByIndices('confirm', 'up confirm', [0, 1, 2, 3], '', 24, false, false, false);
+        this.animation.addByIndices('confirm-hold', 'up confirm', [2, 3, 4, 5], '', 24, true, false, false);
+
+      case NoteDirection.RIGHT:
+        this.animation.addByIndices('static', 'right confirm', [6, 7], '', 24, false, false, false);
+        this.animation.addByPrefix('press', 'right press', 24, false, false, false);
+        this.animation.addByIndices('confirm', 'right confirm', [0, 1, 2, 3], '', 24, false, false, false);
+        this.animation.addByIndices('confirm-hold', 'right confirm', [2, 3, 4, 5], '', 24, true, false, false);
+    }
+
+    this.antialiasing = true;
+
+    this.setGraphicSize(Std.int(Strumline.STRUMLINE_SIZE * 1.55));
+    this.updateHitbox();
+    this.playStatic();
+  }
+
+  public function playAnimation(name:String = 'static', force:Bool = false, reversed:Bool = false, startFrame:Int = 0):Void
+  {
+    this.animation.play(name, force, reversed, startFrame);
+
+    centerOffsets();
+    centerOrigin();
+  }
+
+  public function playStatic():Void
+  {
+    this.active = false;
+    this.playAnimation('static', true);
+  }
+
+  public function playPress():Void
+  {
+    this.active = true;
+    this.playAnimation('press', true);
+  }
+
+  public function playConfirm():Void
+  {
+    this.active = true;
+    this.playAnimation('confirm', true);
+  }
+
+  public function isConfirm():Bool
+  {
+    return getCurrentAnimation().startsWith('confirm');
+  }
+
+  public function holdConfirm():Void
+  {
+    this.active = true;
+
+    if (getCurrentAnimation() == "confirm-hold") return;
+    if (getCurrentAnimation() == "confirm")
+    {
+      if (isAnimationFinished())
+      {
+        this.playAnimation('confirm-hold', true, false);
+      }
+      return;
+    }
+    this.playAnimation('confirm', false, false);
+  }
+
+  /**
+   * Returns the name of the animation that is currently playing.
+   * If no animation is playing (usually this means the sprite is BROKEN!),
+   *   returns an empty string to prevent NPEs.
+   */
+  public function getCurrentAnimation():String
+  {
+    if (this.animation == null || this.animation.curAnim == null) return "";
+    return this.animation.curAnim.name;
+  }
+
+  public function isAnimationFinished():Bool
+  {
+    return this.animation.finished;
+  }
+
+  static final DEFAULT_OFFSET:Int = 13;
+
+  /**
+   * Adjusts the position of the sprite's graphic relative to the hitbox.
+   */
+  function fixOffsets():Void
+  {
+    // Automatically center the bounding box within the graphic.
+    this.centerOffsets();
+
+    if (getCurrentAnimation() == "confirm")
+    {
+      // Move the graphic down and to the right to compensate for
+      // the "glow" effect on the strumline note.
+      this.offset.x -= DEFAULT_OFFSET;
+      this.offset.y -= DEFAULT_OFFSET;
+    }
+    else
+    {
+      this.centerOrigin();
+    }
+  }
+}
diff --git a/source/funkin/play/notes/SustainTrail.hx b/source/funkin/play/notes/SustainTrail.hx
new file mode 100644
index 000000000..0b84f2d64
--- /dev/null
+++ b/source/funkin/play/notes/SustainTrail.hx
@@ -0,0 +1,272 @@
+package funkin.play.notes;
+
+import funkin.play.notes.NoteDirection;
+import funkin.play.song.SongData.SongNoteData;
+import flixel.util.FlxDirectionFlags;
+import flixel.FlxSprite;
+import flixel.graphics.FlxGraphic;
+import flixel.graphics.tile.FlxDrawTrianglesItem;
+import flixel.math.FlxMath;
+import funkin.ui.PreferencesMenu;
+
+/**
+ * This is based heavily on the `FlxStrip` class. It uses `drawTriangles()` to clip a sustain note
+ * trail at a certain time.
+ * The whole `FlxGraphic` is used as a texture map. See the `NOTE_hold_assets.fla` file for specifics
+ * on how it should be constructed.
+ *
+ * @author MtH
+ */
+class SustainTrail extends FlxSprite
+{
+  /**
+   * The triangles corresponding to the hold, followed by the endcap.
+   * `top left, top right, bottom left`
+   * `top left, bottom left, bottom right`
+   */
+  static final TRIANGLE_VERTEX_INDICES:Array<Int> = [0, 1, 2, 1, 2, 3, 4, 5, 6, 5, 6, 7];
+
+  public var strumTime:Float = 0; // millis
+  public var noteDirection:NoteDirection = 0;
+  public var sustainLength(default, set):Float = 0; // millis
+  public var fullSustainLength:Float = 0;
+  public var noteData:SongNoteData;
+
+  /**
+   * Set to `true` if the user missed the note.
+   * The trail should be made transparent, with clipping and effects disabled
+   */
+  public var missed:Bool = false; // maybe BlendMode.MULTIPLY if missed somehow, drawTriangles does not support!
+
+  /**
+   * A `Vector` of floats where each pair of numbers is treated as a coordinate location (an x, y pair).
+   */
+  public var vertices:DrawData<Float> = new DrawData<Float>();
+
+  /**
+   * A `Vector` of integers or indexes, where every three indexes define a triangle.
+   */
+  public var indices:DrawData<Int> = new DrawData<Int>();
+
+  /**
+   * A `Vector` of normalized coordinates used to apply texture mapping.
+   */
+  public var uvtData:DrawData<Float> = new DrawData<Float>();
+
+  private var processedGraphic:FlxGraphic;
+
+  private var zoom:Float = 1;
+
+  /**
+   * What part of the trail's end actually represents the end of the note.
+   * This can be used to have a little bit sticking out.
+   */
+  public var endOffset:Float = 0.5; // 0.73 is roughly the bottom of the sprite in the normal graphic!
+
+  /**
+   * At what point the bottom for the trail's end should be clipped off.
+   * Used in cases where there's an extra bit of the graphic on the bottom to avoid antialiasing issues with overflow.
+   */
+  public var bottomClip:Float = 0.9;
+
+  /**
+   * Normally you would take strumTime:Float, noteData:Int, sustainLength:Float, parentNote:Note (?)
+   * @param NoteData
+   * @param SustainLength Length in milliseconds.
+   * @param fileName
+   */
+  public function new(noteDirection:NoteDirection, sustainLength:Float, fileName:String)
+  {
+    super(0, 0, fileName);
+
+    antialiasing = true;
+    if (fileName == "arrowEnds")
+    {
+      endOffset = bottomClip = 1;
+      antialiasing = false;
+      zoom = 6;
+    }
+    // BASIC SETUP
+    this.sustainLength = sustainLength;
+    this.fullSustainLength = sustainLength;
+    this.noteDirection = noteDirection;
+
+    zoom *= 0.7;
+
+    // CALCULATE SIZE
+    width = graphic.width / 8 * zoom; // amount of notes * 2
+    height = sustainHeight(sustainLength, PlayState.instance.currentChart.scrollSpeed);
+    // instead of scrollSpeed, PlayState.SONG.speed
+
+    flipY = PreferencesMenu.getPref('downscroll');
+
+    // alpha = 0.6;
+    alpha = 1.0;
+    // calls updateColorTransform(), which initializes processedGraphic!
+    updateColorTransform();
+
+    updateClipping();
+    indices = new DrawData<Int>(12, true, TRIANGLE_VERTEX_INDICES);
+  }
+
+  /**
+   * Calculates height of a sustain note for a given length (milliseconds) and scroll speed.
+   * @param	susLength	The length of the sustain note in milliseconds.
+   * @param	scroll		The current scroll speed.
+   */
+  public static inline function sustainHeight(susLength:Float, scroll:Float)
+  {
+    return (susLength * 0.45 * scroll);
+  }
+
+  function set_sustainLength(s:Float)
+  {
+    if (s < 0) s = 0;
+
+    height = sustainHeight(s, PlayState.instance.currentChart.scrollSpeed);
+    updateColorTransform();
+    updateClipping();
+    return sustainLength = s;
+  }
+
+  /**
+   * Sets up new vertex and UV data to clip the trail.
+   * If flipY is true, top and bottom bounds swap places.
+   * @param songTime	The time to clip the note at, in milliseconds.
+   */
+  public function updateClipping(songTime:Float = 0):Void
+  {
+    var clipHeight:Float = FlxMath.bound(sustainHeight(sustainLength - (songTime - strumTime), PlayState.instance.currentChart.scrollSpeed), 0, height);
+    if (clipHeight == 0)
+    {
+      visible = false;
+      return;
+    }
+    else
+      visible = true;
+
+    var bottomHeight:Float = graphic.height * zoom * endOffset;
+    var partHeight:Float = clipHeight - bottomHeight;
+
+    // ===HOLD VERTICES==
+    // Top left
+    vertices[0 * 2] = 0.0; // Inline with left side
+    vertices[0 * 2 + 1] = flipY ? clipHeight : height - clipHeight;
+
+    // Top right
+    vertices[1 * 2] = width;
+    vertices[1 * 2 + 1] = vertices[0 * 2 + 1]; // Inline with top left vertex
+
+    // Bottom left
+    vertices[2 * 2] = 0.0; // Inline with left side
+    vertices[2 * 2 + 1] = if (partHeight > 0)
+    {
+      // flipY makes the sustain render upside down.
+      flipY ? 0.0 + bottomHeight : vertices[1] + partHeight;
+    }
+    else
+    {
+      vertices[0 * 2 + 1]; // Inline with top left vertex (no partHeight available)
+    }
+
+    // Bottom right
+    vertices[3 * 2] = width;
+    vertices[3 * 2 + 1] = vertices[2 * 2 + 1]; // Inline with bottom left vertex
+
+    // ===HOLD UVs===
+
+    // The UVs are a bit more complicated.
+    // UV coordinates are normalized, so they range from 0 to 1.
+    // We are expecting an image containing 8 horizontal segments, each representing a different colored hold note followed by its end cap.
+
+    uvtData[0 * 2] = 1 / 4 * (noteDirection % 4); // 0%/25%/50%/75% of the way through the image
+    uvtData[0 * 2 + 1] = (-partHeight) / graphic.height / zoom; // top bound
+    // Top left
+
+    // Top right
+    uvtData[1 * 2] = uvtData[0 * 2] + 1 / 8; // 12.5%/37.5%/62.5%/87.5% of the way through the image (1/8th past the top left)
+    uvtData[1 * 2 + 1] = uvtData[0 * 2 + 1]; // top bound
+
+    // Bottom left
+    uvtData[2 * 2] = uvtData[0 * 2]; // 0%/25%/50%/75% of the way through the image
+    uvtData[2 * 2 + 1] = 0.0; // bottom bound
+
+    // Bottom right
+    uvtData[3 * 2] = uvtData[1 * 2]; // 12.5%/37.5%/62.5%/87.5% of the way through the image (1/8th past the top left)
+    uvtData[3 * 2 + 1] = uvtData[2 * 2 + 1]; // bottom bound
+
+    // === END CAP VERTICES ===
+    // Top left
+    vertices[4 * 2] = vertices[2 * 2]; // Inline with bottom left vertex of hold
+    vertices[4 * 2 + 1] = vertices[2 * 2 + 1]; // Inline with bottom left vertex of hold
+
+    // Top right
+    vertices[5 * 2] = vertices[3 * 2]; // Inline with bottom right vertex of hold
+    vertices[5 * 2 + 1] = vertices[3 * 2 + 1]; // Inline with bottom right vertex of hold
+
+    // Bottom left
+    vertices[6 * 2] = vertices[2 * 2]; // Inline with left side
+    vertices[6 * 2 + 1] = flipY ? (graphic.height * (-bottomClip + endOffset) * zoom) : (height + graphic.height * (bottomClip - endOffset) * zoom);
+
+    // Bottom right
+    vertices[7 * 2] = vertices[3 * 2]; // Inline with right side
+    vertices[7 * 2 + 1] = vertices[6 * 2 + 1]; // Inline with bottom of end cap
+
+    // === END CAP UVs ===
+    // Top left
+    uvtData[4 * 2] = uvtData[2 * 2] + 1 / 8; // 12.5%/37.5%/62.5%/87.5% of the way through the image (1/8th past the top left of hold)
+    uvtData[4 * 2 + 1] = if (partHeight > 0)
+    {
+      0;
+    }
+    else
+    {
+      (bottomHeight - clipHeight) / zoom / graphic.height;
+    };
+
+    // Top right
+    uvtData[5 * 2] = uvtData[4 * 2] + 1 / 8; // 25%/50%/75%/100% of the way through the image (1/8th past the top left of cap)
+    uvtData[5 * 2 + 1] = uvtData[4 * 2 + 1]; // top bound
+
+    // Bottom left
+    uvtData[6 * 2] = uvtData[4 * 2]; // 12.5%/37.5%/62.5%/87.5% of the way through the image (1/8th past the top left of hold)
+    uvtData[6 * 2 + 1] = bottomClip; // bottom bound
+
+    // Bottom right
+    uvtData[7 * 2] = uvtData[5 * 2]; // 25%/50%/75%/100% of the way through the image (1/8th past the top left of cap)
+    uvtData[7 * 2 + 1] = uvtData[6 * 2 + 1]; // bottom bound
+  }
+
+  @:access(flixel.FlxCamera)
+  override public function draw():Void
+  {
+    if (alpha == 0 || graphic == null || vertices == null) return;
+
+    for (camera in cameras)
+    {
+      if (!camera.visible || !camera.exists) continue;
+      // if (!isOnScreen(camera)) continue; // TODO: Update this code to make it work properly.
+
+      getScreenPosition(_point, camera).subtractPoint(offset);
+      camera.drawTriangles(processedGraphic, vertices, indices, uvtData, null, _point, blend, true, antialiasing);
+    }
+  }
+
+  override public function destroy():Void
+  {
+    vertices = null;
+    indices = null;
+    uvtData = null;
+    processedGraphic.destroy();
+
+    super.destroy();
+  }
+
+  override function updateColorTransform():Void
+  {
+    super.updateColorTransform();
+    if (processedGraphic != null) processedGraphic.destroy();
+    processedGraphic = FlxGraphic.fromGraphic(graphic, true);
+    processedGraphic.bitmap.colorTransform(processedGraphic.bitmap.rect, colorTransform);
+  }
+}
diff --git a/source/funkin/play/song/Song.hx b/source/funkin/play/song/Song.hx
index 7de005cb0..b42c8e7c4 100644
--- a/source/funkin/play/song/Song.hx
+++ b/source/funkin/play/song/Song.hx
@@ -298,9 +298,16 @@ class SongDifficulty
     return cast events;
   }
 
-  public inline function cacheInst():Void
+  public inline function cacheInst(?currentPlayerId:String = null):Void
   {
-    FlxG.sound.cache(Paths.inst(this.song.songId));
+    if (currentPlayerId != null)
+    {
+      FlxG.sound.cache(Paths.inst(this.song.songId, getPlayableChar(currentPlayerId).inst));
+    }
+    else
+    {
+      FlxG.sound.cache(Paths.inst(this.song.songId));
+    }
   }
 
   public inline function playInst(volume:Float = 1.0, looped:Bool = false):Void
diff --git a/source/funkin/play/song/SongData.hx b/source/funkin/play/song/SongData.hx
index a744c9a65..dc46ae365 100644
--- a/source/funkin/play/song/SongData.hx
+++ b/source/funkin/play/song/SongData.hx
@@ -427,6 +427,12 @@ abstract SongNoteData(RawSongNoteData)
     return Math.floor(this.d / strumlineSize);
   }
 
+  /**
+   * Returns true if the note is one that Boyfriend should try to hit (i.e. it's on his side).
+   * TODO: The name of this function is a little misleading; what about mines?
+   * @param strumlineSize Defaults to 4.
+   * @return True if it's Boyfriend's note.
+   */
   public inline function getMustHitNote(strumlineSize:Int = 4):Bool
   {
     return getStrumlineIndex(strumlineSize) == 0;
diff --git a/source/funkin/ui/ColorsMenu.hx b/source/funkin/ui/ColorsMenu.hx
index 9ebccf1c9..68fc7e7e0 100644
--- a/source/funkin/ui/ColorsMenu.hx
+++ b/source/funkin/ui/ColorsMenu.hx
@@ -5,23 +5,24 @@ import flixel.addons.effects.chainable.FlxOutlineEffect;
 import flixel.group.FlxGroup.FlxTypedGroup;
 import flixel.util.FlxColor;
 import funkin.ui.OptionsState.Page;
+import funkin.play.notes.NoteSprite;
 
 class ColorsMenu extends Page
 {
   var curSelected:Int = 0;
 
-  var grpNotes:FlxTypedGroup<Note>;
+  var grpNotes:FlxTypedGroup<NoteSprite>;
 
   public function new()
   {
     super();
 
-    grpNotes = new FlxTypedGroup<Note>();
+    grpNotes = new FlxTypedGroup<NoteSprite>();
     add(grpNotes);
 
     for (i in 0...4)
     {
-      var note:Note = new Note(0, i);
+      var note:NoteSprite = new NoteSprite(0, i);
 
       note.x = (100 * i) + i;
       note.screenCenter(Y);
@@ -52,14 +53,14 @@ class ColorsMenu extends Page
 
     if (controls.UI_UP)
     {
-      grpNotes.members[curSelected].colorSwap.update(elapsed * 0.3);
-      Note.arrowColors[curSelected] += elapsed * 0.3;
+      // grpNotes.members[curSelected].colorSwap.update(elapsed * 0.3);
+      // Note.arrowColors[curSelected] += elapsed * 0.3;
     }
 
     if (controls.UI_DOWN)
     {
-      grpNotes.members[curSelected].colorSwap.update(-elapsed * 0.3);
-      Note.arrowColors[curSelected] += -elapsed * 0.3;
+      // grpNotes.members[curSelected].colorSwap.update(-elapsed * 0.3);
+      // Note.arrowColors[curSelected] += -elapsed * 0.3;
     }
 
     super.update(elapsed);
diff --git a/source/funkin/ui/debug/charting/ChartEditorState.hx b/source/funkin/ui/debug/charting/ChartEditorState.hx
index a23a04231..566e75706 100644
--- a/source/funkin/ui/debug/charting/ChartEditorState.hx
+++ b/source/funkin/ui/debug/charting/ChartEditorState.hx
@@ -22,6 +22,7 @@ import funkin.audio.VoicesGroup;
 import funkin.input.Cursor;
 import funkin.modding.events.ScriptEvent;
 import funkin.play.HealthIcon;
+import funkin.play.notes.NoteSprite;
 import funkin.play.song.Song;
 import funkin.play.song.SongData.SongChartData;
 import funkin.play.song.SongData.SongDataParser;
@@ -2803,11 +2804,9 @@ class ChartEditorState extends HaxeUIState
 
       // Character preview.
 
-      // Why does NOTESCRIPTEVENT TAKE A SPRITE AAAAA
-      var tempNote:Note = new Note(noteData.time, noteData.data, null, false, NORMAL);
-      tempNote.mustPress = noteData.getMustHitNote();
-      tempNote.data.sustainLength = noteData.length;
-      tempNote.data.noteKind = noteData.kind;
+      // NoteScriptEvent takes a sprite, ehe. Need to rework that.
+      var tempNote:NoteSprite = new NoteSprite();
+      tempNote.noteData = noteData;
       tempNote.scrollFactor.set(0, 0);
       var event:NoteScriptEvent = new NoteScriptEvent(ScriptEvent.NOTE_HIT, tempNote, 1, true);
       dispatchEvent(event);
diff --git a/source/funkin/util/Constants.hx b/source/funkin/util/Constants.hx
index c1bac76c4..bcf0f7359 100644
--- a/source/funkin/util/Constants.hx
+++ b/source/funkin/util/Constants.hx
@@ -88,9 +88,9 @@ class Constants
   public static final COLOR_HEALTH_BAR_GREEN:FlxColor = 0xFF66FF33;
 
   /**
-   * Default variation for charts.
+   * The base colors of the notes.
    */
-  public static final DEFAULT_VARIATION:String = 'default';
+  public static final COLOR_NOTES:Array<FlxColor> = [0xFFFF22AA, 0xFF00EEFF, 0xFF00CC00, 0xFFCC1111];
 
   /**
    * STAGE DEFAULTS
@@ -117,6 +117,11 @@ class Constants
    */
   public static final DEFAULT_SONG:String = 'tutorial';
 
+  /**
+   * Default variation for charts.
+   */
+  public static final DEFAULT_VARIATION:String = 'default';
+
   /**
    * OTHER
    */
@@ -144,6 +149,9 @@ class Constants
    */
   public static final COUNTDOWN_VOLUME:Float = 0.6;
 
+  public static final STRUMLINE_X_OFFSET:Float = 48;
+  public static final STRUMLINE_Y_OFFSET:Float = 24;
+
   /**
    * The default intensity for camera zooms.
    */
diff --git a/source/funkin/util/SortUtil.hx b/source/funkin/util/SortUtil.hx
index 60b522744..649923275 100644
--- a/source/funkin/util/SortUtil.hx
+++ b/source/funkin/util/SortUtil.hx
@@ -4,6 +4,7 @@ package funkin.util;
 import flixel.FlxBasic;
 import flixel.util.FlxSort;
 #end
+import funkin.play.notes.NoteSprite;
 
 class SortUtil
 {
@@ -22,8 +23,8 @@ class SortUtil
    *
    * @param order Either `FlxSort.ASCENDING` or `FlxSort.DESCENDING`
    */
-  public static inline function byStrumtime(order:Int, a:Note, b:Note)
+  public static inline function byStrumtime(order:Int, a:NoteSprite, b:NoteSprite)
   {
-    return FlxSort.byValues(order, a.data.strumTime, b.data.strumTime);
+    return FlxSort.byValues(order, a.noteData.time, b.noteData.time);
   }
 }
diff --git a/source/funkin/util/WindowUtil.hx b/source/funkin/util/WindowUtil.hx
index f2f1dcf0a..42930570f 100644
--- a/source/funkin/util/WindowUtil.hx
+++ b/source/funkin/util/WindowUtil.hx
@@ -51,4 +51,13 @@ class WindowUtil
     // Do nothing.
     #end
   }
+
+  /**
+   * Sets the title of the application window.
+   * @param value The title to use.
+   */
+  public static function setWindowTitle(value:String):Void
+  {
+    lime.app.Application.current.window.title = value;
+  }
 }
diff --git a/source/funkin/util/tools/ArraySortTools.hx b/source/funkin/util/tools/ArraySortTools.hx
new file mode 100644
index 000000000..3af114b98
--- /dev/null
+++ b/source/funkin/util/tools/ArraySortTools.hx
@@ -0,0 +1,154 @@
+package funkin.util.tools;
+
+/**
+ * Contains code for sorting arrays using various algorithms.
+ * @see https://algs4.cs.princeton.edu/20sorting/
+ */
+class ArraySortTools
+{
+  /**
+   * Sorts the input array using the merge sort algorithm.
+   * Stable and guaranteed to run in linearithmic time `O(n log n)`,
+   * but less efficient in "best-case" situations.
+   *
+   * @param input The array to sort in-place.
+   * @param compare The comparison function to use.
+   */
+  public static function mergeSort<T>(input:Array<T>, compare:CompareFunction<T>):Void
+  {
+    if (input == null || input.length <= 1) return;
+    if (compare == null) throw 'No comparison function provided.';
+
+    // Haxe implements merge sort by default.
+    haxe.ds.ArraySort.sort(input, compare);
+  }
+
+  /**
+   * Sorts the input array using the quick sort algorithm.
+   * More efficient on smaller arrays, but is inefficient `O(n^2)` in "worst-case" situations.
+   * Not stable; relative order of equal elements is not preserved.
+   *
+   * @see https://stackoverflow.com/questions/33884057/quick-sort-stackoverflow-error-for-large-arrays
+   *      Fix for stack overflow issues.
+   * @param input The array to sort in-place.
+   * @param compare The comparison function to use.
+   */
+  public static function quickSort<T>(input:Array<T>, compare:CompareFunction<T>):Void
+  {
+    if (input == null || input.length <= 1) return;
+    if (compare == null) throw 'No comparison function provided.';
+
+    quickSortInner(input, 0, input.length - 1, compare);
+  }
+
+  /**
+   * Internal recursive function for the quick sort algorithm.
+   * Written with ChatGPT!
+   */
+  static function quickSortInner<T>(input:Array<T>, low:Int, high:Int, compare:CompareFunction<T>):Void
+  {
+    // When low == high, the array is empty or too small to sort.
+
+    // EDIT: Recurse on the smaller partition, and loop for the larger partition.
+    while (low < high)
+    {
+      // Designate the first element in the array as the pivot, then partition the array around it.
+      // Elements less than the pivot will be to the left, and elements greater than the pivot will be to the right.
+      // Return the index of the pivot.
+      var pivot:Int = quickSortPartition(input, low, high, compare);
+
+      if ((pivot) - low <= high - (pivot + 1))
+      {
+        quickSortInner(input, low, pivot, compare);
+        low = pivot + 1;
+      }
+      else
+      {
+        quickSortInner(input, pivot + 1, high, compare);
+        high = pivot;
+      }
+    }
+  }
+
+  /**
+   * Internal function for sorting a partition of an array in the quick sort algorithm.
+   * Written with ChatGPT!
+   */
+  static function quickSortPartition<T>(input:Array<T>, low:Int, high:Int, compare:CompareFunction<T>):Int
+  {
+    // Designate the first element in the array as the pivot.
+    var pivot:T = input[low];
+    // Designate two pointers, used to divide the array into two partitions.
+    var i:Int = low - 1;
+    var j:Int = high + 1;
+
+    while (true)
+    {
+      // Move the left pointer to the right until it finds an element greater than the pivot.
+      do
+      {
+        i++;
+      }
+      while (compare(input[i], pivot) < 0);
+
+        // Move the right pointer to the left until it finds an element less than the pivot.
+      do
+      {
+        j--;
+      }
+      while (compare(input[j], pivot) > 0);
+
+        // If i and j have crossed, the array has been partitioned, and the pivot will be at the index j.
+      if (i >= j) return j;
+
+      // Else, swap the elements at i and j, and start over.
+      // This slowly moves the pivot towards the middle of the partition,
+      // while moving elements less than the pivot to the left and elements greater than the pivot to the right.
+      var temp:T = input[i];
+      input[i] = input[j];
+      input[j] = temp;
+    }
+  }
+
+  /**
+   * Sorts the input array using the insertion sort algorithm.
+   * Stable and is very fast on nearly-sorted arrays,
+   * but is inefficient `O(n^2)` in "worst-case" situations.
+   *
+   * @param input The array to sort in-place.
+   * @param compare The comparison function to use.
+   */
+  public static function insertionSort<T>(input:Array<T>, compare:CompareFunction<T>):Void
+  {
+    if (input == null || input.length <= 1) return;
+    if (compare == null) throw 'No comparison function provided.';
+
+    // Iterate through the array, starting at the second element.
+    for (i in 1...input.length)
+    {
+      // Store the current element.
+      var current:T = input[i];
+      // Store the index of the previous element.
+      var j:Int = i - 1;
+
+      // While the previous element is greater than the current element,
+      // move the previous element to the right and move the index to the left.
+      while (j >= 0 && compare(input[j], current) > 0)
+      {
+        input[j + 1] = input[j];
+        j--;
+      }
+
+      // Insert the current element into the array.
+      input[j + 1] = current;
+    }
+  }
+}
+
+/**
+ * A comparison function.
+ * Returns a negative number if the first argument is less than the second,
+ * a positive number if the first argument is greater than the second,
+ * or zero if the two arguments are equal.
+ */
+typedef CompareFunction<T> = T->T->Int;
diff --git a/source/funkin/util/tools/ArrayTools.hx b/source/funkin/util/tools/ArrayTools.hx
index 02671a8e8..c27f1bf43 100644
--- a/source/funkin/util/tools/ArrayTools.hx
+++ b/source/funkin/util/tools/ArrayTools.hx
@@ -22,4 +22,19 @@ class ArrayTools
     }
     return result;
   }
+
+  /**
+   * Return the first element of the array that satisfies the predicate, or null if none do.
+   * @param input The array to search
+   * @param predicate The predicate to call
+   * @return The result
+   */
+  public static function find<T>(input:Array<T>, predicate:T->Bool):Null<T>
+  {
+    for (element in input)
+    {
+      if (predicate(element)) return element;
+    }
+    return null;
+  }
 }

From 2cae781984a91505f79771bfe6b8e2eb7bda22c6 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Thu, 22 Jun 2023 19:28:39 -0400
Subject: [PATCH 02/30] Sustains are kinda working?

---
 source/funkin/play/PlayState.hx          | 20 +++----------
 source/funkin/play/notes/NoteSprite.hx   | 12 ++++++++
 source/funkin/play/notes/Strumline.hx    | 37 +++++++++++++++---------
 source/funkin/play/notes/SustainTrail.hx | 28 ++++++++++++++++--
 4 files changed, 64 insertions(+), 33 deletions(-)

diff --git a/source/funkin/play/PlayState.hx b/source/funkin/play/PlayState.hx
index 5583b7fed..bc1d4fb30 100644
--- a/source/funkin/play/PlayState.hx
+++ b/source/funkin/play/PlayState.hx
@@ -1696,10 +1696,6 @@ class PlayState extends MusicBeatState
         // Judge the miss.
         // NOTE: This is what handles the scoring.
         onNoteMiss(note);
-
-        // Kill the note.
-        // NOTE: This is what handles recycling the note graphic.
-        playerStrumline.killNote(note);
       }
     }
   }
@@ -1932,17 +1928,9 @@ class PlayState extends MusicBeatState
         popUpScore(note, input);
       }
 
-      playerStrumline.playConfirm(note.noteData.getDirection());
+      playerStrumline.hitNote(note);
 
-      note.hasBeenHit = true;
       vocals.playerVolume = 1;
-
-      if (!note.isSustainNote)
-      {
-        note.kill();
-        // activeNotes.remove(note, true);
-        note.destroy();
-      }
     }
   }
 
@@ -2017,9 +2005,9 @@ class PlayState extends MusicBeatState
     note.active = false;
     note.visible = false;
 
-    note.kill();
-    // activeNotes.remove(note, true);
-    note.destroy();
+    // Kill the note.
+    // NOTE: This is what handles recycling the note graphic.
+    playerStrumline.killNote(note);
   }
 
   /**
diff --git a/source/funkin/play/notes/NoteSprite.hx b/source/funkin/play/notes/NoteSprite.hx
index e4b866cc4..655f7e380 100644
--- a/source/funkin/play/notes/NoteSprite.hx
+++ b/source/funkin/play/notes/NoteSprite.hx
@@ -175,4 +175,16 @@ class NoteSprite extends FlxSprite
     this.tooLate = false;
     this.hasMissed = false;
   }
+
+  public override function kill():Void
+  {
+    super.kill();
+  }
+
+  public override function destroy():Void
+  {
+    // This function should ONLY get called as you leave PlayState entirely.
+    // Otherwise, we want the game to keep reusing note sprites to save memory.
+    super.destroy();
+  }
 }
diff --git a/source/funkin/play/notes/Strumline.hx b/source/funkin/play/notes/Strumline.hx
index 7be17a4cb..3cd503b3b 100644
--- a/source/funkin/play/notes/Strumline.hx
+++ b/source/funkin/play/notes/Strumline.hx
@@ -161,10 +161,10 @@ class Strumline extends FlxSpriteGroup
    * @param strumTime
    * @return Float
    */
-  static function calculateNoteYPos(strumTime:Float):Float
+  static function calculateNoteYPos(strumTime:Float, ?vwoosh:Bool = true):Float
   {
     // Make the note move faster visually as it moves offscreen.
-    var vwoosh:Float = (strumTime < Conductor.songPosition) ? 2.0 : 1.0;
+    var vwoosh:Float = (strumTime < Conductor.songPosition) && vwoosh ? 2.0 : 1.0;
     var scrollSpeed:Float = PlayState.instance?.currentChart?.scrollSpeed ?? 1.0;
 
     return Conductor.PIXELS_PER_MS * (Conductor.songPosition - strumTime) * scrollSpeed * vwoosh * (PreferencesMenu.getPref('downscroll') ? 1 : -1);
@@ -206,13 +206,13 @@ class Strumline extends FlxSpriteGroup
       {
         note.visible = false;
         note.hasMissed = true;
-        if (note.holdNoteSprite != null) note.holdNoteSprite.missed = true;
+        if (note.holdNoteSprite != null) note.holdNoteSprite.missedNote = true;
       }
       else
       {
         note.visible = true;
         note.hasMissed = false;
-        if (note.holdNoteSprite != null) note.holdNoteSprite.missed = false;
+        if (note.holdNoteSprite != null) note.holdNoteSprite.missedNote = false;
       }
     }
 
@@ -223,25 +223,25 @@ class Strumline extends FlxSpriteGroup
 
       var renderWindowEnd = holdNote.strumTime + holdNote.fullSustainLength + Conductor.HIT_WINDOW_MS + RENDER_DISTANCE_MS / 8;
 
-      if (Conductor.songPosition >= renderWindowEnd || holdNote.sustainLength <= 0)
+      if (holdNote.missedNote && Conductor.songPosition >= renderWindowEnd)
       {
         // Hold note is offscreen, kill it.
         holdNote.visible = false;
         holdNote.kill(); // Do not destroy! Recycling is faster.
       }
-      else if (holdNote.sustainLength <= 0)
+      else if (holdNote.hitNote && holdNote.sustainLength <= 0)
       {
         // Hold note is completed, kill it.
         playStatic(holdNote.noteDirection);
         holdNote.visible = false;
         holdNote.kill();
       }
-      else if (holdNote.sustainLength <= 10)
+      else if (holdNote.hitNote && holdNote.sustainLength <= 10)
       {
         // TODO: Better handle the weird edge case where the hold note is almost completed.
         holdNote.visible = false;
       }
-      else if (Conductor.songPosition > holdNote.strumTime && !holdNote.missed)
+      else if (Conductor.songPosition > holdNote.strumTime && holdNote.hitNote)
       {
         // Hold note is currently being hit, clip it off.
         holdConfirm(holdNote.noteDirection);
@@ -258,7 +258,7 @@ class Strumline extends FlxSpriteGroup
           holdNote.y = this.y - INITIAL_OFFSET + STRUMLINE_SIZE / 2;
         }
       }
-      else if (holdNote.missed && (holdNote.fullSustainLength > holdNote.sustainLength))
+      else if (holdNote.missedNote && (holdNote.fullSustainLength > holdNote.sustainLength))
       {
         // Hold note was dropped before completing, keep it in its clipped state.
         holdNote.visible = true;
@@ -285,11 +285,11 @@ class Strumline extends FlxSpriteGroup
 
         if (PreferencesMenu.getPref('downscroll'))
         {
-          holdNote.y = this.y + calculateNoteYPos(holdNote.strumTime) - holdNote.height + STRUMLINE_SIZE / 2;
+          holdNote.y = this.y + calculateNoteYPos(holdNote.strumTime, false) - holdNote.height + STRUMLINE_SIZE / 2;
         }
         else
         {
-          holdNote.y = this.y - INITIAL_OFFSET + calculateNoteYPos(holdNote.strumTime) + STRUMLINE_SIZE / 2;
+          holdNote.y = this.y - INITIAL_OFFSET + calculateNoteYPos(holdNote.strumTime, false) + STRUMLINE_SIZE / 2;
         }
       }
     }
@@ -316,7 +316,15 @@ class Strumline extends FlxSpriteGroup
   public function hitNote(note:NoteSprite):Void
   {
     playConfirm(note.direction);
+    note.hasBeenHit = true;
     killNote(note);
+
+    if (note.holdNoteSprite != null)
+    {
+      note.holdNoteSprite.hitNote = true;
+      note.holdNoteSprite.missedNote = false;
+      note.holdNoteSprite.alpha = 1.0;
+    }
   }
 
   public function killNote(note:NoteSprite):Void
@@ -327,8 +335,8 @@ class Strumline extends FlxSpriteGroup
 
     if (note.holdNoteSprite != null)
     {
-      holdNoteSprite.missed = true;
-      holdNoteSprite.alpha = 0.6;
+      note.holdNoteSprite.missedNote = true;
+      note.holdNoteSprite.alpha = 0.6;
     }
   }
 
@@ -416,7 +424,8 @@ class Strumline extends FlxSpriteGroup
       holdNoteSprite.noteDirection = note.getDirection();
       holdNoteSprite.fullSustainLength = note.length;
       holdNoteSprite.sustainLength = note.length;
-      holdNoteSprite.missed = false;
+      holdNoteSprite.missedNote = false;
+      holdNoteSprite.hitNote = false;
 
       holdNoteSprite.x = this.x;
       holdNoteSprite.x += getXPos(DIRECTIONS[note.getDirection() % KEY_COUNT]);
diff --git a/source/funkin/play/notes/SustainTrail.hx b/source/funkin/play/notes/SustainTrail.hx
index 0b84f2d64..a9cc4100e 100644
--- a/source/funkin/play/notes/SustainTrail.hx
+++ b/source/funkin/play/notes/SustainTrail.hx
@@ -33,10 +33,18 @@ class SustainTrail extends FlxSprite
   public var noteData:SongNoteData;
 
   /**
-   * Set to `true` if the user missed the note.
-   * The trail should be made transparent, with clipping and effects disabled
+   * Set to `true` if the user hit the note and is currently holding the sustain.
+   * Should display associated effects.
    */
-  public var missed:Bool = false; // maybe BlendMode.MULTIPLY if missed somehow, drawTriangles does not support!
+  public var hitNote:Bool = false;
+
+  /**
+   * Set to `true` if the user missed the note or released the sustain.
+   * Should make the trail transparent.
+   */
+  public var missedNote:Bool = false;
+
+  // maybe BlendMode.MULTIPLY if missed somehow, drawTriangles does not support!
 
   /**
    * A `Vector` of floats where each pair of numbers is treated as a coordinate location (an x, y pair).
@@ -252,6 +260,20 @@ class SustainTrail extends FlxSprite
     }
   }
 
+  public override function kill():Void
+  {
+    super.kill();
+
+    strumTime = 0;
+    noteDirection = 0;
+    sustainLength = 0;
+    fullSustainLength = 0;
+    noteData = null;
+
+    hitNote = false;
+    missedNote = false;
+  }
+
   override public function destroy():Void
   {
     vertices = null;

From 0fac9184281d71b3cc6e1db46426b38a101f1c5e Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Sun, 25 Jun 2023 12:36:00 -0400
Subject: [PATCH 03/30] Reworked anti-aliasing code (sprites now default to
 true)

---
 .vscode/settings.json                              |  3 ++-
 source/funkin/Alphabet.hx                          |  2 --
 source/funkin/ComboMilestone.hx                    |  2 --
 source/funkin/FreeplayState.hx                     |  1 -
 source/funkin/InitState.hx                         | 14 +++++---------
 source/funkin/LoadingState.hx                      |  1 -
 source/funkin/MainMenuState.hx                     |  2 --
 source/funkin/NoteSplash.hx                        |  2 --
 source/funkin/TitleState.hx                        |  4 ----
 source/funkin/freeplayStuff/FreeplayScore.hx       |  1 -
 source/funkin/freeplayStuff/SongMenuItem.hx        |  1 -
 .../funkin/graphics/adobeanimate/FlxAtlasSprite.hx |  2 --
 source/funkin/play/ResultState.hx                  |  7 -------
 source/funkin/play/notes/NoteSplash.hx             |  1 -
 source/funkin/play/notes/NoteSprite.hx             |  1 -
 source/funkin/play/notes/StrumlineNote.hx          |  2 --
 source/funkin/play/notes/SustainTrail.hx           |  2 ++
 source/funkin/ui/AtlasText.hx                      |  1 -
 source/funkin/ui/ColorsMenu.hx                     |  1 -
 source/funkin/ui/MenuList.hx                       |  1 -
 source/funkin/ui/PopUpStuff.hx                     |  5 ++++-
 source/funkin/ui/PreferencesMenu.hx                |  2 --
 source/funkin/ui/StickerSubState.hx                |  1 -
 source/funkin/ui/TallyCounter.hx                   |  1 -
 .../funkin/ui/animDebugShit/DebugBoundingState.hx  |  1 -
 25 files changed, 13 insertions(+), 48 deletions(-)

diff --git a/.vscode/settings.json b/.vscode/settings.json
index dd4cd7aef..86ae2b643 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -116,5 +116,6 @@
       "target": "html5",
       "args": ["-debug", "-watch"]
     }
-  ]
+  ],
+  "cmake.configureOnOpen": false
 }
diff --git a/source/funkin/Alphabet.hx b/source/funkin/Alphabet.hx
index a501707be..3835ae660 100644
--- a/source/funkin/Alphabet.hx
+++ b/source/funkin/Alphabet.hx
@@ -243,8 +243,6 @@ class AlphaCharacter extends FlxSprite
     super(x, y);
     var tex = Paths.getSparrowAtlas('alphabet');
     frames = tex;
-
-    antialiasing = true;
   }
 
   public function createBold(letter:String)
diff --git a/source/funkin/ComboMilestone.hx b/source/funkin/ComboMilestone.hx
index b72eda2fa..79e454c44 100644
--- a/source/funkin/ComboMilestone.hx
+++ b/source/funkin/ComboMilestone.hx
@@ -26,7 +26,6 @@ class ComboMilestone extends FlxTypedSpriteGroup<FlxSprite>
     effectStuff.frames = Paths.getSparrowAtlas('comboMilestone');
     effectStuff.animation.addByPrefix('funny', 'NOTE COMBO animation', 24, false);
     effectStuff.animation.play('funny');
-    effectStuff.antialiasing = true;
     effectStuff.animation.finishCallback = function(nameThing) {
       kill();
     };
@@ -108,7 +107,6 @@ class ComboMilestoneNumber extends FlxSprite
     frames = Paths.getSparrowAtlas('comboMilestoneNumbers');
     animation.addByPrefix(stringNum, stringNum, 24, false);
     animation.play(stringNum);
-    antialiasing = true;
     updateHitbox();
   }
 
diff --git a/source/funkin/FreeplayState.hx b/source/funkin/FreeplayState.hx
index 322e79e31..1c226dbb5 100644
--- a/source/funkin/FreeplayState.hx
+++ b/source/funkin/FreeplayState.hx
@@ -338,7 +338,6 @@ class FreeplayState extends MusicBeatSubState
     fnfHighscoreSpr.animation.addByPrefix("highscore", "highscore", 24, false);
     fnfHighscoreSpr.visible = false;
     fnfHighscoreSpr.setGraphicSize(0, Std.int(fnfHighscoreSpr.height * 1));
-    fnfHighscoreSpr.antialiasing = true;
     fnfHighscoreSpr.updateHitbox();
     add(fnfHighscoreSpr);
 
diff --git a/source/funkin/InitState.hx b/source/funkin/InitState.hx
index 0ebe7871a..52bdb1015 100644
--- a/source/funkin/InitState.hx
+++ b/source/funkin/InitState.hx
@@ -33,15 +33,11 @@ class InitState extends FlxTransitionableState
 {
   override public function create():Void
   {
-    trace('This is a debug build, loading InitState...');
-    #if android
-    FlxG.android.preventDefaultKeys = [flixel.input.android.FlxAndroidKey.BACK];
-    #end
-    #if newgrounds
-    NGio.init();
-    #end
-    #if discord_rpc
-    DiscordClient.initialize();
+    //
+    // FLIXEL SETUP
+    //
+    // This ain't a pixel art game! (most of the time)
+    FlxSprite.defaultAntialiasing = true;
 
     Application.current.onExit.add(function(exitCode) {
       DiscordClient.shutdown();
diff --git a/source/funkin/LoadingState.hx b/source/funkin/LoadingState.hx
index 604e78f79..3ec2e1005 100644
--- a/source/funkin/LoadingState.hx
+++ b/source/funkin/LoadingState.hx
@@ -42,7 +42,6 @@ class LoadingState extends MusicBeatState
     funkay.loadGraphic(Paths.image('funkay'));
     funkay.setGraphicSize(0, FlxG.height);
     funkay.updateHitbox();
-    funkay.antialiasing = true;
     add(funkay);
     funkay.scrollFactor.set();
     funkay.screenCenter();
diff --git a/source/funkin/MainMenuState.hx b/source/funkin/MainMenuState.hx
index 82fcac77d..348bf8d17 100644
--- a/source/funkin/MainMenuState.hx
+++ b/source/funkin/MainMenuState.hx
@@ -68,7 +68,6 @@ class MainMenuState extends MusicBeatState
     bg.setGraphicSize(Std.int(bg.width * 1.2));
     bg.updateHitbox();
     bg.screenCenter();
-    bg.antialiasing = true;
     add(bg);
 
     camFollow = new FlxObject(0, 0, 1, 1);
@@ -82,7 +81,6 @@ class MainMenuState extends MusicBeatState
     magenta.x = bg.x;
     magenta.y = bg.y;
     magenta.visible = false;
-    magenta.antialiasing = true;
     magenta.color = 0xFFfd719b;
     if (PreferencesMenu.preferences.get('flashing-menu')) add(magenta);
     // magenta.scrollFactor.set();
diff --git a/source/funkin/NoteSplash.hx b/source/funkin/NoteSplash.hx
index 7f3a8c5e4..a32a39c08 100644
--- a/source/funkin/NoteSplash.hx
+++ b/source/funkin/NoteSplash.hx
@@ -22,8 +22,6 @@ class NoteSplash extends FlxSprite
 
     setupNoteSplash(x, y, noteData);
 
-    antialiasing = true;
-
     // alpha = 0.75;
   }
 
diff --git a/source/funkin/TitleState.hx b/source/funkin/TitleState.hx
index bc6ef571d..bd4e7084c 100644
--- a/source/funkin/TitleState.hx
+++ b/source/funkin/TitleState.hx
@@ -149,7 +149,6 @@ class TitleState extends MusicBeatState
 
     logoBl = new FlxSprite(-150, -100);
     logoBl.frames = Paths.getSparrowAtlas('logoBumpin');
-    logoBl.antialiasing = true;
     logoBl.animation.addByPrefix('bump', 'logo bumpin', 24);
     logoBl.animation.play('bump');
 
@@ -161,7 +160,6 @@ class TitleState extends MusicBeatState
     gfDance.frames = Paths.getSparrowAtlas('gfDanceTitle');
     gfDance.animation.addByIndices('danceLeft', 'gfDance', [30, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14], "", 24, false);
     gfDance.animation.addByIndices('danceRight', 'gfDance', [15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29], "", 24, false);
-    gfDance.antialiasing = true;
 
     add(gfDance);
 
@@ -180,7 +178,6 @@ class TitleState extends MusicBeatState
     titleText.frames = Paths.getSparrowAtlas('titleEnter');
     titleText.animation.addByPrefix('idle', "Press Enter to Begin", 24);
     titleText.animation.addByPrefix('press', "ENTER PRESSED", 24);
-    titleText.antialiasing = true;
     titleText.animation.play('idle');
     titleText.updateHitbox();
     // titleText.screenCenter(X);
@@ -223,7 +220,6 @@ class TitleState extends MusicBeatState
 
     ngSpr.updateHitbox();
     ngSpr.screenCenter(X);
-    ngSpr.antialiasing = true;
 
     FlxG.mouse.visible = false;
 
diff --git a/source/funkin/freeplayStuff/FreeplayScore.hx b/source/funkin/freeplayStuff/FreeplayScore.hx
index d22dd2276..ec8f4baa7 100644
--- a/source/funkin/freeplayStuff/FreeplayScore.hx
+++ b/source/funkin/freeplayStuff/FreeplayScore.hx
@@ -117,7 +117,6 @@ class ScoreNum extends FlxSprite
     this.digit = initDigit;
 
     animation.play(numToString[digit], true);
-    antialiasing = true;
 
     setGraphicSize(Std.int(width * 0.4));
     updateHitbox();
diff --git a/source/funkin/freeplayStuff/SongMenuItem.hx b/source/funkin/freeplayStuff/SongMenuItem.hx
index a32b387a3..3d9f9dd04 100644
--- a/source/funkin/freeplayStuff/SongMenuItem.hx
+++ b/source/funkin/freeplayStuff/SongMenuItem.hx
@@ -47,7 +47,6 @@ class SongMenuItem extends FlxSpriteGroup
     favIcon.frames = Paths.getSparrowAtlas('freeplay/favHeart');
     favIcon.animation.addByPrefix('fav', "favorite heart", 24, false);
     favIcon.animation.play('fav');
-    favIcon.antialiasing = true;
     favIcon.setGraphicSize(60, 60);
     add(favIcon);
 
diff --git a/source/funkin/graphics/adobeanimate/FlxAtlasSprite.hx b/source/funkin/graphics/adobeanimate/FlxAtlasSprite.hx
index aad9cd851..ed2418930 100644
--- a/source/funkin/graphics/adobeanimate/FlxAtlasSprite.hx
+++ b/source/funkin/graphics/adobeanimate/FlxAtlasSprite.hx
@@ -42,8 +42,6 @@ class FlxAtlasSprite extends FlxAnimate
       throw 'FlxAtlasSprite not initialized properly. Are you sure the path (${path}) exists?';
     }
 
-    this.antialiasing = true;
-
     onAnimationFinish.add(cleanupAnimation);
 
     // This defaults the sprite to play the first animation in the atlas,
diff --git a/source/funkin/play/ResultState.hx b/source/funkin/play/ResultState.hx
index 302858f2f..aaa2b6d1d 100644
--- a/source/funkin/play/ResultState.hx
+++ b/source/funkin/play/ResultState.hx
@@ -114,7 +114,6 @@ class ResultState extends MusicBeatSubState
       soundSystem.animation.play("idle");
       soundSystem.visible = true;
     });
-    soundSystem.antialiasing = true;
     add(soundSystem);
 
     difficulty = new FlxSprite(555);
@@ -132,7 +131,6 @@ class ResultState extends MusicBeatSubState
     }
 
     difficulty.loadGraphic(Paths.image("resultScreen/" + diffSpr));
-    difficulty.antialiasing = true;
     add(difficulty);
 
     var fontLetters:String = "AaBbCcDdEeFfGgHhiIJjKkLlMmNnOoPpQqRrSsTtUuVvWwXxYyZz:1234567890";
@@ -148,7 +146,6 @@ class ResultState extends MusicBeatSubState
       songName.text += PlayState.instance.currentSong.songId;
     }
 
-    songName.antialiasing = true;
     songName.letterSpacing = -15;
     songName.angle = -4.1;
     add(songName);
@@ -164,22 +161,18 @@ class ResultState extends MusicBeatSubState
     var blackTopBar:FlxSprite = new FlxSprite().loadGraphic(Paths.image("resultScreen/topBarBlack"));
     blackTopBar.y = -blackTopBar.height;
     FlxTween.tween(blackTopBar, {y: 0}, 0.4, {ease: FlxEase.quartOut, startDelay: 0.5});
-    blackTopBar.antialiasing = true;
     add(blackTopBar);
 
     var resultsAnim:FlxSprite = new FlxSprite(-200, -10);
     resultsAnim.frames = Paths.getSparrowAtlas("resultScreen/results");
     resultsAnim.animation.addByPrefix("result", "results", 24, false);
     resultsAnim.animation.play("result");
-    resultsAnim.antialiasing = true;
     add(resultsAnim);
 
     var ratingsPopin:FlxSprite = new FlxSprite(-150, 120);
     ratingsPopin.frames = Paths.getSparrowAtlas("resultScreen/ratingsPopin");
     ratingsPopin.animation.addByPrefix("idle", "Categories", 24, false);
-    // ratingsPopin.animation.play("idle");
     ratingsPopin.visible = false;
-    ratingsPopin.antialiasing = true;
     add(ratingsPopin);
 
     var scorePopin:FlxSprite = new FlxSprite(-180, 520);
diff --git a/source/funkin/play/notes/NoteSplash.hx b/source/funkin/play/notes/NoteSplash.hx
index 90c9825e9..bbe08546c 100644
--- a/source/funkin/play/notes/NoteSplash.hx
+++ b/source/funkin/play/notes/NoteSplash.hx
@@ -26,7 +26,6 @@ class NoteSplash extends FlxSprite
     setup();
 
     this.alpha = ALPHA;
-    this.antialiasing = true;
     this.animation.finishCallback = this.onAnimationFinished;
   }
 
diff --git a/source/funkin/play/notes/NoteSprite.hx b/source/funkin/play/notes/NoteSprite.hx
index 655f7e380..697a29d80 100644
--- a/source/funkin/play/notes/NoteSprite.hx
+++ b/source/funkin/play/notes/NoteSprite.hx
@@ -162,7 +162,6 @@ class NoteSprite extends FlxSprite
 
     setGraphicSize(Strumline.STRUMLINE_SIZE);
     updateHitbox();
-    antialiasing = true;
   }
 
   public override function revive():Void
diff --git a/source/funkin/play/notes/StrumlineNote.hx b/source/funkin/play/notes/StrumlineNote.hx
index 7fbb3a0f9..1d24759dc 100644
--- a/source/funkin/play/notes/StrumlineNote.hx
+++ b/source/funkin/play/notes/StrumlineNote.hx
@@ -92,8 +92,6 @@ class StrumlineNote extends FlxSprite
         this.animation.addByIndices('confirm-hold', 'right confirm', [2, 3, 4, 5], '', 24, true, false, false);
     }
 
-    this.antialiasing = true;
-
     this.setGraphicSize(Std.int(Strumline.STRUMLINE_SIZE * 1.55));
     this.updateHitbox();
     this.playStatic();
diff --git a/source/funkin/play/notes/SustainTrail.hx b/source/funkin/play/notes/SustainTrail.hx
index a9cc4100e..c8f629c90 100644
--- a/source/funkin/play/notes/SustainTrail.hx
+++ b/source/funkin/play/notes/SustainTrail.hx
@@ -88,6 +88,8 @@ class SustainTrail extends FlxSprite
     super(0, 0, fileName);
 
     antialiasing = true;
+
+    // TODO: Why does this reference pixel stuff?
     if (fileName == "arrowEnds")
     {
       endOffset = bottomClip = 1;
diff --git a/source/funkin/ui/AtlasText.hx b/source/funkin/ui/AtlasText.hx
index c311e387a..76837c7ed 100644
--- a/source/funkin/ui/AtlasText.hx
+++ b/source/funkin/ui/AtlasText.hx
@@ -171,7 +171,6 @@ class AtlasChar extends FlxSprite
     super(x, y);
     frames = atlas;
     this.char = char;
-    antialiasing = true;
   }
 
   function set_char(value:String)
diff --git a/source/funkin/ui/ColorsMenu.hx b/source/funkin/ui/ColorsMenu.hx
index 68fc7e7e0..dfa0cf067 100644
--- a/source/funkin/ui/ColorsMenu.hx
+++ b/source/funkin/ui/ColorsMenu.hx
@@ -31,7 +31,6 @@ class ColorsMenu extends Page
       add(_effectSpr);
       _effectSpr.y = 0;
       _effectSpr.x = i * 130;
-      _effectSpr.antialiasing = true;
       _effectSpr.scale.x = _effectSpr.scale.y = 0.7;
       // _effectSpr.setGraphicSize();
       _effectSpr.height = note.height;
diff --git a/source/funkin/ui/MenuList.hx b/source/funkin/ui/MenuList.hx
index 39b53f998..f1de8d40e 100644
--- a/source/funkin/ui/MenuList.hx
+++ b/source/funkin/ui/MenuList.hx
@@ -225,7 +225,6 @@ class MenuItem extends FlxSprite
   {
     super(x, y);
 
-    antialiasing = true;
     setData(name, callback);
     idle();
   }
diff --git a/source/funkin/ui/PopUpStuff.hx b/source/funkin/ui/PopUpStuff.hx
index 20380f50a..3e848b9e6 100644
--- a/source/funkin/ui/PopUpStuff.hx
+++ b/source/funkin/ui/PopUpStuff.hx
@@ -45,6 +45,7 @@ class PopUpStuff extends FlxTypedGroup<FlxSprite>
     if (PlayState.instance.currentStageId.startsWith('school'))
     {
       rating.setGraphicSize(Std.int(rating.width * Constants.PIXEL_ART_SCALE * 0.7));
+      rating.antialiasing = false;
     }
     else
     {
@@ -95,6 +96,7 @@ class PopUpStuff extends FlxTypedGroup<FlxSprite>
     if (PlayState.instance.currentStageId.startsWith('school'))
     {
       comboSpr.setGraphicSize(Std.int(comboSpr.width * Constants.PIXEL_ART_SCALE * 0.7));
+      comboSpr.antialiasing = false;
     }
     else
     {
@@ -134,11 +136,12 @@ class PopUpStuff extends FlxTypedGroup<FlxSprite>
       if (PlayState.instance.currentStageId.startsWith('school'))
       {
         numScore.setGraphicSize(Std.int(numScore.width * Constants.PIXEL_ART_SCALE));
+        numScore.antialiasing = false;
       }
       else
       {
-        numScore.antialiasing = true;
         numScore.setGraphicSize(Std.int(numScore.width * 0.5));
+        numScore.antialiasing = true;
       }
       numScore.updateHitbox();
 
diff --git a/source/funkin/ui/PreferencesMenu.hx b/source/funkin/ui/PreferencesMenu.hx
index 0bf83c125..4fa8f7f5b 100644
--- a/source/funkin/ui/PreferencesMenu.hx
+++ b/source/funkin/ui/PreferencesMenu.hx
@@ -177,8 +177,6 @@ class CheckboxThingie extends FlxSprite
     animation.addByPrefix('static', 'Check Box unselected', 24, false);
     animation.addByPrefix('checked', 'Check Box selecting animation', 24, false);
 
-    antialiasing = true;
-
     setGraphicSize(Std.int(width * 0.7));
     updateHitbox();
 
diff --git a/source/funkin/ui/StickerSubState.hx b/source/funkin/ui/StickerSubState.hx
index 981c79dfa..e9d528773 100644
--- a/source/funkin/ui/StickerSubState.hx
+++ b/source/funkin/ui/StickerSubState.hx
@@ -266,7 +266,6 @@ class StickerSprite extends FlxSprite
     super(x, y);
     loadGraphic(Paths.image('transitionSwag/' + stickerSet + '/' + stickerName));
     updateHitbox();
-    antialiasing = true;
     scrollFactor.set();
   }
 }
diff --git a/source/funkin/ui/TallyCounter.hx b/source/funkin/ui/TallyCounter.hx
index bcc39ca7b..72857671e 100644
--- a/source/funkin/ui/TallyCounter.hx
+++ b/source/funkin/ui/TallyCounter.hx
@@ -80,7 +80,6 @@ class TallyNumber extends FlxSprite
       animation.addByPrefix(Std.string(i), i + " small", 24, false);
 
     animation.play(Std.string(digit));
-    antialiasing = true;
     updateHitbox();
   }
 }
diff --git a/source/funkin/ui/animDebugShit/DebugBoundingState.hx b/source/funkin/ui/animDebugShit/DebugBoundingState.hx
index da7a4e3ff..5a7e555de 100644
--- a/source/funkin/ui/animDebugShit/DebugBoundingState.hx
+++ b/source/funkin/ui/animDebugShit/DebugBoundingState.hx
@@ -143,7 +143,6 @@ class DebugBoundingState extends FlxState
     addInfo('Width', bf.width);
     addInfo('Height', bf.height);
 
-    swagOutlines.antialiasing = true;
     spriteSheetView.add(swagOutlines);
 
     FlxG.stage.window.onDropFile.add(function(path:String) {

From 6f737a1805987449622870ab2c5430ed78af51ab Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Sun, 25 Jun 2023 12:36:12 -0400
Subject: [PATCH 04/30] Update hxCodec to latest stable.

---
 hmm.json | 8 +++-----
 1 file changed, 3 insertions(+), 5 deletions(-)

diff --git a/hmm.json b/hmm.json
index a1d78a29f..ac16be03d 100644
--- a/hmm.json
+++ b/hmm.json
@@ -66,10 +66,8 @@
     },
     {
       "name": "hxCodec",
-      "type": "git",
-      "dir": null,
-      "ref": "c42ab99",
-      "url": "https://github.com/polybiusproxy/hxCodec"
+      "type": "haxelib",
+      "version": "3.0.1"
     },
     {
       "name": "hxcpp",
@@ -123,4 +121,4 @@
       "version": null
     }
   ]
-}
+}
\ No newline at end of file

From 4c85e590372d2c213432e4f7b135ec473c881b4c Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Sun, 25 Jun 2023 12:36:21 -0400
Subject: [PATCH 05/30] Rewrote documentation for InitState

---
 source/funkin/util/WindowUtil.hx | 12 ++++++++++--
 1 file changed, 10 insertions(+), 2 deletions(-)

diff --git a/source/funkin/util/WindowUtil.hx b/source/funkin/util/WindowUtil.hx
index 42930570f..6e6a41641 100644
--- a/source/funkin/util/WindowUtil.hx
+++ b/source/funkin/util/WindowUtil.hx
@@ -11,17 +11,21 @@ import flixel.util.FlxSignal.FlxTypedSignal;
 #end
 class WindowUtil
 {
+  /**
+   * Runs platform-specific code to open a URL in a web browser.
+   * @param targetUrl The URL to open.
+   */
   public static function openURL(targetUrl:String)
   {
     #if CAN_OPEN_LINKS
     #if linux
-    // Sys.command('/usr/bin/xdg-open', [, "&"]);
     Sys.command('/usr/bin/xdg-open', [targetUrl, "&"]);
     #else
+    // This should work on Windows and HTML5.
     FlxG.openURL(targetUrl);
     #end
     #else
-    trace('Cannot open');
+    throw 'Cannot open URLs on this platform.';
     #end
   }
 
@@ -30,6 +34,10 @@ class WindowUtil
    */
   public static final windowExit:FlxTypedSignal<Int->Void> = new FlxTypedSignal<Int->Void>();
 
+  /**
+   * Wires up FlxSignals that happen based on window activity.
+   * For example, we can run a callback when the window is closed.
+   */
   public static function initWindowEvents()
   {
     // onUpdate is called every frame just before rendering.

From 6d5c5f5acb874d04534104ebcb8a479ce027ac8c Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Mon, 26 Jun 2023 20:39:47 -0400
Subject: [PATCH 06/30] Refactor InitState plus fix a couple crash bugs

---
 source/Main.hx                           |  11 -
 source/funkin/InitState.hx               | 375 +++++++++++++----------
 source/funkin/PlayerSettings.hx          |  10 +-
 source/funkin/TitleState.hx              |  33 +-
 source/funkin/ui/story/StoryMenuState.hx |   2 +-
 5 files changed, 224 insertions(+), 207 deletions(-)

diff --git a/source/Main.hx b/source/Main.hx
index 006b54e18..9b70549ab 100644
--- a/source/Main.hx
+++ b/source/Main.hx
@@ -77,17 +77,6 @@ class Main extends Sprite
      * -Eric
      */
 
-    #if !debug
-    /**
-     * Someone was like "hey let's make a state that only runs code on debug builds"
-     * then put essential initialization code in it.
-     * The easiest fix is to make it run in all builds.
-     * -Eric
-     */
-    // TODO: Fix this properly.
-    // initialState = funkin.TitleState;
-    #end
-
     initHaxeUI();
 
     addChild(new FlxGame(gameWidth, gameHeight, initialState, framerate, framerate, skipSplash, startFullscreen));
diff --git a/source/funkin/InitState.hx b/source/funkin/InitState.hx
index 52bdb1015..4d74e1a05 100644
--- a/source/funkin/InitState.hx
+++ b/source/funkin/InitState.hx
@@ -6,52 +6,82 @@ import flixel.addons.transition.TransitionData;
 import flixel.graphics.FlxGraphic;
 import flixel.math.FlxPoint;
 import flixel.math.FlxRect;
+import flixel.FlxSprite;
 import flixel.system.debug.log.LogStyle;
 import flixel.util.FlxColor;
-import funkin.modding.module.ModuleHandler;
-import funkin.play.character.CharacterData.CharacterDataParser;
-import funkin.play.cutscene.dialogue.ConversationDataParser;
-import funkin.play.cutscene.dialogue.DialogueBoxDataParser;
-import funkin.play.cutscene.dialogue.SpeakerDataParser;
-import funkin.play.event.SongEventData.SongEventParser;
-import funkin.play.PlayState;
-import funkin.play.song.SongData.SongDataParser;
-import funkin.play.stage.StageData.StageDataParser;
 import funkin.ui.PreferencesMenu;
 import funkin.util.macro.MacroUtil;
 import funkin.util.WindowUtil;
+import funkin.play.PlayStatePlaylist;
 import openfl.display.BitmapData;
 #if discord_rpc
 import Discord.DiscordClient;
 #end
 
 /**
- * Initializes the game state using custom defines.
- * Only used in Debug builds.
+ * The initialization state has several functions:
+ * - Calls code to set up the game, including loading saves and parsing game data.
+ * - Chooses whether to start via debug or via launching normally.
  */
 class InitState extends FlxTransitionableState
 {
-  override public function create():Void
+  /**
+   * Perform a bunch of game setup, then immediately transition to the title screen.
+   */
+  public override function create():Void
+  {
+    setupShit();
+
+    loadSaveData();
+
+    startGame();
+  }
+
+  /**
+   * Setup a bunch of important Flixel stuff.
+   */
+  function setupShit()
   {
     //
-    // FLIXEL SETUP
+    // GAME SETUP
     //
+
+    // Setup window events (like callbacks for onWindowClose)
+    WindowUtil.initWindowEvents();
+    // Disable the thing on Windows where it tries to send a bug report to Microsoft because why do they care?
+    WindowUtil.disableCrashHandler();
+
     // This ain't a pixel art game! (most of the time)
     FlxSprite.defaultAntialiasing = true;
 
-    Application.current.onExit.add(function(exitCode) {
-      DiscordClient.shutdown();
-    });
-    #end
+    // Disable default keybinds for volume (we manually control volume in MusicBeatState with custom binds)
+    FlxG.sound.volumeUpKeys = [];
+    FlxG.sound.volumeDownKeys = [];
+    FlxG.sound.muteKeys = [];
 
-    // ==== flixel shit ==== //
+    // TODO: Make sure volume still saves/loads properly.
+    // if (FlxG.save.data.volume != null) FlxG.sound.volume = FlxG.save.data.volume;
+    // if (FlxG.save.data.mute != null) FlxG.sound.muted = FlxG.save.data.mute;
 
+    // Set the game to a lower frame rate while it is in the background.
+    FlxG.game.focusLostFramerate = 30;
+
+    //
+    // FLIXEL DEBUG SETUP
+    //
+    #if debug
+    // Disable using ~ to open the console (we use that for the Editor menu)
+    FlxG.debugger.toggleKeys = [F2];
+
+    // Adds an additional Close Debugger button.
     // This big obnoxious white button is for MOBILE, so that you can press it
     // easily with your finger when debug bullshit pops up during testing lol!
     FlxG.debugger.addButton(LEFT, new BitmapData(200, 200), function() {
       FlxG.debugger.visible = false;
     });
 
+    // Adds a red button to the debugger.
+    // This pauses the game AND the music! This ensures the Conductor stops.
     FlxG.debugger.addButton(CENTER, new BitmapData(20, 20, true, 0xFFCC2233), function() {
       if (FlxG.vcr.paused)
       {
@@ -77,7 +107,8 @@ class InitState extends FlxTransitionableState
       }
     });
 
-    #if FLX_DEBUG
+    // Adds a blue button to the debugger.
+    // This skips forward in the song.
     FlxG.debugger.addButton(CENTER, new BitmapData(20, 20, true, 0xFF2222CC), function() {
       FlxG.game.debugger.vcr.onStep();
 
@@ -90,175 +121,197 @@ class InitState extends FlxTransitionableState
       FlxG.sound.music.pause();
       FlxG.sound.music.time += FlxG.elapsed * 1000;
     });
+
+    // Make errors and warnings less annoying.
+    // TODO: Disable this so we know to fix warnings.
+    if (false)
+    {
+      LogStyle.ERROR.openConsole = false;
+      LogStyle.ERROR.errorSound = null;
+      LogStyle.WARNING.openConsole = false;
+      LogStyle.WARNING.errorSound = null;
+    }
     #end
 
-    FlxG.sound.muteKeys = [ZERO];
-    FlxG.game.focusLostFramerate = 60;
-
-    // FlxG.stage.window.borderless = true;
-    // FlxG.stage.window.mouseLock = true;
+    //
+    // FLIXEL TRANSITIONS
+    //
 
+    // Diamond Transition
     var diamond:FlxGraphic = FlxGraphic.fromClass(GraphicTransTileDiamond);
     diamond.persist = true;
     diamond.destroyOnNoUse = false;
 
-    FlxTransitionableState.defaultTransIn = new TransitionData(FADE, FlxColor.BLACK, 1, new FlxPoint(0, -1), {asset: diamond, width: 32, height: 32},
-      new FlxRect(-200, -200, FlxG.width * 1.4, FlxG.height * 1.4));
-    FlxTransitionableState.defaultTransOut = new TransitionData(FADE, FlxColor.BLACK, 0.7, new FlxPoint(0, 1), {asset: diamond, width: 32, height: 32},
-      new FlxRect(-200, -200, FlxG.width * 1.4, FlxG.height * 1.4));
-
-    // ===== save shit ===== //
-
-    FlxG.save.bind('funkin', 'ninjamuffin99');
-
-    // https://github.com/HaxeFlixel/flixel/pull/2396
-    // IF/WHEN MY PR GOES THRU AND IT GETS INTO MAIN FLIXEL, DELETE THIS CHUNKOF CODE, AND THEN UNCOMMENT THE LINE BELOW
-    // FlxG.sound.loadSavedPrefs();
-
-    if (FlxG.save.data.volume != null) FlxG.sound.volume = FlxG.save.data.volume;
-    if (FlxG.save.data.mute != null) FlxG.sound.muted = FlxG.save.data.mute;
-
-    // Make errors and warnings less annoying.
-    LogStyle.ERROR.openConsole = false;
-    LogStyle.ERROR.errorSound = null;
-    LogStyle.WARNING.openConsole = false;
-    LogStyle.WARNING.errorSound = null;
-
-    // FlxG.save.close();
-    // FlxG.sound.loadSavedPrefs();
-    WindowUtil.initWindowEvents();
-    WindowUtil.disableCrashHandler();
-
-    PreferencesMenu.initPrefs();
-    PlayerSettings.init();
-    Highscore.load();
-
-    if (FlxG.save.data.weekUnlocked != null)
-    {
-      // FIX LATER!!!
-      // WEEK UNLOCK PROGRESSION!!
-      // StoryMenuState.weekUnlocked = FlxG.save.data.weekUnlocked;
-
-      // if (StoryMenuState.weekUnlocked.length < 4) StoryMenuState.weekUnlocked.insert(0, true);
-
-      // QUICK PATCH OOPS!
-      // if (!StoryMenuState.weekUnlocked[0]) StoryMenuState.weekUnlocked[0] = true;
-    }
-
-    if (FlxG.save.data.seenVideo != null) VideoState.seenVideo = FlxG.save.data.seenVideo;
-
-    // ===== fuck outta here ===== //
-
-    // FlxTransitionableState.skipNextTransOut = true;
+    // FlxTransitionableState.defaultTransIn = new TransitionData(FADE, FlxColor.BLACK, 1, new FlxPoint(0, -1), {asset: diamond, width: 32, height: 32},
+    //   new FlxRect(-200, -200, FlxG.width * 1.4, FlxG.height * 1.4));
+    // FlxTransitionableState.defaultTransOut = new TransitionData(FADE, FlxColor.BLACK, 0.7, new FlxPoint(0, 1), {asset: diamond, width: 32, height: 32},
+    //   new FlxRect(-200, -200, FlxG.width * 1.4, FlxG.height * 1.4));
+    // Don't play transition in when entering the title state.
     FlxTransitionableState.skipNextTransIn = true;
 
-    // TODO: Register custom event callbacks here
+    //
+    // NEWGROUNDS API SETUP
+    //
+    #if newgrounds
+    NGio.init();
+    #end
 
+    //
+    // DISCORD API SETUP
+    //
+    #if discord_rpc
+    DiscordClient.initialize();
+
+    Application.current.onExit.add(function(exitCode) {
+      DiscordClient.shutdown();
+    });
+    #end
+
+    //
+    // ANDROID SETUP
+    //
+    #if android
+    FlxG.android.preventDefaultKeys = [flixel.input.android.FlxAndroidKey.BACK];
+    #end
+
+    //
+    // GAME DATA PARSING
+    //
     funkin.data.level.LevelRegistry.instance.loadEntries();
-    SongEventParser.loadEventCache();
-    ConversationDataParser.loadConversationCache();
-    DialogueBoxDataParser.loadDialogueBoxCache();
-    SpeakerDataParser.loadSpeakerCache();
-    SongDataParser.loadSongCache();
-    StageDataParser.loadStageCache();
-    CharacterDataParser.loadCharacterCache();
-    ModuleHandler.buildModuleCallbacks();
-    ModuleHandler.loadModuleCache();
+    funkin.play.event.SongEventData.SongEventParser.loadEventCache();
+    funkin.play.cutscene.dialogue.ConversationDataParser.loadConversationCache();
+    funkin.play.cutscene.dialogue.DialogueBoxDataParser.loadDialogueBoxCache();
+    funkin.play.cutscene.dialogue.SpeakerDataParser.loadSpeakerCache();
+    funkin.play.song.SongData.SongDataParser.loadSongCache();
+    funkin.play.stage.StageData.StageDataParser.loadStageCache();
+    funkin.play.character.CharacterData.CharacterDataParser.loadCharacterCache();
+    funkin.modding.module.ModuleHandler.buildModuleCallbacks();
+    funkin.modding.module.ModuleHandler.loadModuleCache();
 
-    FlxG.debugger.toggleKeys = [F2];
+    funkin.modding.module.ModuleHandler.callOnCreate();
+  }
 
-    ModuleHandler.callOnCreate();
+  /**
+   * Retrive and parse data from the user's save.
+   */
+  function loadSaveData()
+  {
+    // Bind save data.
+    // TODO: Migrate save data to a better format.
+    FlxG.save.bind('funkin', 'ninjamuffin99');
 
-    #if song
-    var song:String = getSong();
+    // Load player options from save data.
+    PreferencesMenu.initPrefs();
+    // Load controls from save data.
+    PlayerSettings.init();
+    // Load highscores from save data.
+    Highscore.load();
+    // TODO: Load level/character/cosmetic unlocks from save data.
+  }
 
-    var weeks:Array<Array<String>> = [
-      ['bopeebo', 'fresh', 'dadbattle'],
-      ['spookeez', 'south', 'monster'],
-      ['spooky', 'spooky', 'monster'],
-      ['pico', 'philly', 'blammed'],
-      ['satin-panties', 'high', 'milf'],
-      ['cocoa', 'eggnog', 'winter-horrorland'],
-      ['senpai', 'roses', 'thorns'],
-      ['ugh', 'guns', 'stress']
-    ];
-
-    var week:Int = 0;
-    for (i in 0...weeks.length)
-    {
-      if (weeks[i].contains(song))
-      {
-        week = i + 1;
-        break;
-      }
-    }
-
-    if (week == 0) throw 'Invalid -D song=$song';
-
-    startSong(week, song, false);
-    #elseif week
-    var week:Int = getWeek();
-
-    var songs:Array<String> = [
-      'bopeebo',
-      'spookeez',
-      'spooky',
-      'pico',
-      'satin-panties',
-      'cocoa',
-      'senpai',
-      'ugh'
-    ];
-
-    if (week <= 0 || week >= songs.length) throw 'invalid -D week=' + week;
-
-    startSong(week, songs[week - 1], true);
-    #elseif FREEPLAY
+  /**
+   * Start the game.
+   *
+   * By default, moves to the `TitleState`.
+   * But based on compile defines, the game can start immediately on a specific song,
+   * or immediately in a specific debug menu.
+   */
+  function startGame():Void
+  {
+    #if SONG // -DSONG=bopeebo
+    startSong(defineSong(), defineDifficulty());
+    #elseif LEVEL // -DLEVEL=week1 -DDIFFICULTY=hard
+    startLevel(defineLevel(), defineDifficulty());
+    #elseif FREEPLAY // -DFREEPLAY
     FlxG.switchState(new FreeplayState());
-    #elseif ANIMATE
+    #elseif ANIMATE // -DANIMATE
     FlxG.switchState(new funkin.ui.animDebugShit.FlxAnimateTest());
-    #elseif CHARTING
+    #elseif CHARTING // -DCHARTING
     FlxG.switchState(new funkin.ui.debug.charting.ChartEditorState());
-    #elseif STAGEBUILD
-    FlxG.switchState(new StageBuilderState());
-    #elseif FIGHT
-    FlxG.switchState(new PicoFight());
-    #elseif ANIMDEBUG
+    #elseif STAGEBUILD // -DSTAGEBUILD
+    FlxG.switchState(new funkin.ui.stageBullshit.StageBuilderState());
+    #elseif ANIMDEBUG // -DANIMDEBUG
     FlxG.switchState(new funkin.ui.animDebugShit.DebugBoundingState());
-    #elseif LATENCY
-    FlxG.switchState(new LatencyState());
-    #elseif NETTEST
-    FlxG.switchState(new netTest.NetTest());
+    #elseif LATENCY // -DLATENCY
+    FlxG.switchState(new funkin.LatencyState());
     #else
-    FlxG.sound.cache(Paths.music('freakyMenu'));
-    FlxG.switchState(new TitleState());
+    startGameNormally();
     #end
   }
 
-  function startSong(week, song, isStoryMode):Void
+  /**
+   * Start the game by moving to the title state and play the game as normal.
+   */
+  function startGameNormally():Void
   {
-    var dif:Int = getDif();
+    FlxG.sound.cache(Paths.music('freakyMenu'));
+    FlxG.switchState(new TitleState());
+  }
 
-    var targetDifficulty = switch (dif)
+  /**
+   * Start the game by directly loading into a specific song.
+   * @param songId
+   * @param difficultyId
+   */
+  function startSong(songId:String, difficultyId:String = 'normal'):Void
+  {
+    var songData:funkin.play.song.Song = funkin.play.song.SongData.SongDataParser.fetchSong(songId);
+
+    if (songData == null)
     {
-      case 0: 'easy';
-      case 1: 'normal';
-      case 2: 'hard';
-      default: 'normal';
-    };
-    LoadingState.loadAndSwitchState(new PlayState(
+      startGameNormally();
+      return;
+    }
+
+    LoadingState.loadAndSwitchState(new funkin.play.PlayState(
       {
-        targetSong: SongDataParser.fetchSong(song),
-        targetDifficulty: targetDifficulty,
+        targetSong: songData,
+        targetDifficulty: difficultyId,
       }));
   }
+
+  /**
+   * Start the game by directly loading into a specific story mode level.
+   * @param levelId
+   * @param difficultyId
+   */
+  function startLevel(levelId:String, difficultyId:String = 'normal'):Void
+  {
+    var currentLevel:funkin.ui.story.Level = funkin.data.level.LevelRegistry.instance.fetchEntry(levelId);
+
+    if (currentLevel == null)
+    {
+      startGameNormally();
+      return;
+    }
+
+    PlayStatePlaylist.playlistSongIds = currentLevel.getSongs();
+    PlayStatePlaylist.isStoryMode = true;
+    PlayStatePlaylist.campaignScore = 0;
+
+    var targetSongId:String = PlayStatePlaylist.playlistSongIds.shift();
+
+    var targetSong:funkin.play.song.Song = funkin.play.song.SongData.SongDataParser.fetchSong(targetSongId);
+
+    LoadingState.loadAndSwitchState(new funkin.play.PlayState(
+      {
+        targetSong: targetSong,
+        targetDifficulty: difficultyId,
+      }));
+  }
+
+  function defineSong():String
+  {
+    return MacroUtil.getDefine('SONG');
+  }
+
+  function defineLevel():String
+  {
+    return MacroUtil.getDefine('LEVEL');
+  }
+
+  function defineDifficulty():String
+  {
+    return MacroUtil.getDefine('DIFFICULTY');
+  }
 }
-
-function getWeek():Int
-  return Std.parseInt(MacroUtil.getDefine('week'));
-
-function getSong():String
-  return MacroUtil.getDefine('song');
-
-function getDif():Int
-  return Std.parseInt(MacroUtil.getDefine('dif', '1'));
diff --git a/source/funkin/PlayerSettings.hx b/source/funkin/PlayerSettings.hx
index 1b64d26c2..54fd559fb 100644
--- a/source/funkin/PlayerSettings.hx
+++ b/source/funkin/PlayerSettings.hx
@@ -26,8 +26,10 @@ class PlayerSettings
   // public var avatar:Player;
   // public var camera(get, never):PlayCamera;
 
-  function new(id)
+  function new(id:Int)
   {
+    trace('loading player settings for id: $id');
+
     this.id = id;
     this.controls = new Controls('player$id', None);
 
@@ -52,7 +54,11 @@ class PlayerSettings
       }
     }
 
-    if (useDefault) controls.setKeyboardScheme(Solo);
+    if (useDefault)
+    {
+      trace("falling back to default control scheme");
+      controls.setKeyboardScheme(Solo);
+    }
 
     // Apply loaded settings.
     PreciseInputManager.instance.initializeKeys(controls);
diff --git a/source/funkin/TitleState.hx b/source/funkin/TitleState.hx
index bd4e7084c..59845ed40 100644
--- a/source/funkin/TitleState.hx
+++ b/source/funkin/TitleState.hx
@@ -44,6 +44,7 @@ class TitleState extends MusicBeatState
 
   override public function create():Void
   {
+    super.create();
     swagShader = new ColorSwap();
 
     curWacky = FlxG.random.getObject(getIntroTextShit());
@@ -51,38 +52,6 @@ class TitleState extends MusicBeatState
 
     // DEBUG BULLSHIT
 
-    super.create();
-
-    /*
-          #elseif web
-
-
-          if (!initialized)
-          {
-
-      video = new Video();
-      FlxG.stage.addChild(video);
-
-      var netConnection = new NetConnection();
-      netConnection.connect(null);
-
-      netStream = new NetStream(netConnection);
-      netStream.client = {onMetaData: client_onMetaData};
-      netStream.addEventListener(AsyncErrorEvent.ASYNC_ERROR, netStream_onAsyncError);
-      netConnection.addEventListener(NetStatusEvent.NET_STATUS, netConnection_onNetStatus);
-      // netStream.addEventListener(NetStatusEvent.NET_STATUS) // netStream.play(Paths.file('music/kickstarterTrailer.mp4'));
-
-      overlay = new Sprite();
-      overlay.graphics.beginFill(0, 0.5);
-      overlay.graphics.drawRect(0, 0, 1280, 720);
-      overlay.addEventListener(MouseEvent.MOUSE_DOWN, overlay_onMouseDown);
-
-      overlay.buttonMode = true;
-      // FlxG.stage.addChild(overlay);
-
-          }
-     */
-
     // netConnection.addEventListener(MouseEvent.MOUSE_DOWN, overlay_onMouseDown);
     new FlxTimer().start(1, function(tmr:FlxTimer) {
       startIntro();
diff --git a/source/funkin/ui/story/StoryMenuState.hx b/source/funkin/ui/story/StoryMenuState.hx
index d7f6db00d..f62e064e1 100644
--- a/source/funkin/ui/story/StoryMenuState.hx
+++ b/source/funkin/ui/story/StoryMenuState.hx
@@ -110,7 +110,7 @@ class StoryMenuState extends MusicBeatState
     transIn = FlxTransitionableState.defaultTransIn;
     transOut = FlxTransitionableState.defaultTransOut;
 
-    if (!FlxG.sound.music.playing)
+    if (FlxG.sound.music == null || !FlxG.sound.music.playing)
     {
       FlxG.sound.playMusic(Paths.music('freakyMenu'));
       FlxG.sound.music.fadeIn(4, 0, 0.7);

From 47d8d9e4db8bb54f21e08f7a5d236917d3b321f4 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Mon, 26 Jun 2023 20:40:26 -0400
Subject: [PATCH 07/30] Work in progress on redoing hold note rendering

---
 source/funkin/play/PlayState.hx       |  17 +++--
 source/funkin/play/notes/Strumline.hx | 106 ++++++++++++++++++--------
 source/funkin/play/song/Song.hx       |   8 +-
 3 files changed, 91 insertions(+), 40 deletions(-)

diff --git a/source/funkin/play/PlayState.hx b/source/funkin/play/PlayState.hx
index bc1d4fb30..7f3c137dc 100644
--- a/source/funkin/play/PlayState.hx
+++ b/source/funkin/play/PlayState.hx
@@ -1014,8 +1014,9 @@ class PlayState extends MusicBeatState
     // super.stepHit() returns false if a module cancelled the event.
     if (!super.stepHit()) return false;
 
-    if (Math.abs(FlxG.sound.music.time - (Conductor.songPosition - Conductor.offset)) > 200
-      || Math.abs(vocals.checkSyncError(Conductor.songPosition - Conductor.offset)) > 200)
+    if (FlxG.sound.music != null
+      && (Math.abs(FlxG.sound.music.time - (Conductor.songPosition - Conductor.offset)) > 200
+        || Math.abs(vocals.checkSyncError(Conductor.songPosition - Conductor.offset)) > 200))
     {
       trace("VOCALS NEED RESYNC");
       if (vocals != null) trace(vocals.checkSyncError(Conductor.songPosition - Conductor.offset));
@@ -1473,7 +1474,6 @@ class PlayState extends MusicBeatState
     // }
 
     // Reset the notes on each strumline.
-    var noteData:Array<SongNoteData> = currentChart.notes;
     var playerNoteData:Array<SongNoteData> = [];
     var opponentNoteData:Array<SongNoteData> = [];
 
@@ -1698,6 +1698,9 @@ class PlayState extends MusicBeatState
         onNoteMiss(note);
       }
     }
+
+    // Process hold notes on the player's side.
+    // This handles scoring so we don't need it on the opponent's side.
   }
 
   /**
@@ -1737,6 +1740,8 @@ class PlayState extends MusicBeatState
     {
       var input:PreciseInputEvent = inputPressQueue.shift();
 
+      playerStrumline.pressKey(input.noteDirection);
+
       var notesInDirection:Array<NoteSprite> = notesByDirection[input.noteDirection];
 
       if (canMiss && notesInDirection.length == 0)
@@ -1777,6 +1782,8 @@ class PlayState extends MusicBeatState
 
       // Play the strumline animation.
       playerStrumline.playStatic(input.noteDirection);
+
+      playerStrumline.releaseKey(input.noteDirection);
     }
   }
 
@@ -2004,10 +2011,6 @@ class PlayState extends MusicBeatState
 
     note.active = false;
     note.visible = false;
-
-    // Kill the note.
-    // NOTE: This is what handles recycling the note graphic.
-    playerStrumline.killNote(note);
   }
 
   /**
diff --git a/source/funkin/play/notes/Strumline.hx b/source/funkin/play/notes/Strumline.hx
index 3cd503b3b..2408025ce 100644
--- a/source/funkin/play/notes/Strumline.hx
+++ b/source/funkin/play/notes/Strumline.hx
@@ -53,6 +53,8 @@ class Strumline extends FlxSpriteGroup
   var noteData:Array<SongNoteData> = [];
   var nextNoteIndex:Int = -1;
 
+  var heldKeys:Array<Bool> = [];
+
   public function new(isPlayer:Bool)
   {
     super();
@@ -60,19 +62,23 @@ class Strumline extends FlxSpriteGroup
     this.isPlayer = isPlayer;
 
     this.strumlineNotes = new FlxTypedSpriteGroup<StrumlineNote>();
+    this.strumlineNotes.zIndex = 10;
     this.add(this.strumlineNotes);
 
     // Hold notes are added first so they render behind regular notes.
     this.holdNotes = new FlxTypedSpriteGroup<SustainTrail>();
+    this.holdNotes.zIndex = 20;
     this.add(this.holdNotes);
 
     this.notes = new FlxTypedSpriteGroup<NoteSprite>();
+    this.notes.zIndex = 30;
     this.add(this.notes);
 
     this.noteSplashes = new FlxTypedSpriteGroup<NoteSplash>(0, 0, NOTE_SPLASH_CAP);
+    this.noteSplashes.zIndex = 40;
     this.add(this.noteSplashes);
 
-    for (i in 0...DIRECTIONS.length)
+    for (i in 0...KEY_COUNT)
     {
       var child:StrumlineNote = new StrumlineNote(isPlayer, DIRECTIONS[i]);
       child.x = getXPos(DIRECTIONS[i]);
@@ -81,13 +87,18 @@ class Strumline extends FlxSpriteGroup
       this.strumlineNotes.add(child);
     }
 
+    for (i in 0...KEY_COUNT)
+    {
+      heldKeys.push(false);
+    }
+
     // This MUST be true for children to update!
     this.active = true;
   }
 
   override function get_width():Float
   {
-    return 4 * Strumline.NOTE_SPACING;
+    return KEY_COUNT * Strumline.NOTE_SPACING;
   }
 
   public override function update(elapsed:Float):Void
@@ -183,11 +194,11 @@ class Strumline extends FlxSpriteGroup
       if (note == null) continue;
       if (note.time > renderWindowStart) break;
 
-      buildNoteSprite(note);
+      var noteSprite = buildNoteSprite(note);
 
       if (note.length > 0)
       {
-        buildHoldNoteSprite(note);
+        noteSprite.holdNoteSprite = buildHoldNoteSprite(note);
       }
 
       nextNoteIndex++; // Increment the nextNoteIndex rather than splicing the array, because splicing is slow.
@@ -198,7 +209,8 @@ class Strumline extends FlxSpriteGroup
     {
       if (note == null || note.hasBeenHit) continue;
 
-      note.y = this.y - INITIAL_OFFSET + calculateNoteYPos(note.strumTime);
+      var vwoosh:Bool = note.holdNoteSprite == null;
+      note.y = this.y - INITIAL_OFFSET + calculateNoteYPos(note.strumTime, vwoosh);
 
       // Check if the note is outside the hit window, and if so, mark it as missed.
       // TODO: Check to make sure this doesn't happen when the note is on screen because it'll probably get deleted.
@@ -221,6 +233,17 @@ class Strumline extends FlxSpriteGroup
     {
       if (holdNote == null || !holdNote.alive) continue;
 
+      if (Conductor.songPosition > holdNote.strumTime && holdNote.hitNote && !holdNote.missedNote)
+      {
+        if (isPlayer && !isKeyHeld(holdNote.noteDirection))
+        {
+          // Stopped pressing the hold note.
+          playStatic(holdNote.noteDirection);
+          holdNote.missedNote = true;
+          holdNote.alpha = 0.6;
+        }
+      }
+
       var renderWindowEnd = holdNote.strumTime + holdNote.fullSustainLength + Conductor.HIT_WINDOW_MS + RENDER_DISTANCE_MS / 8;
 
       if (holdNote.missedNote && Conductor.songPosition >= renderWindowEnd)
@@ -241,6 +264,28 @@ class Strumline extends FlxSpriteGroup
         // TODO: Better handle the weird edge case where the hold note is almost completed.
         holdNote.visible = false;
       }
+      else if (holdNote.missedNote && (holdNote.fullSustainLength > holdNote.sustainLength))
+      {
+        // Hold note was dropped before completing, keep it in its clipped state.
+        holdNote.visible = true;
+
+        var yOffset:Float = (holdNote.fullSustainLength - holdNote.sustainLength) * Conductor.PIXELS_PER_MS;
+
+        trace('yOffset: ' + yOffset);
+        trace('holdNote.fullSustainLength: ' + holdNote.fullSustainLength);
+        trace('holdNote.sustainLength: ' + holdNote.sustainLength);
+
+        var vwoosh:Bool = false;
+
+        if (PreferencesMenu.getPref('downscroll'))
+        {
+          holdNote.y = this.y + calculateNoteYPos(holdNote.strumTime, vwoosh) - holdNote.height + STRUMLINE_SIZE / 2;
+        }
+        else
+        {
+          holdNote.y = this.y - INITIAL_OFFSET + calculateNoteYPos(holdNote.strumTime, vwoosh) + yOffset + STRUMLINE_SIZE / 2;
+        }
+      }
       else if (Conductor.songPosition > holdNote.strumTime && holdNote.hitNote)
       {
         // Hold note is currently being hit, clip it off.
@@ -258,38 +303,19 @@ class Strumline extends FlxSpriteGroup
           holdNote.y = this.y - INITIAL_OFFSET + STRUMLINE_SIZE / 2;
         }
       }
-      else if (holdNote.missedNote && (holdNote.fullSustainLength > holdNote.sustainLength))
-      {
-        // Hold note was dropped before completing, keep it in its clipped state.
-        holdNote.visible = true;
-
-        var yOffset:Float = (holdNote.fullSustainLength - holdNote.sustainLength) * Conductor.PIXELS_PER_MS;
-
-        trace('yOffset: ' + yOffset);
-        trace('holdNote.fullSustainLength: ' + holdNote.fullSustainLength);
-        trace('holdNote.sustainLength: ' + holdNote.sustainLength);
-
-        if (PreferencesMenu.getPref('downscroll'))
-        {
-          holdNote.y = this.y + calculateNoteYPos(holdNote.strumTime) - holdNote.height + STRUMLINE_SIZE / 2;
-        }
-        else
-        {
-          holdNote.y = this.y - INITIAL_OFFSET + calculateNoteYPos(holdNote.strumTime) + yOffset + STRUMLINE_SIZE / 2;
-        }
-      }
       else
       {
         // Hold note is new, render it normally.
         holdNote.visible = true;
+        var vwoosh:Bool = false;
 
         if (PreferencesMenu.getPref('downscroll'))
         {
-          holdNote.y = this.y + calculateNoteYPos(holdNote.strumTime, false) - holdNote.height + STRUMLINE_SIZE / 2;
+          holdNote.y = this.y + calculateNoteYPos(holdNote.strumTime, vwoosh) - holdNote.height + STRUMLINE_SIZE / 2;
         }
         else
         {
-          holdNote.y = this.y - INITIAL_OFFSET + calculateNoteYPos(holdNote.strumTime, false) + STRUMLINE_SIZE / 2;
+          holdNote.y = this.y - INITIAL_OFFSET + calculateNoteYPos(holdNote.strumTime, vwoosh) + STRUMLINE_SIZE / 2;
         }
       }
     }
@@ -302,6 +328,21 @@ class Strumline extends FlxSpriteGroup
     if (holdNotes.members.length > 1) holdNotes.members.insertionSort(compareHoldNoteSprites.bind(FlxSort.ASCENDING));
   }
 
+  public function pressKey(dir:NoteDirection):Void
+  {
+    heldKeys[dir] = true;
+  }
+
+  public function releaseKey(dir:NoteDirection):Void
+  {
+    heldKeys[dir] = false;
+  }
+
+  public function isKeyHeld(dir:NoteDirection):Bool
+  {
+    return heldKeys[dir];
+  }
+
   public function applyNoteData(data:Array<SongNoteData>):Void
   {
     this.notes.clear();
@@ -395,7 +436,7 @@ class Strumline extends FlxSpriteGroup
     }
   }
 
-  public function buildNoteSprite(note:SongNoteData):Void
+  public function buildNoteSprite(note:SongNoteData):NoteSprite
   {
     var noteSprite:NoteSprite = constructNoteSprite();
 
@@ -411,9 +452,11 @@ class Strumline extends FlxSpriteGroup
       // noteSprite.x += INITIAL_OFFSET;
       noteSprite.y = -9999;
     }
+
+    return noteSprite;
   }
 
-  public function buildHoldNoteSprite(note:SongNoteData):Void
+  public function buildHoldNoteSprite(note:SongNoteData):SustainTrail
   {
     var holdNoteSprite:SustainTrail = constructHoldNoteSprite();
 
@@ -427,13 +470,16 @@ class Strumline extends FlxSpriteGroup
       holdNoteSprite.missedNote = false;
       holdNoteSprite.hitNote = false;
 
+      holdNoteSprite.alpha = 1.0;
+
       holdNoteSprite.x = this.x;
       holdNoteSprite.x += getXPos(DIRECTIONS[note.getDirection() % KEY_COUNT]);
-      // holdNoteSprite.x += INITIAL_OFFSET;
       holdNoteSprite.x += STRUMLINE_SIZE / 2;
       holdNoteSprite.x -= holdNoteSprite.width / 2;
       holdNoteSprite.y = -9999;
     }
+
+    return holdNoteSprite;
   }
 
   /**
diff --git a/source/funkin/play/song/Song.hx b/source/funkin/play/song/Song.hx
index b42c8e7c4..4cbf1ade3 100644
--- a/source/funkin/play/song/Song.hx
+++ b/source/funkin/play/song/Song.hx
@@ -283,8 +283,9 @@ class SongDifficulty
     return timeChanges[0].bpm;
   }
 
-  public function getPlayableChar(id:String):SongPlayableChar
+  public function getPlayableChar(id:String):Null<SongPlayableChar>
   {
+    if (id == null || id == '') return null;
     return chars.get(id);
   }
 
@@ -300,9 +301,10 @@ class SongDifficulty
 
   public inline function cacheInst(?currentPlayerId:String = null):Void
   {
-    if (currentPlayerId != null)
+    var currentPlayer:Null<SongPlayableChar> = getPlayableChar(currentPlayerId);
+    if (currentPlayer != null)
     {
-      FlxG.sound.cache(Paths.inst(this.song.songId, getPlayableChar(currentPlayerId).inst));
+      FlxG.sound.cache(Paths.inst(this.song.songId, currentPlayer.inst));
     }
     else
     {

From a672db7b0e316c6f9c4bc10e3ba6c839c5ee8858 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Tue, 27 Jun 2023 13:43:42 -0400
Subject: [PATCH 08/30] Messing around with logic for hold note misses/health
 gain

---
 source/funkin/play/PlayState.hx        | 145 ++++++++++++-------------
 source/funkin/play/notes/NoteSprite.hx |  14 +--
 source/funkin/play/notes/Strumline.hx  |  26 ++---
 source/funkin/util/Constants.hx        |  74 +++++++++++++
 4 files changed, 166 insertions(+), 93 deletions(-)

diff --git a/source/funkin/play/PlayState.hx b/source/funkin/play/PlayState.hx
index 7f3c137dc..c76cf24d4 100644
--- a/source/funkin/play/PlayState.hx
+++ b/source/funkin/play/PlayState.hx
@@ -136,10 +136,8 @@ class PlayState extends MusicBeatState
 
   /**
    * The player's current health.
-   * The default maximum health is 2.0, and the default starting health is 1.0.
-   * TODO: Refactor this to [0.0, 1.0]
    */
-  public var health:Float = 1;
+  public var health:Float = Constants.HEALTH_STARTING;
 
   /**
    * The player's current score.
@@ -255,7 +253,7 @@ class PlayState extends MusicBeatState
    * The displayed value of the player's health.
    * Used to provide smooth animations based on linear interpolation of the player's health.
    */
-  var healthLerp:Float = 1;
+  var healthLerp:Float = Constants.HEALTH_STARTING;
 
   /**
    * How long the user has held the "Skip Video Cutscene" button for.
@@ -643,7 +641,7 @@ class PlayState extends MusicBeatState
       hudCameraZoomIntensity = Constants.DEFAULT_ZOOM_INTENSITY * 2.0;
       cameraZoomRate = Constants.DEFAULT_ZOOM_RATE;
 
-      health = 1;
+      health = Constants.HEALTH_STARTING;
       songScore = 0;
       Highscore.tallies.combo = 0;
       Countdown.performCountdown(currentStageId.startsWith('school'));
@@ -735,8 +733,8 @@ class PlayState extends MusicBeatState
     }
 
     // Cap health.
-    if (health > 2.0) health = 2.0;
-    if (health < 0.0) health = 0.0;
+    if (health > Constants.HEALTH_MAX) health = Constants.HEALTH_MAX;
+    if (health < Constants.HEALTH_MIN) health = Constants.HEALTH_MIN;
 
     // Lerp the camera zoom towards the target level.
     if (subState == null)
@@ -761,19 +759,19 @@ class PlayState extends MusicBeatState
       // RESET = Quick Game Over Screen
       if (controls.RESET)
       {
-        health = 0;
+        health = Constants.HEALTH_MIN;
         trace('RESET = True');
       }
 
       #if CAN_CHEAT // brandon's a pussy
       if (controls.CHEAT)
       {
-        health += 1;
+        health += 0.25 * Constants.HEALTH_MAX; // +25% health.
         trace('User is cheating!');
       }
       #end
 
-      if (health <= 0 && !isPracticeMode)
+      if (health <= Constants.HEALTH_MIN && !isPracticeMode)
       {
         vocals.pause();
         FlxG.sound.music.pause();
@@ -934,7 +932,7 @@ class PlayState extends MusicBeatState
    */
   public override function onFocus():Void
   {
-    if (health > 0 && !paused && FlxG.autoPause)
+    if (health > Constants.HEALTH_MIN && !paused && FlxG.autoPause)
     {
       if (Conductor.songPosition > 0.0) DiscordClient.changePresence(detailsText, currentSong.song
         + ' ('
@@ -954,7 +952,8 @@ class PlayState extends MusicBeatState
    */
   public override function onFocusLost():Void
   {
-    if (health > 0 && !paused && FlxG.autoPause) DiscordClient.changePresence(detailsPausedText, currentSong.song + ' (' + storyDifficultyText + ')', iconRPC);
+    if (health > Constants.HEALTH_MIN && !paused && FlxG.autoPause) DiscordClient.changePresence(detailsPausedText,
+      currentSong.song + ' (' + storyDifficultyText + ')', iconRPC);
 
     super.onFocusLost();
   }
@@ -1645,7 +1644,8 @@ class PlayState extends MusicBeatState
       {
         note.tooEarly = false;
         note.mayHit = false;
-        note.tooLate = true;
+        note.hasMissed = true;
+        if (note.holdNoteSprite != null) note.holdNoteSprite.missedNote = true;
       }
       else if (Conductor.songPosition > hitWindowCenter)
       {
@@ -1660,20 +1660,20 @@ class PlayState extends MusicBeatState
         // Command the opponent to hit the note on time.
         // NOTE: This is what handles the strumline and cleaning up the note itself!
         opponentStrumline.hitNote(note);
-
-        // scoreNote();
       }
       else if (Conductor.songPosition > hitWindowStart)
       {
         note.tooEarly = false;
         note.mayHit = true;
-        note.tooLate = false;
+        note.hasMissed = false;
+        if (note.holdNoteSprite != null) note.holdNoteSprite.missedNote = false;
       }
       else
       {
         note.tooEarly = true;
         note.mayHit = false;
-        note.tooLate = false;
+        note.hasMissed = false;
+        if (note.holdNoteSprite != null) note.holdNoteSprite.missedNote = false;
       }
     }
 
@@ -1682,8 +1682,35 @@ class PlayState extends MusicBeatState
     {
       if (note == null || note.hasBeenHit) continue;
 
-      // If this is true, the note is already properly off the screen.
-      if (note.hasMissed)
+      var hitWindowStart = note.strumTime - Conductor.HIT_WINDOW_MS;
+      var hitWindowCenter = note.strumTime;
+      var hitWindowEnd = note.strumTime + Conductor.HIT_WINDOW_MS;
+
+      if (Conductor.songPosition > hitWindowEnd)
+      {
+        note.tooEarly = false;
+        note.mayHit = false;
+        note.hasMissed = true;
+        if (note.holdNoteSprite != null) note.holdNoteSprite.missedNote = true;
+      }
+      else if (Conductor.songPosition > hitWindowStart)
+      {
+        note.tooEarly = false;
+        note.mayHit = true;
+        note.hasMissed = false;
+        if (note.holdNoteSprite != null) note.holdNoteSprite.missedNote = false;
+      }
+      else
+      {
+        note.tooEarly = true;
+        note.mayHit = false;
+        note.hasMissed = false;
+        if (note.holdNoteSprite != null) note.holdNoteSprite.missedNote = false;
+      }
+
+      // This becomes true when the note leaves the hit window.
+      // It might still be on screen.
+      if (note.hasMissed && !note.handledMiss)
       {
         // Call an event to allow canceling the note miss.
         // NOTE: This is what handles the character animations!
@@ -1696,6 +1723,8 @@ class PlayState extends MusicBeatState
         // Judge the miss.
         // NOTE: This is what handles the scoring.
         onNoteMiss(note);
+
+        note.handledMiss = true;
       }
     }
 
@@ -1725,11 +1754,9 @@ class PlayState extends MusicBeatState
     }
 
     // Generate a list of notes within range.
-    var notesInRange:Array<NoteSprite> = playerStrumline.getNotesInRange(Conductor.songPosition, Conductor.HIT_WINDOW_MS);
+    var notesInRange:Array<NoteSprite> = playerStrumline.getNotesMayHit();
 
     // If there are notes in range, pressing a key will cause a ghost miss.
-    // var canMiss:Bool = notesInRange.length > 0;
-    var canMiss:Bool = true; // Forced to true for consistency with other input systems.
 
     var notesByDirection:Array<Array<NoteSprite>> = [[], [], [], []];
 
@@ -1744,10 +1771,19 @@ class PlayState extends MusicBeatState
 
       var notesInDirection:Array<NoteSprite> = notesByDirection[input.noteDirection];
 
-      if (canMiss && notesInDirection.length == 0)
+      if (!Constants.GHOST_TAPPING && notesInDirection.length == 0)
       {
-        // Pressed a wrong key with notes in range.
-        // Perform a ghost miss.
+        // Pressed a wrong key with no notes nearby.
+        // Perform a ghost miss (anti-spam).
+        ghostNoteMiss(input.noteDirection, notesInRange.length > 0);
+
+        // Play the strumline animation.
+        playerStrumline.playPress(input.noteDirection);
+      }
+      else if (Constants.GHOST_TAPPING && notesInRange.length > 0 && notesInDirection.length == 0)
+      {
+        // Pressed a wrong key with no notes nearby AND with notes in a different direction available.
+        // Perform a ghost miss (anti-spam).
         ghostNoteMiss(input.noteDirection, notesInRange.length > 0);
 
         // Play the strumline animation.
@@ -1837,37 +1873,6 @@ class PlayState extends MusicBeatState
       var directionList:Array<Int> = []; // directions that can be hit
       var dumbNotes:Array<NoteSprite> = []; // notes to kill later
 
-      /*
-        activeNotes.forEachAlive(function(daNote:Note) {
-          if (daNote.canBeHit && daNote.mustPress && !daNote.tooLate && !daNote.hasBeenHit)
-          {
-            if (directionList.contains(daNote.data.noteData))
-            {
-              for (coolNote in possibleNotes)
-              {
-                if (coolNote.data.noteData == daNote.data.noteData && Math.abs(daNote.data.strumTime - coolNote.data.strumTime) < 10)
-                { // if it's the same note twice at < 10ms distance, just delete it
-                  // EXCEPT u cant delete it in this loop cuz it fucks with the collection lol
-                  dumbNotes.push(daNote);
-                  break;
-                }
-                else if (coolNote.data.noteData == daNote.data.noteData && daNote.data.strumTime < coolNote.data.strumTime)
-                { // if daNote is earlier than existing note (coolNote), replace
-                  possibleNotes.remove(coolNote);
-                  possibleNotes.push(daNote);
-                  break;
-                }
-              }
-            }
-            else
-            {
-              possibleNotes.push(daNote);
-              directionList.push(daNote.data.noteData);
-            }
-          }
-        });
-       */
-
       for (note in dumbNotes)
       {
         FlxG.log.add('killing dumb ass note at ' + note.noteData.time);
@@ -1955,7 +1960,7 @@ class PlayState extends MusicBeatState
     // Calling event.cancelEvent() skips all the other logic! Neat!
     if (event.eventCanceled) return;
 
-    health -= 0.0775;
+    health -= Constants.HEALTH_MISS_PENALTY;
 
     if (!isPracticeMode)
     {
@@ -2008,9 +2013,6 @@ class PlayState extends MusicBeatState
       vocals.playerVolume = 0;
       FlxG.sound.play(Paths.soundRandom('missnote', 1, 3), FlxG.random.float(0.1, 0.2));
     }
-
-    note.active = false;
-    note.visible = false;
   }
 
   /**
@@ -2025,7 +2027,7 @@ class PlayState extends MusicBeatState
   {
     var event:GhostMissNoteScriptEvent = new GhostMissNoteScriptEvent(direction, // Direction missed in.
       hasPossibleNotes, // Whether there was a note you could have hit.
-      - 0.035 * 2, // How much health to add (negative).
+      - 1 * Constants.HEALTH_MISS_PENALTY, // How much health to add (negative).
       - 10 // Amount of score to add (negative).
     );
     dispatchEvent(event);
@@ -2098,10 +2100,10 @@ class PlayState extends MusicBeatState
     if (FlxG.keys.justPressed.ONE) endSong();
 
     // 2: Gain 10% health.
-    if (FlxG.keys.justPressed.TWO) health += 0.1 * 2.0;
+    if (FlxG.keys.justPressed.TWO) health += 0.1 * Constants.HEALTH_MAX;
 
     // 3: Lose 5% health.
-    if (FlxG.keys.justPressed.THREE) health -= 0.05 * 2.0;
+    if (FlxG.keys.justPressed.THREE) health -= 0.05 * Constants.HEALTH_MAX;
     #end
 
     // 7: Move to the charter.
@@ -2146,36 +2148,33 @@ class PlayState extends MusicBeatState
     var score = Scoring.scoreNote(noteDiff, PBOT1);
     var daRating = Scoring.judgeNote(noteDiff, PBOT1);
 
-    var isSick:Bool = false;
-    var healthMulti:Float = 0;
-
     switch (daRating)
     {
       case 'killer':
         Highscore.tallies.killer += 1;
-        healthMulti = 0.033;
+        health += Constants.HEALTH_KILLER_BONUS;
       case 'sick':
         Highscore.tallies.sick += 1;
-        healthMulti = 0.033;
+        health += Constants.HEALTH_SICK_BONUS;
       case 'good':
         Highscore.tallies.good += 1;
-        healthMulti = 0.033 * 0.78;
+        health += Constants.HEALTH_GOOD_BONUS;
       case 'bad':
         Highscore.tallies.bad += 1;
-        healthMulti = 0.033 * 0.2;
+        health += Constants.HEALTH_BAD_BONUS;
       case 'shit':
         Highscore.tallies.shit += 1;
-        healthMulti = 0;
+        health += Constants.HEALTH_SHIT_BONUS;
       case 'miss':
         Highscore.tallies.missed += 1;
-        healthMulti = 0;
+        health -= Constants.HEALTH_MISS_PENALTY;
     }
 
-    health += healthMulti;
     if (daRating == "sick" || daRating == "killer")
     {
       playerStrumline.playNoteSplash(daNote.noteData.getDirection());
     }
+
     // Only add the score if you're not on practice mode
     if (!isPracticeMode)
     {
diff --git a/source/funkin/play/notes/NoteSprite.hx b/source/funkin/play/notes/NoteSprite.hx
index 697a29d80..ed6417f90 100644
--- a/source/funkin/play/notes/NoteSprite.hx
+++ b/source/funkin/play/notes/NoteSprite.hx
@@ -87,8 +87,10 @@ class NoteSprite extends FlxSprite
   public var lowPriority:Bool = false;
 
   /**
-   * This is true if the note has been fully missed by the player.
-   * It will be destroyed immediately.
+   * This is true if the note is later than 10 frames within the strumline,
+   * and thus can't be hit by the player.
+   * It will be destroyed after it moves offscreen.
+   * Managed by PlayState.
    */
   public var hasMissed:Bool;
 
@@ -107,11 +109,10 @@ class NoteSprite extends FlxSprite
   public var mayHit:Bool;
 
   /**
-   * This is true if the note is earlier than 10 frames after the strumline,
-   * and thus can't be hit by the player.
-   * Managed by PlayState.
+   * This is true if the PlayState has performed the logic for missing this note.
+   * Subtracting score, subtracting health, etc.
    */
-  public var tooLate:Bool;
+  public var handledMiss:Bool;
 
   public function new(strumTime:Float = 0, direction:Int = 0)
   {
@@ -171,7 +172,6 @@ class NoteSprite extends FlxSprite
     this.tooEarly = false;
     this.hasBeenHit = false;
     this.mayHit = false;
-    this.tooLate = false;
     this.hasMissed = false;
   }
 
diff --git a/source/funkin/play/notes/Strumline.hx b/source/funkin/play/notes/Strumline.hx
index 2408025ce..f01031345 100644
--- a/source/funkin/play/notes/Strumline.hx
+++ b/source/funkin/play/notes/Strumline.hx
@@ -123,6 +123,13 @@ class Strumline extends FlxSpriteGroup
     });
   }
 
+  public function getNotesMayHit():Array<NoteSprite>
+  {
+    return notes.members.filter(function(note:NoteSprite) {
+      return note != null && note.alive && !note.hasBeenHit && note.mayHit;
+    });
+  }
+
   public function getHoldNotesInRange(strumTime:Float, hitWindow:Float):Array<SustainTrail>
   {
     var hitWindowStart:Float = strumTime - hitWindow;
@@ -207,24 +214,17 @@ class Strumline extends FlxSpriteGroup
     // Update rendering of notes.
     for (note in notes.members)
     {
-      if (note == null || note.hasBeenHit) continue;
+      if (note == null || !note.alive || note.hasBeenHit) continue;
 
       var vwoosh:Bool = note.holdNoteSprite == null;
+      // Set the note's position.
       note.y = this.y - INITIAL_OFFSET + calculateNoteYPos(note.strumTime, vwoosh);
 
-      // Check if the note is outside the hit window, and if so, mark it as missed.
-      // TODO: Check to make sure this doesn't happen when the note is on screen because it'll probably get deleted.
-      if (Conductor.songPosition > (note.noteData.time + Conductor.HIT_WINDOW_MS))
+      // If the note is miss
+      var isOffscreen = PreferencesMenu.getPref('downscroll') ? note.y > FlxG.height : note.y < -note.height;
+      if (note.handledMiss && isOffscreen)
       {
-        note.visible = false;
-        note.hasMissed = true;
-        if (note.holdNoteSprite != null) note.holdNoteSprite.missedNote = true;
-      }
-      else
-      {
-        note.visible = true;
-        note.hasMissed = false;
-        if (note.holdNoteSprite != null) note.holdNoteSprite.missedNote = false;
+        killNote(note);
       }
     }
 
diff --git a/source/funkin/util/Constants.hx b/source/funkin/util/Constants.hx
index bcf0f7359..b014047bc 100644
--- a/source/funkin/util/Constants.hx
+++ b/source/funkin/util/Constants.hx
@@ -122,6 +122,80 @@ class Constants
    */
   public static final DEFAULT_VARIATION:String = 'default';
 
+  /**
+   * HEALTH VALUES
+   */
+  // ==============================
+
+  /**
+   * The player's maximum health.
+   * If the player is at this value, they can't gain any more health.
+   */
+  public static final HEALTH_MAX:Float = 2.0;
+
+  /**
+   * The player's starting health.
+   */
+  public static final HEALTH_STARTING = HEALTH_MAX / 2.0;
+
+  /**
+   * The player's minimum health.
+   * If the player is at or below this value, they lose.
+   */
+  public static final HEALTH_MIN:Float = 0.0;
+
+  /**
+   * The amount of health the player gains when hitting a note with the KILLER rating.
+   */
+  public static final HEALTH_KILLER_BONUS:Float = 2.0 / 100.0 / HEALTH_MAX; // +2.0%
+
+  /**
+   * The amount of health the player gains when hitting a note with the SICK rating.
+   */
+  public static final HEALTH_SICK_BONUS:Float = 1.5 / 100.0 / HEALTH_MAX; // +1.0%
+
+  /**
+   * The amount of health the player gains when hitting a note with the GOOD rating.
+   */
+  public static final HEALTH_GOOD_BONUS:Float = 0.75 / 100.0 / HEALTH_MAX; // +0.75%
+
+  /**
+   * The amount of health the player gains when hitting a note with the BAD rating.
+   */
+  public static final HEALTH_BAD_BONUS:Float = 0.0 / 100.0 / HEALTH_MAX; // +0.0%
+
+  /**
+   * The amount of health the player gains when hitting a note with the SHIT rating.
+   * If negative, the player will actually lose health.
+   */
+  public static final HEALTH_SHIT_BONUS:Float = -1.0 / 100.0 / HEALTH_MAX; // -1.0%
+
+  /**
+   * The amount of health the player loses upon missing a note.
+   */
+  public static final HEALTH_MISS_PENALTY:Float = 4.0 / 100.0 / HEALTH_MAX; // 4.0%
+
+  /**
+   * The amount of health the player loses upon pressing a key when no note is there.
+   */
+  public static final HEALTH_GHOST_MISS_PENALTY:Float = 2.0 / 100.0 / HEALTH_MAX; // 2.0%
+
+  /**
+   * The amount of health the player loses upon letting go of a hold note while it is still going.
+   */
+  public static final HEALTH_HOLD_DROP_PENALTY:Float = 0.0; // 0.0%
+
+  /**
+   * The amount of health the player loses upon hitting a mine.
+   */
+  public static final HEALTH_MINE_PENALTY:Float = 15.0 / 100.0 / HEALTH_MAX; // 15.0%
+
+  /**
+   * If true, the player will not receive the ghost miss penalty if there are no notes within the hit window.
+   * This is the thing people have been begging for forever lolol.
+   */
+  public static final GHOST_TAPPING:Bool = true;
+
   /**
    * OTHER
    */

From 2f7708c10623bb29fcf8b0e7e770ce3c508697d1 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Tue, 27 Jun 2023 17:22:51 -0400
Subject: [PATCH 09/30] Hold notes pretty much done! yay!

---
 source/funkin/Alphabet.hx                     |   2 -
 source/funkin/NoteSplash.hx                   |  21 +-
 .../funkin/graphics/rendering/SustainTrail.hx | 233 ------------------
 source/funkin/play/PlayState.hx               |  17 +-
 source/funkin/play/notes/Strumline.hx         |  10 +-
 source/funkin/play/notes/StrumlineNote.hx     |   1 +
 source/funkin/util/Constants.hx               |  21 +-
 7 files changed, 47 insertions(+), 258 deletions(-)
 delete mode 100644 source/funkin/graphics/rendering/SustainTrail.hx

diff --git a/source/funkin/Alphabet.hx b/source/funkin/Alphabet.hx
index 3835ae660..670496727 100644
--- a/source/funkin/Alphabet.hx
+++ b/source/funkin/Alphabet.hx
@@ -264,8 +264,6 @@ class AlphaCharacter extends FlxSprite
     animation.play(letter);
     updateHitbox();
 
-    FlxG.log.add('the row' + row);
-
     y = (110 - height);
     y += row * 60;
   }
diff --git a/source/funkin/NoteSplash.hx b/source/funkin/NoteSplash.hx
index a32a39c08..318ef8f9f 100644
--- a/source/funkin/NoteSplash.hx
+++ b/source/funkin/NoteSplash.hx
@@ -2,6 +2,7 @@ package funkin;
 
 import flixel.FlxSprite;
 import haxe.io.Path;
+import flixel.graphics.frames.FlxAtlasFrames;
 
 class NoteSplash extends FlxSprite
 {
@@ -9,15 +10,13 @@ class NoteSplash extends FlxSprite
   {
     super(x, y);
 
-    frames = Paths.getSparrowAtlas('noteSplashes');
-
+    animation.addByPrefix('note0-0', 'note impact 1 purple', 24, false);
     animation.addByPrefix('note1-0', 'note impact 1  blue', 24, false);
     animation.addByPrefix('note2-0', 'note impact 1 green', 24, false);
-    animation.addByPrefix('note0-0', 'note impact 1 purple', 24, false);
     animation.addByPrefix('note3-0', 'note impact 1 red', 24, false);
+    animation.addByPrefix('note0-1', 'note impact 2 purple', 24, false);
     animation.addByPrefix('note1-1', 'note impact 2 blue', 24, false);
     animation.addByPrefix('note2-1', 'note impact 2 green', 24, false);
-    animation.addByPrefix('note0-1', 'note impact 2 purple', 24, false);
     animation.addByPrefix('note3-1', 'note impact 2 red', 24, false);
 
     setupNoteSplash(x, y, noteData);
@@ -25,6 +24,20 @@ class NoteSplash extends FlxSprite
     // alpha = 0.75;
   }
 
+  public static function buildSplashFrames(force:Bool = false):FlxAtlasFrames
+  {
+    // static variables inside functions are a cool of Haxe 4.3.0.
+    static var splashFrames:FlxAtlasFrames = null;
+
+    if (splashFrames != null && !force) return splashFrames;
+
+    splashFrames = Paths.getSparrowAtlas('noteSplashes');
+
+    splashFrames.parent.persist = true;
+
+    return splashFrames;
+  }
+
   public function setupNoteSplash(x:Float, y:Float, noteData:Int = 0)
   {
     setPosition(x, y);
diff --git a/source/funkin/graphics/rendering/SustainTrail.hx b/source/funkin/graphics/rendering/SustainTrail.hx
deleted file mode 100644
index d9f43584e..000000000
--- a/source/funkin/graphics/rendering/SustainTrail.hx
+++ /dev/null
@@ -1,233 +0,0 @@
-package funkin.graphics.rendering;
-
-import flixel.FlxSprite;
-import flixel.graphics.FlxGraphic;
-import flixel.graphics.tile.FlxDrawTrianglesItem;
-import flixel.math.FlxMath;
-
-/**
- * This is based heavily on the `FlxStrip` class. It uses `drawTriangles()` to clip a sustain note
- * trail at a certain time.
- * The whole `FlxGraphic` is used as a texture map. See the `NOTE_hold_assets.fla` file for specifics
- * on how it should be constructed.
- *
- * @author MtH
- */
-class SustainTrail extends FlxSprite
-{
-  /**
-   * Used to determine which note color/direction to draw for the sustain.
-   */
-  public var noteData:Int = 0;
-
-  /**
-   * The zoom level to render the sustain at.
-   * Defaults to 1.0, increased to 6.0 for pixel notes.
-   */
-  public var zoom(default, set):Float = 1;
-
-  /**
-   * The strumtime of the note, in milliseconds.
-   */
-  public var strumTime:Float = 0; // millis
-
-  /**
-   * The sustain length of the note, in milliseconds.
-   */
-  public var sustainLength(default, set):Float = 0; // millis
-
-  /**
-   * The scroll speed of the note, as a multiplier.
-   */
-  public var scrollSpeed(default, set):Float = 1.0; // stand-in for PlayState scroll speed
-
-  /**
-   * Whether the note was missed.
-   */
-  public var missed:Bool = false; // maybe BlendMode.MULTIPLY if missed somehow, drawTriangles does not support!
-
-  /**
-   * A `Vector` of floats where each pair of numbers is treated as a coordinate location (an x, y pair).
-   */
-  var vertices:DrawData<Float> = new DrawData<Float>();
-
-  /**
-   * A `Vector` of integers or indexes, where every three indexes define a triangle.
-   */
-  var indices:DrawData<Int> = new DrawData<Int>();
-
-  /**
-   * A `Vector` of normalized coordinates used to apply texture mapping.
-   */
-  var uvtData:DrawData<Float> = new DrawData<Float>();
-
-  var processedGraphic:FlxGraphic;
-
-  /**
-   * What part of the trail's end actually represents the end of the note.
-   * This can be used to have a little bit sticking out.
-   */
-  public var endOffset:Float = 0.5; // 0.73 is roughly the bottom of the sprite in the normal graphic!
-
-  /**
-   * At what point the bottom for the trail's end should be clipped off.
-   * Used in cases where there's an extra bit of the graphic on the bottom to avoid antialiasing issues with overflow.
-   */
-  public var bottomClip:Float = 0.9;
-
-  /**
-   * Normally you would take strumTime:Float, noteData:Int, sustainLength:Float, parentNote:Note (?)
-   * @param NoteData
-   * @param SustainLength
-   * @param FileName
-   */
-  public function new(NoteData:Int, SustainLength:Float, Path:String, ?Alpha:Float = 0.6, ?Pixel:Bool = false)
-  {
-    super(0, 0, Path);
-
-    // BASIC SETUP
-    this.sustainLength = SustainLength;
-    this.noteData = NoteData;
-
-    // CALCULATE SIZE
-    if (Pixel)
-    {
-      this.endOffset = bottomClip = 1;
-      this.antialiasing = false;
-      this.zoom = 6.0;
-    }
-    else
-    {
-      this.antialiasing = true;
-      this.zoom = 1.0;
-    }
-    // width = graphic.width / 8 * zoom; // amount of notes * 2
-    height = sustainHeight(sustainLength, scrollSpeed);
-    // instead of scrollSpeed, PlayState.SONG.speed
-
-    alpha = Alpha; // setting alpha calls updateColorTransform(), which initializes processedGraphic!
-
-    updateClipping();
-    indices = new DrawData<Int>(12, true, [0, 1, 2, 2, 3, 0, 4, 5, 6, 6, 7, 4]);
-  }
-
-  /**
-   * Calculates height of a sustain note for a given length (milliseconds) and scroll speed.
-   * @param	susLength	The length of the sustain note in milliseconds.
-   * @param	scroll		The current scroll speed.
-   */
-  public static inline function sustainHeight(susLength:Float, scroll:Float)
-  {
-    return (susLength * 0.45 * scroll);
-  }
-
-  function set_zoom(z:Float)
-  {
-    this.zoom = z;
-    width = graphic.width / 8 * z;
-    updateClipping();
-    return this.zoom;
-  }
-
-  function set_sustainLength(s:Float)
-  {
-    height = sustainHeight(s, scrollSpeed);
-    return sustainLength = s;
-  }
-
-  function set_scrollSpeed(s:Float)
-  {
-    height = sustainHeight(sustainLength, s);
-    return scrollSpeed = s;
-  }
-
-  /**
-   * Sets up new vertex and UV data to clip the trail.
-   * If flipY is true, top and bottom bounds swap places.
-   * @param songTime	The time to clip the note at, in milliseconds.
-   */
-  public function updateClipping(songTime:Float = 0):Void
-  {
-    var clipHeight:Float = FlxMath.bound(sustainHeight(sustainLength - (songTime - strumTime), scrollSpeed), 0, height);
-    if (clipHeight == 0)
-    {
-      visible = false;
-      return;
-    }
-    else
-      visible = true;
-    var bottomHeight:Float = graphic.height * zoom * endOffset;
-    var partHeight:Float = clipHeight - bottomHeight;
-    // == HOLD == //
-    // left bound
-    vertices[6] = vertices[0] = 0.0;
-    // top bound
-    vertices[3] = vertices[1] = flipY ? clipHeight : height - clipHeight;
-    // right bound
-    vertices[4] = vertices[2] = width;
-    // bottom bound (also top bound for hold ends)
-    if (partHeight > 0) vertices[7] = vertices[5] = flipY ? 0.0 + bottomHeight : vertices[1] + partHeight;
-    else
-      vertices[7] = vertices[5] = vertices[1];
-
-    // same shit with da bounds, just in relation to the texture
-    uvtData[6] = uvtData[0] = 1 / 4 * (noteData % 4);
-    // height overflows past image bounds so wraps around, looping the texture
-    // flipY bounds are not swapped for UV data, so the graphic is actually flipped
-    // top bound
-    uvtData[3] = uvtData[1] = (-partHeight) / graphic.height / zoom;
-    uvtData[4] = uvtData[2] = uvtData[0] + 1 / 8; // 1
-    // bottom bound
-    uvtData[7] = uvtData[5] = 0.0;
-
-    // == HOLD ENDS == //
-    // left bound
-    vertices[14] = vertices[8] = vertices[0];
-    // top bound
-    vertices[11] = vertices[9] = vertices[5];
-    // right bound
-    vertices[12] = vertices[10] = vertices[2];
-    // bottom bound, mind the bottomClip because it clips off bottom of graphic!!
-    vertices[15] = vertices[13] = flipY ? graphic.height * (-bottomClip + endOffset) : height + graphic.height * (bottomClip - endOffset);
-
-    uvtData[14] = uvtData[8] = uvtData[2];
-    if (partHeight > 0) uvtData[11] = uvtData[9] = 0.0;
-    else
-      uvtData[11] = uvtData[9] = (bottomHeight - clipHeight) / zoom / graphic.height;
-    uvtData[12] = uvtData[10] = uvtData[8] + 1 / 8;
-    // again, clips off bottom !!
-    uvtData[15] = uvtData[13] = bottomClip;
-  }
-
-  @:access(flixel.FlxCamera)
-  override public function draw():Void
-  {
-    if (alpha == 0 || graphic == null || vertices == null) return;
-
-    for (camera in cameras)
-    {
-      if (!camera.visible || !camera.exists || !isOnScreen(camera)) continue;
-
-      getScreenPosition(_point, camera).subtractPoint(offset);
-      camera.drawTriangles(processedGraphic, vertices, indices, uvtData, null, _point, blend, true, antialiasing);
-    }
-  }
-
-  override public function destroy():Void
-  {
-    vertices = null;
-    indices = null;
-    uvtData = null;
-    processedGraphic.destroy();
-
-    super.destroy();
-  }
-
-  override function updateColorTransform():Void
-  {
-    super.updateColorTransform();
-    if (processedGraphic != null) processedGraphic.destroy();
-    processedGraphic = FlxGraphic.fromGraphic(graphic, true);
-    processedGraphic.bitmap.colorTransform(processedGraphic.bitmap.rect, colorTransform);
-  }
-}
diff --git a/source/funkin/play/PlayState.hx b/source/funkin/play/PlayState.hx
index c76cf24d4..0c4c1abfb 100644
--- a/source/funkin/play/PlayState.hx
+++ b/source/funkin/play/PlayState.hx
@@ -836,7 +836,7 @@ class PlayState extends MusicBeatState
     if (isInCutscene && !disableKeys) handleCutsceneKeys(elapsed);
 
     // Moving notes into position is now done by Strumline.update().
-    processNotes();
+    processNotes(elapsed);
 
     // Dispatch the onUpdate event to scripted elements.
     dispatchEvent(new UpdateScriptEvent(elapsed));
@@ -1378,10 +1378,6 @@ class PlayState extends MusicBeatState
     if (!PlayStatePlaylist.isStoryMode)
     {
       playerStrumline.fadeInArrows();
-    }
-
-    if (!PlayStatePlaylist.isStoryMode)
-    {
       opponentStrumline.fadeInArrows();
     }
 
@@ -1629,7 +1625,7 @@ class PlayState extends MusicBeatState
   /**
    * Handles opponent note hits and player note misses.
    */
-  function processNotes():Void
+  function processNotes(elapsed:Float):Void
   {
     // Process notes on the opponent's side.
     for (note in opponentStrumline.notes.members)
@@ -1730,6 +1726,15 @@ class PlayState extends MusicBeatState
 
     // Process hold notes on the player's side.
     // This handles scoring so we don't need it on the opponent's side.
+    for (holdNote in playerStrumline.holdNotes.members)
+    {
+      // While the hold note is being hit, and there is length on the hold note...
+      if (holdNote.hitNote && holdNote.sustainLength > 0)
+      {
+        // Grant the player health.
+        health += Constants.HEALTH_HOLD_BONUS_PER_SECOND * elapsed;
+      }
+    }
   }
 
   /**
diff --git a/source/funkin/play/notes/Strumline.hx b/source/funkin/play/notes/Strumline.hx
index f01031345..77b039712 100644
--- a/source/funkin/play/notes/Strumline.hx
+++ b/source/funkin/play/notes/Strumline.hx
@@ -588,18 +588,18 @@ class Strumline extends FlxSpriteGroup
    * @param arrow The arrow to animate.
    * @param index The index of the arrow in the strumline.
    */
-  function fadeInArrow(arrow:StrumlineNote):Void
+  function fadeInArrow(index:Int, arrow:StrumlineNote):Void
   {
     arrow.y -= 10;
-    arrow.alpha = 0;
-    FlxTween.tween(arrow, {y: arrow.y + 10, alpha: 1}, 1, {ease: FlxEase.circOut, startDelay: 0.5 + (0.2 * arrow.ID)});
+    arrow.alpha = 0.0;
+    FlxTween.tween(arrow, {y: arrow.y + 10, alpha: 1}, 1, {ease: FlxEase.circOut, startDelay: 0.5 + (0.2 * index)});
   }
 
   public function fadeInArrows():Void
   {
-    for (arrow in this.strumlineNotes)
+    for (index => arrow in this.strumlineNotes.members.keyValueIterator())
     {
-      fadeInArrow(arrow);
+      fadeInArrow(index, arrow);
     }
   }
 
diff --git a/source/funkin/play/notes/StrumlineNote.hx b/source/funkin/play/notes/StrumlineNote.hx
index 1d24759dc..2f2b41374 100644
--- a/source/funkin/play/notes/StrumlineNote.hx
+++ b/source/funkin/play/notes/StrumlineNote.hx
@@ -41,6 +41,7 @@ class StrumlineNote extends FlxSprite
     this.animation.callback = onAnimationFrame;
     this.animation.finishCallback = onAnimationFinished;
 
+    // Must be true for animations to play.
     this.active = true;
   }
 
diff --git a/source/funkin/util/Constants.hx b/source/funkin/util/Constants.hx
index b014047bc..c6a6d0265 100644
--- a/source/funkin/util/Constants.hx
+++ b/source/funkin/util/Constants.hx
@@ -147,38 +147,43 @@ class Constants
   /**
    * The amount of health the player gains when hitting a note with the KILLER rating.
    */
-  public static final HEALTH_KILLER_BONUS:Float = 2.0 / 100.0 / HEALTH_MAX; // +2.0%
+  public static final HEALTH_KILLER_BONUS:Float = 2.0 / 100.0 * HEALTH_MAX; // +2.0%
 
   /**
    * The amount of health the player gains when hitting a note with the SICK rating.
    */
-  public static final HEALTH_SICK_BONUS:Float = 1.5 / 100.0 / HEALTH_MAX; // +1.0%
+  public static final HEALTH_SICK_BONUS:Float = 1.5 / 100.0 * HEALTH_MAX; // +1.0%
 
   /**
    * The amount of health the player gains when hitting a note with the GOOD rating.
    */
-  public static final HEALTH_GOOD_BONUS:Float = 0.75 / 100.0 / HEALTH_MAX; // +0.75%
+  public static final HEALTH_GOOD_BONUS:Float = 0.75 / 100.0 * HEALTH_MAX; // +0.75%
 
   /**
    * The amount of health the player gains when hitting a note with the BAD rating.
    */
-  public static final HEALTH_BAD_BONUS:Float = 0.0 / 100.0 / HEALTH_MAX; // +0.0%
+  public static final HEALTH_BAD_BONUS:Float = 0.0 / 100.0 * HEALTH_MAX; // +0.0%
 
   /**
    * The amount of health the player gains when hitting a note with the SHIT rating.
    * If negative, the player will actually lose health.
    */
-  public static final HEALTH_SHIT_BONUS:Float = -1.0 / 100.0 / HEALTH_MAX; // -1.0%
+  public static final HEALTH_SHIT_BONUS:Float = -1.0 / 100.0 * HEALTH_MAX; // -1.0%
+
+  /**
+   * The amount of health the player gains, while holding a hold note, per second.
+   */
+  public static final HEALTH_HOLD_BONUS_PER_SECOND:Float = 7.5 / 100.0 * HEALTH_MAX; // +7.5% / second
 
   /**
    * The amount of health the player loses upon missing a note.
    */
-  public static final HEALTH_MISS_PENALTY:Float = 4.0 / 100.0 / HEALTH_MAX; // 4.0%
+  public static final HEALTH_MISS_PENALTY:Float = 4.0 / 100.0 * HEALTH_MAX; // 4.0%
 
   /**
    * The amount of health the player loses upon pressing a key when no note is there.
    */
-  public static final HEALTH_GHOST_MISS_PENALTY:Float = 2.0 / 100.0 / HEALTH_MAX; // 2.0%
+  public static final HEALTH_GHOST_MISS_PENALTY:Float = 2.0 / 100.0 * HEALTH_MAX; // 2.0%
 
   /**
    * The amount of health the player loses upon letting go of a hold note while it is still going.
@@ -188,7 +193,7 @@ class Constants
   /**
    * The amount of health the player loses upon hitting a mine.
    */
-  public static final HEALTH_MINE_PENALTY:Float = 15.0 / 100.0 / HEALTH_MAX; // 15.0%
+  public static final HEALTH_MINE_PENALTY:Float = 15.0 / 100.0 * HEALTH_MAX; // 15.0%
 
   /**
    * If true, the player will not receive the ghost miss penalty if there are no notes within the hit window.

From d91c341bd2a3e6cd17b8a2c4167d2a7bf9715c3d Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Tue, 27 Jun 2023 18:06:33 -0400
Subject: [PATCH 10/30] Fixed a bug where you could hold notes forever

---
 source/funkin/NoteSplash.hx           | 10 ++++++++++
 source/funkin/play/PlayState.hx       | 11 +++++++++--
 source/funkin/play/notes/Strumline.hx | 26 ++++++++++++++++++++------
 3 files changed, 39 insertions(+), 8 deletions(-)

diff --git a/source/funkin/NoteSplash.hx b/source/funkin/NoteSplash.hx
index 318ef8f9f..81b35b36d 100644
--- a/source/funkin/NoteSplash.hx
+++ b/source/funkin/NoteSplash.hx
@@ -24,6 +24,16 @@ class NoteSplash extends FlxSprite
     // alpha = 0.75;
   }
 
+  public override function update(elapsed:Float):Void
+  {
+    super.update(elapsed);
+
+    if (animation.finished)
+    {
+      kill();
+    }
+  }
+
   public static function buildSplashFrames(force:Bool = false):FlxAtlasFrames
   {
     // static variables inside functions are a cool of Haxe 4.3.0.
diff --git a/source/funkin/play/PlayState.hx b/source/funkin/play/PlayState.hx
index 0c4c1abfb..c407203f6 100644
--- a/source/funkin/play/PlayState.hx
+++ b/source/funkin/play/PlayState.hx
@@ -33,6 +33,7 @@ import funkin.play.event.SongEventData.SongEventParser;
 import funkin.play.notes.NoteSprite;
 import funkin.play.notes.NoteDirection;
 import funkin.play.notes.Strumline;
+import funkin.play.notes.SustainTrail;
 import funkin.play.scoring.Scoring;
 import funkin.play.song.Song;
 import funkin.play.song.SongData.SongDataParser;
@@ -1728,10 +1729,15 @@ class PlayState extends MusicBeatState
     // This handles scoring so we don't need it on the opponent's side.
     for (holdNote in playerStrumline.holdNotes.members)
     {
+      if (holdNote == null || !holdNote.alive) continue;
+
       // While the hold note is being hit, and there is length on the hold note...
-      if (holdNote.hitNote && holdNote.sustainLength > 0)
+      if (holdNote.hitNote && !holdNote.missedNote && holdNote.sustainLength > 0)
       {
         // Grant the player health.
+        trace(holdNote);
+        trace(holdNote.noteData);
+        trace(holdNote.sustainLength);
         health += Constants.HEALTH_HOLD_BONUS_PER_SECOND * elapsed;
       }
     }
@@ -1760,6 +1766,7 @@ class PlayState extends MusicBeatState
 
     // Generate a list of notes within range.
     var notesInRange:Array<NoteSprite> = playerStrumline.getNotesMayHit();
+    var holdNotesInRange:Array<SustainTrail> = playerStrumline.getHoldNotesHitOrMissed();
 
     // If there are notes in range, pressing a key will cause a ghost miss.
 
@@ -1785,7 +1792,7 @@ class PlayState extends MusicBeatState
         // Play the strumline animation.
         playerStrumline.playPress(input.noteDirection);
       }
-      else if (Constants.GHOST_TAPPING && notesInRange.length > 0 && notesInDirection.length == 0)
+      else if (Constants.GHOST_TAPPING && (holdNotesInRange.length + notesInRange.length > 0) && notesInDirection.length == 0)
       {
         // Pressed a wrong key with no notes nearby AND with notes in a different direction available.
         // Perform a ghost miss (anti-spam).
diff --git a/source/funkin/play/notes/Strumline.hx b/source/funkin/play/notes/Strumline.hx
index 77b039712..0edc4435a 100644
--- a/source/funkin/play/notes/Strumline.hx
+++ b/source/funkin/play/notes/Strumline.hx
@@ -130,6 +130,13 @@ class Strumline extends FlxSpriteGroup
     });
   }
 
+  public function getHoldNotesHitOrMissed():Array<SustainTrail>
+  {
+    return holdNotes.members.filter(function(holdNote:SustainTrail) {
+      return holdNote != null && holdNote.alive && (holdNote.hitNote || holdNote.missedNote);
+    });
+  }
+
   public function getHoldNotesInRange(strumTime:Float, hitWindow:Float):Array<SustainTrail>
   {
     var hitWindowStart:Float = strumTime - hitWindow;
@@ -255,15 +262,17 @@ class Strumline extends FlxSpriteGroup
       else if (holdNote.hitNote && holdNote.sustainLength <= 0)
       {
         // Hold note is completed, kill it.
-        playStatic(holdNote.noteDirection);
+        if (isKeyHeld(holdNote.noteDirection))
+        {
+          playPress(holdNote.noteDirection);
+        }
+        else
+        {
+          playStatic(holdNote.noteDirection);
+        }
         holdNote.visible = false;
         holdNote.kill();
       }
-      else if (holdNote.hitNote && holdNote.sustainLength <= 10)
-      {
-        // TODO: Better handle the weird edge case where the hold note is almost completed.
-        holdNote.visible = false;
-      }
       else if (holdNote.missedNote && (holdNote.fullSustainLength > holdNote.sustainLength))
       {
         // Hold note was dropped before completing, keep it in its clipped state.
@@ -294,6 +303,11 @@ class Strumline extends FlxSpriteGroup
 
         holdNote.sustainLength = (holdNote.strumTime + holdNote.fullSustainLength) - Conductor.songPosition;
 
+        if (holdNote.sustainLength <= 10)
+        {
+          holdNote.visible = false;
+        }
+
         if (PreferencesMenu.getPref('downscroll'))
         {
           holdNote.y = this.y - holdNote.height + STRUMLINE_SIZE / 2;

From 085d7aaa9d1918e1a8a1207d3426725a3f94ec16 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Tue, 27 Jun 2023 21:29:50 -0400
Subject: [PATCH 11/30] Fix for note splashes

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

diff --git a/source/funkin/play/PlayState.hx b/source/funkin/play/PlayState.hx
index c407203f6..6ebffd0c7 100644
--- a/source/funkin/play/PlayState.hx
+++ b/source/funkin/play/PlayState.hx
@@ -35,6 +35,7 @@ import funkin.play.notes.NoteDirection;
 import funkin.play.notes.Strumline;
 import funkin.play.notes.SustainTrail;
 import funkin.play.scoring.Scoring;
+import funkin.NoteSplash;
 import funkin.play.song.Song;
 import funkin.play.song.SongData.SongDataParser;
 import funkin.play.song.SongData.SongEventData;
@@ -453,6 +454,8 @@ class PlayState extends MusicBeatState
     }
     instance = this;
 
+    NoteSplash.buildSplashFrames();
+
     if (currentSong != null)
     {
       // Load and cache the song's charts.

From 100565f9fcce2b7d3cd9d98797d0b09c5a36bb85 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Tue, 27 Jun 2023 21:29:58 -0400
Subject: [PATCH 12/30] Messing with transitions.

---
 source/funkin/InitState.hx | 11 +++++++----
 1 file changed, 7 insertions(+), 4 deletions(-)

diff --git a/source/funkin/InitState.hx b/source/funkin/InitState.hx
index 4d74e1a05..686453603 100644
--- a/source/funkin/InitState.hx
+++ b/source/funkin/InitState.hx
@@ -142,10 +142,13 @@ class InitState extends FlxTransitionableState
     diamond.persist = true;
     diamond.destroyOnNoUse = false;
 
-    // FlxTransitionableState.defaultTransIn = new TransitionData(FADE, FlxColor.BLACK, 1, new FlxPoint(0, -1), {asset: diamond, width: 32, height: 32},
-    //   new FlxRect(-200, -200, FlxG.width * 1.4, FlxG.height * 1.4));
-    // FlxTransitionableState.defaultTransOut = new TransitionData(FADE, FlxColor.BLACK, 0.7, new FlxPoint(0, 1), {asset: diamond, width: 32, height: 32},
-    //   new FlxRect(-200, -200, FlxG.width * 1.4, FlxG.height * 1.4));
+    // NOTE: tileData is ignored if TransitionData.type is FADE instead of TILES.
+    var tileData:TransitionTileData = {asset: diamond, width: 32, height: 32};
+
+    FlxTransitionableState.defaultTransIn = new TransitionData(FADE, FlxColor.BLACK, 1, new FlxPoint(1, 1), tileData,
+      new FlxRect(-200, -200, FlxG.width * 1.4, FlxG.height * 1.4));
+    FlxTransitionableState.defaultTransOut = new TransitionData(FADE, FlxColor.BLACK, 0.7, new FlxPoint(1, 1), tileData,
+      new FlxRect(-200, -200, FlxG.width * 1.4, FlxG.height * 1.4));
     // Don't play transition in when entering the title state.
     FlxTransitionableState.skipNextTransIn = true;
 

From b615fbf82af84dbd6012da98195cafebba423efa Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Tue, 4 Jul 2023 16:38:10 -0400
Subject: [PATCH 13/30] WIP on hold covers

---
 source/funkin/InitState.hx                |  4 +-
 source/funkin/play/PlayState.hx           |  5 ++
 source/funkin/play/notes/NoteHoldCover.hx | 75 +++++++++++++++++++++
 source/funkin/play/notes/NoteSprite.hx    | 13 ----
 source/funkin/play/notes/Strumline.hx     | 81 ++++++++++++++++++++---
 source/funkin/play/song/SongData.hx       |  7 ++
 6 files changed, 160 insertions(+), 25 deletions(-)
 create mode 100644 source/funkin/play/notes/NoteHoldCover.hx

diff --git a/source/funkin/InitState.hx b/source/funkin/InitState.hx
index 686453603..97e451320 100644
--- a/source/funkin/InitState.hx
+++ b/source/funkin/InitState.hx
@@ -145,9 +145,9 @@ class InitState extends FlxTransitionableState
     // NOTE: tileData is ignored if TransitionData.type is FADE instead of TILES.
     var tileData:TransitionTileData = {asset: diamond, width: 32, height: 32};
 
-    FlxTransitionableState.defaultTransIn = new TransitionData(FADE, FlxColor.BLACK, 1, new FlxPoint(1, 1), tileData,
+    FlxTransitionableState.defaultTransIn = new TransitionData(FADE, FlxColor.BLACK, 1, new FlxPoint(0, -1), tileData,
       new FlxRect(-200, -200, FlxG.width * 1.4, FlxG.height * 1.4));
-    FlxTransitionableState.defaultTransOut = new TransitionData(FADE, FlxColor.BLACK, 0.7, new FlxPoint(1, 1), tileData,
+    FlxTransitionableState.defaultTransOut = new TransitionData(FADE, FlxColor.BLACK, 0.7, new FlxPoint(0, 1), tileData,
       new FlxRect(-200, -200, FlxG.width * 1.4, FlxG.height * 1.4));
     // Don't play transition in when entering the title state.
     FlxTransitionableState.skipNextTransIn = true;
diff --git a/source/funkin/play/PlayState.hx b/source/funkin/play/PlayState.hx
index 6ebffd0c7..fffc9a549 100644
--- a/source/funkin/play/PlayState.hx
+++ b/source/funkin/play/PlayState.hx
@@ -2190,6 +2190,11 @@ class PlayState extends MusicBeatState
       playerStrumline.playNoteSplash(daNote.noteData.getDirection());
     }
 
+    if (daNote.noteData.isHoldNote)
+    {
+      playerStrumline.playNoteHoldCover(daNote.noteData.getDirection());
+    }
+
     // Only add the score if you're not on practice mode
     if (!isPracticeMode)
     {
diff --git a/source/funkin/play/notes/NoteHoldCover.hx b/source/funkin/play/notes/NoteHoldCover.hx
new file mode 100644
index 000000000..e64238681
--- /dev/null
+++ b/source/funkin/play/notes/NoteHoldCover.hx
@@ -0,0 +1,75 @@
+package funkin.play.notes;
+
+import flixel.group.FlxSpriteGroup.FlxTypedSpriteGroup;
+import funkin.play.notes.NoteDirection;
+import flixel.graphics.frames.FlxFramesCollection;
+import flixel.FlxG;
+import flixel.graphics.frames.FlxAtlasFrames;
+import flixel.FlxSprite;
+
+class NoteHoldCover extends FlxTypedSpriteGroup<FlxSprite>
+{
+  static final FRAMERATE_DEFAULT:Int = 24;
+
+  static var glowFrames:FlxAtlasFrames;
+
+  var glow:FlxSprite;
+  var sparks:FlxSprite;
+
+  public static function preloadFrames():Void
+  {
+    glowFrames = Paths.getSparrowAtlas('holdCoverRed');
+  }
+
+  public function new()
+  {
+    super(0, 0);
+
+    setup();
+  }
+
+  /**
+   * Add ALL the animations to this sprite. We will recycle and reuse the FlxSprite multiple times.
+   */
+  function setup():Void
+  {
+    glow = new FlxSprite();
+    add(glow);
+    if (glowFrames == null) preloadFrames();
+    glow.frames = glowFrames;
+
+    glow.animation.addByPrefix('holdCoverRed', 'holdCoverRed0', FRAMERATE_DEFAULT, true, false, false);
+    glow.animation.addByPrefix('holdCoverEndRed', 'holdCoverEndRed0', FRAMERATE_DEFAULT, true, false, false);
+
+    glow.animation.finishCallback = this.onAnimationFinished;
+
+    if (glow.animation.getAnimationList().length < 2)
+    {
+      trace('WARNING: NoteHoldCover failed to initialize all animations.');
+    }
+  }
+
+  public function playStart(direction:NoteDirection):Void
+  {
+    glow.animation.play('holdCoverRed');
+  }
+
+  public function playContinue(direction:NoteDirection):Void
+  {
+    glow.animation.play('holdCoverRed');
+  }
+
+  public function playEnd(direction:NoteDirection):Void
+  {
+    glow.animation.play('holdCoverEndRed');
+  }
+
+  public function onAnimationFinished(animationName:String):Void
+  {
+    if (animationName.startsWith('holdCoverEnd'))
+    {
+      // *lightning* *zap* *crackle*
+      this.kill();
+    }
+  }
+}
diff --git a/source/funkin/play/notes/NoteSprite.hx b/source/funkin/play/notes/NoteSprite.hx
index ed6417f90..dd378bf02 100644
--- a/source/funkin/play/notes/NoteSprite.hx
+++ b/source/funkin/play/notes/NoteSprite.hx
@@ -21,19 +21,6 @@ class NoteSprite extends FlxSprite
     return this.strumTime;
   }
 
-  /**
-   * The length of the note's sustain, in milliseconds.
-   * If 0, the note is a tap note.
-   */
-  public var length(default, set):Float;
-
-  function set_length(value:Float):Float
-  {
-    this.length = value;
-    this.isSustainNote = (this.length > 0);
-    return this.length;
-  }
-
   /**
    * The time at which the note should be hit, in steps.
    */
diff --git a/source/funkin/play/notes/Strumline.hx b/source/funkin/play/notes/Strumline.hx
index 0edc4435a..298c429c0 100644
--- a/source/funkin/play/notes/Strumline.hx
+++ b/source/funkin/play/notes/Strumline.hx
@@ -1,16 +1,18 @@
 package funkin.play.notes;
 
-import flixel.tweens.FlxEase;
-import flixel.tweens.FlxTween;
-import funkin.ui.PreferencesMenu;
-import funkin.play.notes.NoteSprite;
-import flixel.util.FlxSort;
-import funkin.play.notes.SustainTrail;
-import funkin.util.SortUtil;
-import funkin.play.song.SongData.SongNoteData;
 import flixel.FlxG;
 import flixel.group.FlxSpriteGroup;
 import flixel.group.FlxSpriteGroup.FlxTypedSpriteGroup;
+import flixel.tweens.FlxEase;
+import flixel.tweens.FlxTween;
+import flixel.util.FlxSort;
+import funkin.play.notes.NoteHoldCover;
+import funkin.play.notes.NoteSplash;
+import funkin.play.notes.NoteSprite;
+import funkin.play.notes.SustainTrail;
+import funkin.play.song.SongData.SongNoteData;
+import funkin.ui.PreferencesMenu;
+import funkin.util.SortUtil;
 
 /**
  * A group of sprites which handles the receptor, the note splashes, and the notes (with sustains) for a given player.
@@ -48,7 +50,7 @@ class Strumline extends FlxSpriteGroup
 
   var strumlineNotes:FlxTypedSpriteGroup<StrumlineNote>;
   var noteSplashes:FlxTypedSpriteGroup<NoteSplash>;
-  var sustainSplashes:FlxTypedSpriteGroup<NoteSplash>;
+  var noteHoldCovers:FlxTypedSpriteGroup<NoteHoldCover>;
 
   var noteData:Array<SongNoteData> = [];
   var nextNoteIndex:Int = -1;
@@ -74,8 +76,12 @@ class Strumline extends FlxSpriteGroup
     this.notes.zIndex = 30;
     this.add(this.notes);
 
+    this.noteHoldCovers = new FlxTypedSpriteGroup<NoteHoldCover>(0, 0, 4);
+    this.noteHoldCovers.zIndex = 40;
+    this.add(this.noteHoldCovers);
+
     this.noteSplashes = new FlxTypedSpriteGroup<NoteSplash>(0, 0, NOTE_SPLASH_CAP);
-    this.noteSplashes.zIndex = 40;
+    this.noteSplashes.zIndex = 50;
     this.add(this.noteSplashes);
 
     for (i in 0...KEY_COUNT)
@@ -450,6 +456,27 @@ class Strumline extends FlxSpriteGroup
     }
   }
 
+  public function playNoteHoldCover(direction:NoteDirection):Void
+  {
+    // TODO: Add a setting to disable note splashes.
+    // if (Settings.noSplash) return;
+
+    var cover:NoteHoldCover = this.constructNoteHoldCover();
+
+    if (cover != null)
+    {
+      cover.playStart(direction);
+
+      cover.x = this.x;
+      cover.x += getXPos(direction);
+      cover.x -= cover.width / 8;
+      cover.x += INITIAL_OFFSET;
+      cover.y = this.y;
+      cover.y -= cover.height / 4;
+      //
+    }
+  }
+
   public function buildNoteSprite(note:SongNoteData):NoteSprite
   {
     var noteSprite:NoteSprite = constructNoteSprite();
@@ -530,6 +557,40 @@ class Strumline extends FlxSpriteGroup
     return result;
   }
 
+  /**
+   * Custom recycling behavior.
+   */
+  function constructNoteHoldCover():NoteHoldCover
+  {
+    var result:NoteHoldCover = null;
+
+    // If we haven't filled the pool yet...
+    if (noteHoldCovers.length < noteHoldCovers.maxSize)
+    {
+      // Create a new note hold cover.
+      result = new NoteHoldCover();
+      this.noteHoldCovers.add(result);
+    }
+    else
+    {
+      // Else, find a note splash which is inactive so we can revive it.
+      result = this.noteHoldCovers.getFirstAvailable();
+
+      if (result != null)
+      {
+        result.revive();
+      }
+      else
+      {
+        // The note hold cover pool is full and all note hold covers are active,
+        // so we just pick one at random to destroy and restart.
+        result = FlxG.random.getObject(this.noteHoldCovers.members);
+      }
+    }
+
+    return result;
+  }
+
   /**
    * Custom recycling behavior.
    */
diff --git a/source/funkin/play/song/SongData.hx b/source/funkin/play/song/SongData.hx
index dc46ae365..7122d130e 100644
--- a/source/funkin/play/song/SongData.hx
+++ b/source/funkin/play/song/SongData.hx
@@ -450,6 +450,13 @@ abstract SongNoteData(RawSongNoteData)
     return this.l = value;
   }
 
+  public var isHoldNote(get, never):Bool;
+
+  public function get_isHoldNote():Bool
+  {
+    return this.l > 0;
+  }
+
   public var kind(get, set):String;
 
   public function get_kind():String

From 8e071221ff92482064647fd87be4205c09f7c121 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Wed, 5 Jul 2023 22:11:58 -0400
Subject: [PATCH 14/30] Work in progress on hold covers

---
 source/funkin/play/PlayState.hx           | 17 +++++---
 source/funkin/play/notes/NoteHoldCover.hx | 52 +++++++++++++++++------
 source/funkin/play/notes/NoteSprite.hx    |  7 ++-
 source/funkin/play/notes/Strumline.hx     | 29 ++++++++++---
 source/funkin/play/notes/SustainTrail.hx  |  2 +
 source/funkin/ui/AtlasText.hx             | 11 ++++-
 6 files changed, 89 insertions(+), 29 deletions(-)

diff --git a/source/funkin/play/PlayState.hx b/source/funkin/play/PlayState.hx
index fffc9a549..65a750e66 100644
--- a/source/funkin/play/PlayState.hx
+++ b/source/funkin/play/PlayState.hx
@@ -1660,6 +1660,11 @@ class PlayState extends MusicBeatState
         // Command the opponent to hit the note on time.
         // NOTE: This is what handles the strumline and cleaning up the note itself!
         opponentStrumline.hitNote(note);
+
+        if (note.holdNoteSprite != null)
+        {
+          opponentStrumline.playNoteHoldCover(note.holdNoteSprite);
+        }
       }
       else if (Conductor.songPosition > hitWindowStart)
       {
@@ -1945,7 +1950,7 @@ class PlayState extends MusicBeatState
       // Calling event.cancelEvent() skips all the other logic! Neat!
       if (event.eventCanceled) return;
 
-      if (!note.isSustainNote)
+      if (!note.isHoldNote)
       {
         Highscore.tallies.combo++;
         Highscore.tallies.totalNotesHit++;
@@ -1957,6 +1962,11 @@ class PlayState extends MusicBeatState
 
       playerStrumline.hitNote(note);
 
+      if (note.holdNoteSprite != null)
+      {
+        playerStrumline.playNoteHoldCover(note.holdNoteSprite);
+      }
+
       vocals.playerVolume = 1;
     }
   }
@@ -2190,11 +2200,6 @@ class PlayState extends MusicBeatState
       playerStrumline.playNoteSplash(daNote.noteData.getDirection());
     }
 
-    if (daNote.noteData.isHoldNote)
-    {
-      playerStrumline.playNoteHoldCover(daNote.noteData.getDirection());
-    }
-
     // Only add the score if you're not on practice mode
     if (!isPracticeMode)
     {
diff --git a/source/funkin/play/notes/NoteHoldCover.hx b/source/funkin/play/notes/NoteHoldCover.hx
index e64238681..b68de3946 100644
--- a/source/funkin/play/notes/NoteHoldCover.hx
+++ b/source/funkin/play/notes/NoteHoldCover.hx
@@ -13,14 +13,11 @@ class NoteHoldCover extends FlxTypedSpriteGroup<FlxSprite>
 
   static var glowFrames:FlxAtlasFrames;
 
+  public var holdNote:SustainTrail;
+
   var glow:FlxSprite;
   var sparks:FlxSprite;
 
-  public static function preloadFrames():Void
-  {
-    glowFrames = Paths.getSparrowAtlas('holdCoverRed');
-  }
-
   public function new()
   {
     super(0, 0);
@@ -28,6 +25,12 @@ class NoteHoldCover extends FlxTypedSpriteGroup<FlxSprite>
     setup();
   }
 
+  public static function preloadFrames():Void
+  {
+    glowFrames = Paths.getSparrowAtlas('holdCoverRed');
+    glowFrames.parent.persist = true;
+  }
+
   /**
    * Add ALL the animations to this sprite. We will recycle and reuse the FlxSprite multiple times.
    */
@@ -38,8 +41,9 @@ class NoteHoldCover extends FlxTypedSpriteGroup<FlxSprite>
     if (glowFrames == null) preloadFrames();
     glow.frames = glowFrames;
 
+    glow.animation.addByPrefix('holdCoverStartRed', 'holdCoverStartRed0', FRAMERATE_DEFAULT, false, false, false);
     glow.animation.addByPrefix('holdCoverRed', 'holdCoverRed0', FRAMERATE_DEFAULT, true, false, false);
-    glow.animation.addByPrefix('holdCoverEndRed', 'holdCoverEndRed0', FRAMERATE_DEFAULT, true, false, false);
+    glow.animation.addByPrefix('holdCoverEndRed', 'holdCoverEndRed0', FRAMERATE_DEFAULT, false, false, false);
 
     glow.animation.finishCallback = this.onAnimationFinished;
 
@@ -49,26 +53,48 @@ class NoteHoldCover extends FlxTypedSpriteGroup<FlxSprite>
     }
   }
 
-  public function playStart(direction:NoteDirection):Void
+  public override function update(elapsed):Void
   {
+    super.update(elapsed);
+    if (!holdNote.alive && !glow.animation.curAnim.name.startsWith('holdCoverEnd'))
+    {
+      this.visible = false;
+      this.kill();
+    }
+    else
+    {
+      this.visible = true;
+    }
+  }
+
+  public function playStart():Void
+  {
+    // glow.animation.play('holdCoverStart${noteDirection.colorName.toTitleCase()}');\
+    glow.animation.play('holdCoverStartRed');
+  }
+
+  public function playContinue():Void
+  {
+    // glow.animation.play('holdCover${noteDirection.colorName.toTitleCase()}');\
     glow.animation.play('holdCoverRed');
   }
 
-  public function playContinue(direction:NoteDirection):Void
-  {
-    glow.animation.play('holdCoverRed');
-  }
-
-  public function playEnd(direction:NoteDirection):Void
+  public function playEnd():Void
   {
+    // glow.animation.play('holdCoverEnd${noteDirection.colorName.toTitleCase()}');\
     glow.animation.play('holdCoverEndRed');
   }
 
   public function onAnimationFinished(animationName:String):Void
   {
+    if (animationName.startsWith('holdCoverStart'))
+    {
+      playContinue();
+    }
     if (animationName.startsWith('holdCoverEnd'))
     {
       // *lightning* *zap* *crackle*
+      this.visible = false;
       this.kill();
     }
   }
diff --git a/source/funkin/play/notes/NoteSprite.hx b/source/funkin/play/notes/NoteSprite.hx
index dd378bf02..8955f9d42 100644
--- a/source/funkin/play/notes/NoteSprite.hx
+++ b/source/funkin/play/notes/NoteSprite.hx
@@ -61,7 +61,12 @@ class NoteSprite extends FlxSprite
 
   public var noteData:SongNoteData;
 
-  public var isSustainNote:Bool = false;
+  public var isHoldNote(get, never):Bool;
+
+  function get_isHoldNote():Bool
+  {
+    return noteData.length > 0;
+  }
 
   /**
    * Set this flag to true when hitting the note to avoid scoring it multiple times.
diff --git a/source/funkin/play/notes/Strumline.hx b/source/funkin/play/notes/Strumline.hx
index 298c429c0..60df77e69 100644
--- a/source/funkin/play/notes/Strumline.hx
+++ b/source/funkin/play/notes/Strumline.hx
@@ -264,6 +264,8 @@ class Strumline extends FlxSpriteGroup
         // Hold note is offscreen, kill it.
         holdNote.visible = false;
         holdNote.kill(); // Do not destroy! Recycling is faster.
+
+        // The cover will see this and clean itself up.
       }
       else if (holdNote.hitNote && holdNote.sustainLength <= 0)
       {
@@ -276,6 +278,12 @@ class Strumline extends FlxSpriteGroup
         {
           playStatic(holdNote.noteDirection);
         }
+
+        if (holdNote.cover != null)
+        {
+          holdNote.cover.playEnd();
+        }
+
         holdNote.visible = false;
         holdNote.kill();
       }
@@ -456,7 +464,7 @@ class Strumline extends FlxSpriteGroup
     }
   }
 
-  public function playNoteHoldCover(direction:NoteDirection):Void
+  public function playNoteHoldCover(holdNote:SustainTrail):Void
   {
     // TODO: Add a setting to disable note splashes.
     // if (Settings.noSplash) return;
@@ -465,15 +473,22 @@ class Strumline extends FlxSpriteGroup
 
     if (cover != null)
     {
-      cover.playStart(direction);
+      cover.holdNote = holdNote;
+      holdNote.cover = cover;
+      cover.visible = true;
+
+      cover.playStart();
 
       cover.x = this.x;
-      cover.x += getXPos(direction);
-      cover.x -= cover.width / 8;
-      cover.x += INITIAL_OFFSET;
+      cover.x += getXPos(holdNote.noteDirection);
+      cover.x += STRUMLINE_SIZE / 2;
+      cover.x -= cover.width / 2;
+      // cover.x += INITIAL_OFFSET * 2;
       cover.y = this.y;
-      cover.y -= cover.height / 4;
-      //
+      cover.y += INITIAL_OFFSET;
+      cover.y += STRUMLINE_SIZE / 2;
+      // cover.y -= cover.height / 2;
+      // cover.y += STRUMLINE_SIZE / 2;
     }
   }
 
diff --git a/source/funkin/play/notes/SustainTrail.hx b/source/funkin/play/notes/SustainTrail.hx
index c8f629c90..addc312f4 100644
--- a/source/funkin/play/notes/SustainTrail.hx
+++ b/source/funkin/play/notes/SustainTrail.hx
@@ -32,6 +32,8 @@ class SustainTrail extends FlxSprite
   public var fullSustainLength:Float = 0;
   public var noteData:SongNoteData;
 
+  public var cover:NoteHoldCover = null;
+
   /**
    * Set to `true` if the user hit the note and is currently holding the sustain.
    * Should display associated effects.
diff --git a/source/funkin/ui/AtlasText.hx b/source/funkin/ui/AtlasText.hx
index 76837c7ed..fea09de54 100644
--- a/source/funkin/ui/AtlasText.hx
+++ b/source/funkin/ui/AtlasText.hx
@@ -178,8 +178,15 @@ class AtlasChar extends FlxSprite
     if (this.char != value)
     {
       var prefix = getAnimPrefix(value);
-      animation.addByPrefix("anim", prefix, 24);
-      animation.play("anim");
+      animation.addByPrefix('anim', prefix, 24);
+      if (animation.exists('anim'))
+      {
+        animation.play('anim');
+      }
+      else
+      {
+        trace('Could not find animation for char "' + value + '"');
+      }
       updateHitbox();
     }
 

From 796a51325f92d303c281386056899173ffa50cd8 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Sat, 8 Jul 2023 01:03:46 -0400
Subject: [PATCH 15/30] Hold note covers are in and properly positioned

---
 source/funkin/MusicBeatState.hx               |  5 ++
 source/funkin/play/PlayState.hx               | 83 ++++++++++++-------
 source/funkin/play/character/BaseCharacter.hx | 11 ++-
 source/funkin/play/notes/NoteHoldCover.hx     | 41 ++++++---
 source/funkin/play/notes/NoteSprite.hx        | 20 ++---
 source/funkin/play/notes/Strumline.hx         | 48 +++++++++--
 source/funkin/play/notes/StrumlineNote.hx     | 73 ++++++++++------
 source/funkin/util/Constants.hx               |  2 +-
 8 files changed, 193 insertions(+), 90 deletions(-)

diff --git a/source/funkin/MusicBeatState.hx b/source/funkin/MusicBeatState.hx
index 4b86d801c..9aad66773 100644
--- a/source/funkin/MusicBeatState.hx
+++ b/source/funkin/MusicBeatState.hx
@@ -60,6 +60,11 @@ class MusicBeatState extends FlxUIState implements IEventHandler
   {
     super.update(elapsed);
 
+    // Rebindable volume keys.
+    if (controls.VOLUME_MUTE) FlxG.sound.toggleMuted();
+    else if (controls.VOLUME_UP) FlxG.sound.changeVolume(0.1);
+    else if (controls.VOLUME_DOWN) FlxG.sound.changeVolume(-0.1);
+
     // Emergency exit button.
     if (FlxG.keys.justPressed.F4) FlxG.switchState(new MainMenuState());
 
diff --git a/source/funkin/play/PlayState.hx b/source/funkin/play/PlayState.hx
index 65a750e66..7d5dc48b9 100644
--- a/source/funkin/play/PlayState.hx
+++ b/source/funkin/play/PlayState.hx
@@ -1367,13 +1367,14 @@ class PlayState extends MusicBeatState
     add(playerStrumline);
     add(opponentStrumline);
 
-    // Position the player strumline on the right
-    playerStrumline.x = FlxG.width - playerStrumline.width - Constants.STRUMLINE_X_OFFSET;
+    // Position the player strumline on the right half of the screen
+    playerStrumline.x = FlxG.width / 2 + Constants.STRUMLINE_X_OFFSET; // Classic style
+    // playerStrumline.x = FlxG.width - playerStrumline.width - Constants.STRUMLINE_X_OFFSET; // Centered style
     playerStrumline.y = PreferencesMenu.getPref('downscroll') ? FlxG.height - playerStrumline.height - Constants.STRUMLINE_Y_OFFSET : Constants.STRUMLINE_Y_OFFSET;
     playerStrumline.zIndex = 200;
     playerStrumline.cameras = [camHUD];
 
-    // Position the opponent strumline on the left
+    // Position the opponent strumline on the left half of the screen
     opponentStrumline.x = Constants.STRUMLINE_X_OFFSET;
     opponentStrumline.y = PreferencesMenu.getPref('downscroll') ? FlxG.height - opponentStrumline.height - Constants.STRUMLINE_Y_OFFSET : Constants.STRUMLINE_Y_OFFSET;
     opponentStrumline.zIndex = 100;
@@ -1642,13 +1643,18 @@ class PlayState extends MusicBeatState
 
       if (Conductor.songPosition > hitWindowEnd)
       {
+        if (note.hasMissed) continue;
+
         note.tooEarly = false;
         note.mayHit = false;
         note.hasMissed = true;
+
         if (note.holdNoteSprite != null) note.holdNoteSprite.missedNote = true;
       }
       else if (Conductor.songPosition > hitWindowCenter)
       {
+        if (note.hasBeenHit) continue;
+
         // Call an event to allow canceling the note hit.
         // NOTE: This is what handles the character animations!
         var event:NoteScriptEvent = new NoteScriptEvent(ScriptEvent.NOTE_HIT, note, 0, true);
@@ -1668,6 +1674,8 @@ class PlayState extends MusicBeatState
       }
       else if (Conductor.songPosition > hitWindowStart)
       {
+        if (note.hasBeenHit || note.hasMissed) continue;
+
         note.tooEarly = false;
         note.mayHit = true;
         note.hasMissed = false;
@@ -1682,6 +1690,25 @@ class PlayState extends MusicBeatState
       }
     }
 
+    // Process hold notes on the opponent's side.
+    for (holdNote in opponentStrumline.holdNotes.members)
+    {
+      if (holdNote == null || !holdNote.alive) continue;
+
+      // While the hold note is being hit, and there is length on the hold note...
+      if (holdNote.hitNote && !holdNote.missedNote && holdNote.sustainLength > 0)
+      {
+        // Make sure the opponent keeps singing while the note is held.
+        if (currentStage != null && currentStage.getDad() != null && currentStage.getDad().isSinging())
+        {
+          currentStage.getDad().holdTimer = 0;
+        }
+      }
+
+      // TODO: Potential penalty for dropping a hold note?
+      // if (holdNote.missedNote && !holdNote.handledMiss) { holdNote.handledMiss = true; }
+    }
+
     // Process notes on the player's side.
     for (note in playerStrumline.notes.members)
     {
@@ -1743,11 +1770,11 @@ class PlayState extends MusicBeatState
       if (holdNote.hitNote && !holdNote.missedNote && holdNote.sustainLength > 0)
       {
         // Grant the player health.
-        trace(holdNote);
-        trace(holdNote.noteData);
-        trace(holdNote.sustainLength);
         health += Constants.HEALTH_HOLD_BONUS_PER_SECOND * elapsed;
       }
+
+      // TODO: Potential penalty for dropping a hold note?
+      // if (holdNote.missedNote && !holdNote.handledMiss) { holdNote.handledMiss = true; }
     }
   }
 
@@ -1821,6 +1848,7 @@ class PlayState extends MusicBeatState
 
         targetNote.visible = false;
         targetNote.kill();
+        notesInDirection.remove(targetNote);
 
         // Play the strumline animation.
         playerStrumline.playConfirm(input.noteDirection);
@@ -1942,33 +1970,30 @@ class PlayState extends MusicBeatState
 
   function goodNoteHit(note:NoteSprite, input:PreciseInputEvent):Void
   {
-    if (!note.hasBeenHit)
+    var event:NoteScriptEvent = new NoteScriptEvent(ScriptEvent.NOTE_HIT, note, Highscore.tallies.combo + 1, true);
+    dispatchEvent(event);
+
+    // Calling event.cancelEvent() skips all the other logic! Neat!
+    if (event.eventCanceled) return;
+
+    if (!note.isHoldNote)
     {
-      var event:NoteScriptEvent = new NoteScriptEvent(ScriptEvent.NOTE_HIT, note, Highscore.tallies.combo + 1, true);
-      dispatchEvent(event);
+      Highscore.tallies.combo++;
+      Highscore.tallies.totalNotesHit++;
 
-      // Calling event.cancelEvent() skips all the other logic! Neat!
-      if (event.eventCanceled) return;
+      if (Highscore.tallies.combo > Highscore.tallies.maxCombo) Highscore.tallies.maxCombo = Highscore.tallies.combo;
 
-      if (!note.isHoldNote)
-      {
-        Highscore.tallies.combo++;
-        Highscore.tallies.totalNotesHit++;
-
-        if (Highscore.tallies.combo > Highscore.tallies.maxCombo) Highscore.tallies.maxCombo = Highscore.tallies.combo;
-
-        popUpScore(note, input);
-      }
-
-      playerStrumline.hitNote(note);
-
-      if (note.holdNoteSprite != null)
-      {
-        playerStrumline.playNoteHoldCover(note.holdNoteSprite);
-      }
-
-      vocals.playerVolume = 1;
+      popUpScore(note, input);
     }
+
+    playerStrumline.hitNote(note);
+
+    if (note.holdNoteSprite != null)
+    {
+      playerStrumline.playNoteHoldCover(note.holdNoteSprite);
+    }
+
+    vocals.playerVolume = 1;
   }
 
   /**
diff --git a/source/funkin/play/character/BaseCharacter.hx b/source/funkin/play/character/BaseCharacter.hx
index b27a46a0f..fa0a502fb 100644
--- a/source/funkin/play/character/BaseCharacter.hx
+++ b/source/funkin/play/character/BaseCharacter.hx
@@ -359,7 +359,7 @@ class BaseCharacter extends Bopper
     }
 
     // Handle character note hold time.
-    if (getCurrentAnimation().startsWith('sing'))
+    if (isSinging())
     {
       // TODO: Rework this code (and all character animations ugh)
       // such that the hold time is handled by padding frames,
@@ -405,6 +405,11 @@ class BaseCharacter extends Bopper
     }
   }
 
+  public function isSinging():Bool
+  {
+    return getCurrentAnimation().startsWith('sing');
+  }
+
   override function dance(force:Bool = false):Void
   {
     // Prevent default dancing behavior.
@@ -412,13 +417,13 @@ class BaseCharacter extends Bopper
 
     if (!force)
     {
-      if (getCurrentAnimation().startsWith('sing')) return;
+      if (isSinging()) return;
 
       if (['hey', 'cheer'].contains(getCurrentAnimation()) && !isAnimationFinished()) return;
     }
 
     // Prevent dancing while another animation is playing.
-    if (!force && getCurrentAnimation().startsWith('sing')) return;
+    if (!force && isSinging()) return;
 
     // Otherwise, fallback to the super dance() method, which handles playing the idle animation.
     super.dance();
diff --git a/source/funkin/play/notes/NoteHoldCover.hx b/source/funkin/play/notes/NoteHoldCover.hx
index b68de3946..a2041fb83 100644
--- a/source/funkin/play/notes/NoteHoldCover.hx
+++ b/source/funkin/play/notes/NoteHoldCover.hx
@@ -47,7 +47,7 @@ class NoteHoldCover extends FlxTypedSpriteGroup<FlxSprite>
 
     glow.animation.finishCallback = this.onAnimationFinished;
 
-    if (glow.animation.getAnimationList().length < 2)
+    if (glow.animation.getAnimationList().length < 3)
     {
       trace('WARNING: NoteHoldCover failed to initialize all animations.');
     }
@@ -56,35 +56,54 @@ class NoteHoldCover extends FlxTypedSpriteGroup<FlxSprite>
   public override function update(elapsed):Void
   {
     super.update(elapsed);
-    if (!holdNote.alive && !glow.animation.curAnim.name.startsWith('holdCoverEnd'))
+    if ((!holdNote.alive || holdNote.missedNote) && !glow.animation.curAnim.name.startsWith('holdCoverEnd'))
     {
-      this.visible = false;
-      this.kill();
-    }
-    else
-    {
-      this.visible = true;
+      // If alive is false, the hold note was held to completion.
+      // If missedNote is true, the hold note was "dropped".
+
+      playEnd();
     }
   }
 
   public function playStart():Void
   {
-    // glow.animation.play('holdCoverStart${noteDirection.colorName.toTitleCase()}');\
+    // glow.animation.play('holdCoverStart${noteDirection.colorName.toTitleCase()}');
     glow.animation.play('holdCoverStartRed');
   }
 
   public function playContinue():Void
   {
-    // glow.animation.play('holdCover${noteDirection.colorName.toTitleCase()}');\
+    // glow.animation.play('holdCover${noteDirection.colorName.toTitleCase()}');
     glow.animation.play('holdCoverRed');
   }
 
   public function playEnd():Void
   {
-    // glow.animation.play('holdCoverEnd${noteDirection.colorName.toTitleCase()}');\
+    // glow.animation.play('holdCoverEnd${noteDirection.colorName.toTitleCase()}');
     glow.animation.play('holdCoverEndRed');
   }
 
+  public override function kill():Void
+  {
+    super.kill();
+
+    this.visible = false;
+
+    if (glow != null) glow.visible = false;
+    if (sparks != null) sparks.visible = false;
+  }
+
+  public override function revive():Void
+  {
+    super.revive();
+
+    this.visible = true;
+    this.alpha = 1.0;
+
+    if (glow != null) glow.visible = true;
+    if (sparks != null) sparks.visible = true;
+  }
+
   public function onAnimationFinished(animationName:String):Void
   {
     if (animationName.startsWith('holdCoverStart'))
diff --git a/source/funkin/play/notes/NoteSprite.hx b/source/funkin/play/notes/NoteSprite.hx
index 8955f9d42..b407e7f74 100644
--- a/source/funkin/play/notes/NoteSprite.hx
+++ b/source/funkin/play/notes/NoteSprite.hx
@@ -127,7 +127,7 @@ class NoteSprite extends FlxSprite
 
     if (noteFrames != null && !force) return noteFrames;
 
-    noteFrames = Paths.getSparrowAtlas('NOTE_assets');
+    noteFrames = Paths.getSparrowAtlas('notes');
 
     noteFrames.parent.persist = true;
 
@@ -138,20 +138,10 @@ class NoteSprite extends FlxSprite
   {
     this.frames = buildNoteFrames();
 
-    animation.addByPrefix('greenScroll', 'green instance');
-    animation.addByPrefix('redScroll', 'red instance');
-    animation.addByPrefix('blueScroll', 'blue instance');
-    animation.addByPrefix('purpleScroll', 'purple instance');
-
-    animation.addByPrefix('purpleholdend', 'pruple end hold');
-    animation.addByPrefix('greenholdend', 'green hold end');
-    animation.addByPrefix('redholdend', 'red hold end');
-    animation.addByPrefix('blueholdend', 'blue hold end');
-
-    animation.addByPrefix('purplehold', 'purple hold piece');
-    animation.addByPrefix('greenhold', 'green hold piece');
-    animation.addByPrefix('redhold', 'red hold piece');
-    animation.addByPrefix('bluehold', 'blue hold piece');
+    animation.addByPrefix('greenScroll', 'noteUp');
+    animation.addByPrefix('redScroll', 'noteRight');
+    animation.addByPrefix('blueScroll', 'noteDown');
+    animation.addByPrefix('purpleScroll', 'noteLeft');
 
     setGraphicSize(Strumline.STRUMLINE_SIZE);
     updateHitbox();
diff --git a/source/funkin/play/notes/Strumline.hx b/source/funkin/play/notes/Strumline.hx
index 60df77e69..7730073f8 100644
--- a/source/funkin/play/notes/Strumline.hx
+++ b/source/funkin/play/notes/Strumline.hx
@@ -20,7 +20,7 @@ import funkin.util.SortUtil;
 class Strumline extends FlxSpriteGroup
 {
   public static final DIRECTIONS:Array<NoteDirection> = [NoteDirection.LEFT, NoteDirection.DOWN, NoteDirection.UP, NoteDirection.RIGHT];
-  public static final STRUMLINE_SIZE:Int = 112;
+  public static final STRUMLINE_SIZE:Int = 104;
   public static final NOTE_SPACING:Int = STRUMLINE_SIZE + 8;
 
   // Positional fixes for new strumline graphics.
@@ -84,6 +84,8 @@ class Strumline extends FlxSpriteGroup
     this.noteSplashes.zIndex = 50;
     this.add(this.noteSplashes);
 
+    this.refresh();
+
     for (i in 0...KEY_COUNT)
     {
       var child:StrumlineNote = new StrumlineNote(isPlayer, DIRECTIONS[i]);
@@ -102,6 +104,11 @@ class Strumline extends FlxSpriteGroup
     this.active = true;
   }
 
+  public function refresh():Void
+  {
+    sort(SortUtil.byZIndex, FlxSort.ASCENDING);
+  }
+
   override function get_width():Float
   {
     return KEY_COUNT * Strumline.NOTE_SPACING;
@@ -112,8 +119,25 @@ class Strumline extends FlxSpriteGroup
     super.update(elapsed);
 
     updateNotes();
+
+    #if debug
+    if (!isPlayer)
+    {
+      FlxG.watch.addQuick("strumlineAnim", strumlineNotes.members[3]?.animation?.curAnim?.name);
+      var curFrame = strumlineNotes.members[3]?.animation?.curAnim?.curFrame;
+      frameMax = (curFrame > frameMax) ? curFrame : frameMax;
+      FlxG.watch.addQuick("strumlineFrame", strumlineNotes.members[3]?.animation?.curAnim?.curFrame);
+      FlxG.watch.addQuick("strumlineFrameMax", frameMax);
+      animFinishedEver = animFinishedEver || strumlineNotes.members[3]?.animation?.curAnim?.finished;
+      FlxG.watch.addQuick("strumlineFinished", strumlineNotes.members[3]?.animation?.curAnim?.finished);
+      FlxG.watch.addQuick("strumlineFinishedEver", animFinishedEver);
+    }
+    #end
   }
 
+  var frameMax:Int;
+  var animFinishedEver:Bool;
+
   /**
    * Get a list of notes within + or - the given strumtime.
    * @param strumTime The current time.
@@ -253,7 +277,8 @@ class Strumline extends FlxSpriteGroup
           // Stopped pressing the hold note.
           playStatic(holdNote.noteDirection);
           holdNote.missedNote = true;
-          holdNote.alpha = 0.6;
+          holdNote.visible = true;
+          holdNote.alpha = 0.0;
         }
       }
 
@@ -347,6 +372,15 @@ class Strumline extends FlxSpriteGroup
         }
       }
     }
+
+    // Update rendering of pressed keys.
+    for (dir in DIRECTIONS)
+    {
+      if (isKeyHeld(dir) && getByDirection(dir).getCurrentAnimation() == "static")
+      {
+        playPress(dir);
+      }
+    }
   }
 
   public function onBeatHit():Void
@@ -405,7 +439,7 @@ class Strumline extends FlxSpriteGroup
     if (note.holdNoteSprite != null)
     {
       note.holdNoteSprite.missedNote = true;
-      note.holdNoteSprite.alpha = 0.6;
+      note.holdNoteSprite.visible = false;
     }
   }
 
@@ -483,12 +517,12 @@ class Strumline extends FlxSpriteGroup
       cover.x += getXPos(holdNote.noteDirection);
       cover.x += STRUMLINE_SIZE / 2;
       cover.x -= cover.width / 2;
-      // cover.x += INITIAL_OFFSET * 2;
+      cover.x += -12; // Manual tweaking because fuck.
+
       cover.y = this.y;
       cover.y += INITIAL_OFFSET;
       cover.y += STRUMLINE_SIZE / 2;
-      // cover.y -= cover.height / 2;
-      // cover.y += STRUMLINE_SIZE / 2;
+      cover.y += -96; // Manual tweaking because fuck.
     }
   }
 
@@ -525,7 +559,7 @@ class Strumline extends FlxSpriteGroup
       holdNoteSprite.sustainLength = note.length;
       holdNoteSprite.missedNote = false;
       holdNoteSprite.hitNote = false;
-
+      holdNoteSprite.visible = true;
       holdNoteSprite.alpha = 1.0;
 
       holdNoteSprite.x = this.x;
diff --git a/source/funkin/play/notes/StrumlineNote.hx b/source/funkin/play/notes/StrumlineNote.hx
index 2f2b41374..6361f607e 100644
--- a/source/funkin/play/notes/StrumlineNote.hx
+++ b/source/funkin/play/notes/StrumlineNote.hx
@@ -13,6 +13,10 @@ class StrumlineNote extends FlxSprite
 
   public var direction(default, set):NoteDirection;
 
+  var confirmHoldTimer:Float = -1;
+
+  static final CONFIRM_HOLD_TIME:Float = 0.1;
+
   public function updatePosition(parentNote:NoteSprite)
   {
     this.x = parentNote.x;
@@ -49,9 +53,12 @@ class StrumlineNote extends FlxSprite
 
   function onAnimationFinished(name:String):Void
   {
-    if (!isPlayer && name.startsWith('confirm'))
+    // Run a timer before we stop playing the confirm animation.
+    // On opponent, this prevent issues with hold notes.
+    // On player, this allows holding the confirm key to fall back to press.
+    if (name == 'confirm')
     {
-      playStatic();
+      confirmHoldTimer = 0;
     }
   }
 
@@ -60,37 +67,49 @@ class StrumlineNote extends FlxSprite
     super.update(elapsed);
 
     centerOrigin();
+
+    if (confirmHoldTimer >= 0)
+    {
+      confirmHoldTimer += elapsed;
+
+      // Ensure the opponent stops holding the key after a certain amount of time.
+      if (confirmHoldTimer >= CONFIRM_HOLD_TIME)
+      {
+        confirmHoldTimer = -1;
+        playStatic();
+      }
+    }
   }
 
   function setup():Void
   {
-    this.frames = Paths.getSparrowAtlas('StrumlineNotes');
+    this.frames = Paths.getSparrowAtlas('noteStrumline');
 
     switch (this.direction)
     {
       case NoteDirection.LEFT:
-        this.animation.addByIndices('static', 'left confirm', [6, 7], '', 24, false, false, false);
-        this.animation.addByPrefix('press', 'left press', 24, false, false, false);
-        this.animation.addByIndices('confirm', 'left confirm', [0, 1, 2, 3], '', 24, false, false, false);
-        this.animation.addByIndices('confirm-hold', 'left confirm', [2, 3, 4, 5], '', 24, true, false, false);
+        this.animation.addByPrefix('static', 'staticLeft0', 24, false, false, false);
+        this.animation.addByPrefix('press', 'pressLeft0', 24, false, false, false);
+        this.animation.addByPrefix('confirm', 'confirmLeft0', 24, false, false, false);
+        this.animation.addByPrefix('confirm-hold', 'confirmHoldLeft0', 24, true, false, false);
 
       case NoteDirection.DOWN:
-        this.animation.addByIndices('static', 'down confirm', [6, 7], '', 24, false, false, false);
-        this.animation.addByPrefix('press', 'down press', 24, false, false, false);
-        this.animation.addByIndices('confirm', 'down confirm', [0, 1, 2, 3], '', 24, false, false, false);
-        this.animation.addByIndices('confirm-hold', 'down confirm', [2, 3, 4, 5], '', 24, true, false, false);
+        this.animation.addByPrefix('static', 'staticDown0', 24, false, false, false);
+        this.animation.addByPrefix('press', 'pressDown0', 24, false, false, false);
+        this.animation.addByPrefix('confirm', 'confirmDown0', 24, false, false, false);
+        this.animation.addByPrefix('confirm-hold', 'confirmHoldDown0', 24, true, false, false);
 
       case NoteDirection.UP:
-        this.animation.addByIndices('static', 'up confirm', [6, 7], '', 24, false, false, false);
-        this.animation.addByPrefix('press', 'up press', 24, false, false, false);
-        this.animation.addByIndices('confirm', 'up confirm', [0, 1, 2, 3], '', 24, false, false, false);
-        this.animation.addByIndices('confirm-hold', 'up confirm', [2, 3, 4, 5], '', 24, true, false, false);
+        this.animation.addByPrefix('static', 'staticUp0', 24, false, false, false);
+        this.animation.addByPrefix('press', 'pressUp0', 24, false, false, false);
+        this.animation.addByPrefix('confirm', 'confirmUp0', 24, false, false, false);
+        this.animation.addByPrefix('confirm-hold', 'confirmHoldUp0', 24, true, false, false);
 
       case NoteDirection.RIGHT:
-        this.animation.addByIndices('static', 'right confirm', [6, 7], '', 24, false, false, false);
-        this.animation.addByPrefix('press', 'right press', 24, false, false, false);
-        this.animation.addByIndices('confirm', 'right confirm', [0, 1, 2, 3], '', 24, false, false, false);
-        this.animation.addByIndices('confirm-hold', 'right confirm', [2, 3, 4, 5], '', 24, true, false, false);
+        this.animation.addByPrefix('static', 'staticRight0', 24, false, false, false);
+        this.animation.addByPrefix('press', 'pressRight0', 24, false, false, false);
+        this.animation.addByPrefix('confirm', 'confirmRight0', 24, false, false, false);
+        this.animation.addByPrefix('confirm-hold', 'confirmHoldRight0', 24, true, false, false);
     }
 
     this.setGraphicSize(Std.int(Strumline.STRUMLINE_SIZE * 1.55));
@@ -133,16 +152,22 @@ class StrumlineNote extends FlxSprite
   {
     this.active = true;
 
-    if (getCurrentAnimation() == "confirm-hold") return;
-    if (getCurrentAnimation() == "confirm")
+    if (getCurrentAnimation() == "confirm-hold")
+    {
+      return;
+    }
+    else if (getCurrentAnimation() == "confirm")
     {
       if (isAnimationFinished())
       {
-        this.playAnimation('confirm-hold', true, false);
+        this.confirmHoldTimer = -1;
+        this.playAnimation('confirm-hold', false, false);
       }
-      return;
     }
-    this.playAnimation('confirm', false, false);
+    else
+    {
+      this.playAnimation('confirm', false, false);
+    }
   }
 
   /**
diff --git a/source/funkin/util/Constants.hx b/source/funkin/util/Constants.hx
index c6a6d0265..bc280f176 100644
--- a/source/funkin/util/Constants.hx
+++ b/source/funkin/util/Constants.hx
@@ -199,7 +199,7 @@ class Constants
    * If true, the player will not receive the ghost miss penalty if there are no notes within the hit window.
    * This is the thing people have been begging for forever lolol.
    */
-  public static final GHOST_TAPPING:Bool = true;
+  public static final GHOST_TAPPING:Bool = false;
 
   /**
    * OTHER

From 17a8610640c2c418834229c6392e9b0cbe82ca90 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Mon, 10 Jul 2023 18:14:34 -0400
Subject: [PATCH 16/30] Added colors to hold note covers

---
 source/funkin/play/notes/NoteHoldCover.hx | 47 ++++++++++++++++-------
 1 file changed, 34 insertions(+), 13 deletions(-)

diff --git a/source/funkin/play/notes/NoteHoldCover.hx b/source/funkin/play/notes/NoteHoldCover.hx
index a2041fb83..52ae97d4f 100644
--- a/source/funkin/play/notes/NoteHoldCover.hx
+++ b/source/funkin/play/notes/NoteHoldCover.hx
@@ -3,6 +3,7 @@ package funkin.play.notes;
 import flixel.group.FlxSpriteGroup.FlxTypedSpriteGroup;
 import funkin.play.notes.NoteDirection;
 import flixel.graphics.frames.FlxFramesCollection;
+import funkin.util.assets.FlxAnimationUtil;
 import flixel.FlxG;
 import flixel.graphics.frames.FlxAtlasFrames;
 import flixel.FlxSprite;
@@ -11,7 +12,7 @@ class NoteHoldCover extends FlxTypedSpriteGroup<FlxSprite>
 {
   static final FRAMERATE_DEFAULT:Int = 24;
 
-  static var glowFrames:FlxAtlasFrames;
+  static var glowFrames:FlxFramesCollection;
 
   public var holdNote:SustainTrail;
 
@@ -27,8 +28,23 @@ class NoteHoldCover extends FlxTypedSpriteGroup<FlxSprite>
 
   public static function preloadFrames():Void
   {
-    glowFrames = Paths.getSparrowAtlas('holdCoverRed');
-    glowFrames.parent.persist = true;
+    glowFrames = null;
+    for (direction in Strumline.DIRECTIONS)
+    {
+      var directionName = direction.colorName.toTitleCase();
+
+      var atlas:FlxFramesCollection = Paths.getSparrowAtlas('holdCover${directionName}');
+      atlas.parent.persist = true;
+
+      if (glowFrames != null)
+      {
+        glowFrames = FlxAnimationUtil.combineFramesCollections(glowFrames, atlas);
+      }
+      else
+      {
+        glowFrames = atlas;
+      }
+    }
   }
 
   /**
@@ -41,13 +57,18 @@ class NoteHoldCover extends FlxTypedSpriteGroup<FlxSprite>
     if (glowFrames == null) preloadFrames();
     glow.frames = glowFrames;
 
-    glow.animation.addByPrefix('holdCoverStartRed', 'holdCoverStartRed0', FRAMERATE_DEFAULT, false, false, false);
-    glow.animation.addByPrefix('holdCoverRed', 'holdCoverRed0', FRAMERATE_DEFAULT, true, false, false);
-    glow.animation.addByPrefix('holdCoverEndRed', 'holdCoverEndRed0', FRAMERATE_DEFAULT, false, false, false);
+    for (direction in Strumline.DIRECTIONS)
+    {
+      var directionName = direction.colorName.toTitleCase();
+
+      glow.animation.addByPrefix('holdCoverStart$directionName', 'holdCoverStart${directionName}0', FRAMERATE_DEFAULT, false, false, false);
+      glow.animation.addByPrefix('holdCover$directionName', 'holdCover${directionName}0', FRAMERATE_DEFAULT, true, false, false);
+      glow.animation.addByPrefix('holdCoverEnd$directionName', 'holdCoverEnd${directionName}0', FRAMERATE_DEFAULT, false, false, false);
+    }
 
     glow.animation.finishCallback = this.onAnimationFinished;
 
-    if (glow.animation.getAnimationList().length < 3)
+    if (glow.animation.getAnimationList().length < 3 * 4)
     {
       trace('WARNING: NoteHoldCover failed to initialize all animations.');
     }
@@ -67,20 +88,20 @@ class NoteHoldCover extends FlxTypedSpriteGroup<FlxSprite>
 
   public function playStart():Void
   {
-    // glow.animation.play('holdCoverStart${noteDirection.colorName.toTitleCase()}');
-    glow.animation.play('holdCoverStartRed');
+    var direction:NoteDirection = holdNote.noteDirection;
+    glow.animation.play('holdCoverStart${direction.colorName.toTitleCase()}');
   }
 
   public function playContinue():Void
   {
-    // glow.animation.play('holdCover${noteDirection.colorName.toTitleCase()}');
-    glow.animation.play('holdCoverRed');
+    var direction:NoteDirection = holdNote.noteDirection;
+    glow.animation.play('holdCover${direction.colorName.toTitleCase()}');
   }
 
   public function playEnd():Void
   {
-    // glow.animation.play('holdCoverEnd${noteDirection.colorName.toTitleCase()}');
-    glow.animation.play('holdCoverEndRed');
+    var direction:NoteDirection = holdNote.noteDirection;
+    glow.animation.play('holdCoverEnd${direction.colorName.toTitleCase()}');
   }
 
   public override function kill():Void

From 364753286f73dcbdc5db7f2365ac4f90675cafbd Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Mon, 10 Jul 2023 20:10:17 -0400
Subject: [PATCH 17/30] Attempt at fixing custom Lime in build

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

diff --git a/.github/workflows/build-shit.yml b/.github/workflows/build-shit.yml
index 35d436b2c..ac5ce00f0 100644
--- a/.github/workflows/build-shit.yml
+++ b/.github/workflows/build-shit.yml
@@ -45,6 +45,16 @@ jobs:
     steps:
       - uses: actions/checkout@v3
       - uses: ./.github/actions/setup-haxeshit
+      - name: Build Lime
+        # TODO: Remove the step that builds Lime later.
+        run: |
+          LIME_PATH=$(haxelib libpath lime)
+          echo "Moving to $LIME_PATH"
+          cd $LIME_PATH
+          git submodule sync --recursive
+          git submodule update --recursive
+          git status
+          lime rebuild windows --clean
       - name: Build game
         run: |
           haxelib run lime build windows -debug

From 6c9ec918afdcda075db406b20cf5b2533a8d50d2 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Mon, 10 Jul 2023 20:49:28 -0400
Subject: [PATCH 18/30] Attempt 2, with powershell

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

diff --git a/.github/workflows/build-shit.yml b/.github/workflows/build-shit.yml
index ac5ce00f0..77b8e9895 100644
--- a/.github/workflows/build-shit.yml
+++ b/.github/workflows/build-shit.yml
@@ -47,8 +47,9 @@ jobs:
       - uses: ./.github/actions/setup-haxeshit
       - name: Build Lime
         # TODO: Remove the step that builds Lime later.
+        # Powershell method
         run: |
-          LIME_PATH=$(haxelib libpath lime)
+          $LIME_PATH = &"haxelib libpath lime"
           echo "Moving to $LIME_PATH"
           cd $LIME_PATH
           git submodule sync --recursive

From b0b8b4fba0d26efa80ba031b27461b5bfa72de4e Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Mon, 10 Jul 2023 23:23:18 -0400
Subject: [PATCH 19/30] Shut up StickerState

---
 source/funkin/ui/StickerSubState.hx | 49 -----------------------------
 1 file changed, 49 deletions(-)

diff --git a/source/funkin/ui/StickerSubState.hx b/source/funkin/ui/StickerSubState.hx
index e9d528773..93eabdf14 100644
--- a/source/funkin/ui/StickerSubState.hx
+++ b/source/funkin/ui/StickerSubState.hx
@@ -44,7 +44,6 @@ class StickerSubState extends MusicBeatSubState
       for (sticker in oldStickers)
       {
         grpStickers.add(sticker);
-        trace(sticker);
       }
 
       degenStickers();
@@ -89,31 +88,6 @@ class StickerSubState extends MusicBeatSubState
     for (stickerSets in stickerInfo.getPack("all"))
     {
       stickers.set(stickerSets, stickerInfo.getStickers(stickerSets));
-
-      trace(stickers);
-
-      // for (stickerShit in stickerInfo.getStickers(stickerSets))
-      // {
-      //   // for loop jus to repeat it easy easy easy
-      //   for (i in 0...FlxG.random.int(1, 5))
-      //   {
-      //     var sticky:StickerSprite = new StickerSprite(0, 0, stickerInfo.name, stickerShit);
-      //     sticky.x -= sticky.width / 2;
-      //     sticky.y -= sticky.height * 0.9;
-
-      //     // random location by default
-      //     sticky.x += FlxG.random.float(0, FlxG.width);
-      //     sticky.y += FlxG.random.float(0, FlxG.height);
-
-      //     sticky.visible = false;
-      //     sticky.scrollFactor.set();
-      //     sticky.angle = FlxG.random.int(-60, 70);
-      //     // sticky.flipX = FlxG.random.bool();
-      //     grpStickers.add(sticky);
-
-      //     sticky.timing = FlxG.random.float(0, 0.8);
-      //   }
-      // }
     }
 
     var xPos:Float = -100;
@@ -281,7 +255,6 @@ class StickerInfo
   {
     var path = Paths.file('images/transitionSwag/' + stickerSet + '/stickers.json');
     var json = Json.parse(Assets.getText(path));
-    trace(json);
 
     // doin this dipshit nonsense cuz i dunno how to deal with casting a json object with
     // a dash in its name (sticker-packs)
@@ -298,13 +271,8 @@ class StickerInfo
       var stickerStuff = Reflect.field(stickerFunny, field);
 
       stickerPacks.set(field, cast stickerStuff);
-
-      trace(field);
-      trace(Reflect.field(stickerFunny, field));
     }
 
-    trace(stickerPacks);
-
     // creates a similar for loop as before but for the stickers
     stickers = new Map<String, Array<String>>();
 
@@ -314,24 +282,7 @@ class StickerInfo
       var stickerStuff = Reflect.field(stickerFunny, field);
 
       stickers.set(field, cast stickerStuff);
-
-      trace(field);
-      trace(Reflect.field(stickerFunny, field));
     }
-
-    trace(stickers);
-
-    // this.stickerPacks = cast jsonInfo.stickerPacks;
-    // this.stickers = cast jsonInfo.stickers;
-
-    // trace(stickerPacks);
-    // trace(stickers);
-
-    // for (packs in stickers)
-    // {
-    //   // this.stickers.set(packs, Reflect.field(json, "sticker-packs"));
-    //   trace(packs);
-    // }
   }
 
   public function getStickers(stickerName:String):Array<String>

From 736eecfd9a78846cc5b2750813b622e1e5c3853f Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Thu, 13 Jul 2023 00:37:54 -0400
Subject: [PATCH 20/30] Fixed some random crash bug?

---
 source/funkin/ui/StickerSubState.hx | 2 ++
 1 file changed, 2 insertions(+)

diff --git a/source/funkin/ui/StickerSubState.hx b/source/funkin/ui/StickerSubState.hx
index 93eabdf14..067f50c31 100644
--- a/source/funkin/ui/StickerSubState.hx
+++ b/source/funkin/ui/StickerSubState.hx
@@ -159,6 +159,8 @@ class StickerSubState extends MusicBeatSubState
         if (ind == grpStickers.members.length - 1) frameTimer = 2;
 
         new FlxTimer().start((1 / 24) * frameTimer, _ -> {
+          if (sticker == null) return;
+
           sticker.scale.x = sticker.scale.y = FlxG.random.float(0.97, 1.02);
 
           if (ind == grpStickers.members.length - 1)

From 70584c6e88e98549efaa1e7a0bff7b969a8cefc3 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Thu, 13 Jul 2023 01:13:26 -0400
Subject: [PATCH 21/30] Make funkin.util.Constants auto-imported

---
 source/funkin/DialogueBox.hx                                 | 1 -
 source/funkin/MainMenuState.hx                               | 1 -
 source/funkin/TitleState.hx                                  | 1 -
 source/funkin/import.hx                                      | 1 +
 source/funkin/play/Countdown.hx                              | 1 -
 source/funkin/play/PlayState.hx                              | 1 -
 source/funkin/play/PlayStatePlaylist.hx                      | 4 +---
 .../funkin/play/cutscene/dialogue/ConversationDebugState.hx  | 1 -
 source/funkin/play/event/SetCameraBopSongEvent.hx            | 5 ++---
 source/funkin/play/notes/NoteDirection.hx                    | 1 -
 source/funkin/play/song/SongValidator.hx                     | 1 -
 source/funkin/ui/OptionsState.hx                             | 1 -
 source/funkin/ui/PopUpStuff.hx                               | 1 -
 source/funkin/ui/debug/charting/ChartEditorState.hx          | 1 -
 source/funkin/ui/story/StoryMenuState.hx                     | 1 -
 source/funkin/util/Constants.hx                              | 1 +
 16 files changed, 5 insertions(+), 18 deletions(-)

diff --git a/source/funkin/DialogueBox.hx b/source/funkin/DialogueBox.hx
index 4258f71ce..342fcba10 100644
--- a/source/funkin/DialogueBox.hx
+++ b/source/funkin/DialogueBox.hx
@@ -1,6 +1,5 @@
 package funkin;
 
-import funkin.util.Constants;
 import flixel.FlxSprite;
 import flixel.addons.text.FlxTypeText;
 import flixel.group.FlxSpriteGroup;
diff --git a/source/funkin/MainMenuState.hx b/source/funkin/MainMenuState.hx
index 348bf8d17..020a121c0 100644
--- a/source/funkin/MainMenuState.hx
+++ b/source/funkin/MainMenuState.hx
@@ -26,7 +26,6 @@ import funkin.ui.story.StoryMenuState;
 import funkin.ui.OptionsState;
 import funkin.ui.PreferencesMenu;
 import funkin.ui.Prompt;
-import funkin.util.Constants;
 import funkin.util.WindowUtil;
 import lime.app.Application;
 import openfl.filters.ShaderFilter;
diff --git a/source/funkin/TitleState.hx b/source/funkin/TitleState.hx
index 59845ed40..30e8d67a5 100644
--- a/source/funkin/TitleState.hx
+++ b/source/funkin/TitleState.hx
@@ -14,7 +14,6 @@ import funkin.shaderslmfao.ColorSwap;
 import funkin.shaderslmfao.LeftMaskShader;
 import funkin.shaderslmfao.TitleOutline;
 import funkin.ui.AtlasText;
-import funkin.util.Constants;
 import openfl.Assets;
 import openfl.display.Sprite;
 import openfl.events.AsyncErrorEvent;
diff --git a/source/funkin/import.hx b/source/funkin/import.hx
index 9aa99fade..4ba062b8f 100644
--- a/source/funkin/import.hx
+++ b/source/funkin/import.hx
@@ -2,6 +2,7 @@ package;
 
 #if !macro
 // Only import these when we aren't in a macro.
+import funkin.util.Constants;
 import funkin.Paths;
 import flixel.FlxG; // This one in particular causes a compile error if you're using macros.
 
diff --git a/source/funkin/play/Countdown.hx b/source/funkin/play/Countdown.hx
index 169bda24b..51d72693e 100644
--- a/source/funkin/play/Countdown.hx
+++ b/source/funkin/play/Countdown.hx
@@ -1,6 +1,5 @@
 package funkin.play;
 
-import funkin.util.Constants;
 import flixel.tweens.FlxEase;
 import flixel.tweens.FlxTween;
 import flixel.FlxSprite;
diff --git a/source/funkin/play/PlayState.hx b/source/funkin/play/PlayState.hx
index 7d5dc48b9..f651f5ac6 100644
--- a/source/funkin/play/PlayState.hx
+++ b/source/funkin/play/PlayState.hx
@@ -47,7 +47,6 @@ import funkin.ui.PopUpStuff;
 import funkin.ui.PreferencesMenu;
 import funkin.ui.stageBuildShit.StageOffsetSubState;
 import funkin.ui.story.StoryMenuState;
-import funkin.util.Constants;
 import funkin.util.SerializerUtil;
 import funkin.util.SortUtil;
 import lime.ui.Haptic;
diff --git a/source/funkin/play/PlayStatePlaylist.hx b/source/funkin/play/PlayStatePlaylist.hx
index acfd26752..6b754878c 100644
--- a/source/funkin/play/PlayStatePlaylist.hx
+++ b/source/funkin/play/PlayStatePlaylist.hx
@@ -1,10 +1,8 @@
 package funkin.play;
 
-import funkin.util.Constants;
-
 /**
  * Manages playback of multiple songs in a row.
- * 
+ *
  * TODO: Add getters/setters for all these properties to validate them.
  */
 class PlayStatePlaylist
diff --git a/source/funkin/play/cutscene/dialogue/ConversationDebugState.hx b/source/funkin/play/cutscene/dialogue/ConversationDebugState.hx
index 5f2b98f8b..4d7f74a58 100644
--- a/source/funkin/play/cutscene/dialogue/ConversationDebugState.hx
+++ b/source/funkin/play/cutscene/dialogue/ConversationDebugState.hx
@@ -4,7 +4,6 @@ import flixel.FlxState;
 import funkin.modding.events.ScriptEventDispatcher;
 import funkin.modding.events.ScriptEvent;
 import flixel.util.FlxColor;
-import funkin.Paths;
 
 /**
  * A state with displays a conversation with no background.
diff --git a/source/funkin/play/event/SetCameraBopSongEvent.hx b/source/funkin/play/event/SetCameraBopSongEvent.hx
index 6f8a0645d..b17d4511c 100644
--- a/source/funkin/play/event/SetCameraBopSongEvent.hx
+++ b/source/funkin/play/event/SetCameraBopSongEvent.hx
@@ -1,6 +1,5 @@
 package funkin.play.event;
 
-import funkin.util.Constants;
 import flixel.tweens.FlxTween;
 import flixel.FlxCamera;
 import flixel.tweens.FlxEase;
@@ -11,7 +10,7 @@ import funkin.play.event.SongEventData.SongEventFieldType;
 
 /**
  * This class represents a handler for configuring camera bop intensity and rate.
- * 
+ *
  * Example: Bop the camera twice as hard, once per beat (rather than once every four beats).
  * ```
  * {
@@ -22,7 +21,7 @@ import funkin.play.event.SongEventData.SongEventFieldType;
  *   }
  * }
  * ```
- * 
+ *
  * Example: Reset the camera bop to default values.
  * ```
  * {
diff --git a/source/funkin/play/notes/NoteDirection.hx b/source/funkin/play/notes/NoteDirection.hx
index 8a0fb5ecc..c937916f1 100644
--- a/source/funkin/play/notes/NoteDirection.hx
+++ b/source/funkin/play/notes/NoteDirection.hx
@@ -1,6 +1,5 @@
 package funkin.play.notes;
 
-import funkin.util.Constants;
 import flixel.util.FlxColor;
 
 /**
diff --git a/source/funkin/play/song/SongValidator.hx b/source/funkin/play/song/SongValidator.hx
index d393c11eb..936ad46f7 100644
--- a/source/funkin/play/song/SongValidator.hx
+++ b/source/funkin/play/song/SongValidator.hx
@@ -5,7 +5,6 @@ import funkin.play.song.SongData.SongMetadata;
 import funkin.play.song.SongData.SongPlayData;
 import funkin.play.song.SongData.SongTimeChange;
 import funkin.play.song.SongData.SongTimeFormat;
-import funkin.util.Constants;
 
 /**
  * For SongMetadata and SongChartData objects,
diff --git a/source/funkin/ui/OptionsState.hx b/source/funkin/ui/OptionsState.hx
index 291dafdf1..6c32c7f4c 100644
--- a/source/funkin/ui/OptionsState.hx
+++ b/source/funkin/ui/OptionsState.hx
@@ -5,7 +5,6 @@ import flixel.FlxSubState;
 import flixel.addons.transition.FlxTransitionableState;
 import flixel.group.FlxGroup;
 import flixel.util.FlxSignal;
-import funkin.util.Constants;
 import funkin.util.WindowUtil;
 
 class OptionsState extends MusicBeatState
diff --git a/source/funkin/ui/PopUpStuff.hx b/source/funkin/ui/PopUpStuff.hx
index 3e848b9e6..75fc87c8b 100644
--- a/source/funkin/ui/PopUpStuff.hx
+++ b/source/funkin/ui/PopUpStuff.hx
@@ -4,7 +4,6 @@ import flixel.FlxSprite;
 import flixel.group.FlxGroup.FlxTypedGroup;
 import flixel.tweens.FlxTween;
 import funkin.play.PlayState;
-import funkin.util.Constants;
 
 class PopUpStuff extends FlxTypedGroup<FlxSprite>
 {
diff --git a/source/funkin/ui/debug/charting/ChartEditorState.hx b/source/funkin/ui/debug/charting/ChartEditorState.hx
index 566e75706..aad43f93f 100644
--- a/source/funkin/ui/debug/charting/ChartEditorState.hx
+++ b/source/funkin/ui/debug/charting/ChartEditorState.hx
@@ -34,7 +34,6 @@ import funkin.ui.debug.charting.ChartEditorThemeHandler.ChartEditorTheme;
 import funkin.ui.debug.charting.ChartEditorToolboxHandler.ChartEditorToolMode;
 import funkin.ui.haxeui.components.CharacterPlayer;
 import funkin.ui.haxeui.HaxeUIState;
-import funkin.util.Constants;
 import funkin.util.FileUtil;
 import funkin.util.DateUtil;
 import funkin.util.SerializerUtil;
diff --git a/source/funkin/ui/story/StoryMenuState.hx b/source/funkin/ui/story/StoryMenuState.hx
index 37aa94d23..8badbbded 100644
--- a/source/funkin/ui/story/StoryMenuState.hx
+++ b/source/funkin/ui/story/StoryMenuState.hx
@@ -16,7 +16,6 @@ import funkin.play.PlayState;
 import funkin.play.PlayStatePlaylist;
 import funkin.play.song.Song;
 import funkin.play.song.SongData.SongDataParser;
-import funkin.util.Constants;
 
 class StoryMenuState extends MusicBeatState
 {
diff --git a/source/funkin/util/Constants.hx b/source/funkin/util/Constants.hx
index bc280f176..0278d59b9 100644
--- a/source/funkin/util/Constants.hx
+++ b/source/funkin/util/Constants.hx
@@ -205,6 +205,7 @@ class Constants
    * OTHER
    */
   // ==============================
+  public static final LIBRARY_SEPARATOR:String = ':';
 
   /**
    * All MP3 decoders introduce a playback delay of `528` samples,

From 883ab879318e52e903c323c080a59483e945c393 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Thu, 13 Jul 2023 20:25:01 -0400
Subject: [PATCH 22/30] Moved AnimationData into the data package

---
 .../{play => data/animation}/AnimationData.hx | 48 ++++++++++++++++++-
 source/funkin/data/level/LevelData.hx         |  2 +-
 source/funkin/play/character/CharacterData.hx |  1 +
 .../play/cutscene/dialogue/DialogueBoxData.hx |  1 +
 .../play/cutscene/dialogue/SpeakerData.hx     |  2 +
 source/funkin/play/stage/StageData.hx         |  1 +
 source/funkin/util/assets/FlxAnimationUtil.hx |  6 +--
 7 files changed, 56 insertions(+), 5 deletions(-)
 rename source/funkin/{play => data/animation}/AnimationData.hx (60%)

diff --git a/source/funkin/play/AnimationData.hx b/source/funkin/data/animation/AnimationData.hx
similarity index 60%
rename from source/funkin/play/AnimationData.hx
rename to source/funkin/data/animation/AnimationData.hx
index e512bb757..2116109db 100644
--- a/source/funkin/play/AnimationData.hx
+++ b/source/funkin/data/animation/AnimationData.hx
@@ -1,14 +1,60 @@
-package funkin.play;
+package funkin.data.animation;
 
+class AnimationDataUtil
+{
+  public static function toNamed(data:UnnamedAnimationData, ?name:String = ""):AnimationData
+  {
+    return {
+      name: name,
+      prefix: data.prefix,
+      assetPath: data.assetPath,
+      offsets: data.offsets,
+      looped: data.looped,
+      flipX: data.flipX,
+      flipY: data.flipY,
+      frameRate: data.frameRate,
+      frameIndices: data.frameIndices
+    };
+  }
+
+  public static function toUnnamed(data:AnimationData):UnnamedAnimationData
+  {
+    return {
+      prefix: data.prefix,
+      assetPath: data.assetPath,
+      offsets: data.offsets,
+      looped: data.looped,
+      flipX: data.flipX,
+      flipY: data.flipY,
+      frameRate: data.frameRate,
+      frameIndices: data.frameIndices
+    };
+  }
+}
+
+/**
+ * A data structure representing an animation in a spritesheet.
+ * This is a generic data structure used by characters, stage props, and more!
+ * BE CAREFUL when changing it.
+ */
 typedef AnimationData =
 {
+  > UnnamedAnimationData,
+
   /**
    * The name for the animation.
    * This should match the animation name queried by the game;
    * for example, characters need animations with names `idle`, `singDOWN`, `singUPmiss`, etc.
    */
   var name:String;
+}
 
+/**
+ * A data structure representing an animation in a spritesheet.
+ * This animation doesn't specify a name, that's presumably specified by the parent data structure.
+ */
+typedef UnnamedAnimationData =
+{
   /**
    * The prefix for the frames of the animation as defined by the XML file.
    * This will may or may not differ from the `name` of the animation,
diff --git a/source/funkin/data/level/LevelData.hx b/source/funkin/data/level/LevelData.hx
index 0342c3d39..0ba26354a 100644
--- a/source/funkin/data/level/LevelData.hx
+++ b/source/funkin/data/level/LevelData.hx
@@ -1,6 +1,6 @@
 package funkin.data.level;
 
-import funkin.play.AnimationData;
+import funkin.data.animation.AnimationData;
 
 /**
  * A type definition for the data in a story mode level JSON file.
diff --git a/source/funkin/play/character/CharacterData.hx b/source/funkin/play/character/CharacterData.hx
index 16bf3a8a6..18a537a40 100644
--- a/source/funkin/play/character/CharacterData.hx
+++ b/source/funkin/play/character/CharacterData.hx
@@ -1,5 +1,6 @@
 package funkin.play.character;
 
+import funkin.data.animation.AnimationData;
 import funkin.modding.events.ScriptEvent;
 import funkin.modding.events.ScriptEventDispatcher;
 import funkin.play.character.ScriptedCharacter.ScriptedAnimateAtlasCharacter;
diff --git a/source/funkin/play/cutscene/dialogue/DialogueBoxData.hx b/source/funkin/play/cutscene/dialogue/DialogueBoxData.hx
index 2ae79f8d8..537a27129 100644
--- a/source/funkin/play/cutscene/dialogue/DialogueBoxData.hx
+++ b/source/funkin/play/cutscene/dialogue/DialogueBoxData.hx
@@ -1,5 +1,6 @@
 package funkin.play.cutscene.dialogue;
 
+import funkin.data.animation.AnimationData;
 import funkin.util.SerializerUtil;
 
 /**
diff --git a/source/funkin/play/cutscene/dialogue/SpeakerData.hx b/source/funkin/play/cutscene/dialogue/SpeakerData.hx
index 44e13b025..a0f9a3300 100644
--- a/source/funkin/play/cutscene/dialogue/SpeakerData.hx
+++ b/source/funkin/play/cutscene/dialogue/SpeakerData.hx
@@ -1,5 +1,7 @@
 package funkin.play.cutscene.dialogue;
 
+import funkin.data.animation.AnimationData;
+
 /**
  * Data about a conversation.
  * Includes what speakers are in the conversation, and what phrases they say.
diff --git a/source/funkin/play/stage/StageData.hx b/source/funkin/play/stage/StageData.hx
index 6164c3cde..de8804cfb 100644
--- a/source/funkin/play/stage/StageData.hx
+++ b/source/funkin/play/stage/StageData.hx
@@ -1,5 +1,6 @@
 package funkin.play.stage;
 
+import funkin.data.animation.AnimationData;
 import flixel.util.typeLimit.OneOfTwo;
 import funkin.play.stage.ScriptedStage;
 import funkin.play.stage.Stage;
diff --git a/source/funkin/util/assets/FlxAnimationUtil.hx b/source/funkin/util/assets/FlxAnimationUtil.hx
index bab1e090b..0e32a1918 100644
--- a/source/funkin/util/assets/FlxAnimationUtil.hx
+++ b/source/funkin/util/assets/FlxAnimationUtil.hx
@@ -2,12 +2,12 @@ package funkin.util.assets;
 
 import flixel.FlxSprite;
 import flixel.graphics.frames.FlxFramesCollection;
-import funkin.play.AnimationData;
+import funkin.data.animation.AnimationData;
 
 class FlxAnimationUtil
 {
   /**
-   * Properly adds an animation to a sprite based on JSON data.
+   * Properly adds an animation to a sprite based on the provided animation data.
    */
   public static function addAtlasAnimation(target:FlxSprite, anim:AnimationData)
   {
@@ -31,7 +31,7 @@ class FlxAnimationUtil
   }
 
   /**
-   * Properly adds multiple animations to a sprite based on JSON data.
+   * Properly adds multiple animations to a sprite based on the provided animation data.
    */
   public static function addAtlasAnimations(target:FlxSprite, animations:Array<AnimationData>)
   {

From 00cfeeff72710a8dacb885f5c0c821e238baacd6 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Thu, 13 Jul 2023 20:25:44 -0400
Subject: [PATCH 23/30] Created the Note Style class and data registry

---
 source/funkin/data/BaseRegistry.hx            |  14 +
 source/funkin/data/notestyle/NoteStyleData.hx | 171 ++++++++++
 .../data/notestyle/NoteStyleRegistry.hx       |  65 ++++
 .../funkin/play/notes/notestyle/NoteStyle.hx  | 304 ++++++++++++++++++
 .../play/notes/notestyle/ScriptedNoteStyle.hx |   9 +
 5 files changed, 563 insertions(+)
 create mode 100644 source/funkin/data/notestyle/NoteStyleData.hx
 create mode 100644 source/funkin/data/notestyle/NoteStyleRegistry.hx
 create mode 100644 source/funkin/play/notes/notestyle/NoteStyle.hx
 create mode 100644 source/funkin/play/notes/notestyle/ScriptedNoteStyle.hx

diff --git a/source/funkin/data/BaseRegistry.hx b/source/funkin/data/BaseRegistry.hx
index b30c311a3..36b1d26e3 100644
--- a/source/funkin/data/BaseRegistry.hx
+++ b/source/funkin/data/BaseRegistry.hx
@@ -84,6 +84,7 @@ abstract class BaseRegistry<T:(IRegistryEntry<J> & Constructible<EntryConstructo
       }
       catch (e:Dynamic)
       {
+        // Print the error.
         trace('  Failed to load entry data: ${entryId}');
         trace(e);
         continue;
@@ -91,16 +92,29 @@ abstract class BaseRegistry<T:(IRegistryEntry<J> & Constructible<EntryConstructo
     }
   }
 
+  /**
+   * Retrieve a list of all entry IDs in this registry.
+   * @return The list of entry IDs.
+   */
   public function listEntryIds():Array<String>
   {
     return entries.keys().array();
   }
 
+  /**
+   * Count the number of entries in this registry.
+   * @return The number of entries.
+   */
   public function countEntries():Int
   {
     return entries.size();
   }
 
+  /**
+   * Fetch an entry by its ID.
+   * @param id The ID of the entry to fetch.
+   * @return The entry, or `null` if it does not exist.
+   */
   public function fetchEntry(id:String):Null<T>
   {
     return entries.get(id);
diff --git a/source/funkin/data/notestyle/NoteStyleData.hx b/source/funkin/data/notestyle/NoteStyleData.hx
new file mode 100644
index 000000000..04fda67ca
--- /dev/null
+++ b/source/funkin/data/notestyle/NoteStyleData.hx
@@ -0,0 +1,171 @@
+package funkin.data.notestyle;
+
+import haxe.DynamicAccess;
+import funkin.data.animation.AnimationData;
+
+/**
+ * A type definition for the data in a note style JSON file.
+ * @see https://lib.haxe.org/p/json2object/
+ */
+typedef NoteStyleData =
+{
+  /**
+   * The version number of the note style data schema.
+   * When making changes to the note style data format, this should be incremented,
+   * and a migration function should be added to NoteStyleDataParser to handle old versions.
+   */
+  @:default(funkin.data.notestyle.NoteStyleRegistry.NOTE_STYLE_DATA_VERSION)
+  var version:String;
+
+  /**
+   * The readable title of the note style.
+   */
+  var name:String;
+
+  /**
+   * The author of the note style.
+   */
+  var author:String;
+
+  /**
+   * The note style to use as a fallback/parent.
+   * @default null
+   */
+  @:optional
+  var fallback:Null<String>;
+
+  /**
+   * Data for each of the assets in the note style.
+   */
+  var assets:NoteStyleAssetsData;
+}
+
+typedef NoteStyleAssetsData =
+{
+  /**
+   * The sprites for the notes.
+   * @default The sprites from the fallback note style.
+   */
+  @:optional
+  var note:NoteStyleAssetData<NoteStyleData_Note>;
+
+  /**
+   * The sprites for the hold notes.
+   * @default The sprites from the fallback note style.
+   */
+  @:optional
+  var holdNote:NoteStyleAssetData<NoteStyleData_HoldNote>;
+
+  /**
+   * The sprites for the strumline.
+   * @default The sprites from the fallback note style.
+   */
+  @:optional
+  var noteStrumline:NoteStyleAssetData<NoteStyleData_NoteStrumline>;
+
+  /**
+   * The sprites for the note splashes.
+   */
+  @:optional
+  var noteSplash:NoteStyleAssetData<NoteStyleData_NoteSplash>;
+
+  /**
+   * The sprites for the hold note covers.
+   */
+  @:optional
+  var holdNoteCover:NoteStyleAssetData<NoteStyleData_HoldNoteCover>;
+}
+
+/**
+ * Data shared by all note style assets.
+ */
+typedef NoteStyleAssetData<T> =
+{
+  /**
+   * The image to use for the asset. May be a Sparrow sprite sheet.
+   */
+  var assetPath:String;
+
+  /**
+   * The scale to render the prop at.
+   * @default 1.0
+   */
+  @:default(1.0)
+  @:optional
+  var scale:Float;
+
+  /**
+   * Offset the sprite's position by this amount.
+   * @default [0, 0]
+   */
+  @:default([0, 0])
+  @:optional
+  var offsets:Null<Array<Float>>;
+
+  /**
+   * If true, the prop is a pixel sprite, and will be rendered without anti-aliasing.
+   */
+  @:default(false)
+  @:optional
+  var isPixel:Bool;
+
+  /**
+   * The structure of this data depends on the asset.
+   */
+  var data:T;
+}
+
+typedef NoteStyleData_Note =
+{
+  var left:UnnamedAnimationData;
+  var down:UnnamedAnimationData;
+  var up:UnnamedAnimationData;
+  var right:UnnamedAnimationData;
+}
+
+typedef NoteStyleData_HoldNote = {}
+
+/**
+ * Data on animations for each direction of the strumline.
+ */
+typedef NoteStyleData_NoteStrumline =
+{
+  var leftStatic:UnnamedAnimationData;
+  var leftPress:UnnamedAnimationData;
+  var leftConfirm:UnnamedAnimationData;
+  var leftConfirmHold:UnnamedAnimationData;
+  var downStatic:UnnamedAnimationData;
+  var downPress:UnnamedAnimationData;
+  var downConfirm:UnnamedAnimationData;
+  var downConfirmHold:UnnamedAnimationData;
+  var upStatic:UnnamedAnimationData;
+  var upPress:UnnamedAnimationData;
+  var upConfirm:UnnamedAnimationData;
+  var upConfirmHold:UnnamedAnimationData;
+  var rightStatic:UnnamedAnimationData;
+  var rightPress:UnnamedAnimationData;
+  var rightConfirm:UnnamedAnimationData;
+  var rightConfirmHold:UnnamedAnimationData;
+}
+
+typedef NoteStyleData_NoteSplash =
+{
+  /**
+   * If false, note splashes are entirely hidden on this note style.
+   * @default Note splashes are enabled.
+   */
+  @:optional
+  @:default(true)
+  var enabled:Bool;
+};
+
+typedef NoteStyleData_HoldNoteCover =
+{
+  /**
+   * If false, hold note covers are entirely hidden on this note style.
+   * @default Hold note covers are enabled.
+   */
+  @:optional
+  @:default(true)
+  var enabled:Bool;
+};
diff --git a/source/funkin/data/notestyle/NoteStyleRegistry.hx b/source/funkin/data/notestyle/NoteStyleRegistry.hx
new file mode 100644
index 000000000..d666a037c
--- /dev/null
+++ b/source/funkin/data/notestyle/NoteStyleRegistry.hx
@@ -0,0 +1,65 @@
+package funkin.data.notestyle;
+
+import funkin.play.notes.notestyle.NoteStyle;
+import funkin.play.notes.notestyle.ScriptedNoteStyle;
+import funkin.data.notestyle.NoteStyleData;
+
+class NoteStyleRegistry extends BaseRegistry<NoteStyle, NoteStyleData>
+{
+  /**
+   * The current version string for the note style data format.
+   * Handle breaking changes by incrementing this value
+   * and adding migration to the `migrateNoteStyleData()` function.
+   */
+  public static final NOTE_STYLE_DATA_VERSION:String = "1.0.0";
+
+  public static final DEFAULT_NOTE_STYLE_ID:String = "funkin";
+
+  public static final instance:NoteStyleRegistry = new NoteStyleRegistry();
+
+  public function new()
+  {
+    super('NOTESTYLE', 'notestyles');
+  }
+
+  public function fetchDefault():NoteStyle
+  {
+    return fetchEntry(DEFAULT_NOTE_STYLE_ID);
+  }
+
+  /**
+   * Read, parse, and validate the JSON data and produce the corresponding data object.
+   */
+  public function parseEntryData(id:String):Null<NoteStyleData>
+  {
+    if (id == null) id = DEFAULT_NOTE_STYLE_ID;
+
+    // JsonParser does not take type parameters,
+    // otherwise this function would be in BaseRegistry.
+    var parser = new json2object.JsonParser<NoteStyleData>();
+    var jsonStr:String = loadEntryFile(id);
+
+    parser.fromJson(jsonStr);
+
+    if (parser.errors.length > 0)
+    {
+      trace('Failed to parse entry data: ${id}');
+      for (error in parser.errors)
+      {
+        trace(error);
+      }
+      return null;
+    }
+    return parser.value;
+  }
+
+  function createScriptedEntry(clsName:String):NoteStyle
+  {
+    return ScriptedNoteStyle.init(clsName, "unknown");
+  }
+
+  function getScriptedClassNames():Array<String>
+  {
+    return ScriptedNoteStyle.listScriptClasses();
+  }
+}
diff --git a/source/funkin/play/notes/notestyle/NoteStyle.hx b/source/funkin/play/notes/notestyle/NoteStyle.hx
new file mode 100644
index 000000000..6cb9e7cdc
--- /dev/null
+++ b/source/funkin/play/notes/notestyle/NoteStyle.hx
@@ -0,0 +1,304 @@
+package funkin.play.notes.notestyle;
+
+import flixel.graphics.frames.FlxAtlasFrames;
+import flixel.graphics.frames.FlxFramesCollection;
+import funkin.data.animation.AnimationData;
+import funkin.data.IRegistryEntry;
+import funkin.data.notestyle.NoteStyleData;
+import funkin.data.notestyle.NoteStyleRegistry;
+import funkin.data.notestyle.NoteStyleRegistry;
+import funkin.util.assets.FlxAnimationUtil;
+
+using funkin.data.animation.AnimationData.AnimationDataUtil;
+
+/**
+ * Holds the data for what assets to use for a note style,
+ * and provides convenience methods for building sprites based on them.
+ */
+class NoteStyle implements IRegistryEntry<NoteStyleData>
+{
+  /**
+   * The ID of the note style.
+   */
+  public final id:String;
+
+  /**
+   * Note style data as parsed from the JSON file.
+   */
+  public final _data:NoteStyleData;
+
+  /**
+   * The note style to use if this one doesn't have a certain asset.
+   * This can be recursive, ehe.
+   */
+  final fallback:Null<NoteStyle>;
+
+  /**
+   * @param id The ID of the JSON file to parse.
+   */
+  public function new(id:String)
+  {
+    this.id = id;
+    _data = _fetchData(id);
+
+    if (_data == null)
+    {
+      throw 'Could not parse note style data for id: $id';
+    }
+
+    this.fallback = NoteStyleRegistry.instance.fetchEntry(getFallbackID());
+  }
+
+  /**
+   * Get the readable name of the note style.
+   * @return String
+   */
+  public function getName():String
+  {
+    return _data.name;
+  }
+
+  /**
+   * Get the author of the note style.
+   * @return String
+   */
+  public function getAuthor():String
+  {
+    return _data.author;
+  }
+
+  /**
+   * Get the note style ID of the parent note style.
+   * @return The string ID, or `null` if there is no parent.
+   */
+  function getFallbackID():Null<String>
+  {
+    return _data.fallback;
+  }
+
+  public function buildNoteSprite(target:NoteSprite):Void
+  {
+    // Apply the note sprite frames.
+    var atlas:FlxAtlasFrames = buildNoteFrames(false);
+
+    if (atlas == null)
+    {
+      throw 'Could not load spritesheet for note style: $id';
+    }
+
+    target.frames = atlas;
+
+    target.scale.x = _data.assets.note.scale;
+    target.scale.y = _data.assets.note.scale;
+    target.antialiasing = !_data.assets.note.isPixel;
+
+    // Apply the animations.
+    buildNoteAnimations(target);
+  }
+
+  var noteFrames:FlxAtlasFrames = null;
+
+  function buildNoteFrames(force:Bool = false):FlxAtlasFrames
+  {
+    if (noteFrames != null && !force) return noteFrames;
+
+    noteFrames = Paths.getSparrowAtlas(getNoteAssetPath(), getNoteAssetLibrary());
+
+    noteFrames.parent.persist = true;
+
+    return noteFrames;
+  }
+
+  function getNoteAssetPath(?raw:Bool = false):String
+  {
+    if (raw)
+    {
+      var rawPath:Null<String> = _data?.assets?.note?.assetPath;
+      if (rawPath == null) return fallback.getNoteAssetPath(true);
+      return rawPath;
+    }
+
+    // library:path
+    var parts = getNoteAssetPath(true).split(Constants.LIBRARY_SEPARATOR);
+    if (parts.length == 1) return getNoteAssetPath(true);
+    return parts[1];
+  }
+
+  function getNoteAssetLibrary():Null<String>
+  {
+    // library:path
+    var parts = getNoteAssetPath(true).split(Constants.LIBRARY_SEPARATOR);
+    if (parts.length == 1) return null;
+    return parts[0];
+  }
+
+  function buildNoteAnimations(target:NoteSprite):Void
+  {
+    var leftData:AnimationData = fetchNoteAnimationData(LEFT);
+    target.animation.addByPrefix('purpleScroll', leftData.prefix);
+    var downData:AnimationData = fetchNoteAnimationData(DOWN);
+    target.animation.addByPrefix('blueScroll', downData.prefix);
+    var upData:AnimationData = fetchNoteAnimationData(UP);
+    target.animation.addByPrefix('greenScroll', upData.prefix);
+    var rightData:AnimationData = fetchNoteAnimationData(RIGHT);
+    target.animation.addByPrefix('redScroll', rightData.prefix);
+  }
+
+  function fetchNoteAnimationData(dir:NoteDirection):AnimationData
+  {
+    var result:Null<AnimationData> = switch (dir)
+    {
+      case LEFT: _data.assets.note.data.left.toNamed();
+      case DOWN: _data.assets.note.data.down.toNamed();
+      case UP: _data.assets.note.data.up.toNamed();
+      case RIGHT: _data.assets.note.data.right.toNamed();
+    };
+
+    return (result == null) ? fallback.fetchNoteAnimationData(dir) : result;
+  }
+
+  public function getHoldNoteAssetPath(?raw:Bool = false):String
+  {
+    if (raw)
+    {
+      var rawPath:Null<String> = _data?.assets?.holdNote?.assetPath;
+      return (rawPath == null) ? fallback.getHoldNoteAssetPath(true) : rawPath;
+    }
+
+    // library:path
+    var parts = getHoldNoteAssetPath(true).split(Constants.LIBRARY_SEPARATOR);
+    if (parts.length == 1) return Paths.image(parts[0]);
+    return Paths.image(parts[1], parts[0]);
+  }
+
+  public function isHoldNotePixel():Bool
+  {
+    var data = _data?.assets?.holdNote;
+    if (data == null) return fallback.isHoldNotePixel();
+    return data.isPixel;
+  }
+
+  public function fetchHoldNoteScale():Float
+  {
+    var data = _data?.assets?.holdNote;
+    if (data == null) return fallback.fetchHoldNoteScale();
+    return data.scale;
+  }
+
+  public function applyStrumlineFrames(target:StrumlineNote):Void
+  {
+    // TODO: Add support for multi-Sparrow.
+    // Will be less annoying after this is merged: https://github.com/HaxeFlixel/flixel/pull/2772
+
+    var atlas:FlxAtlasFrames = Paths.getSparrowAtlas(getStrumlineAssetPath(), getStrumlineAssetLibrary());
+
+    if (atlas == null)
+    {
+      throw 'Could not load spritesheet for note style: $id';
+    }
+
+    target.frames = atlas;
+
+    target.scale.x = _data.assets.noteStrumline.scale;
+    target.scale.y = _data.assets.noteStrumline.scale;
+    target.antialiasing = !_data.assets.noteStrumline.isPixel;
+  }
+
+  function getStrumlineAssetPath(?raw:Bool = false):String
+  {
+    if (raw)
+    {
+      var rawPath:Null<String> = _data?.assets?.noteStrumline?.assetPath;
+      if (rawPath == null) return fallback.getStrumlineAssetPath(true);
+      return rawPath;
+    }
+
+    // library:path
+    var parts = getStrumlineAssetPath(true).split(Constants.LIBRARY_SEPARATOR);
+    if (parts.length == 1) return getStrumlineAssetPath(true);
+    return parts[1];
+  }
+
+  function getStrumlineAssetLibrary():Null<String>
+  {
+    // library:path
+    var parts = getStrumlineAssetPath(true).split(Constants.LIBRARY_SEPARATOR);
+    if (parts.length == 1) return null;
+    return parts[0];
+  }
+
+  public function applyStrumlineAnimations(target:StrumlineNote, dir:NoteDirection):Void
+  {
+    FlxAnimationUtil.addAtlasAnimations(target, getStrumlineAnimationData(dir));
+  }
+
+  function getStrumlineAnimationData(dir:NoteDirection):Array<AnimationData>
+  {
+    var result:Array<AnimationData> = switch (dir)
+    {
+      case NoteDirection.LEFT: [
+          _data.assets.noteStrumline.data.leftStatic.toNamed('static'),
+          _data.assets.noteStrumline.data.leftPress.toNamed('press'),
+          _data.assets.noteStrumline.data.leftConfirm.toNamed('confirm'),
+          _data.assets.noteStrumline.data.leftConfirmHold.toNamed('confirm-hold'),
+        ];
+      case NoteDirection.DOWN: [
+          _data.assets.noteStrumline.data.downStatic.toNamed('static'),
+          _data.assets.noteStrumline.data.downPress.toNamed('press'),
+          _data.assets.noteStrumline.data.downConfirm.toNamed('confirm'),
+          _data.assets.noteStrumline.data.downConfirmHold.toNamed('confirm-hold'),
+        ];
+      case NoteDirection.UP: [
+          _data.assets.noteStrumline.data.upStatic.toNamed('static'),
+          _data.assets.noteStrumline.data.upPress.toNamed('press'),
+          _data.assets.noteStrumline.data.upConfirm.toNamed('confirm'),
+          _data.assets.noteStrumline.data.upConfirmHold.toNamed('confirm-hold'),
+        ];
+      case NoteDirection.RIGHT: [
+          _data.assets.noteStrumline.data.rightStatic.toNamed('static'),
+          _data.assets.noteStrumline.data.rightPress.toNamed('press'),
+          _data.assets.noteStrumline.data.rightConfirm.toNamed('confirm'),
+          _data.assets.noteStrumline.data.rightConfirmHold.toNamed('confirm-hold'),
+        ];
+    };
+
+    return result;
+  }
+
+  public function applyStrumlineOffsets(target:StrumlineNote)
+  {
+    target.x += _data.assets.noteStrumline.offsets[0];
+    target.y += _data.assets.noteStrumline.offsets[1];
+  }
+
+  public function getStrumlineScale():Float
+  {
+    return _data.assets.noteStrumline.scale;
+  }
+
+  public function isNoteSplashEnabled():Bool
+  {
+    var data = _data?.assets?.noteSplash?.data;
+    if (data == null) return fallback.isNoteSplashEnabled();
+    return data.enabled;
+  }
+
+  public function isHoldNoteCoverEnabled():Bool
+  {
+    var data = _data?.assets?.holdNoteCover?.data;
+    if (data == null) return fallback.isHoldNoteCoverEnabled();
+    return data.enabled;
+  }
+
+  public function destroy():Void {}
+
+  public function toString():String
+  {
+    return 'NoteStyle($id)';
+  }
+
+  public function _fetchData(id:String):Null<NoteStyleData>
+  {
+    return NoteStyleRegistry.instance.parseEntryData(id);
+  }
+}
diff --git a/source/funkin/play/notes/notestyle/ScriptedNoteStyle.hx b/source/funkin/play/notes/notestyle/ScriptedNoteStyle.hx
new file mode 100644
index 000000000..cae0e60ec
--- /dev/null
+++ b/source/funkin/play/notes/notestyle/ScriptedNoteStyle.hx
@@ -0,0 +1,9 @@
+package funkin.play.notes.notestyle;
+
+/**
+ * A script that can be tied to a NoteStyle.
+ * Create a scripted class that extends NoteStyle to use this.
+ * This allows you to customize how a specific note style appears.
+ */
+@:hscriptClass
+class ScriptedNoteStyle extends funkin.play.notes.notestyle.NoteStyle implements polymod.hscript.HScriptedClass {}

From ba53957191c9f9d37562bcbc68ab0d86f596bb98 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Thu, 13 Jul 2023 20:26:56 -0400
Subject: [PATCH 24/30] Fixed an issue where dialogue scripts were not being
 reloaded

---
 source/funkin/InitState.hx                    | 36 +++++++++++++------
 source/funkin/modding/PolymodHandler.hx       | 17 ++++++---
 .../dialogue/DialogueBoxDataParser.hx         |  4 +--
 .../play/cutscene/dialogue/ScriptedSpeaker.hx |  7 +++-
 .../cutscene/dialogue/SpeakerDataParser.hx    |  4 +--
 5 files changed, 47 insertions(+), 21 deletions(-)

diff --git a/source/funkin/InitState.hx b/source/funkin/InitState.hx
index 97e451320..ce863bd0b 100644
--- a/source/funkin/InitState.hx
+++ b/source/funkin/InitState.hx
@@ -14,6 +14,16 @@ import funkin.util.macro.MacroUtil;
 import funkin.util.WindowUtil;
 import funkin.play.PlayStatePlaylist;
 import openfl.display.BitmapData;
+import funkin.data.level.LevelRegistry;
+import funkin.data.notestyle.NoteStyleRegistry;
+import funkin.play.event.SongEventData.SongEventParser;
+import funkin.play.cutscene.dialogue.ConversationDataParser;
+import funkin.play.cutscene.dialogue.DialogueBoxDataParser;
+import funkin.play.cutscene.dialogue.SpeakerDataParser;
+import funkin.play.song.SongData.SongDataParser;
+import funkin.play.stage.StageData.StageDataParser;
+import funkin.play.character.CharacterData.CharacterDataParser;
+import funkin.modding.module.ModuleHandler;
 #if discord_rpc
 import Discord.DiscordClient;
 #end
@@ -180,18 +190,22 @@ class InitState extends FlxTransitionableState
     //
     // GAME DATA PARSING
     //
-    funkin.data.level.LevelRegistry.instance.loadEntries();
-    funkin.play.event.SongEventData.SongEventParser.loadEventCache();
-    funkin.play.cutscene.dialogue.ConversationDataParser.loadConversationCache();
-    funkin.play.cutscene.dialogue.DialogueBoxDataParser.loadDialogueBoxCache();
-    funkin.play.cutscene.dialogue.SpeakerDataParser.loadSpeakerCache();
-    funkin.play.song.SongData.SongDataParser.loadSongCache();
-    funkin.play.stage.StageData.StageDataParser.loadStageCache();
-    funkin.play.character.CharacterData.CharacterDataParser.loadCharacterCache();
-    funkin.modding.module.ModuleHandler.buildModuleCallbacks();
-    funkin.modding.module.ModuleHandler.loadModuleCache();
 
-    funkin.modding.module.ModuleHandler.callOnCreate();
+    // NOTE: Registries and data parsers must be imported and not referenced with fully qualified names,
+    // to ensure build macros work properly.
+    LevelRegistry.instance.loadEntries();
+    NoteStyleRegistry.instance.loadEntries();
+    SongEventParser.loadEventCache();
+    ConversationDataParser.loadConversationCache();
+    DialogueBoxDataParser.loadDialogueBoxCache();
+    SpeakerDataParser.loadSpeakerCache();
+    SongDataParser.loadSongCache();
+    StageDataParser.loadStageCache();
+    CharacterDataParser.loadCharacterCache();
+    ModuleHandler.buildModuleCallbacks();
+    ModuleHandler.loadModuleCache();
+
+    ModuleHandler.callOnCreate();
   }
 
   /**
diff --git a/source/funkin/modding/PolymodHandler.hx b/source/funkin/modding/PolymodHandler.hx
index d90c1386d..bed63d1d8 100644
--- a/source/funkin/modding/PolymodHandler.hx
+++ b/source/funkin/modding/PolymodHandler.hx
@@ -10,6 +10,11 @@ import polymod.backends.PolymodAssets.PolymodAssetType;
 import polymod.format.ParseRules.TextFileFormat;
 import funkin.play.event.SongEventData.SongEventParser;
 import funkin.util.FileUtil;
+import funkin.data.level.LevelRegistry;
+import funkin.data.notestyle.NoteStyleRegistry;
+import funkin.play.cutscene.dialogue.ConversationDataParser;
+import funkin.play.cutscene.dialogue.DialogueBoxDataParser;
+import funkin.play.cutscene.dialogue.SpeakerDataParser;
 
 class PolymodHandler
 {
@@ -279,12 +284,14 @@ class PolymodHandler
 
     // TODO: Reload event callbacks
 
-    funkin.data.level.LevelRegistry.instance.loadEntries();
+    // These MUST be imported at the top of the file and not referred to by fully qualified name,
+    // to ensure build macros work properly.
+    LevelRegistry.instance.loadEntries();
+    NoteStyleRegistry.instance.loadEntries();
     SongEventParser.loadEventCache();
-    // TODO: Uncomment this once conversation data is implemented.
-    // ConversationDataParser.loadConversationCache();
-    // DialogueBoxDataParser.loadDialogueBoxCache();
-    // SpeakerDataParser.loadSpeakerCache();
+    ConversationDataParser.loadConversationCache();
+    DialogueBoxDataParser.loadDialogueBoxCache();
+    SpeakerDataParser.loadSpeakerCache();
     SongDataParser.loadSongCache();
     StageDataParser.loadStageCache();
     CharacterDataParser.loadCharacterCache();
diff --git a/source/funkin/play/cutscene/dialogue/DialogueBoxDataParser.hx b/source/funkin/play/cutscene/dialogue/DialogueBoxDataParser.hx
index 7bac9cf38..cb00dd80d 100644
--- a/source/funkin/play/cutscene/dialogue/DialogueBoxDataParser.hx
+++ b/source/funkin/play/cutscene/dialogue/DialogueBoxDataParser.hx
@@ -21,7 +21,7 @@ class DialogueBoxDataParser
 
   /**
    * Parses and preloads the game's dialogueBox data and scripts when the game starts.
-   * 
+   *
    * If you want to force dialogue boxes to be reloaded, you can just call this function again.
    */
   public static function loadDialogueBoxCache():Void
@@ -123,7 +123,7 @@ class DialogueBoxDataParser
 
   /**
    * Load a dialogueBox's JSON file, parse its data, and return it.
-   * 
+   *
    * @param dialogueBoxId The dialogueBox to load.
    * @return The dialogueBox data, or null if validation failed.
    */
diff --git a/source/funkin/play/cutscene/dialogue/ScriptedSpeaker.hx b/source/funkin/play/cutscene/dialogue/ScriptedSpeaker.hx
index 03846eb42..a244ff6f1 100644
--- a/source/funkin/play/cutscene/dialogue/ScriptedSpeaker.hx
+++ b/source/funkin/play/cutscene/dialogue/ScriptedSpeaker.hx
@@ -1,4 +1,9 @@
 package funkin.play.cutscene.dialogue;
 
+/**
+ * A script that can be tied to a Speaker.
+ * Create a scripted class that extends Speaker to use this.
+ * This allows you to customize how a specific conversation speaker appears.
+ */
 @:hscriptClass
-class ScriptedSpeaker extends Speaker implements polymod.hscript.HScriptedClass {}
+class ScriptedSpeaker extends funkin.play.cutscene.dialogue.Speaker implements polymod.hscript.HScriptedClass {}
diff --git a/source/funkin/play/cutscene/dialogue/SpeakerDataParser.hx b/source/funkin/play/cutscene/dialogue/SpeakerDataParser.hx
index 62a8a105b..f7ddb099f 100644
--- a/source/funkin/play/cutscene/dialogue/SpeakerDataParser.hx
+++ b/source/funkin/play/cutscene/dialogue/SpeakerDataParser.hx
@@ -21,7 +21,7 @@ class SpeakerDataParser
 
   /**
    * Parses and preloads the game's speaker data and scripts when the game starts.
-   * 
+   *
    * If you want to force speakers to be reloaded, you can just call this function again.
    */
   public static function loadSpeakerCache():Void
@@ -123,7 +123,7 @@ class SpeakerDataParser
 
   /**
    * Load a speaker's JSON file, parse its data, and return it.
-   * 
+   *
    * @param speakerId The speaker to load.
    * @return The speaker data, or null if validation failed.
    */

From 16bcf2c76770e228423e4a200c1a9dd4b79243ef Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Thu, 13 Jul 2023 20:27:45 -0400
Subject: [PATCH 25/30] Rework note sprites to pull note style data

---
 source/funkin/LatencyState.hx                 |  3 +-
 source/funkin/MusicBeatState.hx               |  4 +-
 source/funkin/audio/VoicesGroup.hx            |  7 +++
 source/funkin/play/PlayState.hx               | 62 ++++++++++---------
 source/funkin/play/notes/NoteSprite.hx        | 28 ++-------
 source/funkin/play/notes/Strumline.hx         | 29 ++++-----
 source/funkin/play/notes/StrumlineNote.hx     | 52 +++-------------
 source/funkin/play/notes/SustainTrail.hx      | 12 ++--
 source/funkin/ui/ColorsMenu.hx                |  3 +-
 .../ui/debug/charting/ChartEditorState.hx     |  3 +-
 10 files changed, 83 insertions(+), 120 deletions(-)

diff --git a/source/funkin/LatencyState.hx b/source/funkin/LatencyState.hx
index bd78a4298..4a8ed2d2e 100644
--- a/source/funkin/LatencyState.hx
+++ b/source/funkin/LatencyState.hx
@@ -1,5 +1,6 @@
 package funkin;
 
+import funkin.data.notestyle.NoteStyleRegistry;
 import flixel.FlxSprite;
 import flixel.FlxSubState;
 import flixel.group.FlxGroup;
@@ -128,7 +129,7 @@ class LatencyState extends MusicBeatSubState
 
     for (i in 0...32)
     {
-      var note:NoteSprite = new NoteSprite(Conductor.beatLengthMs * i);
+      var note:NoteSprite = new NoteSprite(NoteStyleRegistry.instance.fetchDefault(), Conductor.beatLengthMs * i);
       noteGrp.add(note);
     }
 
diff --git a/source/funkin/MusicBeatState.hx b/source/funkin/MusicBeatState.hx
index 9aad66773..4499b0946 100644
--- a/source/funkin/MusicBeatState.hx
+++ b/source/funkin/MusicBeatState.hx
@@ -107,7 +107,9 @@ class MusicBeatState extends FlxUIState implements IEventHandler
   {
     PolymodHandler.forceReloadAssets();
 
-    // Restart the current state, so old data is cleared.
+    this.destroy();
+
+    // Create a new instance of the current state, so old data is cleared.
     FlxG.resetState();
   }
 
diff --git a/source/funkin/audio/VoicesGroup.hx b/source/funkin/audio/VoicesGroup.hx
index 9f688eb48..6d61e6481 100644
--- a/source/funkin/audio/VoicesGroup.hx
+++ b/source/funkin/audio/VoicesGroup.hx
@@ -65,4 +65,11 @@ class VoicesGroup extends SoundGroup
     opponentVoices.clear();
     super.clear();
   }
+
+  public override function destroy():Void
+  {
+    playerVoices.destroy();
+    opponentVoices.destroy();
+    super.destroy();
+  }
 }
diff --git a/source/funkin/play/PlayState.hx b/source/funkin/play/PlayState.hx
index f651f5ac6..ff5b924f8 100644
--- a/source/funkin/play/PlayState.hx
+++ b/source/funkin/play/PlayState.hx
@@ -1,6 +1,9 @@
 package funkin.play;
 
 import haxe.Int64;
+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.FlxTransitionableState;
 import flixel.FlxCamera;
@@ -989,6 +992,9 @@ class PlayState extends MusicBeatState
    */
   override function debug_refreshModules():Void
   {
+    // Prevent further gameplay updates, which will try to reference dead objects.
+    criticalFailure = true;
+
     // Remove the current stage. If the stage gets deleted while it's still in use,
     // it'll probably crash the game or something.
     if (this.currentStage != null)
@@ -999,8 +1005,14 @@ class PlayState extends MusicBeatState
       currentStage = null;
     }
 
+    // Stop the instrumental.
+    if (FlxG.sound.music != null)
+    {
+      FlxG.sound.music.stop();
+    }
+
     // Stop the vocals.
-    if (vocals != null)
+    if (vocals != null && vocals.exists)
     {
       vocals.stop();
     }
@@ -1013,6 +1025,8 @@ class PlayState extends MusicBeatState
 
   override function stepHit():Bool
   {
+    if (criticalFailure) return false;
+
     // super.stepHit() returns false if a module cancelled the event.
     if (!super.stepHit()) return false;
 
@@ -1034,6 +1048,8 @@ class PlayState extends MusicBeatState
 
   override function beatHit():Bool
   {
+    if (criticalFailure) return false;
+
     // super.beatHit() returns false if a module cancelled the event.
     if (!super.beatHit()) return false;
 
@@ -1279,9 +1295,9 @@ class PlayState extends MusicBeatState
     //
     // OPPONENT HEALTH ICON
     //
-    iconP2 = new HealthIcon(currentCharData.opponent, 1);
+    iconP2 = new HealthIcon('dad', 1);
     iconP2.y = healthBar.y - (iconP2.height / 2);
-    dad.initHealthIcon(true);
+    dad.initHealthIcon(true); // Apply the character ID here
     add(iconP2);
     iconP2.cameras = [camHUD];
 
@@ -1298,9 +1314,9 @@ class PlayState extends MusicBeatState
     //
     // PLAYER HEALTH ICON
     //
-    iconP1 = new HealthIcon(currentPlayerId, 0);
+    iconP1 = new HealthIcon('bf', 0);
     iconP1.y = healthBar.y - (iconP1.height / 2);
-    boyfriend.initHealthIcon(false);
+    boyfriend.initHealthIcon(false); // Apply the character ID here
     add(iconP1);
     iconP1.cameras = [camHUD];
 
@@ -1350,19 +1366,17 @@ class PlayState extends MusicBeatState
    */
   function initStrumlines():Void
   {
-    // var strumlineStyle:StrumlineStyle = NORMAL;
-    //
-    //// TODO: Put this in the chart or something?
-    // switch (currentStageId)
-    // {
-    //  case 'school':
-    //    strumlineStyle = PIXEL;
-    //  case 'schoolEvil':
-    //    strumlineStyle = PIXEL;
-    // }
+    var noteStyleId:String = switch (currentStageId)
+    {
+      case 'school': 'pixel';
+      case 'schoolEvil': 'pixel';
+      default: 'funkin';
+    }
+    var noteStyle:NoteStyle = NoteStyleRegistry.instance.fetchEntry(noteStyleId);
+    if (noteStyle == null) noteStyle = NoteStyleRegistry.instance.fetchDefault();
 
-    playerStrumline = new Strumline(true);
-    opponentStrumline = new Strumline(false);
+    playerStrumline = new Strumline(noteStyle, true);
+    opponentStrumline = new Strumline(noteStyle, false);
     add(playerStrumline);
     add(opponentStrumline);
 
@@ -1460,18 +1474,6 @@ class PlayState extends MusicBeatState
     songEvents = currentChart.getEvents();
     SongEventParser.resetEvents(songEvents);
 
-    // TODO: Put this in the chart or something?
-    // var strumlineStyle:StrumlineStyle = null;
-    // switch (currentStageId)
-    // {
-    //   case 'school':
-    //     strumlineStyle = PIXEL;
-    //   case 'schoolEvil':
-    //     strumlineStyle = PIXEL;
-    //   default:
-    //     strumlineStyle = NORMAL;
-    // }
-
     // Reset the notes on each strumline.
     var playerNoteData:Array<SongNoteData> = [];
     var opponentNoteData:Array<SongNoteData> = [];
@@ -1631,6 +1633,8 @@ class PlayState extends MusicBeatState
    */
   function processNotes(elapsed:Float):Void
   {
+    if (playerStrumline?.notes?.members == null || opponentStrumline?.notes?.members == null) return;
+
     // Process notes on the opponent's side.
     for (note in opponentStrumline.notes.members)
     {
diff --git a/source/funkin/play/notes/NoteSprite.hx b/source/funkin/play/notes/NoteSprite.hx
index b407e7f74..25b23eee7 100644
--- a/source/funkin/play/notes/NoteSprite.hx
+++ b/source/funkin/play/notes/NoteSprite.hx
@@ -1,6 +1,7 @@
 package funkin.play.notes;
 
 import funkin.play.song.SongData.SongNoteData;
+import funkin.play.notes.notestyle.NoteStyle;
 import flixel.graphics.frames.FlxAtlasFrames;
 import flixel.FlxSprite;
 
@@ -106,7 +107,7 @@ class NoteSprite extends FlxSprite
    */
   public var handledMiss:Bool;
 
-  public function new(strumTime:Float = 0, direction:Int = 0)
+  public function new(noteStyle:NoteStyle, strumTime:Float = 0, direction:Int = 0)
   {
     super(0, -9999);
     this.strumTime = strumTime;
@@ -114,34 +115,15 @@ class NoteSprite extends FlxSprite
 
     if (this.strumTime < 0) this.strumTime = 0;
 
-    setupNoteGraphic();
+    setupNoteGraphic(noteStyle);
 
     // Disables the update() function for performance.
     this.active = false;
   }
 
-  public static function buildNoteFrames(force:Bool = false):FlxAtlasFrames
+  function setupNoteGraphic(noteStyle:NoteStyle):Void
   {
-    // static variables inside functions are a cool of Haxe 4.3.0.
-    static var noteFrames:FlxAtlasFrames = null;
-
-    if (noteFrames != null && !force) return noteFrames;
-
-    noteFrames = Paths.getSparrowAtlas('notes');
-
-    noteFrames.parent.persist = true;
-
-    return noteFrames;
-  }
-
-  function setupNoteGraphic():Void
-  {
-    this.frames = buildNoteFrames();
-
-    animation.addByPrefix('greenScroll', 'noteUp');
-    animation.addByPrefix('redScroll', 'noteRight');
-    animation.addByPrefix('blueScroll', 'noteDown');
-    animation.addByPrefix('purpleScroll', 'noteLeft');
+    noteStyle.buildNoteSprite(this);
 
     setGraphicSize(Strumline.STRUMLINE_SIZE);
     updateHitbox();
diff --git a/source/funkin/play/notes/Strumline.hx b/source/funkin/play/notes/Strumline.hx
index 7730073f8..4fdf5afe3 100644
--- a/source/funkin/play/notes/Strumline.hx
+++ b/source/funkin/play/notes/Strumline.hx
@@ -1,6 +1,7 @@
 package funkin.play.notes;
 
 import flixel.FlxG;
+import funkin.play.notes.notestyle.NoteStyle;
 import flixel.group.FlxSpriteGroup;
 import flixel.group.FlxSpriteGroup.FlxTypedSpriteGroup;
 import flixel.tweens.FlxEase;
@@ -52,16 +53,19 @@ class Strumline extends FlxSpriteGroup
   var noteSplashes:FlxTypedSpriteGroup<NoteSplash>;
   var noteHoldCovers:FlxTypedSpriteGroup<NoteHoldCover>;
 
+  final noteStyle:NoteStyle;
+
   var noteData:Array<SongNoteData> = [];
   var nextNoteIndex:Int = -1;
 
   var heldKeys:Array<Bool> = [];
 
-  public function new(isPlayer:Bool)
+  public function new(noteStyle:NoteStyle, isPlayer:Bool)
   {
     super();
 
     this.isPlayer = isPlayer;
+    this.noteStyle = noteStyle;
 
     this.strumlineNotes = new FlxTypedSpriteGroup<StrumlineNote>();
     this.strumlineNotes.zIndex = 10;
@@ -88,10 +92,11 @@ class Strumline extends FlxSpriteGroup
 
     for (i in 0...KEY_COUNT)
     {
-      var child:StrumlineNote = new StrumlineNote(isPlayer, DIRECTIONS[i]);
+      var child:StrumlineNote = new StrumlineNote(noteStyle, isPlayer, DIRECTIONS[i]);
       child.x = getXPos(DIRECTIONS[i]);
       child.x += INITIAL_OFFSET;
       child.y = 0;
+      noteStyle.applyStrumlineOffsets(child);
       this.strumlineNotes.add(child);
     }
 
@@ -119,20 +124,6 @@ class Strumline extends FlxSpriteGroup
     super.update(elapsed);
 
     updateNotes();
-
-    #if debug
-    if (!isPlayer)
-    {
-      FlxG.watch.addQuick("strumlineAnim", strumlineNotes.members[3]?.animation?.curAnim?.name);
-      var curFrame = strumlineNotes.members[3]?.animation?.curAnim?.curFrame;
-      frameMax = (curFrame > frameMax) ? curFrame : frameMax;
-      FlxG.watch.addQuick("strumlineFrame", strumlineNotes.members[3]?.animation?.curAnim?.curFrame);
-      FlxG.watch.addQuick("strumlineFrameMax", frameMax);
-      animFinishedEver = animFinishedEver || strumlineNotes.members[3]?.animation?.curAnim?.finished;
-      FlxG.watch.addQuick("strumlineFinished", strumlineNotes.members[3]?.animation?.curAnim?.finished);
-      FlxG.watch.addQuick("strumlineFinishedEver", animFinishedEver);
-    }
-    #end
   }
 
   var frameMax:Int;
@@ -482,6 +473,7 @@ class Strumline extends FlxSpriteGroup
   {
     // TODO: Add a setting to disable note splashes.
     // if (Settings.noSplash) return;
+    if (!noteStyle.isNoteSplashEnabled()) return;
 
     var splash:NoteSplash = this.constructNoteSplash();
 
@@ -502,6 +494,7 @@ class Strumline extends FlxSpriteGroup
   {
     // TODO: Add a setting to disable note splashes.
     // if (Settings.noSplash) return;
+    if (!noteStyle.isHoldNoteCoverEnabled()) return;
 
     var cover:NoteHoldCover = this.constructNoteHoldCover();
 
@@ -659,7 +652,7 @@ class Strumline extends FlxSpriteGroup
     {
       // The note sprite pool is full and all note splashes are active.
       // We have to create a new note.
-      result = new NoteSprite();
+      result = new NoteSprite(noteStyle);
       this.notes.add(result);
     }
 
@@ -685,7 +678,7 @@ class Strumline extends FlxSpriteGroup
     {
       // The note sprite pool is full and all note splashes are active.
       // We have to create a new note.
-      result = new SustainTrail(0, 100, Paths.image("NOTE_hold_assets"));
+      result = new SustainTrail(0, 100, noteStyle.getHoldNoteAssetPath(), noteStyle);
       this.holdNotes.add(result);
     }
 
diff --git a/source/funkin/play/notes/StrumlineNote.hx b/source/funkin/play/notes/StrumlineNote.hx
index 6361f607e..40d893255 100644
--- a/source/funkin/play/notes/StrumlineNote.hx
+++ b/source/funkin/play/notes/StrumlineNote.hx
@@ -1,5 +1,6 @@
 package funkin.play.notes;
 
+import funkin.play.notes.notestyle.NoteStyle;
 import flixel.graphics.frames.FlxAtlasFrames;
 import flixel.FlxSprite;
 import funkin.play.notes.NoteSprite;
@@ -17,24 +18,13 @@ class StrumlineNote extends FlxSprite
 
   static final CONFIRM_HOLD_TIME:Float = 0.1;
 
-  public function updatePosition(parentNote:NoteSprite)
-  {
-    this.x = parentNote.x;
-    this.x += parentNote.width / 2;
-    this.x -= this.width / 2;
-
-    this.y = parentNote.y;
-    this.y += parentNote.height / 2;
-  }
-
   function set_direction(value:NoteDirection):NoteDirection
   {
     this.direction = value;
-    setup();
     return this.direction;
   }
 
-  public function new(isPlayer:Bool, direction:NoteDirection)
+  public function new(noteStyle:NoteStyle, isPlayer:Bool, direction:NoteDirection)
   {
     super(0, 0);
 
@@ -42,6 +32,8 @@ class StrumlineNote extends FlxSprite
 
     this.direction = direction;
 
+    setup(noteStyle);
+
     this.animation.callback = onAnimationFrame;
     this.animation.finishCallback = onAnimationFinished;
 
@@ -81,39 +73,15 @@ class StrumlineNote extends FlxSprite
     }
   }
 
-  function setup():Void
+  function setup(noteStyle:NoteStyle):Void
   {
-    this.frames = Paths.getSparrowAtlas('noteStrumline');
+    noteStyle.applyStrumlineFrames(this);
+    noteStyle.applyStrumlineAnimations(this, this.direction);
 
-    switch (this.direction)
-    {
-      case NoteDirection.LEFT:
-        this.animation.addByPrefix('static', 'staticLeft0', 24, false, false, false);
-        this.animation.addByPrefix('press', 'pressLeft0', 24, false, false, false);
-        this.animation.addByPrefix('confirm', 'confirmLeft0', 24, false, false, false);
-        this.animation.addByPrefix('confirm-hold', 'confirmHoldLeft0', 24, true, false, false);
-
-      case NoteDirection.DOWN:
-        this.animation.addByPrefix('static', 'staticDown0', 24, false, false, false);
-        this.animation.addByPrefix('press', 'pressDown0', 24, false, false, false);
-        this.animation.addByPrefix('confirm', 'confirmDown0', 24, false, false, false);
-        this.animation.addByPrefix('confirm-hold', 'confirmHoldDown0', 24, true, false, false);
-
-      case NoteDirection.UP:
-        this.animation.addByPrefix('static', 'staticUp0', 24, false, false, false);
-        this.animation.addByPrefix('press', 'pressUp0', 24, false, false, false);
-        this.animation.addByPrefix('confirm', 'confirmUp0', 24, false, false, false);
-        this.animation.addByPrefix('confirm-hold', 'confirmHoldUp0', 24, true, false, false);
-
-      case NoteDirection.RIGHT:
-        this.animation.addByPrefix('static', 'staticRight0', 24, false, false, false);
-        this.animation.addByPrefix('press', 'pressRight0', 24, false, false, false);
-        this.animation.addByPrefix('confirm', 'confirmRight0', 24, false, false, false);
-        this.animation.addByPrefix('confirm-hold', 'confirmHoldRight0', 24, true, false, false);
-    }
-
-    this.setGraphicSize(Std.int(Strumline.STRUMLINE_SIZE * 1.55));
+    this.setGraphicSize(Std.int(Strumline.STRUMLINE_SIZE * noteStyle.getStrumlineScale()));
     this.updateHitbox();
+    noteStyle.applyStrumlineOffsets(this);
+
     this.playStatic();
   }
 
diff --git a/source/funkin/play/notes/SustainTrail.hx b/source/funkin/play/notes/SustainTrail.hx
index addc312f4..d9f1aab6e 100644
--- a/source/funkin/play/notes/SustainTrail.hx
+++ b/source/funkin/play/notes/SustainTrail.hx
@@ -1,5 +1,6 @@
 package funkin.play.notes;
 
+import funkin.play.notes.notestyle.NoteStyle;
 import funkin.play.notes.NoteDirection;
 import funkin.play.song.SongData.SongNoteData;
 import flixel.util.FlxDirectionFlags;
@@ -79,25 +80,28 @@ class SustainTrail extends FlxSprite
    */
   public var bottomClip:Float = 0.9;
 
+  public var isPixel:Bool;
+
   /**
    * Normally you would take strumTime:Float, noteData:Int, sustainLength:Float, parentNote:Note (?)
    * @param NoteData
    * @param SustainLength Length in milliseconds.
    * @param fileName
    */
-  public function new(noteDirection:NoteDirection, sustainLength:Float, fileName:String)
+  public function new(noteDirection:NoteDirection, sustainLength:Float, fileName:String, noteStyle:NoteStyle)
   {
     super(0, 0, fileName);
 
     antialiasing = true;
 
-    // TODO: Why does this reference pixel stuff?
-    if (fileName == "arrowEnds")
+    this.isPixel = noteStyle.isHoldNotePixel();
+    if (isPixel)
     {
       endOffset = bottomClip = 1;
       antialiasing = false;
-      zoom = 6;
     }
+    zoom *= noteStyle.fetchHoldNoteScale();
+
     // BASIC SETUP
     this.sustainLength = sustainLength;
     this.fullSustainLength = sustainLength;
diff --git a/source/funkin/ui/ColorsMenu.hx b/source/funkin/ui/ColorsMenu.hx
index dfa0cf067..6a844eef3 100644
--- a/source/funkin/ui/ColorsMenu.hx
+++ b/source/funkin/ui/ColorsMenu.hx
@@ -1,5 +1,6 @@
 package funkin.ui;
 
+import funkin.data.notestyle.NoteStyleRegistry;
 import flixel.addons.effects.chainable.FlxEffectSprite;
 import flixel.addons.effects.chainable.FlxOutlineEffect;
 import flixel.group.FlxGroup.FlxTypedGroup;
@@ -22,7 +23,7 @@ class ColorsMenu extends Page
 
     for (i in 0...4)
     {
-      var note:NoteSprite = new NoteSprite(0, i);
+      var note:NoteSprite = new NoteSprite(NoteStyleRegistry.instance.fetchDefault(), 0, i);
 
       note.x = (100 * i) + i;
       note.screenCenter(Y);
diff --git a/source/funkin/ui/debug/charting/ChartEditorState.hx b/source/funkin/ui/debug/charting/ChartEditorState.hx
index aad43f93f..ecb9db5b6 100644
--- a/source/funkin/ui/debug/charting/ChartEditorState.hx
+++ b/source/funkin/ui/debug/charting/ChartEditorState.hx
@@ -1,5 +1,6 @@
 package funkin.ui.debug.charting;
 
+import funkin.data.notestyle.NoteStyleRegistry;
 import funkin.ui.debug.charting.ChartEditorCommand;
 import flixel.input.keyboard.FlxKey;
 import funkin.input.TurboKeyHandler;
@@ -2804,7 +2805,7 @@ class ChartEditorState extends HaxeUIState
       // Character preview.
 
       // NoteScriptEvent takes a sprite, ehe. Need to rework that.
-      var tempNote:NoteSprite = new NoteSprite();
+      var tempNote:NoteSprite = new NoteSprite(NoteStyleRegistry.instance.fetchDefault());
       tempNote.noteData = noteData;
       tempNote.scrollFactor.set(0, 0);
       var event:NoteScriptEvent = new NoteScriptEvent(ScriptEvent.NOTE_HIT, tempNote, 1, true);

From 8fe837d76d469b4ab31af60cb7891be34391abab Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Fri, 14 Jul 2023 19:51:45 -0400
Subject: [PATCH 26/30] Attempt to fix github actions

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

diff --git a/.github/workflows/build-shit.yml b/.github/workflows/build-shit.yml
index 77b8e9895..6d818f6d1 100644
--- a/.github/workflows/build-shit.yml
+++ b/.github/workflows/build-shit.yml
@@ -49,7 +49,7 @@ jobs:
         # TODO: Remove the step that builds Lime later.
         # Powershell method
         run: |
-          $LIME_PATH = &"haxelib libpath lime"
+          $LIME_PATH = haxelib libpath lime
           echo "Moving to $LIME_PATH"
           cd $LIME_PATH
           git submodule sync --recursive

From f675a5c578005815982d745585abc922da37c9ae Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Fri, 14 Jul 2023 22:25:51 -0400
Subject: [PATCH 27/30] Fix actions more

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

diff --git a/.github/workflows/build-shit.yml b/.github/workflows/build-shit.yml
index 6d818f6d1..11cd28343 100644
--- a/.github/workflows/build-shit.yml
+++ b/.github/workflows/build-shit.yml
@@ -55,7 +55,7 @@ jobs:
           git submodule sync --recursive
           git submodule update --recursive
           git status
-          lime rebuild windows --clean
+          haxelib run lime rebuild windows --clean
       - name: Build game
         run: |
           haxelib run lime build windows -debug

From 5a70a50a56cd580d121cb58b0b613d437e60e0a6 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Fri, 14 Jul 2023 22:53:11 -0400
Subject: [PATCH 28/30] Remember to build Lime on HTML5 too.

---
 .github/workflows/build-shit.yml | 13 ++++++++++++-
 1 file changed, 12 insertions(+), 1 deletion(-)

diff --git a/.github/workflows/build-shit.yml b/.github/workflows/build-shit.yml
index 11cd28343..c18db7093 100644
--- a/.github/workflows/build-shit.yml
+++ b/.github/workflows/build-shit.yml
@@ -25,7 +25,18 @@ jobs:
     steps:
       - uses: actions/checkout@v3
       - uses: ./.github/actions/setup-haxeshit
-      - name: Build game?
+      - 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: |
           sudo apt-get install -y libx11-dev libxinerama-dev libxrandr-dev libgl1-mesa-dev libgl-dev libxi-dev libxext-dev libasound2-dev
           haxelib run lime build html5 -debug --times

From ddcb0474a3ef49b2f58ea2fb7607297e8f64b9c8 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Fri, 14 Jul 2023 23:07:17 -0400
Subject: [PATCH 29/30] Convert command to Bash

---
 .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 c18db7093..07de07075 100644
--- a/.github/workflows/build-shit.yml
+++ b/.github/workflows/build-shit.yml
@@ -27,9 +27,9 @@ jobs:
       - uses: ./.github/actions/setup-haxeshit
       - name: Build Lime
         # TODO: Remove the step that builds Lime later.
-        # Powershell method
+        # Bash method
         run: |
-          $LIME_PATH = haxelib libpath lime
+          LIME_PATH=`haxelib libpath lime`
           echo "Moving to $LIME_PATH"
           cd $LIME_PATH
           git submodule sync --recursive

From 3d8459fe6f56aa9918f24331d7024c2dbb6b165e Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Sat, 15 Jul 2023 04:22:02 -0400
Subject: [PATCH 30/30] Added dependency to HTML5 lime build

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

diff --git a/.github/workflows/build-shit.yml b/.github/workflows/build-shit.yml
index 07de07075..32c2a0ede 100644
--- a/.github/workflows/build-shit.yml
+++ b/.github/workflows/build-shit.yml
@@ -35,7 +35,8 @@ jobs:
           git submodule sync --recursive
           git submodule update --recursive
           git status
-          haxelib run lime rebuild windows --clean
+          sudo apt-get install -y libxinerama-dev
+          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